Fast codeplug protocol
A second way to read and write the codeplug, beside the open-firmware CPS (cps-protocol.md 2). It moves the same
bytes: the open-firmware image of open-firmware-cps.md 7, as the CPS shows it. It differs in how they travel:
- Whole 4 KB blocks, the flash's erase unit.
- Blocks of 0xFF are reported, never sent, in both directions.
- The host learns each block's CRC-32 first, so only blocks that changed move. A host with an image from an earlier read reads only the difference, and writes only the blocks it changed.
- Blocks travel as LZ4 streams in both directions.
- A write is staged and committed under a record a power cut can't break, where the board has a staging area.
- At the end the radio re-reads its codeplug, as after a CPS
C 5. No restart.
The CPS stays: qdmr and the open-firmware CPS speak it. The fast protocol is for our own tools: tools/cps_fast.py
now, the web Codeplug Editor later (section 12).
- Firmware:
core/cps/cps_fast.c(the engine, no RTOS),core/cps/cps_lz4.c(the codec), the hook incore/debuglink/debuglink.c, the tick incore/hostlink/hostlink.c, the boot resume incore/app/app.c. - Switch:
CONFIG_CPS_FAST(docs/build-config.md), on by default withCONFIG_CPS_OPENFWandCONFIG_DEBUGLINK. Built without it, the command answers status 4 (unavailable). - Host:
tools/cps_fast.py. - Tests:
tests/host/cpsFastTest.c(the engine),tests/host/cpsFastClientTest.py(the client againstcpsSim).
1. Transport
The protocol is a debug link command: CODEPLUG, command byte 0x1C (debuglink.md 3.20). Each request is one debug
link frame, each answer one reply frame (debuglink.md 2). The first payload byte is the operation.
Why the debug link and not a mode of the CPS port:
| Debug link frame | CPS byte protocol | |
|---|---|---|
| Integrity | CRC-16 on every frame, both ways | none: a garbled byte in a write is written |
| Request boundary | length field | the header, or 3 ms of quiet |
| Lost answer | sequence number: the host tells a late answer from a new one | none |
| Status | a status byte and a reason | - for both "done" and "error" |
| Hosts | rfoundry_debug.py, tests/hil, the web flasher's link.js already speak it, over USB, a UART cable and UDP |
needs its own parser |
The debug link's limits shape the operations: a request carries at most 64 payload bytes, a reply at most 1024. Both come from buffers the firmware already has, so the protocol adds none. Writes therefore move in pieces of 61 stream bytes, which costs little: the flash, not the link, is the slow part of a write (section 10).
2. The image and its blocks
Addresses on the wire are image offsets, as in the files tools/cps_client.py and the web editor keep: 0 is the
EEPROM image (hal_flash_map_t.openfw_eeprom), the flash image starts at openfw_flash - openfw_eeprom (0x20000 on
every radio so far). The radio adds openfw_eeprom (0x700000 on the HR_C7000 radios), so a host never needs it.
A block is 4096 bytes at an offset that is a multiple of 4096. The protocol reads and writes a block when all of it lies inside:
- the EEPROM image, offsets 0x00000 to 0x0FFFF, or
- the flash image, from
openfw_flash - openfw_eepromup toopenfw_end - openfw_eeprom,
and none of it overlaps the radio's own areas: the settings ring, the sector journal, the native codeplug area, the
staging area and the calibration window. Any other block is refused (reason 1, section 6), for reads too. The gap
between the two images (0x10000 to 0x1FFFF on the STM32 radios: calibration, settings, journal) is never served,
unlike the CPS's area 1, which reads it raw (cps-protocol.md 2.7, decision 2).
The image is the one the CPS shows. The VFO A record (EEPROM 0x7590) shows the live VFO when one is set, in sums and
reads alike (open-firmware-cps.md 2.2). A block written with a VFO A record that differs from the live VFO gives the
VFO its settings, as a CPS commit does.
3. Operations
All numbers are little endian. Every reply starts with the debug link status byte (debuglink.md 2); the tables give
the bytes after it.
| Op | Name | Request after the op byte | Reply after the status |
|---|---|---|---|
| 0 | INFO | none | the layout and limits (3.1) |
| 1 | SUMS | addr u32, count u8 (1 to 16) | addr u32, count u8, blank mask u16, CRC-32 u32 x count |
| 2 | READ | addr u32, offset u16 | addr u32, format u8, length u16, CRC-32 u32, offset u16, up to 1000 stream bytes |
| 3 | BEGIN | mode u8: 0 direct, 1 staged | stage_max u16 (0 for direct) |
| 4 | BLOCK | addr u32, format u8, length u16, CRC-32 u32 | none |
| 5 | DATA | offset u16, 1 to 61 stream bytes | received u16 |
| 6 | END | none | none |
| 7 | COMMIT | none | none |
| 8 | STATUS | none | state u8, mode u8, blocks u16, copied u16, reason u8 |
| 9 | ABORT | none | none |
Formats: 1 an LZ4 stream (section 5), 2 a blank block (no stream: length 0). Format 0 is kept for raw blocks and not offered: an LZ4 stream of incompressible data is 0.4 % longer than the data, and one format keeps the radio smaller.
3.1 INFO
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | protocol version: 1 |
| 1 | 1 | flags: bit 0 staged writes available, bit 1 the flash can be written (storage_ready) |
| 2 | 2 | block size: 4096 |
| 4 | 1 | blocks per SUMS: 16 |
| 5 | 1 | stream bytes per DATA: 61 |
| 6 | 2 | stream bytes per READ reply: 1000 |
| 8 | 4 | EEPROM image size: 0x10000 |
| 12 | 4 | flash image start (image offset) |
| 16 | 4 | image end (image offset) |
| 20 | 4 | openfw_eeprom, for information |
| 24 | 2 | stage_max: blocks one staged session holds (0: no staging area) |
A host takes the limits from INFO rather than from this page. A radio without the protocol answers status 1 (firmware
older than the command) or 4 (built without CONFIG_CPS_FAST): the host falls back to the CPS.
3.2 SUMS
Up to 16 consecutive blocks from addr. For each, the CRC-32 of its 4096 bytes (section 4) and, in the mask, bit i set when block i is all 0xFF. The radio reads and hashes 64 KB at most per request (some 30 ms on an STM32F4 radio). The whole request is refused when any of its blocks is outside the image.
A host compares the CRCs with its own image: a block whose CRC matches is not read (a delta read) or not written (a delta write). The radio's CRCs travel to the host rather than the host's to the radio: the bytes on the wire are the same either way, one operation serves reads and writes, and a reply holds 16 CRCs where a 64-byte request holds 14.
3.3 READ
The block's LZ4 stream from offset on, at most 1000 bytes per reply. The radio encodes the block again for every READ (a few hundred microseconds), so READ keeps no state: a lost reply is asked for again, and the host asks for offset + n next until it has length bytes. A blank block answers format 2, length 0 and no stream. The CRC-32 is the block's: the host checks it after decoding, and a CRC or length that changes between two READs of one block means the block changed meanwhile (read it again). An offset past the stream's end is a bad argument. A READ while a write holds the storage sector buffer (a block between BLOCK and END, or a pending CPS sector) answers busy (3).
3.4 Writing: BEGIN, BLOCK, DATA, END, COMMIT
BEGIN mode
BLOCK addr format length crc for each block that changed
DATA offset bytes ... the stream in order, 61 bytes at a time
END the radio decodes, checks the CRC, writes (direct) or stages (staged)
COMMIT staged: the copy starts, poll STATUS; direct: the session ends
- BEGIN opens a write session. Only one at a time: a second BEGIN is refused (reason 6), busy while a commit runs. Staged mode needs a staging area (reason 3 without), one nobody else holds (reason 5, section 7.3).
- BLOCK names the next block: format, stream length (1 to 4128 for LZ4, 0 for blank) and the CRC-32 of the decoded 4096 bytes. It takes the storage sector buffer (busy while someone else has it). A BLOCK while another block is open drops that one: the host's way to start a block again.
- DATA carries the next stream bytes. Offset must be what came so far. A DATA whose bytes all came already (the host repeats one whose answer was lost) is answered with the count and not taken again. A gap is refused (reason 8) and the block stays open. Bytes that make the stream corrupt drop the block (reason 8).
- END checks the stream was whole and decodes to exactly 4096 bytes (reason 8), then the CRC-32 (reason 7). A good block is written or staged (section 7); a flash failure is reason 9. The buffer is let go either way.
- COMMIT ends the session. Direct: the codeplug is re-read if a block was written. Staged: the record is written and checked (reason 9 if it didn't take; the staged blocks stay and COMMIT may be sent again), then the host link task copies one block per pass. The host polls STATUS until DONE or FAILED.
- ABORT ends the session without a commit: staged blocks are forgotten (no record names them), a direct session that wrote blocks re-reads the codeplug. ABORT during a commit answers busy: a commit always runs to its end.
- A session ends by itself 10 s after its last request (not while committing), as an ABORT.
Every operation is safe to repeat after a lost answer: BLOCK starts the block again, DATA takes a repeat as a repeat, END after a lost answer is refused (reason 6) and the host sends the block again. A direct rewrite of the same bytes is skipped by the journal; a staged one reuses the block's slot.
3.5 STATUS
| Field | Meaning |
|---|---|
| state | 0 idle, 1 a session between blocks, 2 a block open, 3 committing, 4 done (the last session ended well), 5 failed (a commit could not finish: its record stays and the next boot finishes it) |
| mode | the session's mode |
| blocks | blocks ended in the session |
| copied | committing: blocks copied in so far |
| reason | the last refusal's reason (section 6), 0 since BEGIN if none |
The debug link's storage view section has cpsf.state, cpsf.read, cpsf.written, cpsf.commits and
cpsf.refused (debuglink.md 3.7).
4. The hash: CRC-32
The block hash is CRC-32 (IEEE 802.3, reflected, the zlib crc32), over the 4096 bytes of the block as the image shows
it. The firmware already has it (storage_crc32, for the journal and the TFTP staging), so it costs no flash, and every
host language has it in its standard library. The STM32F4 flash stub and the TFTP staging use the same.
CRC-32 finds every change of up to 32 consecutive bits and misses a random change with probability 2^-32 per block. A delta transfer over a 1 MB image (256 blocks) misses a changed block about once in 16 million transfers of a fully changed image. The host is trusted (it writes the codeplug anyway), so a stronger hash against deliberate collisions buys nothing. The cost of a miss is a block not moved; the next edit of that block moves it.
The CRC of a blank block is 0xF154670A, that of 4096 bytes of 0xFF. The blank flag says the same without the host comparing.
5. Compression: LZ4 blocks
Streams are LZ4 blocks: the public LZ4 block format (sequences of a token, literals, a 2-byte offset and a match
length), one block per 4 KB, no frame header. The stub's sector reads use the same format, so the host decoders exist
already: stub/stubtool.py (lz4_block, lz4_encode) and site/static/js/flasher/lz4.js.
5.1 Which direction compresses on the radio
Both. The cost decided it:
| Part | Flash (STM32F4 radio) | RAM |
|---|---|---|
| Decoder (writes) | about 300 B | none: a streaming decoder writes each DATA's bytes straight into the storage sector buffer, matches copy from what it already decoded there |
| Encoder (reads) | about 300 B | 128 B of stack (64 hash entries); it writes the requested window of the stream straight into the debug link's reply buffer |
Neither needs a buffer of its own, which is why the protocol fits a small RAM (section 9). The encoder's table is small because codeplug blocks repeat over short distances (records of 56, 24 or 80 bytes, padding runs): on a 33-block codeplug read from a bench radio (135,168 bytes), a 64-entry table gives 3,618 bytes of LZ4, a 4096-entry one 3,692, a full search about 3,500. Runs of one byte only (no table at all) give 17,397: the table pays for itself on any link.
Reads gain most on the 115200 baud programming cable of the radios without USB (11.5 KB/s), writes on every radio: the flash is slow, but a write session also reads blocks back (section 7) and a host's write is usually followed by a verify.
5.2 Format rules
- The radio's encoder follows the format's end rules: the last 5 bytes of a block are literals, and no match starts in its last 12 bytes. Any LZ4 decoder takes its streams.
- The radio's decoder takes any valid LZ4 block stream: matches may reach back to the start of the block, overlap (a run), and be of any length. It refuses an offset of 0 or before the start, a literal or match run past the end of the block, and bytes after the end. The stream must end with a literals-only sequence and decode to exactly 4096 bytes.
- The longest stream a 4 KB block can need is 4096 + 4096 / 255 + 16 bytes: BLOCK refuses longer.
6. Errors and refusals
Status bytes are the debug link's (debuglink.md 2): 0 OK, 2 bad argument (a length that doesn't fit the operation,
a count outside its range, an offset past a stream), 3 busy (the storage sector buffer is taken, a commit is running),
4 unavailable (built without the switch, no flash map), 7 refused. A refusal carries a reason byte after the status:
| Reason | Name | When |
|---|---|---|
| 1 | RANGE | a block outside the image, misaligned, or touching the settings ring, the journal, the native area, the staging area or the calibration window |
| 2 | STORAGE | BEGIN while the flash can't be written: no usable flash, or the settings region holds another firmware's data (settings-store.md 5) |
| 3 | NO_STAGING | BEGIN staged on a radio without a staging area |
| 4 | STAGE_FULL | a new block when the session already holds stage_max blocks |
| 5 | STAGE_BUSY | BEGIN staged while a TFTP write holds the staging area, or a commit record waits for the next boot |
| 6 | STATE | an operation out of order: BLOCK, DATA or END outside a session, END without BLOCK, BEGIN in a session, COMMIT with a block open |
| 7 | CRC | the block decoded, but its CRC-32 is not the one BLOCK gave |
| 8 | STREAM | a corrupt LZ4 stream, a gap in the offsets, a stream that doesn't decode to 4096 bytes |
| 9 | FLASH | a flash erase, program or read-back failed |
These are the CPS's refusals and more: the CPS refuses the settings ring, the journal and the native area at X 1
and filters the calibration window byte by byte (cps-protocol.md 2.3); this protocol refuses those blocks whole,
the calibration window's and the staging area's included.
7. Writes and power cuts
7.1 Direct mode
Each block is written at END through the sector journal (storage_sector_write, settings-store.md 4): a copy of
the new block and a header go to the journal first, so a power cut during the erase or the program is finished at the
next boot. Each block is old or new after a cut. A session cut halfway leaves some blocks new and some old, as a CPS
write cut halfway does. Boards without a staging area write this way, and every radio
uses it for writes larger than the staging area (a DMR ID database: write its header block last, so a cut leaves the
old header pointing at old records).
7.2 Staged mode
Where the board has a staging area (hal_flash_map_t.staging_*, typically 256 KB),
the whole session's blocks change together or not at all, as a TFTP codeplug write does (tftp.md 4):
- END puts the block in its slot of the staging area (slot k at
staging_addr+ (k + 1) x 4096; a block sent again reuses its slot), erases and programs it, and reads it back against the CRC-32. Nothing in the codeplug changes. - COMMIT writes the record into sector 0 of the staging area and reads it back.
- The host link task copies one slot per pass into the codeplug through the journal, then erases the record and re-reads the codeplug.
The record:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic 0x4652444F (bytes 4F 44 52 46 in memory order) |
| 4 | 4 | version: 1 |
| 8 | 4 | count: staged blocks (1 to stage_max) |
| 12 | 4 | CRC-32 of the staged slots, in slot order |
| 16 | 2 x count | each slot's block number (image offset / 4096) |
| 16 + 2 x count | 4 | CRC-32 of the bytes before it |
At boot (cps_fast_resume, after storage_init and before the codeplug is read) a record whose magic, version,
count, header CRC and data CRC all check, and whose blocks are all writable (section 2), is finished: every slot is
copied in again (the journal skips those already in) and the record erased. A record that doesn't check is erased:
it was torn while being written, so the copy never started. The VFO settings are not touched at boot (the settings
aren't up yet); the VFO A record is in the codeplug and takes effect as stored.
| Power cut | After the next boot |
|---|---|
| before COMMIT (staging) | the old codeplug; the staged slots are left, unused |
| while the record is written | the old codeplug; the torn record is erased |
| during the copy | the new codeplug: the boot copies every slot |
| during one block's copy | the journal finishes that block, then the boot copies every slot |
tests/host/cpsFastTest.c cuts the power at points all through a staged write of four blocks and checks the four are
all old or all new after the boot, never a mix.
Staged writes do twice the flash work of direct ones (each block is programmed into its slot and then copied), so they
take about 1.5 times as long (section 10). tools/cps_fast.py uses staged mode when the radio offers it and the
changed blocks fit, direct mode otherwise or with --direct.
7.3 The staging area is shared
The TFTP server's codeplug write (tftp.md 4) uses the same area. The first word of its first sector says whose
record is there (STORAGE_STAGE_MAGIC_TFTP, STORAGE_STAGE_MAGIC_CPSF):
- At boot
cps_fast_resumeruns first and leaves a TFTP record alone;tftp_codeplug_resumeleaves ours alone. - BEGIN staged is refused (reason 5) while a TFTP write is open or a record of either kind is there.
- A TFTP write is refused while a staged session is open, a commit runs, or our record waits for the boot.
- A record sector whose first word is erased but whose other bytes are not (a torn erase) is erased at BEGIN.
Both run in the host link task, so the checks don't race.
8. Sizes
| Item | Value |
|---|---|
| block | 4096 bytes, aligned |
| request payload | at most 64 bytes (the debug link's) |
| reply payload | at most 1024 bytes |
| SUMS | 16 blocks: request 6 bytes, reply 72 |
| READ | at most 1000 stream bytes per reply (15 header bytes) |
| DATA | at most 61 stream bytes |
| LZ4 stream | 1 to 4128 bytes per block |
| staged blocks | stage_max: (staging size / 4096) - 1, at most 64 (63 for a 256 KB area) |
| session time-out | 10 s after the last request |
| RAM | about 200 B of state (STM32F4 radio); the 4 KB storage sector buffer and the debug link's buffers are shared |
| flash | about 3.9 KB (STM32F4 radio) with the switch on, nothing with it off |
9. Example exchange
A host reads block 0x3000 (channel bank 0) of the simulator's codeplug (tests/host/cpsSim --populate), renames
channel 3 and writes the block back staged. Payloads in hex; each travels in a debug link frame, for example INFO:
a5 5a 1c 31 01 00 00 10 f3 (command 0x1C, sequence 0x31, length 1, payload 00, CRC-16).
| Dir | Payload | Meaning |
|---|---|---|
| > | 00 |
INFO |
| < | 00 01 03 00 10 10 3d e8 03 00 00 01 00 00 00 02 00 00 00 e0 00 00 00 00 00 3f 00 |
OK, version 1, staging and writable, block 4096, 16 sums, 61 data, 1000 read, EEPROM 0x10000, flash 0x20000, end 0xE00000, base 0, 63 slots |
| > | 01 00 30 00 00 01 |
SUMS 0x3000, 1 block |
| < | 00 00 30 00 00 01 00 00 15 dd 07 05 |
OK, not blank, CRC-32 0x0507DD15 |
| > | 02 00 30 00 00 00 00 |
READ 0x3000 from offset 0 |
| < | 00 00 30 00 00 01 99 03 15 dd 07 05 00 00 ... (936 bytes) |
OK, LZ4, 921 stream bytes, the CRC, offset 0, the whole stream |
| > | 03 01 |
BEGIN staged |
| < | 00 3f 00 |
OK, 63 slots |
| > | 04 00 30 00 00 01 d7 02 1b d9 60 28 |
BLOCK 0x3000, LZ4, 727 bytes, CRC-32 0x2860D91B |
| < | 00 |
OK |
| > | 05 00 00 1f ff 01 ... (61 stream bytes) |
DATA from offset 0 |
| < | 00 3d 00 |
OK, 61 received |
| ... | 11 more DATA | |
| < | 00 d7 02 |
OK, 727 received |
| > | 06 |
END: decoded, CRC checked, staged in slot 0 |
| < | 00 |
OK |
| > | 07 |
COMMIT: the record is written |
| < | 00 |
OK |
| > | 08 |
STATUS |
| < | 00 04 01 01 00 01 00 00 |
OK, done, staged, 1 block, 1 copied, no refusal |
The read took 2 requests (SUMS, READ) and about 1 KB on the wire, where the CPS's 32-byte reads of the same 4 KB take 128 requests and 5.5 KB. The CPS's chunked reads (the web editor's 32 KB on USB) move the full 4 KB plus the codeplug's blank blocks.
10. Speed
What a transfer costs, on a 33-block codeplug (the bench radio's, 135 KB in blocks that aren't blank):
| Transfer | Bytes on the wire | 115200 baud cable | USB |
|---|---|---|---|
| CPS, 32-byte reads of the qdmr regions (qdmr) | about 169 KB, 3,928 requests | about 15 s plus 3,928 round trips | several seconds (the round trips) |
| CPS, 4 KB / 32 KB reads (the web editor) | about 135 KB, blank blocks too | about 12 s | under 1 s |
| fast, full read | about 5 KB: 3 SUMS, 33 READs | about 0.5 s plus 36 round trips | about 0.1 s |
| fast, read from a cache, nothing changed | 3 SUMS, about 0.3 KB | a few round trips | a few ms |
| fast, write one changed channel | one block: about 0.8 KB of stream | under 0.1 s on the wire |
Writes are limited by the flash, not the link. A 4 KB sector erase and program take some 60 ms on a W25Q128 SPI NOR (the erase alone up to 400 ms at worst). A direct block costs two of them (journal and target), a staged block three (slot, journal, target). Only changed blocks are written, where a CPS write rewrites every sector of its regions (qdmr) or every sector with a change (the web editor).
11. Alternative considered: the RAM stub
The flash stubs (stub/README.md, stub-stm32f4.md) already offer CRC-compared sector writes, blank detection and,
on the HR_C7000 stub, LZ4 reads. The codeplug could be transferred through them instead, with no protocol code in
the application. It was not chosen:
| In the application (this protocol) | Through the RAM stub | |
|---|---|---|
| Flash (STM32F4 radio) | about 3.9 KB | about 0.8 KB for CONFIG_STUB_ENTRY (on by default) |
| RAM (STM32F4 radio) | about 0.2 KB | 8 KB: the stub's staging buffer (CONFIG_STUB_ENTRY takes RAM from 87 % to 94 %) |
| The radio meanwhile | keeps working; the codeplug is re-read at the end | stops (the stub owns the SoC); a restart at the end |
| Power-cut safety | the journal and the staged commit | the stub writes sectors in place: a cut mid-write leaves a broken sector |
| Refusals | the radio enforces its areas | the host must; the stub writes any sector |
| VFO and settings | as the CPS | not seen |
The stub stays the tool for firmware and whole-flash work.
12. Open points
- Not yet run on a radio: the HIL test (a read against the CPS, a staged write, a power cut during a commit) is to be written once the owner flashes a build with the switch.
- The web Codeplug Editor's transport (
site/static/js/codeplug/transport.js) doesn't speak the protocol yet. It would sit besidecps-serialand fall back to it when INFO answers status 1 or 4;link.jsandflasher/lz4.jsalready have the framing and the codec. - A host's write of the DMR ID database through the protocol: direct mode works today; ordering the header block last is the host's job.