kairos — BLE Protocol
kairos — BLE Protocol
Protocol version: 2 · Status: Draft
A Bluetooth Low Energy (GATT) protocol for configuring, monitoring and manually controlling a Kairos device from a phone, with no Wi‑Fi or network needed. Version 2 is a compact binary protocol. It replaces the JSON protocol of version 1, and nothing is compatible with it.
1. Goals
- Work in the field with no network. The device has no reliable clock source of its own, so the phone sets the device clock (a wrong clock means a missed launch).
- Run on small hardware (an ESP32, or the legacy Raspberry Pi Zero W): assume the default 23-byte ATT MTU, and take a larger one if negotiated.
- Cheap to encode, decode and store. Every message is a fixed binary layout, and the stored form of the config is the wire form.
- Be hard to misuse: nobody nearby should be able to stop a recording mid-launch.
2. GATT layout
One primary service, four characteristics, plus an optional fifth (Firmware) on devices that can be updated over Bluetooth. All multi-byte integers are little-endian, except the 2-byte message length in the fragment header (§3).
| Name | UUID | Properties | Purpose |
|---|---|---|---|
| Service | 6b610001-6169-4f72-8f73-b3e1c5a97d24 |
— | Kairos service |
| Device Info | 6b610002-6169-4f72-8f73-b3e1c5a97d24 |
Read | Static identity |
| Status | 6b610003-6169-4f72-8f73-b3e1c5a97d24 |
Read, Notify | Compact live state (13 bytes) |
| Command | 6b610004-6169-4f72-8f73-b3e1c5a97d24 |
Write, Write Without Response | Client → device messages |
| Response | 6b610005-6169-4f72-8f73-b3e1c5a97d24 |
Notify | Device → client responses and events |
| Firmware | 6b610006-6169-4f72-8f73-b3e1c5a97d24 |
Write Without Response | Firmware image bytes (§4.3). Present only when Device Info caps bit0 is set. |
Advertising
- Advertise the Service UUID in the advertisement packet.
- Put the local name in the scan response:
{name}-{deviceId}(for examplePad Cam-A1B2C3), or just{deviceId}when the name is empty. - The scan response allows 29 bytes of name. If
{name}-{deviceId}is longer, truncate the name part, never the id: keep at most 22 bytes of name (cut on a UTF‑8 character boundary), then-, then the 6-character id. Phones recognise a device by the id after the last-. - Interval about 1 s. One central at a time: stop advertising while connected, resume on disconnect.
Device Info (read)
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | v: protocol version, 2. A client must refuse any version it does not know. |
| 1 | 3 bytes | id: the 6 hex digits of the device id as 3 raw bytes (A1B2C3 is A1 B2 C3) |
| 4 | u8 | cam: the configured camera type, 0 generic or 1 zcam |
| 5 | u8 | cams: the camera types this device can drive, a bit set (bit 0 generic, bit 1 zcam). A device without the Ethernet hardware reports only generic, so a client offers Z CAM only when bit 1 is set. |
| 6 | u8 + bytes | fw: software version, a length-prefixed ASCII string |
| … | u8 + bytes | name: friendly name, a length-prefixed UTF‑8 string (may be empty) |
| … | u8 | caps (optional): bit0 = the device can be updated over Bluetooth (§4.3). Missing means 0. |
| … | u8 | chip (optional, after caps): 1 ESP32-S3, 2 ESP32-C3, 0 or missing for other hardware |
| … | u32 | slot (optional, after chip): the largest firmware image the device accepts, in bytes. 0 or missing when not updatable. |
Strings are a u8 length followed by that many bytes. Clients must use Read Long. The last three fields were added after the first release of v2 and follow name. A client reads them only if bytes remain, and treats a device that lacks them as not updatable.
Status (read / notify)
Fixed 13 bytes, cheap enough to poll or subscribe to at any MTU. Notified on every change, and at most once per second while a recording is active.
| Offset | Type | Field | Values |
|---|---|---|---|
| 0 | u8 | flags |
bit0 recording · bit1 camera ready · bit2 pairing window open · bit3 clock valid |
| 1 | u8 | phase |
0 idle · 1 awake (woken, waiting to record) · 2 recording · 3 test running · 4 error |
| 2 | u32 | configRev |
Increments on every successful config save |
| 6 | u32 | nextEventAt |
Unix seconds of the next wake/start/stop; 0 if none |
| 10 | u8 | nextEventType |
0 none · 1 wake · 2 start · 3 stop |
| 11 | u8 | attemptsRemaining |
Attempts not yet completed |
| 12 | u8 | nextAttempt |
Index (in the config’s attempt list) of the attempt that owns the next event, 0xFF for none |
Bit 3 (clock valid) is set once the device clock has been set by a phone or restored from a trustworthy clock source. Clients cache the config and re-fetch it only when configRev changes.
3. Message framing
A message is up to 2048 bytes, split into fragments that each fit one ATT write or notification (MTU − 3 bytes). The same framing is used on Command and Response.
First fragment: [ 1 b: 0x80 | idx ] [ u16 BE: total length ] [ payload… ]
Continuation fragment: [ 1 b: idx ] [ payload… ]
idxis the 7‑bit fragment counter: 0 for the first fragment, +1 each fragment, wrapping at 128.- The message is complete when
total lengthpayload bytes have arrived. - A fragment with the wrong
idx, or a stalled message (> 5 s between fragments), discards the partial message. The device then sends abad_requesterror and the client just retries. - Fragments of one message are never interleaved with another message. The device queues events until a response finishes.
- Use Write with response for Command so the stack applies back-pressure. Response is Notify only; the device paces fragments (at least one connection interval apart).
Example: a 60‑byte message at MTU 23 (20 payload bytes per fragment) is sent as 80 00 3C <17 bytes>, 01 <19 bytes>, 02 <19 bytes>, 03 <5 bytes>.
4. Messages
Envelope
Request : [ op u8 ][ id u8 ][ body … ]
Response: [ 0x80 ][ id u8 ][ status u8 ][ body … ] status 0 = ok, otherwise an error code and its detail
Event : [ 0x81 ][ ev u8 ][ body … ]
idis chosen by the client and echoed back. Only one request may be outstanding at a time. A second one getsbusy.- A parser checks the minimum length of a body and ignores trailing bytes. New fields are appended, and new ops use new codes.
- Request timeout: 10 s. Long jobs (
test.run) respond immediately and report progress as events. - An unknown
opreturnsnot_supported. An error that cannot be tied to a request (a bad frame, or a request too short to carry anid) usesid0.
Types
| Type | Encoding |
|---|---|
| integers | little-endian, u8/u16/u32/u64/i32 |
| time | u64 Unix milliseconds for the clock ops, u32 Unix seconds everywhere else |
| string | u8 length + bytes (UTF‑8 for names and messages, ASCII for the rest). The limits below are in bytes |
| bool | u8, 0 or 1 |
| pin | u8 GPIO number, 0xFF for none |
| IPv4 address | 4 bytes, 192.168.1.50 is C0 A8 01 32. All zeros means none |
Operations
| Op | Code | Auth | Request body | Response body |
|---|---|---|---|---|
hello |
0x01 | no | — | v u8, flags u8 (bit0 paired), nonce[16], now u64, maxMessage u16 |
auth |
0x02 | no | clientNonce[16], proof[32] |
proof[32] |
auth.pair |
0x03 | pairing window | secret[16] |
— |
time.get |
0x04 | no | — | now u64 |
time.set |
0x05 | yes | now u64 |
now u64, offsetMs i32 |
config.get |
0x07 | yes | — | Config (§4.2) |
config.set |
0x08 | yes | Config | configRev u32 |
jobs.get |
0x09 | yes | — | count u8, then count × state u8 |
camera.wake |
0x10 | yes | — | recording u8 |
camera.start |
0x11 | yes | — | recording u8 |
camera.stop |
0x12 | yes | — | recording u8 |
camera.sleep |
0x13 | yes | — | recording u8 |
test.run |
0x14 | yes | — | — |
logs.get |
0x20 | yes | after u32, limit u8 |
last u32, more u8, count u8, then count log lines |
logs.subscribe |
0x21 | yes | enabled u8, after u32 |
— |
fw.begin |
0x30 | yes | size u32, sha256[32], sig[64] |
offset u32, window u16 |
fw.end |
0x31 | yes | — | rebootInMs u16 |
fw.abort |
0x32 | yes | — | — |
Code 0x06 (the v1 status.get) is unused: the Status characteristic carries everything it did.
hello
Starts a session. nonce is 16 random bytes, new for every call. flags bit0 is set when a shared secret exists. maxMessage is 2048.
time.get / time.set
time.set sets the device clock to now and returns the device’s clock afterwards plus offsetMs, the device’s clock minus the value just set (the drift that was corrected), clamped to i32.
- Setting the time must re-plan every scheduled timer. It is refused with
busyunless the device is idle (phase0). - The app calls
time.seton every connect when the measured offset exceeds about 250 ms. Expected accuracy is tens of milliseconds, which is fine for a ±20 s recording window.
config.set
A full replacement, atomic: validate, store, then re-plan. On any error the old config and schedule are untouched. It is refused with busy while phase is not idle, because re-planning cancels the pending stop and would leave the camera recording. Errors: invalid_config, busy, too_large, internal.
Config layout (the config.get response, the config.set request, and the form stored on the device):
| Field | Type | Notes |
|---|---|---|
layout |
u8 | Config layout version, currently 1 |
cameraType |
u8 | 0 generic, 1 zcam |
wakeUpTimeout |
u16 | seconds, 0–3600 |
startBefore |
u16 | seconds, 0–3600 |
endAfter |
u16 | seconds, 0–3600 |
cameraIp |
IPv4 | required for zcam |
name |
string | ≤ 32 bytes, may be empty |
count |
u8 | number of attempts, ≤ 30 |
attempts[count] |
time u32 + name string |
time is the T‑0 in Unix seconds; name is 1–32 bytes |
GPIO pins are not part of the protocol: they describe how a device is wired, so each device keeps them itself (config.json on the Pi, constants in the firmware on the ESP32) and a phone neither reads nor sets them. A config is at most about 1.2 KB. deviceId and configRev belong to the device and are not part of the layout: the id is in Device Info and the revision is in Status.
Validation rules: cameraType must be 0 or 1; zcam needs a non-zero cameraIp; every attempt time must be unique and in the future, and so must its camera wake-up time (time − startBefore − wakeUpTimeout).
jobs.get
Returns one state per attempt, in the config’s attempt order: 0 pending, 1 active, 2 complete, 3 missed. The phone computes each attempt’s wake, record and stop times itself from time, startBefore, wakeUpTimeout and endAfter.
camera.* and test.run
No body. Manual commands call the matching camera action and respond when it returns. camera.start and camera.stop are idempotent. They are rejected with busy while a scheduled attempt is active or a test is running. test.run responds immediately, then emits test events: wake → start → stop → sleep → done.
logs.get / logs.subscribe
Each log line has a monotonically increasing seq. A log line is seq u32, ts u32 (Unix seconds), ns (string, ASCII, ≤ 8 bytes), msg (string, UTF‑8, ≤ 120 bytes). logs.get returns up to limit lines with seq > after (limit ≤ 100, capped so the response fits maxMessage); last is the highest seq returned and more is 1 if lines remain. logs.subscribe with enabled = 1 replays lines after after, then pushes a log event for each new line. The log’s free-form data blob is never sent over BLE.
fw.* (firmware update)
Updates are always the user’s choice: the device never starts one itself. Only devices with Device Info caps bit0 accept these ops. Any other device answers not_supported.
Flow. fw.begin, then the image bytes on the Firmware characteristic, then fw.end. The device reboots into the new image and the connection drops. The phone reconnects and reads fw from Device Info to see which version is running.
fw.begindeclares the image: itssizein bytes, thesha256of the whole image, andsig, a 64-byte ECDSA P-256 signature (rthens, 32 bytes each, big-endian) over that SHA-256 value. All zeros means no signature. It is refused with:busyunless the phase is idle and the next scheduled event is at least 15 minutes away (a clock that is not valid counts as no event);too_largeifsizeexceeds Device Infoslot;bad_image(size) ifsizeis below the smallest valid image;bad_image(signature missing) for an all-zerosig, unless the device accepts unsigned images (developer builds only);bad_image(signature invalid) ifsigdoes not verify against any of the device’s embedded public keys.
- The response gives the
offsetto start from (0, or the resume point), andwindow, the number of bytes the phone may have sent but not yet seen acknowledged. Afw.beginwith the samesizeandsha256as a transfer already in progress resumes it. The device keeps an unfinished transfer in RAM for 2 minutes after a disconnect, and loses it on a reset. A different image replaces it. - Firmware characteristic. Each write is
[offset u32][bytes]and at most MTU − 3 bytes in total. The device accepts a write only whenoffsetis the next byte it expects. A write at a lower offset is ignored, and one at a higher offset (a gap) is not stored and makes the device send anfwevent with itsoffset, so the phone rewinds to it. The first 64 bytes are checked as soon as they arrive: image magic0xE9, the chip ID (a little-endianu16at offset 12) matchingchip, and the application descriptor magic0xABCD5432(a little-endianu32at offset 32). The last check is what tells an application image from a factory image, which must never be sent. - Progress. The
fwevent (below) reports the next expected offset everywindow / 4bytes and on any state change. fw.endis valid when every byte has arrived. The device compares the SHA-256 it computed with the declared one, validates the image, selects it for the next boot, answers withrebootInMs, and restarts after that delay. Errors arebad_image(incomplete, hash, write failed) andbusyif no transfer is open.fw.abortdiscards an unfinished transfer. It is always accepted.- The device aborts a transfer, keeps running the current firmware, and sends an
fwevent withfailedand reasoninterruptedwhen no data arrives for 2 minutes or a scheduled event comes within 2 minutes. - The new image must confirm itself after booting: the device marks it valid once it has run for 30 seconds and a client has authenticated over Bluetooth with it. If it resets before that, the bootloader returns to the previous image. A client therefore reconnects and authenticates after the restart (the apps do this to read the new version).
Signing. Release images are signed with the project’s key. The device holds one or more public keys and verifies the signature in fw.begin, before accepting any data. Developer builds may accept unsigned images. Clients treat the signature as opaque bytes.
Events
Pushed to an authenticated client on the Response characteristic: [0x81][ev][body]. Events are best-effort: after a reconnect a client rebuilds everything from Status, config.get and jobs.get.
| Event | Code | Body |
|---|---|---|
job |
0x01 | attempt u8 (config index), step u8 (0 wake, 1 start, 2 stop, 3 complete, 4 missed), at u32 Unix seconds |
config |
0x02 | configRev u32 |
test |
0x03 | step u8 (0 wake, 1 start, 2 stop, 3 sleep, 4 done), ok u8, error string (empty unless a failed done) |
log |
0x04 | a log line |
fw |
0x05 | state u8 (0 receiving, 1 verifying, 2 ready to reboot, 3 failed), offset u32 (the next byte expected), reason u8 (a bad_image reason when failed, else 0) |
Errors
A response with status ≠ 0 carries an error code and its detail in the body.
| Code | Name | Detail |
|---|---|---|
| 1 | bad_request |
— |
| 2 | unauthorized |
— |
| 3 | locked_out |
retryAfter u16 seconds |
| 4 | not_supported |
— |
| 5 | busy |
— |
| 6 | invalid_config |
field u8, index u8 (attempt index, else 0), reason u8 |
| 7 | too_large |
— |
| 8 | pairing_closed |
— |
| 9 | internal |
an optional string with detail |
| 10 | bad_image |
reason u8 |
invalid_config fields: 1 name, 2 cameraType, 3 wakeUpTimeout, 4 startBefore, 5 endAfter, 9 cameraIp (6 to 8 are unused), 10 attempts (the list), 11 attempts[i].name, 12 attempts[i].time.
Reasons: 1 required, 2 out of range, 3 bad value, 4 in the past, 5 duplicate, 6 too soon (the wake-up time has already passed), 7 too many.
bad_image reasons: 1 size (below the smallest valid image), 2 header (not an application image), 3 chip (built for different hardware), 4 hash, 5 write (flash failed), 6 signature missing, 7 signature invalid, 8 incomplete (fw.end before all bytes arrived), 9 interrupted (no data for 2 minutes, or a scheduled event came close).
The apps build their error text from these codes, so the device carries no message strings.
5. Security
A rogue central could otherwise stop a recording seconds before ignition, so everything except hello, auth, auth.pair, time.get, Device Info and Status requires authentication.
Shared secret. The device stores a 16‑byte random secret, never returned to a client. The phone stores the same secret.
Pairing (physical access required). Pressing the device’s button opens a 120 s pairing window (Status flag bit 2). During it, the client sends auth.pair with a fresh random 16-byte secret. The secret replaces any existing one (so a lost phone is recoverable with physical access) and the session becomes authenticated. The window closes after one success or on timeout. The Command characteristic SHOULD require an encrypted link (LE Secure Connections, Just Works bonding is acceptable because a secret is only ever accepted inside the physical window), so the secret is not sent in clear.
Authenticating. On later connections the client sends auth with clientNonce (16 random bytes) and
proof = HMAC-SHA256(secret, "kairos-auth-v2" || nonce || clientNonce)
and the device answers with
proof = HMAC-SHA256(secret, "kairos-auth-v2-srv" || nonce || clientNonce)
noncecomes fromhello. The labels are ASCII and the concatenation has no separators. The device compares in constant time.- The device’s returned proof lets the client verify that it is talking to the real device. A client must treat a mismatch as
unauthorized. - Authentication lasts for the connection only.
- 5 consecutive failures →
locked_outfor 30 s, doubling each time up to 5 min.
If the device has no secret yet (paired is 0), everything but auth.pair inside a pairing window is unauthorized.
6. Typical session
Client Device
│── connect, discover service ───────────────▶│
│── (optional) negotiate MTU ────────────────▶│
│── read Device Info (check v, cam) ─────────▶│
│── subscribe Response + Status notify ──────▶│
│── hello ───────────────────────────────────▶│
│◀──────────────────── nonce, now, paired ────│
│── auth ────────────────────────────────────▶│
│── time.set (if drift > 250 ms) ────────────▶│
│── config.get / jobs.get (if rev changed) ──▶│
│── logs.subscribe {after: lastSeq} ─────────▶│
│◀──────── Status + job/log events (push) ────│
7. Implementation notes
- Test vectors.
protocol/test-vectors.jsonholds vectors for framing, the auth proofs, Status, Device Info, the Config layout, every message, and every error. Each implementation (Node, ESP32, iOS, Android) must pass them. They are generated bycd firmware/legacy-pi && node test/vectors.js --writefrom the Node reference, which is the only place they are edited. - Reference implementations.
firmware/esp32/(C++, on an ESP32) andfirmware/legacy-pi/trigger/ble/(Node, on the legacy Raspberry Pi). Both keep the protocol logic free of any radio code so it can be tested without hardware. - Job state after a restart. State lives in memory. After a restart, any attempt whose wake-up time has already passed is
missed, even if it ran before. - Log buffer. The last 500 lines are kept in memory.
- Firmware images. A release image carries a small tag the apps read to show its version before installing: the 8 bytes
KAIROSFW, au8tag version (1), au8chip (1 ESP32-S3, 2 ESP32-C3) and a 32-byte NUL-padded ASCII version. A client scans the whole file for it.protocol/test-vectors.jsonhas vectors for reading images and for comparing versions. - Compatibility. Additive changes (new ops, new trailing fields) don’t change
v. Anything that changes framing or existing layouts does.