# 联网设备 —— 第二类虚拟硬件 > **来源**:用户提出「真实项目下这个插件能不能和虚拟连接和测试」,并给了现成例子 `D:\MAX_xiangmu`(智座)。 > 核实后发现:**智座不适配 I2C**(全项目无 `smbus`/`GPIO`/`serial`),它要的是**设备模拟器**。 > 本文记录这一类虚拟硬件的设计、为什么这么设计、以及实测证据。 --- ## 1. 为什么是「第二类」,而不是「又一种设备」 `VirtualDevice.tick` 的契约原文(`contracts/device.ts`): > **`tick` 必须纯同步、无 IO、无真随机。** **这一条从结构上排除了联网设备** —— 它天生要做 IO,而且不可确定(对面是真服务器)。 所以它不是 `VirtualDevice` 的一个实现,而是**并列的另一类**: | | 总线设备(I2C) | **联网设备** | |---|---|---| | 驱动源 | **虚拟时钟**(可加速、可跳空) | **墙钟**(真实节拍) | | 时序 | 确定性、可复现 | 依赖外部系统 | | IO | 无(纯函数式) | **有**(HTTP) | | 发起方 | **被动**(等被读) | **主动**(自己周期上报) | | 进 SSOT | `virtualDevices` | `virtualDevices`(**同一份真相**) | ### ★★ 为什么驱动源**必须**不同(这是最容易做错的地方) 虚拟时钟是**加速**的。智座设备 `report_interval_ms = 1000`, 若把它挂在加速时钟上,一次 `advance(60)` 会瞬间打出 60 次真实 HTTP —— **把对方服务器打爆**。 ⇒ 联网设备按**墙钟**走,与虚拟时钟解耦。 ⇒ 推论:**"虚拟时间"对联网设备没有意义**,所以快照里带 `wallClockDriven: true`, 前端必须能区分(否则会把它们画在虚拟时间轴上,让人以为跟着仿真加速)。 --- ## 2. 三个关键设计决定 ### ① 传输是**注入**的(与 `SimSpawner` 同一手法) 设备不直接 `fetch`,而是拿一个 `NetworkTransport`。 理由:**内核零依赖宿主 API**(§10.3),且测试可以**不联网**跑完整条逻辑。 宿主的真实实现是 `host/fetch-transport.ts`(带超时;非 2xx **不抛**而是如实返回状态码, 因为智座把业务错误也放在 body 里)。 ### ② 协议知识收在**适配器**里,内核不知道任何协议细节 `NetworkProtocolAdapter` 有 `register` / `pullConfig` / `report` / `intervalMs` / `translate`。 **加一个新协议 = 加一个适配器,不动内核。** 目前有 `zhizuo-sensor`。 `readingKeys` 由适配器声明,内核据此生成默认读数 —— **协议知识不漏进内核**。 ### ③ ★★ **放进来 ≠ 开始发请求** 总线设备放进场景就"活着"是**无害的**(它只是等着被读)。 **联网设备不是** —— 一旦启动就真的往外发 HTTP。 ⇒ 设备被添加后停在 `idle`,**必须显式 `start`**。 这条不是保守,是「**默认不该有外部副作用**」。 有测试专门钉住它(`tests/network-tools.test.ts`:添加后断言 `fake.calls.length === 0`)。 --- ## 3. 顺带修掉的两个结构性隐患 ### 3.1 两个注册表抢同一个字段 `AssemblyState.setVirtualDevices` 原本只有一个,`DeviceRegistry` 在写。 若 `NetworkRegistry` 也调它,**后写的会把先写的整个覆盖** —— 而症状是"设备时有时无"(取决于谁后跑)。 ⇒ 拆成 `setVirtualDevices` / `setNetworkDevices` **两个字段、两个写者**, 对外仍在 `snapshot().virtualDevices` 里**合并成一份**。 这是「所有权唯一」与「一份真相」同时满足的写法 —— **一份真相 ≠ 一个字段**。 ### 3.2 契约缺口:`report` 看不到配置 设备"该上报哪个座位"**是服务端说的**(智座 `_device_config_payload()` 返回 `seat_id`)。 早先 `report()` 只看创建时写死的 `options.seat_id`,后果: - 面板上改了绑定 ⇒ 设备仍往**旧座位**上报(**而且不报错**) - 创建时没写 options ⇒ 直接报"没绑定座位",**即使服务端已经绑好了** ⇒ `NetworkCallContext` 增加 `config`,约定 **配置 > options**。 --- ## 4. 智座适配器里编码的四个真实约定 全部**从 `app.py` 读出来的**,不是推测: | # | 约定 | 出处 | 做错的后果 | |---|---|---|---| | 1 | 信封 `{code, message, data}`,**HTTP 状态码 == code** | `api_response()` app.py:270 | 只看一层 ⇒ 另一层出错时当成功 | | 2 | **上报必须带 `device_id`** | app.py:2174 | 设备"一直在上报"却显示**离线**(纯静默) | | 3 | `ir_active_high` 决定遮挡读到 1 还是 0 | `_device_config_payload()` | **人坐下反而释放**(纯静默) | | 4 | **释放需要连续 2 次空** | app.py:2144 | 只报一次空不释放,会以为系统坏了 | 第 2 条尤其隐蔽:它**不是**接口参数校验,而是"心跳刷新"的副作用 —— 不带它,HTTP 200、一切正常,只有面板上的"在线"是错的。 --- ## 5. 实测证据(2026-10-08) **全流程用插件工具驱动,没有用任何脚本**(智座跑在本机 5800,MySQL+Redis 均为本机已在运行的服务): ``` hw_network_add {componentId: esp1, protocol: zhizuo-sensor, endpoint: http://127.0.0.1:5800, deviceId: 38:3E:51:6F:8E:00} → 已添加(**尚未启动**) hw_network_devices → esp1 [zhizuo-sensor] idle(已停止) 上报 0 次 / 失败 0 次 **未绑定座位** ← 还没拉配置 hw_network_control {action: start} hw_network_devices → esp1 [zhizuo-sensor] online(运行中) 上报 5 次 / 失败 0 次 绑定座位 6 ← ★ 座位号来自**服务端配置** 最近上报 2026-10-08T06:47:38.889Z hw_network_set_reading {componentId: esp1, reading: {ir_front: true, ir_back: true}} → 智座侧 seat 6:status=occupied ir_front=1 ir_back=1 consecutive_empty=0 last_seen 也被刷新 ← ★ 第 2 条约定生效的证据 hw_network_set_reading {ir_front: false, ir_back: false} → 智座侧 seat 6:status=free ← 周期循环自动满足了"连续 2 次空" ``` **服务端日志显示节拍完全正确**(这是"没有打爆对面"的直接证据): ``` 14:47:33 POST /api/sensor/device/register 200 14:47:34 POST /api/sensor/report 200 14:47:35 POST /api/sensor/report 200 ← 稳定 ~1.01s,与 report_interval_ms=1000 一致 ⋯ 14:47:43 GET /api/sensor/device_config 200 ← 每 10 次上报拉一次配置(设计值) ⋯ ``` **测试后已还原**:seat 6 回到 `free`(`ir_front=0`),智座实例已停止,5800 已释放。 --- ## 6. 工具一览(4 个) | 工具 | 作用 | |---|---| | `hw_network_add` | 添加一台联网设备(**不启动**) | | `hw_network_devices` | 列出全部联网设备:状态/上报次数/读数/配置/**失败原因** | | `hw_network_control` | `start` / `stop` / `disconnect`(掉线故障)/ `clear` | | `hw_network_set_reading` | 设置**物理读数**(true = 被遮挡) | `hw_network_set_reading` 收的是**物理量**,电平翻译由协议按配置做 —— 这条分工很重要:搞反极性的责任在协议侧,不在调用方。 --- ## 7. 边界(别误读) - ❌ **不模拟物理过程** —— 直接给"遮挡/未遮挡",不做距离→遮挡换算 - ❌ **不做多设备编排** —— 一台设备一个实例,多座位加多次即可 - ❌ **`disconnect` 只让我们停发** —— 对面多久判定离线是**它**的策略 (智座 `SEAT_ONLINE_TIMEOUT_MINUTES=3`,所以离线判定要 3 分钟才看得出来) - ⚠️ **加速模式对联网设备无意义** —— 它们始终按墙钟走