onlyn00bs-badge

OnlyN00bs: a DEF CON 34 friend-finder badge. ESP32 firmware, Web Bluetooth setup app, printable case
git clone https://git.virtualshack.io/onlyn00bs-badge.git
Log | Files | Refs | README | LICENSE

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 &amp; 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 &amp; 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-&lt;MAC&gt;</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. (&gt;_&lt;)" 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 &amp; 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 => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[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>