Badge firmware updates
The badge can fetch its own firmware from the dashboard. A release is a commit in this repository and a deploy; installing one is four button presses on the badge itself.
Pull, not push
Nothing pushes an update to a badge, and nothing can. The badge deep-sleeps after 30 seconds, has no inbound connection, and brings its radio up only on external power — a device in that shape has no listening socket to push to, and giving it one would be a battery leak that is offline whenever you need it.
So the direction is reversed: the wearer opens MENU → Tools → Check for Update, the badge asks the dashboard what is current, and installs it if it wants to. A rollout finishes at the speed people pick their badges up, which is the honest property of this design rather than a limitation of it.
Two older paths still exist and are unrelated. ArduinoOTA pushes from a laptop on the same network, and /updateon the badge’s own web server takes a .binfrom a phone. Both need physical proximity; only this one works for a badge that is not on a developer’s desk.
Publishing a release
Images live in the dashboard repository under firmware/badge/, alongside a manifest that names the current release. A release is therefore a commit and a deploy, reviewed and rolled back like anything else, and git loganswers “what is live”.
# in the firmware repo, after bumping FW_SEMVER in src/main.cpp
pio run -e esp32s3_n4r8_release
# in the dashboard repo
node scripts/publish-firmware.mjs \
--bin ../onetap-id-card/.pio/build/esp32s3_n4r8_release/firmware.bin \
--version 1.4.0 \
--notes "Check for Update over the air"The publish script refuses more than it accepts, and each refusal maps to a failure that would otherwise only surface on hardware:
- The image must contain its own version string. This catches publishing yesterday’s build under today’s number, which everything downstream would then quietly trust.
- It must not contain the Wi-Fi password from
include/secrets.h. That file is compiled in as a developer convenience and a#define’d string survives into the binary verbatim. Theesp32s3_n4r8_releaseenv exists to exclude it. - It must start with
0xE9and fit the0x1E0000OTA slot — an oversized image can only be discovered after a badge has spent a radio session fetching it. - The version must be strictly newer than the current latest, and not already published. Republishing a version changes the image under badges that were already told its hash.
Checking for an update
A bearer-authenticated POST, in the same k=v request format the sync endpoint uses.
curl -sS -X POST https://onetaplabs.com/api/badge/ota/check \
-H "Authorization: Bearer $DEVICE_TOKEN" \
-H 'Content-Type: text/plain' \
--data-binary $'v=1\nsv=1.3.0\nfw=Labs Id v1.3.0\nbat=87\n'V1
S 1785092834
U 1.4.0
B Labs Id v1.4.0
Z 1109449
M 9f2c1a...
P /api/badge/ota/download?v=1.4.0
L Check for Update over the air
.| Line | Meaning |
|---|---|
| U | Offered version. Only ever strictly newer than sv. |
| B | Display string, shown on the badge. |
| Z | Image size in bytes. The badge sizes its partition write from this. |
| M | MD5 of the image, lowercase hex. |
| P | Path to fetch. The badge already knows the origin. |
| L | A release note. Up to three, 40 characters each — what fits on the panel. |
| N | Instead of the above: you are already current. |
| X | Instead of the above: a reason the badge cannot be offered anything. |
sv is compared as three dotted numbers and nothing else — no pre-release tags, no build metadata. A badge is only ever offered a strictly greater version, so a hand-flashed development build is never dragged backwards to the last published release.
Downloading
A bearer-authenticated GET returning application/octet-stream. The badge requests it over HTTP/1.0 on purpose: chunked transfer-encoding is an HTTP/1.1 feature, and the body goes straight into the flash writer with no de-framing in between — chunk headers spliced into an app partition would write cleanly, fail the hash, and waste the whole download.
curl -sS -o firmware.bin \
-H "Authorization: Bearer $DEVICE_TOKEN" \
'https://onetaplabs.com/api/badge/ota/download?v=1.4.0'Images are authenticated rather than served as static files. That is partly defence in depth around the credential question above, and partly so that every fetch is attributable to a known badge — which is what makes the update timeline on /badge mean anything.
What the badge does with it
The image is written to the inactive application partition. The bootloader keeps running the current one until otadata is flipped, which happens as the last act of the install and only after the MD5 has already matched.
So a brownout, a dropped connection or a corrupt download costs a wasted radio session and nothing else — the badge reboots into exactly the firmware it was already running, and says so on screen.
What is not protected is an image that writes and verifies cleanly but crashes on boot. The Arduino bootloader does not enable rollback, so recovery from that is a USB reflash. Install a release on one badge before publishing it to the fleet.
The badge refuses to start an install below 3.85 V unless it is charging. Writing an app partition is the heaviest sustained current the board draws, on a rail that already dips during an e-paper refresh.
Reporting the outcome
A badge cannot report a successful update at the time it happens — succeeding means rebooting, and the reboot ends the request. So the outcome is written to NVS and sent on the next sync as an ota field:
ota=ok|1.4.0
ota=err|1.4.0|Download stopped at 62%Which means an applied row is reported by the new image — genuine evidence that the firmware boots, not merely that it was written. A pending report also makes the badge treat its cache as stale, so a failure is reported on the next opportunity rather than waiting out the backoff the failure itself set.
Rows land in device_ota_events and surface on /badge for the wearer and under Admin → Firmware for the fleet.
Security
The MD5 in the check response is an integritycheck, not a signature. Authenticity comes from the transport: the badge pins the ISRG roots and validates the certificate chain, so the image is known to have come from this origin before a byte of it is written. MD5’s broken collision resistance would matter if the hash were standing in for a signature — it is not, and the hash is computed by the flash writer as it writes, for free.
Image signing is the thing that would genuinely raise the bar, and it needs secure boot enabled at the bootloader. That is a deliberate deferral: it complicates serial recovery and cannot itself be turned on over the air.
- Both endpoints require a device bearer token. A revoked token returns
401and the badge clears it. - The
vparameter selects a manifest entry; the filename comes from the manifest, never from the request. - A badge flashed over USB with an unpublished version reports as Unreleased in the fleet table rather than Current — it is not evidence a release landed.