実装

LCDメーカーがHMIを30分で — パネルが道具を語り、画面が定義になる

著者: makemind · 2026年1月15日

ディスプレイパネルを作る会社がある。良いパネルだ。ところが客からはこう聞かれ続ける。「タッチの制御画面もそちらでできませんか」。答えはいつも同じだった。「それは HMI の専門会社へ」。

この壁は技術の壁ではなく やり方の壁 だと、以前ここに書いた。あのときは として書いた。今回は違うやり方をする。その画面を実際に出すコードを最初から最後まで作り、走らせ、レンダーされたピクセルをそのまま載せる。 以下の画面はどれも手描きの画像ではなく、この記事のために実行して撮った実レンダーだ。

まず結果 — 実際に動く画面

温度制御 HMI — 開始状態(22°C)。flutter_mcp_ui_runtime が定義を本当にレンダーしたキャプチャ
温度制御 HMI — 開始状態(22°C)。fluttermcpui_runtime が定義を本当にレンダーしたキャプチャ

この画面は、自前の 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_serveraddTool で登録し、結果は画面がバインドする状態 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 の中だ。


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

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

プランを見る
Twitter