ガラス温室のコントローラを一度でも触ったことがある人は知っている。温度センサーひとつを別モデルに替える仕事が、なぜ見積書に「再開発」として載るのかを。
理由はたいていコードの中にある。どのセンサーがどのリレーに繋がっているかをプログラムが知っている。その対応表がソースのあちこちに散らばっていて、センサーを替えれば値の範囲が変わり単位が変わり、するとその表を知る全ての場所を直さねばならない。部品ひとつがシステム全体を人質に取る。
この記事は、その対応表をそもそも持たない温室を作ってみた記録だ。ノードが自分を宣言し、規則は部品ではなく種類で語る。 それができれば、摂氏センサーを華氏モデルに差し替え、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 の区間) |
| 規則 1 回の適用(スキャン 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 {
原則一行にまとめるとこうなる。クライアントが問い合わせられるものは、何ひとつハードウェアの応答に依存してはならない。 まだスキャン前でノード一覧が空なのは構わない — その瞬間の事実だからだ。画面そのものが無いのは別の話だ。
直したあと三回連続で通った。検証スクリプトが失敗ログを残していなかったら、この欠陥は「間欠的」という札を付けたまま残っていただろう。