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)