第 1 阶段:虚拟实训台,项目 2、3 的示例跑通
- 实训台状态由服务器统一保存:滑块和开关设定传感器读数,推给学生程序; 学生程序控制的灯、继电器、LED 屏回写到实训台 - 网页按教材实训台布局图绘制实训台,传感器卡片上直接调节环境 - 新控件:CheckBox、RadioButton(事件顺序与 WinForms 一致),Lamp、Alarm - 设备类库:串口未打开时控制无效,与实物一致 - 修正:列表属性序列化时数字被转成字符串 - 示例:项目 2 任务 2–3、项目 3 任务 1–2;网址参数 ?sample= 打开指定示例 - 测试:实训台单元测试,项目 2、3 的端到端测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98be8331bc
commit
26104ae81e
38 files changed
+2346
-77
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
ANALOG_CHANNELS,
|
||||
ANALOG_INFO,
|
||||
type BoardState,
|
||||
type ClientMessage,
|
||||
DIGITAL_CHANNELS,
|
||||
type RunnerInput,
|
||||
type RunnerMessage,
|
||||
} from '@iot/shared'
|
||||
|
||||
export function defaultBoardState(): BoardState {
|
||||
return {
|
||||
analog: { temperature: 25, humidity: 60, light: 500, airPressure: 101, airQuality: 60, co2: 600, wind: 3 },
|
||||
digital: { bodyInfrared: false, fire: false, smoke: false, infrared: false },
|
||||
relays: [false, false, false, false, false],
|
||||
zigbee: [false, false],
|
||||
ledText: '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个浏览器会话的虚拟实训台。多次运行之间保留状态,和真实实训台断电前继电器保持原状一样 */
|
||||
export class Board {
|
||||
state = defaultBoardState()
|
||||
|
||||
/** 发给运行器的传感器读数,按通道号排列 */
|
||||
runnerInputs(): Extract<RunnerInput, { type: 'board' }> {
|
||||
return {
|
||||
type: 'board',
|
||||
analog: ANALOG_CHANNELS.map((key) => this.state.analog[key]),
|
||||
digital: DIGITAL_CHANNELS.map((key) => this.state.digital[key]),
|
||||
}
|
||||
}
|
||||
|
||||
/** 浏览器调节环境;返回 true 表示传感器读数有变化 */
|
||||
applyInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
|
||||
let changed = false
|
||||
for (const [key, value] of Object.entries(message.analog ?? {})) {
|
||||
const name = key as keyof BoardState['analog']
|
||||
if (!(name in ANALOG_INFO) || typeof value !== 'number' || !Number.isFinite(value)) continue
|
||||
const { min, max } = ANALOG_INFO[name]
|
||||
// 教材类库返回整数字符串,这里保证读数是整数
|
||||
this.state.analog[name] = Math.round(Math.min(max, Math.max(min, value)))
|
||||
changed = true
|
||||
}
|
||||
for (const [key, value] of Object.entries(message.digital ?? {})) {
|
||||
const name = key as keyof BoardState['digital']
|
||||
if (!(name in this.state.digital) || typeof value !== 'boolean') continue
|
||||
this.state.digital[name] = value
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** 学生程序控制了输出设备;返回 true 表示实训台状态有变化 */
|
||||
applyDevice(message: Extract<RunnerMessage, { type: 'device' }>) {
|
||||
switch (`${message.device}/${message.action}`) {
|
||||
case 'led/text':
|
||||
this.state.ledText = String(message.data ?? '')
|
||||
return true
|
||||
case 'adam4150/do': {
|
||||
const [channel, on] = message.data as [number, number]
|
||||
if (channel < 0 || channel >= this.state.relays.length) return false
|
||||
this.state.relays[channel] = on === 1
|
||||
return true
|
||||
}
|
||||
case 'zigbee/relay': {
|
||||
const [relay, on] = message.data as [number, number]
|
||||
if (relay < 0 || relay >= this.state.zigbee.length) return false
|
||||
this.state.zigbee[relay] = on === 1
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = defaultBoardState()
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ const server = Bun.serve({
|
||||
}
|
||||
} else if (message.type === 'stop') {
|
||||
await session.stop()
|
||||
} else if (message.type === 'boardInput') {
|
||||
session.boardInput(message)
|
||||
} else if (message.type === 'boardReset') {
|
||||
session.boardReset()
|
||||
} else {
|
||||
session.input(message)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import { dirname, join, normalize, resolve } from 'node:path'
|
||||
import type { Subprocess } from 'bun'
|
||||
import type { RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
|
||||
import type { ClientMessage, RunnerInput, RunnerMessage, ServerMessage, SourceFile } from '@iot/shared'
|
||||
import { Board } from './board'
|
||||
import { compileService } from './compile-service'
|
||||
import { ROOT, RUNNER_DLL, readLines } from './runner-process'
|
||||
|
||||
@@ -12,8 +13,12 @@ export async function cleanupStaleRuns() {
|
||||
await rm(RUNS_DIR, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
/** 一次运行:写出学生工程 → 编译服务编译 → 启动运行进程,在运行进程和浏览器之间转发消息 */
|
||||
/**
|
||||
* 一个浏览器会话:拥有一台虚拟实训台,负责每次运行(写出学生工程 → 编译服务编译 → 启动运行进程),
|
||||
* 在运行进程和浏览器之间转发消息,并让实训台状态在两边保持一致。
|
||||
*/
|
||||
export class RunSession {
|
||||
readonly board = new Board()
|
||||
private proc?: Subprocess<'pipe', 'pipe', 'pipe'>
|
||||
private dir?: string
|
||||
/** 每次启动、停止都递增,用来丢弃已经过时的编译结果 */
|
||||
@@ -21,7 +26,9 @@ export class RunSession {
|
||||
/** 同一会话的多次"运行"排队依次执行,避免两次启动交错 */
|
||||
private queue = Promise.resolve()
|
||||
|
||||
constructor(private readonly send: (message: ServerMessage) => void) {}
|
||||
constructor(private readonly send: (message: ServerMessage) => void) {
|
||||
this.sendBoard()
|
||||
}
|
||||
|
||||
start(files: SourceFile[]) {
|
||||
const next = this.queue.then(() => this.startNow(files))
|
||||
@@ -59,7 +66,13 @@ export class RunSession {
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1' },
|
||||
env: {
|
||||
...process.env,
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
||||
DOTNET_NOLOGO: '1',
|
||||
// 启动时就带上传感器读数,学生程序第一次 SetData() 前不必等消息
|
||||
SIM_BOARD: JSON.stringify(this.board.runnerInputs()),
|
||||
},
|
||||
})
|
||||
this.proc = proc
|
||||
|
||||
@@ -72,6 +85,10 @@ export class RunSession {
|
||||
return
|
||||
}
|
||||
if (message.type === 'started') this.send({ type: 'status', state: 'running' })
|
||||
if (message.type === 'device') {
|
||||
if (this.board.applyDevice(message)) this.sendBoard()
|
||||
return
|
||||
}
|
||||
this.send(message)
|
||||
})
|
||||
void readLines(proc.stderr, (line) => this.send({ type: 'serverError', message: line }))
|
||||
@@ -85,6 +102,23 @@ export class RunSession {
|
||||
})
|
||||
}
|
||||
|
||||
/** 浏览器调节实训台环境:更新状态,推给浏览器和正在运行的学生程序 */
|
||||
boardInput(message: Extract<ClientMessage, { type: 'boardInput' }>) {
|
||||
if (!this.board.applyInput(message)) return
|
||||
this.sendBoard()
|
||||
this.input(this.board.runnerInputs())
|
||||
}
|
||||
|
||||
boardReset() {
|
||||
this.board.reset()
|
||||
this.sendBoard()
|
||||
this.input(this.board.runnerInputs())
|
||||
}
|
||||
|
||||
private sendBoard() {
|
||||
this.send({ type: 'board', state: structuredClone(this.board.state) })
|
||||
}
|
||||
|
||||
input(message: RunnerInput) {
|
||||
if (!this.proc) return
|
||||
this.proc.stdin.write(`${JSON.stringify(message)}\n`)
|
||||
|
||||
Reference in new issue
Block a user