Build

An LCD Maker Builds an HMI in 30 Minutes — The Panel Speaks Tools, the Screen Becomes a Definition

By makemind · Jan 15, 2026

There is a company that makes display panels. Good panels. But customers kept asking, "can you do the touch control screen on it as well?" The answer was always the same. "For that, an HMI specialist."

We once wrote that this wall is a wall of method, not of technology. Back then we wrote it as a story. This time we do it differently. We build the code that actually puts that screen up, from beginning to end, run it, and publish the rendered pixels as they came out. Every screen below is not a hand-drawn image but a real render, executed and captured for this article.

The result first — a screen that actually runs

Temperature control HMI — starting state (22°C). A capture of flutter_mcp_ui_runtime really rendering the definition
Temperature control HMI — starting state (22°C). A capture of fluttermcpui_runtime really rendering the definition

That screen came up without a single line of our own GUI code. No firmware was flashed either. And it is not a static image — raise the target and the temperature loop inside the firmware really turns, and the number moves. Below is a real capture of that happening (22°C → 70°C, converging on a target of 72).

<video src="./media/heating.webp" controls loop muted playsinline></video>

(Video alternative: a real render sequence starting at 22°C and climbing frame by frame 33 → 48 → 60 → 70°C. The number in each frame is the actual value the simulated firmware returned at that moment.)

How this is possible, we now see by laying the whole sample out, piece by piece. All the code in this article is in content/sample/lcd-hmi/ exactly as shown, and can be taken away and run immediately.

The whole picture — three packages mesh

The heart of it is dividing three pieces cleanly. And each of the three is handled by a different package.

panel_firmware (C)  ──stdio/UART──▶  bridge_server (Dart·mcp_server)  ──MCP──▶  panel_app (Flutter·mcp_client + flutter_mcp_ui_runtime)
   STM32 simulated                     tool proxy + serves the screen definition     renders the definition, buttons call tools
PiecePackageRole
Firmware(C, no dependencies)Exposes peripherals (temperature sensor, PWM) as named tools. The real-time loop stays here in C.
Bridgemcp_serverRe-exposes the firmware's tools over MCP, and serves the screen as a ui://panel resource.
Appmcpclient + fluttermcpuiruntimeReceives the definition from the server and renders it. Button tap → tool call → firmware.

Three packages mesh into one chain. Now the actual code of each piece.


① Firmware — peripherals as tools (C)

The hard part of the panel — the real-time work of reading sensors and driving the backlight — stays exactly where it is, in the C firmware. All that changes is that those capabilities are exposed as tools callable by name from outside. Here an STM32 board is simulated by a C program — simulated, but C that really compiles and runs.

Temperature does not jump. It approaches the target. So it is simulated with a first-order thermal model (Newton relaxation) — precisely the kind of loop that must stay in firmware on real hardware.

/* 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;
}

Each peripheral becomes a named tool. One request line in, one response line out — this line-JSON simulates the UART/USB-CDC message layer.

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);
    }
}

Evidence that this is not talk but really runs — actual output from building it and feeding in a few lines:

$ cc -O2 -o panel_firmware panel_firmware.c -lm
$ echo '...' | ./panel_firmware
{"id":1,"ok":true,"result":{"celsius":22.0}}     ← start
{"id":2,"ok":true,"result":{"celsius":72.0}}     ← target set to 72
{"id":3,"ok":true,"result":{"celsius":33.1}}     ← one second later, climbing
{"id":4,"ok":true,"result":{"celsius":48.5}}     ← two more, still climbing
{"id":5,"ok":true,"result":{"level":100}}        ← feed it 150 and it clamps to 100

The temperature really climbs 22 → 33.1 → 48.5, and feeding 150 to the backlight got cut to 100 by the firmware. The hard things stay safely locked in C — however strange a value the screen definition sends, it is filtered at the tool's entrance.


② Bridge — tools over MCP, the screen as a resource (mcp_server)

The bridge does only two things. It re-exposes the firmware's tools as MCP tools, and serves the screen as a definition resource. It has no device logic of its own — it is wiring between the C and the definition.

First the link to the firmware. Write one request line, wait for the response matched by id — on real hardware the same code writes to a serial port instead of a child process.

/// 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>();
  }
}

Each MCP tool is a thin proxy to the firmware. Registered with mcp_server's addTool, and the result comes back as the state JSON the screen will bind to.

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']});
  },
);

And the screen itself is a definition. Not screen code burned into the firmware, but one declarative block of JSON saying "the temperature in large type, buttons under it, and pressing a button calls this tool". What links the widget and the tool is a single name reference.

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}} is bound to state, and the button's onTap calls a tool. Nowhere in the screen is an I2C address or a GPIO register — those are behind the tool name, inside the firmware C.


This content requires Developer or above

Sign in and upgrade your plan to continue reading.

View Plans
Twitter