Firmware over RDM

Implementation Guide

A step-by-step, MCU-agnostic guide to implementing firmware update over RDM - the responder on your device and the streaming logic on the controller.

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.

Dual-slot flash
prerequisite required
Two firmware partitions (A and B) plus a small trampoline boot-loader that picks the highest-priority valid slot. You write the slot you are not running from.
RDM responder
prerequisite required
A working RDM stack with a parameter/PID dispatch table you can extend, and access to whether an incoming request was broadcast (so you can suppress the reply).
CRC32
prerequisite required
An incremental CRC32 routine (crc = crc32_update(crc, bytes, len)). The reference uses the standard reflected polynomial 0xEDB88320.
A byte you own
prerequisite
One RDM manufacturer ID for your device family. Its broadcast UID (mmmm:FFFFFFFF) becomes your isolated "flash everyone" group.
Pick your six PID numbers up front and keep them identical on the device and in the controller. This guide uses the reference set - 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
Every handler should reject any sub-device other than root - firmware lives on the root device. NACK with 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.

Blocking write vs. the RDM deadline. On an MCU that halts the core during flash erase/write (STM32F1 and friends), 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:

  1. When the image is complete and its CRC matches, raise the new slot's boot priority to one above the running slot.
  2. 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

Ship it as a fallback. RDM flashing takes minutes and lacks per-chunk retransmit. Keep network OTA as the default and present RDM update to operators as the deliberate no-network recovery path it is.
Copyright © 2026