index.html (31221B)
1 <!DOCTYPE html> 2 <html lang="en"> 3 <head> 4 <meta charset="utf-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1"> 6 <title>badge :: set handle</title> 7 <style> 8 :root { --bg:#0a0e14; --fg:#c8d3e0; --accent:#3ddc97; --warn:#ffb454; --err:#ff5370; --panel:#121821; } 9 * { box-sizing:border-box; } 10 body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; 11 background:var(--bg); color:var(--fg); font:16px/1.5 ui-monospace,Menlo,Consolas,monospace; } 12 .card { width:min(420px,92vw); background:var(--panel); border:1px solid #1e2733; 13 border-radius:12px; padding:28px; box-shadow:0 0 40px #0008; } 14 h1 { margin:0 0 4px; font-size:20px; color:var(--accent); letter-spacing:.5px; } 15 .sub { margin:0 0 22px; font-size:13px; color:#6b7a8d; } 16 label { display:block; font-size:12px; color:#6b7a8d; margin-bottom:6px; text-transform:uppercase; letter-spacing:1px; } 17 input { width:100%; padding:12px 14px; font:inherit; color:var(--fg); background:#0c1119; 18 border:1px solid #243040; border-radius:8px; outline:none; } 19 input:focus { border-color:var(--accent); } 20 .meta { display:flex; justify-content:space-between; font-size:11px; color:#4a5666; margin-top:6px; } 21 button { width:100%; margin-top:20px; padding:13px; font:inherit; font-weight:600; cursor:pointer; 22 color:#08110b; background:var(--accent); border:none; border-radius:8px; transition:opacity .15s; } 23 button:disabled { opacity:.4; cursor:not-allowed; } 24 button.secondary { margin-top:10px; background:transparent; color:var(--accent); border:1px solid #243040; } 25 #status { margin-top:18px; font-size:13px; min-height:1.4em; } 26 .ok { color:var(--accent); } .warn { color:var(--warn); } .err { color:var(--err); } 27 .hint { margin-top:20px; padding-top:16px; border-top:1px solid #1e2733; font-size:12px; color:#5a6878; } 28 /* browser gate — shown when Web Bluetooth is missing, so a recipient gets an 29 explanation instead of a button that silently does nothing. */ 30 .gate { display:none; margin:0 0 20px; padding:14px 16px; border-radius:8px; 31 background:#2a1520; border:1px solid var(--err); font-size:13px; line-height:1.5; } 32 .gate.show { display:block; } 33 .gate b { color:var(--err); } 34 .gate p { margin:0 0 8px; } .gate p:last-child { margin-bottom:0; } 35 .gate a { color:var(--accent); } 36 .home { display:block; margin-top:16px; font-size:12px; color:#5a6878; text-align:center; } 37 .home a { color:#5a6878; } 38 </style> 39 </head> 40 <body> 41 <div class="card"> 42 <h1>// badge handle</h1> 43 <p class="sub">connect over bluetooth & set your username</p> 44 45 <div id="gate" class="gate"></div> 46 47 <label for="handle">handle</label> 48 <div id="handleWarn" style="display:none;color:#e0a83a;font-size:11px;margin:2px 0 4px;">⚠ no commas — they break the CSV / metrics export</div> 49 <input id="handle" maxlength="24" placeholder="e.g. crash_override" autocomplete="off" autocapitalize="off" spellcheck="false"> 50 <div class="meta"><span id="count">0 / 24</span><span>printable ASCII only</span></div> 51 52 <label for="code">pairing code</label> 53 <input id="code" maxlength="3" placeholder="K7Q" autocomplete="off" autocapitalize="characters" spellcheck="false" style="text-transform:uppercase;letter-spacing:6px;font-weight:700;text-align:center;"> 54 <div class="meta"><span>the 3 chars shown on the badge screen</span><span>required</span></div> 55 56 <button id="go">connect & save</button> 57 <button id="sync" class="secondary">sync clock only</button> 58 <div id="status"></div> 59 60 <div class="hint"> 61 Needs <b>Chrome / Edge</b> (Web Bluetooth) over <b>https</b> or <b>localhost</b>.<br/> 62 Put the badge in setup mode (either by holding the microcontroller's BOOT button on boot (requires opening the case), or <b>Options → Badge Settings → Developer Options → Provisioning Mode</b>), 63 then <b>type the 3-char code shown on its screen</b> above, hit connect, and pick 64 <code>badge-setup-<MAC></code> (the full MAC matches your bench roster).<br/>The badge ignores 65 provisioning until that code matches, and <b>locks setup after 5 wrong codes</b> — power-cycle 66 it for a fresh one. <br/>Already named? Hit <b>sync clock only</b> to re-sync the 67 time (e.g. after a battery swap) without renaming — the badge reboots back to home. 68 </div> 69 70 <div class="hint"> 71 <label style="margin-top:0;color:var(--accent);">custom avatar (optional)</label> 72 <input id="pet_name" maxlength="11" placeholder="name — e.g. ghost" autocomplete="off" spellcheck="false"> 73 <input id="pet_idle" maxlength="15" placeholder="idle face — e.g. (^_^)" autocomplete="off" spellcheck="false" style="margin-top:8px;"> 74 <input id="pet_contact" maxlength="15" placeholder="new-crew face — e.g. (o_o)!" autocomplete="off" spellcheck="false" style="margin-top:8px;"> 75 <input id="pet_milestone" maxlength="15" placeholder="milestone face — e.g. (^o^)" autocomplete="off" spellcheck="false" style="margin-top:8px;"> 76 <input id="pet_lonely" maxlength="15" placeholder="lonely face — e.g. (-_-)z" autocomplete="off" spellcheck="false" style="margin-top:8px;"> 77 <input id="pet_lowbatt" maxlength="15" placeholder="low-batt face — e.g. (>_<)" autocomplete="off" spellcheck="false" style="margin-top:8px;"> 78 <button id="petBtn" class="secondary">upload custom avatar</button> 79 <p style="margin:10px 0 0;font-size:11px;color:#5a6878;">Uploads to the badge's 4th avatar slot — pick it on the device: User Settings → Avatar.</p> 80 </div> 81 82 <div class="hint"> 83 <label style="margin-top:0;color:var(--accent);">// onboard metrics (beta)</label> 84 <p style="margin:0 0 10px;font-size:11px;color:#5a6878;"> 85 Pulls the badge's encounter log + points history over BLE (plus the Test-Mode 86 CSVs <code>/test_batt.csv</code> / <code>/test_pts.csv</code> if Debug is on). 87 Put the badge in provisioning mode, enter its pairing code, then fetch. 88 </p> 89 <label for="mcode" style="margin-top:0;">pairing code</label> 90 <input id="mcode" maxlength="3" placeholder="K7Q" autocomplete="off" autocapitalize="characters" spellcheck="false" style="text-transform:uppercase;letter-spacing:6px;font-weight:700;text-align:center;"> 91 <button id="fetchBtn" class="secondary" style="margin-top:10px;">fetch from badge (ble)</button> 92 <label for="jsonFile" style="margin-top:12px;">…or import a JSON export</label> 93 <input id="jsonFile" type="file" accept=".json,application/json" style="padding:9px 10px;"> 94 <div id="report" style="margin-top:14px;"></div> 95 <button id="pdfBtn" class="secondary" style="display:none;margin-top:4px;">export figures to PDF</button> 96 <button id="jsonBtn" class="secondary" style="display:none;margin-top:4px;">download JSON</button> 97 </div> 98 99 <p class="home"><a href="../">← screens, battery safety & troubleshooting</a></p> 100 </div> 101 102 <script> 103 // ── must match firmware/src/config.h ── 104 const SERVICE_UUID = "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e"; 105 const CHAR_UUID = "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e"; // handle 106 const TIME_UUID = "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e"; // clock sync 107 const PET_UUID = "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e"; // custom pet 108 const CODE_UUID = "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e"; // 3-char pairing code (gates the others) 109 const DEVICE_NAME = "badge-setup"; 110 const MAX_LEN = 24; 111 112 const $ = id => document.getElementById(id); 113 const handleEl = $("handle"), statusEl = $("status"), btn = $("go"), syncBtn = $("sync"), countEl = $("count"), petBtn = $("petBtn"), handleWarn = $("handleWarn"); 114 115 // Set by the browser gate at the bottom of this script when Web Bluetooth is 116 // unavailable. Anything that re-enables a button has to respect it, or typing 117 // would quietly undo the gate. 118 let g_bt_blocked = false; 119 120 // keep input to printable ASCII, clamp to MAX_LEN bytes (ASCII = 1 byte/char here) 121 handleEl.addEventListener("input", () => { 122 let v = handleEl.value.replace(/[^\x20-\x7E]/g, ""); 123 if (v.length > MAX_LEN) v = v.slice(0, MAX_LEN); 124 handleEl.value = v; 125 countEl.textContent = `${v.length} / ${MAX_LEN}`; 126 const hasComma = v.includes(","); // commas break the CSV export → warn + block provisioning 127 handleWarn.style.display = hasComma ? "" : "none"; 128 btn.disabled = hasComma || g_bt_blocked; 129 }); 130 131 function setStatus(msg, cls="") { statusEl.className = cls; statusEl.textContent = msg; } 132 133 // The badge latches its pairing gate after PROV_CODE_MAX_TRIES (5) wrong codes and drops 134 // the link — so a mid-flow disconnect is far more often "you mistyped the code a few 135 // times" than a real radio problem. The raw GATT error says none of that. 136 function friendlyError(e) { 137 const m = (e && e.message) ? e.message : String(e); 138 if (/disconnect|GATT Server is disconnected|NetworkError/i.test(m)) { 139 return m + " — note the badge locks setup after 5 wrong pairing codes. Power-cycle it " + 140 "and read a fresh code off its screen."; 141 } 142 return m; 143 } 144 145 // Connect to a badge in setup mode and return its GATT service. 146 async function connect() { 147 if (!navigator.bluetooth) throw new Error("Web Bluetooth unavailable — use Chrome/Edge over https"); 148 setStatus("requesting device…"); 149 const device = await navigator.bluetooth.requestDevice({ 150 filters: [{ services: [SERVICE_UUID] }, { namePrefix: DEVICE_NAME }], 151 optionalServices: [SERVICE_UUID] 152 }); 153 setStatus("connecting…"); 154 const server = await device.gatt.connect(); 155 return { device, service: await server.getPrimaryService(SERVICE_UUID) }; 156 } 157 158 // Write the 3-char pairing code shown on the badge. The badge ignores every other 159 // write until this matches, so call it right after connect() in every action. 160 async function writeCode(service, id = "code") { 161 const code = ($(id).value || "").trim().toUpperCase(); 162 if (code.length !== 3) throw new Error("enter the 3-char pairing code shown on the badge"); 163 const cch = await service.getCharacteristic(CODE_UUID); 164 await cch.writeValue(new TextEncoder().encode(code)); 165 } 166 167 // Write the phone's current epoch (little-endian u32) to the clock characteristic. 168 async function writeTime(service) { 169 const tch = await service.getCharacteristic(TIME_UUID); 170 const tb = new Uint8Array(4); 171 new DataView(tb.buffer).setUint32(0, Math.floor(Date.now() / 1000), true); 172 await tch.writeValue(tb); 173 } 174 175 async function provision() { 176 const handle = handleEl.value.trim(); 177 if (!handle) { setStatus("enter a handle first", "warn"); return; } 178 if (handle.includes(",")) { setStatus("remove the comma — it breaks the CSV/metrics export", "err"); return; } 179 btn.disabled = syncBtn.disabled = true; 180 try { 181 const { device, service } = await connect(); 182 setStatus("sending pairing code…"); 183 await writeCode(service); 184 setStatus("writing handle…"); 185 const ch = await service.getCharacteristic(CHAR_UUID); 186 await ch.writeValue(new TextEncoder().encode(handle).slice(0, MAX_LEN)); 187 // Best-effort clock sync so the con report can show real wall-clock times. 188 let synced = false; 189 try { await writeTime(service); synced = true; } catch (_) { /* time char optional */ } 190 device.gatt.disconnect(); 191 setStatus(`✓ sent "${handle}"${synced ? " + clock" : ""} — badge reboots to home if the code matched`, "ok"); 192 } catch (e) { 193 setStatus("✗ " + friendlyError(e), "err"); 194 } finally { 195 btn.disabled = syncBtn.disabled = false; 196 } 197 } 198 199 // Re-sync the clock only (no handle change). The badge reboots to home if 200 // it already has a handle stored. 201 async function syncClock() { 202 btn.disabled = syncBtn.disabled = true; 203 try { 204 const { device, service } = await connect(); 205 setStatus("sending pairing code…"); 206 await writeCode(service); 207 setStatus("syncing clock…"); 208 await writeTime(service); 209 device.gatt.disconnect(); 210 setStatus("✓ sent — badge reboots to home if the code matched", "ok"); 211 } catch (e) { 212 setStatus("✗ " + friendlyError(e), "err"); 213 } finally { 214 btn.disabled = syncBtn.disabled = false; 215 } 216 } 217 218 // keep custom-avatar fields to printable ASCII (element ids stay pet_* — see below) 219 ["pet_name","pet_idle","pet_contact","pet_milestone","pet_lonely","pet_lowbatt"].forEach(id => { 220 $(id).addEventListener("input", e => { e.target.value = e.target.value.replace(/[^\x20-\x7E]/g, ""); }); 221 }); 222 223 // Write the custom-pet blob: name + 5 faces, newline-separated. 224 async function writePet(service) { 225 const f = id => $(id).value; 226 const blob = [f("pet_name"), f("pet_idle"), f("pet_contact"), 227 f("pet_milestone"), f("pet_lonely"), f("pet_lowbatt")].join("\n"); 228 const pch = await service.getCharacteristic(PET_UUID); 229 await pch.writeValue(new TextEncoder().encode(blob)); 230 } 231 232 async function uploadPet() { 233 const name = $("pet_name").value.trim(); 234 if (!name) { setStatus("give your avatar a name first", "warn"); return; } 235 btn.disabled = syncBtn.disabled = petBtn.disabled = true; 236 try { 237 const { device, service } = await connect(); 238 setStatus("sending pairing code…"); 239 await writeCode(service); 240 setStatus("uploading avatar…"); 241 await writePet(service); 242 device.gatt.disconnect(); 243 setStatus(`✓ sent avatar "${name}" — if the code matched, pick it on the badge: User Settings → Avatar`, "ok"); 244 } catch (e) { 245 setStatus("✗ " + friendlyError(e), "err"); 246 } finally { 247 btn.disabled = syncBtn.disabled = petBtn.disabled = false; 248 } 249 } 250 251 // ── onboard metrics / reports ────────────────────────────────────────────── 252 // The badge persists Test-Mode diagnostics as CSV on LittleFS. Two retrieval 253 // paths, both live: (1) BLE auto-fetch over the metrics characteristic, served 254 // by the firmware since v1.7 (PROV_METRICS_CHAR_UUID in firmware/src/config.h, 255 // stream_metrics() in main.cpp); (2) manual import of a serial 256 // `==== … ==== end ====` dump or a JSON export from a previous session. 257 const METRICS_UUID = "6e8f0005-b5a3-f393-e0a9-e50e24dcca9e"; // read/notify, gated by the pairing code 258 const fetchBtn = $("fetchBtn"), jsonFileEl = $("jsonFile"), reportEl = $("report"), 259 pdfBtn = $("pdfBtn"), jsonBtn = $("jsonBtn"); 260 // State from the last render, for the exports. g_figures = [{title,summary,svg}] (PDF); 261 // g_data = {device, exported, sections:[{name,header,rows}]} (JSON round-trip). 262 let g_figures = [], g_reportDevice = "", g_data = null; 263 const FIGURE_TITLE = { 264 "/encounters.log": "Encounter Log — Friends Ranking", 265 "/best_friends.csv": "Best Friends — Shared Time", 266 "/points_history.csv": "Points Over Time", 267 }; 268 269 // Known export schemas. The BLE dump frames each source as `==== path ==== … ==== end ====`. 270 // /encounters.log is headerless TSV on the badge (we add the header + comma-ize on receipt); 271 // the rest carry their own CSV header. (firmware: encounters.h, points_history.h, main.cpp.) 272 const METRIC_SCHEMAS = { 273 "/encounters.log": "first_epoch,last_epoch,sightings,rssi_max,handle", 274 "/points_history.csv": "epoch,points,seg_start", 275 "/test_batt.csv": "boot,elapsed_ms,vbat_mv,soc_pct", 276 "/test_pts.csv": "boot,elapsed_ms,points,prox_s,peers,mult", 277 }; 278 279 // Parse a CSV blob → {header:[], rows:[[...]]}; tolerates the dump's `==== … ====` 280 // frame lines by dropping them. 281 function parseCsv(text) { 282 const lines = text.trim().split(/\r?\n/).filter(l => l && !l.startsWith("====")); 283 if (!lines.length) return { header: [], rows: [] }; 284 return { 285 header: lines[0].split(",").map(s => s.trim()), 286 rows: lines.slice(1).map(l => l.split(",").map(s => s.trim())), 287 }; 288 } 289 290 // ── inline SVG charts (self-contained — the page is used air-gapped) ──────── 291 const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&":"&","<":"<",">":">",'"':""" }[c])); 292 const svg = (w, h, inner) => `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" style="display:block">${inner}</svg>`; 293 function fmtDur(s) { 294 s = Math.round(s); 295 if (s < 60) return s + "s"; 296 if (s < 3600) return Math.floor(s / 60) + "m"; 297 return Math.floor(s / 3600) + "h" + Math.floor((s % 3600) / 60) + "m"; 298 } 299 function chartNote(msg) { 300 return `<div style="height:40px;border:1px dashed #243040;border-radius:6px;display:flex;` + 301 `align-items:center;justify-content:center;color:#4a5666;font-size:11px;">${esc(msg)}</div>`; 302 } 303 // Horizontal bar chart: items = [{label, value, sub}], longest value = full width. 304 function barChart(items) { 305 const W = 300, rowH = 17, pad = 82, valW = 46; 306 const max = Math.max(...items.map(i => i.value)) || 1, h = items.length * rowH + 4; 307 let s = ""; 308 items.forEach((it, i) => { 309 const y = i * rowH; 310 const label = it.label.length > 12 ? it.label.slice(0, 11) + "…" : it.label; 311 const bw = Math.max(1, Math.round((W - pad - valW) * it.value / max)); 312 s += `<text x="0" y="${y+12}" fill="#8aa3b8" font-size="10" font-family="monospace">${esc(label)}</text>`; 313 s += `<rect x="${pad}" y="${y+3}" width="${bw}" height="10" rx="2" style="fill:var(--accent)"/>`; 314 s += `<text x="${pad+bw+4}" y="${y+12}" fill="#5a6878" font-size="9" font-family="monospace">${esc(it.sub)}</text>`; 315 }); 316 return svg(W, h, s); 317 } 318 // Line chart of ys over xs (parallel arrays), with min/max y labels. 319 function lineChart(xs, ys) { 320 const W = 300, H = 70, pad = 4; 321 const xmin = Math.min(...xs), xmax = Math.max(...xs), ymin = Math.min(...ys), ymax = Math.max(...ys); 322 const xr = (xmax - xmin) || 1, yr = (ymax - ymin) || 1; 323 const pts = xs.map((x, i) => { 324 const px = pad + (W - 2 * pad) * (x - xmin) / xr; 325 const py = (H - pad) - (H - 2 * pad) * (ys[i] - ymin) / yr; 326 return `${px.toFixed(1)},${py.toFixed(1)}`; 327 }).join(" "); 328 return svg(W, H, `<polyline points="${pts}" style="fill:none;stroke:var(--accent);stroke-width:1.5"/>` + 329 `<text x="0" y="9" fill="#5a6878" font-size="9">${ymax}</text>` + 330 `<text x="0" y="${H-2}" fill="#5a6878" font-size="9">${ymin}</text>`); 331 } 332 // Friends-ranking: aggregate the encounter log per handle by time-together, top 8. 333 function friendsChart(rows) { 334 const agg = {}; 335 for (const r of rows) { 336 const first = +r[0], last = +r[1], h = r[4]; 337 if (!h) continue; 338 (agg[h] || (agg[h] = { secs: 0 })).secs += (last > first ? last - first : 0); 339 } 340 const items = Object.entries(agg).map(([h, a]) => ({ label: h, value: a.secs, sub: fmtDur(a.secs) })) 341 .sort((x, y) => y.value - x.value).slice(0, 8); 342 return items.length 343 ? `<div style="font-size:10px;color:#5a6878;margin-bottom:3px;">top crew by time together</div>` + barChart(items) 344 : chartNote("no encounters logged yet"); 345 } 346 // Points-over-time: epoch (or row index if the clock was unsynced) vs cumulative points. 347 function pointsChart(rows) { 348 const valid = rows.filter(r => !isNaN(+r[1])); 349 if (valid.length < 2) return chartNote("no points history yet"); 350 const ys = valid.map(r => +r[1]); 351 const xs = valid.map((r, i) => { const e = +r[0]; return e > 0 ? e : i; }); 352 return `<div style="font-size:10px;color:#5a6878;margin-bottom:3px;">points over time</div>` + lineChart(xs, ys); 353 } 354 // Best-friends ledger: rows = [seconds, handle…] (handle last, may contain commas). Bar by time. 355 function bestFriendsChart(rows) { 356 const items = rows.map(r => ({ label: r.slice(1).join(","), value: +r[0], sub: fmtDur(+r[0]) })) 357 .filter(it => it.label && !isNaN(it.value)) 358 .sort((x, y) => y.value - x.value).slice(0, 8); 359 return items.length 360 ? `<div style="font-size:10px;color:#5a6878;margin-bottom:3px;">best friends by shared time</div>` + barChart(items) 361 : chartNote("no best-friends data yet"); 362 } 363 function chartFor(name, rows) { 364 if (name === "/encounters.log") return friendsChart(rows); 365 if (name === "/best_friends.csv") return bestFriendsChart(rows); 366 if (name === "/points_history.csv") return pointsChart(rows); 367 return ""; // diagnostics CSVs: numeric summary only (no battery chart, per design) 368 } 369 370 // Per-section numeric summary + a section-appropriate chart, from structured data. 371 function renderSection(name, header, rows) { 372 const summary = header.map((h, i) => { 373 const v = rows.map(r => parseFloat(r[i])).filter(x => !isNaN(x)); 374 return v.length ? `${h}: n=${v.length} min=${Math.min(...v)} max=${Math.max(...v)}` : `${h}: —`; 375 }).join(" · "); 376 const chart = chartFor(name, rows); 377 if (chart && chart.includes("<svg")) // a real figure (not a "no data" note) → collect for the PDF 378 g_figures.push({ title: FIGURE_TITLE[name] || name, summary, svg: chart }); 379 const el = document.createElement("div"); 380 el.style.cssText = "margin-bottom:12px;font-size:11px;color:#8aa3b8;"; 381 el.innerHTML = `<b style="color:var(--accent)">${esc(name)}</b> — ${rows.length} rows<br>${summary}` + 382 (chart ? `<div style="margin-top:6px;">${chart}</div>` : ""); 383 reportEl.appendChild(el); 384 } 385 386 // Import a JSON export (round-trip: re-render a saved dump without re-fetching the badge). 387 jsonFileEl.addEventListener("change", async () => { 388 if (!jsonFileEl.files.length) return; 389 try { 390 const obj = JSON.parse(await jsonFileEl.files[0].text()); 391 if (!obj || !Array.isArray(obj.sections)) throw new Error("not a badge-metrics JSON export"); 392 reportEl.innerHTML = ""; 393 g_figures = []; 394 g_data = obj; 395 g_reportDevice = obj.device || ""; 396 for (const s of obj.sections) renderSection(s.name, s.header || [], s.rows || []); 397 pdfBtn.style.display = g_figures.length ? "" : "none"; 398 jsonBtn.style.display = obj.sections.length ? "" : "none"; 399 setStatus(`✓ imported ${obj.sections.length} section(s) from JSON`, "ok"); 400 } catch (e) { 401 setStatus("✗ " + friendlyError(e), "err"); 402 } finally { 403 jsonFileEl.value = ""; // allow re-importing the same file 404 } 405 }); 406 407 // Download the fetched/imported data as a structured JSON file (re-importable above). Direct 408 // Blob download — no library, so it works on the air-gapped page. 409 function exportJson() { 410 if (!g_data || !g_data.sections.length) { setStatus("nothing to export — fetch first", "warn"); return; } 411 const blob = new Blob([JSON.stringify(g_data, null, 2)], { type: "application/json" }); 412 const url = URL.createObjectURL(blob); 413 const a = document.createElement("a"); 414 a.href = url; 415 a.download = `badge-metrics-${(g_reportDevice || "badge").replace(/[^\w.-]/g, "_")}-${Date.now()}.json`; 416 document.body.appendChild(a); 417 a.click(); 418 a.remove(); 419 URL.revokeObjectURL(url); 420 setStatus("✓ downloaded JSON", "ok"); 421 } 422 423 // Export the collected figures to PDF via the browser's native print-to-PDF (no external 424 // library — the page is air-gapped): open a print-styled doc with each figure under a header. 425 function exportPdf() { 426 if (!g_figures.length) { setStatus("nothing to export — fetch first", "warn"); return; } 427 const w = window.open("", "_blank"); 428 if (!w) { setStatus("popup blocked — allow popups to export the PDF", "err"); return; } 429 const when = new Date().toLocaleString(); 430 const secs = g_figures.map(f => 431 `<section><h2>${esc(f.title)}</h2>` + 432 (f.summary ? `<p class="sum">${esc(f.summary)}</p>` : "") + 433 `<div class="fig">${f.svg}</div></section>`).join(""); 434 w.document.write( 435 `<!doctype html><html><head><meta charset="utf-8"><title>badge metrics report</title><style>` + 436 `:root{--accent:#2563eb}` + 437 `body{font-family:system-ui,-apple-system,sans-serif;color:#111;margin:28px;}` + 438 `h1{font-size:20px;margin:0 0 2px;}.meta{color:#666;font-size:12px;margin:0 0 22px;}` + 439 `section{margin:0 0 24px;page-break-inside:avoid;}` + 440 `h2{font-size:14px;margin:0 0 6px;border-bottom:1px solid #ddd;padding-bottom:4px;}` + 441 `.sum{color:#555;font-size:11px;margin:0 0 8px;font-family:monospace;word-break:break-word;}` + 442 `.fig{max-width:540px;}svg{max-width:540px;}svg text{fill:#333;}` + 443 `@media print{body{margin:0;}}` + 444 `</style></head><body>` + 445 `<h1>DEF CON badge — metrics report</h1>` + 446 `<p class="meta">${esc(g_reportDevice || "badge")} · exported ${esc(when)} · ${g_figures.length} figure(s)</p>` + 447 secs + `</body></html>`); 448 w.document.close(); 449 w.focus(); 450 setTimeout(() => { try { w.print(); } catch (_) {} }, 350); 451 setStatus("opened print view — choose “Save as PDF”", "ok"); 452 } 453 454 // Split the reassembled dump into { path: content } by its `==== path ====` frame lines 455 // (`==== end ====` / `==== EOF ====` close the current section). Mirrors TestLog::dump framing. 456 function parseSections(text) { 457 const out = {}; let cur = null, buf = []; 458 const flush = () => { if (cur) out[cur] = buf.join("\n"); cur = null; buf = []; }; 459 for (const line of text.split(/\r?\n/)) { 460 const m = line.match(/^==== (.+?) ====$/); 461 if (m) { flush(); if (m[1] !== "end" && m[1] !== "EOF") cur = m[1]; } 462 else if (cur !== null) buf.push(line); 463 } 464 flush(); 465 return out; 466 } 467 468 // Pull the metrics dump off the badge over BLE: connect → send the pairing code (the badge 469 // streams only once it matches) → subscribe to the metrics char → reassemble the notify chunks 470 // until the "==== EOF ====" sentinel → split the sections → render each. The badge reboots to 471 // discovery a few seconds after the stream, so it's one-shot per provisioning session. 472 async function fetchMetricsOverBle() { 473 fetchBtn.disabled = true; 474 let dev = null; 475 try { 476 const { device, service } = await connect(); 477 dev = device; 478 g_reportDevice = device.name || ""; // e.g. "badge-setup-14335C519F20" — for the PDF header 479 setStatus("sending pairing code…"); 480 await writeCode(service, "mcode"); // gate — the badge ignores the stream until this matches 481 const mch = await service.getCharacteristic(METRICS_UUID); 482 let text = ""; 483 const done = new Promise((resolve, reject) => { 484 const to = setTimeout(() => reject(new Error("timed out waiting for the badge stream (right code? still in provisioning mode?)")), 25000); 485 mch.addEventListener("characteristicvaluechanged", (e) => { 486 text += new TextDecoder().decode(e.target.value); // chunks are ASCII CSV/TSV 487 setStatus(`receiving… ${text.length} bytes`); 488 if (text.includes("==== EOF ====")) { clearTimeout(to); resolve(); } 489 }); 490 }); 491 setStatus("subscribing…"); 492 await mch.startNotifications(); // → badge onSubscribe → it streams (code accepted) 493 await done; 494 await mch.stopNotifications().catch(() => {}); 495 const sections = parseSections(text); 496 const names = Object.keys(sections); 497 reportEl.innerHTML = ""; 498 g_figures = []; 499 g_data = { device: g_reportDevice, exported: new Date().toISOString(), sections: [] }; 500 for (const name of names) { 501 let content = sections[name]; 502 if (name === "/encounters.log") // headerless TSV → add header + comma-ize for parseCsv 503 content = METRIC_SCHEMAS["/encounters.log"] + "\n" + content.replace(/\t/g, ","); 504 const { header, rows } = parseCsv(content); 505 g_data.sections.push({ name, header, rows }); // structured, for the JSON export 506 renderSection(name, header, rows); 507 } 508 pdfBtn.style.display = g_figures.length ? "" : "none"; 509 jsonBtn.style.display = g_data.sections.length ? "" : "none"; 510 setStatus(`✓ fetched ${text.length} bytes · ${names.length} section(s)` + 511 `${g_figures.length ? ` · ${g_figures.length} figure(s)` : ""} — badge reboots to home shortly`, "ok"); 512 } catch (e) { 513 setStatus("✗ " + friendlyError(e), "err"); 514 } finally { 515 try { if (dev && dev.gatt && dev.gatt.connected) dev.gatt.disconnect(); } catch (_) {} 516 fetchBtn.disabled = false; 517 } 518 } 519 520 btn.addEventListener("click", provision); 521 syncBtn.addEventListener("click", syncClock); 522 petBtn.addEventListener("click", uploadPet); 523 fetchBtn.addEventListener("click", fetchMetricsOverBle); 524 pdfBtn.addEventListener("click", exportPdf); 525 jsonBtn.addEventListener("click", exportJson); 526 527 // ── browser gate ─────────────────────────────────────────────────────────── 528 // Web Bluetooth is Chrome/Edge only, and needs https or localhost. Every iOS 529 // browser is WebKit underneath, so no iPhone can provision a badge — Chrome for 530 // iOS included. Say that up front instead of handing someone a dead button. 531 (function gateCheck() { 532 const gate = $("gate"); 533 const ua = navigator.userAgent; 534 const isIOS = /iPad|iPhone|iPod/.test(ua) || 535 (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); // iPadOS 536 const insecure = !window.isSecureContext; 537 if (navigator.bluetooth && !insecure) return; // all good, stay hidden 538 539 let msg; 540 if (isIOS) { 541 msg = `<p><b>iOS can't do this.</b></p><p>Web Bluetooth doesn't exist on iPhone or 542 iPad — every iOS browser is Safari underneath, so Chrome for iOS won't help 543 either. Borrow an <b>Android phone or a laptop</b> running Chrome or Edge.</p>`; 544 } else if (insecure) { 545 msg = `<p><b>Insecure page.</b></p><p>Web Bluetooth only runs over <b>https</b> or 546 <b>localhost</b>. Open this page at its https address, or serve it locally 547 with <code>python3 -m http.server</code>.</p>`; 548 } else { 549 msg = `<p><b>This browser can't do Bluetooth.</b></p><p>Badge setup needs 550 <b>Chrome or Edge</b> on desktop or Android. Firefox and Safari don't 551 implement Web Bluetooth at all.</p>`; 552 } 553 gate.innerHTML = msg + `<p>Everything else on this page still works — you can 554 import a JSON export and read the charts.</p>`; 555 gate.className = "gate show"; 556 g_bt_blocked = true; 557 [btn, syncBtn, petBtn, fetchBtn].forEach(b => { b.disabled = true; }); 558 setStatus("bluetooth unavailable in this browser", "err"); 559 })(); 560 561 // Offline shell, when hosted (badge.virtualshack.io/provision/ → ../sw.js). 562 // Harmless 404 when this file is opened straight out of the repo. 563 if ("serviceWorker" in navigator && window.isSecureContext) { 564 navigator.serviceWorker.register("../sw.js").catch(() => {}); 565 } 566 </script> 567 </body> 568 </html>