빌드

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°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_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}}},
    // ... 밝기 버튼도 같은 골격: onTap → panel.set_backlight
  ]}},
};

{{temp}}는 상태에 묶여 있고, 버튼의 onTap은 도구를 부른다. 화면 어디에도 I2C 주소나 GPIO 레지스터가 없다 — 그건 도구 이름 뒤, 펌웨어 C 안에 있다.


이 콘텐츠는 개발자 이상이 필요합니다

로그인 후 플랜을 업그레이드하면 계속 읽을 수 있습니다.

플랜 보기
Twitter