有一家做显示面板的公司。面板做得好。可客户总在问:「触控的控制画面你们能一起做吗?」答案永远一样:「那得找 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 固件里。唯一变的是,这些能力被当作 可以从外面按名字调用的工具 交了出来。这里用一个 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_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 里。