Implementation Guide
This guide is a single build-and-verify sequence for implementing firmware update
over RDM on any microcontroller. It
assumes you already have a working RDM responder (discovery, DEVICE_INFO, the
standard GET/SET dispatch) and a dual-slot ("A/B") flash layout.
Code is pseudocode distilled from the KLSTR.nano (STM32F103) reference. Where a more capable MCU can do better, that's called out inline - but build the reference model first; it works everywhere.
crc = crc32_update(crc, bytes, len)). The reference
uses the standard reflected polynomial 0xEDB88320.mmmm:FFFFFFFF) becomes your isolated "flash everyone" group.file_setup 0xc8d7,
file_chunk 0xc8cf, file_report 0xc8cd, partition_info 0xc8ce,
control 0xc8c8, firmware_state 0xc8f9 - reused under your manufacturer ID.Build sequence
Step 1 · Define the session state
Keep a single in-flight session - one update at a time is fine, RDM is a single transfer bus.
typedef enum { IDLE, RECEIVING } fw_state_t;
typedef struct {
fw_state_t state;
uint32_t next_chunk; // the chunk_nb we expect next
uint32_t crc_acc; // running CRC32 (init 0xFFFFFFFF)
slot_t slot; // the inactive partition we write
uint8_t listen_bc; // accept broadcast chunks? (set by file_setup)
// + decryptor state, if images are encrypted
} fw_session_t;
static fw_session_t fw; // one global session
Step 2 · Register the six PIDs
Add each PID to your responder's dispatch table with its command class.
rdm_register(0xc8d7, RDM_SET, handle_file_setup);
rdm_register(0xc8cf, RDM_SET, handle_file_chunk);
rdm_register(0xc8cd, RDM_GET, handle_file_report);
rdm_register(0xc8ce, RDM_GET, handle_partition_info); // optional but useful
rdm_register(0xc8c8, RDM_SET, handle_control);
rdm_register(0xc8f9, RDM_GET, handle_firmware_state); // optional
SUB_DEVICE_OUT_OF_RANGE otherwise.Step 3 · Implement file_setup (arm the receiver)
The only per-device step. Bit 0 of flags tells the device to accept the
broadcast chunks that follow.
// SET 0xc8d7 {u32 flags} -> {u32 status}
status_t handle_file_setup(const uint8_t *req, uint8_t *resp) {
uint32_t flags = rd_u32(req);
fw.listen_bc = flags & 1u;
// (do NOT begin a session here; chunk 0 does that - see Step 4)
wr_u32(resp, 0); // status = 0
return ACK;
}
Step 4 · Implement file_chunk (receive the stream)
The heart of it. Three responsibilities: validate the sequence, handle chunk 0 as a header, and write data + fold the CRC.
// SET 0xc8cf {u32 chunk_nb}{<=128 B data} -> {u32 status}
status_t handle_file_chunk(const uint8_t *req, uint16_t len, bool is_broadcast) {
uint32_t chunk_nb = rd_u32(req);
const uint8_t *data = req + 4;
uint16_t dlen = len - 4;
// Ignore broadcast chunks unless armed (Step 3)
if (is_broadcast && !fw.listen_bc) return NO_REPLY;
// --- chunk 0: self-describing header -------------------------------
if (chunk_nb == 0) {
fw_header_t h = parse_header(data, dlen); // flash range, ctrl block, seed
fw.slot = other_partition(); // never the running slot!
fw.next_chunk = 1; // data starts at chunk 1
fw.crc_acc = 0xFFFFFFFF;
fw.state = RECEIVING;
decryptor_init(&fw, &h); // if encrypted
flash_erase_slot(fw.slot); // or erase-on-write per sector
return reply(is_broadcast, /*status*/0);
}
if (fw.state != RECEIVING) return reply(is_broadcast, ERR_NOT_ARMED);
// --- sequence validation ------------------------------------------
if (chunk_nb < fw.next_chunk) { // duplicate / retransmit
return reply(is_broadcast, 0); // idempotent ACK, no rewrite
}
if (chunk_nb > fw.next_chunk) { // gap → out of order
return reply(is_broadcast, ERR_OUT_OF_ORDER);
}
// --- write + checksum ---------------------------------------------
uint8_t plain[128];
decrypt_chunk(&fw, chunk_nb, data, plain); // no-op if unencrypted
uint32_t addr = slot_start(fw.slot) + 128 * (chunk_nb - 1);
flash_write(addr, plain, dlen); // BLOCKING on the reference MCU
fw.crc_acc = crc32_update(fw.crc_acc, plain, dlen);
fw.next_chunk = chunk_nb + 1;
return reply(is_broadcast, 0);
}
Two rules do the heavy lifting: the flash offset is derived from chunk_nb, never
sent explicitly - reject any gap, ACK a duplicate without rewriting, which makes the
stream idempotent. And reply(is_broadcast, …) sends no bytes on a
broadcast request (RDM forbids it) but still runs the handler - that's what
makes vendorcast fire-and-forget.
flash_write() blows the ~3 ms RDM reply
deadline - that's expected: the controller paces the stream (55 ms) and relaxes
its unicast timeout so the device finishes before the next chunk. If your MCU can
run while flash erases (e.g. ESP32-S3), don't block: copy the chunk into a RAM ring
buffer, fold the CRC, and ACK immediately in the callback; drain the buffer to flash
in a background worker task in large (e.g. 4 KB) blocks, erasing one sector at a
time rather than the whole slot up front. Build and verify the blocking path first.Step 5 · Implement file_report (confirm)
A GET, so always unicast. Return the device's verdict and accumulated CRC so the
controller can cross-check.
// GET 0xc8cd {} -> {u32 status, u32 crc}
status_t handle_file_report(uint8_t *resp) {
uint32_t crc = crc32_final(fw.crc_acc); // one's-complement
wr_u32(resp + 0, fw.image_valid ? 0 : ERR_BAD_REPORT);
wr_u32(resp + 4, crc);
fw.listen_bc = 0; // disarm broadcast - avoid surprises
return ACK;
}
Step 6 · Implement control{reset_device} (reboot)
control carries a 16-bit index; 0x0019 = reset_device. Reply first, then
reboot, so the ACK is never lost.
// SET 0xc8c8 {u16 index, u32 value} -> {u32 status}
status_t handle_control(const uint8_t *req, uint8_t *resp) {
uint16_t index = rd_u16(req);
if (index == 0x0019) { // reset_device
wr_u32(resp, 0);
schedule_reboot_after_reply(); // defer to post-response callback
return ACK;
}
wr_u32(resp, 0); // ignore unknown indices, still ACK
return ACK;
}
Step 7 · Wire the trampoline boot logic
You do not boot the new slot from application code:
- When the image is complete and its CRC matches, raise the new slot's boot priority to one above the running slot.
- On reset, the trampoline picks the highest-priority slot that passes its own independent CRC check and jumps to it.
A half-written slot fails that check and is skipped - the device falls back to the previous good image. This is your brick-safety guarantee; do not shortcut it.
Step 8 · Controller: slice the image
const CHUNK_SIZE = 128
const PACING_MS = 55 // inter-chunk delay for the blocking device model
function* chunks(image: Uint8Array) {
// chunk 0 is the self-describing header, then 1..N are data
yield { nb: 0, data: buildHeader(image) }
for (let i = 0, nb = 1; i < image.length; i += CHUNK_SIZE, nb++) {
yield { nb, data: image.subarray(i, i + CHUNK_SIZE) }
}
}
Step 9 · Controller: drive the transfer
Arm every target, stream once to the whole family, confirm each device, retry stragglers, then reboot everyone.
async function flashOverRdm(node, port, targets: Uid[], image: Uint8Array) {
const BROADCAST = uid(MANUFACTURER, 0xffffffff) // mmmm:FFFFFFFF
// 1 · Arm every target (unicast) so they accept broadcast chunks
for (const uid of targets) {
await rdmSet(node, port, uid, 0xc8d7, u32(1)) // flags: listen=1
}
// 2 · Stream the image once, to the whole family (vendorcast, no ACK)
for (const { nb, data } of chunks(image)) {
rdmSetNoReply(node, port, BROADCAST, 0xc8cf, concat(u32(nb), data))
await sleep(PACING_MS) // pace for the slowest device
}
// 3 · Confirm each device individually (unicast GET)
const failed: Uid[] = []
for (const uid of targets) {
const { status, crc } = await rdmGet(node, port, uid, 0xc8cd)
if (status !== 0 || crc !== crc32(image)) failed.push(uid)
}
// 4 · Retry stragglers by unicast, then reboot everyone
for (const uid of failed) await unicastFlash(node, port, uid, image)
for (const uid of targets) {
await rdmSet(node, port, uid, 0xc8c8, control(0x0019)) // reset_device
}
}
unicastFlash is the same loop addressed to one UID, checking each chunk's
status reply - the controller must relax its RDM reply timeout, since the
device answers late while flash is busy. Reserve it for single-fixture targeting
and CRC-failure recovery.
You address every message as (gateway-node, output-port, responder-UID) - no
per-device routing needed: cut-through forwarding carries frames down the chain, and
the manufacturer broadcast reaches every device of your family behind the node. The
controller only needs topology to order reboots (far end of the chain first).
Step 10 · Bring-up: unicast, single device, plaintext
Flash one device by unicast with a plaintext image and relaxed timeouts. Verify
file_report returns status = 0 and a CRC matching your host-side CRC. Reboot and
confirm the new image runs.
Step 11 · Bring-up: interrupt mid-transfer
Pull power (or the cable) partway through. Confirm the device still boots the old image - the trampoline must reject the half-written slot. This proves brick-safety before you trust broadcast.
Step 12 · Bring-up: vendorcast, single device
Switch to the broadcast UID with 55 ms pacing and no per-chunk ACK. Confirm the
armed device writes correctly and the final unicast file_report passes.
Step 13 · Bring-up: vendorcast, full chain
Flash several devices at once. Confirm every device's file_report passes;
deliberately induce a drop and confirm your unicast fallback re-flashes only the
failed device.
Step 14 · Bring-up: encrypted images (if applicable)
Ship an encrypted image and confirm it's accepted only when read-out protection is locked, and rejected otherwise.
Porting notes
hw_type / hw_revision field to the
chunk-0 header and have each device reject a mismatched image (refuse to begin
the write); treat 0 as a wildcard. Without this guard, one broadcast can brick the
wrong variant. (This is the one safety the ESP32-S3 implementation adds over the
nano, precisely because it broadcasts to a mixed fleet.)chunk_nb header.partition_info (0xc8ce) lets the controller introspect A/B
slot addresses and CRCs; firmware_state (0xc8f9) returns a human-readable state
string. Implement them only if you want richer tooling and diagnostics.SET commands addressed to a manufacturer-specific
broadcast UID, and requires responders not to reply to them. The prohibition in
§6.2.10.2 only concerns the all-manufacturer broadcast (FFFF:FFFFFFFF), where PID
numbers would be ambiguous - not the manufacturer-specific one used here.