実装

ボードが自分の画面を差し出す — 実機二台、伝送二種、クライアント一つ

著者: makemind · 2026年7月27日

これまでの記事のハードウェアは全部シミュレータだった。C で書いて実際にコンパイルされ実行されるシミュレータではあったが、ガラスも土も車も無かった。毎回そう書き添えてきた。

今回は違う。机の上にボードが二台、実際に挿さっている。 一台は USB ケーブルで、一台は Wi-Fi の向こうに。

そしてこの記事の論旨は、その二つを繋ぐ方法がどれだけ違い、繋いだ後がどれだけ同じか、である。

まず結果 — 二台のボードが差し出した画面

USB シリアルで繋いだ WeAct H723(STM32H7) が差し出した画面。

On-board LED — Turn On / Turn Off / Read Info. ボードが渡した定義をランタイムがレンダーした実キャプチャ
On-board LED — Turn On / Turn Off / Read Info. ボードが渡した定義をランタイムがレンダーした実キャプチャ

mDNS で見つけて Wi-Fi TCP で繋いだ ESP32 が差し出した画面。同じクライアントなのに、ずっと多い。

Live uptime 45745 s · Subscribe/Unsubscribe · Snapshot · Store name/Load
Live uptime 45745 s · Subscribe/Unsubscribe · Snapshot · Store name/Load

どちらの画面もこのリポジトリには無い。ボードが差し出した。

全体像

STM32H723 ──UART 115200──▶ serial_bridge (C) ──┐
                                                ├──stdio──▶ mcp_client + ランタイム
ESP32     ──Wi-Fi TCP:6270──▶ tcp_bridge  (C) ──┘            (両側で同一)
              ▲
              └─ mDNS `_mcp._tcp` で発見(住所を人が書かない)

① 伝送はプロセスだ

MCP クライアントはコマンドを起動してその stdio で話す方法をすでに知っている。ならば 伝送をプロセスにすれば、クライアントにシリアル対応もソケット対応も要らなくなる。

シリアル側のブリッジの核心はこうだ。

struct termios tio;
tcgetattr(fd, &tio);
cfmakeraw(&tio);          /* エコー無し、行編集無し、CR/LF 変換無し */
cfsetispeed(&tio, speed);
cfsetospeed(&tio, speed);
tio.c_cflag |= (CLOCAL | CREAD);
tio.c_cflag &= (tcflag_t)~CRTSCTS;
tcsetattr(fd, TCSANOW, &tio);

cfmakeraw が無ければ端末ドライバが行を編集し CR を差し込んで JSON が壊れる。組み込みでの「なぜ時々パースが失敗するのか」の相当数がここにある。

TCP 側は別の場所でちょうど一行が重要だ。

/* 要求は短い一行で、答えはすぐ要る。Nagle がセグメントを埋めようと
 * 抱え込めば、得るものなく遅延だけが増える。 */
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));

そして 両側がまったく同じにやることがひとつある。 ボードは人が読むログをプロトコルと同じ線に流す。

/* ボードは JSON-RPC 応答と同じ線に人が読むログを打つ。それは
 * プロトコルではなくクライアントのパーサを怒らせるので、JSON に
 * 見える行だけを通す。残りは stderr へ — 目には見えるが応答と
 * 取り違えられない場所へ。 */
if (up[0] == '{') {
    printf("%s\n", up);
    fflush(stdout);
} else {
    fprintf(stderr, "[board] %s\n", up);
}

実際 ESP32 のプロビジョニングコンソールは今も同じ UART に W (45475251) console_prov: ... のような行を打つ。この三行が無ければクライアントはその行を応答として受け取って死ぬ。

② 繋いだ後は同じだ

ブリッジ二つの違いは上が全部だ。その上のクライアントはこうなっている。

final result = await McpClient.createAndConnect(
  config: McpClient.simpleConfig(name: 'Board Probe', version: '1.0.0'),
  transportConfig: TransportConfig.stdio(
    command: link.command,      // serial_bridge か tcp_bridge
    arguments: link.arguments,  // [ポート, ボーレート] か [ホスト, ポート]
  ),
);

