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 0000000..0ad8b38 Binary files /dev/null and b/web/public/devices/AlarmOff.jpg differ diff --git a/web/public/devices/AlarmOn.jpg b/web/public/devices/AlarmOn.jpg new file mode 100644 index 0000000..75c8a4c Binary files /dev/null and b/web/public/devices/AlarmOn.jpg differ diff --git a/web/public/devices/LampOff.png b/web/public/devices/LampOff.png new file mode 100644 index 0000000..6d2d998 Binary files /dev/null and b/web/public/devices/LampOff.png differ diff --git a/web/public/devices/LampOn.png b/web/public/devices/LampOn.png new file mode 100644 index 0000000..ed6a1ee Binary files /dev/null and b/web/public/devices/LampOn.png differ 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;