第 1 阶段:虚拟实训台,项目 2、3 的示例跑通
- 实训台状态由服务器统一保存:滑块和开关设定传感器读数,推给学生程序; 学生程序控制的灯、继电器、LED 屏回写到实训台 - 网页按教材实训台布局图绘制实训台,传感器卡片上直接调节环境 - 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),Lamp、Alarm - 设备类库:串口未打开时控制无效,与实物一致 - 修正:列表属性序列化时数字被转成字符串 - 示例:项目 2 任务 2–3、项目 3 任务 1–2;网址参数 ?sample= 打开指定示例 - 测试:实训台单元测试,项目 2、3 的端到端测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98be8331bc
commit
26104ae81e
38 files changed
+2346
-77
No files matched your search
@@ -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)、账号和班级、自动评测。
|
||||
@@ -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]);
|
||||
|
||||
/// <summary>控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯),通道号有效时返回 true</summary>
|
||||
/// <summary>
|
||||
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
||||
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>控制 ZigBee 继电器节点 0、1,通道号有效时返回 true</summary>
|
||||
/// <summary>控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace DeviceCtrlLibrary;
|
||||
|
||||
/// <summary>
|
||||
/// 虚拟实训台的设备状态。第 0 阶段先放在运行器进程里、用固定的默认值;
|
||||
/// 第 1 阶段改为向 Bun 服务器上的实训台查询,数值由网页上的滑块和开关控制。
|
||||
/// 学生程序进程里的实训台传感器读数副本。权威状态在 Bun 服务器上(网页滑块和开关控制):
|
||||
/// 启动时从环境变量 SIM_BOARD 读入,之后服务器每次变化都通过标准输入推送过来。
|
||||
/// SetData() 直接读这里,不需要等待网络往返。
|
||||
/// </summary>
|
||||
internal static class TrainingBoard
|
||||
public static class TrainingBoard
|
||||
{
|
||||
/// <summary>ADAM-4017 各通道的读数(Vin0 温度、Vin1 湿度、Vin2 光照……),按教材格式保存为整数</summary>
|
||||
public static readonly ConcurrentDictionary<int, int> 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];
|
||||
|
||||
/// <summary>ADAM-4150 数字量输入(DI0 人体红外、DI1 火焰、DI2 烟雾、DI3 红外对射)</summary>
|
||||
public static readonly ConcurrentDictionary<int, bool> DigitalInputs = new();
|
||||
static TrainingBoard()
|
||||
{
|
||||
var initial = Environment.GetEnvironmentVariable("SIM_BOARD");
|
||||
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
|
||||
}
|
||||
|
||||
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…]}</summary>
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Drawing;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace WinFormsDeviceControlLibrary;
|
||||
|
||||
/// <summary>教材控件库的灯泡:OnOff(true) 显示亮灯图片,OnOff(false) 显示灭灯图片。只改变界面,不控制实训台</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>教材控件库的报警灯:OnOff(true) 显示报警图片。只改变界面,不控制实训台</summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
/// <summary>
|
||||
/// 复选框。用户点击时先切换 Checked(触发 CheckedChanged),再触发 Click——
|
||||
/// 教材项目 3 在 Click 里读取 Checked,依赖这个顺序。
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 单选按钮。同一个容器里的单选按钮互斥。选中一个时,事件顺序与 WinForms 一致:
|
||||
/// 先是原来选中的那个触发 CheckedChanged(变为未选中),再是新选中的触发 CheckedChanged,最后触发 Click。
|
||||
/// 教材项目 3 任务 1 在两个按钮的 CheckedChanged 里分别开灯、关灯,最终状态依赖这个顺序。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new frm_analog());
|
||||
}
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
namespace 模拟量采集
|
||||
{
|
||||
partial class frm_analog
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目2 任务2 实时采集温度、湿度和光照度数据",
|
||||
"source": "教材书 p36–39,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new frm_LED());
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
namespace LED推送模拟量
|
||||
{
|
||||
partial class frm_LED
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目2 任务3 在LED显示屏中显示环境数据",
|
||||
"source": "教材书 p42–45,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
Generated
+140
@@ -0,0 +1,140 @@
|
||||
namespace ZigBee路灯
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目3 任务1 使用ZigBee技术控制路灯开关",
|
||||
"source": "教材书 p53–56,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
namespace 人体红外ZigBee联动
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
/// <summary>
|
||||
/// 必需的设计器变量。
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// 清理所有正在使用的资源。
|
||||
/// </summary>
|
||||
/// <param name="disposing">如果应释放托管资源,为 true;否则为 false。</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows 窗体设计器生成的代码
|
||||
|
||||
/// <summary>
|
||||
/// 设计器支持所需的方法 - 不要
|
||||
/// 使用代码编辑器修改此方法的内容。
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 应用程序的主入口点。
|
||||
/// </summary>
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.Run(new Form1());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "项目3 任务2 实现人体红外传感器和路灯联动",
|
||||
"source": "教材书 p58–61,按书上代码整理并修正印刷错误(控件名以书上表格和代码为准)"
|
||||
}
|
||||
@@ -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<RunnerInput, { type: 'board' }> {
|
||||
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<ClientMessage, { type: 'boardInput' }>) {
|
||||
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<RunnerMessage, { type: 'device' }>) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<ClientMessage, { type: 'boardInput' }>) {
|
||||
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`)
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
@@ -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<T extends RunnerMessage['type']>(
|
||||
type: T,
|
||||
match: (m: Extract<RunnerMessage, { type: T }>) => boolean = () => true,
|
||||
timeoutMs = 15000,
|
||||
): Promise<Extract<RunnerMessage, { type: T }>> {
|
||||
const from = this.messages.length
|
||||
return this.waitFor(type, (m) => this.messages.indexOf(m) >= from && match(m), timeoutMs)
|
||||
}
|
||||
|
||||
/** 等待下一条满足条件的消息(已经收到的也算) */
|
||||
waitFor<T extends RunnerMessage['type']>(
|
||||
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)
|
||||
+58
-1
@@ -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<AnalogKey, { label: string; unit: string; min: number; max: number }> = {
|
||||
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<BoardState['analog']>; digital?: Partial<BoardState['digital']> }
|
||||
/** 实训台复位:关掉所有灯和继电器、清空 LED 屏,传感器恢复默认 */
|
||||
| { type: 'boardReset' }
|
||||
| Exclude<RunnerInput, { type: 'board' }>
|
||||
|
||||
/** 服务器发给浏览器的消息 */
|
||||
export type ServerMessage =
|
||||
| RunnerMessage
|
||||
| { type: 'board'; state: BoardState }
|
||||
| { type: 'status'; state: 'compiling' | 'running' | 'stopped' }
|
||||
| { type: 'serverError'; message: string }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
+13
-5
@@ -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() {
|
||||
<NConfigProvider :locale="zhCN">
|
||||
<div class="page">
|
||||
<header class="toolbar">
|
||||
<strong>物联网编程仿真平台 · 第 0 阶段</strong>
|
||||
<strong>物联网编程仿真平台</strong>
|
||||
<NSelect
|
||||
class="sample-select"
|
||||
:value="sampleId"
|
||||
@@ -95,7 +103,7 @@ function run() {
|
||||
/>
|
||||
<div v-if="state.forms.length === 0" class="desktop-hint">点击"运行"后,学生程序的窗体会显示在这里</div>
|
||||
</div>
|
||||
<TrainingBoard :led-text="state.devices.ledText" />
|
||||
<TrainingBoard :board="state.board" @set="session.setBoard" @reset="session.resetBoard" />
|
||||
<div class="output">
|
||||
<div v-for="(d, i) in state.diagnostics" :key="`d${i}`" :class="['diag', d.severity]">
|
||||
{{ 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;
|
||||
/* 窗体比桌面区域大时可以滚动查看 */
|
||||
|
||||
+34
-16
@@ -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<string, ControlNode>())
|
||||
let socket: WebSocket | undefined
|
||||
/** 正在建立或已建立的连接;同时多处调用 connect() 时共用同一个 */
|
||||
let connecting: Promise<void> | 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<void>((resolve) => socket!.addEventListener('open', () => resolve(), { once: true }))
|
||||
connecting ??= new Promise<void>((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<Extract<ClientMessage, { type: 'boardInput' }>, '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<typeof createRunSession>
|
||||
+526
-19
@@ -1,52 +1,536 @@
|
||||
<script setup lang="ts">
|
||||
// 虚拟实训台。第 0 阶段只有 LED 显示屏,其余设备按布局图逐步加入。
|
||||
defineProps<{ ledText: string }>()
|
||||
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
|
||||
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
|
||||
import { computed, ref } from 'vue'
|
||||
import { ANALOG_INFO, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
|
||||
|
||||
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
|
||||
|
||||
const props = defineProps<{ board: BoardState | null }>()
|
||||
const emit = defineEmits<{ set: [input: BoardInput]; reset: [] }>()
|
||||
|
||||
const MORE_SENSORS: AnalogKey[] = ['airPressure', 'airQuality', 'co2', 'wind']
|
||||
const RELAYS = ['报警灯', '照明灯', '路灯', '插座 1', '插座 2']
|
||||
const DIGITAL_INPUTS: { key: DigitalKey; label: string }[] = [
|
||||
{ key: 'bodyInfrared', label: '人体红外' },
|
||||
{ key: 'fire', label: '火焰' },
|
||||
{ key: 'smoke', label: '烟雾' },
|
||||
{ key: 'infrared', label: '红外对射' },
|
||||
]
|
||||
|
||||
const showMore = ref(false)
|
||||
|
||||
// 拖动滑块时先显示本地值,避免服务器回传前滑块跳动;发送做节流
|
||||
const dragging = ref<Partial<Record<AnalogKey, number>>>({})
|
||||
let pending: Partial<Record<AnalogKey, number>> = {}
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function analog(key: AnalogKey) {
|
||||
return dragging.value[key] ?? props.board?.analog[key] ?? 0
|
||||
}
|
||||
|
||||
function slide(key: AnalogKey, value: number) {
|
||||
dragging.value = { ...dragging.value, [key]: value }
|
||||
pending[key] = value
|
||||
flushTimer ??= setTimeout(flush, 80)
|
||||
}
|
||||
|
||||
function flush() {
|
||||
flushTimer = undefined
|
||||
if (Object.keys(pending).length) emit('set', { analog: pending })
|
||||
pending = {}
|
||||
}
|
||||
|
||||
function release(key: AnalogKey) {
|
||||
flush()
|
||||
const { [key]: _, ...rest } = dragging.value
|
||||
dragging.value = rest
|
||||
}
|
||||
|
||||
function toggle(key: DigitalKey) {
|
||||
if (props.board) emit('set', { digital: { [key]: !props.board.digital[key] } })
|
||||
}
|
||||
|
||||
const digital = computed(() => props.board?.digital ?? { bodyInfrared: false, fire: false, smoke: false, infrared: false })
|
||||
const relays = computed(() => props.board?.relays ?? [])
|
||||
const streetLampOn = computed(() => props.board?.zigbee[1] === true)
|
||||
const alarmOn = computed(() => relays.value[0] === true)
|
||||
const ledText = computed(() => props.board?.ledText ?? '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="board">
|
||||
<div class="board-label">LED 显示屏(COM5)</div>
|
||||
<div class="led-screen">
|
||||
<span v-if="ledText" :key="ledText" class="led-screen-text">{{ ledText }}</span>
|
||||
<div class="board" :class="{ disconnected: !board }">
|
||||
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
||||
<div class="ceiling">
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
||||
红外对射
|
||||
</button>
|
||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
||||
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
||||
</div>
|
||||
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
|
||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
|
||||
<div class="device camera" title="网络摄像头(项目 4 使用)">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22"><path fill="currentColor" d="M4 7h11a2 2 0 0 1 2 2v1.5l3.5-2.5v8L17 13.5V15a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z" /></svg>
|
||||
</div>
|
||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">
|
||||
红外对射
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- LED 显示屏 -->
|
||||
<div class="led-screen" title="LED 显示屏">
|
||||
<span v-if="ledText" :key="ledText" class="led-text">{{ ledText }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 传感器与执行器 -->
|
||||
<div class="sensors">
|
||||
<div class="card" title="照明灯 1:由 ZigBee 继电器节点 1 控制(教材项目 3 的路灯)">
|
||||
<div class="bulb" :class="{ on: streetLampOn }" />
|
||||
<div class="card-title">照明灯 1</div>
|
||||
<div class="card-note">ZigBee 节点 1</div>
|
||||
</div>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.bodyInfrared }" title="点击切换:有人 / 无人" @click="toggle('bodyInfrared')">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon">
|
||||
<circle cx="12" cy="5" r="2.6" fill="currentColor" />
|
||||
<path fill="currentColor" d="M9 9h6l1.5 6h-2l-.8 7h-3.4l-.8-7h-2z" />
|
||||
</svg>
|
||||
<div class="card-title">人体红外</div>
|
||||
<div class="card-note">{{ digital.bodyInfrared ? '有人' : '无人' }}</div>
|
||||
</button>
|
||||
|
||||
<div class="card wide">
|
||||
<div class="card-title">温湿度传感器</div>
|
||||
<label v-for="key in ['temperature', 'humidity'] as AnalogKey[]" :key="key" class="slider">
|
||||
<span>{{ ANALOG_INFO[key].label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="ANALOG_INFO[key].min"
|
||||
:max="ANALOG_INFO[key].max"
|
||||
:value="analog(key)"
|
||||
:disabled="!board"
|
||||
@input="slide(key, Number(($event.target as HTMLInputElement).value))"
|
||||
@change="release(key)"
|
||||
/>
|
||||
<b>{{ analog(key) }}{{ ANALOG_INFO[key].unit }}</b>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<div class="card-title">光照传感器</div>
|
||||
<label class="slider">
|
||||
<span>光照</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="ANALOG_INFO.light.min"
|
||||
:max="ANALOG_INFO.light.max"
|
||||
step="10"
|
||||
:value="analog('light')"
|
||||
:disabled="!board"
|
||||
@input="slide('light', Number(($event.target as HTMLInputElement).value))"
|
||||
@change="release('light')"
|
||||
/>
|
||||
<b>{{ analog('light') }}lx</b>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.smoke }" title="点击切换:有烟雾 / 正常" @click="toggle('smoke')">
|
||||
<div class="smoke" :class="{ on: digital.smoke }"><i /><i /><i /></div>
|
||||
<div class="card-title">烟雾传感器</div>
|
||||
<div class="card-note">{{ digital.smoke ? '有烟雾' : '正常' }}</div>
|
||||
</button>
|
||||
|
||||
<button class="card toggle" :class="{ active: digital.fire }" title="点击切换:有火焰 / 正常" @click="toggle('fire')">
|
||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon flame" :class="{ on: digital.fire }">
|
||||
<path fill="currentColor" d="M12 2c1 4 5 5.5 5 11a5 5 0 0 1-10 0c0-3 1.5-4.5 2.5-6 .5 2 1.5 3 2.5 3-1-3 0-6 0-8z" />
|
||||
</svg>
|
||||
<div class="card-title">火焰传感器</div>
|
||||
<div class="card-note">{{ digital.fire ? '有火焰' : '正常' }}</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 采集模块与串口 -->
|
||||
<div class="modules">
|
||||
<div class="module">
|
||||
<div class="module-title">ADAM-4017+ 模拟量(地址 2)</div>
|
||||
<div class="readings">
|
||||
<span v-for="(key, i) in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key">
|
||||
Vin{{ i }} {{ ANALOG_INFO[key].label }} <b>{{ board?.analog[key] ?? '-' }}</b>
|
||||
</span>
|
||||
<button class="link" @click="showMore = !showMore">{{ showMore ? '收起' : 'Vin3–6 更多传感器' }}</button>
|
||||
</div>
|
||||
<div v-if="showMore" class="more">
|
||||
<label v-for="key in MORE_SENSORS" :key="key" class="slider">
|
||||
<span>{{ ANALOG_INFO[key].label }}</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="ANALOG_INFO[key].min"
|
||||
:max="ANALOG_INFO[key].max"
|
||||
:value="analog(key)"
|
||||
:disabled="!board"
|
||||
@input="slide(key, Number(($event.target as HTMLInputElement).value))"
|
||||
@change="release(key)"
|
||||
/>
|
||||
<b>{{ analog(key) }}{{ ANALOG_INFO[key].unit }}</b>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module">
|
||||
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
||||
<div class="io-row">
|
||||
<span v-for="(input, i) in DIGITAL_INPUTS" :key="input.key" class="io" :class="{ lit: digital[input.key] }">
|
||||
<i />DI{{ i }} {{ input.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="io-row">
|
||||
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i] }">
|
||||
<i />DO{{ i }} {{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="module ports">
|
||||
<div class="module-title">串口服务器</div>
|
||||
<div class="port-list">
|
||||
<span>COM2 → 4017</span><span>COM3 → 4150</span><span>COM4 → ZigBee 协调器</span><span>COM5 → LED 屏</span>
|
||||
</div>
|
||||
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.board {
|
||||
padding: 12px;
|
||||
background: #3b4252;
|
||||
--panel: #2b3340;
|
||||
--card: #364052;
|
||||
--text: #dfe5ee;
|
||||
--muted: #98a3b3;
|
||||
--accent: #4fa3ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--panel);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.board-label {
|
||||
margin-bottom: 6px;
|
||||
color: #d8dee9;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.board.disconnected {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 顶棚 */
|
||||
.ceiling {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
background: #3a4556;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.device {
|
||||
flex: none;
|
||||
padding: 4px 8px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.beam-end {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.beam-end.active {
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.beam {
|
||||
flex: 1;
|
||||
height: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(#0000 5px, #ff4d4d88 5px, #ff4d4d88 7px, #0000 7px);
|
||||
}
|
||||
|
||||
.beam.broken {
|
||||
background: repeating-linear-gradient(90deg, #ff4d4d 0 6px, #0000 6px 12px) center / 100% 2px no-repeat;
|
||||
}
|
||||
|
||||
.beam-tag {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -4px;
|
||||
transform: translateX(-50%);
|
||||
padding: 0 4px;
|
||||
background: #ff4d4d;
|
||||
color: #fff;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.warning-light {
|
||||
background: #6b4a2a;
|
||||
border-color: #8a5a2a;
|
||||
}
|
||||
|
||||
.warning-light.on {
|
||||
background: #ff3b30;
|
||||
border-color: #ff8a80;
|
||||
color: #fff;
|
||||
box-shadow: 0 0 14px 4px #ff3b30aa;
|
||||
animation: blink 0.8s steps(2) infinite;
|
||||
}
|
||||
|
||||
.camera {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* LED 屏 */
|
||||
.led-screen {
|
||||
position: relative;
|
||||
height: 56px;
|
||||
height: 48px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle, rgb(255 255 255 / 0.06) 1px, transparent 1.5px) 0 0 / 4px 4px,
|
||||
#0b0b0b;
|
||||
border: 3px solid #222;
|
||||
border: 3px solid #1a1f27;
|
||||
}
|
||||
|
||||
.led-screen-text {
|
||||
.led-text {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
top: 6px;
|
||||
white-space: pre;
|
||||
color: #ff2a2a;
|
||||
text-shadow: 0 0 6px rgb(255 40 40 / 0.8);
|
||||
font-family: "SimHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 34px;
|
||||
line-height: 40px;
|
||||
font-size: 30px;
|
||||
line-height: 36px;
|
||||
animation: led-scroll 12s linear infinite;
|
||||
}
|
||||
|
||||
/* 传感器 */
|
||||
.sensors {
|
||||
display: grid;
|
||||
/* 照明灯、人体红外、温湿度(2 格)、光照(2 格)、烟雾、火焰排成一行 */
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 74px;
|
||||
padding: 6px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card.wide {
|
||||
grid-column: span 2;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.card.toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card.toggle:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.card.toggle.active {
|
||||
border-color: #ff9f43;
|
||||
background: #4a3d33;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-note {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.active .icon {
|
||||
color: #ffb86b;
|
||||
}
|
||||
|
||||
.flame.on {
|
||||
color: #ff5722;
|
||||
filter: drop-shadow(0 0 6px #ff5722);
|
||||
animation: flicker 0.3s alternate infinite;
|
||||
}
|
||||
|
||||
.bulb {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: #555f6e;
|
||||
border: 2px solid #6d7889;
|
||||
}
|
||||
|
||||
.bulb.on {
|
||||
background: #ffe066;
|
||||
border-color: #fff3b0;
|
||||
box-shadow: 0 0 18px 6px #ffd43b99;
|
||||
}
|
||||
|
||||
.smoke {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
height: 30px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.smoke i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #6d7889;
|
||||
}
|
||||
|
||||
.smoke.on i {
|
||||
background: #c9ced6;
|
||||
animation: rise 1.4s ease-in infinite;
|
||||
}
|
||||
|
||||
.smoke.on i:nth-child(2) {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.smoke.on i:nth-child(3) {
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
.slider {
|
||||
display: grid;
|
||||
grid-template-columns: 3em 1fr 4.5em;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.slider input {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.slider b {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 模块 */
|
||||
.modules {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1.5fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.module {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
background: #3a4556;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.module-title {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.readings,
|
||||
.io-row,
|
||||
.port-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
}
|
||||
|
||||
.readings b {
|
||||
color: #9fd3ff;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.io {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.io i {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #56606f;
|
||||
}
|
||||
|
||||
.io.lit {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.io.lit i {
|
||||
background: #ff9f43;
|
||||
box-shadow: 0 0 6px #ff9f43;
|
||||
}
|
||||
|
||||
.io.out.lit i {
|
||||
background: #51cf66;
|
||||
box-shadow: 0 0 6px #51cf66;
|
||||
}
|
||||
|
||||
.port-list {
|
||||
flex-direction: column;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset {
|
||||
align-self: flex-start;
|
||||
padding: 3px 10px;
|
||||
background: var(--card);
|
||||
border: 1px solid #4a566a;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
@keyframes led-scroll {
|
||||
from {
|
||||
left: 100%;
|
||||
@@ -57,4 +541,27 @@ defineProps<{ ledText: string }>()
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flicker {
|
||||
to {
|
||||
transform: scale(1.08, 0.94);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(-18px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -108,6 +108,26 @@ function selectItem(index: number) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- CheckBox / RadioButton:自绘勾选框,状态完全由运行器决定(点击只上报,不在浏览器里自行切换) -->
|
||||
<div
|
||||
v-else-if="node.type === 'CheckBox' || node.type === 'RadioButton'"
|
||||
class="wf-control wf-check"
|
||||
:class="{ 'wf-disabled': !enabled, 'wf-autosize': p.AutoSize === true }"
|
||||
:style="style"
|
||||
@click="act({ action: 'click' })"
|
||||
>
|
||||
<span :class="node.type === 'CheckBox' ? 'wf-checkbox-box' : 'wf-radio-dot'" :data-checked="p.Checked === true" />
|
||||
<span class="wf-check-text">{{ displayText(p.Text) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 教材控件库:灯泡、报警灯(图片沿用教材控件库) -->
|
||||
<div v-else-if="node.type === 'WinFormsDeviceControlLibrary.Lamp'" class="wf-control wf-device-image" :style="style">
|
||||
<img :src="p.IsOn ? '/devices/LampOn.png' : '/devices/LampOff.png'" alt="" draggable="false" />
|
||||
</div>
|
||||
<div v-else-if="node.type === 'WinFormsDeviceControlLibrary.Alarm'" class="wf-control wf-device-image" :style="style">
|
||||
<img :src="p.IsOn ? '/devices/AlarmOn.jpg' : '/devices/AlarmOff.jpg'" alt="" draggable="false" />
|
||||
</div>
|
||||
|
||||
<!-- GroupBox -->
|
||||
<div v-else-if="node.type === 'GroupBox'" class="wf-control wf-groupbox" :style="style">
|
||||
<div class="wf-groupbox-frame" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user