A tiny robot protocol: COBS and CBOR over USB and Bluetooth
How a small boat gets a protocol with acknowledgements, telemetry and a watchdog, and what WebBluetooth's 20-byte writes do to it.
My remote-controlled boat has two Raspberry Pi Pico boards: boat_drive in the hull and boat_rc in the handheld remote. A browser app can connect to either. That is three kinds of link (UART between the boards, USB serial, Bluetooth Low Energy) and two kinds of device, which is enough to need a real protocol. This note describes the one I ended up with.
Requirements
- The same messages on every link, so the browser can talk to the boat directly over USB or through the remote over Bluetooth.
- Messages that can grow new fields without breaking older firmware.
- Commands that confirm they were accepted, and report when they are done.
- Continuous state for the UI, and events for faults.
- A boat that stops by itself when the link goes quiet.
Framing with COBS
A serial link delivers bytes, not messages. COBS (Consistent Overhead Byte Stuffing) encodes a frame so that it contains no zero bytes at all; a single 0x00 then marks the end of each frame. The overhead is at most one byte per 254, and a receiver that joins mid-stream or loses a byte simply waits for the next zero and is back in sync. No length prefixes, no escape sequences.
Messages with CBOR
Inside each frame is a CBOR map. CBOR is a binary JSON: the same data model (maps, arrays, numbers, strings, booleans), but compact and cheap to parse on a microcontroller. Because messages are maps, adding a field never breaks a reader that does not know it.
Every newer message carries a type:
| type | direction | meaning |
|---|---|---|
cmd |
host to device | A command, with an optional req_id |
cmd_ack |
device to host | Accepted or rejected, immediately |
cmd_done |
device to host | Final result of a longer command |
state_report |
device to host | Periodic snapshot of the live state |
event |
device to host | Faults, such as an expired watchdog |
A short command returns its result right away. A longer one (such as probing a sensor for up to two seconds) is acknowledged first and finished later, matched by its req_id; state reports keep flowing in between.
{ "type": "cmd", "req_id": 7, "cmd": "drive_rpm:get", "window_ms": 500 }
{ "type": "cmd_ack", "req_id": 7, "ok": true, "status": "accepted" }
{ "type": "cmd_done", "req_id": 7, "ok": true, "result": { } }
Older frames without type are still accepted, which let me migrate one firmware at a time.
Who am I talking to?
Both boards answer the same first question:
{ "type": "cmd", "req_id": 1, "cmd": "pico_info:get" }
{ "ok": true, "pico": "drive" }
The web app sends this right after connecting and then shows only the controls that board supports: motor and rudder for drive; calibration, throttle direction and the virtual anchor for rc.
Telemetry
boat_drive sends a state_report about every 50 ms, boat_rc about every 100 ms. Each has a sequence number, so the UI can tell a lost report from a stale one:
{
"type": "state_report", "seq": 1207, "pico": "drive",
"drive_pwm": 56, "steering_pwm": 50,
"watchdog_ms_remaining": 180, "motor_watchdog_active": true
}
The watchdog
Throttle commands double as keepalives. The sender repeats drive_pwm:set about every 50 ms while it controls the motor. If boat_drive hears nothing valid for two seconds, it ramps the motor back to neutral and reports why:
{ "type": "event", "event": "motor_watchdog_expired", "drive_pwm": 50, "reason": "missing_drive_pwm_set" }
Steering works the same way: the rudder has its own watchdog and returns to centre.
The motor never jumps. Even normal commands only set a target, and the firmware slews the actual output towards it, with a gentler ramp back to neutral.
Bluetooth details
boat_rc exposes the byte stream over the Nordic UART Service, a common BLE profile with one characteristic for each direction. COBS frames go through unchanged; each side reassembles on the zero byte.
The awkward part is size. A BLE write can carry up to the negotiated MTU, but WebBluetooth does not tell the page what was negotiated. The web app therefore splits its writes into 20-byte chunks, the guaranteed minimum. Commands fit in a single chunk in practice; telemetry from the board arrives in notifications of up to 64 bytes.
boat_rc answers a command on the link it came from, and copies telemetry and events to every connected link. The remote works the same with or without a browser attached.
What I would do again
- COBS + CBOR: small, robust, and easy to implement on both sides.
- Acknowledge first, finish later, with a request id.
- Put safety in the device that moves. The boat stops itself; no host has to remember to.
- An identity handshake, so one UI serves several firmwares.