构建

换掉传感器,控制照旧 — 不把一座温室建两遍的办法

作者: makemind · 2026年8月1日

碰过玻璃温室控制器的人都知道,把一只温度传感器换成另一个型号,为什么会在报价单上写成「二次开发」。

原因通常在代码里。程序知道哪只传感器接在哪个继电器上。那张对应表散落在源码各处,一换传感器,值的范围变了、单位变了,于是知道那张表的每一处都得重做。一个零件把整套系统扣作人质。

这篇文章记录的是做一座压根没有那张表的温室。节点自己声明自己,规则按种类而不是按零件说话。 一旦做到,把摄氏探头换成华氏型号、再加装 CO2 传感器和灌溉阀 —— 服务端代码和规则一行都不用改。 下面是真的跑了两遍的结果。

先看结果 —— 同一份代码,不同的温室

最初的装机。一只摄氏温度计、一只湿度计、一扇天窗。

Greenhouse — TH-100(C) 摄氏探头的 3 节点装机。真实渲染截图
Greenhouse — TH-100(C) 摄氏探头的 3 节点装机。真实渲染截图

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

天窗规则触发 — 26.5 度时 v1=80%。湿度规则因为没有阀门而显示 idle
天窗规则触发 — 26.5 度时 v1=80%。湿度规则因为没有阀门而显示 idle

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

换成 FX-200(F) 华氏探头,加装 CO2 与阀门。发现 5 个节点
换成 FX-200(F) 华氏探头,加装 CO2 与阀门。发现 5 个节点
同一条规则原样触发 — 26.1 度时 v1=80%。画面上的温度计以华氏显示(68.9 F)
同一条规则原样触发 — 26.1 度时 v1=80%。画面上的温度计以华氏显示(68.9 F)

指一下最后那张画面。温度计用自己的单位说 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 {

归纳成一条原则就是:客户端能问的东西,没有一样可以依赖硬件的回应。 扫描之前节点列表是空的没关系 —— 那是那一刻的事实。画面本身不存在,那是另一回事。

改完之后连过三次。要是校验脚本没留下失败日志,这个缺陷就会顶着「偶发」这个标签一直留在那儿。

此内容需要开发者或更高等级

登录并升级您的方案即可继续阅读。

查看方案
Twitter