构建

LCD 厂商三十分钟做出 HMI — 面板说出工具,画面成为定义

作者: 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 固件里。唯一变的是,这些能力被当作 可以从外面按名字调用的工具 交了出来。这里用一个 C 程序模拟 STM32 板 —— 是模拟,但是 真的会编译、会运行 的 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