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

main.cpp (86698B)


      1 // ─────────────────────────────────────────────────────────────────────────
      2 //  DefCon friend-finder badge — firmware core
      3 //
      4 //  Modes, chosen at boot. The radio is never shared:
      5 //
      6 //    BOOT ─┬─ (no handle stored) OR (BOOT button held) ─► PROVISIONING (BLE)
      7 //          │        phone writes handle + clock → NVS → reboot
      8 //          └─ (handle stored) ───────────────────────────► DISCOVERY (ESP-NOW)
      9 //                   TX signed beacon ~1Hz; RX verify → peer table → display
     10 //                   peers leaving range → encounter log (LittleFS)
     11 //                   +1 point per minute while ≥1 crew is in range (→ NVS),
     12 //                   ×2 with crew around / ×3 in a group of 5+ (the group
     13 //                   multiplier — POINTS_X2/X3_PEERS), credited to every
     14 //                   in-gate companion (the points ledger)
     15 //                   hold BUTTON ≥REPORT_HOLD_MS → cycle view:
     16 //                        FINDER → REPORT → POINTS → FRIENDS → BATTERY → FINDER
     17 // ─────────────────────────────────────────────────────────────────────────
     18 
     19 #include <Arduino.h>
     20 #include <time.h>            // localtime_r / tzset — render the soft-clock epoch in DISPLAY_TZ
     21 #include <Preferences.h>
     22 #include <WiFi.h>
     23 #include <esp_now.h>
     24 #include <esp_wifi.h>
     25 #include <esp_mac.h>
     26 #include <esp_system.h>
     27 #include <esp_sleep.h>       // ext0 wake + deep sleep entry (OPTIONS → Power → Deep sleep)
     28 #include <NimBLEDevice.h>
     29 
     30 #include "config.h"
     31 #include "beacon.h"
     32 #include "clock.h"
     33 #include "encounters.h"
     34 #include "peers.h"
     35 #include "points.h"
     36 #include "points_history.h"
     37 #include "battery.h"
     38 #include "display.h"
     39 #include "pet.h"
     40 #include "test_log.h"
     41 
     42 enum Mode { MODE_PROVISION, MODE_DISCOVER };
     43 static Mode mode;
     44 
     45 // In discovery mode the BUTTON cycles through these views (REPORT is a static
     46 // snapshot; HOME, FINDER and POINTS are live and redraw on every display tick).
     47 // HOME is the at-a-glance default/initial view; it leads the ring.
     48 enum View { VIEW_HOME = 0, VIEW_FINDER, VIEW_REPORT, VIEW_POINTS, VIEW_FRIENDS, VIEW_BATTERY,
     49             VIEW_OPTIONS, VIEW_COUNT };
     50 static View g_view = VIEW_HOME;
     51 // FINDER right column: false = peer pet face, true = dBm. Set via User Settings →
     52 // "Finder View" (persisted to NVS, key "finder"); the FINDER header no longer labels it.
     53 static bool g_finder_rssi = false;
     54 // FINDER paging: the list shows FINDER_PAGE_ROWS closest peers per screen; a single tap
     55 // pages down (wrapping). g_finder_pages caches the last render's page total so the tap
     56 // handler can wrap without re-locking the peer table.
     57 static int g_finder_page  = 0;
     58 static int g_finder_pages = 1;
     59 static int finder_pages(int n) {              // total finder pages for n peers (≥1)
     60   int p = (n + FINDER_PAGE_ROWS - 1) / FINDER_PAGE_ROWS;
     61   return p < 1 ? 1 : p;
     62 }
     63 
     64 // OPTIONS is a nested menu: the ROOT list (Back · User Settings · Badge Settings), the
     65 // two settings sub-menus, their sub-sub-menus (Display / Developer Options / Power under
     66 // Badge, Reminders under User), or a child value-picker (pet, reached from User; splash,
     67 // from Display). Every screen stays <= 5 rows, which is the renderer's row array and
     68 // y0/dy layout ladder budget. BADGE sits exactly ON that cap — "Power" replaced "Reboot"
     69 // there rather than adding a row, which is why the reboot action moved down a level.
     70 enum OptScreen { OPT_MENU = 0, OPT_USER, OPT_BADGE, OPT_DISPLAY, OPT_DEVOPTS,
     71                  OPT_PET, OPT_SPLASH, OPT_WHOAMI, OPT_REMIND, OPT_RESET, OPT_POWER };
     72 static OptScreen g_opt_screen = OPT_MENU;
     73 static int       g_opt_cursor = 0;        // current menu row; 0=Back leads so it's the
     74                                           //   DEFAULT row (double-tap in, double-tap back
     75                                           //   out). Reset to 0 entering a sub-menu; restored
     76                                           //   to the sub-menu's row when backing out to ROOT.
     77 // POINTS graph window, cycled by a single tap on the POINTS view.
     78 enum PointsWin { PWIN_5DAY = 0, PWIN_24H, PWIN_1H, PWIN_COUNT };
     79 static PointsWin g_points_window = PWIN_5DAY;
     80 
     81 // Settings + companion state (NVS-backed: keys "splash", "pet", "bright").
     82 static uint8_t  g_splash_style    = BOOT_SPLASH_STYLE;  // active boot splash (0..BOOT_SPLASH_COUNT-1)
     83 static uint8_t  g_bright          = SCREEN_BRIGHT_DEFAULT;  // screen brightness (0=LOW..2=HIGH)
     84 static int      g_pet_idx         = 0;                  // active pet (0..PET_BUILTIN_N-1)
     85 static PetState g_pet_react       = PET_IDLE;           // transient reaction (PET_IDLE = none)
     86 static uint32_t g_pet_react_until = 0;
     87 static uint32_t g_pet_popup_until = 0;                  // cooldown gate for reaction pop-ups
     88 static Pet      g_custom_pet      = {};                 // uploaded custom pet (slot PET_BUILTIN_N)
     89 static bool     g_custom_valid    = false;              // is a custom pet stored in NVS?
     90 static const Pet* current_pet();                        // fwd decl (send_beacon needs it)
     91 
     92 // Reminders (User Settings → Reminders; NVS keys "rmglob"/"rmshow"). "Global" is the
     93 // master switch for all reminders; "Shower" gates the shower reminder specifically.
     94 // Both default ON — toggling a switch OFF disables it. The alert is a NON-BLOCKING
     95 // toast (g_toast_active): shown by the loop, dismissed by any button press or after
     96 // REMINDER_TOAST_MS, so the badge keeps beaconing/scoring while it's up.
     97 static bool     g_remind_global   = true;
     98 static bool     g_remind_shower   = true;
     99 static bool     g_toast_active    = false;              // a reminder toast is on screen
    100 static uint32_t g_toast_until     = 0;                  // auto-dismiss deadline (millis)
    101 
    102 // Broadcast switch, surfaced as Badge Settings → STEALTH. Note the polarity: this holds
    103 // the RADIO state, and stealth is its inverse (stealth ON = g_radio_on false). The name
    104 // stays radio-side because that's what the code actually gates; the inversion happens once,
    105 // where OptView is built. NVS key "radio", default ON (= not stealthed).
    106 // Off gates send_beacon() and nothing else: the badge stops broadcasting — nobody can see
    107 // it — but the ESP-NOW stack stays up, so RX, the peer table, FINDER and scoring all keep
    108 // working. See the Radio/discovery block in config.h for why it's TX-only, not a teardown.
    109 static bool     g_radio_on        = true;
    110 
    111 static Preferences prefs;
    112 static SoftClock   g_clock;
    113 static char        g_handle[HANDLE_MAX_LEN + 1] = {0};
    114 static uint32_t    g_counter = 0;
    115 
    116 // Points: +1 per full minute spent with ≥1 crew badge in range. We tally the
    117 // proximity time in seconds (g_prox_seconds, persisted to NVS) and carry the
    118 // sub-second remainder between ticks (g_prox_accum_ms). points = seconds / 60.
    119 // Each earned second is also credited to EVERY in-gate friend in g_ledger,
    120 // which backs the "Best Friends" view: a friend's seconds = how many of your
    121 // social minutes they shared (so each ≤ the tally, but they overlap and don't
    122 // sum to it — being near three friends at once is one shared minute, not three).
    123 static uint32_t      g_prox_seconds  = 0;
    124 static uint32_t      g_prox_accum_ms = 0;
    125 static PointsLedger  g_ledger;
    126 static PointsHistory g_points_history;   // rolling score samples for the POINTS graph (hourly, 5-day)
    127 static PointsRecent  g_points_recent;    // fine 1-min ring for the 1-hour POINTS view (in-RAM)
    128 
    129 // Peer table is touched by both the ESP-NOW RX callback and loop() → guard it.
    130 static PeerTable peers;
    131 static portMUX_TYPE peerMux = portMUX_INITIALIZER_UNLOCKED;
    132 
    133 // ─── Test mode (OPTIONS → Test): LittleFS CSV logs + boot MAC / serial dump ──
    134 static bool     g_test_mode = false;
    135 static uint32_t g_boot_id   = 0;        // ++ each time test mode goes active; tags every log line
    136 static TestLog  g_batt_log("/test_batt.csv", "boot,elapsed_ms,vbat_mv,soc_pct");
    137 static TestLog  g_pts_log ("/test_pts.csv",  "boot,elapsed_ms,points,prox_s,peers,mult");
    138 
    139 static const uint8_t BCAST[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
    140 
    141 // Session set of badge MACs we've already greeted, so the "new crew!" pet
    142 // reaction fires exactly once per device — not on every TTL re-entry, the way
    143 // the old snapshot-diff did. MAC (not handle) is the identity: it's the stable
    144 // hardware address and is available on both Arduino cores (handles can change or
    145 // collide). RAM-only by design — a reboot re-greets your crew. Bounded ring:
    146 // past CONTACT_SEEN_MAX distinct devices the oldest is evicted, so a long-gone
    147 // badge may re-greet once (fine for a cosmetic pop-up). Single-writer (the recv
    148 // callback is serialized on the WiFi task), so the set itself needs no lock; the
    149 // main loop only reads/clears the volatile flag.
    150 static constexpr int CONTACT_SEEN_MAX = 256;
    151 static uint8_t       g_seen_macs[CONTACT_SEEN_MAX][6];
    152 static int           g_seen_n    = 0;            // entries in use (0..MAX)
    153 static int           g_seen_head = 0;            // ring evict point once full
    154 static volatile bool g_new_contact = false;      // a never-seen MAC just arrived
    155 static char g_contact_handle[HANDLE_MAX_LEN + 1] = {0};  // its handle, for the contact toast
    156 
    157 // First-sighting test for a source MAC: returns true (and records it) only the
    158 // first time this MAC is seen this session. Called from the recv callback only.
    159 static bool note_contact_mac(const uint8_t* mac) {
    160   if (!mac) return false;
    161   for (int i = 0; i < g_seen_n; i++)
    162     if (memcmp(g_seen_macs[i], mac, 6) == 0) return false;   // already greeted
    163   if (g_seen_n < CONTACT_SEEN_MAX) {
    164     memcpy(g_seen_macs[g_seen_n++], mac, 6);
    165   } else {
    166     memcpy(g_seen_macs[g_seen_head], mac, 6);                // evict oldest
    167     g_seen_head = (g_seen_head + 1) % CONTACT_SEEN_MAX;
    168   }
    169   return true;
    170 }
    171 
    172 // ─── shared helpers ────────────────────────────────────────────────────────
    173 
    174 // Clamp untrusted input to printable ASCII, max HANDLE_MAX_LEN bytes.
    175 static uint8_t sanitize_handle(const uint8_t* in, size_t n, char* out) {
    176   uint8_t k = 0;
    177   for (size_t i = 0; i < n && k < HANDLE_MAX_LEN; i++) {
    178     uint8_t c = in[i];
    179     // drop control/non-ASCII AND comma: a comma would break the CSV metrics export
    180     // (best_friends.csv, and the encounters TSV the web comma-izes on receipt).
    181     if (c >= 0x20 && c <= 0x7E && c != ',') out[k++] = (char)c;
    182   }
    183   out[k] = '\0';
    184   return k;
    185 }
    186 
    187 // ─── PROVISIONING MODE (BLE GATT) ───────────────────────────────────────────
    188 
    189 static volatile bool g_saved = false;
    190 
    191 // Pairing code: a fresh 3-char code is shown on the badge each provisioning session;
    192 // the web app must write it (PROV_CODE_CHAR_UUID) before any handle/time/pet write is
    193 // honored — a physical-presence gate against drive-by BLE provisioning. NOT crypto (BLE
    194 // writes are cleartext); it just requires line-of-sight to the badge screen.
    195 static char          g_prov_code[4] = {0};
    196 static volatile bool g_prov_authed  = false;
    197 static char          g_prov_name[32] = {0};   // advertised name; kept for setup-screen redraws
    198 
    199 // Pairing-code attempt limiting (see PROV_CODE_MAX_TRIES). The tally is per-BOOT, not
    200 // per-connection: onDisconnect must NOT reset it, or reconnecting resets the limit and
    201 // the limit means nothing — free reconnects were the actual finding.
    202 static volatile uint8_t  g_prov_fails        = 0;
    203 static volatile bool     g_prov_locked       = false;  // latched; only a reboot clears it
    204 static volatile bool     g_prov_lock_pending = false;  // loop(): rotate, redraw, drop the peer
    205 static volatile uint16_t g_prov_conn         = 0;      // conn handle of the last code writer
    206 
    207 // Metrics export (BLE, provisioning mode): the reserved metrics char streams a framed CSV/TSV
    208 // dump to the web app once the pairing code is accepted. The client subscribes → onSubscribe
    209 // flags a request; the actual streaming runs in loop() (never in a BLE callback), then the
    210 // badge reboots to discovery. g_metrics_ch = the char (for notify); g_metrics_conn = the peer
    211 // (for the MTU query that sizes the chunks).
    212 static NimBLECharacteristic* g_metrics_ch       = nullptr;
    213 static volatile uint16_t     g_metrics_conn     = 0;
    214 static volatile bool         g_metrics_req      = false;   // subscribed → stream once authed
    215 static bool                  g_metrics_done      = false;  // stream sent → reboot to discovery
    216 static uint32_t              g_metrics_reboot_at = 0;
    217 
    218 // Unambiguous uppercase alphabet (no 0/O/1/I/L) — easy to read off-screen + retype.
    219 static void gen_prov_code(char out[4]) {
    220   static const char A[] = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";   // 30 glyphs
    221   for (int i = 0; i < 3; i++) out[i] = A[esp_random() % (sizeof(A) - 1)];
    222   out[3] = '\0';
    223 }
    224 
    225 class HandleWriteCB : public NimBLECharacteristicCallbacks {
    226   void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override {
    227     if (!g_prov_authed) { Serial.println("[PROV] handle write ignored — pairing code not accepted"); return; }
    228     NimBLEAttValue v = c->getValue();
    229     char clean[HANDLE_MAX_LEN + 1];
    230     uint8_t len = sanitize_handle(v.data(), v.length(), clean);
    231     if (len == 0) return;                       // ignore empty/garbage writes
    232     prefs.putString("handle", clean);
    233     Serial.printf("[PROV] stored handle: \"%s\"\n", clean);
    234     g_saved = true;                             // loop() will reboot us
    235   }
    236 };
    237 
    238 // Optional: phone writes the current epoch (u32 little-endian) to seed the
    239 // soft-clock, so the report can show real wall-clock times.
    240 class TimeWriteCB : public NimBLECharacteristicCallbacks {
    241   void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override {
    242     if (!g_prov_authed) { Serial.println("[PROV] time write ignored — pairing code not accepted"); return; }
    243     NimBLEAttValue v = c->getValue();
    244     if (v.length() < 4) return;
    245     const uint8_t* d = v.data();
    246     uint32_t epoch = (uint32_t)d[0] | ((uint32_t)d[1] << 8) |
    247                      ((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24);
    248     g_clock.set(epoch);
    249     Serial.printf("[PROV] clock synced: %u\n", (unsigned)epoch);
    250     // If a handle is already stored, this is a re-sync (not first-time setup),
    251     // so reboot back into home for the corrected clock to take effect. During
    252     // first-time provisioning the handle isn't stored yet, so this no-ops and we
    253     // wait for the handle write to trigger the reboot.
    254     if (prefs.getString("handle", "").length() > 0) g_saved = true;
    255   }
    256 };
    257 
    258 // Optional: phone uploads a custom pet — a blob of up to 6 newline-separated
    259 // fields (name, then the 5 state-faces idle/contact/milestone/lonely/lowbatt).
    260 // Each field is clamped to printable ASCII + its max length, stored to NVS, and
    261 // auto-selected. If a handle is already stored, reboots into home (re-provision).
    262 class PetWriteCB : public NimBLECharacteristicCallbacks {
    263   void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override {
    264     if (!g_prov_authed) { Serial.println("[PROV] pet write ignored — pairing code not accepted"); return; }
    265     NimBLEAttValue v = c->getValue();
    266     const char* data = (const char*)v.data();
    267     size_t len = v.length();
    268     Pet p;
    269     memset(&p, 0, sizeof(p));
    270     int field = 0;
    271     size_t start = 0;
    272     for (size_t i = 0; i <= len && field < 1 + PET_STATE_COUNT; i++) {
    273       if (i == len || data[i] == '\n') {
    274         uint8_t maxlen = (field == 0) ? PET_NAME_MAX - 1 : PET_FACE_MAX - 1;
    275         char clean[PET_FACE_MAX];
    276         uint8_t k = 0;
    277         for (size_t j = start; j < i && k < maxlen; j++) {
    278           uint8_t ch = (uint8_t)data[j];
    279           if (ch >= 0x20 && ch <= 0x7E) clean[k++] = (char)ch;   // printable ASCII only
    280         }
    281         clean[k] = '\0';
    282         if (field == 0) strncpy(p.name, clean, PET_NAME_MAX - 1);
    283         else            strncpy(p.face[field - 1], clean, PET_FACE_MAX - 1);
    284         field++;
    285         start = i + 1;
    286       }
    287     }
    288     if (p.name[0] == '\0') return;                       // need at least a name
    289     g_custom_pet = p;
    290     g_custom_valid = true;
    291     prefs.putBytes("petcust", &g_custom_pet, sizeof(g_custom_pet));
    292     prefs.putUChar("petc_ok", 1);
    293     g_pet_idx = PET_BUILTIN_N;                           // auto-select the new custom pet
    294     prefs.putUInt("pet", g_pet_idx);
    295     Serial.printf("[PROV] custom pet stored: \"%s\"\n", p.name);
    296     if (prefs.getString("handle", "").length() > 0) g_saved = true;
    297   }
    298 };
    299 
    300 // The gate: the web app writes the 3-char code shown on the badge here; only an exact
    301 // (case-insensitive) match flips g_prov_authed, unlocking the handle/time/pet writes.
    302 class CodeWriteCB : public NimBLECharacteristicCallbacks {
    303   void onWrite(NimBLECharacteristic* c, NimBLEConnInfo& info) override {
    304     // Checked first: once latched, nothing gets compared at all, so a locked badge is
    305     // also a badge that no longer leaks whether a guess was close.
    306     if (g_prov_locked) {
    307       g_prov_authed = false;
    308       Serial.println("[PROV] code write rejected — gate locked until reboot");
    309       return;
    310     }
    311 
    312     NimBLEAttValue v = c->getValue();
    313     char in[4] = {0};
    314     for (uint8_t i = 0; i < v.length() && i < 3; i++) {
    315       char ch = (char)v.data()[i];
    316       in[i] = (ch >= 'a' && ch <= 'z') ? (char)(ch - 32) : ch;   // uppercase
    317     }
    318     g_prov_authed = (strcmp(in, g_prov_code) == 0);
    319     if (g_prov_authed) {
    320       g_prov_fails = 0;                       // a good code clears the tally
    321       Serial.printf("[PROV] pairing code \"%s\" -> ACCEPTED\n", in);
    322       return;
    323     }
    324 
    325     if (g_prov_fails < 255) g_prov_fails++;
    326     Serial.printf("[PROV] pairing code \"%s\" -> REJECTED (%u/%u)\n",
    327                   in, (unsigned)g_prov_fails, (unsigned)PROV_CODE_MAX_TRIES);
    328     if (g_prov_fails >= PROV_CODE_MAX_TRIES) {
    329       g_prov_locked       = true;             // latch immediately — before loop() gets a turn
    330       g_prov_conn         = info.getConnHandle();
    331       g_prov_lock_pending = true;             // loop() rotates the code, redraws, and disconnects
    332     }
    333   }
    334 };
    335 
    336 // Metrics stream: the web app subscribes to notifications (after entering the pairing code)
    337 // to pull the framed dump. We only flag the request here; loop() does the streaming, gated on
    338 // g_prov_authed, so this BLE-stack callback stays fast (no long work in the callback).
    339 class MetricsReadCB : public NimBLECharacteristicCallbacks {
    340   void onSubscribe(NimBLECharacteristic*, NimBLEConnInfo& info, uint16_t subValue) override {
    341     if (subValue == 0) return;                  // client unsubscribed
    342     g_metrics_conn = info.getConnHandle();      // for the MTU query
    343     g_metrics_req  = true;                       // loop() streams once the code is accepted
    344     Serial.println("[METRICS] client subscribed — will stream after the pairing code");
    345   }
    346 };
    347 
    348 class ServerCB : public NimBLEServerCallbacks {
    349   void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override {
    350     g_prov_authed = false;                      // each new connection must re-send the code
    351     g_metrics_req = false;                       // cancel any pending (unauthed) stream request
    352     // NOTE: g_prov_fails is deliberately NOT reset here. Advertising restarts below, so a
    353     // reconnect is free — if the tally reset with it, the attempt limit would be worthless.
    354     NimBLEDevice::startAdvertising();           // allow repeated attempts
    355   }
    356 };
    357 
    358 static void start_provisioning() {
    359   // Per-badge advertised name: base + the FULL 6-byte eFuse MAC (e.g.
    360   // "badge-setup-14335C591858"), so badges in pairing mode are uniquely identifiable in
    361   // the browser device picker AND map 1:1 to the bench roster (which dedups by full MAC).
    362   // 24 chars fits the 31B BLE scan-response budget; the OLED setup screen renders it in a
    363   // 5px font so the whole name fits one line.
    364   uint8_t mac[6];
    365   esp_read_mac(mac, ESP_MAC_WIFI_STA);
    366   // File-static, not a local: prov_escape_tick() redraws this screen when an aborted
    367   // hold-to-exit has to put the setup screen back.
    368   snprintf(g_prov_name, sizeof(g_prov_name), "%s-%02X%02X%02X%02X%02X%02X",
    369            PROV_DEVICE_NAME, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    370   const char* prov_name = g_prov_name;
    371 
    372   gen_prov_code(g_prov_code);                  // fresh pairing code per session
    373   g_prov_authed = false;
    374   display_provisioning(prov_name, g_prov_code);
    375 
    376   NimBLEDevice::init(prov_name);
    377   NimBLEServer* server = NimBLEDevice::createServer();
    378   server->setCallbacks(new ServerCB());
    379 
    380   NimBLEService* svc = server->createService(PROV_SERVICE_UUID);
    381 
    382   NimBLECharacteristic* hch =
    383       svc->createCharacteristic(PROV_CHAR_UUID, NIMBLE_PROPERTY::WRITE);
    384   hch->setCallbacks(new HandleWriteCB());
    385 
    386   NimBLECharacteristic* tch =
    387       svc->createCharacteristic(PROV_TIME_CHAR_UUID, NIMBLE_PROPERTY::WRITE);
    388   tch->setCallbacks(new TimeWriteCB());
    389 
    390   NimBLECharacteristic* pch =
    391       svc->createCharacteristic(PROV_PET_CHAR_UUID, NIMBLE_PROPERTY::WRITE);
    392   pch->setCallbacks(new PetWriteCB());
    393 
    394   NimBLECharacteristic* cch =                  // pairing-code gate (write before the others)
    395       svc->createCharacteristic(PROV_CODE_CHAR_UUID, NIMBLE_PROPERTY::WRITE);
    396   cch->setCallbacks(new CodeWriteCB());
    397 
    398   // Metrics characteristic (READ|NOTIFY): streams a framed CSV/TSV dump — encounters.log +
    399   // points history, plus the Test-Mode CSVs when Debug is on — to the provisioning web app,
    400   // gated by the pairing code. Chunked to the negotiated MTU; the page reassembles the notify
    401   // chunks until the "==== EOF ====" sentinel. Streaming happens in loop() (see stream_metrics).
    402   NimBLECharacteristic* mch =
    403       svc->createCharacteristic(PROV_METRICS_CHAR_UUID,
    404                                 NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY);
    405   mch->setCallbacks(new MetricsReadCB());
    406   mch->setValue("subscribe (notify) after the pairing code to pull metrics");
    407   g_metrics_ch = mch;
    408 
    409   svc->start();
    410 
    411   // The 128-bit service UUID (18B) + flags (3B) fill 21B of the 31B legacy adv packet,
    412   // so the name can't also live there. Build BOTH packets EXPLICITLY rather than rely on
    413   // auto-routing: the previous `setName()` shortened a 24-char name down to "badge-setup"
    414   // (31B − the 18B UUID = a 13B slot → an 11-char *Shortened Local Name*). Here the scan
    415   // response carries the full name ALONE (its own 31B), so the complete name survives.
    416   NimBLEAdvertising* adv = NimBLEDevice::getAdvertising();
    417 
    418   NimBLEAdvertisementData advData;                                   // PRIMARY: flags + service filter
    419   advData.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP);
    420   advData.addServiceUUID(PROV_SERVICE_UUID);
    421   adv->setAdvertisementData(advData);
    422 
    423   NimBLEAdvertisementData scanData;                                  // SCAN RESPONSE: full name, alone
    424   scanData.setName(prov_name);
    425   adv->setScanResponseData(scanData);
    426 
    427   adv->enableScanResponse(true);
    428   adv->start();
    429 }
    430 
    431 // ─── DISCOVERY MODE (ESP-NOW) ───────────────────────────────────────────────
    432 
    433 // The ESP-NOW receive callback signature changed between Arduino-ESP32 cores:
    434 //   core 3.x (IDF 5.x): (const esp_now_recv_info_t*, ...) — exposes RSSI
    435 //   core 2.x (IDF 4.x): (const uint8_t* mac,         ...) — no RSSI
    436 // Support both: compiles on the stock toolchain today, and gains proximity
    437 // sorting for free on core 3.x (pioarduino platform — see platformio.ini).
    438 #if ESP_ARDUINO_VERSION_MAJOR >= 3
    439 static void on_espnow_recv(const esp_now_recv_info_t* info,
    440                            const uint8_t* data, int len) {
    441   int8_t rssi = (info && info->rx_ctrl) ? info->rx_ctrl->rssi : 0;
    442   const uint8_t* src = info ? info->src_addr : nullptr;
    443 #else
    444 static void on_espnow_recv(const uint8_t* mac,
    445                            const uint8_t* data, int len) {
    446   int8_t rssi = 0;   // RSSI not exposed by the core 2.x ESP-NOW callback
    447   const uint8_t* src = mac;
    448 #endif
    449   // Verify (HMAC) OUTSIDE the lock — never run crypto in a critical section.
    450   if (!beacon_verify((const Beacon*)data, (size_t)len)) return;
    451   const Beacon* b = (const Beacon*)data;
    452   uint32_t epoch = g_clock.now();
    453 
    454   portENTER_CRITICAL(&peerMux);
    455   peers.upsert(b->handle, b->handle_len, rssi, b->counter, millis(), epoch,
    456                b->pet, b->pet_face);
    457   portEXIT_CRITICAL(&peerMux);
    458 
    459   // MAC dedup is independent of the peer table, so keep it out of the lock: fire
    460   // the contact flag once, the first time this device's MAC is heard.
    461   if (note_contact_mac(src)) {                   // first time this MAC is heard
    462     uint8_t hl = b->handle_len <= HANDLE_MAX_LEN ? b->handle_len : HANDLE_MAX_LEN;
    463     memcpy(g_contact_handle, b->handle, hl);
    464     g_contact_handle[hl] = '\0';                 // name the peer in the contact toast
    465     g_new_contact = true;
    466   }
    467 }
    468 
    469 static void send_beacon() {
    470   Beacon b;
    471   memset(&b, 0, sizeof(b));                      // zero-pad handle for the MAC
    472   b.magic   = BEACON_MAGIC;
    473   b.version = BEACON_VERSION;
    474   b.counter = ++g_counter;
    475   size_t hl = strlen(g_handle);
    476   if (hl > HANDLE_MAX_LEN) hl = HANDLE_MAX_LEN;
    477   b.handle_len = (uint8_t)hl;
    478   memcpy(b.handle, g_handle, hl);
    479   b.pet = (uint8_t)g_pet_idx;                          // our selected pet …
    480   strncpy(b.pet_face, current_pet()->face[PET_IDLE], PET_FACE_MAX);  // … + its idle face (zero-padded)
    481   beacon_sign(&b);
    482   esp_now_send(BCAST, (const uint8_t*)&b, sizeof(b));
    483 }
    484 
    485 static void start_discovery() {
    486   enc_begin();   // mount LittleFS for the encounter log
    487 
    488   // Persisted counter jumps forward each boot so old captured beacons (with
    489   // lower counters) can't be replayed as "fresher" after a power cycle.
    490   g_counter = prefs.getUInt("ctr", 0) + 1000;
    491   prefs.putUInt("ctr", g_counter);
    492 
    493   // Resume the points tally + per-friend ledger — a power cycle mid-con must
    494   // not zero your score or your best-friends ranking.
    495   g_prox_seconds = prefs.getUInt("prox", 0);
    496   g_ledger.load(prefs);
    497   g_points_history.load(prefs);   // restore the graph; arms a reboot break
    498 
    499   WiFi.mode(WIFI_STA);
    500   WiFi.disconnect();
    501   esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE);
    502   // NB: the ESP32's DEFAULT max TX (~19.5 dBm) is already its effective ceiling here. Calling
    503   // esp_wifi_set_max_tx_power(80 or 84) paradoxically *lowered* it to 17.5 dBm on this chip
    504   // (rc=ESP_OK, but the read-back regressed — an ESP-IDF quirk), so we deliberately DON'T set
    505   // it; we just read + log. The real range lever is antenna keepout, not these tenths of a dB.
    506   int8_t txp = 0; esp_wifi_get_max_tx_power(&txp);
    507   Serial.printf("[DISC] TX power: %.2f dBm\n", txp * 0.25f);
    508 
    509   if (esp_now_init() != ESP_OK) {
    510     display_boot("ESP-NOW init FAILED");
    511     return;
    512   }
    513   esp_now_register_recv_cb(on_espnow_recv);
    514 
    515   esp_now_peer_info_t p = {};
    516   memcpy(p.peer_addr, BCAST, 6);
    517   p.channel = ESPNOW_CHANNEL;
    518   p.ifidx   = WIFI_IF_STA;
    519   p.encrypt = false;
    520   esp_now_add_peer(&p);
    521 
    522   Serial.printf("[DISC] %s as \"%s\" on ch %d\n",
    523                 g_radio_on ? "beaconing" : "listening only (STEALTH on)",
    524                 g_handle, ESPNOW_CHANNEL);
    525 }
    526 
    527 // Finalize in-range encounters, aggregate the log, and render the report.
    528 static void enter_report() {
    529   // static scratch: this path nests loop()→enter_report→enc_append→vfprintf, and ev[32] (~1.1KB)
    530   // + Report r (~1.9KB) on the stack tripped the loopTask stack canary (a reboot on FINDER→REPORT).
    531   // Single-task, non-reentrant, refilled every call (ev by flush, r by enc_report's memset), so
    532   // static is safe and moves ~3KB off the stack.
    533   static Encounter ev[MAX_PEERS];
    534   int en = 0;
    535   portENTER_CRITICAL(&peerMux);
    536   peers.flush(ev, en, MAX_PEERS);
    537   portEXIT_CRITICAL(&peerMux);
    538   for (int i = 0; i < en; i++) enc_append(ev[i]);   // file IO outside the lock
    539 
    540   static Report r;
    541   enc_report(&r, g_clock.synced());
    542   display_report(g_handle, r);
    543 }
    544 
    545 // True when ≥1 crew is in range AND the closest clears the RSSI gate. The peer
    546 // snapshot is sorted closest-first, so snap[0] carries the strongest signal.
    547 //
    548 // STEALTH also stops scoring. Points are meant to measure time spent WITH crew, and
    549 // while stealthed the exchange is one-way: they can't see us, so they aren't being
    550 // credited for us either. Earning off badges that can't earn off you back is a leech.
    551 // Gating it here rather than at the accrual site is deliberate — the POINTS view reads
    552 // the same predicate, so the tally and the on-screen "earning/paused" state can't drift.
    553 static bool points_earning(const Peer* snap, int n) {
    554   return g_radio_on && n > 0 && snap[0].rssi >= POINTS_MIN_RSSI;
    555 }
    556 
    557 // Group multiplier from the in-range crew count (the peer table — anyone on the
    558 // FINDER list counts, gated or not; the RSSI gate still decides WHETHER you
    559 // earn). A 1-on-1 earns at base ×1; ×2 needs POINTS_X2_PEERS+ crew in range, ×3
    560 // needs POINTS_X3_PEERS+. Each earned second is scaled by this before tally + ledger.
    561 static uint8_t points_multiplier(int n) {
    562   if (n >= POINTS_X3_PEERS) return 3;
    563   if (n >= POINTS_X2_PEERS) return 2;
    564   return 1;
    565 }
    566 
    567 // Render the points scoreboard for the current tally + in-range crew count.
    568 static void render_points() {
    569   Peer snap[MAX_PEERS];
    570   int n;
    571   portENTER_CRITICAL(&peerMux);
    572   n = peers.snapshot(snap, MAX_PEERS);
    573   portEXIT_CRITICAL(&peerMux);
    574   static PointSample hist[POINTS_HISTORY_LEN];   // static: keep ~960B off the loop-task
    575   int hn;                                         //   stack (POINTS-only; render runs single-threaded)
    576   uint32_t span;
    577   const char* label;
    578   if (g_points_window == PWIN_1H) {
    579     hn = g_points_recent.snapshot(hist, POINTS_HISTORY_LEN);   // fine 1-min ring
    580     span = POINTS_GRAPH_1H_SPAN_S; label = "1h";
    581   } else if (g_points_window == PWIN_24H) {
    582     hn = g_points_history.snapshot(hist, POINTS_HISTORY_LEN);
    583     span = POINTS_GRAPH_24H_SPAN_S; label = "24h";
    584   } else {
    585     hn = g_points_history.snapshot(hist, POINTS_HISTORY_LEN);
    586     span = POINTS_GRAPH_SPAN_S; label = "5d";
    587   }
    588   display_points(g_handle, g_prox_seconds / 60, g_prox_seconds, n,
    589                  points_earning(snap, n), hist, hn, span, label,
    590                  g_points_window != PWIN_5DAY, points_multiplier(n), !g_radio_on);
    591 }
    592 
    593 // Render the best-friends scoreboard from the points ledger (top contributors).
    594 static void render_friends() {
    595   PointFriend top[MAX_POINT_FRIENDS];
    596   int n = g_ledger.top(top, MAX_POINT_FRIENDS);
    597   display_friends(g_handle, top, n, g_prox_seconds / 60);
    598 }
    599 
    600 // ─── disk usage cache ────────────────────────────────────────────────────────
    601 // LittleFS.usedBytes() walks the filesystem, so it is NOT safe to call from a live
    602 // redraw (the BATTERY/STORAGE view repaints every VIEW_REFRESH). The disk guard already
    603 // polls once a minute (DISK_CHECK_MS); both it and the STORAGE view read this cache
    604 // instead of re-walking. disk_refresh() re-reads on demand — entering the STORAGE view
    605 // forces one, since the guard's first poll is a whole DISK_CHECK_MS after boot.
    606 static size_t g_disk_total = 0, g_disk_used = 0;
    607 
    608 static void disk_refresh() {
    609   g_disk_total = LittleFS.totalBytes();
    610   g_disk_used  = LittleFS.usedBytes();
    611 }
    612 
    613 // The disk-guard trip: >=90% full. Shared so the STORAGE view's "FULL!" badge lights on
    614 // exactly the condition that trims the log, rather than a second threshold that could drift.
    615 static bool disk_is_full() {
    616   return g_disk_total && g_disk_used * DISK_FULL_DEN >= g_disk_total * DISK_FULL_NUM;
    617 }
    618 
    619 // % USED, matching the STORAGE view's polarity — for the HOME header gauge.
    620 static uint8_t disk_pct() {
    621   return g_disk_total ? (uint8_t)((uint64_t)g_disk_used * 100 / g_disk_total) : 0;
    622 }
    623 
    624 // BATTERY view sub-toggle: a single tap flips to the STORAGE readout and back, the same
    625 // way POINTS cycles its window. Reset on fresh entry so cycling views always lands on the
    626 // battery face — storage is a peek, not a place to park.
    627 static bool g_batt_storage = false;
    628 
    629 // Render the battery fuel gauge from the latest smoothed ADC reading — or, when toggled,
    630 // the storage readout from the cached LittleFS figures.
    631 static void render_battery() {
    632   if (g_batt_storage) display_storage(g_handle, g_disk_used, g_disk_total, disk_is_full());
    633   else                display_battery(g_handle, battery_mv(), battery_pct(), battery_is_low());
    634 }
    635 
    636 // ─── pet (companion) ─────────────────────────────────────────────────────────
    637 
    638 // The active pet: a built-in, or the uploaded custom pet in slot PET_BUILTIN_N.
    639 static const Pet* current_pet() {
    640   if (g_pet_idx == PET_BUILTIN_N && g_custom_valid) return &g_custom_pet;
    641   return pet_get(g_pet_idx);
    642 }
    643 // How many pets are selectable (built-ins + the custom slot iff uploaded).
    644 static int pet_total() { return PET_BUILTIN_N + (g_custom_valid ? 1 : 0); }
    645 
    646 // Resolve the face to show: an active reaction wins, else the ambient state
    647 // (low-batt > lonely > idle).
    648 static const char* pet_face_now(const Pet* p, uint32_t now, int nearby, bool lowbatt) {
    649   if (g_pet_react != PET_IDLE && now < g_pet_react_until) return p->face[g_pet_react];
    650   if (lowbatt)     return p->face[PET_LOWBATT];
    651   if (nearby == 0) return p->face[PET_LONELY];
    652   return p->face[PET_IDLE];
    653 }
    654 
    655 static void render_pet();   // fwd decl (pet_react may redraw it)
    656 
    657 // Fire a transient reaction (contact/milestone). On the PET view it redraws the
    658 // face; off it, the reaction pops up briefly over the current view, rate-limited
    659 // by PET_POPUP_COOLDOWN_MS so a crowded room doesn't spam it.
    660 static void pet_react(PetState s, uint32_t now, const char* line1 = nullptr,
    661                       const char* line2 = nullptr, uint16_t hold = 0, bool force = false) {
    662   g_pet_react = s;
    663   g_pet_react_until = now + PET_REACT_MS;
    664   if (g_view == VIEW_OPTIONS && g_opt_screen == OPT_PET) { render_pet(); return; }
    665   // A reminder toast owns the screen — hold reaction pop-ups (the face still updates) so a
    666   // blocking pet_popup can't clobber it; the toast auto-clears in ≤ REMINDER_TOAST_MS.
    667   if (!g_toast_active && (force || now >= g_pet_popup_until)) {   // force = important enough to skip the cooldown
    668     g_pet_popup_until = now + PET_POPUP_COOLDOWN_MS;
    669     const Pet* p = current_pet();
    670     // line1 = the reason (caller wins, else a generic), line2 = the detail (optional)
    671     const char* l1 = line1 ? line1 : (s == PET_MILESTONE ? "milestone" : "new crew");
    672     uint16_t h = hold ? hold : (s == PET_MILESTONE ? PET_MILESTONE_POPUP_MS : PET_POPUP_MS);
    673     display_pet_popup(p->face[s], l1, line2, h);
    674   }
    675 }
    676 
    677 // Render the PET view: the active pet's current face + a quick stat line.
    678 static void render_pet() {
    679   Peer snap[MAX_PEERS];
    680   int n;
    681   portENTER_CRITICAL(&peerMux);
    682   n = peers.snapshot(snap, MAX_PEERS);
    683   portEXIT_CRITICAL(&peerMux);
    684   const Pet* p = current_pet();
    685   display_pet(p->name, pet_face_now(p, millis(), n, battery_is_low()));   // n still feeds the lonely/idle face
    686 }
    687 
    688 // Render the settings MENU (boot-splash picker).
    689 static void render_menu() {
    690   display_menu(g_splash_style);
    691 }
    692 
    693 static const char* splash_name(uint8_t s) {
    694   static const char* n[BOOT_SPLASH_COUNT] = { "radar", "terminal", "glitch", "matrix" };
    695   return n[s % BOOT_SPLASH_COUNT];
    696 }
    697 
    698 static const char* bright_name(uint8_t b) {
    699   static const char* n[SCREEN_BRIGHT_LEVELS] = { "LOW", "MED", "HIGH" };
    700   return n[b % SCREEN_BRIGHT_LEVELS];
    701 }
    702 
    703 // Snapshot the peer table and draw the current FINDER page (closest first, in the
    704 // selected pet/dBm column mode). Recomputes the page total and clamps the page in
    705 // case peers have left. Used by the ring, the page tap, and the live redraw.
    706 static void render_finder() {
    707   Peer snap[MAX_PEERS];
    708   int n;
    709   portENTER_CRITICAL(&peerMux);
    710   n = peers.snapshot(snap, MAX_PEERS);
    711   portEXIT_CRITICAL(&peerMux);
    712   g_finder_pages = finder_pages(n);
    713   if (g_finder_page >= g_finder_pages) g_finder_page = g_finder_pages - 1;   // peers left → clamp
    714   display_peers(g_handle, snap, n, g_finder_rssi, points_multiplier(n), g_finder_page);
    715 }
    716 
    717 // Render the HOME view — the at-a-glance default: handle, pet, total points,
    718 // battery, and wall-clock time. The clock is formatted HERE (epoch→HH:MM, or
    719 // "--:--" when unsynced/stale) so both display backends stay clock-agnostic.
    720 static void render_home() {
    721   Peer snap[MAX_PEERS];
    722   int n;
    723   portENTER_CRITICAL(&peerMux);
    724   n = peers.snapshot(snap, MAX_PEERS);                  // for the resolved pet face (lonely/idle)
    725   portEXIT_CRITICAL(&peerMux);
    726   const Pet* p = current_pet();
    727   char clk[6];
    728   if (g_clock.synced()) {
    729     time_t t = (time_t)g_clock.now();                  // seeded epoch is UTC (phone sends Date.now()/1000)
    730     struct tm lt;
    731     localtime_r(&t, &lt);                              // → DISPLAY_TZ (Pacific, DST-aware) via tzset() at boot
    732     snprintf(clk, sizeof(clk), "%02u:%02u", (unsigned)lt.tm_hour, (unsigned)lt.tm_min);
    733   } else {
    734     strncpy(clk, "--:--", sizeof(clk));                // never synced, or stale → don't show a wrong time
    735   }
    736   display_home(g_handle, pet_face_now(p, millis(), n, battery_is_low()),
    737                g_prox_seconds / 60, battery_pct(), battery_is_low(), disk_pct(), clk,
    738                g_radio_on);
    739 }
    740 
    741 // Wipe every trace of this badge's owner and restart into a fresh-board state. Exists so a
    742 // badge can be handed on WITHOUT a USB cable — the flashing SOP's `esptool erase_flash` is
    743 // still the authority when a board is on the bench, but that is no help
    744 // to someone passing a badge to a friend at the con.
    745 //
    746 // Three separate stores have to go, and missing any one leaves the badge identifiably the
    747 // previous owner's:
    748 //   "badge" NVS  — handle, avatar, points/ledger/history, splash, brightness, stealth, …
    749 //   "clock" NVS  — a SEPARATE namespace, so prefs.clear() above does not reach it
    750 //   LittleFS     — /encounters.log (the social graph) + the Test-Mode CSVs. format() rather
    751 //                  than enc_clear(), which only removes the log and would strand the CSVs.
    752 // Never returns: the caller's world (g_handle, the peer table, the ledger) is now
    753 // inconsistent with NVS, so the only safe next step is a cold boot.
    754 static void factory_reset() {
    755   display_boot("erasing...");            // format() can take a moment; don't look hung
    756   prefs.clear();
    757   g_clock.factory_clear();
    758   LittleFS.format();
    759   delay(150);                            // let the NVS + FS commits land before the reset
    760   esp_restart();
    761 }
    762 
    763 // OPTIONS → Power → "Deep sleep". The closest thing to an OFF switch this hardware has:
    764 // the SoC's RTC domain stays alive to watch one pin, everything else stops. Never returns —
    765 // waking is a full application restart (ESP_RST_DEEPSLEEP), not a resume, so setup() runs
    766 // again from the top and the badge comes up on HOME.
    767 //
    768 // NOT actually off. The regulator, the charge IC and the onboard VBAT divider on GPIO34 all
    769 // sit on the battery side of anything software controls, so a slept badge still drains.
    770 // Board-level sleep current is unmeasured and decides whether this
    771 // is an "off" or a long nap.
    772 //
    773 // Wake is the button and ONLY the button: this board has no VBUS-detect tap,
    774 // so plugging in USB will not wake it (it does still charge). esptool drives EN/BOOT over
    775 // RTS/DTR, which resets the SoC regardless — a slept unit is still flashable.
    776 static void enter_deep_sleep() {
    777   display_pet_popup(current_pet()->face[PET_IDLE], "Sleeping...", "Press to wake", 2000);
    778   g_points_history.save(prefs);          // sleep may last days; don't strand the last sample
    779   display_sleep();                       // panel off — without this the sleep saves ~nothing
    780 
    781   // Don't sleep into a held button. ext0 wakes on the pin going LOW and the button is
    782   // INPUT_PULLUP/active-low, so entering deep sleep while it is still down re-wakes us
    783   // instantly and the badge looks like it ignored the command. Same guard the provisioning
    784   // escape hatch uses before its restart.
    785   while (digitalRead(BUTTON_PIN) == LOW) delay(10);
    786   delay(50);                             // let the contact settle before arming the wake
    787 
    788   esp_sleep_enable_ext0_wakeup((gpio_num_t)BUTTON_PIN, 0);   // 0 = wake on LOW (press)
    789   esp_deep_sleep_start();
    790 }
    791 
    792 // Jump to the HOME view (long-press from anywhere, or the OPTIONS "Back" row).
    793 static void go_home() {
    794   g_view = VIEW_HOME;
    795   render_home();
    796 }
    797 
    798 // Render whichever OPTIONS screen is active: parent list or a child picker.
    799 // Formatted eFuse MAC "14:33:5C:xx:xx:xx" (same base MAC as the provisioning name).
    800 static void format_mac(char out[18]) {
    801   uint8_t mac[6];
    802   esp_read_mac(mac, ESP_MAC_WIFI_STA);
    803   snprintf(out, 18, "%02X:%02X:%02X:%02X:%02X:%02X",
    804            mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    805 }
    806 
    807 static void render_options() {
    808   if (g_opt_screen == OPT_PET)    { render_pet();  return; }   // pet face + live reactions
    809   if (g_opt_screen == OPT_SPLASH) { render_menu(); return; }   // splash picker
    810   if (g_opt_screen == OPT_RESET)  { display_reset_confirm(g_opt_cursor); return; }
    811   if (g_opt_screen == OPT_WHOAMI) {                            // read-only identity screen
    812     char ms[18]; format_mac(ms);
    813     display_whoami(g_handle, ms);
    814     return;
    815   }
    816   int m = (g_opt_screen == OPT_USER)    ? OPTM_USER
    817         : (g_opt_screen == OPT_BADGE)   ? OPTM_BADGE
    818         : (g_opt_screen == OPT_DISPLAY) ? OPTM_DISPLAY
    819         : (g_opt_screen == OPT_DEVOPTS) ? OPTM_DEVOPTS
    820         : (g_opt_screen == OPT_POWER)   ? OPTM_POWER
    821         : (g_opt_screen == OPT_REMIND)  ? OPTM_REMIND : OPTM_ROOT;
    822   OptView v = { current_pet()->name, splash_name(g_splash_style), bright_name(g_bright),
    823                 g_test_mode ? "ON" : "OFF", g_finder_rssi ? "dBm" : "avatar",
    824                 g_remind_global ? "ON" : "OFF", g_remind_shower ? "ON" : "OFF",
    825                 g_radio_on ? "OFF" : "ON" };   // stealth is the INVERSE of the radio state
    826   display_options_menu(m, g_opt_cursor, v);
    827 }
    828 
    829 // Redraw whichever view is active — used to restore the screen after a reminder toast
    830 // clears (the toast overlays the live view; dismissing it repaints what was underneath).
    831 static void redraw_current_view() {
    832   switch (g_view) {
    833     case VIEW_HOME:    render_home();    break;
    834     case VIEW_FINDER:  render_finder();  break;
    835     case VIEW_REPORT:  enter_report();   break;   // re-snapshot the static report
    836     case VIEW_POINTS:  render_points();  break;
    837     case VIEW_FRIENDS: render_friends(); break;
    838     case VIEW_BATTERY: render_battery(); break;
    839     case VIEW_OPTIONS: render_options(); break;
    840     default:           render_home();    break;
    841   }
    842 }
    843 
    844 // Fire a NON-BLOCKING reminder toast: draw it now and arm an auto-dismiss deadline. The
    845 // loop holds the frame (view redraws are suppressed while g_toast_active) until a button
    846 // press or REMINDER_TOAST_MS clears it — unlike display_pet_popup it never blocks the loop,
    847 // so beaconing/scoring keep running under the toast.
    848 static void show_toast(const char* face, const char* l1, const char* l2) {
    849   g_toast_active = true;
    850   g_toast_until  = millis() + REMINDER_TOAST_MS;
    851   display_toast(face, l1, l2);
    852 }
    853 
    854 // Unified one-button gesture detector. With a single button we disambiguate by
    855 // tap count and hold:
    856 //   single tap → sub-view action (fires after DOUBLETAP_GAP_MS rules out a 2nd tap)
    857 //   double tap → next view (fires immediately on the second tap)
    858 //   long press → fires once at REPORT_HOLD_MS while held (jump home to FINDER)
    859 // A "tap" is a press released within TAP_MAX_MS. (Holding BOOT at power-on forces
    860 // provisioning — a separate boot-time check in setup().) Call every loop.
    861 enum Gesture { GEST_NONE = 0, GEST_SINGLE, GEST_DOUBLE, GEST_LONG };
    862 
    863 static Gesture button_gesture() {
    864   static uint32_t press_ms = 0;
    865   static uint32_t first_tap_ms = 0;
    866   static bool was_down = false;
    867   static bool pending = false;          // a first tap is awaiting a possible second
    868   static bool long_fired = false;       // long-press already emitted for this hold
    869   uint32_t now = millis();
    870   bool down = (digitalRead(BUTTON_PIN) == LOW);
    871   Gesture g = GEST_NONE;
    872 
    873   if (down && !was_down) {               // press edge
    874     press_ms = now;
    875     long_fired = false;
    876   } else if (down && was_down) {         // still held → emit long-press once at threshold
    877     if (!long_fired && now - press_ms >= REPORT_HOLD_MS) {
    878       long_fired = true;
    879       pending = false;                   // a long hold cancels any pending tap
    880       g = GEST_LONG;
    881     }
    882   } else if (!down && was_down) {        // release edge
    883     if (!long_fired && now - press_ms <= TAP_MAX_MS) {   // a short tap
    884       if (pending && now - first_tap_ms <= DOUBLETAP_GAP_MS) {
    885         pending = false;
    886         g = GEST_DOUBLE;                 // second tap within the window → double
    887       } else {
    888         pending = true;                  // first tap; wait the window out
    889         first_tap_ms = now;
    890       }
    891     } else {
    892       pending = false;                   // a hold/long, not a tap
    893     }
    894   }
    895   was_down = down;
    896   // A lone first tap, no second within the window → single (only once released).
    897   if (g == GEST_NONE && pending && !down && now - first_tap_ms > DOUBLETAP_GAP_MS) {
    898     pending = false;
    899     g = GEST_SINGLE;
    900   }
    901   return g;
    902 }
    903 
    904 // Whatever the setup screen should currently be. Once the pairing gate has latched,
    905 // redrawing the normal setup screen would show a freshly rotated code that cannot work —
    906 // baiting the owner into retyping forever. Show the lockout instead.
    907 static void prov_redraw() {
    908   if (g_prov_locked) display_boot("locked, power-cycle");
    909   else               display_provisioning(g_prov_name, g_prov_code);
    910 }
    911 
    912 // ─── provisioning escape hatch ──────────────────────────────────────────────
    913 // Setup mode never times out and doesn't beacon, so a badge that lands there by accident
    914 // is inert with no way out on the device itself. The firmware even nudges people toward
    915 // re-provisioning to fix a stale clock — and the crowd most likely to follow that nudge
    916 // into a dead end is the iOS-only one, who cannot run the Web Bluetooth app to provision
    917 // their way back out. Holding BUTTON for PROV_ESCAPE_HOLD_MS reboots to the home view.
    918 //
    919 // Two things this MUST get right or it silently does nothing at all:
    920 //
    921 //   1. THE BUTTON IS STILL DOWN WHEN WE REBOOT. setup() reads BUTTON_PIN to decide
    922 //      whether to force provisioning, so restarting mid-hold walks straight back into
    923 //      setup mode. We wait for the release first.
    924 //
    925 //   2. A BADGE WITH NO STORED HANDLE RE-ENTERS SETUP REGARDLESS of the button —
    926 //      have_handle is false, so setup() has nowhere else to send it. Escaping therefore
    927 //      has to leave a usable handle behind, so we seed a MAC-derived default. That is
    928 //      the difference between "escaped" and "escaped into the same room".
    929 //
    930 // Called only from the MODE_PROVISION branch of loop(), where the button is otherwise
    931 // unused, so there's no gesture to conflict with.
    932 static void prov_escape_tick() {
    933   static uint32_t press_ms = 0;
    934   static bool     was_down = false;
    935   static bool     armed    = false;     // see below — requires one release before it listens
    936   static int      shown    = -1;        // countdown value currently drawn (-1 = setup screen)
    937 
    938   uint32_t now  = millis();
    939   bool     down = (digitalRead(BUTTON_PIN) == LOW);
    940 
    941   // Trap 3: the usual way INTO setup mode is holding BOOT through power-on, so the
    942   // button is often still down on our first ticks. Without this, entering setup mode
    943   // and simply not letting go would bounce you straight back out — the feature
    944   // sabotaging the very gesture that reaches it. Require a fresh press.
    945   if (!armed) {
    946     if (down) return;
    947     armed = true;
    948   }
    949 
    950   if (down && !was_down) press_ms = now;              // press edge
    951   was_down = down;
    952 
    953   if (!down) {                                        // released early → restore the screen
    954     if (shown >= 0) { prov_redraw(); shown = -1; }
    955     return;
    956   }
    957 
    958   uint32_t held = now - press_ms;
    959   if (held < PROV_ESCAPE_FEEDBACK_MS) return;         // ignore incidental presses
    960 
    961   if (held < PROV_ESCAPE_HOLD_MS) {                   // counting down, once per second
    962     int left = (int)((PROV_ESCAPE_HOLD_MS - held + 999) / 1000);
    963     if (left != shown) {
    964       char line[24];
    965       snprintf(line, sizeof(line), "exit setup in %d", left);
    966       display_boot(line);
    967       shown = left;
    968     }
    969     return;
    970   }
    971 
    972   // Held the full duration → leave setup mode.
    973   if (prefs.getString("handle", "").length() == 0) {  // trap 2: never-provisioned badge
    974     uint8_t mac[6];
    975     esp_read_mac(mac, ESP_MAC_WIFI_STA);
    976     char dflt[HANDLE_MAX_LEN + 1];
    977     snprintf(dflt, sizeof(dflt), "n00b-%02X%02X", mac[4], mac[5]);
    978     prefs.putString("handle", dflt);                  // renameable later from the web app
    979     char line[24];
    980     snprintf(line, sizeof(line), "named %s", dflt);
    981     display_boot(line);
    982     delay(1200);                                       // long enough to read it
    983   }
    984 
    985   display_boot("exiting setup");
    986   while (digitalRead(BUTTON_PIN) == LOW) delay(10);   // trap 1: don't reboot into a held button
    987   delay(50);                                          // debounce the release
    988   ESP.restart();
    989 }
    990 
    991 // ─── lifecycle ──────────────────────────────────────────────────────────────
    992 
    993 // Active boot-splash style: the user's NVS pick (set in the MENU view), defaulting to the
    994 // compiled-in BOOT_SPLASH_STYLE. Wrapped on BOOT_SPLASH_COUNT so a stored index from an
    995 // older build (or a wider one) can never index past the name table.
    996 static int pick_splash_style() {
    997   return (int)(prefs.getUInt("splash", BOOT_SPLASH_STYLE) % BOOT_SPLASH_COUNT);
    998 }
    999 
   1000 // Serial POST / boot diagnostics — printed after the splash, before the welcome.
   1001 static void print_boot_diag(const char* modestr, const char* handle) {
   1002   uint8_t mac[6] = {0};
   1003   esp_read_mac(mac, ESP_MAC_WIFI_STA);
   1004   const char* rst;
   1005   switch (esp_reset_reason()) {
   1006     case ESP_RST_POWERON:   rst = "POWERON";   break;
   1007     case ESP_RST_SW:        rst = "SW";        break;
   1008     case ESP_RST_PANIC:     rst = "PANIC";     break;
   1009     case ESP_RST_INT_WDT:
   1010     case ESP_RST_TASK_WDT:
   1011     case ESP_RST_WDT:       rst = "WDT";       break;
   1012     case ESP_RST_BROWNOUT:  rst = "BROWNOUT";  break;
   1013     case ESP_RST_DEEPSLEEP: rst = "DEEPSLEEP"; break;
   1014     default:                rst = "OTHER";     break;
   1015   }
   1016   Serial.println("  ── boot ───────────────────────────────");
   1017   Serial.printf( "  build : %s\n", FW_BUILD_STR);
   1018   Serial.printf( "  chip  : %s · %d core @ %d MHz\n",
   1019                  ESP.getChipModel(), ESP.getChipCores(), (int)ESP.getCpuFreqMHz());
   1020   Serial.printf( "  flash : %u MB   heap: %u KB free\n",
   1021                  (unsigned)(ESP.getFlashChipSize() >> 20),
   1022                  (unsigned)(ESP.getFreeHeap() >> 10));
   1023   Serial.printf( "  mac   : %02X:%02X:%02X:%02X:%02X:%02X\n",
   1024                  mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
   1025   Serial.printf( "  reset : %s\n", rst);
   1026   Serial.printf( "  mode  : %s   handle \"%s\"   ch %d\n",
   1027                  modestr, handle, ESPNOW_CHANNEL);
   1028   Serial.printf( "  stealth: %s\n", g_radio_on ? "OFF (broadcasting)"
   1029                                                : "ON (not broadcasting — RX still live)");
   1030   Serial.println("  ────────────────────────────────────────");
   1031   delay((uint32_t)BOOT_DIAG_HOLD_MS * BOOT_TIMING_SCALE);   // hold so the readout is readable
   1032 }
   1033 
   1034 void setup() {
   1035   Serial.begin(115200);
   1036   delay(200);
   1037   display_init();
   1038 
   1039   prefs.begin("badge", false);                 // up top: settings (splash/pet) + handle live here
   1040 
   1041   // Brightness first: display_init() ran before NVS was open (it has to — it owns the
   1042   // bus), so the saved level can only be applied here. Do it before the splash so the
   1043   // very first frame the user sees is at their pick, not a flash of the panel default.
   1044   g_bright = (uint8_t)(prefs.getUChar("bright", SCREEN_BRIGHT_DEFAULT) % SCREEN_BRIGHT_LEVELS);
   1045   display_set_brightness(g_bright);
   1046 
   1047   g_splash_style = (uint8_t)pick_splash_style();
   1048   // restore a custom pet (slot PET_BUILTIN_N) if one was uploaded
   1049   if (prefs.getUChar("petc_ok", 0) == 1 &&
   1050       prefs.getBytes("petcust", &g_custom_pet, sizeof(g_custom_pet)) == sizeof(g_custom_pet))
   1051     g_custom_valid = true;
   1052   g_pet_idx = (int)(prefs.getUInt("pet", 0) % pet_total());
   1053   g_finder_rssi = (prefs.getUChar("finder", 0) == 1);   // FINDER column: 0=pet face, 1=dBm (User Settings)
   1054   g_remind_global = (prefs.getUChar("rmglob", 1) == 1); // reminders default ON (toggle to disable)
   1055   g_remind_shower = (prefs.getUChar("rmshow", 1) == 1);
   1056   g_radio_on = (prefs.getUChar("radio", 1) == 1);       // broadcast defaults ON (Badge Settings → Radio)
   1057   g_test_mode = (prefs.getUChar("testmode", 0) == 1);   // OPTIONS → Test (persisted)
   1058   if (g_test_mode) {
   1059     g_boot_id = prefs.getUInt("bootid", 0) + 1;
   1060     prefs.putUInt("bootid", g_boot_id);
   1061     // The user's pet announces test mode BEFORE the boot splash.
   1062     display_pet_popup(current_pet()->face[PET_MILESTONE], "Debug Mode On", nullptr, TEST_MODE_POPUP_MS);
   1063   }
   1064   display_boot_splash(g_splash_style);         // animated splash (OLED) / ASCII banner (serial)
   1065   display_boot_logos();                        // logo card — FOLLOWS the chosen splash, every boot
   1066   display_disclaimer();                        // heat/LiPo safety card, held then continues boot
   1067 
   1068   g_clock.begin();
   1069   setenv("TZ", DISPLAY_TZ, 1);   // wall-clock shown in Pacific (DST-aware); epoch stays UTC
   1070   tzset();
   1071   // A brownout reset means we were dark for an unknown span, so the resumed-from-NVS
   1072   // wall-clock is skewed → flag it stale (report shows relative + a re-sync nudge).
   1073   //
   1074   // A deep-sleep wake is the SAME failure with a different cause, and it is the more likely
   1075   // one now that OPTIONS → Power → Deep sleep exists: the soft clock free-runs off millis(),
   1076   // which resets to 0 on wake, so a badge slept overnight resumes from its last 60 s NVS
   1077   // checkpoint hours behind. Without this the report would show a confidently-WRONG
   1078   // wall-clock with no warning — exactly the bug the stale flag was added to kill.
   1079   {
   1080     esp_reset_reason_t rr = esp_reset_reason();
   1081     if (rr == ESP_RST_BROWNOUT || rr == ESP_RST_DEEPSLEEP) g_clock.mark_stale();
   1082   }
   1083   battery_init();
   1084 
   1085   pinMode(BUTTON_PIN, INPUT_PULLUP);
   1086   delay(50);
   1087   // Enter provisioning if BOOT is held, OR if OPTIONS → Provisioning Mode requested it
   1088   // (one-shot NVS flag) so the hosted BLE page can re-seed handle/clock/pet without a
   1089   // button-held reboot.
   1090   bool force_provision = (digitalRead(BUTTON_PIN) == LOW) ||
   1091                          (prefs.getUChar("provreq", 0) == 1);
   1092   prefs.putUChar("provreq", 0);                  // consume the one-shot
   1093 
   1094   String stored = prefs.getString("handle", "");
   1095   bool have_handle = stored.length() > 0 && !force_provision;
   1096   if (have_handle) {
   1097     strncpy(g_handle, stored.c_str(), HANDLE_MAX_LEN);
   1098     g_handle[HANDLE_MAX_LEN] = '\0';
   1099   }
   1100 
   1101   // Serial POST / boot diagnostics, after the splash and before the welcome.
   1102   print_boot_diag(have_handle ? "DISCOVERY" : "PROVISION",
   1103                   have_handle ? g_handle : "(unset)");
   1104 
   1105   if (g_test_mode) {                            // TEST MODE: show the MAC + dump prior logs
   1106     uint8_t mac[6] = {0};
   1107     esp_read_mac(mac, ESP_MAC_WIFI_STA);        // the STA MAC = the peer-table identity
   1108     char macs[18];
   1109     snprintf(macs, sizeof(macs), "%02X:%02X:%02X:%02X:%02X:%02X",
   1110              mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
   1111     display_boot(macs);                         // on the OLED — read/photograph it, no USB needed
   1112     Serial.printf("[TEST] MAC %s  -- dumping logs --\n", macs);
   1113     LittleFS.begin(true);                       // ensure mounted before reading the CSVs
   1114     g_batt_log.dump(Serial);
   1115     g_pts_log.dump(Serial);
   1116     delay(TEST_MAC_HOLD_MS);                     // hold the MAC screen long enough to capture
   1117   }
   1118 
   1119   if (!have_handle) {
   1120     mode = MODE_PROVISION;
   1121     start_provisioning();
   1122   } else {
   1123     // Welcome as a pet TOAST (the old welcome screen in toast form) — the pet greets you by
   1124     // handle. "Welcome," over "<handle>!" across the toast's two lines (as the old screen was)
   1125     // so the full handle keeps its own line width; one combined line would cap the handle ~8
   1126     // chars. PET_CONTACT = the greeting "new crew in range" face.
   1127     char hl[HANDLE_MAX_LEN + 2];
   1128     snprintf(hl, sizeof(hl), "%.*s!", 17, g_handle);   // handle + '!', clamped to the toast line
   1129     display_pet_popup(current_pet()->face[PET_CONTACT], "Welcome,", hl, 1200 * BOOT_TIMING_SCALE);
   1130     mode = MODE_DISCOVER;
   1131     start_discovery();
   1132     disk_refresh();       // seed the cache — start_discovery() has just mounted LittleFS, and the
   1133                           //   guard's first poll is DISK_CHECK_MS away, but HOME shows the disk
   1134                           //   gauge immediately and would otherwise read 0% for the first minute.
   1135   }
   1136 }
   1137 
   1138 // ─── test-mode loggers (gated by g_test_mode at the call sites) ──────────────
   1139 
   1140 // Battery: adaptive cadence — coarse on the plateau, fast once the cell drops
   1141 // below the knee (the cliff is where VBAT_EMPTY + the real curve live).
   1142 static void test_batt_log_tick(uint32_t now) {
   1143   static uint32_t last = 0;
   1144   uint16_t mv = battery_mv();
   1145   uint32_t iv = (mv && mv < TEST_LOG_CLIFF_MV) ? TEST_LOG_FAST_MS : TEST_LOG_INTERVAL_MS;
   1146   if (last != 0 && (now - last) < iv) return;
   1147   last = now;
   1148   char line[48];
   1149   snprintf(line, sizeof(line), "%lu,%lu,%u,%u",
   1150            (unsigned long)g_boot_id, (unsigned long)now, mv, battery_pct());
   1151   g_batt_log.append(line);
   1152 }
   1153 
   1154 // Points: 1-min snapshots of the live scoring counters (the graph rings don't
   1155 // expose these as CSV — handy for validating the group-multiplier logic).
   1156 static void test_pts_log_tick(uint32_t now, int peers_now, uint8_t mult) {
   1157   static uint32_t last = 0;
   1158   if (last != 0 && (now - last) < TEST_PTLOG_INTERVAL_MS) return;
   1159   last = now;
   1160   char line[64];
   1161   snprintf(line, sizeof(line), "%lu,%lu,%lu,%lu,%d,%u",
   1162            (unsigned long)g_boot_id, (unsigned long)now,
   1163            (unsigned long)(g_prox_seconds / 60), (unsigned long)g_prox_seconds,
   1164            peers_now, (unsigned)mult);
   1165   g_pts_log.append(line);
   1166 }
   1167 
   1168 // ─── metrics export (BLE stream, provisioning mode) ──────────────────────────
   1169 // Send `data` (len bytes) as ≤`chunk` notify pieces, paced so the BLE tx buffer can drain.
   1170 // Returns false if the peer vanished mid-send (caller aborts the stream).
   1171 static bool metrics_notify(const uint8_t* data, size_t len, size_t chunk) {
   1172   size_t off = 0;
   1173   while (off < len) {
   1174     if (!g_metrics_ch || NimBLEDevice::getServer()->getConnectedCount() == 0) return false;
   1175     size_t n = (len - off < chunk) ? (len - off) : chunk;
   1176     // notify(data,len) sends THIS slice directly (no setValue/notify race) and returns false when
   1177     // the tx buffer is full — retry with backoff so a chunk is NEVER silently dropped. Dropped
   1178     // chunks fuse CSV lines into garbage on the far end (the bug that made vbat_mv read in the
   1179     // millions). Abort if the peer vanishes.
   1180     int tries = 0;
   1181     while (!g_metrics_ch->notify(data + off, n)) {
   1182       if (++tries > METRICS_NOTIFY_RETRIES ||
   1183           NimBLEDevice::getServer()->getConnectedCount() == 0) return false;
   1184       delay(METRICS_PACE_MS);                    // let the controller drain, then retry the same slice
   1185     }
   1186     off += n;
   1187     delay(METRICS_PACE_MS);                      // steady pacing between chunks (also feeds the WDT)
   1188   }
   1189   return true;
   1190 }
   1191 static bool metrics_notify_str(const char* s, size_t chunk) {
   1192   return metrics_notify((const uint8_t*)s, strlen(s), chunk);
   1193 }
   1194 
   1195 // Stream one LittleFS file, framed like TestLog::dump (==== path ==== … ==== end ====).
   1196 static bool metrics_stream_file(const char* path, size_t chunk) {
   1197   char hdr[64];
   1198   snprintf(hdr, sizeof(hdr), "==== %s ====\n", path);
   1199   if (!metrics_notify_str(hdr, chunk)) return false;
   1200   File f = LittleFS.open(path, FILE_READ);
   1201   if (f) {
   1202     uint8_t buf[METRICS_CHUNK_MAX];
   1203     while (f.available()) {
   1204       size_t n = f.read(buf, chunk);
   1205       if (n && !metrics_notify(buf, n, chunk)) { f.close(); return false; }
   1206     }
   1207     f.close();
   1208   } else if (!metrics_notify_str("(none)\n", chunk)) {
   1209     return false;
   1210   }
   1211   return metrics_notify_str("==== end ====\n", chunk);
   1212 }
   1213 
   1214 // Stream the points-history ring as CSV (formatted from the NVS-backed RAM buffer).
   1215 static bool metrics_stream_points(size_t chunk) {
   1216   if (!metrics_notify_str("==== /points_history.csv ====\n", chunk)) return false;
   1217   if (!metrics_notify_str("epoch,points,seg_start\n", chunk)) return false;
   1218   static PointSample ps[POINTS_HISTORY_LEN];     // static: keep it off the stack
   1219   int n = g_points_history.snapshot(ps, POINTS_HISTORY_LEN);
   1220   char line[32];
   1221   for (int i = 0; i < n; i++) {
   1222     int m = snprintf(line, sizeof(line), "%lu,%u,%u\n",
   1223                      (unsigned long)ps[i].epoch, (unsigned)ps[i].points, (unsigned)ps[i].seg_start);
   1224     if (!metrics_notify((const uint8_t*)line, (size_t)m, chunk)) return false;
   1225   }
   1226   return metrics_notify_str("==== end ====\n", chunk);
   1227 }
   1228 
   1229 // Stream the best-friends ledger (per-friend credited proximity seconds, ranked). Handle LAST —
   1230 // sanitized handles may contain a comma, so keep the numeric column unsplittable by it.
   1231 static bool metrics_stream_friends(size_t chunk) {
   1232   if (!metrics_notify_str("==== /best_friends.csv ====\n", chunk)) return false;
   1233   if (!metrics_notify_str("seconds,handle\n", chunk)) return false;
   1234   static PointFriend bf[MAX_POINT_FRIENDS];
   1235   int n = g_ledger.top(bf, MAX_POINT_FRIENDS);
   1236   char line[HANDLE_MAX_LEN + 16];
   1237   for (int i = 0; i < n; i++) {
   1238     int m = snprintf(line, sizeof(line), "%lu,%s\n", (unsigned long)bf[i].seconds, bf[i].handle);
   1239     if (!metrics_notify((const uint8_t*)line, (size_t)m, chunk)) return false;
   1240   }
   1241   return metrics_notify_str("==== end ====\n", chunk);
   1242 }
   1243 
   1244 // The whole export: encounters.log always, points history always, the Test-Mode CSVs only
   1245 // when Debug is on. Chunk size = the negotiated MTU (−3 ATT overhead), capped. Ends with the
   1246 // "==== EOF ====" sentinel the page reassembles up to. Runs in loop() — never a BLE callback.
   1247 static void stream_metrics() {
   1248   // The export runs in PROVISIONING mode, which never calls start_discovery() — so LittleFS may
   1249   // be unmounted and the NVS points-history ring unloaded (0 samples). Prepare both here or
   1250   // points_history streams empty even when the OLED shows a populated graph, and the log files
   1251   // read as "(none)" on a non-Test badge. Idempotent — start_discovery already did this in
   1252   // discovery mode (re-begin just warns "Already Mounted").
   1253   LittleFS.begin(true);
   1254   g_points_history.load(prefs);
   1255   g_ledger.load(prefs);          // best-friends ledger — also only loaded in start_discovery()
   1256   uint16_t mtu   = NimBLEDevice::getServer()->getPeerMTU(g_metrics_conn);
   1257   size_t   chunk = (mtu > 23) ? (size_t)(mtu - 3) : 20;
   1258   if (chunk > METRICS_CHUNK_MAX) chunk = METRICS_CHUNK_MAX;
   1259   Serial.printf("[METRICS] streaming (mtu=%u chunk=%u debug=%d)\n",
   1260                 (unsigned)mtu, (unsigned)chunk, (int)g_test_mode);
   1261   if (!metrics_stream_file(ENC_LOG_PATH, chunk)) { Serial.println("[METRICS] aborted (peer gone)"); return; }
   1262   if (!metrics_stream_points(chunk))             { Serial.println("[METRICS] aborted (peer gone)"); return; }
   1263   if (!metrics_stream_friends(chunk))            { Serial.println("[METRICS] aborted (peer gone)"); return; }
   1264   if (g_test_mode) {
   1265     if (!metrics_stream_file("/test_batt.csv", chunk)) return;
   1266     if (!metrics_stream_file("/test_pts.csv", chunk))  return;
   1267   }
   1268   metrics_notify_str("==== EOF ====\n", chunk);
   1269   Serial.println("[METRICS] stream complete");
   1270 }
   1271 
   1272 void loop() {
   1273   if (mode == MODE_PROVISION) {
   1274     if (g_saved) {
   1275       display_boot("saved — rebooting to home");
   1276       delay(800);
   1277       ESP.restart();
   1278     }
   1279     // Pairing gate latched (PROV_CODE_MAX_TRIES bad codes). The callback only sets the
   1280     // flag; the screen redraw and the disconnect happen here so the BLE stack callback
   1281     // stays fast — same rule the metrics stream follows.
   1282     if (g_prov_lock_pending) {
   1283       g_prov_lock_pending = false;
   1284       gen_prov_code(g_prov_code);            // burn the code that was being ground down
   1285       prov_redraw();                          // → the lockout screen
   1286       NimBLEServer* srv = NimBLEDevice::getServer();
   1287       if (srv) srv->disconnect(g_prov_conn);  // drop the peer; reconnecting won't help
   1288       Serial.printf("[PROV] gate LOCKED after %u bad codes — power-cycle to retry\n",
   1289                     (unsigned)PROV_CODE_MAX_TRIES);
   1290     }
   1291 
   1292     prov_escape_tick();      // hold-to-exit: the only on-device way out of setup mode
   1293     // Metrics export: once the client has subscribed AND entered the pairing code, stream the
   1294     // framed dump here (not in the BLE callback), then reboot to discovery a moment later so
   1295     // the badge returns to normal use without a manual power-cycle.
   1296     if (g_metrics_req && g_prov_authed && !g_metrics_done) {
   1297       g_metrics_req = false;
   1298       stream_metrics();
   1299       g_metrics_done = true;
   1300       g_metrics_reboot_at = millis() + METRICS_REBOOT_MS;
   1301     }
   1302     if (g_metrics_done && (int32_t)(millis() - g_metrics_reboot_at) >= 0) {
   1303       display_boot("export done — rebooting");
   1304       delay(300);
   1305       ESP.restart();
   1306     }
   1307     delay(50);
   1308     return;
   1309   }
   1310 
   1311   // MODE_DISCOVER
   1312   static uint32_t last_beacon = 0, last_display = 0;
   1313   uint32_t now = millis();
   1314   g_clock.tick();
   1315   battery_sample(now);                       // throttled + smoothed internally
   1316   if (g_test_mode) test_batt_log_tick(now);  // test mode: log VBAT on the adaptive cadence
   1317 
   1318   // Low-battery toast: fire when VBAT first crosses the LOW threshold, then re-warn
   1319   // every PET_LOWBATT_REMIND_MS while still low — persistent, since it matters. Forced
   1320   // past the reaction cooldown so an unrelated toast can't swallow the warning.
   1321   static bool     prev_low = false;
   1322   static uint32_t last_lowbatt_warn = 0;
   1323   bool low_now = battery_is_low();
   1324   if (low_now && (!prev_low || now - last_lowbatt_warn >= PET_LOWBATT_REMIND_MS)) {
   1325     last_lowbatt_warn = now;
   1326     char d[16];
   1327     snprintf(d, sizeof(d), "%u%% - charge", (unsigned)battery_pct());
   1328     pet_react(PET_LOWBATT, now, "low battery", d, PET_LOWBATT_POPUP_MS, true);
   1329   }
   1330   prev_low = low_now;
   1331 
   1332   // Shower reminder — an 8h UPTIME interval (the board has no RTC, so we nudge on elapsed
   1333   // uptime, not wall-clock 8am/5pm). "Global" gates all reminders, "Shower" gates this one
   1334   // (User Settings → Reminders, both default ON). Seeded to `now` so it never fires at boot,
   1335   // and skipped while a toast is already up. show_toast is non-blocking (loop keeps running).
   1336   static uint32_t last_shower_reminder = 0;
   1337   if (last_shower_reminder == 0) last_shower_reminder = now;
   1338   if (g_remind_global && g_remind_shower && !g_toast_active &&
   1339       now - last_shower_reminder >= REMINDER_SHOWER_MS) {
   1340     last_shower_reminder = now;
   1341     show_toast(current_pet()->face[PET_MILESTONE], "shower time", "freshen up!");
   1342   }
   1343 
   1344   // Disk guard — now a BACKSTOP, not the primary bound. enc_append caps the log at
   1345   // ENC_LOG_MAX_BYTES, so this should never fire on encounter growth alone; if it does, the
   1346   // pressure is coming from somewhere else (the Test-Mode CSVs) and trimming the log is a
   1347   // best-effort response. It used to be the only bound, and it could not work: at ≥90% used
   1348   // there is ~90 KB free but `keep` below asks for ~80% of the partition, so the copy ran out
   1349   // of space and — walking forward from the cut — kept the OLDEST slice, destroying the newest
   1350   // history. enc_trim_oldest now clamps `keep` to real free space and refuses to replace the
   1351   // live log on a short write, so an over-large request degrades to "less history, still the
   1352   // most recent" instead of silent inversion.
   1353   // NVS (settings/points/handle) is a separate partition — safe.
   1354   static uint32_t last_disk_check = 0, last_disk_warn = 0;
   1355   if (now - last_disk_check >= DISK_CHECK_MS) {
   1356     last_disk_check = now;
   1357     disk_refresh();                                                      // also feeds the STORAGE view
   1358     size_t total = g_disk_total, used = g_disk_used;
   1359     if (disk_is_full()) {                                                // ≥90% full
   1360       size_t logsz  = enc_size();
   1361       size_t nonlog = (used > logsz) ? used - logsz : 0;                 // test CSVs + FS overhead
   1362       size_t target = (size_t)((uint64_t)total * DISK_TARGET_NUM / DISK_TARGET_DEN);
   1363       size_t keep   = (target > nonlog) ? target - nonlog : total / 20;  // newest log bytes to keep (≥5% floor)
   1364       size_t freed  = enc_trim_oldest(keep);
   1365       disk_refresh();                                                    // the trim freed space — re-cache
   1366       Serial.printf("[DISK] %u/%u used (>=90%%) - dropped %u B of oldest encounters\n",
   1367                     (unsigned)used, (unsigned)total, (unsigned)freed);
   1368       if (!g_toast_active && (last_disk_warn == 0 || now - last_disk_warn >= DISK_WARN_REMIND_MS)) {
   1369         last_disk_warn = now;
   1370         show_toast(current_pet()->face[PET_LOWBATT], "log almost full", "export soon");
   1371       }
   1372     }
   1373   }
   1374 
   1375   // One button (called every loop to keep its edge state current):
   1376   //   double tap → next view
   1377   //   single tap → change this view's sub-setting (POINTS window · PET swap · MENU splash)
   1378   Gesture gest = button_gesture();
   1379   // A reminder toast overlays the live view: dismiss it on ANY button press (press consumed,
   1380   // not routed) or when its deadline passes; either way repaint the view underneath. While
   1381   // it's up, the view redraws further down are suppressed so it holds the frame.
   1382   if (g_toast_active && (gest != GEST_NONE || (int32_t)(now - g_toast_until) >= 0)) {
   1383     g_toast_active = false;
   1384     gest = GEST_NONE;                                // swallow the dismissing press
   1385     redraw_current_view();
   1386   }
   1387   if (gest == GEST_LONG) {
   1388     go_home();                                       // long-press → home from anywhere
   1389   } else if (g_view == VIEW_OPTIONS) {
   1390     // Nested OPTIONS menu — single tap = move/cycle, double tap = enter/back.
   1391     if (g_opt_screen == OPT_MENU) {                  // ROOT: Back · User Settings · Badge Settings
   1392       if (gest == GEST_SINGLE) {
   1393         g_opt_cursor = (g_opt_cursor + 1) % 3;
   1394         render_options();
   1395       } else if (gest == GEST_DOUBLE) {
   1396         if      (g_opt_cursor == 0) go_home();        // "Back" — the default row
   1397         else if (g_opt_cursor == 1) { g_opt_screen = OPT_USER;  g_opt_cursor = 0; render_options(); }
   1398         else                        { g_opt_screen = OPT_BADGE; g_opt_cursor = 0; render_options(); }
   1399       }
   1400     } else if (g_opt_screen == OPT_USER) {           // Back · Pet · Finder View · Reminders
   1401       if (gest == GEST_SINGLE) {
   1402         g_opt_cursor = (g_opt_cursor + 1) % 4;
   1403         render_options();
   1404       } else if (gest == GEST_DOUBLE) {
   1405         if      (g_opt_cursor == 0) { g_opt_screen = OPT_MENU; g_opt_cursor = 1; render_options(); }  // Back → ROOT (on "User Settings")
   1406         else if (g_opt_cursor == 1) { g_opt_screen = OPT_PET; render_options(); }
   1407         else if (g_opt_cursor == 2) { g_finder_rssi = !g_finder_rssi;            // "Finder View" — inline toggle pet ↔ dBm
   1408                                       prefs.putUChar("finder", g_finder_rssi ? 1 : 0); render_options(); }
   1409         else { g_opt_screen = OPT_REMIND; g_opt_cursor = 0; render_options(); }  // "Reminders" — submenu
   1410       }
   1411     } else if (g_opt_screen == OPT_BADGE) {          // Back · Display · Stealth · Developer Options · Power
   1412       if (gest == GEST_SINGLE) {
   1413         g_opt_cursor = (g_opt_cursor + 1) % 5;
   1414         render_options();
   1415       } else if (gest == GEST_DOUBLE) {
   1416         if      (g_opt_cursor == 0) { g_opt_screen = OPT_MENU;    g_opt_cursor = 2; render_options(); }  // Back → ROOT (on "Badge Settings")
   1417         else if (g_opt_cursor == 1) { g_opt_screen = OPT_DISPLAY; g_opt_cursor = 0; render_options(); }  // "Display" — submenu
   1418         else if (g_opt_cursor == 2) {                // "Stealth" — inline toggle: stop/resume broadcasting
   1419           g_radio_on = !g_radio_on;                  //   TX-only gate; RX and the peer table are untouched
   1420           prefs.putUChar("radio", g_radio_on ? 1 : 0);
   1421           render_options();
   1422         }
   1423         else if (g_opt_cursor == 3) { g_opt_screen = OPT_DEVOPTS; g_opt_cursor = 0; render_options(); }  // "Developer Options" — submenu
   1424         else { g_opt_screen = OPT_POWER; g_opt_cursor = 0; render_options(); }                           // "Power" — submenu (was Reboot)
   1425       }
   1426     } else if (g_opt_screen == OPT_POWER) {          // Back · Reboot · Deep sleep
   1427       if (gest == GEST_SINGLE) {
   1428         g_opt_cursor = (g_opt_cursor + 1) % 3;
   1429         render_options();
   1430       } else if (gest == GEST_DOUBLE) {
   1431         if      (g_opt_cursor == 0) { g_opt_screen = OPT_BADGE; g_opt_cursor = 4; render_options(); }  // Back → Badge (on "Power")
   1432         else if (g_opt_cursor == 1) {                // "Reboot" — plain restart (moved here from Badge)
   1433           display_pet_popup(current_pet()->face[PET_IDLE], "Rebooting...", nullptr, 700);
   1434           esp_restart();
   1435         }
   1436         else enter_deep_sleep();                     // "Deep sleep" — never returns; button wakes
   1437       }
   1438     } else if (g_opt_screen == OPT_DISPLAY) {        // Back · Splash · Brightness
   1439       if (gest == GEST_SINGLE) {
   1440         g_opt_cursor = (g_opt_cursor + 1) % 3;
   1441         render_options();
   1442       } else if (gest == GEST_DOUBLE) {
   1443         if      (g_opt_cursor == 0) { g_opt_screen = OPT_BADGE; g_opt_cursor = 1; render_options(); }  // Back → Badge (on "Display")
   1444         else if (g_opt_cursor == 1) { g_opt_screen = OPT_SPLASH; render_options(); }                   // "Splash" — picker
   1445         else {                                       // "Brightness" — inline cycle LOW → MED → HIGH
   1446           g_bright = (g_bright + 1) % SCREEN_BRIGHT_LEVELS;
   1447           prefs.putUChar("bright", g_bright);
   1448           display_set_brightness(g_bright);          // takes effect on this very redraw
   1449           render_options();
   1450         }
   1451       }
   1452     } else if (g_opt_screen == OPT_DEVOPTS) {        // Back · Whoami · Debug · Provisioning · Factory Reset
   1453       if (gest == GEST_SINGLE) {
   1454         g_opt_cursor = (g_opt_cursor + 1) % 5;
   1455         render_options();
   1456       } else if (gest == GEST_DOUBLE) {
   1457         if (g_opt_cursor == 0) { g_opt_screen = OPT_BADGE; g_opt_cursor = 3; render_options(); }      // Back → Badge (on "Developer Options")
   1458         else if (g_opt_cursor == 1) { g_opt_screen = OPT_WHOAMI; render_options(); }                  // "Whoami" — identity screen
   1459         else if (g_opt_cursor == 2) {                // "Debug" — toggle diagnostics
   1460           g_test_mode = !g_test_mode;
   1461           prefs.putUChar("testmode", g_test_mode ? 1 : 0);
   1462           if (g_test_mode) {                         // enabling → fresh capture + new boot id
   1463             g_boot_id = prefs.getUInt("bootid", 0) + 1;
   1464             prefs.putUInt("bootid", g_boot_id);
   1465             g_batt_log.reset();
   1466             g_pts_log.reset();
   1467           }
   1468           render_options();
   1469         } else if (g_opt_cursor == 3) {              // "Provisioning Mode" — reboot into BLE setup
   1470           prefs.putUChar("provreq", 1);              // one-shot: setup() enters provisioning
   1471           display_pet_popup(current_pet()->face[PET_CONTACT], "Provisioning mode", nullptr, 1200);
   1472           esp_restart();                             // BLE comes up → hosted page sets handle/clock/pet
   1473         } else {                                     // "Factory Reset" — confirm first, never inline
   1474           g_opt_screen = OPT_RESET; g_opt_cursor = 0;   // land on Cancel
   1475           render_options();
   1476         }
   1477       }
   1478     } else if (g_opt_screen == OPT_RESET) {           // Cancel · ERASE (destructive; confirm gate)
   1479       if (gest == GEST_SINGLE) {
   1480         g_opt_cursor = (g_opt_cursor + 1) % 2;
   1481         render_options();
   1482       } else if (gest == GEST_DOUBLE) {
   1483         if (g_opt_cursor == 0) { g_opt_screen = OPT_DEVOPTS; g_opt_cursor = 4; render_options(); }  // Cancel
   1484         else                   { factory_reset(); }   // does not return — restarts
   1485       }
   1486     } else if (g_opt_screen == OPT_WHOAMI) {          // read-only identity screen
   1487       if (gest == GEST_DOUBLE) { g_opt_screen = OPT_DEVOPTS; g_opt_cursor = 1; render_options(); }  // back → Developer Options (on Whoami)
   1488     } else if (g_opt_screen == OPT_PET) {
   1489       if (gest == GEST_SINGLE) {
   1490         g_pet_idx = (g_pet_idx + 1) % pet_total();
   1491         prefs.putUInt("pet", g_pet_idx);
   1492         render_pet();
   1493       } else if (gest == GEST_DOUBLE) { g_opt_screen = OPT_USER; g_opt_cursor = 1; render_options(); }   // back → User Settings (on "Pet")
   1494     } else if (g_opt_screen == OPT_REMIND) {          // Back · Global · Shower (toggles; default ON)
   1495       if (gest == GEST_SINGLE) {
   1496         g_opt_cursor = (g_opt_cursor + 1) % 3;
   1497         render_options();
   1498       } else if (gest == GEST_DOUBLE) {
   1499         if      (g_opt_cursor == 0) { g_opt_screen = OPT_USER; g_opt_cursor = 3; render_options(); }  // Back → User (on "Reminders")
   1500         else if (g_opt_cursor == 1) { g_remind_global = !g_remind_global;         // master switch
   1501                                       prefs.putUChar("rmglob", g_remind_global ? 1 : 0); render_options(); }
   1502         else                        { g_remind_shower = !g_remind_shower;          // shower reminder
   1503                                       prefs.putUChar("rmshow", g_remind_shower ? 1 : 0); render_options(); }
   1504       }
   1505     } else {  // OPT_SPLASH
   1506       if (gest == GEST_SINGLE) {
   1507         g_splash_style = (g_splash_style + 1) % BOOT_SPLASH_COUNT;
   1508         prefs.putUInt("splash", g_splash_style);
   1509         render_menu();
   1510       } else if (gest == GEST_DOUBLE) { g_opt_screen = OPT_DISPLAY; g_opt_cursor = 1; render_options(); }   // back → Display (on "Splash")
   1511     }
   1512   } else if (gest == GEST_DOUBLE) {
   1513     g_view = (View)((g_view + 1) % VIEW_COUNT);
   1514     switch (g_view) {
   1515       case VIEW_HOME:    render_home();    break;  // also redraws live below
   1516       case VIEW_FINDER:  g_finder_page = 0; render_finder(); break;  // fresh entry → page 1; live below
   1517       case VIEW_REPORT:  enter_report();   break;  // static snapshot, drawn once
   1518       case VIEW_POINTS:  render_points();  break;  // also redraws live below
   1519       case VIEW_FRIENDS: render_friends(); break;  // also redraws live below
   1520       case VIEW_BATTERY: g_batt_storage = false; render_battery(); break;  // fresh entry → battery face; live below
   1521       case VIEW_OPTIONS: g_opt_screen = OPT_MENU; g_opt_cursor = 0; render_options(); break;
   1522       default:           display_boot("resuming"); break;
   1523     }
   1524   } else if (gest == GEST_SINGLE) {
   1525     if (g_view == VIEW_POINTS) {                    // POINTS: cycle the graph window
   1526       g_points_window = (PointsWin)((g_points_window + 1) % PWIN_COUNT);
   1527       render_points();
   1528     } else if (g_view == VIEW_FINDER) {             // FINDER: page down the badge list (wraps)
   1529       g_finder_page = (g_finder_page + 1) % g_finder_pages;
   1530       render_finder();
   1531     } else if (g_view == VIEW_BATTERY) {            // BATTERY: flip to the storage readout
   1532       g_batt_storage = !g_batt_storage;
   1533       if (g_batt_storage) disk_refresh();           // fresh on arrival — the guard only polls every 60 s
   1534       render_battery();
   1535     }
   1536   }
   1537 
   1538   // Broadcast. Gated by the Stealth switch: while stealthed we simply never transmit, so
   1539   // badge is invisible to everyone else while still hearing them. g_counter therefore
   1540   // stops advancing too, which is fine — it only has to be monotonic, and receivers
   1541   // compare it per-sender, so it just resumes where it left off when the radio comes back.
   1542   if (g_radio_on && now - last_beacon >= BEACON_INTERVAL_MS) {
   1543     last_beacon = now;
   1544     send_beacon();
   1545   }
   1546 
   1547   // Low-battery glyph blink: the home view needs a faster redraw than the 3 s
   1548   // view refresh to animate it. Cheap (re-renders home only), and only while low.
   1549   static uint32_t last_lowblink = 0;
   1550   if (!g_toast_active && g_view == VIEW_HOME && battery_is_low() && now - last_lowblink >= BATT_LOW_BLINK_MS / 2) {
   1551     last_lowblink = now;
   1552     render_home();
   1553   }
   1554 
   1555   // Expire + log departures, accrue points, and redraw the live view (FINDER
   1556   // or POINTS). REPORT is a static snapshot, so we leave its screen untouched.
   1557   if (now - last_display >= DISPLAY_REFRESH_MS) {
   1558     last_display = now;
   1559     static Encounter ev[MAX_PEERS];              // static: this block also nests into enc_append→
   1560     int en = 0;                                  //   vfprintf on departures; keep ev (~1.1KB) off the
   1561     Peer snap[MAX_PEERS];                         //   loopTask stack (same canary risk as enter_report)
   1562     int n;
   1563     portENTER_CRITICAL(&peerMux);
   1564     peers.expire(now, ev, en, MAX_PEERS);
   1565     n = peers.snapshot(snap, MAX_PEERS);
   1566     portEXIT_CRITICAL(&peerMux);
   1567     for (int i = 0; i < en; i++) enc_append(ev[i]);   // file IO outside the lock
   1568 
   1569     // Points: add the elapsed tick to the proximity tally whenever crew are
   1570     // both in range AND close enough to clear the RSSI gate. Seeded to `now` on
   1571     // the first pass so we never count boot time. Whole seconds earned this tick
   1572     // are scaled by the group multiplier (×2 crew around / ×3 group of 5+) and
   1573     // credited to EVERY in-gate companion for the best-friends view — the same
   1574     // scaled amount, so friend minutes stay ≤ the total and the views agree.
   1575     bool earning = points_earning(snap, n);
   1576     uint8_t mult = points_multiplier(n);
   1577     static uint32_t last_points_ms = 0;
   1578     if (last_points_ms == 0) last_points_ms = now;
   1579     uint32_t dt = now - last_points_ms;
   1580     last_points_ms = now;
   1581     if (earning) {
   1582       g_prox_accum_ms += dt;
   1583       uint32_t whole = g_prox_accum_ms / 1000;
   1584       g_prox_accum_ms %= 1000;
   1585       if (whole) {
   1586         g_prox_seconds += whole * mult;           // each wall second counts ×mult
   1587         // Credit the minute to EVERY in-gate friend (snapshot is sorted
   1588         // closest-first, so stop at the first one below the gate).
   1589         for (int i = 0; i < n && snap[i].rssi >= POINTS_MIN_RSSI; i++)
   1590           g_ledger.credit(snap[i].handle, whole * mult);
   1591       }
   1592     }
   1593 
   1594     // Group-bonus activation pop-up: when the multiplier level RISES (crew
   1595     // gathered), celebrate it like an achievement — the milestone face with an
   1596     // "xN bonus!" label. Falling levels change the header chip silently. The
   1597     // pet_react cooldown + the 15s peer TTL keep a flapping 5th badge from
   1598     // spamming the screen.
   1599     static uint8_t prev_mult = 1;
   1600     if (mult > prev_mult) {
   1601       char mb[16];
   1602       snprintf(mb, sizeof(mb), "x%u score", (unsigned)mult);
   1603       pet_react(PET_MILESTONE, now, "group bonus", mb);
   1604     }
   1605     prev_mult = mult;
   1606 
   1607     // Checkpoint the tally to NVS at most once per interval, and only when it
   1608     // actually moved (no needless flash writes while you're off alone).
   1609     static uint32_t last_persist_ms  = 0;
   1610     static uint32_t last_persisted_s = 0xFFFFFFFF;
   1611     if (now - last_persist_ms >= POINTS_PERSIST_MS && g_prox_seconds != last_persisted_s) {
   1612       last_persist_ms  = now;
   1613       last_persisted_s = g_prox_seconds;
   1614       prefs.putUInt("prox", g_prox_seconds);
   1615       g_ledger.save(prefs);                 // same cadence/guard as the tally
   1616     }
   1617 
   1618     // Sample the cumulative score (with wall-clock epoch) for the POINTS graph,
   1619     // self-throttled to the hourly cadence. Persist only when a new sample lands
   1620     // (≈hourly) so the graph survives reboots without churning flash.
   1621     if (g_points_history.sample((uint16_t)(g_prox_seconds / 60), g_clock.now(), now))
   1622       g_points_history.save(prefs);
   1623     g_points_recent.sample((uint16_t)(g_prox_seconds / 60), g_clock.now(), now);  // 1h ring (in-RAM)
   1624     if (g_test_mode) test_pts_log_tick(now, n, mult);   // test mode: snapshot the live counters
   1625 
   1626     // Pet reactions: a never-before-seen device MAC → CONTACT (fires once per
   1627     // device per session, flagged by the recv callback); crossing a
   1628     // PET_MILESTONE_STEP points boundary → MILESTONE. Off the PET view, pet_react
   1629     // pops the reaction up over the current view (cooldown-limited). Clear the
   1630     // flag before reacting so a contact arriving mid-reaction isn't lost.
   1631     if (g_new_contact) { g_new_contact = false; pet_react(PET_CONTACT, now, "new crew", g_contact_handle); }
   1632     static uint32_t prev_milestone_pts = 0;
   1633     uint32_t pts_now = g_prox_seconds / 60;
   1634     if (pts_now > 0 && pts_now / PET_MILESTONE_STEP > prev_milestone_pts / PET_MILESTONE_STEP) {
   1635       char ms[16];                                    // the milestone value = the detail line
   1636       snprintf(ms, sizeof(ms), "%u pts",
   1637                (unsigned)((pts_now / PET_MILESTONE_STEP) * PET_MILESTONE_STEP));
   1638       pet_react(PET_MILESTONE, now, "milestone", ms);
   1639     }
   1640     prev_milestone_pts = pts_now;
   1641 
   1642     if      (g_toast_active)         { /* a reminder toast owns the screen — hold its frame */ }
   1643     else if (g_view == VIEW_HOME)    render_home();      // live clock/points/battery
   1644     else if (g_view == VIEW_FINDER) {                   // live: keep the page clamped as peers move
   1645       g_finder_pages = finder_pages(n);
   1646       if (g_finder_page >= g_finder_pages) g_finder_page = g_finder_pages - 1;
   1647       display_peers(g_handle, snap, n, g_finder_rssi, mult, g_finder_page);
   1648     }
   1649     else if (g_view == VIEW_POINTS)  render_points();   // passes the history graph
   1650     else if (g_view == VIEW_FRIENDS) render_friends();
   1651     else if (g_view == VIEW_BATTERY) render_battery();
   1652     else if (g_view == VIEW_OPTIONS && g_opt_screen == OPT_PET) render_pet();  // live pet reactions
   1653   }
   1654 
   1655   delay(10);
   1656 }