Skip to content

Communication protocol (ProtoEmb)

The app and the firmware talk over a serial link using ProtoEmb, a small, dependency-light binary protocol toolchain. A single YAML schema defines every message, and code generators emit matching encode/decode code for C (firmware), TypeScript (the app), and Rust (the test rig and the WASM core).

flowchart LR
    schema["MaDProtocol.yaml"] --> gen["generate.py<br/>(Jinja templates)"]
    gen --> c["C codec<br/>(firmware)"]
    gen --> ts["TypeScript codec<br/>(app)"]
    gen --> rs["Rust codec<br/>(SIL + WASM)"]

The toolchain itself is documented in the ProtoEmb README and the canonical wire-format spec. This page summarises the MaD-specific contract.

Frame layer

The link is a half-duplex request/response stream. Every frame starts with a sync byte 0x55; multi-byte fields are little-endian.

READ request:   [0x55] [0x00] [CMD]
WRITE request:  [0x55] [0x01] [CMD] [LEN_LO] [LEN_HI] [DATA…] [CRC8]
NACK response:  [0x55] [0x00] [CMD] [REASON]
ACK response:   [0x55] [0x01] [CMD]
DATA response:  [0x55] [0x02] [CMD] [LEN_LO] [LEN_HI] [DATA…] [CRC8]
NOTIFICATION:   [0x55] [0x03] [0x00] [LEN_LO] [LEN_HI] [DATA…] [CRC8]
  • The type byte is direction-overloaded: 0x00 = READ / NACK, 0x01 = WRITE / ACK, 0x02 = DATA, 0x03 = NOTIFICATION.
  • REASON says why the firmware refused the request (protocol 3 on). Codes below 0x20 belong to ProtoEmb's runtime (REFUSED, UNKNOWN_COMMAND, BAD_CRC, BAD_SIZE, UNAVAILABLE); MaD's own, from 0x20, are the nack_reasons in MaDProtocol.yaml. MaD Control logs the reason's name and shows its meaning in the error, for example device NACKed command 8: BAD_SIZE: The payload is not the size this command takes. A code the app does not know shows as reason 0xNN. Which reasons each message can return is in the protocol-messages reference.
  • CRC8 is CRC-8/MAXIM over the payload only. The parser resynchronises by scanning for 0x55; integrity rests on the CRC.

Payload layer

A struct is packed (bit-level, no padding) or aligned (byte-level, fixed C-type sizes); both produce a fixed wire size known at generation time. Fields can carry a scale, min/max (offset-binary), explicit bit counts, enums, nested structs, fixed arrays, optionals, and tagged unions. The full list of MaD messages and struct sizes is in the protocol-messages reference.

Units

Values cross the wire in firmware-native units; the app converts to display units automatically.

Quantity Wire unit Display unit Scale
Force mN N ÷ 1000
Position µm mm ÷ 1000
Velocity µm/s mm/s ÷ 1000
Time (dwell) ms ms 1

Priority model

The UI queues outgoing messages at two priorities, and only one message is in-flight at a time. The next is sent after an ACK/NACK/DATA or a timeout: 2 seconds for writes and on-demand reads, 250 ms for the periodic sample and state polls (MaD Control), so one lost poll costs a ~¼ s gap in live data rather than a 2-second stall:

  • HIGH — commands and config: gcode uploads, reads/writes, motion commands. Sent first, FIFO.
  • LOW — periodic polling (sample at ~100 Hz, state at 1 Hz). Coalesced so only the latest request per type is kept, and sent only when the high queue is empty.

This guarantees interactive operations are never blocked behind background telemetry.

G-code

Motion profiles are compiled to a small G-code dialect (G0/G1 moves, G4 dwell, G28 home, G90/G91 absolute/relative, G2/G3 arcs, and G122 to signal test completion). Profiles are uploaded to the machine's SD card and played back by the firmware. See the G-code reference.

One schema, byte-identical everywhere

Because all three codecs are generated from the same schema, MaD includes a cross-language conformance test that asserts C, TypeScript, and Rust produce byte-identical wire output. Change the schema, regenerate, and every layer stays consistent.