← All notes

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

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