Badge pairing and sync

Once paired to a dashboard account, the badge pulls the tasks assigned to that person and reports its own battery and firmware back. Two constraints shape the whole protocol: the badge has five buttons and no keyboard, and its radio is only usable on external power.

Why it works this way

The badge deep-sleeps after 30 seconds of inactivity, and a deep-sleep wake is a cold boot — RAM does not survive it. If the task list lived only in memory, every wake would need Wi-Fi. So the list is cached in NVS, the Pipeline screen works entirely offline, and syncing is the exception rather than the path.

Wi-Fi is also gated on a healthy cell. On a 50 mAh battery the transmit peak is the largest current draw on the board, so the badge refuses to bring the radio up below 3.85 V. In practice it syncs while charging.

Responses are text/plain, not JSON. The client is an ESP32-S3 with 4 MB of flash and no PSRAM; a JSON parser heap-allocates while mbedTLS is holding a 16 KB TLS record buffer, which is exactly the fragmentation to avoid. A line-oriented format also means you can read a response with curl and your eyes, which matters when the client is a device you cannot attach a debugger to.

Pairing

Pairing is an RFC 8628 device authorization grant. The badge cannot accept typed input, so it displays a code and a QR, and a phone that is already signed in to the dashboard claims it.

  • The badge posts to /api/badge/pair/start with its serial and receives a device_code, a short user_code, and a verification URL.
  • It renders the URL as a QR and the code as text, then polls /api/badge/pair/poll.
  • Someone opens the URL, confirms on /badge/pair, and the code is claimed.
  • The badge sees the claim, shows “Pair with <name>?”, and only on a button press calls /api/badge/pair/confirm — which is where the token is minted and returned exactly once.

That last confirmation step is not ceremony. The user_code is displayed on a screen readable from across a room, and it is the credential. Requiring a press on the device means a code photographed from a distance is useless without the badge in hand.

The seriala badge sends is an unauthenticated assertion and authenticates nothing. It exists so the claim page can ask the human “is this the badge in your hand?”, and so a badge already paired to someone else can be refused — a handover requires the current owner to unpair first.

Sync

One request carries both directions. Every extra round trip is another TLS handshake on a small cell, so telemetry goes up and the task list comes down together.

curl -sS -X POST https://onetaplabs.com/api/badge/sync \
  -H "Authorization: Bearer $DEVICE_TOKEN" \
  -H 'Content-Type: text/plain' \
  --data-binary $'v=1\nfw=Labs Id v1.3\nbat=87\nmv=4012\nchg=1\n'

The response also carries the server’s clock. The badge has no RTC chip, so its time is only ever as good as its last sync — and TLS certificate validation needs a plausible wall clock. Handing it the time on every sync means the clock improves with use instead of drifting.

Wire format

Line-oriented ASCII, \n only. A version line first, a . terminator last, and no line longer than 127 bytes.

V1
S 1785092834
U Austin Lawrence
R Founder & CEO
I ID-0001
N 19 12
T 41fffadd|2343|1|1|Onetap Labs SOP|SOP-027 Investor Update Cadence
.
LineMeaning
V1Format version. Always first.
SServer time, UTC seconds.
U / R / IName, role and badge ID from the paired profile.
N <total> <sent>Tasks that exist, and how many T lines follow. The badge shows both, so “12/27” tells you what you are not seeing.
TOne task, exactly six pipe-separated fields: id · due (days since 2020-01-01, or -) · status 0-3 · priority 0-2 · project · title.
.Terminator. Its absence is how the badge detects a truncated response.

Text is folded to printable ASCII before it is sent. The badge renders Adafruit GFX bitmap fonts, which are 7-bit — an em-dash or a curly quote would draw a garbage box, so they are transliterated, and | becomes / so it cannot corrupt field alignment.

Failure behaviour

A failed sync never clears the cache. Only a 200 with a valid version line and a terminator replaces the stored list; everything else leaves the previous one on screen with its age shown in the header.

  • 401 — the pairing was revoked. The badge clears its token and asks to be paired again, but keeps showing the tasks it already had.
  • No route to the server, or a 5xx, shows the cached list and backs off for 30 minutes rather than retrying on every boot.
  • A body that arrives truncated is discarded whole. A half-written cache would survive deep sleep and be presented as fact for days.
  • Unknown line tags are ignored, so a newer server can add fields without breaking badges already in the field.