実装

センサーを差し替えても制御はそのまま — 温室をふたつ作らない方法

著者: 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 msbus.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 {

原則一行にまとめるとこうなる。クライアントが問い合わせられるものは、何ひとつハードウェアの応答に依存してはならない。 まだスキャン前でノード一覧が空なのは構わない — その瞬間の事実だからだ。画面そのものが無いのは別の話だ。

直したあと三回連続で通った。検証スクリプトが失敗ログを残していなかったら、この欠陥は「間欠的」という札を付けたまま残っていただろう。

このコンテンツは開発者以上が必要です

サインインしてプランをアップグレードすると続きを読めます。

プランを見る
Twitter