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, <); // → 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 }