From 26104ae81ecb7576fcdbce4caa65e8cceef3bb9a Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Thu, 22 Oct 2026 10:32:38 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=201=20=E9=98=B6=E6=AE=B5=EF=BC=9A?= =?UTF-8?q?=E8=99=9A=E6=8B=9F=E5=AE=9E=E8=AE=AD=E5=8F=B0=EF=BC=8C=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=202=E3=80=813=20=E7=9A=84=E7=A4=BA=E4=BE=8B=E8=B7=91?= =?UTF-8?q?=E9=80=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实训台状态由服务器统一保存:滑块和开关设定传感器读数,推给学生程序; 学生程序控制的灯、继电器、LED 屏回写到实训台 - 网页按教材实训台布局图绘制实训台,传感器卡片上直接调节环境 - 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),Lamp、Alarm - 设备类库:串口未打开时控制无效,与实物一致 - 修正:列表属性序列化时数字被转成字符串 - 示例:项目 2 任务 2–3、项目 3 任务 1–2;网址参数 ?sample= 打开指定示例 - 测试:实训台单元测试,项目 2、3 的端到端测试 Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- .../DeviceCtrlLibrary/DeviceCtrlHelper.cs | 16 +- .../DeviceCtrlLibrary/TrainingBoard.cs | 55 +- .../WinFormsDeviceControlLibrary/Lamp.cs | 53 ++ runtime/Sim.Runner/StudentProgramHost.cs | 3 + runtime/Sim.WinForms/Forms/CheckBox.cs | 130 +++++ runtime/Sim.WinForms/Host/PropValue.cs | 2 +- samples/p2-t2-模拟量采集/Program.cs | 22 + .../p2-t2-模拟量采集/frm_analog.Designer.cs | 193 +++++++ samples/p2-t2-模拟量采集/frm_analog.cs | 64 ++ samples/p2-t2-模拟量采集/meta.json | 4 + samples/p2-t3-LED推送模拟量/Program.cs | 22 + .../p2-t3-LED推送模拟量/frm_LED.Designer.cs | 227 ++++++++ samples/p2-t3-LED推送模拟量/frm_LED.cs | 78 +++ samples/p2-t3-LED推送模拟量/meta.json | 4 + samples/p3-t1-ZigBee路灯/Form1.Designer.cs | 140 +++++ samples/p3-t1-ZigBee路灯/Form1.cs | 68 +++ samples/p3-t1-ZigBee路灯/Program.cs | 22 + samples/p3-t1-ZigBee路灯/meta.json | 4 + .../Form1.Designer.cs | 182 ++++++ samples/p3-t2-人体红外ZigBee联动/Form1.cs | 96 +++ samples/p3-t2-人体红外ZigBee联动/Program.cs | 22 + samples/p3-t2-人体红外ZigBee联动/meta.json | 4 + server/src/board.ts | 80 +++ server/src/index.ts | 4 + server/src/run-session.ts | 42 +- server/test/board.test.ts | 33 ++ server/test/runner.test.ts | 102 +++- shared/protocol.ts | 59 +- web/public/devices/AlarmOff.jpg | Bin 0 -> 22929 bytes web/public/devices/AlarmOn.jpg | Bin 0 -> 23219 bytes web/public/devices/LampOff.png | Bin 0 -> 24893 bytes web/public/devices/LampOn.png | Bin 0 -> 26505 bytes web/src/App.vue | 18 +- web/src/run/session.ts | 50 +- web/src/scene/TrainingBoard.vue | 545 +++++++++++++++++- web/src/winforms/ControlView.vue | 20 + web/src/winforms/winforms.css | 49 ++ 38 files changed, 2346 insertions(+), 77 deletions(-) create mode 100644 runtime/Sim.Devices/WinFormsDeviceControlLibrary/Lamp.cs create mode 100644 runtime/Sim.WinForms/Forms/CheckBox.cs create mode 100644 samples/p2-t2-模拟量采集/Program.cs create mode 100644 samples/p2-t2-模拟量采集/frm_analog.Designer.cs create mode 100644 samples/p2-t2-模拟量采集/frm_analog.cs create mode 100644 samples/p2-t2-模拟量采集/meta.json create mode 100644 samples/p2-t3-LED推送模拟量/Program.cs create mode 100644 samples/p2-t3-LED推送模拟量/frm_LED.Designer.cs create mode 100644 samples/p2-t3-LED推送模拟量/frm_LED.cs create mode 100644 samples/p2-t3-LED推送模拟量/meta.json create mode 100644 samples/p3-t1-ZigBee路灯/Form1.Designer.cs create mode 100644 samples/p3-t1-ZigBee路灯/Form1.cs create mode 100644 samples/p3-t1-ZigBee路灯/Program.cs create mode 100644 samples/p3-t1-ZigBee路灯/meta.json create mode 100644 samples/p3-t2-人体红外ZigBee联动/Form1.Designer.cs create mode 100644 samples/p3-t2-人体红外ZigBee联动/Form1.cs create mode 100644 samples/p3-t2-人体红外ZigBee联动/Program.cs create mode 100644 samples/p3-t2-人体红外ZigBee联动/meta.json create mode 100644 server/src/board.ts create mode 100644 server/test/board.test.ts create mode 100644 web/public/devices/AlarmOff.jpg create mode 100644 web/public/devices/AlarmOn.jpg create mode 100644 web/public/devices/LampOff.png create mode 100644 web/public/devices/LampOn.png diff --git a/README.md b/README.md index 2073756..2ad1fc9 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,15 @@ bun test/load.ts 100 # 压力测试:同时运行 100 个学生程序 | 100 个程序同时运行:内存 | 合计约 1.1 GB(PSS,平均每个 11 MB);另加编译服务约 150 MB | | 100 个程序同时点击:往返 | 中位数约 60 ms,P95 约 75 ms | +## 第 1 阶段进行中 + +已完成: + +- **虚拟实训台**:按教材实训台布局图排列(红外对射、警示灯、LED 屏、照明灯、人体红外、温湿度、光照、烟雾、火焰、ADAM 模块)。每个浏览器会话一台,状态由服务器统一保存:传感器读数由网页上的滑块和开关设定,经服务器推给学生程序;灯、继电器、LED 屏由学生程序控制。 +- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),教材控件库的 Lamp、Alarm。 +- 示例工程(按书上代码整理):项目 2 任务 1–3、项目 3 任务 1–2。网址带 `?sample=示例编号` 可直接打开指定示例。 + 尚未完成: - 学生代码目前直接在本机进程里运行,**还没有沙箱**,只能在本机开发时使用;部署前要改为在 Docker 容器中运行。 -- 设备状态暂时放在运行器进程里,用的是固定默认值;第 1 阶段改为由 Bun 服务器上的虚拟实训台统一管理。 +- 浏览器内的窗体设计器、代码编辑器(Monaco)、账号和班级、自动评测。 diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs index 3cbb999..9364640 100644 --- a/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/DeviceCtrlHelper.cs @@ -91,8 +91,7 @@ public class ADAM4017Helper return model; } - private string Read(int quantity) => - TrainingBoard.AnalogInputs.TryGetValue(_channels[quantity], out var value) ? value.ToString() : "0"; + private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString(); public void Close() => _portName = null; } @@ -120,12 +119,15 @@ public class ADAM4150Helper return model; } - private bool Read(int sensor) => TrainingBoard.DigitalInputs.TryGetValue(_channels[sensor], out var value) && value; + private bool Read(int sensor) => TrainingBoard.ReadDigital(_channels[sensor]); - /// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯),通道号有效时返回 true + /// + /// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。 + /// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。 + /// public bool OnOff(int DO, bool IsOpen) { - if (DO < 0 || DO > 4) return false; + if (DO < 0 || DO > 4 || string.IsNullOrWhiteSpace(_portName)) return false; SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 }); return true; } @@ -139,10 +141,10 @@ public class ZigBeeHelper public void Open(string PortName) => _portName = PortName; - /// 控制 ZigBee 继电器节点 0、1,通道号有效时返回 true + /// 控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false public bool OnOff(int RelayNum, bool IsOpen) { - if (RelayNum < 0 || RelayNum > 1) return false; + if (RelayNum < 0 || RelayNum > 1 || string.IsNullOrWhiteSpace(_portName)) return false; SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 }); return true; } diff --git a/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs b/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs index bae64cd..66acef8 100644 --- a/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs +++ b/runtime/Sim.Devices/DeviceCtrlLibrary/TrainingBoard.cs @@ -1,25 +1,46 @@ -using System.Collections.Concurrent; +using System; +using System.Text.Json.Nodes; namespace DeviceCtrlLibrary; /// -/// 虚拟实训台的设备状态。第 0 阶段先放在运行器进程里、用固定的默认值; -/// 第 1 阶段改为向 Bun 服务器上的实训台查询,数值由网页上的滑块和开关控制。 +/// 学生程序进程里的实训台传感器读数副本。权威状态在 Bun 服务器上(网页滑块和开关控制): +/// 启动时从环境变量 SIM_BOARD 读入,之后服务器每次变化都通过标准输入推送过来。 +/// SetData() 直接读这里,不需要等待网络往返。 /// -internal static class TrainingBoard +public static class TrainingBoard { - /// ADAM-4017 各通道的读数(Vin0 温度、Vin1 湿度、Vin2 光照……),按教材格式保存为整数 - public static readonly ConcurrentDictionary AnalogInputs = new() - { - [0] = 25, // 温度 ℃ - [1] = 60, // 湿度 % - [2] = 500, // 光照 lx - [3] = 101, // 气压 kPa - [4] = 60, // 空气质量 - [5] = 600, // CO2 ppm - [6] = 20, // 风速 - }; + private static readonly object Lock = new(); + // ADAM-4017 Vin0–Vin7(温度、湿度、光照、气压、空气质量、CO2、风速、预留) + private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 }; + // ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留) + private static bool[] _digital = new bool[7]; - /// ADAM-4150 数字量输入(DI0 人体红外、DI1 火焰、DI2 烟雾、DI3 红外对射) - public static readonly ConcurrentDictionary DigitalInputs = new(); + static TrainingBoard() + { + var initial = Environment.GetEnvironmentVariable("SIM_BOARD"); + if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json); + } + + /// 平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…]} + public static void Update(JsonObject json) + { + lock (Lock) + { + if (json["analog"] is JsonArray analog) + for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0; + if (json["digital"] is JsonArray digital) + for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false; + } + } + + internal static int ReadAnalog(int channel) + { + lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0; + } + + internal static bool ReadDigital(int channel) + { + lock (Lock) return channel >= 0 && channel < _digital.Length && _digital[channel]; + } } diff --git a/runtime/Sim.Devices/WinFormsDeviceControlLibrary/Lamp.cs b/runtime/Sim.Devices/WinFormsDeviceControlLibrary/Lamp.cs new file mode 100644 index 0000000..0778896 --- /dev/null +++ b/runtime/Sim.Devices/WinFormsDeviceControlLibrary/Lamp.cs @@ -0,0 +1,53 @@ +using System.Drawing; +using System.Text.Json.Nodes; +using System.Windows.Forms; + +namespace WinFormsDeviceControlLibrary; + +/// 教材控件库的灯泡:OnOff(true) 显示亮灯图片,OnOff(false) 显示灭灯图片。只改变界面,不控制实训台 +[ClientType("WinFormsDeviceControlLibrary.Lamp")] +public class Lamp : UserControl +{ + private bool _isOn; + + public Lamp() => BackColor = Color.White; + + protected override Size DefaultSize => new(104, 185); + + public void OnOff(bool IsOpen) + { + EnsureThreadAccess(); + _isOn = IsOpen; + NotifyChanged("IsOn", _isOn); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["IsOn"] = _isOn; + } +} + +/// 教材控件库的报警灯:OnOff(true) 显示报警图片。只改变界面,不控制实训台 +[ClientType("WinFormsDeviceControlLibrary.Alarm")] +public class Alarm : UserControl +{ + private bool _isOn; + + public Alarm() => BackColor = Color.White; + + protected override Size DefaultSize => new(150, 150); + + public void OnOff(bool IsOpen) + { + EnsureThreadAccess(); + _isOn = IsOpen; + NotifyChanged("IsOn", _isOn); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["IsOn"] = _isOn; + } +} diff --git a/runtime/Sim.Runner/StudentProgramHost.cs b/runtime/Sim.Runner/StudentProgramHost.cs index fb2a6cc..708a969 100644 --- a/runtime/Sim.Runner/StudentProgramHost.cs +++ b/runtime/Sim.Runner/StudentProgramHost.cs @@ -105,6 +105,9 @@ internal static class StudentProgramHost SimHost.Ui.Post(() => control.PerformUserAction(action)); } break; + case "board": + DeviceCtrlLibrary.TrainingBoard.Update(message!); + break; case "reply": SimHost.CompleteRequest((int?)message!["requestId"] ?? 0, message["result"]?.DeepClone()); break; diff --git a/runtime/Sim.WinForms/Forms/CheckBox.cs b/runtime/Sim.WinForms/Forms/CheckBox.cs new file mode 100644 index 0000000..289a1b2 --- /dev/null +++ b/runtime/Sim.WinForms/Forms/CheckBox.cs @@ -0,0 +1,130 @@ +using System.Drawing; +using System.Text.Json.Nodes; +using Sim.Host; + +namespace System.Windows.Forms; + +public enum CheckState { Unchecked, Checked, Indeterminate } + +public enum Appearance { Normal, Button } + +/// +/// 复选框。用户点击时先切换 Checked(触发 CheckedChanged),再触发 Click—— +/// 教材项目 3 在 Click 里读取 Checked,依赖这个顺序。 +/// +public class CheckBox : ButtonBase +{ + private CheckState _checkState; + + protected override Size DefaultSize => new(104, 24); + + public bool AutoCheck { get; set; } = true; + public bool ThreeState { get; set; } + public Appearance Appearance { get; set; } + public ContentAlignment CheckAlign { get; set; } = ContentAlignment.MiddleLeft; + + public bool Checked + { + get => _checkState != CheckState.Unchecked; + set => CheckState = value ? CheckState.Checked : CheckState.Unchecked; + } + + public CheckState CheckState + { + get => _checkState; + set + { + if (_checkState == value) return; + var wasChecked = Checked; + SetProp(ref _checkState, value, nameof(CheckState)); + NotifyChanged(nameof(Checked), Checked); + if (wasChecked != Checked) OnCheckedChanged(EventArgs.Empty); + OnCheckStateChanged(EventArgs.Empty); + } + } + + public event EventHandler? CheckedChanged; + public event EventHandler? CheckStateChanged; + + protected virtual void OnCheckedChanged(EventArgs e) => CheckedChanged?.Invoke(this, e); + protected virtual void OnCheckStateChanged(EventArgs e) => CheckStateChanged?.Invoke(this, e); + + protected override void OnClick(EventArgs e) + { + if (AutoCheck) + { + CheckState = _checkState switch + { + CheckState.Unchecked => CheckState.Checked, + CheckState.Checked when ThreeState => CheckState.Indeterminate, + _ => CheckState.Unchecked, + }; + } + base.OnClick(e); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Checked"] = Checked; + props["CheckState"] = _checkState.ToString(); + } +} + +/// +/// 单选按钮。同一个容器里的单选按钮互斥。选中一个时,事件顺序与 WinForms 一致: +/// 先是原来选中的那个触发 CheckedChanged(变为未选中),再是新选中的触发 CheckedChanged,最后触发 Click。 +/// 教材项目 3 任务 1 在两个按钮的 CheckedChanged 里分别开灯、关灯,最终状态依赖这个顺序。 +/// +public class RadioButton : ButtonBase +{ + private bool _checked; + + protected override Size DefaultSize => new(104, 24); + + public bool AutoCheck { get; set; } = true; + public Appearance Appearance { get; set; } + public ContentAlignment CheckAlign { get; set; } = ContentAlignment.MiddleLeft; + + public bool Checked + { + get => _checked; + set + { + if (_checked == value) return; + SetProp(ref _checked, value, nameof(Checked)); + if (value && AutoCheck) UncheckSiblings(); + OnCheckedChanged(EventArgs.Empty); + } + } + + private void UncheckSiblings() + { + if (Parent == null) return; + foreach (var sibling in Parent.Controls) + { + if (sibling != this && sibling is RadioButton { AutoCheck: true, Checked: true } radio) radio.Checked = false; + } + } + + public event EventHandler? CheckedChanged; + + protected virtual void OnCheckedChanged(EventArgs e) => CheckedChanged?.Invoke(this, e); + + public void PerformClick() + { + if (Enabled && Visible) OnClick(EventArgs.Empty); + } + + protected override void OnClick(EventArgs e) + { + if (AutoCheck) Checked = true; + base.OnClick(e); + } + + protected override void WriteProps(JsonObject props) + { + base.WriteProps(props); + props["Checked"] = _checked; + } +} diff --git a/runtime/Sim.WinForms/Host/PropValue.cs b/runtime/Sim.WinForms/Host/PropValue.cs index f8dd9a5..517cc0c 100644 --- a/runtime/Sim.WinForms/Host/PropValue.cs +++ b/runtime/Sim.WinForms/Host/PropValue.cs @@ -49,7 +49,7 @@ public static class PropValue private static JsonArray ListOf(IEnumerable items) { var array = new JsonArray(); - foreach (var item in items) array.Add(From(item is string or null ? item : item.ToString())); + foreach (var item in items) array.Add(From(item)); return array; } diff --git a/samples/p2-t2-模拟量采集/Program.cs b/samples/p2-t2-模拟量采集/Program.cs new file mode 100644 index 0000000..75e1c7c --- /dev/null +++ b/samples/p2-t2-模拟量采集/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 模拟量采集 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new frm_analog()); + } + } +} diff --git a/samples/p2-t2-模拟量采集/frm_analog.Designer.cs b/samples/p2-t2-模拟量采集/frm_analog.Designer.cs new file mode 100644 index 0000000..c596e56 --- /dev/null +++ b/samples/p2-t2-模拟量采集/frm_analog.Designer.cs @@ -0,0 +1,193 @@ +namespace 模拟量采集 +{ + partial class frm_analog + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.grp_show = new System.Windows.Forms.GroupBox(); + this.btn_get = new System.Windows.Forms.Button(); + this.txt_humidity = new System.Windows.Forms.TextBox(); + this.txt_temperature = new System.Windows.Forms.TextBox(); + this.txt_light = new System.Windows.Forms.TextBox(); + this.lbl_humidity = new System.Windows.Forms.Label(); + this.lbl_temperature = new System.Windows.Forms.Label(); + this.lbl_light = new System.Windows.Forms.Label(); + this.grp_config = new System.Windows.Forms.GroupBox(); + this.btn_Open = new System.Windows.Forms.Button(); + this.com_port = new System.Windows.Forms.ComboBox(); + this.lbl_port = new System.Windows.Forms.Label(); + this.grp_show.SuspendLayout(); + this.grp_config.SuspendLayout(); + this.SuspendLayout(); + // + // grp_show + // + this.grp_show.Controls.Add(this.btn_get); + this.grp_show.Controls.Add(this.txt_humidity); + this.grp_show.Controls.Add(this.txt_temperature); + this.grp_show.Controls.Add(this.txt_light); + this.grp_show.Controls.Add(this.lbl_humidity); + this.grp_show.Controls.Add(this.lbl_temperature); + this.grp_show.Controls.Add(this.lbl_light); + this.grp_show.Location = new System.Drawing.Point(12, 12); + this.grp_show.Name = "grp_show"; + this.grp_show.Size = new System.Drawing.Size(260, 126); + this.grp_show.TabIndex = 0; + this.grp_show.TabStop = false; + this.grp_show.Text = "数据显示"; + // + // btn_get + // + this.btn_get.Location = new System.Drawing.Point(209, 24); + this.btn_get.Name = "btn_get"; + this.btn_get.Size = new System.Drawing.Size(39, 83); + this.btn_get.TabIndex = 6; + this.btn_get.Text = "获取"; + this.btn_get.UseVisualStyleBackColor = true; + this.btn_get.Click += new System.EventHandler(this.btn_get_Click); + // + // txt_humidity + // + this.txt_humidity.Location = new System.Drawing.Point(83, 86); + this.txt_humidity.Name = "txt_humidity"; + this.txt_humidity.Size = new System.Drawing.Size(100, 21); + this.txt_humidity.TabIndex = 5; + // + // txt_temperature + // + this.txt_temperature.Location = new System.Drawing.Point(83, 54); + this.txt_temperature.Name = "txt_temperature"; + this.txt_temperature.Size = new System.Drawing.Size(100, 21); + this.txt_temperature.TabIndex = 4; + // + // txt_light + // + this.txt_light.Location = new System.Drawing.Point(83, 24); + this.txt_light.Name = "txt_light"; + this.txt_light.Size = new System.Drawing.Size(100, 21); + this.txt_light.TabIndex = 3; + // + // lbl_humidity + // + this.lbl_humidity.AutoSize = true; + this.lbl_humidity.Location = new System.Drawing.Point(20, 89); + this.lbl_humidity.Name = "lbl_humidity"; + this.lbl_humidity.Size = new System.Drawing.Size(29, 12); + this.lbl_humidity.TabIndex = 2; + this.lbl_humidity.Text = "湿度"; + // + // lbl_temperature + // + this.lbl_temperature.AutoSize = true; + this.lbl_temperature.Location = new System.Drawing.Point(20, 57); + this.lbl_temperature.Name = "lbl_temperature"; + this.lbl_temperature.Size = new System.Drawing.Size(29, 12); + this.lbl_temperature.TabIndex = 1; + this.lbl_temperature.Text = "温度"; + // + // lbl_light + // + this.lbl_light.AutoSize = true; + this.lbl_light.Location = new System.Drawing.Point(20, 27); + this.lbl_light.Name = "lbl_light"; + this.lbl_light.Size = new System.Drawing.Size(41, 12); + this.lbl_light.TabIndex = 0; + this.lbl_light.Text = "光照度"; + // + // grp_config + // + this.grp_config.Controls.Add(this.btn_Open); + this.grp_config.Controls.Add(this.com_port); + this.grp_config.Controls.Add(this.lbl_port); + this.grp_config.Location = new System.Drawing.Point(12, 144); + this.grp_config.Name = "grp_config"; + this.grp_config.Size = new System.Drawing.Size(260, 100); + this.grp_config.TabIndex = 1; + this.grp_config.TabStop = false; + this.grp_config.Text = "配置"; + // + // btn_Open + // + this.btn_Open.Location = new System.Drawing.Point(149, 59); + this.btn_Open.Name = "btn_Open"; + this.btn_Open.Size = new System.Drawing.Size(75, 23); + this.btn_Open.TabIndex = 2; + this.btn_Open.Text = "开启"; + this.btn_Open.UseVisualStyleBackColor = true; + this.btn_Open.Click += new System.EventHandler(this.btn_Open_Click); + // + // com_port + // + this.com_port.FormattingEnabled = true; + this.com_port.Location = new System.Drawing.Point(105, 33); + this.com_port.Name = "com_port"; + this.com_port.Size = new System.Drawing.Size(119, 20); + this.com_port.TabIndex = 1; + // + // lbl_port + // + this.lbl_port.AutoSize = true; + this.lbl_port.Location = new System.Drawing.Point(44, 36); + this.lbl_port.Name = "lbl_port"; + this.lbl_port.Size = new System.Drawing.Size(29, 12); + this.lbl_port.TabIndex = 0; + this.lbl_port.Text = "串口"; + // + // frm_analog + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(284, 262); + this.Controls.Add(this.grp_config); + this.Controls.Add(this.grp_show); + this.Name = "frm_analog"; + this.Text = "模拟量采集"; + this.Load += new System.EventHandler(this.frm_analog_Load); + this.grp_show.ResumeLayout(false); + this.grp_show.PerformLayout(); + this.grp_config.ResumeLayout(false); + this.grp_config.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private System.Windows.Forms.GroupBox grp_show; + private System.Windows.Forms.Button btn_get; + private System.Windows.Forms.TextBox txt_humidity; + private System.Windows.Forms.TextBox txt_temperature; + private System.Windows.Forms.TextBox txt_light; + private System.Windows.Forms.Label lbl_humidity; + private System.Windows.Forms.Label lbl_temperature; + private System.Windows.Forms.Label lbl_light; + private System.Windows.Forms.GroupBox grp_config; + private System.Windows.Forms.Button btn_Open; + private System.Windows.Forms.ComboBox com_port; + private System.Windows.Forms.Label lbl_port; + } +} diff --git a/samples/p2-t2-模拟量采集/frm_analog.cs b/samples/p2-t2-模拟量采集/frm_analog.cs new file mode 100644 index 0000000..c4154c7 --- /dev/null +++ b/samples/p2-t2-模拟量采集/frm_analog.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO.Ports; +using DeviceCtrlLibrary; +using DeviceCtrlLibrary.Model; + +namespace 模拟量采集 +{ + public partial class frm_analog : Form + { + public frm_analog() + { + InitializeComponent(); + } + + bool IsOpen = false; + ADAM4017ResModel ResModel; + + private void frm_analog_Load(object sender, EventArgs e) + { + //清空列表 + com_port.Items.Clear(); + //加载所有可用的串口号到列表中 + foreach (string item in SerialPort.GetPortNames()) + { + com_port.Items.Add(item); + } + } + + private void btn_Open_Click(object sender, EventArgs e) + { + if (!IsOpen) + { + //打开模拟量采集器所在的串口 + DeviceCtrlHelper.adam4017Helper.Open(com_port.Text); + btn_Open.Text = "关闭"; + IsOpen = true; + } + else + { + //关闭串口 + DeviceCtrlHelper.adam4017Helper.Close(); + btn_Open.Text = "开启"; + IsOpen = false; + } + } + + private void btn_get_Click(object sender, EventArgs e) + { + //获取传感器数值 + ResModel = DeviceCtrlHelper.adam4017Helper.SetData(); + txt_light.Text = ResModel.lightValue; + txt_humidity.Text = ResModel.humidityValue; + txt_temperature.Text = ResModel.temperatureValue; + } + } +} diff --git a/samples/p2-t2-模拟量采集/meta.json b/samples/p2-t2-模拟量采集/meta.json new file mode 100644 index 0000000..c3589db --- /dev/null +++ b/samples/p2-t2-模拟量采集/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目2 任务2 实时采集温度、湿度和光照度数据", + "source": "教材书 p36–39,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)" +} diff --git a/samples/p2-t3-LED推送模拟量/Program.cs b/samples/p2-t3-LED推送模拟量/Program.cs new file mode 100644 index 0000000..08a636a --- /dev/null +++ b/samples/p2-t3-LED推送模拟量/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace LED推送模拟量 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new frm_LED()); + } + } +} diff --git a/samples/p2-t3-LED推送模拟量/frm_LED.Designer.cs b/samples/p2-t3-LED推送模拟量/frm_LED.Designer.cs new file mode 100644 index 0000000..3d69506 --- /dev/null +++ b/samples/p2-t3-LED推送模拟量/frm_LED.Designer.cs @@ -0,0 +1,227 @@ +namespace LED推送模拟量 +{ + partial class frm_LED + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.led = new WinFormsDeviceControlLibrary.LED(); + this.grp_show = new System.Windows.Forms.GroupBox(); + this.btn_get = new System.Windows.Forms.Button(); + this.txt_temperature = new System.Windows.Forms.TextBox(); + this.txt_humidity = new System.Windows.Forms.TextBox(); + this.txt_light = new System.Windows.Forms.TextBox(); + this.lbl_humidity = new System.Windows.Forms.Label(); + this.lbl_temperature = new System.Windows.Forms.Label(); + this.lbl_light = new System.Windows.Forms.Label(); + this.grp_config = new System.Windows.Forms.GroupBox(); + this.com_ledPort = new System.Windows.Forms.ComboBox(); + this.lbl_ledPort = new System.Windows.Forms.Label(); + this.btn_open = new System.Windows.Forms.Button(); + this.com_adamPort = new System.Windows.Forms.ComboBox(); + this.lbl_adamPort = new System.Windows.Forms.Label(); + this.grp_show.SuspendLayout(); + this.grp_config.SuspendLayout(); + this.SuspendLayout(); + // + // led + // + this.led.BackColor = System.Drawing.Color.Black; + this.led.Location = new System.Drawing.Point(19, 0); + this.led.Name = "led"; + this.led.Size = new System.Drawing.Size(800, 130); + this.led.TabIndex = 0; + // + // grp_show + // + this.grp_show.Controls.Add(this.btn_get); + this.grp_show.Controls.Add(this.txt_temperature); + this.grp_show.Controls.Add(this.txt_humidity); + this.grp_show.Controls.Add(this.txt_light); + this.grp_show.Controls.Add(this.lbl_humidity); + this.grp_show.Controls.Add(this.lbl_temperature); + this.grp_show.Controls.Add(this.lbl_light); + this.grp_show.Location = new System.Drawing.Point(19, 156); + this.grp_show.Name = "grp_show"; + this.grp_show.Size = new System.Drawing.Size(370, 200); + this.grp_show.TabIndex = 1; + this.grp_show.TabStop = false; + this.grp_show.Text = "数据显示"; + // + // btn_get + // + this.btn_get.Location = new System.Drawing.Point(287, 47); + this.btn_get.Name = "btn_get"; + this.btn_get.Size = new System.Drawing.Size(51, 103); + this.btn_get.TabIndex = 6; + this.btn_get.Text = "获取"; + this.btn_get.UseVisualStyleBackColor = true; + this.btn_get.Click += new System.EventHandler(this.btn_get_Click); + // + // txt_temperature + // + this.txt_temperature.Location = new System.Drawing.Point(105, 85); + this.txt_temperature.Name = "txt_temperature"; + this.txt_temperature.Size = new System.Drawing.Size(147, 21); + this.txt_temperature.TabIndex = 4; + // + // txt_humidity + // + this.txt_humidity.Location = new System.Drawing.Point(105, 129); + this.txt_humidity.Name = "txt_humidity"; + this.txt_humidity.Size = new System.Drawing.Size(147, 21); + this.txt_humidity.TabIndex = 5; + // + // txt_light + // + this.txt_light.Location = new System.Drawing.Point(105, 47); + this.txt_light.Name = "txt_light"; + this.txt_light.Size = new System.Drawing.Size(147, 21); + this.txt_light.TabIndex = 3; + // + // lbl_humidity + // + this.lbl_humidity.AutoSize = true; + this.lbl_humidity.Location = new System.Drawing.Point(43, 132); + this.lbl_humidity.Name = "lbl_humidity"; + this.lbl_humidity.Size = new System.Drawing.Size(29, 12); + this.lbl_humidity.TabIndex = 2; + this.lbl_humidity.Text = "湿度"; + // + // lbl_temperature + // + this.lbl_temperature.AutoSize = true; + this.lbl_temperature.Location = new System.Drawing.Point(43, 88); + this.lbl_temperature.Name = "lbl_temperature"; + this.lbl_temperature.Size = new System.Drawing.Size(29, 12); + this.lbl_temperature.TabIndex = 1; + this.lbl_temperature.Text = "温度"; + // + // lbl_light + // + this.lbl_light.AutoSize = true; + this.lbl_light.Location = new System.Drawing.Point(43, 50); + this.lbl_light.Name = "lbl_light"; + this.lbl_light.Size = new System.Drawing.Size(41, 12); + this.lbl_light.TabIndex = 0; + this.lbl_light.Text = "光照度"; + // + // grp_config + // + this.grp_config.Controls.Add(this.com_ledPort); + this.grp_config.Controls.Add(this.lbl_ledPort); + this.grp_config.Controls.Add(this.btn_open); + this.grp_config.Controls.Add(this.com_adamPort); + this.grp_config.Controls.Add(this.lbl_adamPort); + this.grp_config.Location = new System.Drawing.Point(449, 156); + this.grp_config.Name = "grp_config"; + this.grp_config.Size = new System.Drawing.Size(370, 200); + this.grp_config.TabIndex = 2; + this.grp_config.TabStop = false; + this.grp_config.Text = "配置"; + // + // com_ledPort + // + this.com_ledPort.FormattingEnabled = true; + this.com_ledPort.Location = new System.Drawing.Point(127, 84); + this.com_ledPort.Name = "com_ledPort"; + this.com_ledPort.Size = new System.Drawing.Size(121, 20); + this.com_ledPort.TabIndex = 3; + // + // lbl_ledPort + // + this.lbl_ledPort.AutoSize = true; + this.lbl_ledPort.Location = new System.Drawing.Point(48, 87); + this.lbl_ledPort.Name = "lbl_ledPort"; + this.lbl_ledPort.Size = new System.Drawing.Size(47, 12); + this.lbl_ledPort.TabIndex = 2; + this.lbl_ledPort.Text = "LED串口"; + // + // btn_open + // + this.btn_open.Location = new System.Drawing.Point(173, 121); + this.btn_open.Name = "btn_open"; + this.btn_open.Size = new System.Drawing.Size(75, 23); + this.btn_open.TabIndex = 4; + this.btn_open.Text = "开启"; + this.btn_open.UseVisualStyleBackColor = true; + this.btn_open.Click += new System.EventHandler(this.btn_open_Click); + // + // com_adamPort + // + this.com_adamPort.FormattingEnabled = true; + this.com_adamPort.Location = new System.Drawing.Point(127, 41); + this.com_adamPort.Name = "com_adamPort"; + this.com_adamPort.Size = new System.Drawing.Size(121, 20); + this.com_adamPort.TabIndex = 1; + // + // lbl_adamPort + // + this.lbl_adamPort.AutoSize = true; + this.lbl_adamPort.Location = new System.Drawing.Point(46, 44); + this.lbl_adamPort.Name = "lbl_adamPort"; + this.lbl_adamPort.Size = new System.Drawing.Size(65, 12); + this.lbl_adamPort.TabIndex = 0; + this.lbl_adamPort.Text = "模拟量串口"; + // + // frm_LED + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(834, 380); + this.Controls.Add(this.grp_config); + this.Controls.Add(this.grp_show); + this.Controls.Add(this.led); + this.Name = "frm_LED"; + this.Text = "LED推送模拟量"; + this.Load += new System.EventHandler(this.frm_LED_Load); + this.grp_show.ResumeLayout(false); + this.grp_show.PerformLayout(); + this.grp_config.ResumeLayout(false); + this.grp_config.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private WinFormsDeviceControlLibrary.LED led; + private System.Windows.Forms.GroupBox grp_show; + private System.Windows.Forms.Button btn_get; + private System.Windows.Forms.TextBox txt_temperature; + private System.Windows.Forms.TextBox txt_humidity; + private System.Windows.Forms.TextBox txt_light; + private System.Windows.Forms.Label lbl_humidity; + private System.Windows.Forms.Label lbl_temperature; + private System.Windows.Forms.Label lbl_light; + private System.Windows.Forms.GroupBox grp_config; + private System.Windows.Forms.ComboBox com_ledPort; + private System.Windows.Forms.Label lbl_ledPort; + private System.Windows.Forms.Button btn_open; + private System.Windows.Forms.ComboBox com_adamPort; + private System.Windows.Forms.Label lbl_adamPort; + } +} diff --git a/samples/p2-t3-LED推送模拟量/frm_LED.cs b/samples/p2-t3-LED推送模拟量/frm_LED.cs new file mode 100644 index 0000000..aa83799 --- /dev/null +++ b/samples/p2-t3-LED推送模拟量/frm_LED.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO.Ports; +using DeviceCtrlLibrary; +using DeviceCtrlLibrary.Model; + +namespace LED推送模拟量 +{ + public partial class frm_LED : Form + { + public frm_LED() + { + InitializeComponent(); + } + + bool IsOpen = false; + ADAM4017ResModel ResModel; + String LED_MSG = ""; + + private void frm_LED_Load(object sender, EventArgs e) + { + //清空列表 + com_adamPort.Items.Clear(); + com_ledPort.Items.Clear(); + //加载所有可用的串口号到列表中 + foreach (string item in SerialPort.GetPortNames()) + { + com_adamPort.Items.Add(item); + com_ledPort.Items.Add(item); + } + } + + private void btn_open_Click(object sender, EventArgs e) + { + if (!IsOpen) + { + //打开模拟量采集器和LED显示屏所在的串口 + DeviceCtrlHelper.adam4017Helper.Open(com_adamPort.Text); + DeviceCtrlHelper.ledHelper.OpenLedCom(com_ledPort.Text); + btn_open.Text = "关闭"; + IsOpen = true; + } + else + { + //关闭串口 + DeviceCtrlHelper.adam4017Helper.Close(); + DeviceCtrlHelper.ledHelper.Close(); + btn_open.Text = "开启"; + IsOpen = false; + } + } + + private void btn_get_Click(object sender, EventArgs e) + { + LED_MSG = ""; + //获取传感器数值 + ResModel = DeviceCtrlHelper.adam4017Helper.SetData(); + txt_light.Text = ResModel.lightValue; + txt_humidity.Text = ResModel.humidityValue; + txt_temperature.Text = ResModel.temperatureValue; + //拼接要推送的环境信息 + LED_MSG += ("当前环境信息" + " " + "光照度:" + ResModel.lightValue + "lx温度:" + ResModel.temperatureValue + "℃湿度:" + ResModel.humidityValue + "%"); + //推送到LED显示屏 + String Res = DeviceCtrlHelper.ledHelper.SendLedString(LED_MSG); + if (!(Res.IndexOf("Error") >= 0)) + { + led.setLedText(Res); + } + } + } +} diff --git a/samples/p2-t3-LED推送模拟量/meta.json b/samples/p2-t3-LED推送模拟量/meta.json new file mode 100644 index 0000000..eb4a030 --- /dev/null +++ b/samples/p2-t3-LED推送模拟量/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目2 任务3 在LED显示屏中显示环境数据", + "source": "教材书 p42–45,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)" +} diff --git a/samples/p3-t1-ZigBee路灯/Form1.Designer.cs b/samples/p3-t1-ZigBee路灯/Form1.Designer.cs new file mode 100644 index 0000000..9dc6bd5 --- /dev/null +++ b/samples/p3-t1-ZigBee路灯/Form1.Designer.cs @@ -0,0 +1,140 @@ +namespace ZigBee路灯 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.lamp1 = new WinFormsDeviceControlLibrary.Lamp(); + this.rb_Open = new System.Windows.Forms.RadioButton(); + this.rb_Close = new System.Windows.Forms.RadioButton(); + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.btn_open = new System.Windows.Forms.Button(); + this.com_port = new System.Windows.Forms.ComboBox(); + this.lbl_Lamp = new System.Windows.Forms.Label(); + this.groupBox1.SuspendLayout(); + this.SuspendLayout(); + // + // lamp1 + // + this.lamp1.BackColor = System.Drawing.Color.White; + this.lamp1.Location = new System.Drawing.Point(29, 28); + this.lamp1.Name = "lamp1"; + this.lamp1.Size = new System.Drawing.Size(105, 185); + this.lamp1.TabIndex = 0; + // + // rb_Open + // + this.rb_Open.AutoSize = true; + this.rb_Open.Location = new System.Drawing.Point(29, 222); + this.rb_Open.Name = "rb_Open"; + this.rb_Open.Size = new System.Drawing.Size(35, 16); + this.rb_Open.TabIndex = 1; + this.rb_Open.Text = "开"; + this.rb_Open.UseVisualStyleBackColor = true; + this.rb_Open.CheckedChanged += new System.EventHandler(this.rb_Open_CheckedChanged); + // + // rb_Close + // + this.rb_Close.AutoSize = true; + this.rb_Close.Location = new System.Drawing.Point(99, 222); + this.rb_Close.Name = "rb_Close"; + this.rb_Close.Size = new System.Drawing.Size(35, 16); + this.rb_Close.TabIndex = 2; + this.rb_Close.Text = "关"; + this.rb_Close.UseVisualStyleBackColor = true; + this.rb_Close.CheckedChanged += new System.EventHandler(this.rb_Close_CheckedChanged); + // + // groupBox1 + // + this.groupBox1.Controls.Add(this.btn_open); + this.groupBox1.Controls.Add(this.com_port); + this.groupBox1.Controls.Add(this.lbl_Lamp); + this.groupBox1.Location = new System.Drawing.Point(184, 50); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Size = new System.Drawing.Size(238, 120); + this.groupBox1.TabIndex = 3; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "配置"; + // + // btn_open + // + this.btn_open.Location = new System.Drawing.Point(145, 77); + this.btn_open.Name = "btn_open"; + this.btn_open.Size = new System.Drawing.Size(75, 23); + this.btn_open.TabIndex = 2; + this.btn_open.Text = "打开"; + this.btn_open.UseVisualStyleBackColor = true; + this.btn_open.Click += new System.EventHandler(this.btn_open_Click); + // + // com_port + // + this.com_port.FormattingEnabled = true; + this.com_port.Location = new System.Drawing.Point(90, 34); + this.com_port.Name = "com_port"; + this.com_port.Size = new System.Drawing.Size(130, 20); + this.com_port.TabIndex = 1; + // + // lbl_Lamp + // + this.lbl_Lamp.AutoSize = true; + this.lbl_Lamp.Location = new System.Drawing.Point(21, 37); + this.lbl_Lamp.Name = "lbl_Lamp"; + this.lbl_Lamp.Size = new System.Drawing.Size(29, 12); + this.lbl_Lamp.TabIndex = 0; + this.lbl_Lamp.Text = "路灯"; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BackColor = System.Drawing.Color.White; + this.ClientSize = new System.Drawing.Size(442, 254); + this.Controls.Add(this.groupBox1); + this.Controls.Add(this.rb_Close); + this.Controls.Add(this.rb_Open); + this.Controls.Add(this.lamp1); + this.Name = "Form1"; + this.Text = "ZigBee路灯"; + this.Load += new System.EventHandler(this.Form1_Load); + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); + + } + + #endregion + + private WinFormsDeviceControlLibrary.Lamp lamp1; + private System.Windows.Forms.RadioButton rb_Open; + private System.Windows.Forms.RadioButton rb_Close; + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.Button btn_open; + private System.Windows.Forms.ComboBox com_port; + private System.Windows.Forms.Label lbl_Lamp; + } +} diff --git a/samples/p3-t1-ZigBee路灯/Form1.cs b/samples/p3-t1-ZigBee路灯/Form1.cs new file mode 100644 index 0000000..175359f --- /dev/null +++ b/samples/p3-t1-ZigBee路灯/Form1.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO.Ports; +using DeviceCtrlLibrary; + +namespace ZigBee路灯 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + //控制开关灯 + private void LampOnOff(int RelayNum, bool IsOpen) + { + DeviceCtrlHelper.zigBeeHelper.OnOff(RelayNum, IsOpen); + lamp1.OnOff(IsOpen); + } + + private void Form1_Load(object sender, EventArgs e) + { + //清空列表 + com_port.Items.Clear(); + //加载所有可用的串口号到列表中 + foreach (string item in SerialPort.GetPortNames()) + { + com_port.Items.Add(item); + } + } + + private void btn_open_Click(object sender, EventArgs e) + { + if (btn_open.Text.Equals("打开")) + { + //打开ZigBee协调器所在的串口 + DeviceCtrlHelper.zigBeeHelper.Open(com_port.Text); + btn_open.Text = "关闭"; + } + else if (btn_open.Text.Equals("关闭")) + { + //关闭串口 + DeviceCtrlHelper.zigBeeHelper.Close(); + btn_open.Text = "打开"; + } + } + + private void rb_Open_CheckedChanged(object sender, EventArgs e) + { + //开灯 + LampOnOff(1, true); + } + + private void rb_Close_CheckedChanged(object sender, EventArgs e) + { + //关灯 + LampOnOff(1, false); + } + } +} diff --git a/samples/p3-t1-ZigBee路灯/Program.cs b/samples/p3-t1-ZigBee路灯/Program.cs new file mode 100644 index 0000000..06ae9e0 --- /dev/null +++ b/samples/p3-t1-ZigBee路灯/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace ZigBee路灯 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p3-t1-ZigBee路灯/meta.json b/samples/p3-t1-ZigBee路灯/meta.json new file mode 100644 index 0000000..65acdd5 --- /dev/null +++ b/samples/p3-t1-ZigBee路灯/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目3 任务1 使用ZigBee技术控制路灯开关", + "source": "教材书 p53–56,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)" +} diff --git a/samples/p3-t2-人体红外ZigBee联动/Form1.Designer.cs b/samples/p3-t2-人体红外ZigBee联动/Form1.Designer.cs new file mode 100644 index 0000000..9b3017a --- /dev/null +++ b/samples/p3-t2-人体红外ZigBee联动/Form1.Designer.cs @@ -0,0 +1,182 @@ +namespace 人体红外ZigBee联动 +{ + partial class Form1 + { + /// + /// 必需的设计器变量。 + /// + private System.ComponentModel.IContainer components = null; + + /// + /// 清理所有正在使用的资源。 + /// + /// 如果应释放托管资源,为 true;否则为 false。 + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows 窗体设计器生成的代码 + + /// + /// 设计器支持所需的方法 - 不要 + /// 使用代码编辑器修改此方法的内容。 + /// + private void InitializeComponent() + { + this.components = new System.ComponentModel.Container(); + this.lamp1 = new WinFormsDeviceControlLibrary.Lamp(); + this.btn_lampOpen = new System.Windows.Forms.Button(); + this.btn_lampClose = new System.Windows.Forms.Button(); + this.groupBox1 = new System.Windows.Forms.GroupBox(); + this.ch_bodyInfrared = new System.Windows.Forms.CheckBox(); + this.com_environment = new System.Windows.Forms.ComboBox(); + this.lbl_environment = new System.Windows.Forms.Label(); + this.btn_open = new System.Windows.Forms.Button(); + this.com_lamp = new System.Windows.Forms.ComboBox(); + this.lbl_lamp = new System.Windows.Forms.Label(); + this.timer1 = new System.Windows.Forms.Timer(this.components); + this.groupBox1.SuspendLayout(); + this.SuspendLayout(); + // + // lamp1 + // + this.lamp1.BackColor = System.Drawing.Color.White; + this.lamp1.Location = new System.Drawing.Point(32, 22); + this.lamp1.Name = "lamp1"; + this.lamp1.Size = new System.Drawing.Size(105, 185); + this.lamp1.TabIndex = 0; + // + // btn_lampOpen + // + this.btn_lampOpen.Location = new System.Drawing.Point(32, 213); + this.btn_lampOpen.Name = "btn_lampOpen"; + this.btn_lampOpen.Size = new System.Drawing.Size(33, 23); + this.btn_lampOpen.TabIndex = 1; + this.btn_lampOpen.Text = "开"; + this.btn_lampOpen.UseVisualStyleBackColor = true; + this.btn_lampOpen.Click += new System.EventHandler(this.btn_lampOpen_Click); + // + // btn_lampClose + // + this.btn_lampClose.Location = new System.Drawing.Point(104, 213); + this.btn_lampClose.Name = "btn_lampClose"; + this.btn_lampClose.Size = new System.Drawing.Size(33, 23); + this.btn_lampClose.TabIndex = 2; + this.btn_lampClose.Text = "关"; + this.btn_lampClose.UseVisualStyleBackColor = true; + this.btn_lampClose.Click += new System.EventHandler(this.btn_lampClose_Click); + // + // groupBox1 + // + this.groupBox1.Controls.Add(this.ch_bodyInfrared); + this.groupBox1.Controls.Add(this.com_environment); + this.groupBox1.Controls.Add(this.lbl_environment); + this.groupBox1.Controls.Add(this.btn_open); + this.groupBox1.Controls.Add(this.com_lamp); + this.groupBox1.Controls.Add(this.lbl_lamp); + this.groupBox1.Location = new System.Drawing.Point(193, 58); + this.groupBox1.Name = "groupBox1"; + this.groupBox1.Size = new System.Drawing.Size(238, 126); + this.groupBox1.TabIndex = 3; + this.groupBox1.TabStop = false; + this.groupBox1.Text = "配置"; + // + // ch_bodyInfrared + // + this.ch_bodyInfrared.AutoSize = true; + this.ch_bodyInfrared.Location = new System.Drawing.Point(25, 81); + this.ch_bodyInfrared.Name = "ch_bodyInfrared"; + this.ch_bodyInfrared.Size = new System.Drawing.Size(72, 16); + this.ch_bodyInfrared.TabIndex = 4; + this.ch_bodyInfrared.Text = "人体开关"; + this.ch_bodyInfrared.UseVisualStyleBackColor = true; + this.ch_bodyInfrared.Click += new System.EventHandler(this.ch_bodyInfrared_Click); + // + // com_environment + // + this.com_environment.FormattingEnabled = true; + this.com_environment.Location = new System.Drawing.Point(90, 51); + this.com_environment.Name = "com_environment"; + this.com_environment.Size = new System.Drawing.Size(130, 20); + this.com_environment.TabIndex = 3; + // + // lbl_environment + // + this.lbl_environment.AutoSize = true; + this.lbl_environment.Location = new System.Drawing.Point(23, 54); + this.lbl_environment.Name = "lbl_environment"; + this.lbl_environment.Size = new System.Drawing.Size(53, 12); + this.lbl_environment.TabIndex = 2; + this.lbl_environment.Text = "环境采集"; + // + // btn_open + // + this.btn_open.Location = new System.Drawing.Point(145, 77); + this.btn_open.Name = "btn_open"; + this.btn_open.Size = new System.Drawing.Size(75, 23); + this.btn_open.TabIndex = 5; + this.btn_open.Text = "打开"; + this.btn_open.UseVisualStyleBackColor = true; + this.btn_open.Click += new System.EventHandler(this.btn_open_Click); + // + // com_lamp + // + this.com_lamp.FormattingEnabled = true; + this.com_lamp.Location = new System.Drawing.Point(90, 26); + this.com_lamp.Name = "com_lamp"; + this.com_lamp.Size = new System.Drawing.Size(130, 20); + this.com_lamp.TabIndex = 1; + // + // lbl_lamp + // + this.lbl_lamp.AutoSize = true; + this.lbl_lamp.Location = new System.Drawing.Point(21, 29); + this.lbl_lamp.Name = "lbl_lamp"; + this.lbl_lamp.Size = new System.Drawing.Size(29, 12); + this.lbl_lamp.TabIndex = 0; + this.lbl_lamp.Text = "路灯"; + // + // timer1 + // + this.timer1.Interval = 5000; + this.timer1.Tick += new System.EventHandler(this.timer1_Tick); + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.BackColor = System.Drawing.Color.White; + this.ClientSize = new System.Drawing.Size(477, 249); + this.Controls.Add(this.groupBox1); + this.Controls.Add(this.btn_lampClose); + this.Controls.Add(this.btn_lampOpen); + this.Controls.Add(this.lamp1); + this.Name = "Form1"; + this.Text = "人体红外ZigBee联动"; + this.Load += new System.EventHandler(this.Form1_Load); + this.groupBox1.ResumeLayout(false); + this.groupBox1.PerformLayout(); + this.ResumeLayout(false); + + } + + #endregion + + private WinFormsDeviceControlLibrary.Lamp lamp1; + private System.Windows.Forms.Button btn_lampOpen; + private System.Windows.Forms.Button btn_lampClose; + private System.Windows.Forms.GroupBox groupBox1; + private System.Windows.Forms.CheckBox ch_bodyInfrared; + private System.Windows.Forms.ComboBox com_environment; + private System.Windows.Forms.Label lbl_environment; + private System.Windows.Forms.Button btn_open; + private System.Windows.Forms.ComboBox com_lamp; + private System.Windows.Forms.Label lbl_lamp; + private System.Windows.Forms.Timer timer1; + } +} diff --git a/samples/p3-t2-人体红外ZigBee联动/Form1.cs b/samples/p3-t2-人体红外ZigBee联动/Form1.cs new file mode 100644 index 0000000..ebbbaae --- /dev/null +++ b/samples/p3-t2-人体红外ZigBee联动/Form1.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Data; +using System.Drawing; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Forms; +using System.IO.Ports; +using DeviceCtrlLibrary; + +namespace 人体红外ZigBee联动 +{ + public partial class Form1 : Form + { + public Form1() + { + InitializeComponent(); + } + + private void Form1_Load(object sender, EventArgs e) + { + //清空列表 + com_lamp.Items.Clear(); + com_environment.Items.Clear(); + //加载所有可用的串口号到列表中 + foreach (string item in SerialPort.GetPortNames()) + { + com_lamp.Items.Add(item); + com_environment.Items.Add(item); + } + } + + //控制开关灯 + private void LampOnOff(int RelayNum, bool IsOpen) + { + DeviceCtrlHelper.zigBeeHelper.OnOff(RelayNum, IsOpen); + lamp1.OnOff(IsOpen); + } + + private void btn_open_Click(object sender, EventArgs e) + { + if (btn_open.Text.Equals("打开")) + { + //打开ZigBee协调器所在的串口 + DeviceCtrlHelper.zigBeeHelper.Open(com_lamp.Text); + btn_open.Text = "关闭"; + } + else if (btn_open.Text.Equals("关闭")) + { + //关闭串口 + DeviceCtrlHelper.zigBeeHelper.Close(); + btn_open.Text = "打开"; + } + } + + private void ch_bodyInfrared_Click(object sender, EventArgs e) + { + if (ch_bodyInfrared.Checked) + { + //打开数字量采集器所在的串口,启动定时检测 + DeviceCtrlHelper.adam4150Helper.Open(com_environment.Text); + timer1.Start(); + btn_lampOpen.Enabled = false; + btn_lampClose.Enabled = false; + } + else + { + DeviceCtrlHelper.adam4150Helper.Close(); + timer1.Stop(); + btn_lampOpen.Enabled = true; + btn_lampClose.Enabled = true; + } + } + + private void btn_lampOpen_Click(object sender, EventArgs e) + { + LampOnOff(1, true); + } + + private void btn_lampClose_Click(object sender, EventArgs e) + { + LampOnOff(1, false); + } + + //时间控件实时判断是否感应到人,如果感应到人则开路灯,否则关闭 + private void timer1_Tick(object sender, EventArgs e) + { + if (DeviceCtrlHelper.adam4150Helper.SetData().bodyInfraredValue) + LampOnOff(1, true); + else + LampOnOff(1, false); + } + } +} diff --git a/samples/p3-t2-人体红外ZigBee联动/Program.cs b/samples/p3-t2-人体红外ZigBee联动/Program.cs new file mode 100644 index 0000000..943a163 --- /dev/null +++ b/samples/p3-t2-人体红外ZigBee联动/Program.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; + +namespace 人体红外ZigBee联动 +{ + static class Program + { + /// + /// 应用程序的主入口点。 + /// + [STAThread] + static void Main() + { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.Run(new Form1()); + } + } +} diff --git a/samples/p3-t2-人体红外ZigBee联动/meta.json b/samples/p3-t2-人体红外ZigBee联动/meta.json new file mode 100644 index 0000000..e7407e3 --- /dev/null +++ b/samples/p3-t2-人体红外ZigBee联动/meta.json @@ -0,0 +1,4 @@ +{ + "title": "项目3 任务2 实现人体红外传感器和路灯联动", + "source": "教材书 p58–61,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)" +} diff --git a/server/src/board.ts b/server/src/board.ts new file mode 100644 index 0000000..738f024 --- /dev/null +++ b/server/src/board.ts @@ -0,0 +1,80 @@ +import { + ANALOG_CHANNELS, + ANALOG_INFO, + type BoardState, + type ClientMessage, + DIGITAL_CHANNELS, + type RunnerInput, + type RunnerMessage, +} from '@iot/shared' + +export function defaultBoardState(): BoardState { + return { + analog: { temperature: 25, humidity: 60, light: 500, airPressure: 101, airQuality: 60, co2: 600, wind: 3 }, + digital: { bodyInfrared: false, fire: false, smoke: false, infrared: false }, + relays: [false, false, false, false, false], + zigbee: [false, false], + ledText: '', + } +} + +/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */ +export class Board { + state = defaultBoardState() + + /** 发给运行器的传感器读数,按通道号排列 */ + runnerInputs(): Extract { + return { + type: 'board', + analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]), + digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]), + } + } + + /** 浏览器调节环境;返回 true 表示传感器读数有变化 */ + applyInput(message: Extract) { + let changed = false + for (const [key, value] of Object.entries(message.analog ?? {})) { + const name = key as keyof BoardState['analog'] + if (!(name in ANALOG_INFO) || typeof value !== 'number' || !Number.isFinite(value)) continue + const { min, max } = ANALOG_INFO[name] + // 教材类库返回整数字符串,这里保证读数是整数 + this.state.analog[name] = Math.round(Math.min(max, Math.max(min, value))) + changed = true + } + for (const [key, value] of Object.entries(message.digital ?? {})) { + const name = key as keyof BoardState['digital'] + if (!(name in this.state.digital) || typeof value !== 'boolean') continue + this.state.digital[name] = value + changed = true + } + return changed + } + + /** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */ + applyDevice(message: Extract) { + switch (`${message.device}/${message.action}`) { + case 'led/text': + this.state.ledText = String(message.data ?? '') + return true + case 'adam4150/do': { + const [channel, on] = message.data as [number, number] + if (channel < 0 || channel >= this.state.relays.length) return false + this.state.relays[channel] = on === 1 + return true + } + case 'zigbee/relay': { + const [relay, on] = message.data as [number, number] + if (relay < 0 || relay >= this.state.zigbee.length) return false + this.state.zigbee[relay] = on === 1 + return true + } + default: + return false + } + } + + reset() { + this.state = defaultBoardState() + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 90ae7d1..b9b5505 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -50,6 +50,10 @@ const server = Bun.serve({ } } else if (message.type === 'stop') { await session.stop() + } else if (message.type === 'boardInput') { + session.boardInput(message) + } else if (message.type === 'boardReset') { + session.boardReset() } else { session.input(message) } diff --git a/server/src/run-session.ts b/server/src/run-session.ts index 6abf0a9..600db06 100644 --- a/server/src/run-session.ts +++ b/server/src/run-session.ts @@ -1,7 +1,8 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { dirname, join, normalize, resolve } from 'node:path' import type { Subprocess } from 'bun' -import type { RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared' +import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared' +import { Board } from './board' import { compileService } from './compile-service' import { ROOT, RUNNER_DLL, readLines } from './runner-process' @@ -12,8 +13,12 @@ export async function cleanupStaleRuns() { await rm(RUNS_DIR, { recursive: true, force: true }) } -/** 一次运行:写出学生工程 → 编译服务编译 → 启动运行进程,在运行进程和浏览器之间转发消息 */ +/** + * 一个浏览器会话:拥有一台虚拟实训台,负责每次运行(写出学生工程 → 编译服务编译 → 启动运行进程), + * 在运行进程和浏览器之间转发消息,并让实训台状态在两边保持一致。 + */ export class RunSession { + readonly board = new Board() private proc?: Subprocess<'pipe', 'pipe', 'pipe'> private dir?: string /** 每次启动、停止都递增,用来丢弃已经过时的编译结果 */ @@ -21,7 +26,9 @@ export class RunSession { /** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */ private queue = Promise.resolve() - constructor(private readonly send: (message: ServerMessage) => void) {} + constructor(private readonly send: (message: ServerMessage) => void) { + this.sendBoard() + } start(files: SourceFile[]) { const next = this.queue.then(() => this.startNow(files)) @@ -59,7 +66,13 @@ export class RunSession { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' }, + env: { + ...process.env, + DOTNET_CLI_TELEMETRY_OPTOUT: '1', + DOTNET_NOLOGO: '1', + // 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息 + SIM_BOARD: JSON.stringify(this.board.runnerInputs()), + }, }) this.proc = proc @@ -72,6 +85,10 @@ export class RunSession { return } if (message.type === 'started') this.send({ type: 'status', state: 'running' }) + if (message.type === 'device') { + if (this.board.applyDevice(message)) this.sendBoard() + return + } this.send(message) }) void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line })) @@ -85,6 +102,23 @@ export class RunSession { }) } + /** 浏览器调节实训台环境:更新状态,推给浏览器和正在运行的学生程序 */ + boardInput(message: Extract) { + if (!this.board.applyInput(message)) return + this.sendBoard() + this.input(this.board.runnerInputs()) + } + + boardReset() { + this.board.reset() + this.sendBoard() + this.input(this.board.runnerInputs()) + } + + private sendBoard() { + this.send({ type: 'board', state: structuredClone(this.board.state) }) + } + input(message: RunnerInput) { if (!this.proc) return this.proc.stdin.write(`${JSON.stringify(message)}\n`) diff --git a/server/test/board.test.ts b/server/test/board.test.ts new file mode 100644 index 0000000..76e93e7 --- /dev/null +++ b/server/test/board.test.ts @@ -0,0 +1,33 @@ +import { expect, test } from 'bun:test' +import { Board } from '../src/board' + +test('滑块输入取整并限制在范围内,开关输入只接受布尔值', () => { + const board = new Board() + expect(board.applyInput({ type: 'boardInput', analog: { temperature: 31.6, light: 99999 } })).toBe(true) + expect(board.state.analog.temperature).toBe(32) + expect(board.state.analog.light).toBe(2000) + + board.applyInput({ type: 'boardInput', digital: { fire: true, smoke: 'yes' as unknown as boolean } }) + expect(board.state.digital.fire).toBe(true) + expect(board.state.digital.smoke).toBe(false) + + // 发给运行器的读数按通道号排列:Vin0 温度、Vin1 湿度、Vin2 光照……;DI0 人体红外、DI1 火焰…… + const inputs = board.runnerInputs() + expect(inputs.analog.slice(0, 3)).toEqual([32, 60, 2000]) + expect(inputs.digital).toEqual([false, true, false, false]) +}) + +test('学生程序控制输出设备:LED 屏、4150 继电器、ZigBee 继电器', () => { + const board = new Board() + expect(board.applyDevice({ type: 'device', device: 'led', action: 'text', data: '欢迎' })).toBe(true) + expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'do', data: [0, 1] })).toBe(true) + expect(board.applyDevice({ type: 'device', device: 'zigbee', action: 'relay', data: [1, 1] })).toBe(true) + expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'do', data: [9, 1] })).toBe(false) + expect(board.state.ledText).toBe('欢迎') + expect(board.state.relays[0]).toBe(true) + expect(board.state.zigbee).toEqual([false, true]) + + board.reset() + expect(board.state.relays.every((on) => !on)).toBe(true) + expect(board.state.ledText).toBe('') +}) diff --git a/server/test/runner.test.ts b/server/test/runner.test.ts index 7086f30..e03bdfc 100644 --- a/server/test/runner.test.ts +++ b/server/test/runner.test.ts @@ -1,6 +1,7 @@ -// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材项目 2 任务 1 的行为。 +// 端到端测试:直接启动 C# 运行器,模拟浏览器操作,检查教材示例工程的行为。 // 运行前先编译运行时:bun run build:runtime import { afterAll, afterEach, expect, test } from 'bun:test' +import { cp, rm } from 'node:fs/promises' import { resolve } from 'node:path' import type { ControlNode, RunnerInput, RunnerMessage } from '@iot/shared' import { compileService } from '../src/compile-service' @@ -47,6 +48,16 @@ class Runner { } } + /** 只等待从现在起新收到的消息(不算已经收到的) */ + next( + type: T, + match: (m: Extract) => boolean = () => true, + timeoutMs = 15000, + ): Promise> { + const from = this.messages.length + return this.waitFor(type, (m) => this.messages.indexOf(m) >= from && match(m), timeoutMs) + } + /** 等待下一条满足条件的消息(已经收到的也算) */ waitFor( type: T, @@ -87,16 +98,32 @@ function findByName(node: ControlNode, name: string): ControlNode { return { id: '', type: '', name: '', props: {}, children: [] } } +/** 编译产物写在工程目录里,所以把示例复制到临时目录再编译 */ +async function prepareSample(id: string) { + const dir = resolve(ROOT, '.runs', `test-${id}`) + await rm(dir, { recursive: true, force: true }) + await cp(resolve(ROOT, 'samples', id), dir, { recursive: true }) + return dir +} + +/** 启动示例并等窗体显示;返回按控件名查 id 的函数 */ +async function startSample(id: string) { + runner = await Runner.start(await prepareSample(id)) + const compiled = await runner.waitFor('compiled') + expect(compiled.diagnostics.filter((d) => d.severity === 'error')).toEqual([]) + const { form } = await runner.waitFor('formShown') + return { runner, form, id: (name: string) => findByName(form, name).id } +} + +const ANALOG_DEFAULT = [25, 60, 500, 101, 60, 600, 3] +const DIGITAL_OFF = [false, false, false, false] + let runner: Runner | undefined afterEach(() => runner?.kill()) afterAll(() => compileService.stop()) test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async () => { - // 编译产物写在工程目录里,所以复制到临时目录再编译 - const dir = resolve(ROOT, '.runs/test-led') - const sample = resolve(ROOT, 'samples/p2-t1-LED显示屏') - for (const name of ['Program.cs', 'Form1.cs', 'Form1.Designer.cs']) await Bun.write(`${dir}/${name}`, Bun.file(`${sample}/${name}`)) - runner = await Runner.start(dir) + runner = await Runner.start(await prepareSample('p2-t1-LED显示屏')) const compiled = await runner.waitFor('compiled') expect(compiled.diagnostics).toEqual([]) const { form } = await runner.waitFor('formShown') @@ -147,3 +174,66 @@ test('编译错误:书上常见的 True 大写给出中文提示', async () => expect(error?.message).toContain('True') expect(error?.hint).toContain('小写') }, 30000) + +test('项目2 任务2:温度读数来自实训台,滑块变化后再次获取得到新值', async () => { + const { runner, id } = await startSample('p2-t2-模拟量采集') + const port = id('com_port') + // 串口列表在 Load 里就已填好 + runner.send({ type: 'action', id: port, action: 'select', index: 1 }) + runner.send({ type: 'action', id: id('btn_Open'), action: 'click' }) + const textOf = (name: string) => + runner.next('patch', (m) => m.changes.some((c) => c.id === id(name) && c.prop === 'Text')).then( + (m) => m.changes.findLast((c) => c.id === id(name) && c.prop === 'Text')!.value, + ) + + let temperature = textOf('txt_temperature') + runner.send({ type: 'action', id: id('btn_get'), action: 'click' }) + expect(await temperature).toBe('25') + + runner.send({ type: 'board', analog: [31, 60, 300, 101, 60, 600, 3], digital: DIGITAL_OFF }) + temperature = textOf('txt_temperature') + const light = textOf('txt_light') + runner.send({ type: 'action', id: id('btn_get'), action: 'click' }) + expect(await temperature).toBe('31') + expect(await light).toBe('300') +}, 30000) + +test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,最终灯的状态正确', async () => { + const { runner, id } = await startSample('p3-t1-ZigBee路灯') + const lamp = id('lamp1') + + // 串口没打开时控制无效:没有继电器动作,但界面上的灯照样会亮(学生能看出两者不一致) + runner.send({ type: 'action', id: id('rb_Open'), action: 'click' }) + await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === lamp && c.prop === 'IsOn' && c.value === true)) + expect(runner.messages.some((m) => m.type === 'device')).toBe(false) + + runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 3 }) + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + + // 选"关":先触发"开"的 CheckedChanged(又开一次),再触发"关"的 CheckedChanged,最终关灯 + const relayOff = runner.next('device', (m) => m.device === 'zigbee' && (m.data as number[])[1] === 0) + runner.send({ type: 'action', id: id('rb_Close'), action: 'click' }) + const devices: unknown[] = [] + await relayOff + for (const m of runner.messages) if (m.type === 'device') devices.push(m.data) + expect(devices).toEqual([ + [1, 1], + [1, 0], + ]) +}, 30000) + +test('项目3 任务2:勾选人体开关后,有人经过时 5 秒内路灯打开', async () => { + const { runner, id } = await startSample('p3-t2-人体红外ZigBee联动') + runner.send({ type: 'action', id: id('com_lamp'), action: 'select', index: 3 }) + runner.send({ type: 'action', id: id('com_environment'), action: 'select', index: 2 }) + runner.send({ type: 'action', id: id('btn_open'), action: 'click' }) + + // 复选框的 Click 里读取 Checked:此时 Checked 必须已经是 true + const disabled = runner.next('patch', (m) => m.changes.some((c) => c.id === id('btn_lampOpen') && c.prop === 'Enabled' && c.value === false)) + runner.send({ type: 'action', id: id('ch_bodyInfrared'), action: 'click' }) + await disabled + + runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [true, false, false, false] }) + const on = await runner.next('device', (m) => m.device === 'zigbee', 7000) + expect(on.data).toEqual([1, 1]) +}, 30000) diff --git a/shared/protocol.ts b/shared/protocol.ts index 6caad8b..23850d4 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -35,6 +35,53 @@ export interface PropChange { value: unknown } +/** + * 虚拟实训台(按教材实训台布局图)。每个浏览器会话一台,状态由服务器统一保存: + * 输入(传感器读数)由网页上的滑块和开关设定,推给学生程序;输出(灯、继电器、LED 屏)由学生程序控制。 + */ +export interface BoardState { + /** ADAM-4017 模拟量,按教材整数取值 */ + analog: { + temperature: number // Vin0 温度 ℃ + humidity: number // Vin1 湿度 % + light: number // Vin2 光照 lx + airPressure: number // Vin3 气压 kPa + airQuality: number // Vin4 空气质量 + co2: number // Vin5 CO2 ppm + wind: number // Vin6 风速 m/s + } + /** ADAM-4150 数字量输入 */ + digital: { + bodyInfrared: boolean // DI0 人体红外 + fire: boolean // DI1 火焰 + smoke: boolean // DI2 烟雾 + infrared: boolean // DI3 红外对射 + } + /** ADAM-4150 继电器 DO0–DO4:DO0 报警灯、DO1 照明灯、DO2 路灯、DO3 插座 1、DO4 插座 2 */ + relays: boolean[] + /** ZigBee 继电器节点 0、1(教材用节点 1 控制路灯) */ + zigbee: boolean[] + ledText: string +} + +export type AnalogKey = keyof BoardState['analog'] +export type DigitalKey = keyof BoardState['digital'] + +/** 4017 通道顺序(Vin0–Vin6)和 4150 输入通道顺序(DI0–DI3),C# 端按通道号读取 */ +export const ANALOG_CHANNELS: AnalogKey[] = ['temperature', 'humidity', 'light', 'airPressure', 'airQuality', 'co2', 'wind'] +export const DIGITAL_CHANNELS: DigitalKey[] = ['bodyInfrared', 'fire', 'smoke', 'infrared'] + +/** 各模拟量的单位和滑块范围。光照上限取 2000 lx:教材里的阈值都在 200–800 之间 */ +export const ANALOG_INFO: Record = { + temperature: { label: '温度', unit: '℃', min: -20, max: 60 }, + humidity: { label: '湿度', unit: '%', min: 0, max: 100 }, + light: { label: '光照', unit: 'lx', min: 0, max: 2000 }, + airPressure: { label: '气压', unit: 'kPa', min: 80, max: 120 }, + airQuality: { label: '空气质量', unit: '', min: 0, max: 500 }, + co2: { label: 'CO₂', unit: 'ppm', min: 300, max: 5000 }, + wind: { label: '风速', unit: 'm/s', min: 0, max: 30 }, +} + /** 运行器发出的消息 */ export type RunnerMessage = | { type: 'compiled'; ok: boolean; diagnostics: Diagnostic[]; ms: number } @@ -45,6 +92,7 @@ export type RunnerMessage = | { type: 'controlAdded'; parentId: string; index: number; node: ControlNode } | { type: 'controlRemoved'; id: string } | { type: 'messageBox'; requestId: number; text: string; caption: string; buttons: string; icon: string } + /** 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay,data 见 DeviceCtrlHelper.cs */ | { type: 'device'; device: string; action: string; data: unknown } | { type: 'console'; text: string } | { type: 'exception'; message: string; detail: string } @@ -67,13 +115,22 @@ export type UserAction = export type RunnerInput = | ({ type: 'action'; id: string } & UserAction) | { type: 'reply'; requestId: number; result: unknown } + /** 实训台传感器读数,按通道号排列(见 ANALOG_CHANNELS、DIGITAL_CHANNELS) */ + | { type: 'board'; analog: number[]; digital: boolean[] } | { type: 'stop' } /** 浏览器发给服务器的消息 */ -export type ClientMessage = { type: 'run'; files: SourceFile[] } | RunnerInput +export type ClientMessage = + | { type: 'run'; files: SourceFile[] } + /** 调节实训台环境:拖动滑块、触发传感器 */ + | { type: 'boardInput'; analog?: Partial; digital?: Partial } + /** 实训台复位:关掉所有灯和继电器、清空 LED 屏,传感器恢复默认 */ + | { type: 'boardReset' } + | Exclude /** 服务器发给浏览器的消息 */ export type ServerMessage = | RunnerMessage + | { type: 'board'; state: BoardState } | { type: 'status'; state: 'compiling' | 'running' | 'stopped' } | { type: 'serverError'; message: string } diff --git a/web/public/devices/AlarmOff.jpg b/web/public/devices/AlarmOff.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0ad8b38e6f76261c1f9f57be279130e0f0a020df GIT binary patch literal 22929 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 A2LJ#7 literal 0 HcmV?d00001 diff --git a/web/public/devices/AlarmOn.jpg b/web/public/devices/AlarmOn.jpg new file mode 100644 index 0000000000000000000000000000000000000000..75c8a4ce2b7fd1cb200ee325dfd9d7ead81add47 GIT binary patch literal 23219 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 A4FCWD literal 0 HcmV?d00001 diff --git a/web/public/devices/LampOn.png b/web/public/devices/LampOn.png new file mode 100644 index 0000000000000000000000000000000000000000..ed6a1ee3e87d83902cf02765e977cf7e98e7032b GIT binary patch literal 26505 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 z {FZA)O^_UO(zEzR;q+qO)nQ(}z76bW(Uxfj04yVUXMV^2`k%KY>mv#pAq-5ImV zC7P;bcXyY~jSafKXXpn^jh&qxCesO4!g*hNPUj0w7pIIvpzFGI(p$Tlwr&X_aQE(A z?%cb@%P&5|?|$)%jKjdj#s*EDn<2Zd!#PJ;xA{&mcNx8|e2w!$DjP3z9um2H^%9TV ze1e;gzMD_{=%@LKPyQI!u3e%Vm6OvY_xJ7*W1y~^+~`#~Hrk5KjSaSUcDS&+OFQxO zLr+M?;ql19;R$>DcR4sXU~g}a*Iv8D(eVLc7`Sum7G7a*?;ZyydrT+UeAbo6G!jDK z-o1Mn3w);_4$k?EwfTy+)OIG53C=lORkL&93fo&-RCUGn&JH`5ZgS(sqg=drf$4Nc zTRSdnZ*uX{B{ny=XzNMd&WtjhPMAy@X46eHva%rlsq9_V@Q0hh-`E|B#!z41xE?m4s z)8sOC-L^dT#1lMn<5B8*!q5jUUEJc48@p_6ZF1?-C9Yk&#@5yj8ylO{bwiAa6a^#~ z<%VJ8{{7p8F=K8q4vb>}9~g%mf-ei^7ee61wJW^)iFc7=;O^~vI44ZnhJ%9xLf-?z zbULM~D#k(H8Rl*U`(LQRzIR{c zVDCQ1$0rQqz%WMo5LqsJy5+z(zxhpGdF6!+-^}xF+N7BQOaSjHk|w(4k`P0l&KL$n za`)@T<`yxIwC$8`*)tA3$#OXZP3zwaArQv#ZPBE+1z_FY-ezNCgMJJwmp#r2J3HGP zAD?n^e1db1s%{v%)7LrcJoP7a!{+8Tm#Jb<@%`HLAJa zJ9W!EO`ak#MrN}OCX*>4Rf!rLPO7~BNQ}C!xpM7ME?m4=0t;mrb0f*-<_6o_TO1u7ad>zL=D2e8D%+de z^g~b7#B?^}_U+p&7YnqM?3asq4ynykRW;K|LsI3{S6>1#CT&|#h};sbDPbvr*gH!| z-urch*358nHj&r18+h+Z(}yzI*kF5Wo7v_Dv)PR6*B@m%-DYQJgU!vBlp-N!GN+uh zhRlkhXRqg6X4~@KvAH>AYkQOabR?z3#f!VV_S!3a1;|EBiq|sEaoGJ zhleyxg?FBaCxp08Je{j@tCk4!`J97;15Ay+UlPK|?K`hAKbhm5M>TQp-aTG^xfBdiV$m36NbaFxnaOLt9 zu3o*$IF4*@&bWAC2j?oAWK)<>i-O<+*2{W$*qS7V{JC-g%9_n`hDuGsfkTgZ+ENcS=3!0n^tuY;0{anM`Qf zmYvNlHYO9E`^Gmo?E{a!>q#zMneyl(*ZKLM|2fnkjf#fIvKyG6E*ZzbXn_z%7AHL+ z1iW}+QiOX9j)aZBCe< zoU(tg&-`@GvRl$E=Y*Ka3&jcYx>ATZhiN9h) zj78SqZ3y9=WA0YDhvAAZ$}PqzC1M!BRhb#pIW#83`P}hp;Jn}p58lI`w}`5Q>t}&G zvX;~OHF;fdzk+_MRWF2CS#lEBrSrE#NdNx<0BRp*>0z-k-~a#s07*qoM6N<$f)$|O AxBvhE literal 0 HcmV?d00001 diff --git a/web/src/App.vue b/web/src/App.vue index 2bc6ddb..3531856 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -21,15 +21,23 @@ const statusType = computed(() => ({ idle: 'default', compiling: 'warning', runn const running = computed(() => state.status === 'compiling' || state.status === 'running') onMounted(async () => { + // 一打开页面就连接服务器,实训台在运行程序之前就能显示和调节 + void session.connect() const list: { id: string; title: string }[] = await (await fetch('/api/samples')).json() samples.value = list.map((s) => ({ label: s.title, value: s.id })) - if (list[0]) await loadSample(list[0].id) + // 网址里带 ?sample=示例编号 时直接打开该示例(便于老师把链接发给学生) + const requested = new URLSearchParams(location.search).get('sample') + const initial = list.find((s) => s.id === requested) ?? list[0] + if (initial) await loadSample(initial.id) }) async function loadSample(id: string) { sampleId.value = id + history.replaceState(null, '', `?sample=${encodeURIComponent(id)}`) files.value = await (await fetch(`/api/samples/${encodeURIComponent(id)}`)).json() - activeFile.value = files.value.find((f) => f.path.endsWith('Form1.cs'))?.path ?? files.value[0]?.path + // 默认打开窗体的代码文件(不一定叫 Form1.cs,如教材的 frm_analog.cs) + activeFile.value = + files.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? files.value[0]?.path } function updateFile(path: string, content: string) { @@ -46,7 +54,7 @@ function run() {
- 物联网编程仿真平台 · 第 0 阶段 + 物联网编程仿真平台
点击"运行"后,学生程序的窗体会显示在这里
- +
{{ d.file }}({{ d.line }},{{ d.column }}) {{ d.code }}:{{ d.message }} @@ -182,7 +190,7 @@ body { .desktop { position: relative; flex: 1; - min-height: 380px; + min-height: 320px; background: #2d6aa3; border-radius: 4px; /* 窗体比桌面区域大时可以滚动查看 */ diff --git a/web/src/run/session.ts b/web/src/run/session.ts index f594bb7..ea32c93 100644 --- a/web/src/run/session.ts +++ b/web/src/run/session.ts @@ -1,9 +1,9 @@ import { reactive, shallowReactive } from 'vue' import type { + BoardState, ClientMessage, ControlNode, Diagnostic, - RunnerInput, ServerMessage, SourceFile, UserAction, @@ -31,23 +31,33 @@ export function createRunSession() { errors: [] as string[], forms: [] as OpenForm[], messageBoxes: [] as PendingMessageBox[], - devices: { ledText: '' }, + /** 虚拟实训台,由服务器推送;连接建立前为 null */ + board: null as BoardState | null, metrics: { compileMs: 0, startMs: 0, lastRoundTripMs: 0 }, }) /** id → 控件节点,patch 按 id 修改节点属性 */ const nodes = shallowReactive(new Map()) let socket: WebSocket | undefined + /** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */ + let connecting: Promise | undefined let actionSentAt = 0 function connect() { - const protocol = location.protocol === 'https:' ? 'wss' : 'ws' - socket = new WebSocket(`${protocol}://${location.host}/ws`) - socket.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage) - socket.onclose = () => { - state.status = 'stopped' - socket = undefined - } - return new Promise((resolve) => socket!.addEventListener('open', () => resolve(), { once: true })) + connecting ??= new Promise((resolve) => { + const protocol = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${protocol}://${location.host}/ws`) + socket = ws + ws.onmessage = (event) => handle(JSON.parse(event.data) as ServerMessage) + ws.onopen = () => resolve() + ws.onclose = () => { + state.status = 'stopped' + state.board = null + socket = undefined + connecting = undefined + resolve() + } + }) + return connecting } function send(message: ClientMessage) { @@ -55,24 +65,32 @@ export function createRunSession() { } async function run(files: SourceFile[]) { - if (!socket) await connect() + await connect() state.diagnostics = [] state.console = '' state.errors = [] state.forms = [] state.messageBoxes = [] - state.devices.ledText = '' nodes.clear() send({ type: 'run', files }) } + /** 调节实训台环境(滑块、传感器开关) */ + function setBoard(input: Omit, 'type'>) { + send({ type: 'boardInput', ...input }) + } + + function resetBoard() { + send({ type: 'boardReset' }) + } + function stop() { send({ type: 'stop' }) } function act(id: string, action: UserAction) { actionSentAt = performance.now() - send({ type: 'action', id, ...action } as RunnerInput) + send({ type: 'action', id, ...action } as ClientMessage) } function replyMessageBox(requestId: number, result: string) { @@ -145,8 +163,8 @@ export function createRunSession() { case 'messageBox': state.messageBoxes.push(message) break - case 'device': - if (message.device === 'led' && message.action === 'text') state.devices.ledText = String(message.data) + case 'board': + state.board = message.state break case 'console': state.console += message.text @@ -164,7 +182,7 @@ export function createRunSession() { } } - return { state, run, stop, act, replyMessageBox } + return { state, connect, run, stop, act, replyMessageBox, setBoard, resetBoard } } export type RunSession = ReturnType diff --git a/web/src/scene/TrainingBoard.vue b/web/src/scene/TrainingBoard.vue index 7498c2e..68cfbb1 100644 --- a/web/src/scene/TrainingBoard.vue +++ b/web/src/scene/TrainingBoard.vue @@ -1,52 +1,536 @@ diff --git a/web/src/winforms/ControlView.vue b/web/src/winforms/ControlView.vue index 8a08dd3..a5fba25 100644 --- a/web/src/winforms/ControlView.vue +++ b/web/src/winforms/ControlView.vue @@ -108,6 +108,26 @@ function selectItem(index: number) {
+ +
+ + {{ displayText(p.Text) }} +
+ + +
+ +
+
+ +
+
diff --git a/web/src/winforms/winforms.css b/web/src/winforms/winforms.css index f6c0f49..0798c67 100644 --- a/web/src/winforms/winforms.css +++ b/web/src/winforms/winforms.css @@ -151,6 +151,55 @@ color: #fff; } +.wf-check { + display: flex; + align-items: center; + gap: 3px; + white-space: pre; + cursor: default; +} + +.wf-checkbox-box, +.wf-radio-dot { + flex: none; + width: 13px; + height: 13px; + box-sizing: border-box; + border: 1px solid #333; + background: #fff; +} + +.wf-radio-dot { + border-radius: 50%; +} + +.wf-checkbox-box[data-checked="true"]::after { + content: "✓"; + display: block; + font: bold 11px/11px sans-serif; + text-align: center; + color: #333; +} + +.wf-radio-dot[data-checked="true"] { + background: radial-gradient(circle, #333 0 3px, #fff 3.5px); +} + +.wf-check:hover:not(.wf-disabled) .wf-checkbox-box, +.wf-check:hover:not(.wf-disabled) .wf-radio-dot { + border-color: #0078d7; +} + +.wf-device-image { + overflow: hidden; +} + +/* 与教材控件一致:图片以原始尺寸从左上角显示 */ +.wf-device-image img { + display: block; + user-select: none; +} + .wf-groupbox-frame { position: absolute; inset: 6px 0 0 0;