디스플레이 패널을 만드는 회사가 있다. 좋은 패널을 만든다. 그런데 고객이 종종 물었다. "여기에 터치로 조작하는 제어 화면도 같이 됩니까?" 답은 늘 같았다. "그건 HMI 전문 업체에."
전에 우리는 이 벽이 방식의 벽이지 기술의 벽이 아니라고 적었다. 그때는 그것을 이야기로 적었다. 이번엔 다르게 한다. 그 화면을 실제로 띄우는 코드를 처음부터 끝까지 짓고, 돌리고, 렌더된 픽셀을 그대로 캡처해 싣는다. 아래의 모든 화면은 손으로 그린 그림이 아니라, 지금 이 글을 위해 실제로 실행해 찍은 실 렌더다.
결과부터 — 실제로 도는 화면
위 화면은 자체 GUI 코드 한 줄 없이 떴다. 펌웨어를 굽지도 않았다. 그리고 정적인 그림도 아니다 — 목표 온도를 올리면, 펌웨어 안의 온도 루프가 실제로 돌며 숫자가 살아 움직인다. 아래는 그 과정을 실제로 캡처한 영상이다(22°C → 70°C, 목표 72°C로 수렴).
<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다.
온도는 점프하지 않는다. 목표를 향해 접근한다. 그래서 1차 열역학 모델(Newton 완화)로 시뮬한다 — 이런 루프야말로 실제 하드웨어에선 펌웨어에 남아야 하는 부분이다.
/* 1차 열역학: dT/dt = k·(target − T). 목표로 수렴, 약간의 센서 노이즈. */
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; /* 완화율, 1/s */
double alpha = 1.0 - exp(-k * dt); /* 정확한 이산 스텝 */
g_temp += (g_target - g_temp) * alpha;
double noise = ((double)(rand() % 1000) / 1000.0 - 0.5) * 0.1;
g_temp += noise;
}
페리페럴 하나하나가 이름 붙은 도구가 된다. 요청 한 줄이 들어오면 응답 한 줄이 나간다 — 이 line-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)) {
/* 펌웨어가 보장하는 안전 범위로 입력을 좁힌다. */
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}} ← 1초 뒤, 오르는 중
{"id":4,"ok":true,"result":{"celsius":48.5}} ← 2초 더, 계속
{"id":5,"ok":true,"result":{"level":100}} ← 150을 넣어도 100으로 클램프
온도가 실제로 22 → 33.1 → 48.5로 오르고, 백라이트에 150을 넣어도 펌웨어가 100으로 잘랐다. 어려운 것은 C에 안전하게 가둔다 — 화면 정의가 아무리 이상한 값을 보내도 도구의 입구에서 거른다.
② 브리지 — 도구를 MCP로, 화면을 리소스로 (mcp_server)
브리지는 두 가지만 한다. 펌웨어의 도구를 MCP 도구로 재노출하고, 화면을 정의 리소스로 서빙한다. 자기 장치 로직은 없다 — C와 정의 사이의 전선이다.
먼저 펌웨어와의 링크. 한 요청 라인을 쓰고, id로 짝지어 응답을 기다린다 — 실제 하드웨어라면 같은 코드가 자식 프로세스 대신 시리얼 포트에 쓴다.
/// 펌웨어의 line-JSON stdio 채널과 대화한다 (UART 링크의 호스트 측).
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}}},
// ... 밝기 버튼도 같은 골격: onTap → panel.set_backlight
]}},
};
{{temp}}는 상태에 묶여 있고, 버튼의 onTap은 도구를 부른다. 화면 어디에도 I2C 주소나 GPIO 레지스터가 없다 — 그건 도구 이름 뒤, 펌웨어 C 안에 있다.