第 1 阶段:虚拟实训台,项目 2、3 的示例跑通

- 实训台状态由服务器统一保存:滑块和开关设定传感器读数,推给学生程序;
  学生程序控制的灯、继电器、LED 屏回写到实训台
- 网页按教材实训台布局图绘制实训台,传感器卡片上直接调节环境
- 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),Lamp、Alarm
- 设备类库:串口未打开时控制无效,与实物一致
- 修正:列表属性序列化时数字被转成字符串
- 示例:项目 2 任务 2–3、项目 3 任务 1–2;网址参数 ?sample= 打开指定示例
- 测试:实训台单元测试,项目 2、3 的端到端测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-22 10:32:38 +08:00
1 parent 98be8331bc
commit 26104ae81e
38 files changed
+2346 -77

No files matched your search

+80
View File
@@ -0,0 +1,80 @@
import {
ANALOG_CHANNELS,
ANALOG_INFO,
type BoardState,
type ClientMessage,
DIGITAL_CHANNELS,
type RunnerInput,
type RunnerMessage,
} from '@iot/shared'
export function defaultBoardState(): BoardState {
return {
analog: { temperature: 25, humidity: 60, light: 500, airPressure: 101, airQuality: 60, co2: 600, wind: 3 },
digital: { bodyInfrared: false, fire: false, smoke: false, infrared: false },
relays: [false, false, false, false, false],
zigbee: [false, false],
ledText: '',
}
}
/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */
export class Board {
state = defaultBoardState()
/** 发给运行器的传感器读数,按通道号排列 */
runnerInputs(): Extract<RunnerInput, { type: 'board' }> {
return {
type: 'board',
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
}
}
/** 浏览器调节环境;返回 true 表示传感器读数有变化 */
applyInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
let changed = false
for (const [key, value] of Object.entries(message.analog ?? {})) {
const name = key as keyof BoardState['analog']
if (!(name in ANALOG_INFO) || typeof value !== 'number' || !Number.isFinite(value)) continue
const { min, max } = ANALOG_INFO[name]
// 教材类库返回整数字符串,这里保证读数是整数
this.state.analog[name] = Math.round(Math.min(max, Math.max(min, value)))
changed = true
}
for (const [key, value] of Object.entries(message.digital ?? {})) {
const name = key as keyof BoardState['digital']
if (!(name in this.state.digital) || typeof value !== 'boolean') continue
this.state.digital[name] = value
changed = true
}
return changed
}
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
switch (`${message.device}/${message.action}`) {
case 'led/text':
this.state.ledText = String(message.data ?? '')
return true
case 'adam4150/do': {
const [channel, on] = message.data as [number, number]
if (channel < 0 || channel >= this.state.relays.length) return false
this.state.relays[channel] = on === 1
return true
}
case 'zigbee/relay': {
const [relay, on] = message.data as [number, number]
if (relay < 0 || relay >= this.state.zigbee.length) return false
this.state.zigbee[relay] = on === 1
return true
}
default:
return false
}
}
reset() {
this.state = defaultBoardState()
}
}
+4
View File
@@ -50,6 +50,10 @@ const server = Bun.serve({
}
} else if (message.type === 'stop') {
await session.stop()
} else if (message.type === 'boardInput') {
session.boardInput(message)
} else if (message.type === 'boardReset') {
session.boardReset()
} else {
session.input(message)
}
+38 -4
View File
@@ -1,7 +1,8 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { dirname, join, normalize, resolve } from 'node:path'
import type { Subprocess } from 'bun'
import type { RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
import { Board } from './board'
import { compileService } from './compile-service'
import { ROOT, RUNNER_DLL, readLines } from './runner-process'
@@ -12,8 +13,12 @@ export async function cleanupStaleRuns() {
await rm(RUNS_DIR, { recursive: true, force: true })
}
/** 一次运行:写出学生工程 → 编译服务编译 → 启动运行进程,在运行进程和浏览器之间转发消息 */
/**
* 一个浏览器会话:拥有一台虚拟实训台,负责每次运行(写出学生工程 → 编译服务编译 → 启动运行进程),
* 在运行进程和浏览器之间转发消息,并让实训台状态在两边保持一致。
*/
export class RunSession {
readonly board = new Board()
private proc?: Subprocess<'pipe', 'pipe', 'pipe'>
private dir?: string
/** 每次启动、停止都递增,用来丢弃已经过时的编译结果 */
@@ -21,7 +26,9 @@ export class RunSession {
/** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */
private queue = Promise.resolve()
constructor(private readonly send: (message: ServerMessage) => void) {}
constructor(private readonly send: (message: ServerMessage) => void) {
this.sendBoard()
}
start(files: SourceFile[]) {
const next = this.queue.then(() => this.startNow(files))
@@ -59,7 +66,13 @@ export class RunSession {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'pipe',
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
env: {
...process.env,
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
SIM_BOARD: JSON.stringify(this.board.runnerInputs()),
},
})
this.proc = proc
@@ -72,6 +85,10 @@ export class RunSession {
return
}
if (message.type === 'started') this.send({ type: 'status', state: 'running' })
if (message.type === 'device') {
if (this.board.applyDevice(message)) this.sendBoard()
return
}
this.send(message)
})
void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line }))
@@ -85,6 +102,23 @@ export class RunSession {
})
}
/** 浏览器调节实训台环境:更新状态,推给浏览器和正在运行的学生程序 */
boardInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
if (!this.board.applyInput(message)) return
this.sendBoard()
this.input(this.board.runnerInputs())
}
boardReset() {
this.board.reset()
this.sendBoard()
this.input(this.board.runnerInputs())
}
private sendBoard() {
this.send({ type: 'board', state: structuredClone(this.board.state) })
}
input(message: RunnerInput) {
if (!this.proc) return
this.proc.stdin.write(`${JSON.stringify(message)}\n`)