读数到达后,好几个组件要做出反应,而且反应要落到设备上:
FAN_ON这些组件都订阅了数据分发服务。最直接的写法是:在收到读数的回调里算出决策,然后立即下发命令。
结果在硬件模式下是错的,而且错得很彻底。数据回调是在接收循环读串口的过程中被触发的:
runner.run_once()
└─ receiver 读串口、拼帧、解码
└─ DataService.publish(读数)
└─ 通风控制器的回调
└─ 设备管理器.deliver(FAN_ON) ← 在这里下发
└─ 在同一个串口上等设备的应答帧
deliver() 要在同一个串口上读应答,还用着另一个拼帧缓冲。于是:
这不是推演出来的:测试进程直接崩溃(Fatal Python error: Aborted)。
回调里只做内存操作,耗时的事交给轮询循环。 每个派发器拆成两个方法:
def handle_decision(self, decision): # 在数据回调里被调用
self._desired = decision.state # 只记下期望状态,不发任何东西
def dispatch_pending(self): # 由轮询循环调用,紧跟在 run_once() 之后
if self._desired != self._applied:
... 真正下发命令 ...
组合代码里的轮询循环:
runner.run_once() # 读串口、拼帧、发布读数,回调在这里触发
fan.dispatch_pending() # 此时串口空闲,没有别的读者
alerts.dispatch_pending()
alarm_state.dispatch_pending()
answers.dispatch_pending()
所有”让设备做点什么”的组件都遵守这个形状。同一条结论后来也用在了不下发命令、但耗时的地方: 历史记录器在回调里只把读数放进内存批次,满 50 条或 5 秒才由轮询循环写一次盘—— 数据回调跑在采集线程上,一次磁盘写入就会直接坐在维持界面响应的那条路径上。
决策与发送分开以后,”什么时候发、发几次、失败怎么办”就成了派发器自己的事,而且各不相同:
| 派发器 | 携带的是 | 去重 | 失败后 |
|---|---|---|---|
| 风扇 | 状态:应该开还是关 | 与上次已成功下发的值比较,没变就不发 | 不更新”已下发”值,下一轮自然重试,不需要定时器 |
| 语音播报 | 事件:这一刻播一次 | 不去重;两次轮询之间来了多条只保留最后一条 | 不重试。几秒后才响的报警比不响更糟 |
| LCD 报警位图 | 状态 | 同风扇 | 同风扇 |
| LCD 问答页 | 最近一次答案 | 只下发有模板的答案种类 | 同风扇 |
另外两条共同的规矩:
风扇派发器的”已下发”初值是”关”,因为固件上电时风扇是停的——据此假设,可以避免每次启动都白发一条 FAN_OFF。
handle_* 喂决策,调 dispatch_pending() 看发了什么scripts/automation_wiring.py)。
这份共用代码本身也是教训的产物:自动通风最初只接进了一个启动器,另外两个启动器里它根本没在工作命令比决策最多晚一个轮询周期发出。硬件模式的轮询间隔是 0.5 秒,采集周期是 3 秒, 对通风和告警都没有可感知的影响。