伝送を選ぶ仕事が、どのプログラムを起動するかを選ぶ仕事になる。 その下でコードは分岐しない。

③ ボードごとに画面の形が違う

ここは実際に処理が必要だった。二台のボードが ui://app に別のものを入れて寄こす。

STM32 はページひとつをそのまま渡す(656 B)。

{"type":"page","title":"WeAct H723 MCP Node","content":{ ... }}

ESP32 は アプリケーションを渡す(158 B)。画面ではなく画面の地図だ。

{"type":"application","title":"ESP32 MCP Node",
 "routes":{"/":"ui://page/main"},"initialRoute":"/",
 "lifecycle":{"onReady":[{"type":"tool","tool":"sys.info"}]}}

そこでクライアントが分岐をひとつ持つ。

// ボードごとに形が違う。ひとつはページをそのまま渡し、もうひとつは
// ルートを持つアプリケーションを渡して、最初の画面はその先にある。
Map<String, dynamic> screen = def;
if (def['type'] == 'application') {
  final routes = (def['routes'] as Map).cast<String, dynamic>();
  final initial = def['initialRoute'] as String? ?? '/';
  final uri = routes[initial] as String;
  final page = await client.readResource(uri);
  screen = jsonDecode(page.contents.first.text!) as Map<String, dynamic>;
}

ESP32 のそのページは 1894 B で、自分の生命周期に購読を掛けてある。

"lifecycle":{
  "onReady":[{"type":"resource","action":"subscribe","uri":"sensor://uptime","binding":"uptime"}],
  "onDestroy":[{"type":"resource","action":"unsubscribe","uri":"sensor://uptime"}]
}

画面が開けばセンサーを購読し、閉じれば購読を解く。その宣言がボードの中に入っている。

④ 住所を人が書かない

ESP32 に繋ぐとき IP を手で入れていない。ボードが自分を広告する。

$ dns-sd -B _mcp._tcp
Timestamp     A/R Flags if Domain  Service Type   Instance Name
14:04:49.297  Add     2 15 local.  _mcp._tcp.     ESP32 MCP Node

$ dns-sd -L "ESP32 MCP Node" _mcp._tcp
ESP32 MCP Node._mcp._tcp.local. can be reached at mcp-esp32.local.:6270
 v=0.1.0 id=esp32.node proto=ndjson

TXT レコードが proto=ndjson と言っている — 行単位の JSON-RPC。UART で来ていたのとまったく同じバイトだ。だからソケットさえ開けば翻訳するものは残らない。

検証スクリプトがこれをそのまま使う。ハードコードされた住所は無い。

RESOLVED=$(timeout 6 dns-sd -B _mcp._tcp 2>/dev/null | awk 'NR>4 {...}')
DETAIL=$(timeout 6 dns-sd -L "$RESOLVED" _mcp._tcp 2>/dev/null | grep "can be reached at")
HOSTPORT=$(echo "$DETAIL" | sed -n 's/.*can be reached at \([^ ]*\).*/\1/p' | sed 's/\.$//;s/\.:/:/')

⑤ 実行・検証ログ

一度の実行で二つのリンクを続けて回した原文だ。

[+     2ms] links to probe: serial, tcp
[+    33ms] [serial] connected via ../serial_bridge/serial_bridge /dev/cu.usbmodem365D395E33331 115200
[+    53ms] [serial] tools: led.set, sys.info
[+    65ms] [serial] resources: ui://app, ui://app/info, bundle://manifest.json
[+    79ms] [serial] ui://app — 656 B, type "page", title "WeAct H723 MCP Node"
[+   426ms] [serial] led.set({"on":true}) -> "LED on"  (1 ms)
[+   438ms] [serial] sys.info({}) -> "LED=on uptime=185582085ms"  (11 ms)
[+   450ms] [serial] led.set({"on":false}) -> "LED off"  (10 ms)
[+   462ms] [serial] sys.info({}) -> "LED=off uptime=185582110ms"  (11 ms)
[+   463ms] [serial] uptime advanced 185582085 -> 185582110 ms

