Skip to content

Provisioning and storage

rnodeconf gives an RNode an identity, a signature, and a configuration it remembers. oxinode stores all three, plus its Bluetooth bonds, in a device record in the flash the bootloader reserves and never touches, so provisioning survives a reflash.

What a provisioned RNode is

A 256-byte EEPROM image, as rnodeconf's ROM class defines it:

Bytes What
0x000x0A product, model, hardware revision, 4-byte serial, 4-byte manufacture time
0x0B0x1A MD5 of those eleven bytes
0x1B0x9A RSA signature over that MD5, 128 bytes
0x9B lock byte 0x73, written last
0x9C0xA7 the stored radio configuration, with its own validity byte

The host reads it, recomputes the MD5, verifies the signature against the vendor keys plus any local signing key, and reports the device as unverified if none match. The device cannot verify any of that (the keys are on the host); what it has to do is store the image faithfully and mean the same thing by "provisioned" as the host does. So Eeprom::status checks the checksum too, which is why there is an MD5 in oxinode-core.

CMD_ROM_WRITE carries a one-byte address, so 256 is the whole addressable space and Eeprom::read and write are total; a test walks all 256.

Identity

The board says it is a homebrew RNode: product 0xf0, model 0xff, board 0x32. That is the honest answer and the safe one: for an nRF52 with model 0xff and a board that is not a RAK4631, rnodeconf --update refuses with No firmware found for this board rather than offering to flash a RAK4631 image onto an LR1121. The cost is that rnodeconf -i prints the band and power from its own table for model 0xff.

The device hash

CMD_DEV_HASH is the one thing here no host pins: rnodeconf --sign asks the device for thirty-two bytes, signs whatever it is given, and hands the signature back to be stored. oxinode's definition is SHA-256 over the identity block and the MCU's factory device ID: the provisioning and the physical chip. Two people provisioning their own boards with their own rnodeconf counters both get serial number 1, and the device ID is what stops a signature made for one from validating the other. It is answered only when the device is provisioned; an unprovisioned board that answered would let a signature be made over erased bytes.

The device record

0xEA000 ┌──────────────────────────────────────────┐
        │ magic "OXN1" · version 2 · length        │
        │ EEPROM image (256 bytes)                 │
        │ target firmware hash (32 bytes)          │
        │ bonds: count + up to 4 × (address, keys) │
        │ CRC-32 over everything above             │
        └──────────────────────────────────────────┘

0xEA000 is the first page of the 40 KB the bootloader reserves with DFU_APP_DATA_RESERVED and refuses to write through on both of its flashing paths. The address is derived, not written twice: build.rs exports APP_FLASH_END from memory.x. See Memory layout.

The record has a checksum, and the reason is the radio. Flash writes are not atomic. For the identity block a torn record would be survivable, since the host's own MD5 would catch it. For the stored radio configuration it would not: a torn record can leave a plausible frequency, and a device in TNC mode brings its radio up from that at boot with nobody watching. So the record carries a CRC-32 and a record that fails it is discarded rather than partially trusted. A test flips every byte of a record in turn and asserts none of them decode. A version 1 record (no bonds) still decodes.

Writes are debounced, not committed. rnodeconf provisions with 155 single-byte writes six milliseconds apart, and a page erase stalls this CPU for about 85 ms. Committing each one would need thirteen seconds to absorb one second of commands. So Action::Persist means "eventually": the image is held in RAM, the timer restarts on every write, and one commit follows 250 ms after the last. The whole burst costs one erase. A 1200-baud touch and CMD_RESET also force a commit, since a reflash is when losing a provisioning would be least welcome and the host looks at the result after a reset.

sequenceDiagram
    participant C as rnodeconf
    participant P as Protocol
    participant S as Storage (RAM copy)
    participant F as flash 0xEA000
    C->>P: CMD_ROM_WIPE 0xF8
    P->>S: erase image
    loop 155 writes, ~6 ms apart
        C->>P: CMD_ROM_WRITE [addr, value]
        P->>S: image[addr] = value, restart 250 ms timer
    end
    S->>F: one page erase + write (CRC over the record)
    C->>P: CMD_ROM_READ
    P-->>C: 256 bytes (host checks MD5, verifies signature)
    C->>P: CMD_DEV_HASH
    P-->>C: SHA-256(identity ‖ device ID)
    C->>P: CMD_DEV_SIG (64 bytes)
    P->>S: store, commit after 250 ms

TNC mode

A device with a stored configuration comes up on air by itself; that is what rnodeconf --tnc asks for and the only reason to store a configuration. The stored values go through the same validation as anything a host sends, because they were written by a tool that does not know what this radio can do. A configuration that does not validate leaves the radio off with the reason in the log, exactly as an impossible request from a host would. A reset takes about 420 ms to reach a configured radio with no host attached.

From the panel, Save Config stores the live configuration and makes the board a TNC; in TNC mode a confirmed panel edit goes into the stored configuration too. See The interface.

Bluetooth bonds

Up to four bonds are kept in the record, oldest reused first, written on the same 250 ms timer and reloaded into the host stack at boot. iOS keeps its half of a bond the board has forgotten and then refuses the device until the user deletes it by hand, which is why they are persisted. Forget Phones on the Bluetooth screen clears them. See Bluetooth.

Flash writes and the Bluetooth controller

Erasing a page of the nRF52840's flash stalls the CPU for about 85 ms, and the Bluetooth controller cannot hold a connection through that. So while the controller is up the record is not written with the NVMC directly: it goes through the controller's own flash scheduler (nrf_mpsl::Flash), which erases the page in 10 ms partial slices and writes a few words at a time, each inside a timeslot the controller fits between its radio events. A provisioning run, or a bond being stored, leaves a connected phone connected. The write takes longer end to end -- a few hundred milliseconds with a connection up -- and a timeslot the scheduler could not fit comes back as an error rather than a stall, which the firmware answers by starting the write over, up to four times.

The two paths sit behind one trait in the firmware's store module. The record's layout and the decision what to write stay in oxinode-core, which is host-tested; only the last step differs. An image without a controller, or the product image on a boot where the controller failed to start, drives the NVMC directly, which stalls the CPU but has nothing left to stall. The boot log says which: writes via mpsl or writes via nvmc.

The write is awaited to completion by the modem loop that owns the record, so bytes that arrive while it is in flight wait in the pipe rather than restart the 250 ms timer, and a reset requested by the host does not happen until the record has landed.