Expose an MCU’s state the way a PLC exposes its registers: one register table, reachable over a serial CLI, Modbus RTU/TCP, MQTT, and a browser. Pure C99, no allocation.
The same firmware, verbatim, answers a terminal, a Modbus master, an MQTT broker, and the Web Serial panel.
A serial assistant is a tool for the wire: bytes in, bytes out, and the meaning stays in the operator’s head. regtable is a tool for the state behind the wire, and the wire is one of several ways in.
The difference comes from one decision: the interface is nouns, not
verbs. There is no start-pump command anywhere in this library; there is a
pump register, and writing 1 to it.
A command vocabulary grows with every feature, and every protocol reinvents it. A state vocabulary closes: name, type, permission, range. After that, each interface is only a wire format for the same four facts, and validation happens once, below all of them.
Modbus made the same choice in 1979; a PLC exposes registers and coils,
not verbs. Two primitives then cover every interface: pull (a
get, a Modbus read, a poll) and push (a value crossing its
last-published shadow, an MQTT publish).
get · set · info · list, each with --json: one CLI for humans and machines.
FC 03/04/06/16, CRC, spec-faithful exceptions. Any SCADA or PLC master reads and writes.
State published on change, retained; commands on <name>/set.
One description generates the table and its docs; every constraint checked before compiling.
The table renders itself in a browser: values, sliders, toggles, trends. Zero configuration.
on_write checks, on_read computes, on_change reports.
A HAL wraps thousands of silicon registers into typed handles for the firmware engineer. regtable wraps application variables into typed entries for the outside world. Together they are one chain:
static const RegEntry registry[] = { { .name = "temp", .ptr = &temp, .type = REG_FLOAT, .perm = REG_RO }, { .name = "interval", .ptr = &interval, .type = REG_U16, .perm = REG_RW, .min.u = 100, .max.u = 60000, .modbus_addr = 1 }, { .name = "led", .ptr = &led, .type = REG_BOOL, .perm = REG_RW, .on_change = led_changed }, { .name = NULL } }; /* the table is const: it lives in flash */
C99 and the standard library, no dynamic allocation, no platform headers. The transport is two function pointers; switching chips means changing two function bodies.