Protocol DSL
The Protocol DSL is a JSON language for clone-style radio I/O: send bytes on a serial port and wait for a reply. A generic driver interprets the steps. Radio modules ship the protocol in their config instead of writing per-radio driver code.
Mental model
Two kinds of steps:
- Exchange — send bytes, wait for a reply (handshake, ACK).
- Chunk loop — repeat an exchange across a memory segment (download or upload).
endAddress in memoryConfig.segments is inclusive. A range 0–6143 is 6144 bytes.
Root fields
Protocol steps live on the radio config next to serial and memory settings:
{
"id": {
"model": "baofeng-uv5r",
"name": "Baofeng UV-5R",
"manufacturer": "Baofeng"
},
"version": "1.0.0",
"description": "Baofeng UV-5R radio configuration",
"serialConfig": {
"baudRate": 9600,
"dataBits": 8,
"stopBits": 1,
"parity": "none"
},
"memoryConfig": {
"chunkSize": 64,
"addressSize": 2,
"addressEndianness": "big",
"segments": {
"channels": { "startAddress": 0, "endAddress": 6143 },
"settings": { "startAddress": 7872, "endAddress": 8191 }
}
},
"readMemory": [],
"writeMemory": []
}addressSize and addressEndianness control how $address is encoded on the wire.
serialConfig.baudRate is the default speed used to open the programming port. Radios that accept more than one PC/COM baud list them as serialConfig.baudRates (the default must be one of those values). HamBench shows a baud selector on import and write in that case. Mid-session changes still use a protocol step with setBaudRate (for example TH-D74 clone transfers at 57600 after 0M PROGRAM).
Exchange
An exchange has optional send, optional expect, optional setBaudRate, and optional description / timeout (milliseconds, default 5000). At least one of send, expect, or setBaudRate is required.
{
"description": "Send magic number",
"send": ["0x50", "0xBB", "0xFF", "0x20", "0x12", "0x07", "0x25"],
"expect": "0x06"
}Kenwood TH-D74 clone mode enters programming at 9600 baud then transfers at 57600:
{
"description": "Switch to clone baud",
"setBaudRate": 57600,
"expect": { "bytes": 1 }
}Omit expect to send without waiting. Omit send to wait for inbound data first.
Byte tokens
In send and expect arrays:
| Token | Meaning |
|---|---|
6 | Literal byte 0–255 |
"0x50" | Hex literal |
"S" | Single-character ASCII opcode |
"$address" | Current chunk byte address (addressSize + addressEndianness) |
"$block" | Current chunk index (floor(byteAddress / chunkSize)), same width/endianness as $address. Kenwood TH-D74 clone headers use this. |
"$chunkSize" | Current chunk size as one byte (write.chunkSize or memoryConfig.chunkSize) |
"$length" | Current chunk length as one byte |
| "$data" | Chunk payload (see read/write) |
JSON cannot use 0x50 as a number. Prefer "0x50" or "S" over decimal 80 / 83.
Expect
expect is overloaded by shape — there is no type field.
| Author writes | Meaning |
|---|---|
6 or "0x06" | Exact 1-byte match (ACK) |
[6, 0] or ["0x06", "0x00"] | Exact multi-byte match |
{ "bytes": 8 } | Any 8 bytes (radio ID, opaque blob) |
["X", "$address", "$length", "$data"] | Framed reply: literals must match; $… are slots |
$length in expect is a 1-byte length prefix used to size $data. If $length is omitted, $data uses the current chunk size.
Chunked read
read repeats the exchange for every chunk in the named segments. $data in expect is stored in the memory buffer. Optional ack is a second exchange after each chunk.
{
"description": "Read memory",
"read": {
"segments": ["channels", "settings"],
"send": ["S", "$address", "$chunkSize"],
"expect": ["X", "$address", "$length", "$data"],
"ack": {
"send": ["0x06"],
"expect": "0x06"
}
}
}Chunked write
write repeats the exchange for every chunk. $data in send emits the current chunk from the memory buffer.
Optional fields:
| Field | Meaning |
|---|---|
chunkSize | Override memoryConfig.chunkSize for this write |
delay | Milliseconds to wait after each accepted block |
skip | Inclusive radio-address ranges that must not be uploaded |
$length is the current payload size. Use it on write when the block size may differ from the read chunk size.
{
"description": "Write memory",
"write": {
"segments": ["channels", "settings"],
"chunkSize": 16,
"delay": 50,
"skip": [
{ "startAddress": 3312, "endAddress": 3327 },
{ "startAddress": 3568, "endAddress": 3583 }
],
"send": ["X", "$address", "$length", "$data"],
"expect": "0x06"
}
}Live CAT memory (catRead / catWrite)
Clone dumps EEPROM in chunks. Some radios program memories with CAT commands instead. Those steps use catRead / catWrite rather than read / write.
The payload is a RadioCatMemoryConfig:
| Field | Meaning |
|---|---|
segment | Memory-map segment that holds channel records |
count | How many logical channels to read or write |
recordSize | Bytes per channel in the logical image |
pack | Command packer ("kenwood-th-f6" for TH-F6 MR / MW / MNA) |
indexWidth | Optional width of the channel index on the wire |
emptyByte | Optional empty-slot marker |
timeout | Optional timeout in milliseconds |
interCommandDelayMs | Optional pause between CAT commands |
The TH-F6 handshake is ordinary exchanges (ID, AI 0). Memories then loop per channel: catRead issues MR / MNA; catWrite issues MW / MNA. The memory map is a logical image for the codec, not a clone dump.
HamBench still uses a separate cat block (and capabilities.liveControl) for the live VFO page. That is independent of catRead / catWrite.
Baofeng UV-5R
Read
{
"readMemory": [
{
"description": "Send magic number",
"send": ["0x50", "0xBB", "0xFF", "0x20", "0x12", "0x07", "0x25"],
"expect": "0x06"
},
{
"description": "Get radio identifier",
"send": ["0x02"],
"expect": { "bytes": 8 }
},
{
"description": "Begin clone operation",
"send": ["0x06"],
"expect": "0x06"
},
{
"description": "Read memory",
"read": {
"segments": ["channels", "settings"],
"send": ["S", "$address", "$chunkSize"],
"expect": ["X", "$address", "$length", "$data"],
"ack": {
"send": ["0x06"],
"expect": "0x06"
}
}
}
]
}Write
Chirp's UV-5R upload (_ident_radio then _send_block) is the same handshake as read, then 16-byte X blocks. Two 16-byte calibration holes in the main block are not written (0x0CF0–0x0CFF and 0x0DF0–0x0DFF). Each block waits 50ms after the radio ACKs.
{
"writeMemory": [
{
"description": "Send magic number",
"send": ["0x50", "0xBB", "0xFF", "0x20", "0x12", "0x07", "0x25"],
"expect": "0x06"
},
{
"description": "Get radio identifier",
"send": ["0x02"],
"expect": { "bytes": 8 }
},
{
"description": "Begin clone operation",
"send": ["0x06"],
"expect": "0x06"
},
{
"description": "Write memory",
"write": {
"segments": ["channels", "settings"],
"chunkSize": 16,
"delay": 50,
"skip": [
{ "startAddress": 3312, "endAddress": 3327 },
{ "startAddress": 3568, "endAddress": 3583 }
],
"send": ["X", "$address", "$length", "$data"],
"expect": "0x06"
}
}
]
}Handshake is three exchanges. Memory transfer is one read (64-byte S blocks) or write (16-byte X blocks) over both segments.
Kenwood TH-D74 (clone mode)
The TH-D74 is a 256-byte block clone, not a Baofeng-style S/X dump:
- ASCII
0M PROGRAM\rat 9600 baud; radio replies0M\r. - Switch to 57600 baud and discard one sync byte.
- Read: send
R+$block+0x0000, expectW+$block+0x0000+ 256 data bytes, then ACK0x06/0x06. - Write: send
W+$block+0x0000+ 256 data bytes, expect0x06. Skip the last two blocks. - Send
Eto leave programming mode.
Enable serialConfig.rtscts (hardware flow control). macOS USB CDC needs it.
$block is the chunk index (0, 1, 2, …), not the byte address. A 2-byte big-endian $address at byte 256 would be 0x0100 (block 256) instead of 0x0001 (block 1).
Kenwood TM-D710A (clone mode)
The TM-D710A stays at 9600 baud and addresses blocks by byte address, not block index:
- ASCII
ID\r; radio repliesID TM-D710\r(notTM-D710G). - ASCII
0M PROGRAM\r; radio replies0M\r. - Read: send
R+$address+$chunkSize, expectW+$address+$chunkSize+ data, then ACK0x06/0x06.$chunkSizeas one byte is0for a 256-byte block. - Write: send
W+$address+$chunkSize+ data, expect0x06. - Do not clone radio block
0x7F(0x7F00–0x7FFF). After the 256-byte image, read/write0xFEF0(16 bytes) and0xFF00(144 bytes). - Send
Eto leave programming mode.
Connect to the PC port on the TX/RX body, not the control-head Com port. Hardware flow control is off.
This layout is not the TM-D710G clone image.
Execution
The driver walks readMemory or writeMemory in order:
- Resolve
$placeholders and hex/ASCII tokens. - Write
sendbytes to the serial port. - Wait for
expect(byte-length parser + timeout). - For
read/write, repeat per chunk and update progress within that step. - For
catRead/catWrite, repeat per channel using the named packer.
JSON Schema for the language lives in @springfield/ham-radio-utils (radio-protocol-schema.json). Types live in @springfield/ham-radio-api (RadioProtocolStep).