[+  5980ms] [tcp] connected via ../tcp_bridge/tcp_bridge mcp-esp32.local 6270
[+  6044ms] [tcp] tools: led.set, sys.info
[+  6110ms] [tcp] resources: ui://app, ui://page/main, ui://app/info, bundle://manifest.json, sensor://uptime
[+  6169ms] [tcp] ui://app — 158 B, type "application", title "ESP32 MCP Node"
[+  6169ms] [tcp] application — initialRoute "/" -> ui://page/main
[+  6476ms] [tcp] ui://page/main — 1894 B
[+  6512ms] [tcp] sensor://uptime read once -> {"uptime_s":47095} (bound as "uptime", not streamed)
[+  6622ms] [tcp] led.set({"on":true}) -> "LED on"  (24 ms)
[+  6692ms] [tcp] sys.info({}) -> "LED=on uptime=47095369ms"  (69 ms)
[+  6714ms] [tcp] led.set({"on":false}) -> "LED off"  (21 ms)
[+  6757ms] [tcp] sys.info({}) -> "LED=off uptime=47095451ms"  (43 ms)
[+  6758ms] [tcp] uptime advanced 47095369 -> 47095451 ms
[+  6759ms] done — 2 link(s) probed

ビルドと通過はこうだ。

$ cc -O2 -o serial_bridge serial_bridge.c
$ cc -O2 -o tcp_bridge tcp_bridge.c
$ flutter analyze
No issues found!
$ bash verify.sh
   /dev/cu.usbmodem365D395E33331 -> WeAct H723 MCP Node
   discovered "ESP32 MCP Node" at mcp-esp32.local:6270
   2 link(s) probed · screens rendered from the boards' own definitions · LED round-tripped on each

実測値

UART (STM32H723)Wi-Fi TCP (ESP32)
led.set 往復1 · 10 ms24 · 21 ms
sys.info 往復11 · 11 ms69 · 43 ms
接続から画面受信まで46 ms189 ms
ui://app サイズ656 B (page)158 B (application)
最初の画面1,894 B (ui://page/main)

サイズと時間は性質が違う。 サイズはファイルなので何度測っても同じだが、時間は実行ごとに変わる。 上の表はこの記事と一緒に載せた run.log の一回の実行から取った値で、別の実行では Wi-Fi 側がこれより二倍以上になったこともある。だからここで読むべきは正確な数字ではなく 桁の違いだ — UART は一桁から十数ミリ秒、Wi-Fi は数十ミリ秒。UI を設計するとき「押せばすぐ」を期待していい側と、応答待ちを画面に描かねばならない側が、その線で分かれる。

LED が実際に点いて消える。 そして状態はローカル変数ではなく ボードに問い返して確かめた。検証スクリプトが両方向を要求する — 片方だけ通るのは単なるエコーかもしれないからだ。そのうえで uptime が二度の読み取りのあいだに前進したかまで見る。固定応答なら越えられない検査だ。

測れなかったもの

  • ESP32 画面の Live uptime はストリーミングではなく一度読んだ値だ。ボードの実際の uptime(45,745 s)ではあるが、このハーネスは生命周期アクションを駆動しないので購読が掛かっていない。ログにも read once … (bound as "uptime", not streamed) と残した。購読ストリームの実動作はこの記事では測っていない。
  • Wi-Fi の遅延は 一回の実行、一台のルータ、ひと部屋の中で測った値だ。標本が少なく環境がひとつ。実際に回し直すたびに値が揺れたし、特に mDNS 照会を含む実行では接続まで数秒かかることもあった — 時間の値が再現しないということ自体が観測だ。
  • BLE・HTTP・USB CDC はこの記事では繋いでいない。ボードは対応しているが、今回の実行に入れていない。
  • 二台のボードのファームウェアは私が書いていない。この記事が作ったのは 繋ぐ側だ。

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

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

プランを見る
Twitter