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

config.h (25147B)


      1 #pragma once
      2 #include <stdint.h>
      3 #include <stddef.h>
      4 
      5 // Short git rev of the build, injected by git_rev.py (pre: build script) so the
      6 // badge can show which firmware is flashed on boot. A trailing '+' means the
      7 // firmware tree had uncommitted changes at build time. Fallback for builds
      8 // without git (e.g. a tarball checkout).
      9 #ifndef GIT_REV
     10 #define GIT_REV "nogit"
     11 #endif
     12 
     13 // Firmware release version — human-facing, shown on boot. Distinct from
     14 // BEACON_VERSION (the wire protocol). Bump FW_VERSION_MINOR for features /
     15 // FW_VERSION_MAJOR for milestones; GIT_REV still pins the exact build. The
     16 // string forms are derived, so the major/minor numbers are the single source.
     17 #define FW_VERSION_MAJOR 1
     18 #define FW_VERSION_MINOR 10
     19 #define _FW_STR2(x) #x
     20 #define _FW_STR(x)  _FW_STR2(x)
     21 #define FW_VERSION_STR  "v" _FW_STR(FW_VERSION_MAJOR) "." _FW_STR(FW_VERSION_MINOR)
     22 #define FW_BUILD_STR    FW_VERSION_STR " " GIT_REV   // e.g. "v1.1 5597f1a"
     23 
     24 // ─────────────────────────────────────────────────────────────────────────
     25 //  GROUP SECRET
     26 //  Every badge in your crew flashes the SAME 32-byte key. Beacons are
     27 //  HMAC-signed with it; a badge silently drops any beacon that doesn't
     28 //  verify — so outsiders and spoofers in the (very hostile) DefCon RF
     29 //  soup never make it onto anyone's screen.
     30 //
     31 //  The REAL key lives in firmware/src/secrets.h, which is GITIGNORED and must
     32 //  never be committed. Create it once, on the flashing machine, from the
     33 //  tracked template:
     34 //      cp firmware/src/secrets.h.example firmware/src/secrets.h
     35 //      openssl rand -hex 32        # paste the 32 bytes into secrets.h,
     36 //                                  # then delete its IS_PLACEHOLDER line
     37 //  Store the same key in your password manager: it is unrecoverable, and every
     38 //  crew badge must flash the IDENTICAL key or they can't see each other.
     39 //
     40 //  If secrets.h is absent (or still the placeholder), GROUP_PSK falls back to a
     41 //  PUBLIC throwaway key and beacons are UNAUTHENTICATED — fine for dev, never
     42 //  for the fleet. The guard below makes a production build (env oled_v3_prod,
     43 //  which sets -DBADGE_PRODUCTION) REFUSE to compile in that state; plain oled_v3
     44 //  still builds (with a warning) so day-to-day iteration isn't blocked.
     45 // ─────────────────────────────────────────────────────────────────────────
     46 #if __has_include("secrets.h")
     47 #include "secrets.h"                 // defines GROUP_PSK / GROUP_PSK_LEN + GROUP_PSK_SET
     48 #endif
     49 
     50 #ifndef GROUP_PSK_SET
     51 // Fallback: the well-known PUBLIC placeholder. Not a secret — safe to commit.
     52 static const uint8_t GROUP_PSK[] = {
     53   0xde,0xad,0xbe,0xef, 0x00,0x11,0x22,0x33, 0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb,
     54   0xcc,0xdd,0xee,0xff, 0x13,0x37,0xc0,0xde, 0xfe,0xed,0xfa,0xce, 0xba,0xdc,0x0f,0xee
     55 };
     56 static const size_t GROUP_PSK_LEN = sizeof(GROUP_PSK);
     57 #define GROUP_PSK_IS_PLACEHOLDER 1
     58 #endif
     59 
     60 // Production guard. A fleet build (-DBADGE_PRODUCTION, set only by env oled_v3_prod)
     61 // refuses to compile with the throwaway key. A real secrets.h defines GROUP_PSK_SET and
     62 // omits GROUP_PSK_IS_PLACEHOLDER, so it passes; a missing secrets.h OR one still holding
     63 // the placeholder marker stops the build right here.
     64 #if defined(BADGE_PRODUCTION) && defined(GROUP_PSK_IS_PLACEHOLDER)
     65 #error "Production build (oled_v3_prod) with the throwaway GROUP_PSK. Create firmware/src/secrets.h from secrets.h.example, set a real key (openssl rand -hex 32), and delete its GROUP_PSK_IS_PLACEHOLDER line. See the GROUP SECRET block in config.h."
     66 #endif
     67 
     68 // Dev builds on the placeholder still work — but say so, loudly, every compile.
     69 #if defined(GROUP_PSK_IS_PLACEHOLDER) && !defined(BADGE_PRODUCTION)
     70 #warning "Building with the PUBLIC throwaway GROUP_PSK — beacons are UNAUTHENTICATED. Fine for dev; the fleet must use env oled_v3_prod with a real firmware/src/secrets.h."
     71 #endif
     72 
     73 // ─── Radio / discovery ───────────────────────────────────────────────────
     74 //  Badge Settings → STEALTH is a BROADCAST switch (NVS key "radio", default ON = not
     75 //  stealthed; the key and the internal flag stay radio-polarity, the UI inverts). Turning
     76 //  it OFF gates send_beacon() only: the badge goes silent so nobody can see it, but the
     77 //  ESP-NOW stack stays up and RX is untouched — you still see crew in FINDER and still
     78 //  score. That asymmetry is deliberate. Tearing the stack down instead would be a bigger
     79 //  change than it looks: start_discovery() is not idempotent (it bumps the persisted
     80 //  beacon counter by +1000 and RELOADS points/ledger/history from NVS on every call), so
     81 //  a re-enable would clobber up to POINTS_PERSIST_MS of live accrual, and the peer table
     82 //  draining on TTL would fake a synchronized mass-departure into the encounter log. Gating
     83 //  TX sidesteps both. Full RF-down (esp_now_deinit + WiFi off, for the battery win) needs
     84 //  that refactor first.
     85 #define ESPNOW_CHANNEL       1       // ALL badges must agree on one channel
     86 #define BEACON_INTERVAL_MS   1000    // how often we broadcast our presence
     87 #define PEER_TTL_MS          15000   // drop a peer unseen for this long
     88 #define DISPLAY_REFRESH_MS   3000    // how often we redraw the finder screen
     89 #define BATT_LOW_BLINK_MS    600     // home battery-glyph blink half-period when low (slow-ish)
     90 #define MAX_PEERS            32      // bounded table = flood-resistant
     91 
     92 // ─── Test mode (OPTIONS-menu toggle; logs to LittleFS + serial dump on boot) ──
     93 #define TEST_LOG_INTERVAL_MS  600000  // battery-log cadence on the plateau (10 min)
     94 #define TEST_LOG_FAST_MS       60000  // battery-log cadence below the knee (1 min)
     95 #define TEST_LOG_CLIFF_MV       3600  // VBAT mv at/below which the fast cadence kicks in
     96 #define TEST_PTLOG_INTERVAL_MS  60000 // points-log cadence (1 min; points tick ≤ 1/min)
     97 #define TEST_LOG_MAX_BYTES      65536 // per-file safety cap (~64 KB) so it can't fill flash
     98 #define TEST_MAC_HOLD_MS         3000 // how long the boot MAC screen holds (read/photograph)
     99 #define TEST_MODE_POPUP_MS       1500 // "Test Mode Activated" pet popup hold, before the splash
    100 
    101 // ─── Disk guard (BACKSTOP — the log's real bound is ENC_LOG_MAX_BYTES below) ──
    102 //  The encounter log grows one ~50B record per peer departure. It is now capped in
    103 //  enc_append, so this guard should never fire on encounter growth alone; if it does,
    104 //  the pressure is the Test-Mode CSVs and trimming the log is best-effort. At ≥90% full:
    105 //  drop the OLDEST encounters back toward ~80% and raise a dismissable pet toast (≤10%
    106 //  free) so you can export before losing more. Do NOT restore this as the primary bound —
    107 //  the 90/80 pair is unsatisfiable by construction (see the note below).
    108 #define DISK_CHECK_MS         60000    // how often to poll LittleFS usage
    109 #define DISK_FULL_NUM         9        // trip the guard at used ≥ (NUM/DEN) of total …
    110 #define DISK_FULL_DEN         10       //   = 90% full
    111 #define DISK_TARGET_NUM       8        // … then trim the log back toward (NUM/DEN) of total
    112 #define DISK_TARGET_DEN       10       //   = 80% full
    113 #define DISK_WARN_REMIND_MS   600000   // re-warn (toast) at most every 10 min while full
    114 
    115 // ─── Encounter-log cap ──────────────────────────────────────────────────────
    116 //  The disk guard above used to be the ONLY bound on the log, and it could not work. By the
    117 //  time it trips at 90% used there is only ~90 KB free, but it asks the trim to preserve
    118 //  ~734 KB (80% of the partition) — so the copy runs out of space partway. The copy walks
    119 //  FORWARD from the cut point, so what survived was the OLDEST slice of the intended tail:
    120 //  the guard destroyed the newest ~640 KB, the exact inverse of "keeps the most recent
    121 //  history".
    122 //
    123 //  Capping the log proactively fixes it by construction: at 256 KB there is ~640 KB free, far
    124 //  more than the 192 KB the copy needs, so the trim is always feasible. The 90/80 guard
    125 //  becomes a backstop that should never fire, and enc_report()'s parse cost is bounded too.
    126 //
    127 //  256 KB ≈ 5,900 records ≈ well past a 4-day con at the project's own churn model
    128 //  (~800 KB / 1.5 days assumes a crowded hallway continuously; real logs are far smaller).
    129 #define ENC_LOG_MAX_BYTES     262144   // trim once the log grows past this (256 KB)
    130 #define ENC_LOG_TRIM_BYTES    196608   // ...down to this much of the NEWEST history (192 KB)
    131 #define ENC_TRIM_FREE_MARGIN   16384   // slack left free when clamping a trim to real space
    132 
    133 // ─── Identity ────────────────────────────────────────────────────────────
    134 #define HANDLE_MAX_LEN       24      // bytes; untrusted input is clamped to this
    135 
    136 // ─── Logging / report ─────────────────────────────────────────────────────
    137 #define REPORT_HOLD_MS       1500    // hold BUTTON this long to cycle the view
    138 #define TAP_MAX_MS           400     // a release within this = a "tap" (vs a hold)
    139 #define DOUBLETAP_GAP_MS     400     // two taps within this = double-tap (next view); a lone
    140                                      //   tap fires as a single (sub-view) once the window passes
    141 #define CLOCK_PERSIST_MS     60000   // how often the soft-clock checkpoints to NVS
    142 
    143 // Provisioning escape hatch. Provisioning mode never times out and doesn't beacon, so a
    144 // badge that lands there by accident is inert with no on-device way out — worst for the
    145 // iOS-only crowd, who can't run the Web Bluetooth app to provision their way out either.
    146 // Hold BUTTON this long in setup mode to reboot straight to the home view.
    147 // Well clear of REPORT_HOLD_MS so it can't be hit by muscle memory for "go home".
    148 #define PROV_ESCAPE_HOLD_MS      5000  // hold-to-exit duration
    149 #define PROV_ESCAPE_FEEDBACK_MS   600  // start the on-screen countdown after this much hold
    150 
    151 // Pairing-code attempt limit. The 3-char code is a PHYSICAL-PRESENCE gate — it means
    152 // "you can see the badge's screen" — and unlimited guessing defeats exactly that: 30^3 =
    153 // 27,000 codes, ~1.7 min to average compromise with no line-of-sight at all. After this
    154 // many wrong codes the gate latches shut until a reboot, which needs physical access —
    155 // restoring the property. Someone retyping a code off the screen never gets near 5.
    156 #define PROV_CODE_MAX_TRIES         5
    157 
    158 // Wall-clock display timezone (POSIX TZ string). The seeded epoch is UTC (the
    159 // provisioning page sends Date.now()/1000), so the badge renders local time via
    160 // localtime_r() with this TZ set at boot. Pacific w/ US DST rules — DEF CON is on
    161 // Pacific, and the rules auto-handle PST(−8)/PDT(−7). Change this one string to relocate.
    162 #define DISPLAY_TZ           "PST8PDT,M3.2.0,M11.1.0"
    163 
    164 // ─── Points (gamified proximity) ───────────────────────────────────────────
    165 //  +1 point per full minute spent with ≥1 crew badge in range. The accrued
    166 //  proximity time is checkpointed to NVS so a reboot mid-con doesn't zero it.
    167 #define POINTS_PERSIST_MS    60000   // how often the proximity tally checkpoints
    168 
    169 //  RSSI gate: only count a minute when the CLOSEST crew badge is at least this
    170 //  strong (dBm). Tighter = they must be physically nearer to score. Rough feel:
    171 //    -60 ≈ same little huddle · -70 ≈ same area · -80 ≈ anywhere in earshot.
    172 //  ⚠️ The Arduino core 2.x ESP-NOW callback reports rssi=0 for everyone, so on
    173 //  that toolchain the gate ALWAYS passes (≡ "any crew in range"); it only bites
    174 //  on the core 3.x / pioarduino build that exposes real RSSI. To disable the
    175 //  gate entirely, set this to -128 (every real signal clears it).
    176 #define POINTS_MIN_RSSI      -80
    177 
    178 //  Group multiplier: scoring scales with how many crew badges are in range
    179 //  (the peer-table / FINDER count — NOT the RSSI gate, which still decides
    180 //  whether you earn at all). ≥X2 badges → every earned second counts ×2;
    181 //  ≥X3 → ×3. A solo 1-on-1 (one crew in range) earns at the base ×1; ×2 needs
    182 //  a small group (2+ around), ×3 the group huddle. The
    183 //  multiplied seconds flow into BOTH the tally and the per-friend ledger, so
    184 //  the points view, graph, and best-friends ranking stay mutually consistent.
    185 //  Crossing a threshold upward fires a pet "xN bonus!" pop-up (cooldown-
    186 //  limited); the active level shows as an inverse chip on FINDER/POINTS.
    187 #define POINTS_X2_PEERS      2       // ≥ this many crew in range → ×2 (a small group)
    188 #define POINTS_X3_PEERS      5       // ≥ this many crew in range → ×3 (the group huddle)
    189 
    190 // ─── Points history (the POINTS-view graph) ─────────────────────────────────
    191 //  Cumulative-score samples drawn as a line graph on the POINTS view:
    192 //  x = real wall-clock time over a fixed 5-day window (the event), y = points.
    193 //  Each sample carries its epoch; the buffer is persisted to NVS so the graph
    194 //  survives reboots. A reboot inserts a visible break in the line — the board
    195 //  has no RTC, so a power-off can't be measured (clock.h: downtime isn't
    196 //  counted): we can mark the break but not size the gap. If the clock was never
    197 //  synced (epoch 0), the renderer falls back to uniform spacing.
    198 //  120 hourly samples span the 5-day axis; ~120×8 bytes in RAM and in NVS.
    199 #define POINTS_HISTORY_LEN        120          // samples kept (120 h = 5 days)
    200 #define POINTS_HISTORY_SAMPLE_MS  3600000UL    // 1 h cadence (LEN×cadence = 5 days)
    201 #define POINTS_GRAPH_SPAN_S       432000UL     // 5-day window (hourly samples)
    202 #define POINTS_GRAPH_24H_SPAN_S   86400UL      // "last 24h" window (hourly samples)
    203 #define POINTS_GRAPH_1H_SPAN_S    3600UL       // "last 1h" window (fine 1-min ring)
    204 // A single tap on the POINTS view cycles the window: 5d → 24h → 1h. The 1-hour
    205 // view needs finer-than-hourly data, so it reads a small in-RAM 1-min ring:
    206 #define POINTS_RECENT_LEN         60           // 1h ring samples (60 × 1 min = 1h); volatile
    207 #define POINTS_RECENT_SAMPLE_MS   60000UL      // 1-min cadence for the recent ring
    208 
    209 // ─── Provisioning (BLE GATT) ─────────────────────────────────────────────
    210 //  UUIDs MUST match provisioning/index.html. Generate your own for a real
    211 //  build (these are placeholders): https://www.uuidgenerator.net/
    212 #define PROV_DEVICE_NAME     "badge-setup"   // base name; start_provisioning() appends "-<full MAC>"
    213                                              //   so badges are distinct in the picker + map to the roster
    214 #define PROV_SERVICE_UUID    "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e"
    215 #define PROV_CHAR_UUID       "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e"  // handle (write)
    216 #define PROV_TIME_CHAR_UUID  "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e"  // epoch secs, u32 LE (write)
    217 #define PROV_PET_CHAR_UUID   "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e"  // custom pet blob (write)
    218 #define PROV_CODE_CHAR_UUID  "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e"  // 3-char pairing code (write) — GATES the others
    219 // Metrics readout for the provisioning web app's reports/viz: a read/notify characteristic
    220 // that streams a framed CSV/TSV dump (encounters.log + points history + the Test-Mode CSVs
    221 // when Debug is on) to the browser, gated by the pairing code. Chunked to the negotiated MTU;
    222 // the page reassembles the notify chunks until "==== EOF ====". Served in start_provisioning();
    223 // streamed from loop() (stream_metrics). Web side wired in provisioning/index.html.
    224 #define PROV_METRICS_CHAR_UUID "6e8f0005-b5a3-f393-e0a9-e50e24dcca9e"  // metrics CSV stream (read/notify)
    225 #define METRICS_CHUNK_MAX      240    // cap per-notify payload bytes (used as min with MTU−3)
    226 #define METRICS_PACE_MS        8      // delay between notify chunks (tx-buffer breathing room)
    227 #define METRICS_NOTIFY_RETRIES 40     // retry a chunk this many times if the tx buffer is full (no silent drops)
    228 #define METRICS_REBOOT_MS      4000   // after the stream, reboot to discovery this long later
    229 
    230 // ─── Pins ────────────────────────────────────────────────────────────────
    231 //  Single tap = sub-view action (FINDER page-down · POINTS window · PET/SPLASH pick);
    232 //  double tap = next view; long-press = jump home to FINDER. Hold this button
    233 //  during boot to force re-provisioning. Active-low w/ INPUT_PULLUP. Dev boards:
    234 //  BOOT on GPIO0. The FireBeetle 2 ESP32-E badge wires its user button to GPIO27,
    235 //  so the oled envs set -DBUTTON_PIN=27 (platformio.ini); default stays GPIO0.
    236 #ifndef BUTTON_PIN
    237 #define BUTTON_PIN           0
    238 #endif
    239 
    240 // ─── OLED (SSD1309 2.42", I2C) ──────────────────────────────────────────────
    241 //  Adjust to your host board's I2C pins. Classic ESP32 default is 21/22, and
    242 //  most "ESP32 + 18650" boards expose those too — confirm against your board.
    243 #define OLED_SDA_PIN         21
    244 #define OLED_SCL_PIN         22
    245 
    246 // ─── Screen brightness (Badge Settings → Display → Brightness) ──────────────
    247 //  A level index cycled in the menu and persisted to NVS (key "bright"). The index
    248 //  is backend-independent (main.cpp owns the cycling); the OLED backend maps it to
    249 //  the SSD1309 contrast register below, and serial has no panel so it ignores it.
    250 #define SCREEN_BRIGHT_LEVELS   3
    251 #define SCREEN_BRIGHT_DEFAULT  1    // MED — matches the pre-existing look, see OLED_BRIGHT_MED
    252 
    253 //  SSD1309 contrast register (0x81) values, 0..255, indexed by the level above. MED is
    254 //  u8g2's own SSD1309 init default (0x6f = 111, see u8x8_d_ssd1309.c) — so a badge with
    255 //  no saved pick looks exactly as it did before this setting existed. Contrast 0 is
    256 //  dim-but-visible on this panel rather than off, so no level can black out the UI and
    257 //  strand the user with no way back to the menu; LOW is floored well above that anyway.
    258 #define OLED_BRIGHT_LOW       16
    259 #define OLED_BRIGHT_MED      111
    260 #define OLED_BRIGHT_HIGH     255
    261 
    262 // ─── Boot splash (OLED animation style) ─────────────────────────────────────
    263 //  The OLED plays an animated splash at power-on. BOOT_SPLASH_STYLE is the
    264 //  factory default; it's overridden at runtime by the user's pick in the MENU
    265 //  view (persisted to NVS, key "splash"). Serial/e-ink builds ignore the style
    266 //  (serial prints a static ASCII banner; e-ink draws a static logo).
    267 #define BOOT_SPLASH_RADAR     0   // sonar sweep + blips → wordmark (thematic)
    268 #define BOOT_SPLASH_TERMINAL  1   // POST-style typewriter self-test
    269 #define BOOT_SPLASH_GLITCH    2   // scanline-noise wordmark reveal
    270 #define BOOT_SPLASH_MATRIX    3   // "onlyn00bs" glyph rain, bright head + fading trail
    271 //  The number of styles above. Everything that cycles or wraps the selection reads THIS
    272 //  rather than a literal — the count was previously a bare `% 3` repeated across main.cpp
    273 //  and both display backends, which is exactly the kind of thing that goes stale when a
    274 //  style is added. Bump it and the picker, the wrap and the name tables all follow.
    275 #define BOOT_SPLASH_COUNT     4
    276 #define BOOT_SPLASH_STYLE     BOOT_SPLASH_RADAR
    277 
    278 // ─── Boot logo card ─────────────────────────────────────────────────────────
    279 //  A three-frame logo card that plays right AFTER the splash the user picked above
    280 //  (not instead of it) and before the disclaimer. The bitmaps are baked from
    281 //  source artwork into src/logos.h by a generator that isn't part of this release; the
    282 //  bitmaps are plain data, and nothing here needs to know the image geometry.
    283 //  The card is a sequence of full-screen frames; this is the budget for the WHOLE set,
    284 //  split evenly across however many frames are in it. So adding or removing a logo
    285 //  changes what you see without changing how long boot takes.
    286 //
    287 //  Currently the set is the OnlyNoobs wordmark alone. The emblem and foot bitmaps are
    288 //  still baked into logos.h — a three-up single-frame arrangement was tried on glass
    289 //  (2026-07-25) and rejected: at 38px the emblem's interior collapses into noise. Giving
    290 //  each mark the whole panel is the only arrangement where they stay legible, so if they
    291 //  come back they come back as extra frames here, not as a denser layout.
    292 #define BOOT_LOGO_HOLD_MS    1620   // whole-card budget (×SCALE → ~4.9 s)
    293 #define BOOT_LOGO_TOTAL_MS    ((uint32_t)BOOT_LOGO_HOLD_MS * BOOT_TIMING_SCALE)
    294 
    295 // Boot pacing: lengthen every boot-sequence beat — the splash animation, the
    296 // serial POST readout, and the "Welcome" screen — by this factor. 1 = snappy.
    297 #define BOOT_TIMING_SCALE     3
    298 #define BOOT_DIAG_HOLD_MS     700   // base pause holding the serial POST block (×SCALE)
    299 #define DISCLAIMER_HOLD_MS    900   // base hold for the boot heat/LiPo disclaimer screen (×SCALE → ~2.7s)
    300 
    301 // ─── Pet (companion) ────────────────────────────────────────────────────────
    302 //  A selectable ASCII face on the PET view that reacts to events. See pet.h.
    303 #define PET_REACT_MS          4000    // a reaction face (contact/milestone) holds this long
    304 #define PET_MILESTONE_STEP    100     // celebrate every N points
    305 #define PET_POPUP_COOLDOWN_MS 10000   // min gap between reaction pop-ups over other views
    306 #define PET_POPUP_MS          1700    // reaction popup hold — long enough to read 2 lines
    307 #define PET_MILESTONE_POPUP_MS 2200   // milestone popup holds a bit longer (bigger deal)
    308 #define PET_LOWBATT_POPUP_MS  3000    // low-battery toast holds longest (it matters)
    309 #define PET_LOWBATT_REMIND_MS 300000  // re-warn every 5 min while still low (persistent)
    310 
    311 // ─── Reminders (non-blocking pet-toasts; User Settings → Reminders) ──────────
    312 //  Scheduled nudges shown as a pet toast. The board has no RTC, so they fire on an
    313 //  UPTIME interval (not wall-clock 8am/5pm) — no phone-sync/clock dependency. The
    314 //  toast is non-blocking (the loop keeps beaconing/scoring): dismissed by any button
    315 //  press or auto-cleared after REMINDER_TOAST_MS. A master "Global" toggle gates all
    316 //  reminders; each specific one (Shower, …) has its own toggle. Both default ON and
    317 //  persist to NVS (keys "rmglob"/"rmshow"); toggling a switch OFF disables it.
    318 #define REMINDER_SHOWER_MS   28800000UL  // 8 h of uptime between shower reminders
    319 #define REMINDER_TOAST_MS    30000       // auto-dismiss a reminder toast after 30 s
    320 
    321 // ─── Battery sense (FireBeetle 2 ESP32-E onboard divider) ──────────────────
    322 //  The FireBeetle 2 ESP32-E permanently wires VBAT through an onboard 1:2
    323 //  divider (two 1MΩ resistors, ~2.5µA leak) to GPIO34 / ADC1_CH6 (input-only).
    324 //  We read with analogReadMilliVolts() so the ESP32's factory ADC calibration
    325 //  linearises the raw count; then ×VBAT_DIVIDER undoes the divider. (Other dev
    326 //  kits without this divider will read garbage on GPIO34 — fine, the battery
    327 //  view is only meaningful on the real badge board.)
    328 #define PIN_VBAT             34
    329 #define VBAT_DIVIDER         2       // onboard divider: pin-mV × 2 = battery-mV
    330 #define VBAT_FULL_MV         4200    // LiPo fully charged → 100%
    331 #define VBAT_EMPTY_MV        3300    // 0% gauge point; margin above the ~3.0V floor
    332 #define VBAT_LOW_MV          3450    // show LOW warning at/below this (~15%)
    333 #define VBAT_SAMPLE_MS       500     // min interval between real ADC reads
    334 #define VBAT_EMA_NUM         1       // EMA weight on the new sample = NUM/DEN (0.2):
    335 #define VBAT_EMA_DEN         5       //   smooths the dip from each ESP-NOW TX burst
    336 //  Burst averaging: the onboard VBAT divider is two 1MΩ resistors (~500kΩ source
    337 //  impedance) — far above what the ESP32 ADC sample/hold cap can settle through in
    338 //  one conversion, so a single read sits low + noisy. Each sample now discards a
    339 //  priming read (lets the cap track) then averages this many back-to-back reads.
    340 //  Pure firmware; no hardware change. (Per-board calibration + a LiPo SoC curve are
    341 //  the other two accuracy levers, not implemented.)
    342 #define VBAT_BURST_SAMPLES   16      // reads averaged per sample (after 1 priming read)