实训台接线:串口要选对。接线做成数据(BoardState.wiring),默认按教材布局图 COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,实验可在 meta.json 的 wiring 里改:项目 7 的书上代码在一个串口上打开两个模块,这三个实验把 4150 并到 COM2 的 485 总线上。选错串口时返回值照反编译的真机版 dll(4017 全 null、4150 全 false,4150 的 OnOff 和 LED 推送返回成功但设备不动作,ZigBee 返回 false);串口没打开时照教材仿真版。Open / Close / 没打开就读写报告给服务器,实训台最上方用红字提示("程序在 COM2 上打开 ADAM-4150,它接在 COM3""ADAM-4150 的串口没有打开,程序却在读写它"),评分不通过时也附上。只读接线展示:鼠标移到实训台设备上,标题栏写出线路,对应通道、模块、串口高亮;串口服务器按接线列出设备,程序打开的口亮绿点;程序里串口下拉框的灰字按当前接线。打开实验时浏览器发 experiment 消息换接线,老师看学生提交时按作业的实验接线(提交接口返回 sampleId)。"4017"统一写成 ADAM-4017+
上课前逐个检查 23 个实验后的修改:补上三处评分漏洞(项目 3 任务 1、项目 4 任务 3 的关闭和项目 7 任务 2 的熄灯,程序什么都不做也能通过);评分说明的空格;任务说明按实测补充(项目 2 任务 1–3、项目 3 任务 2、项目 4 任务 2、项目 7 任务 1–3、项目 8 任务 5)。使用说明、设计方案同步,设计方案记下以后可能扩展"物联网设备安装与调试"课 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
163e553de4
commit
46e0f40cce
35 files changed
+633
-94
No files matched your search
+13
-5
@@ -51,11 +51,12 @@
|
|||||||
|
|
||||||
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
- 学生自己的窗体(Windows 风格)和虚拟实训台同时显示在页面上。
|
||||||
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
- 实训台上可以互动:拖动滑块调节温度、湿度、光照,点击切换人体红外、烟雾、火焰、红外对射,立刻看到学生程序的反应。
|
||||||
|
- 实训台的接线只看、不能改:鼠标移到设备上,标题栏写出整条线路(如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3"),接的通道和串口高亮;学生自己接线属于"物联网设备安装与调试"课,见路线图第 4 阶段。
|
||||||
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
- 编译错误和运行异常用中文提示,并标出行号;书上常见的印刷错误(如 `True` 大写)给出针对性提示。
|
||||||
|
|
||||||
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
|
**还原真实行为**:在后台线程里直接修改控件,会抛出和 VS 中按 F5 调试时一样的"线程间操作无效"异常;单选按钮、复选框、下拉框的事件顺序与真实 WinForms 一致。
|
||||||
|
|
||||||
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤)。
|
**教材要求的运行能力**:多窗体(`Show`、`Hide`、`Close`,项目 1 的登录程序就用到);控制台输出面板(`Console.WriteLine`);虚拟 COM 口(`SerialPort.GetPortNames()` 返回 COM2–COM5,对应实训台的四台设备,保留"选串口、点打开"的操作步骤;串口要选对,见"设备类库设计"的接线)。
|
||||||
|
|
||||||
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
|
**导入与导出 VS 工程**:设计器能读懂 VS 生成的 `Designer.cs`,教材配套的 19 个参考工程可直接导入,作为初始模板和参考答案;学生作品也能导出为 VS 工程,在机房真机上运行。
|
||||||
|
|
||||||
@@ -97,7 +98,10 @@ private void btn_Send_Click(object sender, EventArgs e)
|
|||||||
兼容细节:
|
兼容细节:
|
||||||
|
|
||||||
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
|
- 数值一律返回整数字符串(项目 7 用 `Convert.ToInt16` 转换)。
|
||||||
- 4017 和 4150 允许打开同一个串口;允许重复调用 `Open`。
|
- **串口要选对**(按实训台接线):接线按教材布局图,ADAM-4017+、ADAM-4150 各经一个 485/232 转换器接串口服务器,即 COM2 4017+、COM3 4150、COM4 ZigBee 协调器、COM5 LED 屏(`shared/protocol.ts` 的 `DEFAULT_WIRING`)。实验可以在 `meta.json` 的 `wiring` 里改:项目 7 的书上代码在一个串口上打开两个模块(书上截图是用仿真版 dll 截的,选哪个口都行),这三个实验把 4150 并到 COM2 的 485 总线上(两个模块地址不同,真机上也能这样接)。
|
||||||
|
- 在没接这台设备的串口上打开它,返回值照真机版 dll(已反编译核对):`Open` 不报错;4017 的 `SetData()` 全是 null、4150 的全是 false;4150 的 `OnOff` 只管发命令、不等回应,返回 true 但继电器不动作;ZigBee 的 `OnOff` 收不到回应,返回 false;LED 的 `SendLedString` 原样返回文字但屏上不显示。真机上这只是"没反应",平台另外在实训台最上方用红字提示"程序在 COM2 上打开 ADAM-4150,它接在 COM3",评分不通过时也附上这句。
|
||||||
|
- 串口没打开(或已经 `Close`)时程序还在读数据、控制设备:返回值照教材仿真版 dll(4150 的 `SetData()` 返回各项为 false 的对象,不是真机版的 null;书上代码是照仿真版写的,返回 null 会让学生看到一个指不出原因的空引用异常),实训台同样用红字提示"ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM3)",评分说明也附上。学生忘了点"打开"时一眼能看出原因。
|
||||||
|
- 允许重复调用 `Open`(项目 7 任务 3 在定时器里每秒打开一次)。
|
||||||
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
- 串口没有打开时,`OnOff` 控制无效、返回 false。教材仿真版不检查这一点,平台按实物行为处理:学生忘了"打开",窗体上的灯会亮而实训台上的灯不亮,正好暴露问题。
|
||||||
- 摄像头事件在非界面线程触发,与真实行为一致。
|
- 摄像头事件在非界面线程触发,与真实行为一致。
|
||||||
|
|
||||||
@@ -224,7 +228,7 @@ IOT/
|
|||||||
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
- **常见错误提示**:学生照抄书上的错误(如 `True` / `False` 大写、`Catch(EventArgs ex)`)时给出针对性中文提示。
|
||||||
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
- **评测规则**:书上评价表的功能项转为自动检查,如"把光照调到 300,2 秒内照明灯应打开"。
|
||||||
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
- **扩展练习**:书上"边想边做"和"自主探究"约 30 道,其中十余道是填空代码题,可直接做成填空练习。
|
||||||
- **场景配置**:初始环境值、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
- **场景配置**:初始环境值、设备接在哪个串口(`meta.json` 的 `wiring`,已实现)、传感器与通道的对应关系、虚拟时钟起始时刻、预置数据表和样例数据。
|
||||||
|
|
||||||
老师不写代码也能修改或新增实验。
|
老师不写代码也能修改或新增实验。
|
||||||
|
|
||||||
@@ -238,7 +242,7 @@ flowchart TB
|
|||||||
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
P1["阶段 1 · MVP(已完成 2026-10-22)<br/>虚拟实训台 ✔、代码编辑器 ✔、窗体设计器 ✔、多窗体 ✔<br/>教材示例:项目 1–3 全部 ✔,项目 4–5 中平台已支持的 3 个任务 ✔<br/>部署(沙箱、Docker Compose、GitHub Actions)✔"]
|
||||||
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
|
P2["阶段 2 · 教学闭环(功能已完成,待一个班试用)<br/>账号与班级 ✔、作品保存在服务器 ✔、自动评分 ✔、布置与提交作业 ✔<br/>评阅(看代码、运行、老师评分、评语)✔、成绩表与导出 ✔<br/>一个班试用(2026-10-26 已部署上线,待试用)"]
|
||||||
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
|
P3["阶段 3 · 扩展(功能已完成 2026-10-23)<br/>教材项目 4–8:摄像头 ✔、线程 ✔、SQLite 模拟数据库 ✔、虚拟时钟 ✔、图表 ✔<br/>教材 8 个项目共 23 个示例 ✔、任务说明 ✔"]
|
||||||
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程"]
|
P4["阶段 4 · 进阶(之后)<br/>校园街景场景、故障注入题库、导出 VS 工程<br/>可能扩展:物联网设备安装与调试课(学生自己接线、配置设备、排故)"]
|
||||||
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
P0 -->|"关口:链路跑通,延迟和资源占用可接受"| P1
|
||||||
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
P1 -->|"关口:教材项目 1–3 能在平台上完整做完"| P2
|
||||||
P2 -->|"关口:一个班试用顺利"| P3
|
P2 -->|"关口:一个班试用顺利"| P3
|
||||||
@@ -249,6 +253,8 @@ flowchart TB
|
|||||||
|
|
||||||
**当前状态(2026-10-26)**:第 1–3 阶段的功能已全部完成,平台已部署到线上服务器(GitHub Actions 自动部署)。下一步是第 2 阶段的关口:带一个班试用,按试用中暴露的问题修改,之后再考虑第 4 阶段。
|
**当前状态(2026-10-26)**:第 1–3 阶段的功能已全部完成,平台已部署到线上服务器(GitHub Actions 自动部署)。下一步是第 2 阶段的关口:带一个班试用,按试用中暴露的问题修改,之后再考虑第 4 阶段。
|
||||||
|
|
||||||
|
**可能的扩展:物联网设备安装与调试课**(2026-10-28 讨论,还没定开不开)。那门课的核心是接线(传感器信号线、电源、485 的 A/B 线)、用厂家工具配置设备(ADAM 地址、波特率、4017+ 量程,ZigBee 网络号)、用串口调试助手收发报文、排故,和本课"不学协议、不接线"正好相反。仿真替代不了拧螺丝,但适合预习、在家练、接错不烧设备,尤其是老师设故障让学生找的**排故练习**(和"故障注入题库"是一回事)。现在为它做的准备是把接线做成数据(`BoardState.wiring`),本课用实验预设的接线、只读显示;将来那门课在同一份数据上让学生改接线,再加上通道接线、电源、配置工具。开工前要先拿到那门课的教材和实训台资料。
|
||||||
|
|
||||||
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
**第 0 阶段结果(2026-10-22,已完成)**:教材项目 2 任务 1 的书上代码一字未改,在网页中完整运行:选串口、打开、推送文字到 LED 屏,未打开串口时弹出 "Error:发送失败"。本机实测(16 核、28 GB):
|
||||||
|
|
||||||
| 指标 | 结果 |
|
| 指标 | 结果 |
|
||||||
@@ -335,6 +341,8 @@ flowchart TB
|
|||||||
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
|
- 2026-10-26:代码用 **bun fmt** 格式化,格式写在 `bunfig.toml`(不写分号、单引号、一行最长 160,和原来的写法一致);`docs/教材摘要/` 是书上的摘录,不格式化。
|
||||||
- 2026-10-28:**兼容机房的 Chrome 94**。用 Chromium 94 实测,打开 http 地址时实验页报 `crypto.randomUUID is not a function`(这个函数只在 https 和 localhost 下有);还时好时坏地一片空白:Chrome 94 在 Promise 回调里调用 `import()`(Vite 的预加载就是这样)时,有时把 `import("./X.js")` 相对于网页地址解析,请求 `/X.js` 拿到首页 HTML。改法:打包目标改成 Chrome / Edge 94;`web/src/polyfills.ts` 补上 `crypto.randomUUID`、`structuredClone`(只复制普通数据)、`findLast` / `findLastIndex`;`vite.config.ts` 的小插件在 Vite 找完预加载依赖之后把代码块之间的 `import("./X.js")` 改成 `import("/assets/X.js")`,改过的代码块换新文件名(不用缓存里的旧文件);容器查询(Chrome 105)改成 ResizeObserver 量宽度加 class(实训台的三档布局、设计器窄时隐藏提示文字);`:has()`(Chrome 105)改成 class(只读和禁用的文本框灰底、没标题的分组框)。Monaco 自带的几条 `:has()`、`color-mix()` 样式在 Chrome 94 里不生效,只影响 Markdown 悬停提示里的复选框等细节。实测 Chrome 94 经 http 打开登录、实验页(设计器改属性、代码补全、运行程序)、数据库、班级管理、使用说明,各页面直接打开 5 遍都正常。
|
- 2026-10-28:**兼容机房的 Chrome 94**。用 Chromium 94 实测,打开 http 地址时实验页报 `crypto.randomUUID is not a function`(这个函数只在 https 和 localhost 下有);还时好时坏地一片空白:Chrome 94 在 Promise 回调里调用 `import()`(Vite 的预加载就是这样)时,有时把 `import("./X.js")` 相对于网页地址解析,请求 `/X.js` 拿到首页 HTML。改法:打包目标改成 Chrome / Edge 94;`web/src/polyfills.ts` 补上 `crypto.randomUUID`、`structuredClone`(只复制普通数据)、`findLast` / `findLastIndex`;`vite.config.ts` 的小插件在 Vite 找完预加载依赖之后把代码块之间的 `import("./X.js")` 改成 `import("/assets/X.js")`,改过的代码块换新文件名(不用缓存里的旧文件);容器查询(Chrome 105)改成 ResizeObserver 量宽度加 class(实训台的三档布局、设计器窄时隐藏提示文字);`:has()`(Chrome 105)改成 class(只读和禁用的文本框灰底、没标题的分组框)。Monaco 自带的几条 `:has()`、`color-mix()` 样式在 Chrome 94 里不生效,只影响 Markdown 悬停提示里的复选框等细节。实测 Chrome 94 经 http 打开登录、实验页(设计器改属性、代码补全、运行程序)、数据库、班级管理、使用说明,各页面直接打开 5 遍都正常。
|
||||||
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
|
- 2026-10-28:**运行中改了自动重新运行**。以前改了代码或设计器,运行区的窗体不变,要按 F5 才看得到。现在程序运行时(`WorkbenchPage.vue`)每次修改后等 1.5 秒没有新的改动,先用语言服务检查整个工程(和边写边检查同一个接口,不生成程序集):没有错误就重新编译运行;有错误就不重启,原来的程序接着运行,工具栏提示"代码有错误,改好后自动重新运行"。检查期间又改了、程序已经停了或换了工程,这次作废。只在运行中触发,停止后改动不会自动启动程序。
|
||||||
|
- 2026-10-28:**实训台接线**。讨论"ADAM 模块和传感器的虚拟连线"后决定:本课学的是 C# 界面和事件,学生不自己接线(那是安装调试课的内容,见路线图第 4 阶段),只做两件事。①**串口要选对**:以前选哪个 COM 口都能控制设备,现在按接线判断(接线随实验,项目 7 把 4150 并到 COM2),选错了返回值照反编译的真机版 dll,设备没反应;C# 端 `TrainingBoard.Reaches` 判断,`Open` / `Close` 报告给服务器(`adam4150/open` 等,没变不发),`BoardState.opened` 记下程序打开的串口,实训台、评分说明据此提示打错的串口。打开实验时浏览器发 `experiment` 消息,服务器按示例的 `meta.json` 换接线;老师看学生提交时也按作业的实验接线。②**只读接线展示**:鼠标移到实训台的设备上,标题栏写出线路、对应通道和串口高亮;串口服务器按接线列出每个口接的设备,程序打开的口亮绿点;程序里串口下拉框的灰字也按当前接线。顺带把"4017"统一写成 ADAM-4017+(代码里的 `adam4017Helper` 是教材 dll 的名字,不改)。另外核对真机版发现:4150 串口没打开时真机版 `SetData()` 返回 null(书上代码会出空引用异常),而书上照着写的仿真版返回全 false;平台保持全 false,改为在实训台和评分说明里提示"串口没有打开,程序却在读写它"(`adam4150/unopened` 等,每次打开后最多报一次)。
|
||||||
|
- 2026-10-28:**上课前检查 8 个项目**。23 个实验逐个在浏览器里照任务说明操作、"自己检查",全部通过。修了:①日期选择框在机房 Chrome 94 上点了没反应(项目 6 任务 3、项目 8 任务 5 选不了日期):以前靠 `showPicker()` 弹出,它 Chrome 99 才正式有,Chrome 94 的原型上有这个方法、调用却没反应;改成把浏览器的日期框透明地盖在下拉按钮上、日历图标撑满按钮,点按钮由浏览器自己弹出(Chromium 94 和新版 Chrome 实测都能选日期)。②运行时的长日期多了星期(2026年10月28日星期三,框窄被截掉):Linux 的 zh-CN 长日期带星期,中文 Windows 不带,运行器改成 Windows 的写法。③评分漏洞:项目 3 任务 1、项目 4 任务 3 的"能关闭"和项目 7 任务 2 的"熄灯模式关灯",程序什么都不做也能通过,补上先确认灯亮过。④串口红字提示挪到实训台最上方(原来在底部,要往下滚)。⑤评分说明的空格("期望 6 秒内""照明灯 1 打开")。⑥任务说明按实测补充:项目 2 任务 1(事件窗口叫"单击",先复位清 LED 屏)、项目 2 任务 2 和项目 3 任务 2 的控件表、项目 2 任务 3 的 `ledHelper.Close()`、项目 3 任务 2 的"计时到"、项目 4 任务 2(IP 不能空、可以放大)、项目 7 任务 1 第 5 步、项目 7 任务 3 的预约步骤和开关第一次点没反应、项目 8 任务 5 先恢复实验数据。没改的平台限制:`Control.Focus()` 不移动焦点,密码框显示圆点不是星号。
|
||||||
|
|
||||||
## 技术风险与待确认事项
|
## 技术风险与待确认事项
|
||||||
|
|
||||||
@@ -356,7 +364,7 @@ flowchart TB
|
|||||||
|
|
||||||
**待确认**
|
**待确认**
|
||||||
|
|
||||||
- [ ] 串口是否要求接对:目前学生选任意 COM 口都能控制设备;是否在部分实验中要求按实训台接线(COM2 4017、COM3 4150、COM4 ZigBee、COM5 LED)
|
- [x] 串口要求接对:按实训台接线(COM2 ADAM-4017+、COM3 ADAM-4150、COM4 ZigBee 协调器、COM5 LED 屏,项目 7 的 4150 并在 COM2),选错和真机一样没反应,实训台上提示
|
||||||
- [ ] 虚拟实训台的画面细节(目前为第一版)
|
- [ ] 虚拟实训台的画面细节(目前为第一版)
|
||||||
- [x] 第一个场景:智慧校园(和教材一致)
|
- [x] 第一个场景:智慧校园(和教材一致)
|
||||||
- [x] 设备写法:兼容教材类库
|
- [x] 设备写法:兼容教材类库
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using DeviceCtrlLibrary.Model;
|
using DeviceCtrlLibrary.Model;
|
||||||
using Sim.Host;
|
using Sim.Host;
|
||||||
@@ -7,7 +8,8 @@ namespace DeviceCtrlLibrary;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
|
/// 教材的设备控制入口:DeviceCtrlHelper.ledHelper.SendLedString(...) 等。
|
||||||
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数。
|
/// 返回值约定与教材仿真版 dll 保持一致;不同之处是数值来自虚拟实训台,而不是随机数,
|
||||||
|
/// 并且和真机版一样,设备要在它接的那个串口上打开才有反应(接线见 TrainingBoard)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class DeviceCtrlHelper
|
public class DeviceCtrlHelper
|
||||||
{
|
{
|
||||||
@@ -20,6 +22,38 @@ public class DeviceCtrlHelper
|
|||||||
public static IPCameraHelper ipCameraHelper = new();
|
public static IPCameraHelper ipCameraHelper = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 告诉实训台程序在哪个串口上打开、关闭了设备,以及串口没打开时还在读写设备(网页上据此提示)。
|
||||||
|
/// 没变就不发:书上有在定时器里反复 Open 的写法;没打开时的读写每次打开之后最多报一次
|
||||||
|
/// </summary>
|
||||||
|
internal static class PortReport
|
||||||
|
{
|
||||||
|
private static readonly HashSet<string> Unopened = new();
|
||||||
|
|
||||||
|
public static string? Open(string device, string? current, string? portName)
|
||||||
|
{
|
||||||
|
lock (Unopened) Unopened.Remove(device);
|
||||||
|
if (current != portName) SimHost.SendDevice(device, "open", portName ?? "");
|
||||||
|
return portName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? Close(string device, string? current)
|
||||||
|
{
|
||||||
|
if (current is not null) SimHost.SendDevice(device, "close", null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>串口没打开(portName 为空)时返回 true,并报告一次</summary>
|
||||||
|
public static bool NotOpen(string device, string? portName)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(portName)) return false;
|
||||||
|
bool first;
|
||||||
|
lock (Unopened) first = Unopened.Add(device);
|
||||||
|
if (first) SimHost.SendDevice(device, "unopened", null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public class SerialPortHelper
|
public class SerialPortHelper
|
||||||
{
|
{
|
||||||
private string? _portName;
|
private string? _portName;
|
||||||
@@ -48,18 +82,21 @@ public class LEDHelper
|
|||||||
private const string SendFailed = "Error:发送失败";
|
private const string SendFailed = "Error:发送失败";
|
||||||
private string? _portName;
|
private string? _portName;
|
||||||
|
|
||||||
public void OpenLedCom(string portName) => _portName = portName;
|
public void OpenLedCom(string portName) => _portName = PortReport.Open("led", _portName, portName);
|
||||||
|
|
||||||
/// <summary>成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"</summary>
|
/// <summary>
|
||||||
|
/// 成功时原样返回发送的文字,并显示到实训台的 LED 屏上;串口未打开时返回 "Error:发送失败"。
|
||||||
|
/// 打开的串口上没接 LED 屏时和真机一样也原样返回(真机只管发出去、不等回应),只是屏上不显示
|
||||||
|
/// </summary>
|
||||||
public string SendLedString(string sendString)
|
public string SendLedString(string sendString)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return SendFailed;
|
if (PortReport.NotOpen("led", _portName)) return SendFailed;
|
||||||
SimHost.SendDevice("led", "text", sendString);
|
if (TrainingBoard.Reaches("led", _portName)) SimHost.SendDevice("led", "text", sendString);
|
||||||
return sendString;
|
return sendString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留空方法让书上代码能编译</summary>
|
/// <summary>教材 dll 里没有这个方法,但书上项目 2 任务 3 调用了它;保留它让书上代码能编译</summary>
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("led", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ADAM4017Helper
|
public class ADAM4017Helper
|
||||||
@@ -70,19 +107,19 @@ public class ADAM4017Helper
|
|||||||
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
|
public void Open(string PortName, int Temperature_Port = 0, int Humidity_Port = 1, int Light_Port = 2,
|
||||||
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
|
int AirPressure_Port = 3, int AirQuality_Port = 4, int CO2_Port = 5, int Wind_Port = 6)
|
||||||
{
|
{
|
||||||
_portName = PortName;
|
_portName = PortReport.Open("adam4017", _portName, PortName);
|
||||||
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
|
_channels = new[] { Temperature_Port, Humidity_Port, Light_Port, AirPressure_Port, AirQuality_Port, CO2_Port, Wind_Port };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
|
/// 读取各通道数值。数值一律是整数字符串(教材项目 7 用 Convert.ToInt16 转换,带小数会出错);
|
||||||
/// 串口未打开时所有属性为 null,与教材仿真版一致。
|
/// 串口未打开时所有属性为 null,与教材仿真版一致;打开的串口上没接 4017+ 时收不到回应,和真机版一样也全是 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public ADAM4017ResModel SetData()
|
public ADAM4017ResModel SetData()
|
||||||
{
|
{
|
||||||
LegacyThreads.CheckPoint();
|
LegacyThreads.CheckPoint();
|
||||||
var model = new ADAM4017ResModel();
|
var model = new ADAM4017ResModel();
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return model;
|
if (PortReport.NotOpen("adam4017", _portName) || !TrainingBoard.Reaches("adam4017", _portName)) return model;
|
||||||
model.temperatureValue = Read(0);
|
model.temperatureValue = Read(0);
|
||||||
model.humidityValue = Read(1);
|
model.humidityValue = Read(1);
|
||||||
model.lightValue = Read(2);
|
model.lightValue = Read(2);
|
||||||
@@ -95,7 +132,7 @@ public class ADAM4017Helper
|
|||||||
|
|
||||||
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
|
private string Read(int quantity) => TrainingBoard.ReadAnalog(_channels[quantity]).ToString();
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("adam4017", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ADAM4150Helper
|
public class ADAM4150Helper
|
||||||
@@ -105,16 +142,16 @@ public class ADAM4150Helper
|
|||||||
|
|
||||||
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
|
public void Open(string PortName, int BodyInfrared_Port = 0, int Fire_Port = 1, int Smoke_Port = 2, int Infrared_Port = 3)
|
||||||
{
|
{
|
||||||
_portName = PortName;
|
_portName = PortReport.Open("adam4150", _portName, PortName);
|
||||||
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
|
_channels = new[] { BodyInfrared_Port, Fire_Port, Smoke_Port, Infrared_Port };
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开时全部为 false</summary>
|
/// <summary>读取人体红外、火焰、烟雾、红外对射;串口未打开、或打开的串口上没接 4150(收不到回应)时全部为 false</summary>
|
||||||
public ADAM4150ResModel SetData()
|
public ADAM4150ResModel SetData()
|
||||||
{
|
{
|
||||||
LegacyThreads.CheckPoint();
|
LegacyThreads.CheckPoint();
|
||||||
var model = new ADAM4150ResModel();
|
var model = new ADAM4150ResModel();
|
||||||
if (string.IsNullOrWhiteSpace(_portName)) return model;
|
if (PortReport.NotOpen("adam4150", _portName) || !TrainingBoard.Reaches("adam4150", _portName)) return model;
|
||||||
model.bodyInfraredValue = Read(0);
|
model.bodyInfraredValue = Read(0);
|
||||||
model.fireValue = Read(1);
|
model.fireValue = Read(1);
|
||||||
model.smokeValue = Read(2);
|
model.smokeValue = Read(2);
|
||||||
@@ -126,31 +163,35 @@ public class ADAM4150Helper
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
/// 控制继电器 DO0–DO4(DO0 报警灯、DO1 照明灯、DO2 路灯、DO3/DO4 插座)。
|
||||||
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物)。
|
/// 和真实设备一样,串口没有打开时控制无效、返回 false(教材仿真版不检查,这里更贴近实物);
|
||||||
|
/// 真机版只管发出命令、不等回应,所以打开的串口上没接 4150 时也返回 true,只是继电器不动作。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool OnOff(int DO, bool IsOpen)
|
public bool OnOff(int DO, bool IsOpen)
|
||||||
{
|
{
|
||||||
if (DO < 0 || DO > 4 || string.IsNullOrWhiteSpace(_portName)) return false;
|
if (DO < 0 || DO > 4 || PortReport.NotOpen("adam4150", _portName)) return false;
|
||||||
SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
|
if (TrainingBoard.Reaches("adam4150", _portName)) SimHost.SendDevice("adam4150", "do", new[] { DO, IsOpen ? 1 : 0 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("adam4150", _portName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ZigBeeHelper
|
public class ZigBeeHelper
|
||||||
{
|
{
|
||||||
private string? _portName;
|
private string? _portName;
|
||||||
|
|
||||||
public void Open(string PortName) => _portName = PortName;
|
public void Open(string PortName) => _portName = PortReport.Open("zigbee", _portName, PortName);
|
||||||
|
|
||||||
/// <summary>控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false</summary>
|
/// <summary>
|
||||||
|
/// 控制 ZigBee 继电器节点 0、1(教材用节点 1 控制路灯);串口没有打开时控制无效、返回 false。
|
||||||
|
/// 真机版要等协调器回应,打开的串口上没接协调器时收不到回应,也返回 false
|
||||||
|
/// </summary>
|
||||||
public bool OnOff(int RelayNum, bool IsOpen)
|
public bool OnOff(int RelayNum, bool IsOpen)
|
||||||
{
|
{
|
||||||
if (RelayNum < 0 || RelayNum > 1 || string.IsNullOrWhiteSpace(_portName)) return false;
|
if (RelayNum < 0 || RelayNum > 1 || PortReport.NotOpen("zigbee", _portName) || !TrainingBoard.Reaches("zigbee", _portName)) return false;
|
||||||
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
|
SimHost.SendDevice("zigbee", "relay", new[] { RelayNum, IsOpen ? 1 : 0 });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Close() => _portName = null;
|
public void Close() => _portName = PortReport.Close("zigbee", _portName);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
namespace DeviceCtrlLibrary;
|
namespace DeviceCtrlLibrary;
|
||||||
@@ -15,6 +16,14 @@ public static class TrainingBoard
|
|||||||
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
|
private static int[] _analog = { 25, 60, 500, 101, 60, 600, 3, 0 };
|
||||||
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
|
// ADAM-4150 DI0–DI6(人体红外、火焰、烟雾、红外对射、预留)
|
||||||
private static bool[] _digital = new bool[7];
|
private static bool[] _digital = new bool[7];
|
||||||
|
// 接线:设备 → 串口(教材布局图的接法,随实验而定,见 shared/protocol.ts 的 DEFAULT_WIRING)
|
||||||
|
private static readonly Dictionary<string, string> Wiring = new()
|
||||||
|
{
|
||||||
|
["adam4017"] = "COM2",
|
||||||
|
["adam4150"] = "COM3",
|
||||||
|
["zigbee"] = "COM4",
|
||||||
|
["led"] = "COM5",
|
||||||
|
};
|
||||||
|
|
||||||
static TrainingBoard()
|
static TrainingBoard()
|
||||||
{
|
{
|
||||||
@@ -22,7 +31,7 @@ public static class TrainingBoard
|
|||||||
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
|
if (!string.IsNullOrEmpty(initial) && JsonNode.Parse(initial) is JsonObject json) Update(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…],"clock":…}(clock 不带时表示不变)</summary>
|
/// <summary>平台内部使用:应用服务器推送的 {"analog":[…],"digital":[…],"clock":…,"wiring":{…}}(clock、wiring 不带时表示不变)</summary>
|
||||||
public static void Update(JsonObject json)
|
public static void Update(JsonObject json)
|
||||||
{
|
{
|
||||||
if (json.ContainsKey("clock")) Sim.Host.SimClock.Update(json["clock"]);
|
if (json.ContainsKey("clock")) Sim.Host.SimClock.Update(json["clock"]);
|
||||||
@@ -32,9 +41,22 @@ public static class TrainingBoard
|
|||||||
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
|
for (var i = 0; i < analog.Count && i < _analog.Length; i++) _analog[i] = (int?)analog[i] ?? 0;
|
||||||
if (json["digital"] is JsonArray digital)
|
if (json["digital"] is JsonArray digital)
|
||||||
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
|
for (var i = 0; i < digital.Count && i < _digital.Length; i++) _digital[i] = (bool?)digital[i] ?? false;
|
||||||
|
if (json["wiring"] is JsonObject wiring)
|
||||||
|
foreach (var (device, port) in wiring)
|
||||||
|
if (Wiring.ContainsKey(device) && port is not null) Wiring[device] = (string)port!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 程序打开的串口上有没有接这台设备。和真机一样,串口打开错了不报错,只是设备收不到命令、也不回应。
|
||||||
|
/// Windows 的串口名不分大小写
|
||||||
|
/// </summary>
|
||||||
|
internal static bool Reaches(string device, string? portName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(portName)) return false;
|
||||||
|
lock (Lock) return Wiring.TryGetValue(device, out var port) && string.Equals(port, portName.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
internal static int ReadAnalog(int channel)
|
internal static int ReadAnalog(int channel)
|
||||||
{
|
{
|
||||||
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
|
lock (Lock) return channel >= 0 && channel < _analog.Length ? _analog[channel] : 0;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
1. 平台已经引用好了 `DeviceCtrlLibrary.dll` 和 `WinFormsDeviceControlLibrary.dll`,不用像书上那样复制 dll、添加引用;LED 显示屏、灯泡、报警灯这些控件直接在工具箱的"物联网设备"里。
|
1. 平台已经引用好了 `DeviceCtrlLibrary.dll` 和 `WinFormsDeviceControlLibrary.dll`,不用像书上那样复制 dll、添加引用;LED 显示屏、灯泡、报警灯这些控件直接在工具箱的"物联网设备"里。
|
||||||
2. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using WinFormsDeviceControlLibrary;`。
|
2. 代码开头加上 `using System.IO.Ports;`、`using DeviceCtrlLibrary;`、`using WinFormsDeviceControlLibrary;`。
|
||||||
3. **com_Port 的 Click**(在 ⚡ 事件里双击 Click):先清空下拉框,再用 foreach 把 `SerialPort.GetPortNames()` 的每个串口号加进去。这样每次点开下拉框都会刷新串口列表。
|
3. **com_Port 的 Click**(在 ⚡ 事件里双击"单击",即 Click):先清空下拉框,再用 foreach 把 `SerialPort.GetPortNames()` 的每个串口号加进去。这样每次点开下拉框都会刷新串口列表。
|
||||||
4. **btn_Open 的 Click**:调用 `DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text)` 打开选中的串口。
|
4. **btn_Open 的 Click**:调用 `DeviceCtrlHelper.ledHelper.OpenLedCom(com_Port.Text)` 打开选中的串口。
|
||||||
5. **btn_Send 的 Click**:调用 `DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text)`,把返回的字符串存到变量 res;如果 res 里含有 "Error"(`res.IndexOf("Error") >= 0`)就用 MessageBox 显示出来,否则调用 `led.setLedText(res)` 让窗体上的 LED 控件显示。
|
5. **btn_Send 的 Click**:调用 `DeviceCtrlHelper.ledHelper.SendLedString(txt_SendText.Text)`,把返回的字符串存到变量 res;如果 res 里含有 "Error"(`res.IndexOf("Error") >= 0`)就用 MessageBox 显示出来,否则调用 `led.setLedText(res)` 让窗体上的 LED 控件显示。
|
||||||
|
|
||||||
@@ -34,4 +34,4 @@
|
|||||||
|
|
||||||
1. 运行后点开"串口"下拉框,选 **COM5**(LED 屏),点"打开"。
|
1. 运行后点开"串口"下拉框,选 **COM5**(LED 屏),点"打开"。
|
||||||
2. 输入"欢迎光临",点"推送",实训台的 **LED 显示屏**和窗体上的 LED 控件都应显示"欢迎光临"。
|
2. 输入"欢迎光临",点"推送",实训台的 **LED 显示屏**和窗体上的 LED 控件都应显示"欢迎光临"。
|
||||||
3. 重新运行,不点"打开"直接推送,应弹出"Error:发送失败"。
|
3. 重新运行,先点实训台的"实训台复位"清掉 LED 屏上的字(和真的 LED 屏一样,上次显示的字会一直留着),再不点"打开"直接推送:应弹出"Error:发送失败",LED 屏不显示,实训台最上方用红字提示串口没有打开。
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值 |
|
| 文本框 | `txt_light`、`txt_temperature`、`txt_humidity` | 显示光照度、温度、湿度的值 |
|
||||||
| 按钮 | `btn_get` | 获取 |
|
| 按钮 | `btn_get` | 获取 |
|
||||||
| 分组框 | `grp_config` | 配置 |
|
| 分组框 | `grp_config` | 配置 |
|
||||||
|
| 标签 | `lbl_port` | 串口 |
|
||||||
| 下拉框 | `com_port` | 串口 |
|
| 下拉框 | `com_port` | 串口 |
|
||||||
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
|
| 按钮 | `btn_Open` | 开启(打开后变成"关闭") |
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
## 和书上不一样的地方
|
## 和书上不一样的地方
|
||||||
|
|
||||||
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台保留了它(什么也不做),书上的代码可以原样编译。
|
> 书上关闭时调用的 `DeviceCtrlHelper.ledHelper.Close()`,教材配套的类库里其实没有这个方法。平台补上了这个方法(关闭 LED 屏的串口),书上的代码可以原样编译。
|
||||||
|
|
||||||
> 书上的 `True` / `False` 首字母大写是印刷错误,要写成小写的 `true` / `false`。
|
> 书上的 `True` / `False` 首字母大写是印刷错误,要写成小写的 `true` / `false`。
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- select: { control: com_port, item: COM4 }
|
- select: { control: com_port, item: COM4 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- click: rb_Open
|
- click: rb_Open
|
||||||
|
- expect: { zigbee: 1, on: true, desc: 先打开 }
|
||||||
- click: rb_Close
|
- click: rb_Close
|
||||||
- expect: { zigbee: 1, on: false }
|
- expect: { zigbee: 1, on: false }
|
||||||
- expect: { control: lamp1, on: false }
|
- expect: { control: lamp1, on: false }
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
|
| 按钮 | `btn_lampOpen` | 开(手动开灯) |
|
||||||
| 按钮 | `btn_lampClose` | 关(手动关灯) |
|
| 按钮 | `btn_lampClose` | 关(手动关灯) |
|
||||||
| 分组框 | `groupBox1` | 配置 |
|
| 分组框 | `groupBox1` | 配置 |
|
||||||
|
| 标签 | `lbl_lamp` | 路灯 |
|
||||||
|
| 标签 | `lbl_environment` | 环境采集 |
|
||||||
| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 |
|
| 下拉框 | `com_lamp` | 路灯(ZigBee 协调器)的串口 |
|
||||||
| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 |
|
| 下拉框 | `com_environment` | 环境采集(ADAM-4150)的串口 |
|
||||||
| 复选框 | `ch_bodyInfrared` | 人体开关 |
|
| 复选框 | `ch_bodyInfrared` | 人体开关 |
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
- 取消时:关闭串口,停止 `timer1`,两个按钮恢复可用。
|
- 取消时:关闭串口,停止 `timer1`,两个按钮恢复可用。
|
||||||
7. **timer1 的 Tick**:读 `adam4150Helper.SetData().bodyInfraredValue`,是 `true`(有人)就开灯,否则关灯。
|
7. **timer1 的 Tick**:读 `adam4150Helper.SetData().bodyInfraredValue`,是 `true`(有人)就开灯,否则关灯。
|
||||||
|
|
||||||
> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**:在设计器里双击 timer1 就会生成 `timer1_Tick`。
|
> 书上第 15 步说"进入 Click 事件",定时器的事件其实是 **Tick**(事件窗口里叫"计时到"):在设计器里双击 timer1 就会生成 `timer1_Tick`。
|
||||||
|
|
||||||
## 在平台上怎么试
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
## 在平台上怎么试
|
## 在平台上怎么试
|
||||||
|
|
||||||
1. 运行后在 IP 框里填 **192.168.2.50**(填别的 IP 也能连上),点"开",按钮变成"关",图片框里出现东围墙的画面,实训台顶棚的**网络摄像头**亮起。
|
1. 运行后在 IP 框里填 **192.168.2.50**(填别的 IP 也能连上,但不能空着:空着点"开",按钮照样变,画面却一直是黑的),点"开",按钮变成"关",图片框里出现东围墙的画面,实训台顶棚的**网络摄像头**亮起。
|
||||||
2. 点四个方向键,画面应跟着转动:每点一次转一格,左右各 3 格、上下各 1 格,转到头就不再动。
|
2. 窗体比较大,运行区会缩小显示,可以点运行区右上角的"放大"。点四个方向键,画面应跟着转动:每点一次转一格,左右各 3 格、上下各 1 格,转到头就不再动。
|
||||||
3. 点"拍照",运行区下方出现"程序保存的图片",点开能看到刚拍的照片(只在这次运行中保留)。
|
3. 点"拍照",运行区下方出现"程序保存的图片",点开能看到刚拍的照片(只在这次运行中保留)。
|
||||||
4. 再点"关",按钮变回"开",画面停止更新。
|
4. 再点"关",按钮变回"开",画面停止更新。
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
- select: { control: com_port, item: COM3 }
|
- select: { control: com_port, item: COM3 }
|
||||||
- click: btn_open
|
- click: btn_open
|
||||||
- click: btn_alarmOpen
|
- click: btn_alarmOpen
|
||||||
|
- expect: { relay: 0, on: true, desc: 先打开 }
|
||||||
- click: btn_alarmClose
|
- click: btn_alarmClose
|
||||||
- expect: { relay: 0, on: false }
|
- expect: { relay: 0, on: false }
|
||||||
- expect: { control: alarm1, on: false }
|
- expect: { control: alarm1, on: false }
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "项目7 任务1 智慧教室",
|
"title": "项目7 任务1 智慧教室",
|
||||||
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库"
|
"source": "教材书 p133–137,界面和代码取自配套参考工程(手动开关灯的判断书上写反了,参考工程是对的);开关图片来自平台素材库",
|
||||||
|
"wiring": {
|
||||||
|
"adam4150": "COM2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -56,8 +56,8 @@
|
|||||||
|
|
||||||
## 在平台上怎么试
|
## 在平台上怎么试
|
||||||
|
|
||||||
1. 运行后串口选 **COM2**,点"启用",按钮变成"关闭"。两个采集器都用这个串口打开(书上就是这样写的)。
|
1. 运行后串口选 **COM2**,点"启用",按钮变成"关闭"。两个采集器都用这个串口打开(书上就是这样写的);这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,所以能行。
|
||||||
2. 在实训台上拖动光照、温度、湿度的滑块,2 秒内三个文本框应跟着变。
|
2. 在实训台上拖动光照、温度、湿度的滑块,2 秒内三个文本框应跟着变。
|
||||||
3. 把光照拖到 400 以下,窗体上的灯泡和实训台 ADAM-4150 的 **DO1 照明灯**应一起亮;拖到 800 以上应熄灭。
|
3. 把光照拖到 400 以下,窗体上的灯泡和实训台 ADAM-4150 的 **DO1 照明灯**应一起亮;拖到 800 以上应熄灭。
|
||||||
4. 把温度拖到 30℃ 以上,空调滑块应停在 26;拖到 5℃ 以下停在 25;其他温度回到 18。
|
4. 把温度拖到 30℃ 以上,空调滑块应停在 26;拖到 5℃ 以下停在 25;其他温度回到 18。
|
||||||
5. 点灯光的开关图片,灯应该一下开、一下关。
|
5. 先把光照拖回 400–800 之间(光照在 800 以上时程序每 2 秒自动关一次灯,400 以下自动开灯,会盖过手动开关),再点灯光的开关图片,灯应该一下开、一下关。
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
- expect: { control: trb_curtain, value: 0, desc: 上课模式窗帘全开 }
|
- expect: { control: trb_curtain, value: 0, desc: 上课模式窗帘全开 }
|
||||||
- clock: '21:30:00'
|
- clock: '21:30:00'
|
||||||
- expect: { control: lbl_mode, contains: 回寝模式, within: 5s, desc: 21:30 是回寝模式 }
|
- expect: { control: lbl_mode, contains: 回寝模式, within: 5s, desc: 21:30 是回寝模式 }
|
||||||
|
- expect: { relay: 1, on: true, desc: 回寝模式打开灯光 }
|
||||||
- expect: { control: trb_curtain, value: 10, desc: 回寝模式关窗帘 }
|
- expect: { control: trb_curtain, value: 10, desc: 回寝模式关窗帘 }
|
||||||
- clock: '23:00:00'
|
- clock: '23:00:00'
|
||||||
- expect: { control: lbl_mode, contains: 熄灯模式, within: 5s, desc: 23:00 是熄灯模式 }
|
- expect: { control: lbl_mode, contains: 熄灯模式, within: 5s, desc: 23:00 是熄灯模式 }
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "项目7 任务2 智慧寝室",
|
"title": "项目7 任务2 智慧寝室",
|
||||||
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间"
|
"source": "教材书 p141–145,界面和代码取自配套参考工程;开关图片和铃声来自平台素材库。按时刻切换情景模式:在实训台的时钟上拨时间",
|
||||||
|
"wiring": {
|
||||||
|
"adam4150": "COM2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
## 在平台上怎么试
|
## 在平台上怎么试
|
||||||
|
|
||||||
1. 运行后串口选 **COM2**,点"开启情景模式",按钮变成"停止情景模式",`txt_time` 每秒走一次,拖动实训台的光照、温度、湿度滑块,文本框跟着变。
|
1. 运行后串口选 **COM2**(这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,所以书上在一个串口上打开两个模块也能用),点"开启情景模式",按钮变成"停止情景模式",`txt_time` 每秒走一次,拖动实训台的光照、温度、湿度滑块,文本框跟着变。
|
||||||
2. 在实训台的时钟上拨到 **06:50:00**:应显示起床模式,ADAM-4150 的 **DO1 照明灯**亮,窗帘滑块到 5,广播响一声铃。
|
2. 在实训台的时钟上拨到 **06:50:00**:应显示起床模式,ADAM-4150 的 **DO1 照明灯**亮,窗帘滑块到 5,广播响一声铃。
|
||||||
3. 拨到 **08:00:00**:上课模式,灯灭,窗帘到 0。
|
3. 拨到 **08:00:00**:上课模式,灯灭,窗帘到 0。
|
||||||
4. 拨到 **21:30:00**:回寝模式,灯亮,窗帘到 10,广播响铃。
|
4. 拨到 **21:30:00**:回寝模式,灯亮,窗帘到 10,广播响铃。
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": "项目7 任务3 智慧会议室",
|
"title": "项目7 任务3 智慧会议室",
|
||||||
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间"
|
"source": "教材书 p148–153,界面和代码取自配套参考工程;补上书上漏写的填充串口列表。预约时刻:在实训台的时钟上拨时间",
|
||||||
|
"wiring": {
|
||||||
|
"adam4150": "COM2"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,9 @@
|
|||||||
|
|
||||||
## 在平台上怎么试
|
## 在平台上怎么试
|
||||||
|
|
||||||
1. 运行后串口选 **COM2**(程序一启动就在采集,不用点别的按钮)。在实训台上拖动光照、温度滑块;气压、空气质量在 ADAM-4017+ 那块点"Vin3–6 更多传感器"展开。几秒内文本框应跟着变。
|
1. 运行后串口选 **COM2**(程序一启动就在采集,不用点别的按钮)。这几个实验的实训台把 ADAM-4150 和 ADAM-4017+ 并在 COM2 的同一条 485 总线上,书上在一个串口上打开两个模块也能用。在实训台上拖动光照、温度滑块;气压、空气质量在 ADAM-4017+ 那块点"Vin3–6 更多传感器"展开。几秒内文本框应跟着变。刚运行还没选串口时,实训台会提示串口没选(书上在定时器里每秒打开一次串口),选了 COM2 就消失。
|
||||||
2. 把时钟拨到 **13:19:54**;模式选"讨论",时分秒填 13、20、00,点"预约"。
|
2. 先点"实训台复位"(上次运行开着的灯会一直亮着)。模式选"讨论",时分秒填 13、20、00,然后把时钟拨到 **13:19:40**,马上点"预约"。
|
||||||
3. 预约时间没到时,ADAM-4150 的 **DO1 照明灯**不亮;到 13:20:00 后,应显示"智慧会议室已开启讨论模式",照明灯亮,窗帘滑块到 0,主、客话筒都是开。
|
3. 预约时间没到时,ADAM-4150 的 **DO1 照明灯**不亮;到 13:20:00 后,应显示"智慧会议室已开启讨论模式",照明灯亮,窗帘滑块到 0,主、客话筒都是开。
|
||||||
4. 点"退出",灯、话筒都关掉。想再试一次,重新运行程序。
|
4. 点"退出",灯、话筒都关掉。想再试一次,先点"实训台复位",再重新运行程序。
|
||||||
|
|
||||||
|
> 模式开启后第一次点灯光或话筒的开关图片没有反应,第二次才变:书上的 ChooseMode 只换了开关图片,没有改记录开关状态的变量,参考工程也是这样,不是你写错了。
|
||||||
@@ -47,6 +47,8 @@
|
|||||||
|
|
||||||
## 在平台上怎么试
|
## 在平台上怎么试
|
||||||
|
|
||||||
|
> 先准备:做过任务 4 的话,SecurityData 表里已经存了任务 4 拍的照片和入侵照片,下面看到的第一张就不是 9:00 那张。先到右上角的**数据库**页面点"恢复实验初始数据",选"项目8 任务5",把表清空(自动评分每次都用干净的库,不受影响)。
|
||||||
|
|
||||||
1. 在实训台的**时钟**里拨到 9:00:00,运行后打开校园安防,等画面出来点"拍照";再拨到 10:30:00,再拍一张。
|
1. 在实训台的**时钟**里拨到 9:00:00,运行后打开校园安防,等画面出来点"拍照";再拨到 10:30:00,再拍一张。
|
||||||
2. 点"查看历史图像",打开历史报警记录窗体:两个日期默认都是今天,显示第一张照片,右下角时间是 9:00 左右,"NO DATA"不显示。
|
2. 点"查看历史图像",打开历史报警记录窗体:两个日期默认都是今天,显示第一张照片,右下角时间是 9:00 左右,"NO DATA"不显示。
|
||||||
3. 点右箭头显示 10:30 那张,再点回到第一张;点左箭头往回翻。
|
3. 点右箭头显示 10:30 那张,再点回到第一张;点左箭头往回翻。
|
||||||
|
|||||||
+35
-2
@@ -4,9 +4,13 @@ import {
|
|||||||
ANALOG_INFO,
|
ANALOG_INFO,
|
||||||
type BoardState,
|
type BoardState,
|
||||||
type ClientMessage,
|
type ClientMessage,
|
||||||
|
DEFAULT_WIRING,
|
||||||
DIGITAL_CHANNELS,
|
DIGITAL_CHANNELS,
|
||||||
|
PORT_DEVICES,
|
||||||
|
type PortDevice,
|
||||||
type RunnerInput,
|
type RunnerInput,
|
||||||
type RunnerMessage,
|
type RunnerMessage,
|
||||||
|
type Wiring,
|
||||||
} from '@iot/shared'
|
} from '@iot/shared'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
|
|
||||||
@@ -19,9 +23,16 @@ export function defaultBoardState(): BoardState {
|
|||||||
ledText: '',
|
ledText: '',
|
||||||
camera: { on: false, x: 3, y: 1 },
|
camera: { on: false, x: 3, y: 1 },
|
||||||
clock: null,
|
clock: null,
|
||||||
|
wiring: { ...DEFAULT_WIRING },
|
||||||
|
opened: closedPorts(),
|
||||||
|
usedUnopened: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closedPorts(): BoardState['opened'] {
|
||||||
|
return { adam4017: null, adam4150: null, zigbee: null, led: null }
|
||||||
|
}
|
||||||
|
|
||||||
/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */
|
/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */
|
||||||
export class Board {
|
export class Board {
|
||||||
state = defaultBoardState()
|
state = defaultBoardState()
|
||||||
@@ -33,9 +44,17 @@ export class Board {
|
|||||||
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
|
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
|
||||||
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
|
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
|
||||||
clock: this.state.clock,
|
clock: this.state.clock,
|
||||||
|
wiring: this.state.wiring,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 换成某个实验的接线;返回 true 表示有变化 */
|
||||||
|
setWiring(wiring: Wiring) {
|
||||||
|
if (PORT_DEVICES.every((device) => this.state.wiring[device] === wiring[device])) return false
|
||||||
|
this.state.wiring = { ...wiring }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/** 浏览器调节环境;返回 true 表示传感器读数有变化 */
|
/** 浏览器调节环境;返回 true 表示传感器读数有变化 */
|
||||||
applyInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
|
applyInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
|
||||||
let changed = false
|
let changed = false
|
||||||
@@ -81,6 +100,17 @@ export class Board {
|
|||||||
|
|
||||||
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
|
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
|
||||||
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
|
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
|
||||||
|
const device = message.device as PortDevice
|
||||||
|
if (PORT_DEVICES.includes(device) && (message.action === 'open' || message.action === 'close')) {
|
||||||
|
this.state.opened[device] = message.action === 'open' ? String(message.data ?? '') : null
|
||||||
|
if (message.action === 'open') this.state.usedUnopened = this.state.usedUnopened.filter((d) => d !== device)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (PORT_DEVICES.includes(device) && message.action === 'unopened') {
|
||||||
|
if (this.state.usedUnopened.includes(device)) return false
|
||||||
|
this.state.usedUnopened = [...this.state.usedUnopened, device]
|
||||||
|
return true
|
||||||
|
}
|
||||||
switch (`${message.device}/${message.action}`) {
|
switch (`${message.device}/${message.action}`) {
|
||||||
case 'led/text':
|
case 'led/text':
|
||||||
this.state.ledText = String(message.data ?? '')
|
this.state.ledText = String(message.data ?? '')
|
||||||
@@ -111,13 +141,16 @@ export class Board {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始) */
|
/** 学生程序结束:摄像头不再传画面,云台回到正中(下次运行的程序从正中开始);打开的串口随程序关闭 */
|
||||||
programExited() {
|
programExited() {
|
||||||
this.state.camera = { on: false, x: 3, y: 1 }
|
this.state.camera = { on: false, x: 3, y: 1 }
|
||||||
|
this.state.opened = closedPorts()
|
||||||
|
this.state.usedUnopened = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 复位不改接线(接线跟着实验走),也不动正在运行的程序打开的串口 */
|
||||||
reset() {
|
reset() {
|
||||||
this.state = defaultBoardState()
|
this.state = { ...defaultBoardState(), wiring: this.state.wiring, opened: this.state.opened, usedUnopened: this.state.usedUnopened }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
// 自动评分:编译一次学生工程,每个检查项各启动一次程序,按步骤操作并核对结果。
|
||||||
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。
|
// 检查大部分时间在等定时器,CPU 主要花在编译和启动程序上;全站同时跑 config.gradeConcurrency 个检查,其余排队。
|
||||||
import type { CheckResult, ControlNode, GradeResult } from '@iot/shared'
|
import { describePortProblem, portProblems, type CheckResult, type ControlNode, type GradeResult } from '@iot/shared'
|
||||||
import { rm } from 'node:fs/promises'
|
import { rm } from 'node:fs/promises'
|
||||||
import { dirname } from 'node:path'
|
import { dirname } from 'node:path'
|
||||||
import { config } from '../config'
|
import { config } from '../config'
|
||||||
import { compileService } from '../compile-service'
|
import { compileService } from '../compile-service'
|
||||||
import { gradingDatabaseDir } from '../databases'
|
import { gradingDatabaseDir } from '../databases'
|
||||||
|
import { sampleWiring } from '../samples'
|
||||||
import type { Check, Expectation, Step } from './checks'
|
import type { Check, Expectation, Step } from './checks'
|
||||||
import { ProgramDriver } from './driver'
|
import { ProgramDriver } from './driver'
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ class Failure extends Error {}
|
|||||||
|
|
||||||
async function runCheck(dir: string, check: Check, sampleId?: string): Promise<CheckResult> {
|
async function runCheck(dir: string, check: Check, sampleId?: string): Promise<CheckResult> {
|
||||||
const driver = new ProgramDriver()
|
const driver = new ProgramDriver()
|
||||||
|
driver.board.setWiring(await sampleWiring(sampleId))
|
||||||
const dbDir = sampleId ? await gradingDatabaseDir(sampleId) : undefined
|
const dbDir = sampleId ? await gradingDatabaseDir(sampleId) : undefined
|
||||||
try {
|
try {
|
||||||
driver.start(dir, dbDir)
|
driver.start(dir, dbDir)
|
||||||
@@ -237,7 +239,7 @@ async function expectStep(driver: ProgramDriver, step: Extract<Step, { kind: 'ex
|
|||||||
key = found
|
key = found
|
||||||
}
|
}
|
||||||
const ok = await driver.waitFor(() => satisfied(driver, e, key), within)
|
const ok = await driver.waitFor(() => satisfied(driver, e, key), within)
|
||||||
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait))
|
if (!ok) throw new Failure(describeFailure(driver, e, step.desc, wait) + portNote(driver))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列表框的 Text 只是选中的那一项,检查"包含"时看所有列表项 */
|
/** 列表框的 Text 只是选中的那一项,检查"包含"时看所有列表项 */
|
||||||
@@ -303,7 +305,14 @@ function chartSeries(node: ControlNode | undefined): { name: string; points: unk
|
|||||||
return (node?.props.Chart as { series?: { name: string; points: unknown[] }[] } | undefined)?.series ?? []
|
return (node?.props.Chart as { series?: { name: string; points: unknown[] }[] } | undefined)?.series ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 串口打开错了或没打开时,设备没反应的原因多半在这里,附在不通过的说明后面 */
|
||||||
|
function portNote(driver: ProgramDriver) {
|
||||||
|
const problems = portProblems(driver.board.state)
|
||||||
|
return problems.length ? `。注意:${problems.map(describePortProblem).join(';')}` : ''
|
||||||
|
}
|
||||||
|
|
||||||
function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>, desc: string | undefined, wait: string): string {
|
function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind: 'unchanged' }>, desc: string | undefined, wait: string): string {
|
||||||
|
// "期望 6 秒内……":数字前空一格
|
||||||
const prefix = wait ? ` ${wait}` : ''
|
const prefix = wait ? ` ${wait}` : ''
|
||||||
const onOff = (on: boolean) => (on ? '打开' : '关闭')
|
const onOff = (on: boolean) => (on ? '打开' : '关闭')
|
||||||
switch (e.kind) {
|
switch (e.kind) {
|
||||||
|
|||||||
+6
-3
@@ -9,7 +9,7 @@ import { config } from './config'
|
|||||||
import { openDatabase } from './db'
|
import { openDatabase } from './db'
|
||||||
import { createGradingQueue } from './grading/queue'
|
import { createGradingQueue } from './grading/queue'
|
||||||
import { restoreExperimentDatabases, userDatabaseDir } from './databases'
|
import { restoreExperimentDatabases, userDatabaseDir } from './databases'
|
||||||
import { listSamples } from './samples'
|
import { knownSample, sampleWiring } from './samples'
|
||||||
import { cleanupStaleRuns, RunSession } from './run-session'
|
import { cleanupStaleRuns, RunSession } from './run-session'
|
||||||
import { sqlService } from './sql-service'
|
import { sqlService } from './sql-service'
|
||||||
import { checkSandbox, sandboxError } from './sandbox'
|
import { checkSandbox, sandboxError } from './sandbox'
|
||||||
@@ -78,15 +78,18 @@ const server = Bun.serve({
|
|||||||
if (!session) return
|
if (!session) return
|
||||||
if (message.type === 'run') {
|
if (message.type === 'run') {
|
||||||
try {
|
try {
|
||||||
const { sampleId } = message
|
const sampleId = await knownSample(message.sampleId)
|
||||||
if (typeof sampleId === 'string' && (await listSamples()).some((s) => s.id === sampleId)) {
|
if (sampleId) {
|
||||||
// 建库失败不影响运行(程序连不上库时会报出来)
|
// 建库失败不影响运行(程序连不上库时会报出来)
|
||||||
await restoreExperimentDatabases(ws.data.userId!, sampleId, true).catch((error) => console.error('自动建实验数据库失败', error))
|
await restoreExperimentDatabases(ws.data.userId!, sampleId, true).catch((error) => console.error('自动建实验数据库失败', error))
|
||||||
}
|
}
|
||||||
|
session.setWiring(await sampleWiring(sampleId))
|
||||||
await session.start(message.files)
|
await session.start(message.files)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ws.send(JSON.stringify({ type: 'serverError', message: String(error) } satisfies ServerMessage))
|
ws.send(JSON.stringify({ type: 'serverError', message: String(error) } satisfies ServerMessage))
|
||||||
}
|
}
|
||||||
|
} else if (message.type === 'experiment') {
|
||||||
|
session.setWiring(await sampleWiring(await knownSample(message.sampleId)))
|
||||||
} else if (message.type === 'stop') {
|
} else if (message.type === 'stop') {
|
||||||
await session.stop()
|
await session.stop()
|
||||||
} else if (message.type === 'boardInput') {
|
} else if (message.type === 'boardInput') {
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ export function studentDetail(db: Database, assignmentId: number, userId: number
|
|||||||
/** 一份提交的代码和它属于谁(老师在实验页里运行学生的程序时用) */
|
/** 一份提交的代码和它属于谁(老师在实验页里运行学生的程序时用) */
|
||||||
export function submissionFiles(db: Database, submissionId: number) {
|
export function submissionFiles(db: Database, submissionId: number) {
|
||||||
const row = db
|
const row = db
|
||||||
.query<{ files: string; assignment_id: number; title: string; name: string; username: string }, [number]>(
|
.query<{ files: string; assignment_id: number; sample_id: string; title: string; name: string; username: string }, [number]>(
|
||||||
`SELECT s.files, s.assignment_id, a.title, u.name, u.username
|
`SELECT s.files, s.assignment_id, a.sample_id, a.title, u.name, u.username
|
||||||
FROM submissions s JOIN assignments a ON a.id = s.assignment_id JOIN users u ON u.id = s.user_id WHERE s.id = ?`,
|
FROM submissions s JOIN assignments a ON a.id = s.assignment_id JOIN users u ON u.id = s.user_id WHERE s.id = ?`,
|
||||||
)
|
)
|
||||||
.get(submissionId)
|
.get(submissionId)
|
||||||
@@ -84,6 +84,8 @@ export function submissionFiles(db: Database, submissionId: number) {
|
|||||||
return {
|
return {
|
||||||
files: JSON.parse(row.files) as SourceFile[],
|
files: JSON.parse(row.files) as SourceFile[],
|
||||||
assignmentId: row.assignment_id,
|
assignmentId: row.assignment_id,
|
||||||
|
/** 作业基于的实验:运行时按它给实训台接线、建实验数据库 */
|
||||||
|
sampleId: row.sample_id,
|
||||||
assignmentTitle: row.title,
|
assignmentTitle: row.title,
|
||||||
studentName: row.name,
|
studentName: row.name,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||||
import { dirname, join, normalize, resolve } from 'node:path'
|
import { dirname, join, normalize, resolve } from 'node:path'
|
||||||
import type { Subprocess } from 'bun'
|
import type { Subprocess } from 'bun'
|
||||||
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
|
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile, Wiring } from '@iot/shared'
|
||||||
import { Board } from './board'
|
import { Board } from './board'
|
||||||
import { compileService } from './compile-service'
|
import { compileService } from './compile-service'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
@@ -129,6 +129,13 @@ export class RunSession {
|
|||||||
this.input(this.board.runnerInputs())
|
this.input(this.board.runnerInputs())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 换成实验的接线(打开实验、运行时) */
|
||||||
|
setWiring(wiring: Wiring) {
|
||||||
|
if (!this.board.setWiring(wiring)) return
|
||||||
|
this.sendBoard()
|
||||||
|
this.input(this.board.runnerInputs())
|
||||||
|
}
|
||||||
|
|
||||||
boardReset() {
|
boardReset() {
|
||||||
this.board.reset()
|
this.board.reset()
|
||||||
this.sendBoard()
|
this.sendBoard()
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,6 @@
|
|||||||
import { readdir } from 'node:fs/promises'
|
import { readdir } from 'node:fs/promises'
|
||||||
import { join, relative, resolve } from 'node:path'
|
import { join, relative, resolve } from 'node:path'
|
||||||
import type { SourceFile } from '@iot/shared'
|
import { BOARD_PORTS, DEFAULT_WIRING, PORT_DEVICES, type SourceFile, type Wiring } from '@iot/shared'
|
||||||
import { config } from './config'
|
import { config } from './config'
|
||||||
|
|
||||||
const SAMPLES_DIR = config.samplesDir
|
const SAMPLES_DIR = config.samplesDir
|
||||||
@@ -22,6 +22,30 @@ export async function listSamples(): Promise<SampleInfo[]> {
|
|||||||
return samples.sort((a, b) => a.id.localeCompare(b.id))
|
return samples.sort((a, b) => a.id.localeCompare(b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 浏览器传来的实验编号:是已有的示例才返回 */
|
||||||
|
export async function knownSample(id: unknown): Promise<string | undefined> {
|
||||||
|
return typeof id === 'string' && (await listSamples()).some((s) => s.id === id) ? id : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实验的实训台接线:meta.json 的 wiring 只写和默认接线不同的设备,如项目 7 的 { "adam4150": "COM2" }。
|
||||||
|
* 没有这个实验(或没给 id)时用默认接线
|
||||||
|
*/
|
||||||
|
export async function sampleWiring(id?: string): Promise<Wiring> {
|
||||||
|
const wiring = { ...DEFAULT_WIRING }
|
||||||
|
if (!id) return wiring
|
||||||
|
const path = resolve(SAMPLES_DIR, id, 'meta.json')
|
||||||
|
if (!path.startsWith(`${SAMPLES_DIR}/`)) return wiring
|
||||||
|
const file = Bun.file(path)
|
||||||
|
if (!(await file.exists())) return wiring
|
||||||
|
const custom = ((await file.json()) as { wiring?: Partial<Wiring> }).wiring ?? {}
|
||||||
|
for (const device of PORT_DEVICES) {
|
||||||
|
const port = custom[device]
|
||||||
|
if (port !== undefined && BOARD_PORTS.includes(port)) wiring[device] = port
|
||||||
|
}
|
||||||
|
return wiring
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadSample(id: string): Promise<SourceFile[] | null> {
|
export async function loadSample(id: string): Promise<SourceFile[] | null> {
|
||||||
const dir = resolve(SAMPLES_DIR, id)
|
const dir = resolve(SAMPLES_DIR, id)
|
||||||
if (!dir.startsWith(`${SAMPLES_DIR}/`)) return null
|
if (!dir.startsWith(`${SAMPLES_DIR}/`)) return null
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { expect, test } from 'bun:test'
|
import { expect, test } from 'bun:test'
|
||||||
|
import { DEFAULT_WIRING, describePortProblem, portProblems } from '@iot/shared'
|
||||||
import { Board } from '../src/board'
|
import { Board } from '../src/board'
|
||||||
|
import { sampleWiring } from '../src/samples'
|
||||||
|
|
||||||
test('滑块输入取整并限制在范围内,开关输入只接受布尔值', () => {
|
test('滑块输入取整并限制在范围内,开关输入只接受布尔值', () => {
|
||||||
const board = new Board()
|
const board = new Board()
|
||||||
@@ -31,3 +33,50 @@ test('学生程序控制输出设备:LED 屏、4150 继电器、ZigBee 继电
|
|||||||
expect(board.state.relays.every((on) => !on)).toBe(true)
|
expect(board.state.relays.every((on) => !on)).toBe(true)
|
||||||
expect(board.state.ledText).toBe('')
|
expect(board.state.ledText).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('接线:记下程序打开的串口,指出打开错了的设备;复位不改接线,程序结束时串口关闭', () => {
|
||||||
|
const board = new Board()
|
||||||
|
board.applyDevice({ type: 'device', device: 'adam4017', action: 'open', data: 'COM2' })
|
||||||
|
board.applyDevice({ type: 'device', device: 'adam4150', action: 'open', data: 'com2' })
|
||||||
|
board.applyDevice({ type: 'device', device: 'led', action: 'open', data: '' })
|
||||||
|
expect(portProblems(board.state).map(describePortProblem)).toEqual([
|
||||||
|
'程序在 com2 上打开 ADAM-4150,它接在 COM3',
|
||||||
|
'程序没有选串口就打开了 LED 屏,它接在 COM5',
|
||||||
|
])
|
||||||
|
|
||||||
|
// 项目 7:4150 并到 COM2 的 485 总线上
|
||||||
|
expect(board.setWiring({ ...DEFAULT_WIRING, adam4150: 'COM2' })).toBe(true)
|
||||||
|
expect(board.setWiring({ ...DEFAULT_WIRING, adam4150: 'COM2' })).toBe(false)
|
||||||
|
expect(board.runnerInputs().wiring?.adam4150).toBe('COM2')
|
||||||
|
expect(portProblems(board.state).map((w) => w.device)).toEqual(['led'])
|
||||||
|
|
||||||
|
board.applyDevice({ type: 'device', device: 'led', action: 'close', data: null })
|
||||||
|
expect(portProblems(board.state)).toEqual([])
|
||||||
|
board.reset()
|
||||||
|
expect(board.state.wiring.adam4150).toBe('COM2')
|
||||||
|
expect(board.state.opened.adam4017).toBe('COM2')
|
||||||
|
board.programExited()
|
||||||
|
expect(Object.values(board.state.opened).every((port) => port === null)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('串口没打开(或已关闭)时程序还在读写设备:提示一次,打开后、程序结束时清除', () => {
|
||||||
|
const board = new Board()
|
||||||
|
expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'unopened', data: null })).toBe(true)
|
||||||
|
expect(board.applyDevice({ type: 'device', device: 'adam4150', action: 'unopened', data: null })).toBe(false)
|
||||||
|
expect(portProblems(board.state).map(describePortProblem)).toEqual(['ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM3)'])
|
||||||
|
|
||||||
|
board.applyDevice({ type: 'device', device: 'adam4150', action: 'open', data: 'COM3' })
|
||||||
|
expect(portProblems(board.state)).toEqual([])
|
||||||
|
board.applyDevice({ type: 'device', device: 'adam4150', action: 'close', data: null })
|
||||||
|
board.applyDevice({ type: 'device', device: 'adam4150', action: 'unopened', data: null })
|
||||||
|
expect(portProblems(board.state).map((p) => p.device)).toEqual(['adam4150'])
|
||||||
|
board.programExited()
|
||||||
|
expect(portProblems(board.state)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('实验的接线:项目 7 把 4150 并到 COM2,其他实验和未知实验用默认接线', async () => {
|
||||||
|
expect(await sampleWiring('p7-t1-智慧教室')).toEqual({ ...DEFAULT_WIRING, adam4150: 'COM2' })
|
||||||
|
expect(await sampleWiring('p4-t3-报警灯')).toEqual(DEFAULT_WIRING)
|
||||||
|
expect(await sampleWiring('../p7-t1-智慧教室')).toEqual(DEFAULT_WIRING)
|
||||||
|
expect(await sampleWiring()).toEqual(DEFAULT_WIRING)
|
||||||
|
})
|
||||||
@@ -135,7 +135,11 @@ describe('改坏的代码', () => {
|
|||||||
`),
|
`),
|
||||||
]
|
]
|
||||||
const result = await grade(dir, checks, id)
|
const result = await grade(dir, checks, id)
|
||||||
expect(result.results.at(-1)).toMatchObject({ passed: false, message: '期望窗体上的 lamp 点开关后灯亮,实际是灭的' })
|
// 这一项没有先打开串口,程序控制 4150 时串口没开,说明后面附上提示
|
||||||
|
expect(result.results.at(-1)).toMatchObject({
|
||||||
|
passed: false,
|
||||||
|
message: '期望窗体上的 lamp 点开关后灯亮,实际是灭的。注意:ADAM-4150 的串口没有打开,程序却在读写它(它接在 COM2)',
|
||||||
|
})
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
test('关闭时忘了挂起线程', async () => {
|
test('关闭时忘了挂起线程', async () => {
|
||||||
@@ -189,6 +193,20 @@ describe('改坏的代码', () => {
|
|||||||
const result = await grade(dir, await checksOf(id), id)
|
const result = await grade(dir, await checksOf(id), id)
|
||||||
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
|
expect(result.results[0]).toMatchObject({ passed: false, message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的' })
|
||||||
}, 60000)
|
}, 60000)
|
||||||
|
|
||||||
|
test('串口写死成 COM2:4150 收不到命令,说明里指出接在 COM3', async () => {
|
||||||
|
const id = 'p4-t3-报警灯'
|
||||||
|
const dir = await prepare(id, 'port', {
|
||||||
|
file: 'Form1.cs',
|
||||||
|
from: 'DeviceCtrlHelper.adam4150Helper.Open(com_port.Text);',
|
||||||
|
to: 'DeviceCtrlHelper.adam4150Helper.Open("COM2");',
|
||||||
|
})
|
||||||
|
const result = await grade(dir, await checksOf(id), id)
|
||||||
|
expect(result.results[0]).toMatchObject({
|
||||||
|
passed: false,
|
||||||
|
message: '期望实训台的报警灯(ADAM-4150 DO0)打开,实际是关闭的。注意:程序在 COM2 上打开 ADAM-4150,它接在 COM3',
|
||||||
|
})
|
||||||
|
}, 60000)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('评分规则写错时指出位置', () => {
|
describe('评分规则写错时指出位置', () => {
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ test('项目2 任务1:选串口、打开、推送文字到 LED 屏', async ()
|
|||||||
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
|
runner.send({ type: 'action', id: id('btn_Open'), action: 'click' })
|
||||||
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
|
runner.send({ type: 'action', id: id('btn_Send'), action: 'click' })
|
||||||
|
|
||||||
const device = await runner.waitFor('device', (m) => m.device === 'led')
|
const device = await runner.waitFor('device', (m) => m.device === 'led' && m.action === 'text')
|
||||||
expect(device.data).toBe('欢迎学习物联网')
|
expect(device.data).toBe('欢迎学习物联网')
|
||||||
const led = id('led')
|
const led = id('led')
|
||||||
const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText'))
|
const ledPatch = await runner.waitFor('patch', (m) => m.changes.some((c) => c.id === led && c.prop === 'LedText'))
|
||||||
@@ -219,10 +219,11 @@ test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,
|
|||||||
const { runner, id } = await startSample('p3-t1-ZigBee路灯')
|
const { runner, id } = await startSample('p3-t1-ZigBee路灯')
|
||||||
const lamp = id('lamp1')
|
const lamp = id('lamp1')
|
||||||
|
|
||||||
// 串口没打开时控制无效:没有继电器动作,但界面上的灯照样会亮(学生能看出两者不一致)
|
// 串口没打开时控制无效:没有继电器动作(实训台提示串口没打开),但界面上的灯照样会亮(学生能看出两者不一致)
|
||||||
runner.send({ type: 'action', id: id('rb_Open'), action: 'click' })
|
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))
|
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)
|
await runner.waitFor('device', (m) => m.device === 'zigbee' && m.action === 'unopened')
|
||||||
|
expect(runner.messages.some((m) => m.type === 'device' && m.action === 'relay')).toBe(false)
|
||||||
|
|
||||||
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 2 })
|
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 2 })
|
||||||
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
|
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
|
||||||
@@ -232,7 +233,7 @@ test('项目3 任务1:单选按钮切换,事件顺序与 WinForms 一致,
|
|||||||
runner.send({ type: 'action', id: id('rb_Close'), action: 'click' })
|
runner.send({ type: 'action', id: id('rb_Close'), action: 'click' })
|
||||||
const devices: unknown[] = []
|
const devices: unknown[] = []
|
||||||
await relayOff
|
await relayOff
|
||||||
for (const m of runner.messages) if (m.type === 'device') devices.push(m.data)
|
for (const m of runner.messages) if (m.type === 'device' && m.action === 'relay') devices.push(m.data)
|
||||||
expect(devices).toEqual([
|
expect(devices).toEqual([
|
||||||
[1, 1],
|
[1, 1],
|
||||||
[1, 0],
|
[1, 0],
|
||||||
@@ -329,6 +330,27 @@ test('项目4 任务1:红外对射被遮挡时显示"发现入侵"', async ()
|
|||||||
await even('发现入侵')
|
await even('发现入侵')
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
|
test('串口要选对:4150 接在 COM3,在 COM2 上打开读不到传感器;换成项目 7 的接线(并到 COM2)就能读到', async () => {
|
||||||
|
const { runner, id } = await startSample('p4-t1-红外对射')
|
||||||
|
const even = (value: string) => runner.next('patch', (m) => m.changes.some((c) => c.id === id('txt_even') && c.value === value), 5000)
|
||||||
|
runner.send({ type: 'board', analog: ANALOG_DEFAULT, digital: [false, false, false, true] })
|
||||||
|
const opened = runner.next('device', (m) => m.device === 'adam4150' && m.action === 'open')
|
||||||
|
runner.send({ type: 'action', id: id('com_port'), action: 'select', index: 0 })
|
||||||
|
runner.send({ type: 'action', id: id('btn_open'), action: 'click' })
|
||||||
|
// 实训台据此提示"程序在 COM2 上打开 ADAM-4150";收不到回应,和真机一样读到的全是 false
|
||||||
|
expect((await opened).data).toBe('COM2')
|
||||||
|
await even('正常')
|
||||||
|
|
||||||
|
const intrusion = even('发现入侵')
|
||||||
|
runner.send({
|
||||||
|
type: 'board',
|
||||||
|
analog: ANALOG_DEFAULT,
|
||||||
|
digital: [false, false, false, true],
|
||||||
|
wiring: { adam4017: 'COM2', adam4150: 'COM2', zigbee: 'COM4', led: 'COM5' },
|
||||||
|
})
|
||||||
|
await intrusion
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => {
|
test('项目4 任务2:打开摄像机显示画面,转动后画面变化,有人翻墙时画面变化,拍照保存到 Camera 文件夹', async () => {
|
||||||
const { runner, id } = await startSample('p4-t2-网络摄像机')
|
const { runner, id } = await startSample('p4-t2-网络摄像机')
|
||||||
const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000)
|
const pictures = () => runner.next('patch', (m) => m.changes.some((c) => c.id === id('pictureBox1') && c.prop === 'Image'), 5000)
|
||||||
@@ -367,7 +389,7 @@ test('项目4 任务2:打开摄像机显示画面,转动后画面变化,
|
|||||||
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
|
test('项目4 任务3:开报警灯——实训台 DO0 动作,窗体上的报警灯亮', async () => {
|
||||||
const { runner, id } = await startSample('p4-t3-报警灯')
|
const { runner, id } = await startSample('p4-t3-报警灯')
|
||||||
await openAdam4150(runner, id)
|
await openAdam4150(runner, id)
|
||||||
const relay = runner.next('device', (m) => m.device === 'adam4150')
|
const relay = runner.next('device', (m) => m.device === 'adam4150' && m.action === 'do')
|
||||||
const lamp = runner.next('patch', (m) => m.changes.some((c) => c.id === id('alarm1') && c.prop === 'IsOn' && c.value === true))
|
const lamp = runner.next('patch', (m) => m.changes.some((c) => c.id === id('alarm1') && c.prop === 'IsOn' && c.value === true))
|
||||||
runner.send({ type: 'action', id: id('btn_alarmOpen'), action: 'click' })
|
runner.send({ type: 'action', id: id('btn_alarmOpen'), action: 'click' })
|
||||||
expect((await relay).data).toEqual([0, 1])
|
expect((await relay).data).toEqual([0, 1])
|
||||||
@@ -730,9 +752,7 @@ test('禁用:分组框禁用后里面的控件 Enabled 读出 false、触发 E
|
|||||||
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
const output = runner.messages.flatMap((m) => (m.type === 'console' ? [m.text] : [])).join('')
|
||||||
// 浏览器只收到分组框自己的 Enabled,子控件按父控件画成禁用
|
// 浏览器只收到分组框自己的 Enabled,子控件按父控件画成禁用
|
||||||
expect(findByName(form, 'button').props.Enabled).toBe(true)
|
expect(findByName(form, 'button').props.Enabled).toBe(true)
|
||||||
expect(output).toBe(
|
expect(output).toBe('button changed=False\ngroup=False button=False off=False\nbutton changed=True\ngroup=True button=True off=False\nclick\n')
|
||||||
'button changed=False\ngroup=False button=False off=False\nbutton changed=True\ngroup=True button=True off=False\nclick\n',
|
|
||||||
)
|
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
test('透明背景:文本框、窗体等设透明背景色和 WinForms 一样报错,标签、面板可以', async () => {
|
test('透明背景:文本框、窗体等设透明背景色和 WinForms 一样报错,标签、面板可以', async () => {
|
||||||
|
|||||||
+57
-3
@@ -66,6 +66,55 @@ export interface BoardState {
|
|||||||
camera: { on: boolean; x: number; y: number }
|
camera: { on: boolean; x: number; y: number }
|
||||||
/** 虚拟时钟;null 表示真实时间 */
|
/** 虚拟时钟;null 表示真实时间 */
|
||||||
clock: VirtualClock | null
|
clock: VirtualClock | null
|
||||||
|
/** 接线:每台串口设备接在哪个串口,随实验而定(示例 meta.json 的 wiring) */
|
||||||
|
wiring: Wiring
|
||||||
|
/** 学生程序在哪个串口上打开了各设备(Open / OpenLedCom);没打开或已 Close 为 null,程序结束时清空 */
|
||||||
|
opened: Record<PortDevice, string | null>
|
||||||
|
/** 串口没打开(或已关闭)时程序还在读写的设备(读数据、控制继电器、推送文字);打开后、程序结束时清除 */
|
||||||
|
usedUnopened: PortDevice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 实训台上经串口服务器连到电脑的设备 */
|
||||||
|
export type PortDevice = 'adam4017' | 'adam4150' | 'zigbee' | 'led'
|
||||||
|
export const PORT_DEVICES: PortDevice[] = ['adam4017', 'adam4150', 'zigbee', 'led']
|
||||||
|
export const PORT_DEVICE_NAMES: Record<PortDevice, string> = { adam4017: 'ADAM-4017+', adam4150: 'ADAM-4150', zigbee: 'ZigBee 协调器', led: 'LED 屏' }
|
||||||
|
/** 串口服务器的四个口(电脑自带的 COM1 不接设备,GetPortNames 也不返回) */
|
||||||
|
export const BOARD_PORTS = ['COM2', 'COM3', 'COM4', 'COM5']
|
||||||
|
|
||||||
|
export type Wiring = Record<PortDevice, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教材实训台布局图的接线:ADAM-4017+、ADAM-4150 各经一个 485/232 转换器接串口服务器,ZigBee 协调器、LED 屏直接接(项目 8 的 AppSet 也是这样)。
|
||||||
|
* 485 是总线,两个模块地址不同(4150 为 1、4017+ 为 2),也可以并在同一个口上:项目 7 的书上代码就在一个串口上打开两个模块。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WIRING: Wiring = { adam4017: 'COM2', adam4150: 'COM3', zigbee: 'COM4', led: 'COM5' }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 串口问题,设备因此没有反应(和真机一样不报错):opened 是程序打开的串口名(打开错了),null 表示串口没打开程序就在读写它。
|
||||||
|
* Windows 的串口名不分大小写
|
||||||
|
*/
|
||||||
|
export interface PortProblem {
|
||||||
|
device: PortDevice
|
||||||
|
opened: string | null
|
||||||
|
wired: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portProblems(state: Pick<BoardState, 'wiring' | 'opened' | 'usedUnopened'>): PortProblem[] {
|
||||||
|
return PORT_DEVICES.flatMap((device): PortProblem[] => {
|
||||||
|
const opened = state.opened[device]
|
||||||
|
const wired = state.wiring[device]
|
||||||
|
if (opened === null) return state.usedUnopened.includes(device) ? [{ device, opened, wired }] : []
|
||||||
|
return opened.trim().toUpperCase() !== wired ? [{ device, opened, wired }] : []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一句话说明串口问题,如"程序在 COM2 上打开 ADAM-4150,它接在 COM3" */
|
||||||
|
export function describePortProblem(p: PortProblem) {
|
||||||
|
const name = PORT_DEVICE_NAMES[p.device]
|
||||||
|
// 名字以中文结尾时直接接"的"("LED 屏的串口"),以字母、数字结尾时空一格("ADAM-4150 的串口")
|
||||||
|
const owner = /[\x00-\x7f]$/.test(name) ? `${name} 的` : `${name}的`
|
||||||
|
if (p.opened === null) return `${owner}串口没有打开,程序却在读写它(它接在 ${p.wired})`
|
||||||
|
return p.opened.trim() ? `程序在 ${p.opened.trim()} 上打开 ${name},它接在 ${p.wired}` : `程序没有选串口就打开了 ${name},它接在 ${p.wired}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,7 +166,10 @@ export type RunnerMessage =
|
|||||||
| { type: 'media'; id: string; action: 'play' | 'stop'; key?: string; name?: string }
|
| { type: 'media'; id: string; action: 'play' | 'stop'; key?: string; name?: string }
|
||||||
/** 学生程序用 Image.Save 保存了图片;path 相对程序所在目录,如 Camera/20241022_10 30 00.jpg */
|
/** 学生程序用 Image.Save 保存了图片;path 相对程序所在目录,如 Camera/20241022_10 30 00.jpg */
|
||||||
| { type: 'imageSaved'; path: string; image: string }
|
| { type: 'imageSaved'; path: string; image: string }
|
||||||
/** 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay、camera/on、camera/pan,data 见 DeviceCtrlHelper.cs、IPCameraHelper.cs */
|
/**
|
||||||
|
* 学生程序操作了实训台输出:led/text、adam4150/do、zigbee/relay、camera/on、camera/pan,data 见 DeviceCtrlHelper.cs、IPCameraHelper.cs;
|
||||||
|
* 以及打开、关闭串口设备:adam4017/open(data 是串口名)、adam4017/close,串口没打开时程序还在读写:adam4017/unopened(每次打开后最多报一次),4150、zigbee、led 同样
|
||||||
|
*/
|
||||||
| { type: 'device'; device: string; action: string; data: unknown }
|
| { type: 'device'; device: string; action: string; data: unknown }
|
||||||
| { type: 'console'; text: string }
|
| { type: 'console'; text: string }
|
||||||
/** 学生程序没有处理的异常;file、line 是出错的那行学生代码(找得到时才有),网页上点击可以跳过去 */
|
/** 学生程序没有处理的异常;file、line 是出错的那行学生代码(找得到时才有),网页上点击可以跳过去 */
|
||||||
@@ -150,14 +202,16 @@ export type UserAction =
|
|||||||
export type RunnerInput =
|
export type RunnerInput =
|
||||||
| ({ type: 'action'; id: string } & UserAction)
|
| ({ type: 'action'; id: string } & UserAction)
|
||||||
| { type: 'reply'; requestId: number; result: unknown }
|
| { type: 'reply'; requestId: number; result: unknown }
|
||||||
/** 实训台传感器读数,按通道号排列(见 ANALOG_CHANNELS、DIGITAL_CHANNELS),以及虚拟时钟(不带时表示不变) */
|
/** 实训台传感器读数,按通道号排列(见 ANALOG_CHANNELS、DIGITAL_CHANNELS),以及虚拟时钟、接线(不带时表示不变) */
|
||||||
| { type: 'board'; analog: number[]; digital: boolean[]; clock?: VirtualClock | null }
|
| { type: 'board'; analog: number[]; digital: boolean[]; clock?: VirtualClock | null; wiring?: Wiring }
|
||||||
| { type: 'stop' }
|
| { type: 'stop' }
|
||||||
|
|
||||||
/** 浏览器发给服务器的消息 */
|
/** 浏览器发给服务器的消息 */
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
/** sampleId:当前实验,第一次运行时自动建好实验预置的数据库(学生还没有这些库时) */
|
/** sampleId:当前实验,第一次运行时自动建好实验预置的数据库(学生还没有这些库时) */
|
||||||
| { type: 'run'; files: SourceFile[]; sampleId?: string }
|
| { type: 'run'; files: SourceFile[]; sampleId?: string }
|
||||||
|
/** 打开了哪个实验:实训台换成这个实验的接线(没有 sampleId 时用默认接线) */
|
||||||
|
| { type: 'experiment'; sampleId?: string }
|
||||||
/** 调节实训台环境:拖动滑块、触发传感器 */
|
/** 调节实训台环境:拖动滑块、触发传感器 */
|
||||||
/** clock:拨到某个时刻(time:yyyy-MM-ddTHH:mm:ss)、改倍速,或 null 恢复真实时间 */
|
/** clock:拨到某个时刻(time:yyyy-MM-ddTHH:mm:ss)、改倍速,或 null 恢复真实时间 */
|
||||||
| { type: 'boardInput'; analog?: Partial<BoardState['analog']>; digital?: Partial<BoardState['digital']>; clock?: { time?: string; speed?: number } | null }
|
| { type: 'boardInput'; analog?: Partial<BoardState['analog']>; digital?: Partial<BoardState['digital']>; clock?: { time?: string; speed?: number } | null }
|
||||||
|
|||||||
@@ -106,7 +106,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
|
|
||||||
<h3>5. 虚拟实训台</h3>
|
<h3>5. 虚拟实训台</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>运行区下面是虚拟实训台,代替书上的实物设备。程序打开的串口:COM2 → ADAM-4017+,COM3 → ADAM-4150,COM4 → ZigBee 协调器,COM5 → LED 屏。程序里的串口下拉框展开时,每个串口后面也会用灰字标出接的设备。</li>
|
<li>
|
||||||
|
运行区下面是虚拟实训台,代替书上的实物设备。设备接在串口服务器上:COM2 → ADAM-4017+,COM3 → ADAM-4150,COM4 → ZigBee 协调器,COM5 → LED 屏(项目 7
|
||||||
|
的 ADAM-4150 和 ADAM-4017+ 一起接在 COM2
|
||||||
|
上,书上的代码就是在一个串口上打开两个模块)。程序里的串口下拉框展开时,每个串口后面也会用灰字标出接的设备。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<b>串口要选对</b
|
||||||
|
>:和真机一样,在没接这台设备的串口上打开它不会报错,只是设备没有反应(读到的数据是空的、灯不亮)。忘了点“打开”就读数据、开灯也一样。这时实训台最上方会用红字提示,比如“程序在
|
||||||
|
COM2 上打开 ADAM-4150,它接在 COM3”。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
鼠标移到实训台的设备上,标题栏会写出它的接线,比如“人体红外 → ADAM-4150 DI0 → 串口服务器
|
||||||
|
COM3”,接的通道和串口也会高亮。串口前面的绿点表示程序打开了这个串口。
|
||||||
|
</li>
|
||||||
<li>拖动滑块(或点一下滑块再按方向键)调温度、湿度、光照;点人体红外、烟雾、火焰、红外对射切换有无。程序控制的灯、警示灯、LED 屏会跟着变化。</li>
|
<li>拖动滑块(或点一下滑块再按方向键)调温度、湿度、光照;点人体红外、烟雾、火焰、红外对射切换有无。程序控制的灯、警示灯、LED 屏会跟着变化。</li>
|
||||||
<li>
|
<li>
|
||||||
两盏照明灯:<b>照明灯 1</b> 接 ZigBee 继电器节点 1(项目 3 的路灯),<b>照明灯 2</b> 接 ADAM-4150 的 DO1(项目 7 的教室、寝室、会议室);警示灯接
|
两盏照明灯:<b>照明灯 1</b> 接 ZigBee 继电器节点 1(项目 3 的路灯),<b>照明灯 2</b> 接 ADAM-4150 的 DO1(项目 7 的教室、寝室、会议室);警示灯接
|
||||||
@@ -142,7 +155,10 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|||||||
|
|
||||||
<h3>7. 数据库(项目 6、8)</h3>
|
<h3>7. 数据库(项目 6、8)</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>点右上角的<b>数据库</b>(在新标签页打开),代替书上的 SSMS:左边点“+ 新建库”“+ 新建表”,点表名编辑表里的数据,表右边的 ⋯(或右键)里能查询、设计表结构、删除;<b>新建查询</b>写 SQL(输入时会提示关键字、表名和列名,写"表名."或"别名."列出这张表的列),按 F5 执行。</li>
|
<li>
|
||||||
|
点右上角的<b>数据库</b>(在新标签页打开),代替书上的 SSMS:左边点“+ 新建库”“+ 新建表”,点表名编辑表里的数据,表右边的
|
||||||
|
⋯(或右键)里能查询、设计表结构、删除;<b>新建查询</b>写 SQL(输入时会提示关键字、表名和列名,写"表名."或"别名."列出这张表的列),按 F5 执行。
|
||||||
|
</li>
|
||||||
<li>每个人有自己的一套数据库,只有自己能看到,一直保留。程序里的连接字符串只看数据库名,服务器名、账号密码照书上写就行。</li>
|
<li>每个人有自己的一套数据库,只有自己能看到,一直保留。程序里的连接字符串只看数据库名,服务器名、账号密码照书上写就行。</li>
|
||||||
<li>数据弄乱了:点<b>恢复实验初始数据</b>,选实验(默认是刚才做的实验),把这个实验用到的库换回初始数据(其他库不动)。</li>
|
<li>数据弄乱了:点<b>恢复实验初始数据</b>,选实验(默认是刚才做的实验),把这个实验用到的库换回初始数据(其他库不动)。</li>
|
||||||
<li>点<b>← 返回实验</b>回到刚才做的实验。</li>
|
<li>点<b>← 返回实验</b>回到刚才做的实验。</li>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ async function loadAssignment(discardWork = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 老师运行学生提交的程序 ----------
|
// ---------- 老师运行学生提交的程序 ----------
|
||||||
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string } | null>(null)
|
const viewing = ref<{ studentName: string; username: string; assignmentTitle: string; sampleId: string } | null>(null)
|
||||||
|
|
||||||
// 第一次打开一个实验(或作业)时自动打开任务面板;记在浏览器里,之后不再自动打开
|
// 第一次打开一个实验(或作业)时自动打开任务面板;记在浏览器里,之后不再自动打开
|
||||||
const TASK_SEEN_KEY = 'iot-sim:task-seen'
|
const TASK_SEEN_KEY = 'iot-sim:task-seen'
|
||||||
@@ -243,7 +243,7 @@ watch(
|
|||||||
|
|
||||||
async function loadSubmission() {
|
async function loadSubmission() {
|
||||||
try {
|
try {
|
||||||
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string }>(
|
const result = await api<{ files: SourceFile[]; studentName: string; username: string; assignmentTitle: string; sampleId: string }>(
|
||||||
'GET',
|
'GET',
|
||||||
`/api/teacher/submissions/${props.submissionId}`,
|
`/api/teacher/submissions/${props.submissionId}`,
|
||||||
)
|
)
|
||||||
@@ -513,10 +513,14 @@ function start() {
|
|||||||
rerunBlocked.value = false
|
rerunBlocked.value = false
|
||||||
session.run(
|
session.run(
|
||||||
files.value.map((f) => ({ ...f })),
|
files.value.map((f) => ({ ...f })),
|
||||||
props.assignmentId ? assignment.value?.sampleId : sampleId.value,
|
experimentId.value,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 当前实验:做作业、看学生提交时是作业基于的实验。实训台按它接线(项目 7 的 4150 并在 COM2 上) */
|
||||||
|
const experimentId = computed(() => (props.submissionId ? viewing.value?.sampleId : props.assignmentId ? assignment.value?.sampleId : sampleId.value))
|
||||||
|
watch(experimentId, (id) => session.setExperiment(id), { immediate: true })
|
||||||
|
|
||||||
// ---------- 运行中改了代码或窗体:自动重新运行,不用再按 F5 ----------
|
// ---------- 运行中改了代码或窗体:自动重新运行,不用再按 F5 ----------
|
||||||
/** 停止修改多久后重新运行 */
|
/** 停止修改多久后重新运行 */
|
||||||
const RERUN_DELAY_MS = 1500
|
const RERUN_DELAY_MS = 1500
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import TrainingBoard from '../scene/TrainingBoard.vue'
|
|||||||
import type { SizeValue } from '../winforms/format'
|
import type { SizeValue } from '../winforms/format'
|
||||||
import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue'
|
import FormWindow, { WINDOW_MARGIN, WINDOW_STEP } from '../winforms/FormWindow.vue'
|
||||||
import { RUN_IMAGES } from '../winforms/images'
|
import { RUN_IMAGES } from '../winforms/images'
|
||||||
|
import { PORT_LABELS, portLabels } from '../scene/wiring'
|
||||||
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||||
import type { RunSession } from './session'
|
import type { RunSession } from './session'
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const state = props.session.state
|
const state = props.session.state
|
||||||
provide(RUN_IMAGES, props.session.images)
|
provide(RUN_IMAGES, props.session.images)
|
||||||
|
provide(
|
||||||
|
PORT_LABELS,
|
||||||
|
computed(() => portLabels(props.session.state.board?.wiring)),
|
||||||
|
)
|
||||||
// 素材库清单:认出素材图片,在高分辨率屏幕上换成两倍图
|
// 素材库清单:认出素材图片,在高分辨率屏幕上换成两倍图
|
||||||
void loadGallery()
|
void loadGallery()
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -61,6 +61,8 @@ export function createRunSession() {
|
|||||||
/** 离开页面时主动断开,不再重连 */
|
/** 离开页面时主动断开,不再重连 */
|
||||||
let closed = false
|
let closed = false
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
/** 当前实验(服务器按它给实训台接线);重连后重新告诉服务器 */
|
||||||
|
let experiment: string | undefined
|
||||||
/** 连续重连失败的次数,等待时间随之加长(最长 RECONNECT_MAX_MS) */
|
/** 连续重连失败的次数,等待时间随之加长(最长 RECONNECT_MAX_MS) */
|
||||||
let reconnectFailures = 0
|
let reconnectFailures = 0
|
||||||
const RECONNECT_MAX_MS = 10_000
|
const RECONNECT_MAX_MS = 10_000
|
||||||
@@ -105,6 +107,7 @@ export function createRunSession() {
|
|||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
state.connection = state.connection === 'lost' ? 'restored' : 'open'
|
state.connection = state.connection === 'lost' ? 'restored' : 'open'
|
||||||
reconnectFailures = 0
|
reconnectFailures = 0
|
||||||
|
send({ type: 'experiment', sampleId: experiment })
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -155,6 +158,14 @@ export function createRunSession() {
|
|||||||
send({ type: 'run', files, sampleId })
|
send({ type: 'run', files, sampleId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开了哪个实验:实训台换成它的接线 */
|
||||||
|
function setExperiment(sampleId: string | undefined) {
|
||||||
|
if (sampleId === experiment) return
|
||||||
|
experiment = sampleId
|
||||||
|
// 还在连接时不能发,连上后 onopen 会发
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) send({ type: 'experiment', sampleId })
|
||||||
|
}
|
||||||
|
|
||||||
/** 调节实训台环境(滑块、传感器开关) */
|
/** 调节实训台环境(滑块、传感器开关) */
|
||||||
function setBoard(input: Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>) {
|
function setBoard(input: Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>) {
|
||||||
send({ type: 'boardInput', ...input })
|
send({ type: 'boardInput', ...input })
|
||||||
@@ -292,7 +303,7 @@ export function createRunSession() {
|
|||||||
socket?.close()
|
socket?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { state, images, connect, run, stop, act, replyMessageBox, setBoard, resetBoard, close }
|
return { state, images, connect, run, stop, act, replyMessageBox, setExperiment, setBoard, resetBoard, close }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunSession = ReturnType<typeof createRunSession>
|
export type RunSession = ReturnType<typeof createRunSession>
|
||||||
+192
-20
@@ -2,7 +2,21 @@
|
|||||||
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
|
// 虚拟实训台:按教材实训台布局图(顶棚 → LED 屏 → 传感器 → 采集模块)排列。
|
||||||
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
|
// 传感器卡片上的滑块和开关调节环境(经服务器推给学生程序),灯和继电器的状态由学生程序控制。
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { ANALOG_INFO, clockWall, type AnalogKey, type BoardState, type ClientMessage, type DigitalKey } from '@iot/shared'
|
import {
|
||||||
|
ANALOG_INFO,
|
||||||
|
BOARD_PORTS,
|
||||||
|
clockWall,
|
||||||
|
DEFAULT_WIRING,
|
||||||
|
describePortProblem,
|
||||||
|
PORT_DEVICE_NAMES,
|
||||||
|
portProblems,
|
||||||
|
type AnalogKey,
|
||||||
|
type BoardState,
|
||||||
|
type ClientMessage,
|
||||||
|
type DigitalKey,
|
||||||
|
type PortDevice,
|
||||||
|
} from '@iot/shared'
|
||||||
|
import { devicesOn } from './wiring'
|
||||||
|
|
||||||
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
|
type BoardInput = Omit<Extract<ClientMessage, { type: 'boardInput' }>, 'type'>
|
||||||
|
|
||||||
@@ -145,6 +159,57 @@ function setSpeed(speed: number) {
|
|||||||
emit('set', { clock: { speed } })
|
emit('set', { clock: { speed } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 接线:鼠标移到设备上,高亮它接的通道、模块和串口,并写出整条线路(只看,不能改) ----------
|
||||||
|
interface Link {
|
||||||
|
label: string
|
||||||
|
module: PortDevice | null
|
||||||
|
/** 接在模块的哪个通道上 */
|
||||||
|
channels?: string[]
|
||||||
|
/** 没有模块通道时的线路说明 */
|
||||||
|
route?: string
|
||||||
|
}
|
||||||
|
const LINKS: Record<string, Link> = {
|
||||||
|
infrared: { label: '红外对射', module: 'adam4150', channels: ['DI3'] },
|
||||||
|
alarm: { label: '警示灯', module: 'adam4150', channels: ['DO0'] },
|
||||||
|
camera: { label: '网络摄像头', module: null, route: '网线 → 路由器(按 IP 地址访问,不经过串口)' },
|
||||||
|
led: { label: 'LED 显示屏', module: 'led' },
|
||||||
|
streetLamp: { label: '照明灯 1', module: 'zigbee', route: 'ZigBee 继电器节点 1 → 无线 → ZigBee 协调器' },
|
||||||
|
bodyInfrared: { label: '人体红外', module: 'adam4150', channels: ['DI0'] },
|
||||||
|
humiture: { label: '温湿度传感器', module: 'adam4017', channels: ['Vin0', 'Vin1'] },
|
||||||
|
light: { label: '光照传感器', module: 'adam4017', channels: ['Vin2'] },
|
||||||
|
speaker: { label: '广播', module: null, route: '电脑的声卡(Windows Media Player 播放)' },
|
||||||
|
smoke: { label: '烟雾传感器', module: 'adam4150', channels: ['DI2'] },
|
||||||
|
fire: { label: '火焰传感器', module: 'adam4150', channels: ['DI1'] },
|
||||||
|
roomLamp: { label: '照明灯 2', module: 'adam4150', channels: ['DO1'] },
|
||||||
|
}
|
||||||
|
const hovered = ref<string | null>(null)
|
||||||
|
const wiring = computed(() => props.board?.wiring ?? DEFAULT_WIRING)
|
||||||
|
const link = computed(() => (hovered.value ? LINKS[hovered.value] : undefined))
|
||||||
|
|
||||||
|
/** 如"人体红外 → ADAM-4150 DI0 → 串口服务器 COM3" */
|
||||||
|
const linkPath = computed(() => {
|
||||||
|
const l = link.value
|
||||||
|
if (!l) return ''
|
||||||
|
const steps = [l.label]
|
||||||
|
if (l.route) steps.push(l.route)
|
||||||
|
if (l.module && l.channels) steps.push(`${PORT_DEVICE_NAMES[l.module]} ${l.channels.join('、')}`)
|
||||||
|
if (l.module) steps.push(`串口服务器 ${wiring.value[l.module]}`)
|
||||||
|
return steps.join(' → ')
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkedChannel = (module: PortDevice, channel: string) => link.value?.module === module && link.value.channels?.includes(channel) === true
|
||||||
|
const linkedModule = (module: PortDevice) => link.value?.module === module
|
||||||
|
const linkedPort = (port: string) => !!link.value?.module && wiring.value[link.value.module] === port
|
||||||
|
|
||||||
|
/** 学生程序打开了这个串口(不管上面接的是不是它要的设备) */
|
||||||
|
const portOpen = (port: string) => Object.values(props.board?.opened ?? {}).some((opened) => opened?.trim().toUpperCase() === port)
|
||||||
|
const portDevices = (port: string) =>
|
||||||
|
devicesOn(wiring.value, port)
|
||||||
|
.map((device) => PORT_DEVICE_NAMES[device])
|
||||||
|
.join('、') || '空'
|
||||||
|
/** 串口打开错了、或没打开就读写的设备:设备没有反应,提示学生 */
|
||||||
|
const problems = computed(() => (props.board ? portProblems(props.board) : []))
|
||||||
|
|
||||||
const cameraTitle = computed(() =>
|
const cameraTitle = computed(() =>
|
||||||
camera.value.on ? `网络摄像头:正在传送画面(云台左右 ${camera.value.x + 1}/7,上下 ${camera.value.y + 1}/3)` : '网络摄像头(项目 4 使用):未开启',
|
camera.value.on ? `网络摄像头:正在传送画面(云台左右 ${camera.value.x + 1}/7,上下 ${camera.value.y + 1}/3)` : '网络摄像头(项目 4 使用):未开启',
|
||||||
)
|
)
|
||||||
@@ -175,6 +240,7 @@ const cameraTitle = computed(() =>
|
|||||||
</button>
|
</button>
|
||||||
<span v-if="ledText" class="summary-led" :title="ledText">LED:{{ ledText }}</span>
|
<span v-if="ledText" class="summary-led" :title="ledText">LED:{{ ledText }}</span>
|
||||||
<span v-if="clock" class="summary-clock" title="虚拟时钟:学生程序里的 DateTime.Now">🕒 {{ wall.slice(11) }}({{ clock.speed }}×)</span>
|
<span v-if="clock" class="summary-clock" title="虚拟时钟:学生程序里的 DateTime.Now">🕒 {{ wall.slice(11) }}({{ clock.speed }}×)</span>
|
||||||
|
<span v-for="p in problems" :key="p.device" class="port-warning">⚠ {{ describePortProblem(p) }}</span>
|
||||||
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
|
<button class="link expand" @click="setCollapsed(false)">展开 ▾</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,39 +252,72 @@ const cameraTitle = computed(() =>
|
|||||||
>
|
>
|
||||||
<div class="board-header">
|
<div class="board-header">
|
||||||
<span class="board-name">虚拟实训台</span>
|
<span class="board-name">虚拟实训台</span>
|
||||||
|
<span class="wiring-path" :class="{ active: link }" :title="linkPath">{{ linkPath || '鼠标移到设备上,看它接在哪个通道、哪个串口' }}</span>
|
||||||
<button class="link" title="收起后只显示一行状态,给窗体留出更多地方" @click="setCollapsed(true)">收起 ▴</button>
|
<button class="link" title="收起后只显示一行状态,给窗体留出更多地方" @click="setCollapsed(true)">收起 ▴</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 串口问题放在最上面,不用往下滚就能看到 -->
|
||||||
|
<div v-if="problems.length" class="port-warnings">
|
||||||
|
<div v-for="p in problems" :key="p.device" class="port-warning">⚠ {{ describePortProblem(p) }},设备收不到命令</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
<!-- 顶棚:红外对射、警示灯(DO0)、摄像头 -->
|
||||||
<div class="ceiling">
|
<div class="ceiling">
|
||||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
|
<button
|
||||||
|
class="device beam-end"
|
||||||
|
:class="{ active: digital.infrared }"
|
||||||
|
title="点击遮挡/恢复光束"
|
||||||
|
@click="toggle('infrared')"
|
||||||
|
@mouseenter="hovered = 'infrared'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
|
红外对射
|
||||||
|
</button>
|
||||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')">
|
||||||
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
<span v-if="digital.infrared" class="beam-tag">有人闯入</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)">警示灯</div>
|
<div class="device warning-light" :class="{ on: alarmOn }" title="警示灯(ADAM-4150 DO0)" @mouseenter="hovered = 'alarm'" @mouseleave="hovered = null">
|
||||||
|
警示灯
|
||||||
|
</div>
|
||||||
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
|
<div class="beam" :class="{ broken: digital.infrared }" @click="toggle('infrared')" />
|
||||||
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle">
|
<div class="device camera" :class="{ on: camera.on }" :title="cameraTitle" @mouseenter="hovered = 'camera'" @mouseleave="hovered = null">
|
||||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<button class="device beam-end" :class="{ active: digital.infrared }" title="点击遮挡/恢复光束" @click="toggle('infrared')">红外对射</button>
|
<button
|
||||||
|
class="device beam-end"
|
||||||
|
:class="{ active: digital.infrared }"
|
||||||
|
title="点击遮挡/恢复光束"
|
||||||
|
@click="toggle('infrared')"
|
||||||
|
@mouseenter="hovered = 'infrared'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
|
红外对射
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED 显示屏 -->
|
<!-- LED 显示屏 -->
|
||||||
<div class="led-screen" title="LED 显示屏">
|
<div class="led-screen" title="LED 显示屏" @mouseenter="hovered = 'led'" @mouseleave="hovered = null">
|
||||||
<span v-if="ledText" :key="ledText" class="led-text">{{ ledText }}</span>
|
<span v-if="ledText" :key="ledText" class="led-text">{{ ledText }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 传感器与执行器 -->
|
<!-- 传感器与执行器 -->
|
||||||
<div class="sensors">
|
<div class="sensors">
|
||||||
<div class="card" title="照明灯 1:由 ZigBee 继电器节点 1 控制(教材项目 3 的路灯)">
|
<div class="card" title="照明灯 1:由 ZigBee 继电器节点 1 控制(教材项目 3 的路灯)" @mouseenter="hovered = 'streetLamp'" @mouseleave="hovered = null">
|
||||||
<div class="bulb" :class="{ on: streetLampOn }" />
|
<div class="bulb" :class="{ on: streetLampOn }" />
|
||||||
<div class="card-title">照明灯 1</div>
|
<div class="card-title">照明灯 1</div>
|
||||||
<div class="card-note">ZigBee 节点 1</div>
|
<div class="card-note">ZigBee 节点 1</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="card toggle" :class="{ active: digital.bodyInfrared }" title="点击切换:有人 / 无人" @click="toggle('bodyInfrared')">
|
<button
|
||||||
|
class="card toggle"
|
||||||
|
:class="{ active: digital.bodyInfrared }"
|
||||||
|
title="点击切换:有人 / 无人"
|
||||||
|
@click="toggle('bodyInfrared')"
|
||||||
|
@mouseenter="hovered = 'bodyInfrared'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon">
|
<svg viewBox="0 0 24 24" width="30" height="30" class="icon">
|
||||||
<circle cx="12" cy="5" r="2.6" fill="currentColor" />
|
<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" />
|
<path fill="currentColor" d="M9 9h6l1.5 6h-2l-.8 7h-3.4l-.8-7h-2z" />
|
||||||
@@ -227,7 +326,7 @@ const cameraTitle = computed(() =>
|
|||||||
<div class="card-note">{{ digital.bodyInfrared ? '有人' : '无人' }}</div>
|
<div class="card-note">{{ digital.bodyInfrared ? '有人' : '无人' }}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="card wide">
|
<div class="card wide" @mouseenter="hovered = 'humiture'" @mouseleave="hovered = null">
|
||||||
<div class="card-title">温湿度传感器</div>
|
<div class="card-title">温湿度传感器</div>
|
||||||
<label v-for="key in ['temperature', 'humidity'] as AnalogKey[]" :key="key" class="slider">
|
<label v-for="key in ['temperature', 'humidity'] as AnalogKey[]" :key="key" class="slider">
|
||||||
<span>{{ ANALOG_INFO[key].label }}</span>
|
<span>{{ ANALOG_INFO[key].label }}</span>
|
||||||
@@ -244,7 +343,7 @@ const cameraTitle = computed(() =>
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card wide">
|
<div class="card wide" @mouseenter="hovered = 'light'" @mouseleave="hovered = null">
|
||||||
<div class="card-title">光照传感器</div>
|
<div class="card-title">光照传感器</div>
|
||||||
<label class="slider">
|
<label class="slider">
|
||||||
<span>光照</span>
|
<span>光照</span>
|
||||||
@@ -262,7 +361,12 @@ const cameraTitle = computed(() =>
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" :title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'">
|
<div
|
||||||
|
class="card"
|
||||||
|
:title="playing ? `广播正在播放 ${playing.name}` : '广播(教材项目 7 用 Windows Media Player 播放铃声)'"
|
||||||
|
@mouseenter="hovered = 'speaker'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
|
<svg viewBox="0 0 24 24" width="30" height="30" class="icon speaker" :class="{ on: playing }">
|
||||||
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
|
<path fill="currentColor" d="M4 9h4l5-4v14l-5-4H4z" />
|
||||||
<path
|
<path
|
||||||
@@ -278,13 +382,27 @@ const cameraTitle = computed(() =>
|
|||||||
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
<div class="card-note">{{ playing ? `播放 ${playing.name}` : '静音' }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="card toggle" :class="{ active: digital.smoke }" title="点击切换:有烟雾 / 正常" @click="toggle('smoke')">
|
<button
|
||||||
|
class="card toggle"
|
||||||
|
:class="{ active: digital.smoke }"
|
||||||
|
title="点击切换:有烟雾 / 正常"
|
||||||
|
@click="toggle('smoke')"
|
||||||
|
@mouseenter="hovered = 'smoke'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
<div class="smoke" :class="{ on: digital.smoke }"><i /><i /><i /></div>
|
<div class="smoke" :class="{ on: digital.smoke }"><i /><i /><i /></div>
|
||||||
<div class="card-title">烟雾传感器</div>
|
<div class="card-title">烟雾传感器</div>
|
||||||
<div class="card-note">{{ digital.smoke ? '有烟雾' : '正常' }}</div>
|
<div class="card-note">{{ digital.smoke ? '有烟雾' : '正常' }}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="card toggle" :class="{ active: digital.fire }" title="点击切换:有火焰 / 正常" @click="toggle('fire')">
|
<button
|
||||||
|
class="card toggle"
|
||||||
|
:class="{ active: digital.fire }"
|
||||||
|
title="点击切换:有火焰 / 正常"
|
||||||
|
@click="toggle('fire')"
|
||||||
|
@mouseenter="hovered = 'fire'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
<svg viewBox="0 0 24 24" width="30" height="30" class="icon flame" :class="{ on: digital.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" />
|
<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>
|
</svg>
|
||||||
@@ -292,7 +410,12 @@ const cameraTitle = computed(() =>
|
|||||||
<div class="card-note">{{ digital.fire ? '有火焰' : '正常' }}</div>
|
<div class="card-note">{{ digital.fire ? '有火焰' : '正常' }}</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="card" title="照明灯 2:由 ADAM-4150 的 DO1 控制(教材项目 7 的教室、寝室、会议室照明)">
|
<div
|
||||||
|
class="card"
|
||||||
|
title="照明灯 2:由 ADAM-4150 的 DO1 控制(教材项目 7 的教室、寝室、会议室照明)"
|
||||||
|
@mouseenter="hovered = 'roomLamp'"
|
||||||
|
@mouseleave="hovered = null"
|
||||||
|
>
|
||||||
<div class="bulb" :class="{ on: roomLampOn }" />
|
<div class="bulb" :class="{ on: roomLampOn }" />
|
||||||
<div class="card-title">照明灯 2</div>
|
<div class="card-title">照明灯 2</div>
|
||||||
<div class="card-note">ADAM-4150 DO1</div>
|
<div class="card-note">ADAM-4150 DO1</div>
|
||||||
@@ -301,10 +424,10 @@ const cameraTitle = computed(() =>
|
|||||||
|
|
||||||
<!-- 采集模块与串口 -->
|
<!-- 采集模块与串口 -->
|
||||||
<div class="modules">
|
<div class="modules">
|
||||||
<div class="module">
|
<div class="module" :class="{ linked: linkedModule('adam4017') }">
|
||||||
<div class="module-title">ADAM-4017+ 模拟量(地址 2)</div>
|
<div class="module-title">ADAM-4017+ 模拟量(地址 2)</div>
|
||||||
<div class="readings">
|
<div class="readings">
|
||||||
<span v-for="(key, i) in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key">
|
<span v-for="(key, i) in ['temperature', 'humidity', 'light'] as AnalogKey[]" :key="key" :class="{ linked: linkedChannel('adam4017', `Vin${i}`) }">
|
||||||
Vin{{ i }} {{ ANALOG_INFO[key].label }} <b>{{ board?.analog[key] ?? '-' }}</b>
|
Vin{{ i }} {{ ANALOG_INFO[key].label }} <b>{{ board?.analog[key] ?? '-' }}</b>
|
||||||
</span>
|
</span>
|
||||||
<button class="link" @click="showMore = !showMore">{{ showMore ? '收起' : 'Vin3–6 更多传感器' }}</button>
|
<button class="link" @click="showMore = !showMore">{{ showMore ? '收起' : 'Vin3–6 更多传感器' }}</button>
|
||||||
@@ -326,19 +449,32 @@ const cameraTitle = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="module">
|
<div class="module" :class="{ linked: linkedModule('adam4150') }">
|
||||||
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
<div class="module-title">ADAM-4150 数字量(地址 1)</div>
|
||||||
<div class="io-row">
|
<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>
|
<span
|
||||||
|
v-for="(input, i) in DIGITAL_INPUTS"
|
||||||
|
:key="input.key"
|
||||||
|
class="io"
|
||||||
|
:class="{ lit: digital[input.key], linked: linkedChannel('adam4150', `DI${i}`) }"
|
||||||
|
>
|
||||||
|
<i />DI{{ i }} {{ input.label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="io-row">
|
<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>
|
<span v-for="(label, i) in RELAYS" :key="label" class="io out" :class="{ lit: relays[i], linked: linkedChannel('adam4150', `DO${i}`) }">
|
||||||
|
<i />DO{{ i }} {{ label }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="module ports">
|
<div class="module ports">
|
||||||
<div class="module-title">串口服务器</div>
|
<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>
|
<div class="port-list" title="绿点:程序打开了这个串口">
|
||||||
|
<span v-for="port in BOARD_PORTS" :key="port" class="io out" :class="{ lit: portOpen(port), linked: linkedPort(port) }">
|
||||||
|
<i />{{ port }} → {{ portDevices(port) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
<button class="reset" :disabled="!board" @click="emit('reset')">实训台复位</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -860,6 +996,42 @@ button {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.port-warning {
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-warnings {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #4a2f35;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 接线:鼠标所在设备接的模块、通道和串口 */
|
||||||
|
.wiring-path {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: #6c7889;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiring-path.active {
|
||||||
|
color: #9fd3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module.linked {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readings .linked,
|
||||||
|
.io.linked {
|
||||||
|
color: #9fd3ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 实训台接线在网页上的显示:串口服务器上每个口接了哪些设备。
|
||||||
|
// 串口下拉框展开时也用灰字标出来:运行区 provide,ControlView inject;设计器里没有实训台,用默认接线。
|
||||||
|
import type { InjectionKey, Ref } from 'vue'
|
||||||
|
import { BOARD_PORTS, DEFAULT_WIRING, PORT_DEVICE_NAMES, PORT_DEVICES, type PortDevice, type Wiring } from '@iot/shared'
|
||||||
|
|
||||||
|
/** 接在这个串口上的设备(项目 7 的 COM2 上并着 4017+ 和 4150) */
|
||||||
|
export function devicesOn(wiring: Wiring, port: string): PortDevice[] {
|
||||||
|
return PORT_DEVICES.filter((device) => wiring[device] === port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 串口 → 设备名,如 { COM2: 'ADAM-4017+、ADAM-4150' };没接设备的口不列出 */
|
||||||
|
export function portLabels(wiring: Wiring = DEFAULT_WIRING): Record<string, string> {
|
||||||
|
const labels: Record<string, string> = {}
|
||||||
|
for (const port of BOARD_PORTS) {
|
||||||
|
const names = devicesOn(wiring, port).map((device) => PORT_DEVICE_NAMES[device])
|
||||||
|
if (names.length) labels[port] = names.join('、')
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PORT_LABELS: InjectionKey<Ref<Record<string, string>>> = Symbol('portLabels')
|
||||||
@@ -10,6 +10,7 @@ import DataGridViewControl from './DataGridViewControl.vue'
|
|||||||
import DateTimePickerControl from './DateTimePickerControl.vue'
|
import DateTimePickerControl from './DateTimePickerControl.vue'
|
||||||
import LedControl from './LedControl.vue'
|
import LedControl from './LedControl.vue'
|
||||||
import TrackBarControl from './TrackBarControl.vue'
|
import TrackBarControl from './TrackBarControl.vue'
|
||||||
|
import { PORT_LABELS, portLabels } from '../scene/wiring'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: ControlNode
|
node: ControlNode
|
||||||
@@ -61,8 +62,8 @@ const items = computed(() => (p.value.Items as string[] | undefined) ?? [])
|
|||||||
const editable = computed(() => p.value.DropDownStyle !== 'DropDownList')
|
const editable = computed(() => p.value.DropDownStyle !== 'DropDownList')
|
||||||
const combo = ref<HTMLElement>()
|
const combo = ref<HTMLElement>()
|
||||||
const listStyle = ref<CSSProperties>({})
|
const listStyle = ref<CSSProperties>({})
|
||||||
/** 串口列表里每个口后面用灰字标出实训台上接的设备,只是提示,下拉框的值还是 COM2 这样的串口名 */
|
/** 串口列表里每个口后面用灰字标出实训台上接的设备(按当前实验的接线),只是提示,下拉框的值还是 COM2 这样的串口名 */
|
||||||
const portDevices: Record<string, string> = { COM2: 'ADAM-4017+', COM3: 'ADAM-4150', COM4: 'ZigBee 协调器', COM5: 'LED 屏' }
|
const portDevices = inject(PORT_LABELS, ref(portLabels()))
|
||||||
|
|
||||||
function openDropDown() {
|
function openDropDown() {
|
||||||
if (!enabled.value) return
|
if (!enabled.value) return
|
||||||
|
|||||||
Reference in new issue
Block a user