ディスプレイパネルを作る会社がある。良いパネルだ。ところが客からはこう聞かれ続ける。「タッチの制御画面もそちらでできませんか」。答えはいつも同じだった。「それは HMI の専門会社へ」。
この壁は技術の壁ではなく やり方の壁 だと、以前ここに書いた。あのときは 話 として書いた。今回は違うやり方をする。その画面を実際に出すコードを最初から最後まで作り、走らせ、レンダーされたピクセルをそのまま載せる。 以下の画面はどれも手描きの画像ではなく、この記事のために実行して撮った実レンダーだ。
まず結果 — 実際に動く画面
この画面は、自前の GUI コードを一行も書かずに出た。ファームウェアの書き込みもしていない。そして静止画ではない — 目標値を上げればファームウェア内の温度ループが本当に回り、数字が動く。下はそれが起きている実キャプチャだ(22°C → 70°C、目標 72 に収束していく)。
<video src="./media/heating.webp" controls loop muted playsinline></video>
(動画の代替:22°C から始まり 33 → 48 → 60 → 70°C とコマごとに上がっていく実レンダーの連続。各コマの数字は、その瞬間にシミュレート・ファームウェアが返した実際の値だ。)
なぜこれが可能なのかを、これから サンプル全体を並べて一つずつ見ていく。この記事のコードはすべて content/sample/lcd-hmi/ にそのままあり、持ち出してすぐ走らせられる。
全体像 — 三つのパッケージが噛み合う
肝は三つをきれいに分けることだ。そしてその三つを、それぞれ別のパッケージが受け持つ。
panel_firmware (C) ──stdio/UART──▶ bridge_server (Dart·mcp_server) ──MCP──▶ panel_app (Flutter·mcp_client + flutter_mcp_ui_runtime)
STM32 をシミュレート 道具のプロキシ+画面定義の配信 定義をレンダー、ボタンが道具を呼ぶ
| 部位 | パッケージ | 役割 |
|---|---|---|
| ファームウェア | (C、依存なし) | ペリフェラル(温度センサ、PWM)を 名前の付いた道具 として差し出す。リアルタイムのループは C のままここに残る。 |
| ブリッジ | mcp_server | ファームウェアの道具を MCP の道具として出し直し、画面を ui://panel リソースとして配る。 |
| アプリ | mcpclient + fluttermcpuiruntime | サーバーから定義を受け取ってレンダーする。ボタン押下 → 道具呼び出し → ファームウェア。 |
三つのパッケージが ひとつの連鎖に噛み合う。では各部位の実際のコードだ。
① ファームウェア — ペリフェラルを道具に(C)
パネルの難しい部分 — センサを読み、バックライトを駆動するリアルタイムの仕事 — は、C のファームウェアの中にそのまま残る。変わるのは、その能力が 外から名前で呼べる道具 として差し出されることだけだ。ここでは STM32 ボードを C のプログラムでシミュレートしている — シミュレートだが、本当にコンパイルされて走る C だ。
温度は跳ばない。目標に 近づく。だから一次の熱モデル(ニュートンの緩和)でシミュレートしている — 実機ならまさにファームウェアに残さねばならない種類のループだ。
/* First-order thermal: dT/dt = k·(target − T). Converges to target
* with a little sensor noise. */
static void thermal_update(void) {
double t = now_seconds();
double dt = t - g_last_tick;
if (dt <= 0.0) return;
g_last_tick = t;
const double k = 0.25; /* relaxation rate, 1/s */
double alpha = 1.0 - exp(-k * dt); /* exact discrete step */
g_temp += (g_target - g_temp) * alpha;
double noise = ((double)(rand() % 1000) / 1000.0 - 0.5) * 0.1;
g_temp += noise;
}
ペリフェラルはそれぞれ 名前の付いた道具 になる。要求の行がひとつ入り、応答の行がひとつ出る — この行 JSON が UART / USB-CDC のメッセージ層をシミュレートしている。
if (strcmp(tool, "device.get_temp") == 0) {
thermal_update();
printf("{\"id\":%ld,\"ok\":true,\"result\":{\"celsius\":%.1f}}\n", (long)id, g_temp);
} else if (strcmp(tool, "panel.set_backlight") == 0) {
double v;
if (json_num(line, "level", &v)) {
/* Clamp the input into the safe range the firmware guarantees. */
if (v < 0) v = 0;
if (v > 100) v = 100;
g_backlight = (int)(v + 0.5);
printf("{\"id\":%ld,\"ok\":true,\"result\":{\"level\":%d}}\n", (long)id, g_backlight);
}
}
これが話ではなく本当に走る証拠 — ビルドして数行流し込んだ実際の出力だ。
$ cc -O2 -o panel_firmware panel_firmware.c -lm
$ echo '...' | ./panel_firmware
{"id":1,"ok":true,"result":{"celsius":22.0}} ← 開始
{"id":2,"ok":true,"result":{"celsius":72.0}} ← 目標を 72 に
{"id":3,"ok":true,"result":{"celsius":33.1}} ← 一秒後、上昇中
{"id":4,"ok":true,"result":{"celsius":48.5}} ← さらに二秒、まだ上昇
{"id":5,"ok":true,"result":{"level":100}} ← 150 を入れると 100 で切られる
温度は本当に 22 → 33.1 → 48.5 と上がり、バックライトに 150 を入れたらファームウェアが 100 に切った。難しいものは C の中に安全に閉じたままだ — 画面定義がどんな妙な値を送ってきても、道具の 入口 で濾される。
② ブリッジ — 道具を MCP に、画面をリソースに(mcp_server)
ブリッジは二つしかしない。ファームウェアの道具を MCP の道具として出し直し、画面を定義リソースとして配る。自前のデバイスロジックは持たない — C と定義のあいだの 配線 だ。
まずファームウェアへの接続。要求の行をひとつ書き、id で突き合わせた応答を待つ — 実機なら同じコードが子プロセスではなくシリアルポートに書く。
/// Talks to the firmware's line-JSON stdio channel (the host side of the
/// UART link).
class FirmwareLink {
Future<Map<String, dynamic>> call(String tool, [Map<String, dynamic> args = const {}]) async {
final id = _nextId++;
final completer = Completer<Map<String, dynamic>>();
_pending[id] = completer;
_proc.stdin.writeln(jsonEncode({'id': id, 'tool': tool, 'args': args}));
final reply = await completer.future;
if (reply['ok'] != true) throw Exception('firmware error: ${reply['error']}');
return (reply['result'] as Map).cast<String, dynamic>();
}
}
MCP の道具はそれぞれファームウェアへの薄いプロキシだ。mcp_server の addTool で登録し、結果は画面がバインドする状態 JSON として返る。
server.addTool(
name: 'device.set_target',
description: 'Set the heater target temperature',
inputSchema: const {'type': 'object', 'properties': {'delta': {'type': 'number'}}},
handler: (args) async {
final delta = (args['delta'] as num?)?.toDouble() ?? 0.0;
final cur = await firmware.call('device.get_target');
final target = (cur['celsius'] as num).toDouble() + delta;
final set = await firmware.call('device.set_target', {'celsius': target});
final temp = await firmware.call('device.get_temp');
return _state({'target': set['celsius'], 'temp': temp['celsius']});
},
);
そして 画面そのものが定義だ。 ファームウェアに焼いた画面コードではなく、「温度を大きな字で、その下にボタン、ボタンを押したら この道具 を呼ぶ」と言う宣言的な JSON のかたまりひとつ。ウィジェットと道具を結ぶのは名前の参照ひとつだけだ。
const Map<String, dynamic> panelDefinition = {
'type': 'page',
'state': {'initial': {'temp': 22.0, 'target': 22.0, 'backlight': 70}},
'content': { 'type': 'center', 'child': { 'type': 'linear', 'direction': 'vertical', 'children': [
{'type': 'text', 'content': '{{temp}}°C', 'style': {'fontSize': 56, 'fontWeight': 'bold'}},
{'type': 'button', 'label': '+ Target',
'onTap': {'type': 'tool', 'tool': 'device.set_target', 'params': {'delta': 1}}},
// ... the brightness buttons share the skeleton: onTap → panel.set_backlight
]}},
};
{{temp}} は状態にバインドされ、ボタンの onTap が道具を呼ぶ。画面のどこにも I2C アドレスも GPIO レジスタも無い — それらは道具の名前の裏、ファームウェアの C の中だ。