碰过玻璃温室控制器的人都知道,把一只温度传感器换成另一个型号,为什么会在报价单上写成「二次开发」。
原因通常在代码里。程序知道哪只传感器接在哪个继电器上。那张对应表散落在源码各处,一换传感器,值的范围变了、单位变了,于是知道那张表的每一处都得重做。一个零件把整套系统扣作人质。
这篇文章记录的是做一座压根没有那张表的温室。节点自己声明自己,规则按种类而不是按零件说话。 一旦做到,把摄氏探头换成华氏型号、再加装 CO2 传感器和灌溉阀 —— 服务端代码和规则一行都不用改。 下面是真的跑了两遍的结果。
先看结果 —— 同一份代码,不同的温室
最初的装机。一只摄氏温度计、一只湿度计、一扇天窗。

温室升温,规则触发,天窗打开。

现在 把温度计换成另一家的产品。 单位也是华氏。再加装 CO2 传感器和灌溉阀。服务端没有重新构建,规则也原样不动。


指一下最后那张画面。温度计用自己的单位说 68.9 F,而规则日志用摄氏判断:vent above 26C: 26.1 -> v1=80.0。规则甚至不知道接上来的是一只华氏传感器。
(68.9 F ≈ 20.5 °C,可规则是在 26.1 °C 触发的。这是顺序问题 —— 26.1 度时天窗打开,之后打开的天窗把温室降到了现在的 20.5 度。也就是说规则真的干了活。)
全景
greenhouse_bus (C) greenhouse_server (Dart · mcp_server) screen
nodes declare themselves ──RS-485──▶ asks what is there ──MCP──▶ renders what
t1 / h1 / c1 / v1 / w1 applies rules written by kind was discovered
关键在 服务端不持有的东西。它没有哪只传感器对应哪个继电器的表。服务端只是问,然后照答案来。
① 节点自己声明自己
问总线上有什么,每个节点都会答出自己的 id、角色、种类、型号和单位。
if (strcmp(tool, "bus.list") == 0) {
tick_environment();
printf("{\"id\":%ld,\"ok\":true,\"result\":{\"nodes\":[", rid);
for (int i = 0; i < g_node_count; i++) {
Node *n = &g_nodes[i];
printf("%s{\"id\":\"%s\",\"role\":\"%s\",\"kind\":\"%s\","
"\"model\":\"%s\",\"unit\":\"%s\",\"value\":%.1f}",
i ? "," : "", n->id, n->role, n->kind, n->model,
n->unit, n->value);
}
printf("]}}\n");
}
这里要紧的是 unit。节点若是按华氏测的东西,它就说华氏。不会为了别人方便先把值换算好。 硬件只诚实地说自己知道的,对齐的活儿在上面做。
而继电器自己守住自己的量程。
} else if (strcmp(tool, "node.set") == 0) {
/* ... */
/* The relay clamps its own range. Whatever the rule upstream
* decided, the hardware still refuses to exceed itself. */
if (v < 0) v = 0;
if (v > 100) v = 100;
n->value = v;
上面的规则不管定了什么,硬件都不会越过自己。安全该是继电器的性质,而不是规则的善意。
② 懂单位的地方只有一处
接住节点声明的内容、把它搬到规范单位的地方。这个样例里懂单位的代码只有这里。
return (value - 32) * 5 / 9;
default:
return value;
}
}
Map<String, dynamic> toJson() => {
'id': id,
'role': role,
'kind': kind,
'model': model,
'unit': unit,
不按型号名分支。 如果写成 if (model == 'FX-200'),那每买一只新传感器都得打开这个文件。看 unit,第一次见的产品也会自动对上。
③ 规则按种类写,不按零件
就是农户说话的方式。「超过 26 度就开天窗。」
/// A growing rule — the way a grower says it.
///
/// "When the house goes above 26 degrees, open the vent." Notice what this
/// rule does not say. It does not say which sensor, which relay, or what the
/// model number is. It states only the *kind* of value it reads and the
/// *kind* of thing it moves, so it survives the hardware underneath being
/// replaced.
const houseRules = <Rule>[
Rule(
name: 'vent above 26C',
whenKind: 'temperature',
above: 26.0,
thenKind: 'vent',
setTo: 80.0,
elseSetTo: 0.0,
),
Rule(
name: 'water below 60% humidity',
whenKind: 'humidity',
above: 60.0,
thenKind: 'valve',
setTo: 0.0,
elseSetTo: 40.0,
),
];
规则在判断时也不去找零件。它找的是 种类对得上的东西。
RuleDecision? decide(List<BusNode> nodes) {
final sensor = nodes.where((n) => n.isSensor && n.kind == whenKind);
final actuator = nodes.where((n) => n.isActuator && n.kind == thenKind);
if (sensor.isEmpty || actuator.isEmpty) return null;
final reading = sensor.first.canonicalValue;
final fired = reading > above;
return RuleDecision(/* ... */);
}
请留意那句 return null。那是规则要动的东西还不在温室里的时候。这不是错误。 农户完全可能先订了 CO2 传感器、在货到之前就把规则写好,那条规则就该安静地等到传感器来。服务端不藏着,而是写在画面上 —— 第一张温室截图里的 idle: water below 60% humidity 就是。意思是没有阀门,灌溉规则在闲着,而农户有资格知道这件事。
④ 运行与校验日志
连着跑完两次装机的日志。是运行输出的原文。
[+ 10ms] === pass A: original install (Celsius probe) ===
[+ 1133ms] A: server up with install [t1:temperature:TH-100:C h1:humidity:HM-20:pct v1:vent:VT-9]
[+ 1542ms] A: discovered 3 nodes — t1:temperature:TH-100(C), h1:humidity:HM-20(pct), v1:vent:VT-9(pct)
[+ 4622ms] A: 1 applied · idle: water below 60% humidity
[+ 4622ms] A: vent above 26C: 26.5 -> v1=80.0
[+ 4682ms] A: actuators now v1=80.0
[+ 4684ms] === pass B: probe swapped for a Fahrenheit model, two nodes added ===
[+ 4684ms] server code: unchanged rules: unchanged rebuild: none
[+ 5773ms] B: server up with install [t1:temperature:FX-200:F h1:humidity:HM-20:pct c1:co2:CO-5:ppm v1:vent:VT-9 w1:valve:WV-3]
[+ 5914ms] B: discovered 5 nodes — t1:temperature:FX-200(F), h1:humidity:HM-20(pct), c1:co2:CO-5(ppm), v1:vent:VT-9(pct), w1:valve:WV-3(pct)
[+ 9163ms] B: 2 rules applied
[+ 9164ms] B: vent above 26C: 26.3 -> v1=80.0 · water below 60% humidity: 62.4 -> w1=0.0
[+ 9275ms] B: actuators now v1=80.0, w1=0.0
请把这两行对着看。
A: vent above 26C: 26.5 -> v1=80.0 (TH-100, Celsius)
B: vent above 26C: 26.3 -> v1=80.0 (FX-200, Fahrenheit)
同一条规则、同一个阈值、不同的硬件。 中间没有人改过代码。如果单位处理没有收在一处,B 就会拿华氏的 79 去和 26 比,每次都把天窗打开。
构建是这样通过的。
$ cc -O2 -o greenhouse_bus greenhouse_bus.c -lm
$ dart analyze # greenhouse_server
No issues found!
$ flutter analyze # greenhouse_app
No issues found!
$ flutter test test/capture_test.dart
00:09 +1: All tests passed!
实测值,以及没能测的
| 值 | |
|---|---|
| 总线扫描往返(5 节点) | 约 50 ms(从调用 bus.state 到状态反映,日志 5914→5956 区间) |
| 规则应用一次(含 2 次扫描+2 次执行器写入) | 约 440 ms |
| 两次装机的完整校验 | 9.3 秒 |
这些数字是 对着模拟总线测的。真实 RS-485 线上的节点受线速和轮询周期支配,量级可能不同。真实线路上没有测 —— 没测过的数字,不会当成实测写出来。
没能测的。 节点不应答时的行为(挂了 2 秒超时,但没试过真的断线)、节点增到几十个时的扫描成本、规则互相冲突时的优先级 —— 三样都在这个样例的范围之外。
做的时候卡住的 —— 差点被当成「有时候能跑」放过去的竞态
接上校验脚本一跑,失败了。可手动跑同一个测试却能过。多试几次,有时过,有时不过。
幸好没有把它归到「环境问题」。校验脚本失败时没有留下原因,所以 第一步先改成把测试输出写进文件。 于是原因就在第一行。
McpError (-32100): Resource not found: ui://grower
是我自己服务端代码的顺序问题。注册画面资源这件事排在 总线扫描结束之后。
// the order that was wrong
Future<void> register() async {
_nodes = await bus.scan(); // ← hardware round trip. A client attaching in here
_registerScreen(); // ← finds the screen does not exist yet
_registerTools();
}
客户端在那段往返时间内连上来请求 ui://grower,得到的就是「没有这个资源」。硬件快,窗口窄,就过了;硬件慢,就失败。改后的形状是这样:
final transport = McpServer.createStdioTransport().get();
server.connect(transport);
await Completer<void>().future;
}
class Slot {
归纳成一条原则就是:客户端能问的东西,没有一样可以依赖硬件的回应。 扫描之前节点列表是空的没关系 —— 那是那一刻的事实。画面本身不存在,那是另一回事。
改完之后连过三次。要是校验脚本没留下失败日志,这个缺陷就会顶着「偶发」这个标签一直留在那儿。