Every embedded project with more than one team has the same three files. There’s the interface control document, written in Word and last updated at the design review. There’s the firmware’s C header, which is what actually runs. And there’s the app team’s parser, written from whichever version of the ICD they had at the time. Sooner or later the three disagree, and you find out from a field bug that turns out to be a byte-order or scaling mismatch.
The fix isn’t more careful editing. It’s having only one thing to edit. This post builds a small generator that turns one interface file into the C codec, a Python decoder, and the ICD page, then adds three checks that make drift fail the build.
TL;DR
- Describe each message once, in a file the tools can read: fields, types, units, scales, ranges.
- Generate everything else: the firmware codec, the host decoder, and the human-readable ICD.
- In CI, regenerate and compare. A hand edit or a forgotten regeneration fails the build.
- Lock each published layout. Changing it without a new version number fails the build too.
- Test across the boundary: bytes from the C encoder go through the host decoder.
One file describes the message
Here’s a telemetry notification for a sensor node, described the way a person would write it in an ICD table, just in YAML:
message: telemetry
id: 0x21
version: 1
byte_order: little
fields:
- {name: timestamp_ms, type: u32, unit: ms, scale: 1, desc: Time since boot}
- {name: temperature, type: i16, unit: degC, scale: 0.01, min: -40, max: 85, desc: Board temperature}
- {name: pressure, type: u16, unit: kPa, scale: 0.1, min: 0, max: 1000, desc: Line pressure}
- {name: battery, type: u16, unit: mV, scale: 1, min: 0, max: 4500, desc: Battery voltage}
- name: flags
type: u8
desc: Status bits
bits: {0: charging, 1: low_battery, 2: sensor_fault}Code language: YAML (yaml)
Everything a reader of the ICD needs is there: the order on the wire, the type and size of each field, the unit, the scale from raw value to physical value, the valid range, and what each flag bit means. The generator adds a message ID and a version byte to the front of every message.
Generate everything else
icd/gen.py reads that file and writes four others. The C header comes out like this:
/* Generated from icd/telemetry.yaml (sha256 3a655ffe4dc5). Do not edit; run icd/gen.py. */
#ifndef TELEMETRY_H
#define TELEMETRY_H
#include <stddef.h>
#include <stdint.h>
#define TELEMETRY_ID 0x21
#define TELEMETRY_VERSION 1
#define TELEMETRY_SIZE 13 /* bytes on the wire */
#define TELEMETRY_FLAGS_CHARGING (1u << 0)
#define TELEMETRY_FLAGS_LOW_BATTERY (1u << 1)
#define TELEMETRY_FLAGS_SENSOR_FAULT (1u << 2)
struct telemetry {
uint32_t timestamp_ms; /* ms, x1: Time since boot */
int16_t temperature; /* degC, x0.01: Board temperature */
uint16_t pressure; /* kPa, x0.1: Line pressure */
uint16_t battery; /* mV, x1: Battery voltage */
uint8_t flags; /* Status bits */
};Code language: Arduino (arduino)
The encoder writes each byte explicitly instead of copying a packed struct, so the wire format doesn’t depend on the compiler’s padding or the CPU’s byte order:
size_t telemetry_encode(const struct telemetry *m, uint8_t out[TELEMETRY_SIZE])
{
out[0] = TELEMETRY_ID;
out[1] = TELEMETRY_VERSION;
put_u32(out + 2, m->timestamp_ms);
put_u16(out + 6, (uint16_t)m->temperature);
put_u16(out + 8, (uint16_t)m->pressure);
put_u16(out + 10, (uint16_t)m->battery);
out[12] = m->flags;
return TELEMETRY_SIZE;
}Code language: Arduino (arduino)
The Python decoder for the test harness and the app team gets a struct format string ("<BBIhHHB") and the scales, and the ICD page gets the table people actually read:
| Offset | Field | Type | Unit | Scale | Range | Description |
|---|---|---|---|---|---|---|
| 0 | msg_id | u8 | | | | Message ID |
| 1 | version | u8 | | | | Layout version |
| 2 | timestamp_ms | u32 | ms | 1 | | Time since boot |
| 6 | temperature | i16 | degC | 0.01 | -40 to 85 | Board temperature |
| 8 | pressure | u16 | kPa | 0.1 | 0 to 1000 | Line pressure |
| 10 | battery | u16 | mV | 1 | 0 to 4500 | Battery voltage |
| 12 | flags | u8 | | | | Status bits |Code language: plaintext (plaintext)
Every generated file says where it came from and carries a hash of the source, so anyone who opens one knows not to edit it.
Check 1: generated files match the source
Generating files doesn’t stop someone from editing them. Say the app team needs signal strength, and someone adds it straight to the header to unblock a demo. gen.py --check regenerates everything in memory and compares it with what’s in the repository:
$ python3 icd/gen.py --check; echo "exit $?"
--- include/telemetry.h (in the repo)
+++ include/telemetry.h (from the ICD)
@@ -20 +19,0 @@
- uint8_t rssi; /* added for the app team */
1 generated file(s) out of date
exit 1Code language: Shell Session (shell)
The same check catches the opposite mistake: someone changes the YAML and forgets to regenerate. Either way, the build fails before the copies disagree in a release.
Check 2: a published layout doesn’t change
The quieter failure is changing an interface that’s already in the field. Devices running version 1 will keep sending version 1 bytes for years. So the generator keeps a lock file with a fingerprint of every layout it has produced, keyed by message and version:
def check_lock(spec):
wire, _ = layout(spec)
sig = hashlib.sha256(json.dumps([(f["name"], f["type"]) for f in wire]).encode()).hexdigest()[:16]
key = f"{spec['message']} v{spec['version']}"
lock = json.loads(LOCK.read_text()) if LOCK.exists() else {}
if lock.get(key, sig) != sig:
return lock, f"layout of {key} changed: bump 'version' (the old layout is published)"
lock[key] = sig
return lock, NoneCode language: Python (python)
Add a humidity field without touching the version, and both the check and the generator refuse:
$ python3 icd/gen.py; echo "exit $?"
layout of telemetry v1 changed: bump 'version' (the old layout is published)
exit 1
$ sed -i 's/^version: 1/version: 2/' icd/telemetry.yaml && python3 icd/gen.py
wrote 4 files for telemetry v2
$ cat icd/layout.lock
{
"telemetry v1": "faea90a5118f2a19",
"telemetry v2": "e76bfcf9ea3c16fc"
}Code language: Shell Session (shell)
The whole change lands as one reviewable diff, source and outputs together:
$ git diff --stat
docs/icd-telemetry.md | 7 ++++---
host/telemetry.py | 12 ++++++------
icd/layout.lock | 3 ++-
icd/telemetry.yaml | 3 ++-
include/telemetry.h | 7 ++++---
src/telemetry.c | 4 +++-
6 files changed, 21 insertions(+), 15 deletions(-)Code language: Shell Session (shell)
The decoder rejects a version it doesn’t know rather than misreading it. What the host does with older versions, whether it decodes both or asks the device to update, is a decision for the ICD, but it’s now an explicit one.
Check 3: test across the boundary
The last check proves the two sides agree on actual bytes. A small C program encodes a sample with the generated encoder and prints the bytes; a pytest test decodes them with the generated Python decoder:
$ ./build/roundtrip
210140e201001efb590d800e02
$ python3 -m pytest -q -p no:cacheprovider test/test_roundtrip.py 2>&1 | tail -1
3 passed in 0.01sCode language: Shell Session (shell)
The sample includes a negative temperature (1efb is -1250, or -12.50 °C), because sign handling is exactly where hand-written parsers go wrong. In CI, all three checks run on every push:
jobs:
icd:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- name: Generated files match the ICD
run: |
python3 -m venv .venv
.venv/bin/pip install -q pyyaml==6.0.3 pytest==9.1.1
.venv/bin/python icd/gen.py --check
- name: C encoder and Python decoder agree
run: |
mkdir -p build
gcc -std=c11 -Wall -Wextra -Wconversion -Werror -Iinclude test/roundtrip.c src/telemetry.c -o build/roundtrip
.venv/bin/python -m pytest -q test/test_roundtrip.pyCode language: YAML (yaml)
Where this stops
This generator handles fixed-size integer fields, which covers a lot of embedded telemetry and command messages. Real interfaces also have variable-length data, optional fields, enums, and timing rules that say how often a message is sent and what happens when one is late. You can grow a generator like this one, adopt an interface description language that already handles those cases, or keep the interface in a system model. A SysML v2 model can carry the same field definitions and generate the same outputs. The source format matters less than the rule: one place to edit, everything else generated, and CI checking both.
The written parts of an ICD still matter, too: why the message exists, who sends it, and what the receiver must do with each flag. That text can live in the source file as descriptions, so it travels with the fields it explains.
Wrap-up
- Keep one machine-readable definition of each message, with units, scales, and ranges.
- Generate the firmware codec, the host decoder, and the ICD page from it.
- Fail CI on hand edits and on forgotten regeneration.
- Freeze published layouts; any change gets a new version.
- Test real bytes across the boundary, including negative and boundary values.
Which copy of your interface would you trust if two of them disagreed today?
Keep going
Generating interfaces from one source is a big part of The Model Is Not the Code, the book I’m writing on SysML v2 for software engineers, where the source is a system model instead of a YAML file.
If you’d like this for your system, the SysML v2 Interface Model package models your logical architecture and interfaces in SysML v2, generates the ICD, JSON Schemas, and C header from the model, and adds a drift check to CI. See the Services page or get in touch.
Leave a Reply