provisioning.md (16503B)
1 # Provisioning: the web/BLE setup flow 2 3 How a blank badge gets a handle. On first boot (or any time you force it) the badge 4 comes up as a **BLE GATT peripheral**, a browser talks to it over **Web Bluetooth**, 5 and you type the badge's name + a couple of optional extras. The write lands in NVS, 6 the badge reboots, and from then on it lives in discovery mode broadcasting that 7 handle. 8 9 Source of truth: 10 [`firmware/src/main.cpp`](../firmware/src/main.cpp) (the `PROVISIONING MODE` block + 11 `setup()`), [`firmware/src/config.h`](../firmware/src/config.h) (UUIDs + clamps), and 12 the companion app [`provisioning/index.html`](../provisioning/index.html). This doc is 13 the *why*; the code is the *what*. 14 15 > One radio, never shared. The ESP32 has a single 2.4 GHz radio. Provisioning runs 16 > **BLE**; discovery runs **WiFi/ESP-NOW**. The firmware picks exactly one at boot so 17 > the two never have to coexist. You provision, the badge reboots, and only then does 18 > the ESP-NOW beacon start. 19 20 --- 21 22 ## The two modes, chosen at boot 23 24 ``` 25 BOOT ─┬─ (no handle stored) OR (BOOT held) OR (one-shot "provreq" flag) ─► PROVISIONING (BLE) 26 │ browser writes pairing code → handle (+ clock, + pet) → NVS → reboot 27 └─ (handle stored, no override) ──────────────────────────────────► DISCOVERY (ESP-NOW) 28 ``` 29 30 `setup()` resolves the mode. There are **three ways** to land in provisioning — a 31 fresh badge, the BOOT button, or a one-shot NVS flag set from the on-device menu 32 (`Options → Badge Settings → Developer Options → Provisioning Mode`) so you can re-seed 33 without holding a button through a power cycle: 34 35 ```cpp 36 // firmware/src/main.cpp — setup() 37 bool force_provision = (digitalRead(BUTTON_PIN) == LOW) || // BOOT held at power-on 38 (prefs.getUChar("provreq", 0) == 1); // menu → Provisioning Mode 39 prefs.putUChar("provreq", 0); // consume the one-shot 40 41 String stored = prefs.getString("handle", ""); 42 bool have_handle = stored.length() > 0 && !force_provision; 43 44 if (!have_handle) { mode = MODE_PROVISION; start_provisioning(); } 45 else { mode = MODE_DISCOVER; start_discovery(); } 46 ``` 47 48 --- 49 50 ## The GATT layout 51 52 One custom service, five characteristics — four **write**, plus one **read/notify** 53 metrics stream. The UUIDs are shared between firmware and browser — 54 they **must** match `config.h` and `index.html` or the browser can't find them. 55 56 ``` 57 service 6e8f0001-… "badge-setup" provisioning service 58 ├─ 0006 CODE (write) 3-char pairing code ── GATES everything below 59 ├─ 0002 HANDLE (write) the name your crew sees 60 ├─ 0003 TIME (write) epoch secs, u32 LE — seed the soft-clock 61 ├─ 0004 PET (write) custom-avatar blob (name + 5 faces) 62 └─ 0005 METRICS(read/notify) framed CSV/TSV dump — pairing-code gated (live, v1.7) 63 ``` 64 65 ```cpp 66 // firmware/src/config.h 67 #define PROV_SERVICE_UUID "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e" 68 #define PROV_CHAR_UUID "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e" // handle (write) 69 #define PROV_TIME_CHAR_UUID "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e" // epoch secs, u32 LE (write) 70 #define PROV_PET_CHAR_UUID "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e" // custom pet blob (write) 71 #define PROV_CODE_CHAR_UUID "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e" // 3-char code — GATES the others 72 ``` 73 74 `start_provisioning()` stands the whole thing up with NimBLE — create the server, 75 create the service, attach a callback object per characteristic: 76 77 ```cpp 78 // firmware/src/main.cpp — start_provisioning() 79 NimBLEServer* server = NimBLEDevice::createServer(); 80 server->setCallbacks(new ServerCB()); 81 NimBLEService* svc = server->createService(PROV_SERVICE_UUID); 82 83 svc->createCharacteristic(PROV_CODE_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new CodeWriteCB()); 84 svc->createCharacteristic(PROV_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new HandleWriteCB()); 85 svc->createCharacteristic(PROV_TIME_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new TimeWriteCB()); 86 svc->createCharacteristic(PROV_PET_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new PetWriteCB()); 87 svc->start(); 88 ``` 89 90 --- 91 92 ## The pairing-code gate (physical presence, not crypto) 93 94 BLE writes are cleartext, so anyone in range could otherwise scribble a handle onto 95 any badge in setup mode. The defense is a **fresh 3-char code** shown on the badge's 96 own screen each session. The browser must write that code first; until it matches, 97 every other write is dropped on the floor. 98 99 ``` 100 ┌─────────────┐ code shown on OLED ┌──────────┐ 101 │ badge OLED │ ── "code: K7Q" ────────────────►│ human │ 102 │ SETUP MODE │ │ eyeball │ 103 └─────────────┘ └────┬─────┘ 104 │ retypes K7Q 105 ▼ 106 handle / time / pet writes ◄── unlocked only after ── CODE write matches 107 ``` 108 109 The code is drawn from an **unambiguous uppercase alphabet** (no `0/O/1/I/L`) so it's 110 easy to read off a tiny mono OLED and retype: 111 112 ```cpp 113 // firmware/src/main.cpp — a fresh code per provisioning session 114 static void gen_prov_code(char out[4]) { 115 static const char A[] = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 30 glyphs 116 for (int i = 0; i < 3; i++) out[i] = A[esp_random() % (sizeof(A) - 1)]; 117 out[3] = '\0'; 118 } 119 ``` 120 121 The gate is a single `volatile bool`. Only an exact (case-insensitive) match flips it: 122 123 ```cpp 124 // firmware/src/main.cpp — CodeWriteCB::onWrite 125 char in[4] = {0}; 126 for (uint8_t i = 0; i < v.length() && i < 3; i++) { 127 char ch = (char)v.data()[i]; 128 in[i] = (ch >= 'a' && ch <= 'z') ? (char)(ch - 32) : ch; // uppercase 129 } 130 g_prov_authed = (strcmp(in, g_prov_code) == 0); 131 ``` 132 133 Every other callback opens with the same guard, so an unauthorized write is a no-op: 134 135 ```cpp 136 // firmware/src/main.cpp — HandleWriteCB::onWrite (TimeWriteCB / PetWriteCB are identical) 137 void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override { 138 if (!g_prov_authed) { Serial.println("[PROV] handle write ignored — pairing code not accepted"); return; } 139 NimBLEAttValue v = c->getValue(); 140 char clean[HANDLE_MAX_LEN + 1]; 141 uint8_t len = sanitize_handle(v.data(), v.length(), clean); 142 if (len == 0) return; // ignore empty/garbage writes 143 prefs.putString("handle", clean); 144 g_saved = true; // loop() will reboot us 145 } 146 ``` 147 148 And the auth flag resets on every disconnect, so a new connection must re-send the 149 code — you can't authorize once and let a second client ride the same session: 150 151 ```cpp 152 // firmware/src/main.cpp — ServerCB 153 void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override { 154 g_prov_authed = false; // each new connection must re-send the code 155 NimBLEDevice::startAdvertising(); // allow repeated attempts 156 } 157 ``` 158 159 > **What it is / isn't.** The code proves *line-of-sight to the screen* — a 160 > physical-presence gate against drive-by BLE provisioning. It is **not** crypto: 161 > BLE writes are still cleartext, so someone who can *see your screen* (or shoulder-surf 162 > the code) could provision your badge. That's an acceptable threat model for a con 163 > badge — the real beacon-authenticity guarantees live in the HMAC layer 164 > ([`docs/hmac-beacon-auth.md`](hmac-beacon-auth.md)), not here. 165 > 166 > **What the code protects.** The read/notify metrics stream (above) is behind the same 167 > code, so a matched code also dumps the owner's **encounter log**, not just the ability to 168 > rename the badge. Guessing is bounded: repeated wrong codes latch the gate shut until a 169 > reboot, which needs physical access. The 3-char code (27,000 keyspace) proves 170 > *presence*, not secrecy. 171 172 --- 173 174 ## Advertising: why the name lives in the scan response 175 176 The badge advertises as `badge-setup-<full 6-byte MAC>` (e.g. 177 `badge-setup-14335C591858`) so multiple badges in setup mode are distinguishable in 178 the browser's device picker and map 1:1 to the bench roster (which dedups by MAC). 179 180 That 24-char name is the catch. A legacy BLE advertisement is only 31 bytes, and the 181 128-bit service UUID (18 B) + flags (3 B) already eat 21 of them — the name won't 182 fit alongside. So the firmware builds **both packets explicitly**: the primary 183 advertisement carries flags + the service UUID (so the browser's service filter can 184 find it), and the **scan response** carries the full name *alone* in its own 31 B: 185 186 ```cpp 187 // firmware/src/main.cpp — start_provisioning() 188 NimBLEAdvertisementData advData; // PRIMARY: flags + service filter 189 advData.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP); 190 advData.addServiceUUID(PROV_SERVICE_UUID); 191 adv->setAdvertisementData(advData); 192 193 NimBLEAdvertisementData scanData; // SCAN RESPONSE: full name, alone 194 scanData.setName(prov_name); 195 adv->setScanResponseData(scanData); 196 adv->enableScanResponse(true); 197 adv->start(); 198 ``` 199 200 Letting NimBLE auto-route the name truncated it to an 11-char *Shortened Local Name* 201 (`badge-setup`), which killed the per-badge MAC suffix — hence the manual split. 202 203 --- 204 205 ## The browser side (Web Bluetooth) 206 207 The companion app is a single static [`provisioning/index.html`](../provisioning/index.html) 208 — no build step, no dependencies. It needs **Chrome or Edge** (Web Bluetooth isn't 209 in Firefox/Safari) served over **https or localhost** (Web Bluetooth is a secure 210 context). Its UUID constants mirror `config.h`: 211 212 ```js 213 // provisioning/index.html — must match firmware/src/config.h 214 const SERVICE_UUID = "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e"; 215 const CHAR_UUID = "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e"; // handle 216 const TIME_UUID = "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e"; // clock sync 217 const PET_UUID = "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e"; // custom pet 218 const CODE_UUID = "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e"; // pairing code (gates the others) 219 ``` 220 221 **Connect** — `requestDevice` pops the OS chooser, filtered to our service and 222 name prefix, then resolves the primary service: 223 224 ```js 225 // provisioning/index.html — connect() 226 const device = await navigator.bluetooth.requestDevice({ 227 filters: [{ services: [SERVICE_UUID] }, { namePrefix: DEVICE_NAME }], 228 optionalServices: [SERVICE_UUID] 229 }); 230 const server = await device.gatt.connect(); 231 return { device, service: await server.getPrimaryService(SERVICE_UUID) }; 232 ``` 233 234 **Code first, every time** — because the badge resets its auth flag on each new 235 connection, the app writes the code immediately after connecting for *every* action 236 (save / sync / pet): 237 238 ```js 239 // provisioning/index.html — writeCode() 240 const code = ($("code").value || "").trim().toUpperCase(); 241 if (code.length !== 3) throw new Error("enter the 3-char pairing code shown on the badge"); 242 const cch = await service.getCharacteristic(CODE_UUID); 243 await cch.writeValue(new TextEncoder().encode(code)); 244 ``` 245 246 **The main flow** — connect → code → handle → (best-effort) clock → disconnect: 247 248 ```js 249 // provisioning/index.html — provision() 250 const { device, service } = await connect(); 251 await writeCode(service); // unlock 252 const ch = await service.getCharacteristic(CHAR_UUID); 253 await ch.writeValue(new TextEncoder().encode(handle).slice(0, MAX_LEN)); 254 try { await writeTime(service); } catch (_) { /* time char optional */ } 255 device.gatt.disconnect(); 256 ``` 257 258 The handle input is clamped to printable ASCII and 24 bytes **in the browser**, and 259 the firmware re-clamps with `sanitize_handle()` on arrival — never trust the client. 260 261 --- 262 263 ## The optional writes 264 265 **Clock sync** — the phone/laptop knows real wall-clock time; the badge doesn't. The 266 app writes the current epoch as a little-endian `u32` so the con report can show real 267 timestamps. There's also a **`sync clock only`** button that skips the handle write 268 entirely (re-sync after a battery swap without renaming): 269 270 ```js 271 // provisioning/index.html — writeTime() 272 const tb = new Uint8Array(4); 273 new DataView(tb.buffer).setUint32(0, Math.floor(Date.now() / 1000), true); // LE u32 epoch 274 await (await service.getCharacteristic(TIME_UUID)).writeValue(tb); 275 ``` 276 277 ```cpp 278 // firmware/src/main.cpp — TimeWriteCB::onWrite (guard omitted) 279 uint32_t epoch = (uint32_t)d[0] | ((uint32_t)d[1] << 8) | 280 ((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24); 281 g_clock.set(epoch); 282 // re-sync on an already-named badge reboots to home; first-time setup waits for the handle write 283 if (prefs.getString("handle", "").length() > 0) g_saved = true; 284 ``` 285 286 **Custom pet** — an optional blob of `name` + 5 state faces (idle / contact / 287 milestone / lonely / low-batt), newline-separated. The browser joins the fields; the 288 firmware splits on `\n`, clamps each field to printable ASCII + its max length, stores 289 it to NVS slot `PET_BUILTIN_N`, and auto-selects it. The pet model itself is in 290 `firmware/src/pet.h`. 291 292 ```js 293 // provisioning/index.html — writePet() 294 const blob = [f("pet_name"), f("pet_idle"), f("pet_contact"), 295 f("pet_milestone"), f("pet_lonely"), f("pet_lowbatt")].join("\n"); 296 await (await service.getCharacteristic(PET_UUID)).writeValue(new TextEncoder().encode(blob)); 297 ``` 298 299 --- 300 301 ## Commit: the reboot handshake 302 303 Callbacks run on the BLE stack task; they don't reboot inline. Instead the write that 304 completes provisioning sets a `volatile bool g_saved`, and the main `loop()` — which 305 in provisioning mode does nothing but poll that flag — performs the restart. That 306 guarantees the NVS write is flushed and the BLE client sees its write-response before 307 the radio drops: 308 309 ```cpp 310 // firmware/src/main.cpp — loop() 311 if (mode == MODE_PROVISION) { 312 if (g_saved) { 313 display_boot("saved — rebooting to home"); 314 delay(800); 315 ESP.restart(); // comes back up in DISCOVERY with the new handle 316 } 317 delay(50); 318 return; 319 } 320 ``` 321 322 Full round-trip: 323 324 ``` 325 browser badge (BLE peripheral) 326 ─────── ────────────────────── 327 requestDevice / connect ──────► advertising as badge-setup-<MAC> 328 write CODE "K7Q" ──────► CodeWriteCB → g_prov_authed = true 329 write HANDLE "crash…" ──────► HandleWriteCB → sanitize → NVS → g_saved = true 330 write TIME <epoch> ──────► TimeWriteCB → g_clock.set() 331 gatt.disconnect() ──────► ServerCB::onDisconnect → g_prov_authed = false 332 loop() sees g_saved → ESP.restart() 333 ↳ reboots into DISCOVERY, beaconing "crash…" 334 ``` 335 336 --- 337 338 ## Onboard metrics export 339 340 The fifth characteristic (`PROV_METRICS_CHAR_UUID`, read/notify) streams a framed 341 CSV/TSV dump to the browser: the **encounter log** (`/encounters.log`) + **points 342 history** (`/points_history.csv`) + the **best-friends ledger** (`/best_friends.csv`), 343 plus the Test-Mode CSVs (`/test_batt.csv`, `/test_pts.csv`) when Debug is on. It is 344 **gated by the pairing code** — the badge won't stream until the code matches — chunked 345 to the negotiated MTU, and the page reassembles the notify chunks up to an 346 `==== EOF ====` sentinel, then the badge auto-reboots to discovery a few seconds later. 347 348 Firmware: `stream_metrics()` in `main.cpp` (runs from `loop()`, never a BLE callback); 349 served by the characteristic created in `start_provisioning()`. Browser: 350 `fetchMetricsOverBle()` in `index.html` renders inline-SVG charts (friends ranking · 351 best friends · points over time) and offers **PDF** (print-to-PDF) and re-importable 352 **JSON** export, all from one in-memory `g_data`. 353 354 > **Export regularly over a long con:** the encounter log is size-capped 355 > (`ENC_LOG_MAX_BYTES` in `config.h`) and trims its oldest entries once it's full, so a 356 > daily JSON export keeps the whole history. 357 358 --- 359 360 ## Related docs 361 362 - [`docs/hmac-beacon-auth.md`](hmac-beacon-auth.md) — beacon authenticity in discovery mode (the actual crypto) 363 - `firmware/src/clock.h` — the soft-clock the TIME write seeds 364 - `firmware/src/pet.h` — the custom-pet model the PET write feeds