Wire Format

What the bytes on a characteristic are: a small frame header, then a CBOR message whose keys are numbers from a shared dictionary.

Overview

Every characteristic value, in both directions, is one frame. A frame is a 4-byte header followed by up to 146 bytes of payload. The payload of all the frames of one message, joined in order, is one CBOR (RFC 8949) map: the message envelope. Names that appear in the envelope and inside values travel as small integers looked up in the dictionary, so a typical state change is about eleven bytes on the air.

Nothing here needs a special library. The reference implementation, a dependency-free JavaScript package with the encoder, the framing and the generated dictionary, is what the BattleCore app uses; the same rules are a few hundred lines in any language.

1. Frame

ByteMeaning
0Format marker. 0xC1 is version 1 of this format; a later version uses a different marker so the two can be told apart.
1Frame id. A per-sender counter that keeps concurrent multi-frame messages apart.
2Part index, from 0.
3Part count. A message that fits one frame has count 1.
4…Payload: up to 146 bytes of the CBOR message, so a frame fits an iPhone's default 185-byte MTU.

Requests are framed too, so a request may span several writes to CMD_IN. A receiver collects frames by id until all parts are in; a partial message older than five seconds is dropped. Frames of one message arrive in order on one characteristic, but CMD_OUT and EVENTS_OUT are separate streams.

Base64 is your library's business

Some BLE libraries hand values across their API boundary as base64 strings. That is a library detail: the bytes on the air are the frame above.

2. Message envelope

A CBOR map with integer keys:

KeyFieldValue
0kind1 get · 2 set · 3 ok · 4 error · 5 event
1nsDictionary id, or text for a name the device does not know
2propDictionary id, or text
3valueAny CBOR value
4idYour correlation id (unsigned integer or text). Absent on events.
5keyError key (dictionary id or text). Errors only.
6msgError message text. Errors only.
7dataExtra error detail, usually the field at fault. Errors only.

The rest of the documentation shows messages in their decoded form, with the field names above, because that is what you work with after decoding.

3. The CBOR subset

  • Integers use the shortest integer encoding. A number that is not an integer is a float32 when that holds it exactly, else a float64. Decoders accept float16, float32 and float64.
  • Strings are UTF-8 text strings. A byte string decodes to an array of numbers.
  • Arrays and maps are definite-length. Indefinite-length items and tags are never sent and are rejected.
  • A map key is text or an unsigned integer. An integer key is always a dictionary id; a text key is literal.
  • null and undefined are the CBOR simple values of the same name.

4. Names and the dictionary

Every name the firmware knows, namespaces, properties, state keys, module and event names, record fields and error keys, has a permanent id in the dictionary. Ids are never renumbered or reused. When you send a name the device knows, send its id; when you send one it does not know, send the text. The device does the same in reply. Both are valid everywhere a name can appear: the ns and prop fields, the error key, and the keys of any object inside value.

That rule is what makes custom events cheap and possible at once: an app can raise a module and event name of its own through fsm / emit and it simply travels as text.

5. Worked example

The event for a weapon ammo change, decoded and as bytes. With deviceState at id 11 and WEAPON_AMMO at id 168 in the dictionary:

// decoded
{ "kind": 5, "ns": "deviceState", "prop": "WEAPON_AMMO", "value": 19 }

// the message, 11 bytes
A4              map of 4 pairs
  00 05         0: kind = 5 (event)
  01 0B         1: ns   = 11
  02 18 A8      2: prop = 168
  03 13         3: value = 19

// the frame that carries it (one part)
C1 07 00 01  A4 00 05 01 0B 02 18 A8 03 13

The same event as the JSON this API used to speak was 50 bytes.

6. Versioning

The format marker in byte 0 is the version. This page describes marker 0xC1. Adding names to the dictionary is not a version change: an id you do not know is one you can show as a number, and a text name is always valid. Changing the frame layout or the envelope would take a new marker.