Skip to the content
4 layers95 × 55 mmDashboard plusplus carrier, the printed circuit board in 3D

Dashboard plusplus carrier

alefinot/Dashboard-V21

Dashboard++ V2

Size
95 × 55 mm
Copper layers
4
Thickness
1.6 mm
Parts
57
Nets
47
Pads
183
Tracks, vias
812, 482
Pours
4
Routed
Yes
KiCad
KiCad 9.0 (file 20241229)
Created
20 Aug 2026
Updated
7 Nov 2026

Overview

Dashboard plusplus carrier is an open-source printed circuit board by alefinot, from the alefinot/Dashboard-V2 repository on GitHub, published under the MIT licence. It is a 4-layer board of 95 × 55 mm with 57 parts on 47 nets, routed with 812 tracks and 482 vias.

It is built around the EL357N(B)(TA)-G, BZT52C15V and P6SMB33A, with the SMF4L33A and XT30U-M among its other parts. Its parts include 13 resistors, 13 connectors, 12 capacitors, 4 diodes and 1 IC. It belongs with the ESP32 boards of this library.

Main components

5

README

README.md

Dashboard++ for ESP32

PlatformIO Framework MCU Display License [blocked]

Dashboard++ is an ultra-high-performance, production-grade automotive digital instrument cluster and telemetry solution designed for motorcycles, cars, and custom electric/combustion vehicles powered by the ESP32 WROOM-32 dual-core microcontroller.

Featuring a 4.0-inch ILI9488 TFT display (480×320 resolution) driven over a 60 MHz SPI bus using LovyanGFX with PROGMEM-mapped VLW fonts, Dashboard++ blends real-time multi-sensor fusion algorithms, sprite-based anti-aliased digit rendering, dirty-rendering optimizations, hysteresis-based dynamic CPU frequency scaling, persistent NVS configuration management, and a Bluetooth LE link to the companion Android app, which carries the whole settings page, 1 Hz telemetry pushes, NVS backup/restore, the weather printed on the panel (fetched by the phone and handed over), and firmware updates. A build from this tree has no Wi-Fi stack of any kind — see How to read this manual.


How to read this manual

Two generations are described in these pages, and they are not the same product:

  • A current-build unit — anything built from this tree, i.e. V2.0.0 and the Changelog entries it collects on top of V1.5.1, the current build being V2.0.3 — is BLE-only, with no Wi-Fi stack at all. src/web.cpp (the SoftAP and station join, every REST handler, the Web Portal, the LAN install route and the weather-fetch task), src/webui.html and scripts/gzip_webui.py were deleted on 2026-11-02, and there is no build flag that compiles any of it back: a unit flashed from here has no SoftAP, no IP, no http://192.168.4.1, no HTTP API, no page on the panel, and no weather it fetched by itself. Its one door is Bluetooth LE (GATT service d9a1f000-6c1e-4b8a-9f2d-3e7c1a5b9d40, src/bt.cpp + src/bt_proto.h + src/bt_rpc.cpp), and its only flashing path is USB serial.
  • A unit still running 1.4.x or older keeps all of that: Wi-Fi, the portal, the REST API, POST /api/ota, and no Bluetooth at all (the GATT server landed after those releases). The Android app cannot reach such a unit at all — it speaks GATT and nothing else — so those units are configured and flashed from their own portal or over the wire.

So whenever a sentence says "in the Web UI…", "post to /api/…", "open 192.168.4.1" or "the unit fetches…", read it as on a Wi-Fi firmware (≤ 1.4.x). On a current-build unit, the same settings live in the Android app's dashboard-settings slot over Bluetooth, and the rest of that sentence does not apply. Chapters and sections that describe a whole Wi-Fi-only subsystem carry the qualifier in their heading. The deleted sources are in git history (git show 7104bf96^:src/web.cpp, …:src/webui.html) if a Wi-Fi-era unit ever has to be understood in detail.

Technical Table of Contents

  1. Executive Overview
  2. System Architecture & Task Allocation
  3. Hardware Component Specifications
  4. Hardware Pinout Matrix
  5. Software Architecture & Mathematical Models
  6. The BLE Link: GATT, Framing and RPC Methods
  7. NVS Configuration Parameter Reference
  8. Android Companion App
  1. Codebase Architecture & File Map
  2. Build, Installation & Flashing Guide
  3. Simulation & Demo Mode
  4. License & Credits

Executive Overview

Dashboard++ replaces legacy analog or basic digital gauges with an automotive-grade telemetry console. Key capabilities include:

  • Dual Speed Fusion (Hall Effect + GNSS): Dynamically fuses microsecond-level hardware interrupt pulses with NMEA GPS speed vectors, adjusting confidence based on satellite lock quality ($N_{\text{sat}}$) and cross-checking delta errors.
  • Sprite-Based Anti-Aliased Speed Rendering: The main speed readout is pre-rendered to an off-screen LGFX_Sprite using a 120px VLW 7-segment digital font, then pushed to the display in a single DMA transfer — reducing SPI bus contention and eliminating per-digit draw calls.
  • PROGMEM VLW Font System: All fonts (Conthrax SemiBold and DS-DIGIT variants) are compiled into flash as PROGMEM byte arrays (generated from the .vlw sources in data/Fonts/ by scripts/vlw_to_header.py), loaded at build time via loadVLWFont() — zero DRAM per glyph, no runtime filesystem lookups.
  • Dirty-Rendering Frame Pipeline: Element state tracking ensures only mutated visual regions are drawn to the SPI bus, with dedicated per-element refresh rate throttles for speed, satellite count, timer, battery, fuel economy, and average speed.
  • Configurable 7-Segment Digital Fonts: 120px (sprite) and 28px (direct) seven-segment fonts with background "ghost digit" rendering (888 backdrop effect) and fully user-configurable integer and decimal digit boundaries for all telemetry counters.
  • Full Sensor Suite Integration: Precise fuel level monitoring (20-point piecewise linear calibration table + EMA filtering), engine coolant thermistor telemetry (Steinhart-Hart equation), battery voltage divider monitoring, trip fuel economy tracking, trip average speed, and a session max speed (Vmax) readout display.
  • FreeRTOS Dual-Core Multitasking: Strict core isolation — the continuous ~1.1KB/s GNSS UART stream is quarantined in its own Core 0 task (bulk ring-buffer read, bounded per tick, sitting below the BLE stack), while the vehicle sensor suite and UI rendering run on Core 1, so a GPS stream backlog can never stall the dashboard or freeze any real-time value.
  • Bluetooth LE link to the companion app (all builds from this tree): one GATT service with three characteristics — telemetry (notify, the same 1 Hz snapshot JSON), RPC (write, the JSON request/response methods that replaced every REST endpoint, including NVS backup as a config.get chunk stream and restore as config.set), and OTA (write/notify, the firmware image path — see The BLE Link). Pairing is LE Secure Connections with a 6-digit passkey shown full-screen on the panel; every characteristic demands an encrypted, bonded link, and a factory reset clears the bonds. The panel's weather widget is fed by the phone (the unit has no radio to fetch it with), as is the clock (time.set once per link-up).
  • Hysteresis-Based Dynamic CPU Scaling: Three-state frequency governor (240/160/80 MHz) with hysteresis deadbands prevents oscillation, and thermal throttling automatically caps frequency at configurable warning/critical temperature thresholds.

System Architecture & Task Allocation

The system leverages the ESP32's Xtensa dual-core processor via FreeRTOS tasks to guarantee deterministic sensor sampling without visual stuttering or UI delays.

text
┌─────────────────────────────────────────────────────────────────────────────────┐
│                             ESP32 WROOM-32 DUAL-CORE                            │
├────────────────────────────────────────┼────────────────────────────────────────┤
│    CORE 0  (GNSS Stream & BLE Link)    │  CORE 1  (Vehicle Sensors & Display)   │
├────────────────────────────────────────┼────────────────────────────────────────┤
│┌──────────────────────────────────────┐│┌──────────────────────────────────────┐│
││ Task: GpsTaskCore0 (Prio 2, 4KB)     │││ Task: SensorTaskCore1 (Prio 2, 4KB)  ││
││                                      │││                                      ││
││• UART2 @ 115200 bulk ring drain      │││• Hall Sensor GPIO33 ISR              ││
││  (readBytes() batch, 1024 B cap)     │││• ADC: Fuel 32, Temp 36, Bat 35       ││
││• TinyGPS++ NMEA + UBX NAV-PVT        │││• Fuel Economy & Accel Timer          ││
││• Speed Fusion & Odometer calc        │││• Trip Average Speed                  ││
││• GNSS epoch -> settimeofday          │││• Safe sync via g_stateMutex          ││
││• Safe sync via g_stateMutex          │││                                      ││
││                                      │││                                      ││
││──────────────────────────────────────│││──────────────────────────────────────││
││ Task: BtTaskCore0 (Prio 1, 8KB)      │││ Main Loop (Priority 1)               ││
││                                      │││                                      ││
││• BLE GATT server (DPP-#### adv)      │││• Dirty-Rendering Frame Pipeline      ││
││• LE SC pairing, passkey on panel     │││• LovyanGFX SPI Driver (60 MHz)       ││
││• Frame reassembly / fragmenting      │││• Sprite-Based Speed (120px VLW)      ││
││• RPC methods, OTA image path         │││• Hysteresis CPU 80/160/240 MHz       ││
││• 1 Hz telemetry pump (TELEM)         │││• Auto Night-Mode Backlight PWM       ││
││ • OTA frames ride the OTA char.      │││• Ignition Deep Sleep State Mach.     ││

[!NOTE] The GNSS task sits on Core 0 below the BLE stack on purpose: the module streams ~1.1KB/s continuously and the drain is I/O-gated at the byte arrival rate, so it can never out-run the stream. GPS is 1Hz-tolerant data, so if the radio preempts the drain, only GPS values lag — the vehicle sensors and the screen are never touched. btTask is the one task that can be given work in bulk (a config save, an image write), and it runs at priority 1 so the two sensor tasks outrank it.

[!NOTE] There is no weather fetch task. The unit has no radio, so the panel's weather widget is fed from the phone: the app sends weather.set over the link (BT_F_WEATHER on the RPC characteristic) with temperature, humidity, wind, sunrise/sunset as epoch seconds (converted to HH:MM through TZ_OFFSET_HOURS + TZ_DST_RULE), the city label and a valid flag, and the result lands in g_weatherData. Nothing on the unit asks for it, retries it or caches an HTTP response — a unit that has never been told shows the widget off (or its last received values until they go stale). The same applies to the clock: the app pushes time.set once per link-up, and a GNSS fix sets it too.

[!NOTE] Every FreeRTOS task is started through startTask() in src/main.cpp, which checks the xTaskCreatePinnedToCore() return value, retries once on half the requested stack (a reduced stack usually fits and beats a missing subsystem) and, if that fails too, records the task in a failedTasksMask and logs it with the free heap. A task that cannot start is therefore named in the boot log instead of silently missing — the failure that used to show up only as a frozen heartbeat, a dead link or dead sensors.


Hardware Component Specifications

ComponentPart / ModelProtocol / SignalSpecifications
MicrocontrollerESP32-WROOM-32Xtensa 32-bit LX6Dual-core 240 MHz, 520 KB SRAM, 4 MB SPI Flash, RTC IO
Display PanelILI9488 TFT LCD (4.0")SPI3 (16-bit RGB565) — SCLK GPIO18, MOSI GPIO23, CS GPIO5, DC GPIO27, RST GPIO14 (no MISO)480×320 pixels, 60 MHz SPI bus speed (clamped to 1–80 MHz), hardware CS/DC/RST
Display BacklightLED Backlight (BL_DISPLAY, GPIO12)LEDC PWM — pin-based ledcAttach(BL_DISPLAY, BL_PWM_HZ, 8)hardware PWM at BL_PWM_HZ (default 12 kHz — above the audible range — configurable 1–40 kHz), 8-bit resolution (256 brightness levels), logarithmic fading; the LEDC channel is assigned by the arduino-esp32 3.x wrapper
GNSS ModuleBZGNSS P25 Pro (u-blox M10)UART2 (RX=GPIO16, TX=GPIO17)115200 baud (configurable), UBX NAV-PVT / NMEA 0183 stream (module is preconfigured — the firmware is receive-only), 10 Hz update rate, multi-constellation (GPS/GLONASS/BDS/Galileo), UTC epoch time synchronization
Wheel Speed SensorHall Effect InterruptGPIO33 (Input Pullup)Any-edge ISR stamps the edges; interval/filter logic runs on the sensor task, microsecond timing
Fuel Level SensorMarine Resistive Sender (capacitive touch removed in v1.3.6)GPIO32 (ADC1_CH4)Voltage divider behind an excitation resistor, ohm-domain calibration table (up to 20 points), oversampling + EMA smoothing, open/short detection
Engine Temp SensorNTC Thermistor (10k/100k)GPIO36 (ADC1_CH0)Analog 0–3.3V, Steinhart-Hart equation, voltage divider balance
Battery VoltageVoltage Divider (5.7:1)GPIO35 (ADC1_CH7)Analog 0–3.3V, range 0–18.8V DC, sampled every 500 ms
Power / IgnitionIgnition Key Sense LineGPIO4High=Ignition ON, Low=Power Lost → Animated Deep Sleep

Hardware Pinout Matrix

For the board side of the same information — which connector/plug each signal lives on, with the connector pin numbers and the small-part roles — see hardware/PINOUT.md.

ESP32 PinFunction NamePeripheral TypeSignal DirectionHardware Configuration & Notes
GPIO0BOOTFactory ResetInput (Pullup)pinMode(0, INPUT_PULLUP); hold BOOT 4 s, release, hold 4 s again within the first 30 s after boot to wipe the config — the reliable path. The release in the middle is required because GPIO0 is shared with the serial adapter's DTR line through the auto-reset transistor: a PC that holds DTR asserted keeps GPIO0 low forever, and the old single 8 s hold wiped the config on every boot with nobody touching the board. The serial fallback (send RESET within the first 2 s of boot) is matched against a rolling 32-character window, so boot-log echo can no longer fill the capture and hide the command (issue #39)
GPIO1Console TXDebug ConsoleOutputSerial.setPins(1, 3) pins the console explicitly (arduino-esp32 3.x moved the UART1 default to GPIO26/27)
GPIO3Console RXDebug ConsoleInputSame Serial.setPins(1, 3) call
GPIO4POWER_SENSE_PINPower SenseInput (No Pull)Ignition sense line; triggers EXT0 RTC wake up from deep sleep
GPIO5CS_DISPLAYSPI Chip SelectOutputHardware SPI CS for ILI9488 Display
GPIO12BL_DISPLAYBacklight PWMOutputPin-based ledcAttach(BL_DISPLAY, BL_PWM_HZ, 8) — frequency is the BL_PWM_HZ knob (default 12 kHz, above hearing), 8-bit resolution
GPIO14SPI_RSTDisplay ResetOutputActive-Low hardware reset line for ILI9488
GPIO16GNSS_UART2_RX_PINGPS Serial RX (UART2)InputConnected to the GNSS module TX pin. Receive-only: the firmware never transmits to the module
GPIO17GNSS_UART2_TX_PINGPS Serial TX (UART2)OutputConnected to the GNSS module RX pin; configured but idle - no UBX command is ever sent
GPIO18cfg.pin_sclkSPI ClockOutputHardcoded in gfx.cpp (SPI3_HOST), 60 MHz default
GPIO23cfg.pin_mosiSPI Master OutOutputHardcoded in gfx.cpp, LCD data/command stream
GPIO25TRIP_RESET_PINTrip Reset ButtonInput (Pullup)Momentary push button to GND; hold TRIP_RESET_HOLD_MS to zero the trip stats (issue #17). Internal ~45 kΩ pull-up, no external parts
GPIO27SPI_DCData / CommandOutputHigh = Data, Low = Command for ILI9488 controller
GPIO32FUEL_TOUCH_PINFuel sender inputInputDedicated ADC1 Channel 4 pin: 3V3 → FUEL_EXC_RES_OHM → GPIO32 → sender → tank ground (capacitive touch removed in v1.3.6)
GPIO33HALL_SENSOR_PINHall InterruptInput (Pullup)Any-edge (CHANGE) hardware interrupt: the ISR timestamps both transitions, the filter chain validates them on the sensor task
GPIO34LIGHT_SENSOR_PINAmbient LightInput (No Pull)LDR ambient light sensor for auto-brightness (calibrated via /api/ambient/cal-dark / cal-bright)
GPIO35BATTERY_SENSE_PINBattery ADCInput (No Pull)Connected to 5.7:1 precision resistor divider node
GPIO36TEMP_SENSE_PINEngine Temp ADCInput (No Pull)Connected to NTC thermistor / balance resistor divider node

[!IMPORTANT] GPIO32 is dedicated to the fuel ADC input to avoid pin-sharing conflicts with the GPIO33 Hall interrupt hardware line.

[!NOTE] This matrix matches the code (src/dashboard.h pin defines and the hardcoded display SPI pins in src/gfx.cpp), which is the source of truth. The GNSS serial pair moved from GPIO25/26 to GPIO16/17 during the pin-swap test and stayed there, which is what freed GPIO25 for the trip-reset button; GPIO26 stays free as the spare for a second button. Strapping pins (GPIO0, GPIO2, GPIO5 = CS_DISPLAY, MTDI/GPIO12, MTDO/GPIO15) are deliberately not used for buttons — a button to GND can hold them the wrong way during reset and block boot. The display SPI bus has no MISO line (cfg.pin_miso = -1), and GPIO21/22 (former compass I²C) are unused.


Software Architecture & Mathematical Models

1. Dual-Source Speed Fusion & Odometer Engine

Dashboard++ features a dual-source speed calculation engine that combines low-latency wheel rotation timing with absolute satellite GPS telemetry.

Hall Sensor Calculation & 5-Layer Defense Pipeline

Speed is derived from microsecond timing between consecutive interrupt pulses on GPIO33, protected by an end-to-end 5-layer defense pipeline that eliminates spark ignition EMI glitches, prevents baseline collapse during deceleration, and bounds rate-of-change to physical limits:

  1. Layer 1: Measured Pulse-Width Qualification (Glitch Filter): Real wheel magnets hold GPIO33 LOW for hundreds of microseconds (one revolution at 200 km/h is still 29.7 ms long). High-frequency ignition coil ringing collapses back to HIGH within <10 µs. The GPIO ISR used to prove the width by busy-waiting HALL_PULSE_MIN_US and re-sampling REG_READ(GPIO_IN1_REG) inside the interrupt, which masked interrupts on that core for up to 1 ms per glitch (~86 bytes of GNSS RX at 115200 baud). Now the interrupt is CHANGE (both edges), the ISR only stamps the transition time into a 64-slot ring, and the qualification is measured: rise − fall ≥ HALL_PULSE_MIN_US (default 150 µs, WebUI-tunable, clamped to 10…1000 µs). A glitch is dropped before any interval logic runs, exactly as before, and the ISR stays at roughly a microsecond. Ring overflow drops the newest edge and is counted as drop= in the HALL: telemetry line; the accept latency is reported as lag=.
  2. Layer 2: Anti-Collapse Timing Baseline (Integer Guard) + Period Guard: 100% integer timestamping and period guard with zero floating-point math, in the validation step that runs on the sensor task (processHallEdges()). When actively rolling, candidate intervals faster than $2\times$ previous speed ($gap \times 2 < last$) are dropped without updating lastHallPulseTimeUs or the stable baseline hallStableIntervalUs. This prevents spark bursts during braking from collapsing the guard baseline and producing >200 km/h spikes. The complementary upper bound is HALL_PERIOD_GUARD: an interval more than $N\times$ the last accepted one is dropped too (a wheel cannot slow $N$-fold in a single revolution; a genuine stop is handled by Layer 3 instead). $N = 1$ disables it.
  3. Layer 3: Standstill Detection, Buffer Purge & Deceleration Decay: If time since the last pulse exceeds STANDSTILL_TIMEOUT_US (1.5 s), the wheel was stopped. The first pulse synchronizes the timing baseline, holds its odometer credit until a confirming pulse follows, and completely purges the history ring buffer. During deceleration, if elapsed time $dt$ since the last pulse exceeds $1.5\times$ the rotation period (plus the recorded accept latency lag, so deferred validation cannot make an on-time pulse look overdue), dynamic decay constrains speed smoothly to 0 km/h without mid-revolution diving.
    • Layer 3b: Confirmed-Roll Speed Gate: the purge in Layer 3 also disarms Layer 2 (which is conditioned on the rolling state) and leaves the median window with a single sample, so before this gate one accepted inter-edge gap became the displayed speed — two ignition-EMI edges 12 ms…1.5 s apart on a parked machine reported a plausible reading (100 ms → 59 km/h on a 1650 mm wheel). The speed output now stays at 0 until HALL_CONFIRM_REVS (3) consecutive accepted intervals agree pairwise within $2\times$ — the same physical bound Layer 2 uses while rolling. Cost: a genuine roll start is displayed two revolutions later (≈0.6 s at 12 km/h, ≈0.2 s at 30 km/h). Distance credits are unaffected (the odometer is guarded by the pending-anchor logic). Below ~4 km/h the 1.5 s standstill timeout already reports 0, so the usable low-speed floor does not change.
  4. Layer 4: Physical Slew-Rate Limiter (Max Accel / Decel): Terrestrial motorcycles cannot exceed physical acceleration limits (~1.7g / 60 km/h/s) or emergency braking (~2.3g / 80 km/h/s). Clamping $\Delta V$ between update ticks (~20 ms) mathematically prevents instantaneous multi-hundred km/h jumps on the speedometer.
  5. Layer 5: Sensor Fusion Outlier Rejection: In Sensor Fusion mode (2), if Hall speed reads $> V_{\text{gps}} + 30\text{ km/h}$ while GPS has a solid satellite lock, the Hall reading is rejected as an anomalous spike and the dashboard displays GPS speed.
Speed Source & Fusion Logic

The displayed speed source is selected by SPEED_SOURCE_MODE: 0=Hall only, 1=GPS only, 2=Sensor Fusion (default).

In Sensor Fusion mode (2), the engine dynamically balances both sensors:

  • Rock-Solid Standstill: When $V_{\text{hall}} = 0$ and $V_{\text{gps}} < \text{GPS_START_KMH}$ (3.0 km/h), speed is forced to solid 0.0 km/h, eliminating GPS drift at traffic lights.
  • Immediate Response: As soon as wheel pulses arrive ($V_{\text{hall}} > 0$), speed is displayed immediately without clamping.
  • Dynamic Throttle Tracking: When $|V_{\text{gps}} - V_{\text{hall}}| > \text{MAX_SPEED_DELTA_KMH}$ (such as normal GPS lag during rapid acceleration), Hall is trusted directly so throttle response is never suppressed.
  • Dual Failsafe Redundancy: If the Hall sensor fails or disconnects at speed, the system automatically falls back to valid GPS ($V_{\text{gps}} \ge 3.0\text{ km/h}$). If GPS signal is lost in a tunnel, Hall sensor carries 100% of the speed. Speed never drops to 0 while moving.
  • Dynamic Confidence Blending: When both sensors are healthy and consistent ($\Delta V \le \text{MAX_SPEED_DELTA_KMH}$): $$C_{\text{sat}} = \text{constrain}\left(\frac{N_{\text{sat}} - N_{\text{min}} + 1}{N_{\text{opt}} - N_{\text{min}} + 1}, 0.0, 1.0\right)$$ $$C_{\delta} = \text{constrain}\left(1.0 - \frac{\Delta V - \Delta V_{\text{min}}}{\Delta V_{\text{max}} - \Delta V_{\text{min}}}, 0.0, 1.0\right)$$ $$W_{\text{gps}} = C_{\text{sat}} \cdot C_{\delta} \cdot 0.5$$ $$V_{\text{fused}} = (W_{\text{gps}} \cdot V_{\text{gps}}) + ((1 - W_{\text{gps}}) \cdot V_{\text{hall}})$$ Capping $W_{\text{gps}}$ at 0.5 ensures Hall sensor's zero-latency dynamic throttle/brake response is always preserved, while GPS anchors long-term tire wear accuracy.
Odometer Persistence Strategy

To protect the ESP32 NVS Flash memory from wear, distance accumulation runs continuously in RAM. The odometer writes to non-volatile storage only after accumulating a full 1.0 km increment: $$\Delta D_{\text{ram}} \ge 1.0\text{ km} \implies \text{Preferences.putDouble("odo", } D_{\text{total}}\text{)}$$

Sharing the odometer across cores. The odometer is a 64-bit double, and a 64-bit load/store on ESP32 is two 32-bit bus cycles: a read that lands between the two halves of a write returns a mixture of two different distances. The totals therefore live file-local in src/sensors.cpp and every other task reaches them through the locked accessors odoGet(), odoSet(), odoAdd() (read-modify-write in one critical section), odoLastSaved() and odoMarkSaved(), guarded by a dedicated spinlock (odoMux) — deliberately not g_stateMutex, so the display never waits behind sensor or NVS work and the critical sections stay a few instructions long. The accessors never write flash; the 1 km wear discipline above is unchanged.

UBX Frame Parser (receive-only)

The module streams UBX and NMEA on the same UART, so src/sensors.cpp runs a byte-at-a-time state machine (ubxParseByte) alongside TinyGPS++: it locks onto 0xB5 0x62, reads class/id/length, seeds both Fletcher-8 accumulators by stepping through each header byte individually, and hands a checksum-valid NAV-PVT to the NMEA side. It never transmits anything (see the receive-only rule).

  • Length is honoured, including zero. A zero-length payload goes straight to the checksum states — entering the payload state with len = 0 used to consume CK_A/CK_B as payload, corrupt the accumulators and raise a spurious CKFAIL, leaving the parser to resync by luck on the next 0xB5 (issue #33).
  • Oversize frames are dropped at the header (len > 92) rather than overflowing the payload buffer, and a bad checksum drops exactly one frame before resyncing.
  • Counters, not silence: ubxSyncSeen, ubxCkFail, ubxOversize are reported in the GPS debug telemetry so a framing problem is visible instead of looking like lost satellites.
  • Checked offline: python scripts/verify_ubx_parser.py runs a host replica of the state machine against hand-built frames — NAV-PVT (92 B), ACK (10 B), zero-length, poisoned checksum, garbage/false-sync, oversize, back-to-back frames — and asserts that the old zero-length path still fails, so the regression cannot come back unnoticed.

2. Anti-Aliased GFX Engine & 7-Segment Fonts

Graphics rendering is built on LovyanGFX with specialized antialiasing routines and a PROGMEM-mapped VLW font system (compiled into flash — no runtime filesystem lookups, zero DRAM per glyph).

Text Bounds Contract (getTextBounds)

LGFX_ST7789_4::getTextBounds() (declared in src/dashboard.h, defined in src/gfx.cpp) reports advance-based bounds, not ink bounds: w is textWidth() (the advance the string consumes), h is the font line height, y1 is -baseline, and x1 is always 0 by contract (issue #44). Every layout path — 7-segment digit cells, the weather/accel badges, the centred splash and signature strings — places text on advances, and the - x1 terms those expressions carry are the documented slot where a real per-glyph left bearing would flow through, not a live correction. Code must not treat x1 as a measured bearing; if ink bounds are ever needed, a separate helper gets added rather than these semantics changing. The digitXOff[] / ds15_digitXOff[] tables fed from it are therefore advance-only offsets by construction.

Alpha Blending Math

Sub-pixel anti-aliased primitives draw edges with fractional coverage $\alpha \in [0, 1]$. Color blending uses fast integer arithmetic: $$R_{\text{out}} = \lfloor (R_{\text{bg}} \cdot (256 - a) + R_{\text{fg}} \cdot a) \gg 8 \rfloor$$ $$G_{\text{out}} = \lfloor (G_{\text{bg}} \cdot (256 - a) + G_{\text{fg}} \cdot a) \gg 8 \rfloor$$ $$B_{\text{out}} = \lfloor (B_{\text{bg}} \cdot (256 - a) + B_{\text{fg}} \cdot a) \gg 8 \rfloor$$

Sprite-Based Speed Rendering

The main speed display is rendered to an off-screen LGFX_Sprite using the VLW 120px 7-segment digital font. Ghost digits (888 backdrop) and active digits are drawn once per frame into the sprite, then pushed to the display in a single DMA transfer — drastically reducing SPI bus transactions compared to per-digit draw calls.

VLW Font System

All typography is compiled into flash as PROGMEM byte arrays (include/*.h, generated from the .vlw sources in data/Fonts/ by scripts/vlw_to_header.py):

  • DS-DIGIT_120px.vlw → DS_DIGIT_120px_vlw.h — Main speed readout (sprite-rendered)
  • DS-DIGIT_28px.vlw → DS_DIGIT_28px_vlw.h — Secondary digits (time, odometer, satellite, battery, fuel economy, average speed)
  • Conthrax_SemiBold_28px.vlw → Conthrax_SemiBold_28px_vlw.h — Section headers and badges
  • Conthrax_SemiBold_16px.vlw → Conthrax_SemiBold_16px_vlw.h — Unit labels and sidebar text
  • Conthrax_SemiBold_10px.vlw → Conthrax_SemiBold_10px_vlw.h — Micro labels (AVG badge, IST badge)

Legacy compiled-in GFX font bitmaps (Conthrax_SemiBold4pt7b, Conthrax_SemiBold7pt7b) are retained as fallbacks for the badge/label sizes.


3. Marine Resistive Fuel Sender: Divider, Ohm-Domain Table & Filtering

Fuel level is read from a standard marine resistive sender wired as a voltage divider on FUEL_TOUCH_PIN (GPIO32). The capacitive pad this input used to carry is gone; the pin is now a plain ADC input (issue #18):

text
3V3 ─── FUEL_EXC_RES_OHM ─── GPIO32 (ADC1_CH4) ─── sender ─── tank ground
From ADC code to ohms

FUEL_OVERSAMPLE conversions are averaged into a raw code $\bar{S}$ (software oversampling — the core exposes no hardware averaging for this pin), turned into a voltage with FUEL_ADC_VREF (the 3.3 V rail — since issue #55 it is not a Web UI field any more), and the sender resistance is recovered from the divider:

$$V = V_{\text{ref}} \cdot \frac{R_{\text{sender}}}{R_{\text{sender}} + R_{\text{exc}}} \qquad\Rightarrow\qquad R_{\text{sender}} = R_{\text{exc}} \cdot \frac{V}{V_{\text{ref}} - V}$$

The calibration table (fuelCalOhms) stores ohms, not ADC codes. That is what makes any sender standard work on the same firmware — SAE 10–180 Ω (low = empty), European/VDO 240–33 Ω (high = empty), GM 0–90 Ω — and it means a captured table still means the same thing after the excitation resistor (or the sender) is swapped. FUEL_OHM_EMPTY / FUEL_OHM_FULL describe the sender: they size the exciter, define the fault band, and pre-fill the table ramp; they do not replace the table.

EMA + piecewise interpolation

Smoothing stays in the ADC-code domain (linear, and the same domain the table was captured against), then the ohm value walks the table: $$\bar{S}t = (\alpha \cdot S_t) + ((1 - \alpha) \cdot \bar{S}{t-1}) \qquad \alpha = \texttt{FUEL_FILTER_ALPHA}\ (default{=}0.08)$$ Given $N$ table points $T[0 \dots N-1]$ mapped to tank slots $0 \dots N-1$, find segment $i$ containing $R_{\text{sender}}$: $$L_{\text{fuel}} = i + \left(\frac{R_{\text{sender}} - T[i]}{T[i+1] - T[i]}\right)$$ $$\text{FuelPercentage} = \text{constrain}\left(\left\lfloor \frac{L_{\text{fuel}}}{N - 1} \times 100 \right\rfloor, 0, 100\right)$$

The traversal direction is taken from the table's two ends, so a rising (SAE) and a falling (European) sender are both handled without a mode switch.

Sender fault detection

The failure that makes a self-built sender useless is an open line that still looks like a plausible reading, so the input is classified every sample and shown in the app's settings page instead of being turned into a tank level:

ConditionReported as
FUEL_INPUT_ENABLED offno input — gauge pinned at 0, a floating pin never invents a level
pin within 0.5 % of the rail, or $R > 3 \times$ the entered full/empty maximumopen circuit — sender unplugged, wire broken, arm off the pivot
$R$ below min(EMPTY, FULL) by more than max(5 Ω, 25 %)shorted input — signal wire on ground

Demo mode drives the same pipeline: the simulated level is converted back through the calibration table into a divider code and read forward again, so the table, divider math and fault band are exercised with no hardware fitted (rule 20).

Excitation resistor sizing

$R_{\text{exc}}$ is a trade: the geometric mean of the sender range spreads the span over the most volts, but pushes more current through the sender element. The app suggests the best E12 value for the entered range under a 15 mA sender current budget and keeps the top of the span below 2.6 V, because a reading pinned near the rail is indistinguishable from an open circuit. For the shipped 10–180 Ω default that suggestion is 220 Ω — the same value the firmware ships with — giving a 1.34 V span at 14.3 mA.

Check the whole chain on a host with python scripts/verify_fuel_ohms.py (divider round-trip, both conventions, resolution per tank slot, demo round-trip, fault band, exciter suggestion).


4. Steinhart-Hart Coolant Temperature Math

Engine temperature is measured via an NTC thermistor connected in a resistor divider configuration with a balance resistor $R_{\text{balance}}$ (default=10,000 $\Omega$).

Voltage Divider Resistance Calculation

$$V_{\text{out}} = \text{ADC}{\text{raw}} \times \left(\frac{3.3}{4095}\right)$$ $$R{\text{ntc}} = R_{\text{balance}} \times \left(\frac{V_{\text{out}}}{3.3 - V_{\text{out}}}\right)$$

Steinhart-Hart B-Parameter Equation

$$T_{\text{Kelvin}} = \left( \frac{1}{\beta} \cdot \ln\left(\frac{R_{\text{ntc}}}{R_{\text{room}}}\right) + \frac{1}{T_{\text{room}}} \right)^{-1}$$ $$T_{\text{Celsius}} = T_{\text{Kelvin}} - 273.15$$ where $R_{\text{room}} =$ NTC_R25 (default=10,000 $\Omega$), $T_{\text{room}} = 298.15\text{ K}$ ($25^\circ\text{C}$), and $\beta =$ NTC_BETA (default=3950). A fixed NTC_TEMP_OFFSET (°C, default=0.0) is added to the result to correct a systematic reading bias.

Sensor Calibration in the App

The Sensors Tuning cards on the app's dashboard-settings page calibrate the two analog sensors against a reference, reading the 1 Hz frames the unit already pushes on its telemetry characteristic (live / snapshot — no raw-ADC conversion needed). On a ≤ 1.4.x unit the same cards were in the portal and polled GET /api/live:

  • Fuel level Sensor — the live sender resistance (Ω) is shown next to a fault state (open / shorted / out of range / no sender). Two ways to build the table (fuelCalOhms): Fill from empty/full ohm pre-fills a linear ramp from FUEL_OHM_EMPTY to FUEL_OHM_FULL (a good start, no driving needed), or drive the tank through its levels and Capture the live reading into each slot — slot 0 = empty, slot N-1 = full. Changing the point count regenerates the ramp. The card is titled without the pin number (issue #55), and the excitation-resistor suggestion is computed from the empty/full ohms on the page plus the fixed 3.3 V rail.
  • Speedometer — SPEED_SOURCE_MODE, SPEED_SOURCE_HOLD_MS, the two hall-vs-GPS deviation bands (GPS_MIN_DEV_KMH, MAX_SPEED_DELTA_KMH), the GPS start/stop pair (GPS_START_KMH, GPS_STOP_SETTLE_MS) and the wheel-signal filters (HALL_MEDIAN_SAMPLES, HALL_PERIOD_GUARD, HALL_PULSE_MIN_US), in that order (issue #56). The GNSS hardware rows (baud, satellite counts) stay in the GPS block.
  • Engine Temperature — the live reading is shown; type a reference temperature and press Apply to set NTC_TEMP_OFFSET. NTC_R25, NTC_R_BALANCE and NTC_BETA are under Advanced.
  • Battery Voltage — the live reading is shown; type a reference voltage (e.g. a multimeter) and press Apply to set BATTERY_OFFSET. BATTERY_SCALE (divider ratio) is under Advanced.
  • Calibrating against a live reading means no raw-ADC arithmetic in the browser: the offset is derived on the device from the value it is currently seeing.

5. Fuel Economy & Performance Drag Timer

Instantaneous & Trip Fuel Economy
  • Trip Average (KM/L): $KM/L_{\text{avg}} = \frac{D_{\text{trip}}}{C_{\text{consumed}}}$. The trip is zeroed only by an explicit trip reset — the physical button on GPIO25, POST /api/trip/reset, or a factory reset — never by a detected refuel (issue #17). A fuel-level rise still re-anchors the consumption baseline so the average stays sane, but it leaves the trip distance and the averages alone.
  • Instantaneous (KM/L): Calculated across a 3-second sliding window: $$KM/L_{\text{inst}} = 0.4 \cdot \left(\frac{\Delta D_{3\text{s}}}{\Delta C_{3\text{s}}}\right) + 0.6 \cdot KM/L_{\text{inst, prev}}$$
  • Trip Average Speed (KM/H): $V_{\text{avg}} = \frac{D_{\text{trip}}}{t_{\text{elapsed}}}$, displayed in the bottom row alongside fuel economy readouts.
  • Session Max Speed (Vmax): The highest fused/filtered speed reached this power-on session; held on screen until the next reboot (RAM-only, no NVS persistence, no manual reset). Rendered as the MAX icon in the bottom row — same 28px 7-segment structure as the Instant KM/L readout — in MPH under imperial. Controlled by SHOW_ELEMENT_MAX_SPEED, OFFSET_MAX_SPEED_X/Y, ALIGN_MAX_SPEED, MAX_SPEED_INT_DIGITS, MAX_SPEED_DEC_DIGITS, and the REFRESH_MAX_SPEED_MS redraw throttle.
Acceleration Performance Timer

Measures time taken to accelerate between configured speed thresholds (ACCEL_START_SPEED to ACCEL_TARGET_SPEED, e.g., 0–50 km/h or 0–100 km/h) using a 3-state state machine with configurable timeout (ACCEL_MAX_TIME, default=30s):

text
  ┌─────────┐    Speed >= StartSpeed     ┌─────────┐
  │  READY  │ ─────────────────────────> │ RUNNING │
  └─────────┘                            └─────────┘
       ▲                                      │
       │ Speed < Start (Reset)                │ Speed >= TargetSpeed OR Timeout
       └──────────────────────────────────────┼───────────────────────┐
                                               ▼                       │
                                         ┌──────────┐                  │
                                         │ FINISHED │ <────────────────┘
                                         └──────────┘

6. Power Management & Dynamic CPU Scaling

To balance processing performance, power consumption, and thermal stability, Dashboard++ includes dynamic CPU management and ignition sensing.

Hysteresis Dynamic Scaling Matrix

The governor uses a 3-state machine with hysteresis deadbands to prevent frequency oscillation:

text
                      ┌──────────────────────────────┐
                      │ Read Current Measured FPS &  │
                      │ Die Temp (temperatureRead()) │
                      └──────────────┬───────────────┘
                                     │
                     Is ENABLE_DYNAMIC_CPU true?
                     ├─── NO  ──> Set MANUAL_CPU_FREQ (80 / 160 / 240 MHz)
                     └─── YES ──> Check current state:
                                    ├── State = 240 MHz:
                                    │    └── FPS > 85% Target → 160 MHz
                                    │    └── else → stay at 240 MHz
                                    ├── State = 160 MHz:
                                    │    ├── FPS < 65% Target → 240 MHz
                                    │    ├── FPS > 95% Target → 80 MHz
                                    │    └── else → stay at 160 MHz
                                    └── State = 80 MHz:
                                         ├── FPS < 45% Target → 240 MHz
                                         ├── FPS < 70% Target → 160 MHz
                                         └── else → stay at 80 MHz
                                    │
                                    └── Apply Thermal Throttling:
                                          ├── Temp >= CRIT (70°C): Force 80 MHz
                                          └── Temp >= WARN (60°C): Cap at 160 MHz
Ignition Loss & Deep Sleep Transition

When ENABLE_POWER_SENSE is enabled and POWER_SENSE_PIN (GPIO4) stays continuously LOW for POWER_SENSE_OFF_MS (default 10 s, 500–120000 ms):

  1. Plays goodbye screen animation (showGoodbyeScreen(true)).
  2. Fades display backlight down to 0% via LEDC PWM.
  3. Configures RTC EXT0 wake-up trigger on GPIO4 (High level).
  4. Invokes esp_deep_sleep_start().

The line is debounced in software (powerSenseOffConfirmed() in src/sensors.cpp): the timer restarts on every HIGH, so ignition bounce, a load-dump dip or a flaky ground of a few milliseconds up to several seconds cannot stall the GPS/odometer task or put the unit to sleep (issue #26). A single LOW sample used to be enough. Side effect: key-off costs POWER_SENSE_OFF_MS of live current draw before the goodbye screen and sleep; wake behaviour is unchanged (EXT0 on GPIO4 going HIGH).


The BLE Link: GATT, Framing and RPC Methods

[!IMPORTANT] This is the only door into a current-build unit. Everything a phone can do to a unit built from this tree travels over one GATT service: settings, live data, calibration, odometer, factory reset, the weather the panel prints, the clock, log reads and firmware. There is no HTTP server, no IP and no hotspot to fall back on. The implementation is src/bt.cpp (profile, pairing, framing, reassembly, the BLE task), src/bt_proto.h (the wire format, shared verbatim with the app) and src/bt_rpc.cpp (the JSON methods).

The GATT profile

ObjectUUIDPropertiesAccess
Serviced9a1f000-6c1e-4b8a-9f2d-3e7c1a5b9d40——
Telemetry (unit → phone)d9a1f001-6c1e-4b8a-9f2d-3e7c1a5b9d40notify (+ CCCD)read/notify encrypted
RPC (both ways)d9a1f002-6c1e-4b8a-9f2d-3e7c1a5b9d40write, write-nr, notify (+ CCCD)ESP_GATT_PERM_WRITE_ENC_MITM
OTA (firmware image)d9a1f003-6c1e-4b8a-9f2d-3e7c1a5b9d40write-nrESP_GATT_PERM_WRITE_ENC_MITM
  • Advertised name DPP-####, the last four hex digits of the unit's eFuse base MAC — the same identity the app binds a garage card to. The name is written explicitly into both the advertisement and the scan response: left to Bluedroid, the primary packet (flags + TX power
    • 128-bit service UUID + name) overflows the 31-byte limit and the name is silently dropped, which is what a Windows scanner reported as "no DPP-* advertising".
  • The CCCD (BLE2902) is mandatory on both notify characteristics. Without it a central has nothing to write to switch notifications on, and every answer the unit sends — responses and config chunks both travel on RPC — is dropped (Windows answers "Write Not Permitted" on subscribe). The RPC characteristic also needs setCallbacks(): without it the ATT layer acknowledges the write and the bytes never reach the reassembler.
  • Pairing is LE Secure Connections with a 6-digit passkey shown full-screen on the panel (Just Works is refused). The bond is what makes the link usable: every characteristic requires an encrypted, MITM-protected write, so an unpaired central can see the service and nothing else. factory.reset clears the bonds, and a unit whose passkey nobody can read (panel dead, backlight off) cannot be paired — that is by design.
  • BtTaskCore0 (Core 0, priority 1, 8 KB stack) owns the radio: Bluedroid's callbacks run on it, request handling runs on it, and the 1 Hz telemetry pump runs on it. Priority 1 is deliberate — the GNSS and vehicle-sensor tasks outrank it, so a config save or an image write can never starve a real reading.

The wire format (src/bt_proto.h)

Every message is an 8-byte header plus a payload; a message larger than one MTU is split into fragments that all carry the same seq, with FIRST on the first piece and LAST on the last. The receiver reassembles by seq and refuses a second open message, so a lost fragment can never grow an unbounded buffer.

text
0  version   1
1  type      see BtFrameType
2  flags     bit0 FIRST, bit1 LAST
3  reserved  must be 0
4  seq       message id, little endian
6  len       payload bytes in this fragment, little endian

Payload per fragment = negotiated MTU − 3 − 8 − 8; at the negotiated MTU of 517 that is 498 bytes (a 506-byte notification). The last 8 bytes are deliberate headroom: a notification whose payload is exactly MTU − 3 is accepted by the unit's BLE stack and then silently lost, so the fragmenter stops short of the ceiling (see the 2026-11-05 changelog entry — this was the whole of the "138 keys the schema knows are missing from the read" report). BT_MAX_MSG (8192 B) caps what the firmware will reassemble in either direction — a full config export is the biggest thing either side sends as a single message.

TypeNameDirection / meaning
1BT_F_REQphone → unit, one JSON request
2BT_F_RESPunit → phone, one complete JSON response
3BT_F_TELEMunit → phone, the 1 Hz snapshot, unprompted
4BT_F_WEATHERphone → unit, the weather the panel shows
5BT_F_TIMEphone → unit, epoch clock set
6/7BT_F_PING / BT_F_PONGlink keepalive
8BT_F_OTAfirmware image frames on the OTA characteristic
9BT_F_LOGunit → phone, serial-ring log lines
10BT_F_CHUNKone slice of a streamed config.get

A config.get answer is streamed as BT_F_CHUNK frames carrying {"id":req,"i":n,"last":bool,"d":{…}} and the stream closes with an ordinary BT_F_RESP carrying {"chunks":n,"keys":n}. It gets its own frame type so the app never has to guess from the shape of a JSON object whether it is looking at a complete answer or one more slice.

The RPC methods

One JSON request per write ({"m":"<name>","p":{…},"id":n}), one BT_F_RESP per answer ({"ok":true,"v":…} / {"ok":false,"e":"<reason>"}), matched back to the request by id. The complete set as implemented in src/bt_rpc.cpp:

MethodWhat it doesReplaces
snapshot, live, sensors, fuel, ambient, noise, perf, health, bootthe read-only surfaces, same JSON bodies as the old GET /api/… endpoints (perf.mac is still the identity the app binds a card to)GET /api/snapshot, /api/live, /api/sensors, /api/fuel, /api/ambient, /api/noisematrix, /api/perf, /api/health, /api/boot
config.getfull NVS export, streamed as BT_F_CHUNK framesGET /api/config (backup download)
config.setapply a config object; a save whose NVS writes partly failed answers with nvsErrors, nvsFailedKeys, nvsAvailablePOST /api/config (restore upload)
config.reset, factory.resetreset the settings / clear settings and bondsPOST /api/reset
odo.get, odo.set, trip.resetodometer read/write, trip zero (same reset as the GPIO25 button)/api/odo, /api/trip/reset
reboot, sleepgraceful restart, immediate deep sleep/api/reboot, /api/sleep
cal.ambient (dark/bright)ambient-light calibration points/api/ambient/cal-dark, -bright
bench.run, noisestart the 4-run ADC noise bench / read its last resultPOST/GET /api/noisematrix
log.getthe 4 KB serial ring, pulled on demandGET /api/serial (which streamed)
weather.set, time.setthe unit has no radio of its own: the phone supplies the weather widget and the clocknothing — the unit used to fetch weather itself and sync NTP; both fetchers are deleted
pingkeepalive—
ota.begin, ota.end, ota.status, ota.abortfirmware install control — implemented in this build (src/bt_ota.cpp; the wire shape is spelled out in Firmware over the link below)POST /api/ota (≤ 1.4.x only)

Firmware over the link — how an install runs in this build

A unit built from this tree can be updated over Bluetooth, by the Android app or by any central that speaks the frames below; the HTTP fallback is gone with the Wi-Fi stack. src/bt_ota.cpp is the install state machine, src/bt.cpp routes the raw OTA-characteristic writes into it (copy into an 8-slot ring on the Bluedroid callback task, flash from BtTask — stalling the GATT callback task stalls the whole stack, including the link the phone is still using), and src/bt_rpc.cpp serves its four methods.

StepOn the wire
OpenRPC ota.begin {"size":N,"crc32":N} → reply {"off":N,"chunk":N}, and the same answer a second time as a begin_ok event. off is where the unit stands — which is what makes an interrupted install resumable, and re-stating the same begin is the resume handshake
DataThe OTA characteristic (d9a1f003-…, write-without-response, encrypted): offset (4 bytes little-endian) + payload, at most chunk bytes per write. chunk is the MTU less the ATT header and the offset prefix, rounded down to 16 and capped at 512 — 506 B at the negotiated MTU 517, so a bigger MTU is a faster install up to MTU 519 and nothing past it
Progress{"ev":"ack","off":N} sent as a BT_F_OTA frame on the RPC characteristic every 4 KiB of accepted data (one flash sector — the same granularity at which the drain pays for an erase, so a resumed install always restarts from a boundary that really is on flash — and never more than half the unit's usable ring, which btOtaBegin prints when it trims it) and at the last byte — the OTA characteristic is write-only, so it has nothing to answer on. The ACK is the resume token: after a drop the phone asks ota.status, gets the offset, and continues from there instead of starting over
WatchRPC ota.status — bytes on flash, declared size, where the unit stands (opening / active / rebooting / failed / off); ota.abort hands the slot back
CloseRPC ota.end. It drains the handoff ring into flash before it counts anything: the app holds one write mutex for the image frames and for this RPC, so the link keeps their order, but btTask picks the RPC up before its next poll pass — an install that received every one of 1,590,912 B answered bad_length at 1,590,176 B, short by exactly the two frames still sitting in the buffer (bench, 2026-11-03). The unit then CRC-32s what it actually received — the polynomial and chaining written out in bt_ota.cpp so the two ends cannot drift, matching java.util.zip.CRC32 and crc32(1) — then esp_ota_end() verifies the image (magic, checksum, its own SHA), and the answer is {"ev":"done","off":N} or {"ev":"error","reason":"…","off":N}

Windows and limits, each one decided on purpose:

  • 120 s of silence closes an install (reason: "stalled"). It was 60 s until 2026-11-02, and on the first bench install after that window was added the unit threw away a healthy transfer (162,192 of 1,590,912 B accepted, and an unusable slot) while the phone sat on its own 60 s resume deadline — the two ends were closing the same door from different rooms. The app carries the same number (OtaController.RESUME_WINDOW_MS) so its wording stays true.
  • A client is allowed to ask for a faster link, and this app does (a7e9f6bb): while the image is flowing the phone calls Android's requestConnectionPriority(CONNECTION_PRIORITY_HIGH) and releases it when the writes stop. Nothing on the unit changes for it, and it is a request rather than a guarantee — the controller gives what it can. Matched runs on the bench: 132.8 s without the request, 76.5 s with it. A client that never asks still finishes; it just waits twice as long.
  • 15 min is the ceiling on a whole install — the escape hatch for a client that opens a session and never sends ota.end.
  • An image must be ≥ 4 KiB (smaller is a truncated download or a text file) and ≤ the app slot (0x1D0000 = 1,900,544 B). That is why the build gate is 1.80 MB: a normal build must never ask a question this gate answers with "no".
  • The bytes go to the inactive app slot; the running firmware is untouched until the image verifies, and only then does otadata flip (esp_ota_set_boot_partition). A failed install leaves the unit booting exactly what it booted before — no half-written slot, and no way to brick a dashboard from a phone.
  • On success the panel holds UPDATE COMPLETE / REBOOTING for 3 s (the delay(3000) the display task takes before bootinfo_tag_reboot("ota") + ESP.restart()) — long enough for the driver to read it and for the done event to leave over BLE, because a restart that beats that event leaves the phone guessing, and a guess is how a finished install gets reported as stalled — then tags the reboot (lastRebootTag='ota') and restarts. A failure frame draws the reason on the panel and the dashboard comes back by itself.
  • While an install is open: the display task replaces the dashboard with the install screen, otaMemReleaseRequested tells it to hand back the panel's flash-resident buffers for the window — ensureSpeedSprite() in ui.cpp refuses to build while the flag is up, and processOtaMemRelease() frees the sprite and the 120 px VLW font that already exist (bench 2026-11-03: heap 40.7 KB before the window, 48.5 KB at begin; heartbeat otaReq=1 sp=0). A refused install gets them back — otaFail() clears the pair and the dashboard's next frame rebuilds them (post-failure maxAlloc came back 13.8 KB against 31.7 KB before the window: that rebuild costs fragmentation, so it happens once per failed install, not once per attempt). The RPC plane answers only ping and ota.* — everything else is ota_in_progress (the gate at the head of btRpcHandle()) — and the panel's hold-to-restart is refused while bytes are going into flash (otaFlashWriteActive() inside processOtaScreenButton()), because a reset mid-write is exactly the half-written slot this whole flow exists to avoid. A client can always ask "are you alive?" while 1.5 MB go past.
  • The producers are parked. From the moment a session opens until it is accepted, refused or timed out, otaQuietActive holds loop() at its own top: no telemetry snapshot, no clock refresh, no dashboard draw, no night-mode switch, no CPU scaler, no factory-reset button watch — and the sensor task waits at its own loop top, because it carries 6–8 KB of stack and produces data no update screen will ever show. The update screen ticks at 120 ms rather than 60 frames a second. Measured twice on the bench with the same 1,590,912 B file: 2:23 and 2:26 — parking changed the throughput not at all, because that rate is the link's round trip and not the dashboard's — but the loop stopped competing for the heap and the flash cache, the handoff ring went from full to 2 of 15 usable slots, and a stale dashboard can no longer be drawn underneath an update frame. What does still run inside the window: the memory release above, the panel's frames and progress bar, the trip-button gesture (hold 3 s to force a restart, refused while flash is being written), and the BLE liveness guard. A refused install closes the window and everything comes back on its own.
  • The window is not allowed to look like a stall. The heartbeat's maxFrame=/sMaxGap= watches and the STALL: loop gap line are re-seeded by the display task on the window's own way out (it is the task that was parked): before this, one bench install printed 1,064 STALL lines during the transfer and then a 181 s maxFrame in the next heartbeat — numbers that described a parked loop, not a fault.
  • Measured shape of a finished install (bench 2026-11-03, dpp-a53.bin = 1,593,664 B, MTU 517, chunk 496 B, app versionCode 57): 75.4 s / 76.8 s / 79.0 s wall clock from START to installed, three runs, no stall and no retry — 3,214 ATT writes in ~74 s of transfer, 42–44 writes/s ≈ 20–21 KB/s. On the unit: esp_ota_begin 5.94–5.99 s (390 sectors erased up front, before a byte moves), esp_ota_write 3,214 calls, mean 3,108 µs, max 15,371 µs, total 9.99 s, which is ~5.9 µs per byte — page programming, not per-call overhead (~230 µs of it); handoff ring high-water 2 of 7 usable slots, longest gap between drains 123 ms, longest gap between arrivals 135 ms; CRC-32 plus esp_ota_end() verify 507–510 ms. The install prints all of it (OTA: open+erase … when the slot opens, OTA: flash … writes mean … max … total …; open+erase …; longest … between arrivals when it closes, OTA: ring high-water N/7 frames, longest Nms between drains), and the ring pair is in the perf reply as ring_high / ring_gap_ms so a soak run leaves those numbers behind without an install failing. What moved that wall clock from the 2:23–2:26 measured for 2.0.0 is one phone-side change: the app asks Android for the fast connection interval for the duration of the chunk phase (requestConnectionPriority(CONNECTION_PRIORITY_HIGH) in ble/OtaController.kt, a7e9f6bb) — 132.8 s → 76.5 s mean on matched runs. Two candidates were measured and reverted: phone-side write pipelining (850c9ae2 → f7274ad3: 140.6 s, and 0-byte sessions) and a unit-side byte FIFO with batched writes (17f95096 → 5d890308: 1,781 writes, flash 9.99 → 9.33 s, wall 76.2 / 78.7 s, FIFO at 81 % of depth). Both stay in the history with their numbers.
  • Where the wall clock still is. At MTU 517 the chunk is 496 B and the air rate measures 20–21 KB/s, so 60 s carries 1.23 MB — under this build's 1,594,112 B image. The flash side is not the slack: ~9.9 s of page programming sits at the physical floor, and ~6 s of up-front erase is the only sizeable block that is not on the data path's critical round trip. Anything below ~70 s means air-side work (connection interval, write scheduling) or a protocol change that moves the erase earlier (ota.arm-style, when the file is picked) — neither is in this build.

No signature, and no version gate on this path. The unit checks that the image fits, that the bytes match the CRC-32 the client declared, and that what lands in the slot is a valid app image. It does not ask which version the client meant, and nothing on a device can verify a signature — signatures are out of every update path by the maintainer's instruction (AGENTS.md rule 3). The version judgement belongs to the client that can show both sides: the app reads the DPPVER=x.y.z marker out of the file it is about to send, prints it next to the unit's running version, and wants a second press (CONFIRM FLASH) before sending an image that claims the version already installed.

Wi-Fi firmwares (≤ 1.4.x) still take POST /api/ota from their own portal, and a current-build unit still takes a wired pio run -t upload --upload-port COM7. Over-The-Air (OTA) Updates below stays as the Wi-Fi-era quiet-window design; the BLE path implements the same shape — gates that live in the unit, a window that always closes, a panel that shows the truth — with the frames above. See also Implementation plans/ for the phased BLE plan.


Historical: the Web Portal and REST API (units running ≤ 1.4.x)

[!IMPORTANT] Nothing in this section exists in a current build. src/web.cpp (SoftAP + station join, every REST handler, the portal, the LAN install route, the weather-fetch task), src/webui.html and scripts/gzip_webui.py were deleted on 2026-11-02 and there is no build_src_filter line left to put them back — a unit flashed from this tree has no Dashboard_Config hotspot, no 192.168.4.1, no REST endpoint and no IP address. The sections below stay because units in the field still run 1.4.x and behave exactly as described; the sources are recoverable from git (git show 7104bf96^:src/web.cpp, …:src/webui.html). Read them as a reference for those units, not as the current API.

Dashboard++ embeds a single-page management portal directly into flash memory (index_html).

WiFi Network Connectivity

  • SoftAP Mode: Emits AP SSID Dashboard_Config (Default IP: 192.168.4.1). Always up alongside the client connection (WIFI_AP_STA), secured by AP_PASSWORD (empty or 8–63 characters).
  • Multi-SSID Client Mode: Stores up to 5 networks. They are tried in slot order, and the search never expires — a failed cycle is simply followed by another one, forever (there is no search-policy parameter to set). The order is automatic: when a network joins, it is moved to slot 0, so the network this unit actually reached last is the first one tried after a reboot or a dropout — no hand-ordered “primary / fallback 1…4”. Credentials are only ever removed by an explicit Forget; being out of range is the normal state for a vehicle and never deletes a network by itself.
  • Phone-style network picker (Web UI → Network → Wifi): press Scan Networks, tap the network, type the passphrase once. Open networks join without a password, a network that is already stored reconnects without asking for it again, and a hidden network is added by name. The scan is a cached list of the 30 strongest SSIDs (channels 1–13); it is never refreshed automatically, because a scan moves the radio off its working channel — it runs when asked and after a failed search cycle. With all five slots full the device refuses to guess: the UI asks which stored network may be replaced.

[!IMPORTANT] Access model (deliberate). The config portal and the REST API have no login — access control is the Wi-Fi itself. AP_PASSWORD must be empty or 8–63 characters; a shorter value is rejected and the default (12345678) is restored, so the hotspot can never come up with a password the ESP32 silently refused. Change that default before using the dashboard on a public network: anyone who joins the hotspot (or reaches the dashboard's LAN IP) can change settings and flash firmware.

Web UI Features

The management portal features a modern grouped card-based layout:

  • Collapsible sections with smooth accordion animations (non-JS fallback)
  • Live search bar to filter configuration parameters across all sections
  • Autosave triggered 2 seconds after any input change
  • Visible checkboxes (UI Layout → Screen Elements) control only what is drawn on the display; sensors, GPS, odometer and every calculation keep running whether the element is visible or not, so hiding a readout never stops the data behind it
  • Advanced Mode switch in the header reveals the technical parameters, so the common setup path stays short (see ADV_MODE)
  • Color pickers with inline preview for all UI color values
  • Slider + number inputs with mouse-wheel scroll protection for all range parameters
  • XY offset controls with linked sliders for UI element positioning
  • Real-time performance panel displaying FPS, CPU frequency/temperature, RAM usage, flash storage utilization, and live serial output monitor
  • Weather Widget group with city, latitude/longitude, refresh interval, locale, and a day/night icon
  • Ambient light calibration (dark / bright reference points)
  • OTA firmware upload via file picker — on a Wi-Fi unit this was the only upload path. The Android app used to reach the same endpoint through its WebView of this page; a Bluetooth-only client never could. It now does the same job over the link without HTTP at all (src/bt_ota.cpp; see Firmware over the link)
  • NVS backup/restore (export downloads GET /api/config as dashboard_backup.json, import posts it back to POST /api/config)

REST API Endpoints

EndpointMethodDescriptionRequest Payload / ParamsContent-Type
/GETServes pre-gzipped single-page Web UINonetext/html
/debugGETDump first bytes of the pre-gzipped UI buffer (build sanity check)Nonetext/plain
/api/configGETExports complete NVS configuration plus read-only build_versionNoneapplication/json
/api/configPOSTUpdates NVS parameters and applies changes; on a save where N values could not be written the answer carries nvsErrors: N, nvsFailedKeys: "KEY,…" and nvsAvailable (free NVS entries)Config JSON objectapplication/json
/api/wifi/scanGETCached Wi-Fi scan plus the stored networks: scanning, ageMs, count, max: 30, nets[] (ssid, rssi, ch, auth, authName, saved) and saved[] (slot, ssid, connected). Reading it never touches the radioNoneapplication/json
/api/wifi/scanPOSTQueues a background scan of channels 1–13 (the radio leaves its channel for a few seconds, which is why the firmware never scans on its own); poll GET for the resultNoneapplication/json ({"status":"requested"} / {"status":"scanning"})
/api/wifi/joinPOSTStores one network and searches for it immediately. An SSID already stored keeps its slot (with no pass field the stored passphrase is reused, not blanked); otherwise the first free slot is used. With all five taken the answer is status:"full" plus the stored list, and re-sending with "slot":n names the network to displace{"ssid":"…","pass":"…"} — pass omitted = reuse stored / open network; "slot":n = overwrite that slotapplication/json (status, slot, ssid, nvsOk, overwrite)
/api/wifi/forgetPOSTRemoves one stored network from RAM and NVS and restarts the search. Credentials are never dropped by the device on its own{"slot":n} (0–4)application/json (status, slot, ssid, nvsOk)
/api/timePOSTSyncs system clock from browserJSON body {"timestamp":1700000000} (integer epoch s; must fall in 2020–2100)application/json ({"status":"ok"} / 400 with reason when out of range)
/api/odoGETReads odometer distance in kmNoneapplication/json
/api/odoPOSTSets odometer distance{"km": 123.45}application/json
/api/trip/resetPOSTZeros the trip stats (same reset as the GPIO25 button); the odometer and session max speed are untouchedNoneapplication/json
/api/rebootPOSTTriggers graceful device restartNonetext/plain
/api/sleepPOSTTriggers immediate deep sleepNonetext/plain
/api/resetPOSTFactory reset — clears the cfg namespace; WiFi credentials and the odometer surviveNonetext/plain
/api/ambientGETReads raw ambient light sensor valueNoneapplication/json
/api/sensorsGETReads calibrated battery voltage (v) and coolant temperature (t)Noneapplication/json
/api/fuelGETReads the fuel input: raw averaged ADC code, liters, pct, ohm sender resistance, st input state (0 = disabled, 1 = ok, 2 = open circuit, 3 = shorted)Noneapplication/json
/api/liveGETSingle 1 Hz snapshot for the Web UI's live readings: ambient, odo (km), fuel_raw, fuel_ohm, fuel_st, volts, temp. One response feeds all five cells, so they always show the same instantNoneapplication/json
/api/snapshotGETFull 1 Hz vehicle + GNSS snapshot for external consumers (the Android app reads this same JSON over the BLE RPC snapshot, and the unit pushes it unprompted on its notification characteristic): ver, up (uptime s), spd, src (0 = Hall, 1 = GPS, 2 = fused), avg, max, trp_km, trp_l, ikml, akml, odo (km, demo-aware), fuel_l, fuel_pct, fuel_st, volts, temp, amb, sats, hdop, alt, lat, lon, fix, tmr_st, tmr_s, fps, heap. Fetched on demand over the BLE RPC snapshot, the answer also carries mac (the unit's eFuse Bluetooth MAC as XX:XX:XX:XX:XX:XX) — the pairing ladder needs that identity, and this reply is one frame on any link; perf carries the same MAC but its answer measured 1307 B and arrives as three fragments, which is a request timeout waiting to happen on a phone whose reassembly of a split message is not perfect. The unprompted 1 Hz notification keeps its old byte count. fix is 1 only while the GNSS location is valid (lat/lon meaningful then); hdop and alt print -1 when the fix carried neither. Read-only and allocation-free like /api/liveNoneapplication/json
/api/noisematrixGETNoise-bench snapshot: status (never / running / done), completed runs, and per run / per channel mean, sigma and peak-to-peak in mV and ADC codes (_mv / _lsb)Noneapplication/json
/api/noisematrixPOSTStarts the four-run sensor noise bench (idle, tft, bl10, bl90); runs in the background for ~17 s, so poll the GET. Answers 409 while a job is runningNoneapplication/json
/api/ambient/cal-darkPOSTSets dark-reference ambient light value (auto-brightness floor)Noneapplication/json ({"status":"ok"|"saved-ram-only","value":N} — saved-ram-only means the value is applied but the NVS write failed)
/api/ambient/cal-brightPOSTSets bright-reference ambient light value (auto-brightness ceiling)Noneapplication/json (same shape as cal-dark)
/api/otaPOSTFirmware install over the LAN — version, size, image-header and one-writer gates always; no signature step on any path; an older image is accepted without a flag (see LAN install (POST /api/ota))multipart/form-data: target_version, firmware (file part); optional total_bytes, force=1 (reflash the identical build). A signature part may still be sent: it is counted, ignored and loggedapplication/json
/api/ota/checkGETReports install state for whoever is pushing the image: state (`idlestagingreceiving
/api/serialGETStreams internal 4 KB ring buffer logsNonetext/plain
/api/perfGETLive telemetry (CPU, Heap, task stack headroom, FPS, WiFi, partitions), plus the web task's own timing: web_serve_ms_last / web_serve_ms_max (what one handleClient() pass costs) and web_slow_iters (loop iterations over 100 ms). Also reports mac: the base eFuse MAC (uppercase, colon-separated) - the stable device identity, mirrored as perf.mac on the BLE RPC and what the companion app binds its vehicle card to, unchanged by Wi-Fi mode or DHCPNoneapplication/json
/api/healthGETHealth probe: heap, FPS, partition layout and the NVS entry budget (nvs_bytes, nvs_entries_used / _available / _total)Noneapplication/json
/api/bootGETBoot/reboot forensics (reset reason, storm, last-reboot tag, heap watermark). reset_reason covers the whole core enum — POWERON, EXT, SW (clean restart), PANIC (crash/abort), INT_WDT, TASK_WDT, WDT (other), DEEP_SLEEP_WAKE, BROWNOUT, SDIO, USB, JTAG, EFUSE_ERR, PWR_GLITCH, CPU_LOCKUP, UNKNOWN; anything unmapped prints RST_<n> (issue #42)Noneapplication/json

Noise Bench

A bench instrument (master plan step 8) that answers "how steady are the sensor readings, and what on the board makes them wobble?" It samples the four real sensor inputs — BAT_SNS/GPIO35, COOL_SNS/GPIO36, LDR_SNS/GPIO34, FUEL_SNS/GPIO32 — 4,000 times per channel at ~1 kS/s and reports mean, standard deviation and peak-to-peak in millivolts and ADC code counts. The four runs are normal running conditions only: idle, tft (full-frame repaint on every display tick), bl10 and bl90 (backlight held at 10 % / 90 %).

The sensor task pauses its own ADC reads while a job runs (ADC1 has one converter), so the dashboard keeps animating but its sensor values hold for the ~17 s duration. Nothing is written to NVS; the result is transient.

Before you start: power the board as it will run in the vehicle (12 V on J1, real sensors on their connectors — the battery divider is on-board, the coolant NTC, LDR and fuel sender plug into theirs). If no fuel sender is wired, fit a resistor across FUEL_SNS to GND (100 Ω is a sane reference) so the pin is not floating. Do not change settings or touch the power wiring during a job — a brightness change or a connector wiggle is itself a disturbance.

From the app (no PC needed): connect to the unit, open its dashboard settings, expand Sensors Tuning and press the noise-bench action in that card. The app sends bench.run, then polls noise; the card shows progress and then the full table — per input, the mean, the wobble (σ), the extra wobble against the quiet baseline (green at the target, amber above it) and the worst jump. The result lives in RAM, so it is gone after a reboot.

From the link / serial (automation): scripts/ble_bench_central.py is a desktop BLE central for exactly this kind of bench work: write {"m":"bench.run","id":1} to the RPC characteristic, then read {"m":"noise"} until status is done. On a ≤ 1.4.x unit the same job was POST /api/noisematrix and GET /api/noisematrix. Either way, watching the serial log on COM7 at 115200 baud needs no client at all: the job prints one NB <run> <channel>: … line per channel per run, then NB DONE.

Reading the numbers: sigma_mv is the wobble (the number to compare between runs); pp_mv is the worst single jump. The _lsb fields are the same statistics in ADC code counts (1 count ≈ 3.3 V / 4095 ≈ 0.806 mV — this is the "LSB" the sensor conversions use, not a calibrated eFuse value). interval_ms reports the sampling period actually achieved.

Pass target (sensor plan D3, pin-referred): a run's wobble (sigma) should add no more than 0.5 ADC counts ≈ 0.40 mV on any pin compared with idle. This is a design target to establish the baseline, not a hard failure; record the numbers before and after a board change (respin, new harness) and compare.


NVS Configuration Parameter Reference

Dashboard++ keeps every configuration parameter as one row of a single table, CFG_TABLE in src/config.cpp: the C++ global, the JSON key, the NVS key, the default, the allowed band and the kind (CK_INT / CK_UINT / CK_FLT / CK_BOOL / CK_STR / CK_SECRET) all on one line. processConfig() walks that table in its three modes — boot load, export (config.get, streamed as BT_F_CHUNK frames; GET /api/config on a ≤ 1.4.x unit) and apply/restore (config.set; there, POST /api/config) — across 179 parameters, plus the fuel-calibration table kept beside it. The CK_SECRET kind still exists in the struct but no row uses it: the five Wi-Fi / hotspot secrets left with the Wi-Fi stack on 2026-11-02, so a config export carries no credential of any kind and a backup file is safe to mail.

Numeric Range Validation (issue #21)

Every numeric parameter carries an explicit known-good band in its CFG_TABLE row in src/config.cpp (lo/hi for CK_INT / CK_UINT, flo/fhi for CK_FLT) — that table is the single source of truth. The band is enforced on all three write paths (boot load, POST /api/config, and config restore): an out-of-range value is clamped to the nearest end of the band, logged by name, and the clamped value is what gets written to NVS — a bad value never reaches the running system, and a restore is never rejected outright because one field is off.

  • Non-finite floats (nan, inf) fall back to the parameter's default rather than poisoning a calculation.
  • Cross-field rules are repaired after the per-field clamps, in one place (sanitizeConfigPairs()): TEMP_BAR_MIN < TEMP_BAR_MAX, TEMP_BAR_MIN <= TEMP_WARN_LOW <= TEMP_WARN_YEL <= TEMP_WARN_RED <= TEMP_BAR_MAX (the three temp color fade points, repaired cold→hot so a band can never flip direction), FUEL_WARN_RED <= FUEL_WARN_YEL (fuel is mirrored — it turns red below its marker), LIGHT_SENSOR_DARK_VAL < LIGHT_SENSOR_BRIGHT_VAL, MIN_SATELLITES <= OPTIMAL_SATELLITES, CPU_THROTTLE_TEMP_WARN <= CPU_THROTTLE_TEMP_CRIT.
  • Enumerated parameters are snapped to the nearest legal step: GPS_BAUD to a standard u-blox rate (1200…921600), MANUAL_CPU_FREQ to 80 / 160 / 240 MHz. DISPLAY_ROTATION is masked to 0–3, WHEEL_CIRCUMFERENCE_MM has always been floored at 1 (it is a divisor).
  • Fuel calibration table (fuelCalOhms) cells are clamped to the sender band 0–100000 Ω on load and on restore. The table is in ohms since issue #18 (it used to be raw ADC codes under the name touchTable); old touchTable values in a backup are ignored rather than re-injected as ohms. The table is also required to be monotonic in one direction (the direction its first and last entries already imply): out-of-order points are sorted by repairFuelTable() on boot load and on save, written back to NVS, and named in the log, because a gap in the ramp would otherwise freeze the needle (issue #19). The gauge walk in src/sensors.cpp carries a second layer — a reading that matches no segment parks on the nearer end and logs once every 5 s instead of leaving the last value in place.
  • The app's settings page mirrors the same bands so a bad entry is caught before it is sent: every numeric field takes its min/max from android/app/src/main/assets/config_schema.json, which python scripts/gen_config_schema.py generates out of config.cpp (labels, groups and icon data come from scripts/config_schema_presentation.json in the same run). Regenerate the asset after any band change — it is generated, never hand-edited. An out-of-range entry is clamped in the field, and an emptied numeric box falls back to the row's default instead of sending a blank (which the device used to read as 0).

Bands are also checked offline by python scripts/verify_config_ranges.py: every numeric parameter must have a band, no shipped default may fall outside its own band, and the cross-field rules must hold for the factory values.

NVS Access, Locking & Write Errors (issue #32)

ESP-IDF's NVS layer has its own internal locking, so a torn write is unlikely — what is not protected is the session. Two tasks opening and closing the same namespace independently can still collide: one task's end() closes the handle the other is still using, which comes back as ESP_ERR_NVS_* from putInt()/putString() and shows up as a setting that quietly reverted after a reboot. The old code took prefsMux at some call sites and not at others, so the protection depended on which file the code happened to live in.

  • Every namespace open now goes through NvsSession (src/dashboard.h): an RAII wrapper that holds prefsMux for the whole begin()/end() pair, logs when a namespace cannot be opened, and closes on scope exit so an early return cannot leak the handle. It skips the lock while prefsMux is still NULL (early boot, single-threaded). The long-lived preferences object for the dashboard namespace keeps its explicit prefsMux pairs.
  • Write results are checked. nvsWriteFailed(key, esp_err_t) logs the failing key with esp_err_to_name(); the table-driven save counts them (cfgApply() bumps cfgNvsWriteErrors for every row that fails) and a save ends with Config save: N parameter(s) failed to reach NVS … they will revert on reboot instead of pretending nothing happened. The same check covers the fuel-table writes, the factory-reset credential restore, the bootinfo writes and the ambient calibrations.
  • A failed read is not a default. Where an unreadable NVS would previously have produced "no value" and triggered a factory seed, the seed is now skipped with a log line — a flash that cannot be read must never be answered by overwriting it.
  • Factory defaults are the table. On first boot (CFG_VER missing or below 5) the seed writes each CFG_TABLE default straight into NVS and RAM (cfgSeedDefaults(), one NvsSession for the whole pass) — there is no second copy of the defaults in flash and no JsonDocument to build one. There is nothing secret left to seed, so a lost CFG_VER stamp can only ever restore settings, and the fuel ramp is rebuilt from the seeded FUEL_OHM_EMPTY/FUEL_OHM_FULL only when the stored table is not already the factory ramp.
  • prefsMux is a plain (non-recursive) mutex: nothing inside a locked NVS region may open NVS itself.

Key Configuration Categories

System & Performance
  • TARGET_FPS (default=60): Desired display refresh rate, 5–120 FPS (band-checked on load and in the app). It sets the derived DISPLAY_REFRESH_MS frame budget (120 → 8 ms, 60 → 16 ms, 5 → 200 ms); values above what the SPI panel can carry simply drop the FPS counter rather than saturating the display core (issue #36).
  • SPI_BUS_SPEED (default=60000000): SPI bus frequency in Hz. Accepted range 1000000–80000000 (1–80 MHz; the app shows MHz). Out-of-range values are clamped on load/save — a 0 or a negative (wrapped) value would otherwise feed LovyanGFX a degenerate clock divider and blank the panel. The read clock is derived as SPI_BUS_SPEED × 8/5, computed in 64-bit and clamped to the same band.
  • ENABLE_DYNAMIC_CPU (default=false): Toggles automatic CPU frequency scaling (hysteresis-based).
  • MANUAL_CPU_FREQ (default=240): Fixed CPU clock frequency (80, 160, or 240 MHz) used when ENABLE_DYNAMIC_CPU is off. The value is snapped to the nearest legal step, and every setCpuFrequencyMhz() call now checks its result — a refused switch logs CPU: refused to switch to …MHz instead of claiming a frequency the chip never switched to (issue #34).
  • ENABLE_CPU_THROTTLE (default=true): Enables thermal frequency capping.
  • CPU_THROTTLE_TEMP_WARN (default=60): Warning temperature threshold in °C.
  • CPU_THROTTLE_TEMP_CRIT (default=70): Critical temperature threshold in °C.
Power, Sleep & Advanced Mode
  • POWER_SENSE_OFF_MS (default=10000): How long POWER_SENSE_PIN (GPIO4) must stay continuously LOW before the unit plays the goodbye screen and deep-sleeps (500–120000 ms). Any HIGH restarts the window, so ignition bounce and supply dips cannot stall the GPS task or sleep the dash (issue #26). Only used when ENABLE_POWER_SENSE is on. The app row (Sleep After Power Loss) is shown and edited in seconds (0.5–120, in 0.5 s steps) and converted on save; NVS, config backups and the firmware keep milliseconds (issue #57).
  • ADV_MODE (default=false): the global Advanced Mode switch at the top of the app's dashboard-settings page. When on, advanced and technical settings (CPU, GPS, Polling Rates, technical display options and sensor-calibration internals) appear as normal rows in their groups; when off they stay hidden, so the common setup path stays short. It is an NVS row, so the choice follows the unit rather than the phone.
Display & Visual Design
  • DISPLAY_ROTATION (default=3): Screen rotation (0, 1, 2, 3).
  • BACKLIGHT_BRIGHTNESS (default=100): Backlight duty cycle (0–100%, clamped on load/save). The percent is turned into the 8-bit LEDC duty in one place (backlightDuty() / applyBacklight() in src/main.cpp), so no path — the app's slider, night mode, boot splash, sleep/wake fade or auto-brightness — can put an out-of-range duty on the panel (issue #20).
  • BL_PWM_HZ (default=12000): Backlight LEDC PWM frequency in Hz (1000–40000, clamped on load/save). The 12 kHz default sits above the audible range — the old fixed 1 kHz was audible as a whine off the panel wiring. Boot attaches with the stored value; a config save retunes the live channel at a frame gap (ledcChangeFrequency() in processConfigApply(), src/main.cpp) and re-asserts the duty, so the panel never blinks off mid-save. Shown in the app in kHz (1–40).
  • TFT_DRIVE (default=0): Display-ribbon output drive strength (0–3). 0 = the ESP32 chip default, untouched — the shipped setting, so nothing changes unless you opt in. 1/2/3 select the 5 / 10 / 20 mA pad drivers (GPIO_DRIVE_CAP_0/1/2, gpio_set_drive_capability() in src/main.cpp) on the five panel outputs — SCLK GPIO18, MOSI GPIO23, DC GPIO27, CS GPIO5, RST GPIO14 — because weaker drivers mean slower edges and less radiated noise into the analog corridor that runs beside the display ribbon. Bench-gated: the weaker levels are experimental until a display test (full-frame fill, no artifacts) passes. For reference, the ILI9488 datasheet rates the serial interface at 20 MHz (50 ns minimum write cycle); this board has run the 60 MHz default (SPI_BUS_SPEED) reliably since bring-up, so the drive knob is an emissions tool, not a clock change.
  • NIGHT_BACKLIGHT (default=29): Night-mode backlight duty cycle (0–100%), applied while night mode is active.
  • ENABLE_ANTIALIASING (default=true): Anti-aliased line rendering toggle.
  • AA_SHARPNESS (default=1.0): Anti-aliasing gamma correction factor.
  • GHOST_COLOR_STR (default="#212021"): Hex color code for inactive 7-segment digit background.
  • COLOR_TEMP_NORM (default=#00ff00), COLOR_TEMP_WARN (default=#ffff00), COLOR_TEMP_CRIT (default=#ff0000): the three colors of the engine temperature sidebar ramp, one per color fade point below.
  • TEMP_BAR_MIN / TEMP_BAR_MAX (default=10 / 110): the two ends of the engine-temp sidebar bar in °C. Scale only — since the three-fade-point ramp they carry no color; what sits below the first fade point and above the last one is flat (issue #46 follow-up).
  • TEMP_WARN_LOW (default=20): color fade point 1 — °C where the bar leaves flat COLOR_TEMP_NORM and starts fading toward COLOR_TEMP_WARN. Below it the cold color is held (this is the app row Color fade point 1). The width of this first band is TEMP_WARN_YEL - TEMP_WARN_LOW, which is the knob for a short or long cold hop.
  • TEMP_WARN_YEL (default=45): color fade point 2 — °C where the ramp reaches COLOR_TEMP_WARN; the COLOR_TEMP_WARN→COLOR_TEMP_CRIT fade starts there.
  • TEMP_WARN_RED (default=90): color fade point 3 — °C where the ramp reaches COLOR_TEMP_CRIT. Above it the bar stays flat COLOR_TEMP_CRIT all the way to TEMP_BAR_MAX; there is no fourth stop and TEMP_BAR_MAX no longer turns the bar red. Kept ordered TEMP_BAR_MIN <= TEMP_WARN_LOW <= TEMP_WARN_YEL <= TEMP_WARN_RED <= TEMP_BAR_MAX; equal neighbours collapse a band to a hard switch. The three stops are blended in RGB565 (rampColor3() over blendColorLinear()), so a yellow→red band walks through orange on its own, while a cyan low stop blended to amber goes through a muddy green that is in neither endpoint — which is why the shipped defaults stay on the green→yellow→red arc (issue #46). The dead TEMP_WARN_GRN marker removed in issue #29 is what TEMP_WARN_LOW replaces, this time stored in NVS, present in backups and editable in the Web UI.
  • COLOR_FUEL_NORM, COLOR_FUEL_WARN, COLOR_FUEL_CRIT: Hex color strings for fuel status bar — the same three-stop model, mirrored: COLOR_FUEL_NORM at 100%, fades down through FUEL_WARN_YEL (app Color fade point 1) and FUEL_WARN_RED (Color fade point 2), flat COLOR_FUEL_CRIT below the last one. Its drawing code (src/ui.cpp) is unchanged.
Sensors & Vehicle Calibration
  • WHEEL_CIRCUMFERENCE_MM (default=1650.0): Tire rolling circumference in millimeters.
  • FUEL_FILTER_ALPHA (default=0.08): EMA filter coefficient applied to the raw fuel ADC code.
  • FUEL_INPUT_ENABLED (default=false): Turns the resistive fuel sender input on. Leave it off until a sender is actually wired to GPIO32 — with no sender the pin floats and the gauge reports "no input" and stays at 0 (issue #18).
  • FUEL_OHM_EMPTY / FUEL_OHM_FULL (defaults=10.0 / 180.0): Sender resistance at empty and full, in ohms. Any standard works — 10–180 Ω (SAE), 240–33 Ω (European/VDO), 0–90 Ω (GM). Used to size the excitation resistor, define the fault band and pre-fill the calibration ramp.
  • FUEL_EXC_RES_OHM (default=220): Resistor from 3V3 to GPIO32 that the sender forms a divider against. The app suggests the best E12 value for the entered range (15 mA sender current budget, top of span below 2.6 V).
  • FUEL_ADC_VREF (default=3.30): ADC reference / actual rail voltage in volts, used to turn the code into a divider voltage. It is not an app field either — the divider hangs off the 3.3 V rail and typing a different value only corrupted the excitation-resistor suggestion (issue #55). The parameter keeps its NVS key and its slot in config backups, so an existing backup still restores; a rail measured to be off should be corrected in the wiring, not here.
  • FUEL_OVERSAMPLE (default=16): ADC conversions averaged into one sample (1–64). The ESP32 converter only delivers ~9–10 usable bits; oversampling is what buys the resolution back for a sender whose useful span can be a few hundred codes.
  • FUEL_TOUCH_POINTS (default=8): Number of valid entries in the fuel calibration table (fuelCalOhms): index 0 = empty, index N-1 = full, so the number of tank slots is one per litre for the shipped 1 L-per-step default. Changing this regenerates the table as a linear ramp between FUEL_OHM_EMPTY and FUEL_OHM_FULL.
  • TRIP_RESET_HOLD_MS (default=1500): How long the physical trip-reset button on GPIO25 must be held to zero the trip stats (200–10000 ms). Shorter holds are ignored so a knock on the dash cannot wipe a trip. Refuelling no longer resets anything by itself (issue #17).
  • BATTERY_SCALE (default=5.7): Battery divider ratio (R1+R2)/R2. Leave at 5.7 for the stock wiring; set to 4.7 for a 4.7 kΩ / 1 kΩ divider.
  • BATTERY_OFFSET (default=0.2): Fixed voltage offset added to the divided battery reading.
  • NTC_R_BALANCE (default=10000.0): Balance resistor value in ohms for NTC divider.
  • NTC_R25 (default=10000.0): Thermistor resistance at 25 °C (R25) in ohms.
  • NTC_BETA (default=3950.0): Thermistor Beta coefficient.
  • NTC_TEMP_OFFSET (default=0.0): Fixed temperature offset (°C) added to the Steinhart result.
  • GPS_BAUD (default=115200): UART baud rate for the GNSS module. On boot the ESP listens passively across the common baud rates and stores the first rate at which the module's own traffic is seen. The module is never configured or reset by the firmware — no UBX command is ever transmitted; use u-center2 to change the module itself.
  • MIN_SATELLITES (default=5): Minimum GPS satellite lock requirement.
  • OPTIMAL_SATELLITES (default=8): Satellite count threshold for full GPS speed reliance.
  • MAX_SPEED_DELTA_KMH (default=5.0): Maximum allowable difference between GPS and Hall speed before falling back.
  • SPEED_SOURCE_MODE (default=2): Speed source — 0=Hall only, 1=GPS only, 2=Sensor Fusion.
  • HALL_MEDIAN_SAMPLES (default=3): Speed window size W (accepted pulses averaged); 1 = raw single interval.
  • HALL_PERIOD_GUARD (default=8): Reject an interval longer than N× the last accepted one (the lower bound is fixed at 1/2 by Layer 2); 1 = off.
  • HALL_PULSE_MIN_US (default=150): Layer 1 pulse-width qualification window in microseconds — a pulse whose measured rise − fall width is below this is dropped as EMI. Clamped to 10…1000 µs. Raise it when ignition EMI gets through; a genuine magnet pass is hundreds of µs wide, so 150 µs costs nothing. Measured from the stamped edges since 1.3.x (issue #30) — it no longer costs interrupt time.
  • ACCEL_MAX_TIME (default=30.0): Acceleration timer maximum duration in seconds before auto-finish.
Weather Widget (phone-fed)
  • SHOW_ELEMENT_WEATHER (default=true): Toggle the weather widget on/off.
  • WEATHER_WIDTH (default=480) and WEATHER_HEIGHT (default=28): Weather card size in pixels — width 160–480, height 20–120, set with the Widget Size sliders under UI Layout → Weather Widget. The card is anchored on its centre, so OFFSET_WEATHER_X/Y stay the point the card grows out from and a resized card does not slide sideways or down. The shipped defaults are the size the widget was before this was a setting, so a unit that never touches the sliders gets exactly the picture it had. The stat icons stay 16 px at any height (only Conthrax_SemiBold_16px and 28px are compiled in), and the text stays 16 px: a taller card gives the rows more breathing room rather than bigger glyphs. Rows: the card keeps everything on one line whenever it fits, which is why the default 480×28 looks exactly as it always did. When a narrow card cannot hold the stats beside the city name it wraps them onto extra rows instead of dropping them, and each extra row costs about 20 px of height: a card at least ~40 px tall can use a second row, ~60 px a third. With a short city name and typical values, 240×48 shows city + temp + humidity on the first line and wind + sunset on the second, 160 px width needs ~60 px of height for all four stats, and a 160×20 card only has room for city + temp + humidity. Sunset is the group dropped first and wind second, and only when there is no row left to put them on; the exact cut depends on the city name and the current values, which are measured at draw time.
  • Gone with the Wi-Fi stack (2026-11-02): WEATHER_CITY, WEATHER_LAT, WEATHER_LON, WEATHER_REFRESH_MIN and WEATHER_LOCALE left the parameter table, the settings page and the backup template. A unit from this tree has no radio, so it never picks a place to ask about, never geocodes, never re-spells a city name into another language and never re-fetches on an interval. The widget is fed from outside: the phone sends weather.set (BT_F_WEATHER) with the values it fetched itself, the label it chose, sunrise/sunset as epoch seconds and a valid flag. A backup that still carries the old keys restores without them, and CFG_VER is deliberately not bumped over the removal — bumping it would wipe every other setting on units that already exist.
Time, Date & Daylight Saving
  • TZ_OFFSET_HOURS (default=1): Zone offset in whole hours — the standard (winter) offset, range −14…14. With DST on, this is the offset the clock uses outside the DST period; the rule below adds the extra hour. New York is -5, Paris is +1, Athens is +2.
  • TZ_DST_ENABLED (default=true): Master switch for daylight saving. When off the clock keeps TZ_OFFSET_HOURS all year.
  • TZ_DST_RULE (default=1): Which calendar TZ_DST_ENABLED applies (issue #22). 0 = none, 1 = EU/EEA (last Sunday of March and October, both at 01:00 UTC), 2 = US/Canada (second Sunday of March, first Sunday of November, both at 02:00 local wall clock — converted to UTC through TZ_OFFSET_HOURS, so a GMT−6 zone springs forward at 08:00 UTC and a GMT−5 zone at 07:00 UTC). The rules are integer arithmetic on the UTC date (no zone database); scripts/verify_dst_rules.py checks them against the published 2024–2026 transition instants. Before this parameter the DST half was hard-wired to the EU calendar, so a US zone was wrong for roughly three weeks a year.
  • The unit never inherits a timezone from the phone: these three rows are the whole clock policy, applied in firmware.
  • Gone with the Wi-Fi stack (2026-11-02): NTP_ENABLED (NTP_EN) and NTP_SERVER (NTP_SRV). With no network there is no SNTP client. The clock is set by a GNSS fix (UBX NAV-PVT / NMEA epoch → settimeofday()), or pushed by the phone through time.set (BT_F_TIME) once per link-up. On a ≤ 1.4.x unit, NTP ran as soon as the station joined and a browser could post /api/time.
Settings deleted with the Wi-Fi stack

The parameter table shrank on 2026-11-02, when the portal and the network stack went out of the tree (git show 7104bf96). No row was replaced by a Bluetooth equivalent — the link carries no settings of its own to store:

Deleted JSON key(s)NVS key(s)What it used to do
WIFI_SSID, WIFI_PASSWORDWIFI_SSID, WIFI_PWDthe station credentials the unit joined (plus the multi-slot list on older builds)
AP_PASSWORDAP_PWDthe Dashboard_Config hotspot passphrase
WIFI_ATTEMPT_SECONDSWIFI_ATTEM_Show long one network got per join try
NTP_ENABLED, NTP_SERVERNTP_EN, NTP_SRVthe SNTP client
WEATHER_CITY, WEATHER_LAT, WEATHER_LON, WEATHER_REFRESH_MIN, WEATHER_LOCALEWEATH_CITY, WEATH_LAT, WEATH_LON, WEATH_RFR, WEATH_LOCALEthe unit's own Open-Meteo forecast
  • Retired earlier, same behaviour: WIFI_RETRY_MODE, WIFI_RETRY_SECONDS and WIFI_TX_POWER_DBM (search policy fixed to search forever, TX fixed at 19.5 dBm), OTA_PULL_ENABLED / OTA_PULL_URL (the self-fetch path deleted 2026-10-31), and VERSION_OVERRIDE (VER_OVR), a hand-typed display label that is not a parameter at all any more.
  • What a restore does with a key it does not know: it ignores it. The table walk only ever applies keys present in CFG_TABLE, so an old dashboard_backup.json restores cleanly into a BLE-only unit and quietly drops the settings that no longer exist. Nothing is migrated into a replacement key, because no deleted setting has one.
  • One exception — renamed, not deleted: the Wi-Fi status icon became the Bluetooth icon and its three settings kept their meaning: SHOW_ELEMENT_WIFI → SHOW_ELEMENT_BLE (SH_WIFI → SH_BLE), OFFSET_WIFI_ICON_X/Y → OFFSET_BLE_ICON_X/Y (O_WIFI_X/O_WIFI_Y → O_BLE_X/O_BLE_Y). cfgCarryRenamedKeys() in src/config.cpp copies an existing unit's stored values across once at boot; it fires only when the new key is absent (getType() answering PT_INVALID — the only way to tell "never stored" from "stored as false / 0") and the old key is present, so a unit that already has real values under the new names is never overwritten and the copy never runs twice.
Ambient Light (Auto-Brightness)
  • LIGHT_SENSOR_DARK_VAL: Dark-reference ambient light value — set through the cal.ambient RPC method (dark), or POST /api/ambient/cal-dark on a ≤ 1.4.x unit.
  • LIGHT_SENSOR_BRIGHT_VAL: Bright-reference ambient light value — set through the cal.ambient RPC method (bright), or POST /api/ambient/cal-bright on a ≤ 1.4.x unit.
Digit Boundaries (Configurable 7-Segment Formatting)
  • SPEED_DIGITS, SAT_DIGITS, TMR_INT_DIGITS, TMR_DEC_DIGITS, BAT_INT_DIGITS, BAT_DEC_DIGITS, INST_INT_DIGITS, INST_DEC_DIGITS, AVG_INT_DIGITS, AVG_DEC_DIGITS, AVG_SPEED_INT_DIGITS, AVG_SPEED_DEC_DIGITS, MAX_SPEED_INT_DIGITS, MAX_SPEED_DEC_DIGITS, FUEL_INT_DIGITS, FUEL_DEC_DIGITS, ODO_INT_DIGITS, ODO_DEC_DIGITS: Configurable integer and decimal digit limits for all UI numerical readouts. These values size fixed 16-slot cell arrays in the display renderer, so they are range-checked on every load and save (issue #6): integer counts 1..14 (1-4 for SPEED_DIGITS, 1-3 for SAT_DIGITS), decimal counts 0..4, and per readout int + dec + 1 <= 16 (+1 is the decimal-point slot). Out-of-range values are clamped on load (boot, POST /api/config, config restore) and logged; the renderer keeps a hard bounds guard as a second line of defence. A readout also caps its value to what its configured digits can print (e.g. FUEL_INT_DIGITS=1 + FUEL_DEC_DIGITS=1 shows at most 9.5; raise the int count to 2 in the WebUI — "Fuel & Battery" card → Fuel LTRS → Integer Digits — for two-digit litres), so a value with more digits than the cells never draws outside its cell array (issue #7). SAT_DIGITS (1–3) sizes the satellite readout the same way since issue #41: the count is capped to what N digits can print and the erase box is measured from N × '8' instead of a hardcoded two-digit string — before that the parameter was stored, exported, adjustable in the WebUI and documented, but no code ever read it. Separately, every digit loop resolves its character through digitIndex() in src/ui.cpp before touching the per-glyph metric tables (digitWidth[10], ds15_digitXOff[10], …): a character outside '0'..'9' returns -1, the cell is skipped and keeps whatever the ghost/erase pass already painted, instead of indexing a 10-entry table with -3 (issue #31).
UI Layout Offset Coordinates
  • BIG_CENTER_X, BIG_CENTER_Y: Screen anchor origin point.
  • OFFSET_BIG_TIME_X/Y, OFFSET_BIG_DATE_X/Y, OFFSET_BIG_SPEED_NUM_X/Y, OFFSET_BIG_SPEED_UNIT_X/Y, OFFSET_BIG_ODO_X/Y, OFFSET_BIG_SAT_X/Y, OFFSET_BIG_TMR_X/Y, OFFSET_BIG_BAT_X/Y, OFFSET_INST_KML_X/Y, OFFSET_AVG_KML_X/Y, OFFSET_AVG_SPEED_X/Y, OFFSET_MAX_SPEED_X/Y, OFFSET_FUEL_LTRS_X/Y, SIDEBAR_LEFT_X/Y, SIDEBAR_RIGHT_X/Y: Fine-grained pixel coordinate offsets for every UI component.

Android Companion App

The companion app lives in android/: Kotlin + Jetpack Compose, Gradle (gradlew.bat), package com.alefinot.dashboardpp, minSdk 26 (Android 8.0), compile/target SDK 35, JVM 17. It is a second product on the same ESP: it does not drive the display, it pairs with the dashboard over Bluetooth LE, holds a garage of vehicles, records the telemetry the unit pushes into a CSV trip log, configures the unit with the same page the unit itself shows, and shows what those drives did. Current build: versionName 2.0.3, versionCode 71 (rule 24: every shared APK bumps versionCode).

One transport, on purpose. The app reaches a unit over GATT and nowhere else. The HTTP client, the NSD/subnet discovery, the WebView that used to hold the unit's own page, the manual-IP panel, the BLE bench panel and the Wi-Fi / network-state permissions are all deleted (commits 1a10c71, 6a15b33, 7c34220, 352429b). The consequence is accepted and it is stated where a driver runs into it, on the vehicle's card: firmware older than the BLE GATT server — 1.4.x and older — cannot be reached from the app at all. Those units still have their own Web UI over Wi-Fi (that is the firmware they were flashed with — the build from this tree no longer includes it, see the note at the top of Embedded Web UI & REST API Reference), and they are flashed as before: wired pio run -t upload, or their own Web Portal at http://192.168.4.1. What changed is that the phone is no longer a second way in.

The shell: one bar, one pager, one list

BarLayout (ui/screens/BarLayout.kt) is the shell as data: one list, pages, used by both the icon row and the swipe pager, so a slot can never point at a page that does not exist and a page can never be unreachable from the bar. Left to right:

SlotAppTabScreen
0LIVELiveTabThe app's own live gauges, drawn from the telemetry the unit pushes (1 Hz)
1TRIPSTripsTabRecorded drives, badges, filter, detail
2VEHICLESVehicleScreenThe garage, and the only place a pairing or a connection is made. centre and home: the app opens here.
3WEBDashboardSettingsScreenThe unit's configuration, drawn natively and read over Bluetooth. The enum case is still spelled WEB — renaming it means touching every when that switches on it — and the bar's glyph says "Dashboard settings"
4SETTINGSSettingsScreenThis app's settings: recording, motion, storage, trips folder, and the linked unit's name and firmware

Index 2 is the middle of five and the app opens there (BarLayout.home, which is the pager's initialPage). The garage became the home screen because it is where a connection is made: before it, every page assumed a dashboard was already there and an unreachable one left the whole app with one sentence and an address box. The bar carries one glyph size (BarLayout.glyph) - the centre larger - and a dimmed slot means "nothing behind this right now", not "quieter".

Pages are composed lazily but kept warm once reached, and the pager distinguishes two questions that used to be collapsed into one: "is this page inside the viewport" (may it paint and take touches) and "is this the settled page" (may it put requests on the one link). Collapsing them made swipes sluggish; two pages polling the same link was a real bug in the HTTP era, and the same rule keeps the recorder and the settings page from reading over each other now.

The garage: a card is a vehicle and the unit that belongs to it

vehicles/Vehicle.kt is one record with two halves on purpose. The user's words - name, type (CAR / MOTORCYCLE / OTHER), plate, maker, model, year (a String: "c. 1998" and "1998/99" are answers a number field would throw away), notes, photoFile - are never written by a connect. The app's evidence about a physical unit - mac, bleAddress, lastConnectedAtMs, firmwareVersion - is never typed. Keeping them in one record is what lets a card mean this dashboard instead of whatever answered at this address.

  • id is app-generated and stable across renames (v1, v2, ...: the next free name, VehicleStore.nextIdName). Trip files tag themselves with it, so renaming a vehicle does not rewrite recorded history.
  • photoFile is a bare file name (v1.jpg) inside the garage's vehicles/ directory, never an absolute path: the folder can change (a storage grant arriving, a restore, an app moved to adopted storage), and a persisted path would become a dead link. A missing JPEG reads as "no photo", and the record stays valid.
  • bleAddress is stored exactly as Android reports it, and is never derived from the eFuse MAC. The ESP32's Bluetooth address is not its base MAC, so deriving one invents an address that may belong to a different device. It is the phone's way back to a link, not an identity; mac stays the identity (see below).
  • The old ips list is gone. Records written before the change still decode (an unknown key is ignored, so an existing garage survives the upgrade), and the list is simply dropped the next time the record is written. One BLE address per card.
  • Storage is one JSON document the owner can open: Documents/Dashboard++/vehicles.json, every card plus the card-id counter and the selected card, written whole through a .tmp and a rename (vehicles/GarageFile.kt). It used to be the vehicle_store preferences DataStore with one row per card. A file the app cannot parse is never written over. Sorting, the next-name rule and the per-record codec stay pure functions in VehicleStore's companion so they can be argued about in tests (VehicleCodecTest — including the ble round-trip and a record that still carries ips — VehicleOrderTest, VehicleMatchTest, GarageFileTest).

Identity: which dashboard belongs to which card

The ESP reports its base eFuse MAC as perf.mac over the Bluetooth RPC (uppercase, colon-separated, six pairs) - an identity that survives radio and link changes, unlike whatever address the phone happens to hold. The app reads it once per link-up, and vehicles/VehicleMatch is the only place that text is ever interpreted:

  • A unit whose reported MAC equals a card's mac is that card, whatever BLE address it advertises at.
  • A card with no mac is matched by its stored BLE address instead — the narrower rule that keeps a unit whose firmware predates perf.mac usable.
  • A card made by pairing alone is upgraded to the MAC on the first link that reports one: the first half of the record is untouched, only the evidence gets stronger.
  • A unit that answers with a MAC another card already owns is refused and reported (notice on the vehicle page) rather than silently adopted; an advertising unit that no card claims is offered as adopt this unit instead of being ignored.
  • mac is null or a canonical XX:XX:XX:XX:XX:XX. Anything else (an empty string, half a value, "garbage") counts as unbound: VehicleMatch rejects it, the next connect clears it, and the store round-trips it verbatim, because remembering is not the store's judgement. The shape check lives in front of the typed value: VehicleMatch.normalizeMac(typed) != null.
  • The recorder's device key comes from vehicles/UnitIdentity.key(mac, bleAddress): MAC when the unit gave one, BLE address when it did not, unknown when neither is known. It is deliberately not a name — names are typed, and typed things collide.

Connection: one page, one ladder, and a reason line

ConnectionCore (viewmodel/ConnectionCore.kt) is the whole decision — pure, no Android, so every branch of it is a JVM test. ConnectionViewModel drives the real GATT link (ble/BleLink, ble/BleRuntime), which is process-wide: a GATT connection belongs to the phone, not to a page, so swiping away from the settings tab does not drop a pairing the user spent a minute setting up.

ConnectionUiState (viewmodel/UiState.kt): Idle(reason?), Connecting(mode) where the mode is DialStored / Scan / Pair / Reconnect, Connected(address, name, version, mac?, vehicleId, mtu), Failed(reason). The reason line is what used to be three screens (NoWifi, ManualEntryNeeded, ConnectionLost) and is now a sentence under the card that was being reached for, with the rest of the app still on screen.

  • Pairing. ADD VEHICLE scans (filtered by the firmware's service UUID, so the list is dashboards and not the neighbours' headphones), lists each DPP-#### unit with its signal in dBm, and the tap calls createBond(). LE Secure Connections then needs a six-digit passkey entered on the phone — the app does not draw its own key pad, it lets Android's pairing dialog ask — and once that matches, the link comes up encrypted, subscribes to telemetry and RPC, negotiates a 517-byte MTU, reads perf for the name/MAC/version, and NAME IT opens the editor with that evidence already filled in. Nothing about the unit is typed by hand on this path.

  • Where the six digits come from: the dashboard's own screen. The unit is the display side of this exchange (ESP_IO_CAP_OUT), so the number the whole encrypted link rests on has to be readable from where a person actually is. While the pairing window is open the LCD shows:

    text
    PAIRING
           ┌──────────────┐
           │ 4 2 8 5 6 1  │   ← the passkey, big 7-segment digits
           └──────────────┘
        TYPE THIS CODE ON YOUR PHONE
       IN THE APP: VEHICLES - ADD VEHICLE
         THIS DASHBOARD: DPP-B594

    The name line is there because two units in range is the normal case at a workshop: the name on the glass is the name in the phone's scan list, so the two can be matched by eye. The panel is drawn by the display loop from btPairingActive() / btPairingPasskey() (src/gfx.cpp, showPairingScreen) and never from the BLE security callback, because that callback runs on the BLE task and the panel belongs to the display task. It repaints when the stack hands out a new code and steps out of the way — with a full redraw — when the pairing lands or the window closes. The number is deliberately not readable over the link: there is no RPC that returns it, and handing it to the side that is supposed to be typing it over an unencrypted connection would make the pairing pointless. On the bench the same information is on COM7 at 115200 (BT: advertising as DPP-1234, BT: pairing mode: passkey NNNNNN, and the pairing outcome), which is where to look if a pairing is refused; no cable is needed to pair a unit any more.

  • Reconnecting. CONNECT on a card dials the stored BLE address with no scan. A dropped link retries on viewmodel/ReconnectLadder.kt — 2 s, 5 s, 15 s, then a Failed reason — and any user action restarts it. A ladder that gave up is no longer the end of the story: while the vehicle page is open the app restarts it quietly on a slow tick, once after 60 s and then every 5 min, and returning to the foreground after a give-up dials immediately rather than waiting for the next tick (ReconnectLadder.rearmDelayMs / rearmWanted, fed by the ON_START/ON_STOP pair in ui/screens/DashboardRoot.kt). That restart is the only automatic thing the app does with a radio, and it is bounded three ways — never in the background, never while a link is up or being built (which is also why a quiet dial can never interrupt a BLE firmware install), and never as a scan: the tick dials the stored pairing and nothing else. There is no ConnectivityManager in this code and that is the point: the phone's Wi-Fi state says nothing about a GATT link, so "check your Wi-Fi" was a sentence about a network the transport never used.

  • Radio and permissions. Bluetooth off or permission denied is one line plus the system-settings handoff, never a dialog storm. BLUETOOTH_SCAN carries neverForLocation for Android 12+, and ACCESS_FINE_LOCATION is capped at maxSdkVersion="30", because on Android 11 and below a BLE scan counts as a location scan. INTERNET stays for the trip map's OSM tiles; the Wi-Fi and network-state permissions the discovery sweep used are gone, and AppXmlWiringTest asserts the block both ways so a leftover permission cannot survive its own deletion.

  • Forgetting. FORGET THIS UNIT clears the app's binding and says out loud that Android may still hold the bond, and offers the system Bluetooth settings. There is no hidden un-bonding API in this app.

  • Everywhere else there is no connect furniture. The covers on the other pages offer exactly one button - OPEN VEHICLE PAGE - and carry no connect vocabulary and no address in either transport's spelling. App Settings cannot run a connection. That is enforced by the guard test ConnectActionsOnOnePageTest, whose allowlist is now the single file VehicleScreen.kt, and which strips comments first because half of that redesign is written down in doc comments naming the controls it removed.

Pairing a phone to a unit: what you actually do

The mechanical version is above (Connection: one page, one ladder, and a reason line); this is the sequence a person follows, and what to do when it does not work.

  1. Power the unit up. It advertises as DPP-<last 4 of its own MAC> — the name is on the unit's own boot log (BT: advertising as DPP-B594), and once a pairing attempt has started it is on the panel too.
  2. In the app: the VEHICLES slot (the middle of the bar, and the page the app opens on) → ADD VEHICLE → SEARCH FOR DASHBOARD++ (the same button reads SEARCH AGAIN once a sweep has run). The sweep lists only units carrying the Dashboard++ service UUID, with signal strength; a unit missing from the list is not advertising (see below).
  3. Pick it. The app asks Android to bond (createBond() in ble/BleLink.kt, and it watches ACTION_BOND_STATE_CHANGED). The dashboard starts a fresh 6-digit number, fills the whole screen with it, and says where to type it. The same line appears on COM7 at 115200 (BT: pairing mode: passkey NNNNNN) for anyone on a cable.
  4. Type the number into Android's pairing dialog. No dialog appears until the stack asks for one, so a dialog-free app screen does not mean the unit is fine.
  5. The panel goes back to the dashboard when the pairing completes or the window closes. The unit's log says BT: auth ok / BT: auth FAILED (reason N) / BT: connected. Name the card in the editor (NAME IT) and the identity is remembered.

Handing a unit from one phone to the next. When a link drops the unit does not offer pairing again on the spot: it goes silent for 10 s (BT_PAIR_DELAY_MS in src/bt.cpp) and then logs BT: pairing mode on, 10000 ms after the link dropped. The pause is there because the peer that just fell off usually redials within a second and takes the single peer slot, and because a phone that walked away from a pairing attempt used to keep the link open indefinitely - which left the unit unreachable by anyone else until it was reflashed. Android still lists the unit for a moment during that window (its scanner answers from the cache), but a connect attempt will not land until the unit advertises again. On the app side a search that ends in failure now drops the link it made along the way, so the hand-over finishes with the old phone gone and the unit advertising 10 s later.

When it does not work:

What you seeWhat it meansWhat to do
Nothing in the scan listThe unit is not advertising: already connected to a phone (it closes advertising once a link is up), in the 10 s quiet window after a link dropped, on a firmware with no GATT server (1.4.x and older), or out of rangeMove closer; check the unit's boot log for BT: advertising as …; if another phone holds the link, disconnect there first and wait f

Parts

57
QtyValueFootprintReferences
10100ndpp:C_0805_2012MetricC2, C4, C5, C6, C7, C8, C9, C10, C11, C13
710kdpp:R_0805_2012MetricR1, R3, R6, R7, R9, R11, R12
41kdpp:R_0805_2012MetricR4, R8, R10, R13
4MOUNTdpp:MOUNTH1, H2, H3, H4
2EL357N-Bdpp:SO-4_4.4x3.9mm_P2.54mmOK1, OK2
1220Rdpp:R_0805_2012MetricR5
1220u/50Vdpp:CP_Elec_10x12.5C1
1470u/35Vdpp:CP_Elec_10x12.5C3
147kdpp:R_0805_2012MetricR2
15x20 2Adpp:Fuseholder_Clip-5x20mm_Littelfuse_521_Lateral_P17.00x5.00mm_D1.30mm_HorizontalF1
1BAT_SNSdpp:TestPoint_Pad_D1.5mmTP3
1BZT52C15Vdpp:D_SOD-123D1
1DEBUGdpp:DEBUGJ13
1DPP_DCDC_MODULEdpp:DPP_DCDC_MODULEU4
1ESP32-DEVKIT-Adpp:PinSocket_1x15_P2.54mm_VerticalJ10a
1ESP32-DEVKIT-Bdpp:PinSocket_1x15_P2.54mm_VerticalJ10b
1FUEL_SNSdpp:TestPoint_Pad_D1.5mmTP4
1GNDdpp:TestPoint_Pad_D1.5mmTP5
1GPS_PADSdpp:GPS_PADSJ8
1HALLdpp:JST_PH_S3B-PH-SM4-TB_1x03-1MP_P2.00mm_HorizontalJ9
1HALLdpp:TestPoint_Pad_D1.5mmTP2
1IGN + COOLANTdpp:JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_HorizontalJ17
1IGN_SENSEdpp:TestPoint_Pad_D1.5mmTP1
1IRFR9540Ndpp:TO-263-3_TabPin2Q1
1JP1_PADS_3dpp:JP1_PADS_3JP1
1LDR + FUELdpp:JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_HorizontalJ6
1P6SMB33Adpp:D_SMBD2
1P6SMB6V2Adpp:D_SMBD3
1SMF4L33Adpp:D_SOD-123FD4
1SPAREdpp:SPAREJ15
1SPARE Ndpp:SPARE_NJ16
1SPARE Sdpp:SPARE_SJ18
1TFT-ILI9488dpp:PinSocket_1x14_P2.54mm_VerticalJ3
1TRIP + ENdpp:JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_HorizontalJ14
1XT30U-Mdpp:AMASS_XT30U-M_1x02_P5.0mm_VerticalJ1

Similar boards

  • 2 layers90 × 54 mmTest 09. With ESP module, the printed circuit board in 3D

    Test 09. With ESP module

    henkjannl/RoomThermostat

    Opentherm room thermostat that can be controlled through Telegram, but also locally

    66 parts1MIT
  • 2 layers130 × 110 mm3D Printer Mainboard STM32, the printed circuit board in 3D

    3D Printer Mainboard STM32

    m47812/Custom-3D-Printer-Controller

    A fully custom 3D Printer controller board based on an stm32f7 controller with TMC2209 stepper drivers

    191 parts10GPL-3.0
  • 6 layers160 × 120 mmPic32main, the printed circuit board in 3D

    Pic32main

    JuanPoueriet/automatic-spork

    Máquina de ruleta física de casino. 6 PCBs: pic32main (cerebro), wheel (rueda de LEDs), displaysa/b/c (paneles de 7 segmentos en cascada) y buttons (botonera).

    256 parts
  • 4 layers66.6 × 86.6 mmDPSin Board WiFi Triac, the printed circuit board in 3D

    DPSin Board WiFi Triac

    gmeirelles-ops/esp32-DPTri

    Firmware ESP-IDF para a DPSin Board WiFi Triac (rev v1.0.1): relé AC ON/OFF e dimmer TRIAC por porcentagem, controlados pelo app, com pareamento por hold ≥10 s no botão PAIR.

    62 parts1
  • 2 layers100 × 150 mmPCB Kicad F446, the printed circuit board in 3D

    PCB Kicad F446

    axvelo/PCB-Kicad-F446

    A custom CNC control interface board designed with KiCad 9.0, intended for use with LinuxCNC and the Remora STM32F446 / W5500 firmware real-time firmware.

    202 partsCERN-OHL-S-2.0
  • 2 layers200 × 170 mmRouting R1, the printed circuit board in 3D

    Routing R1

    ddasdkimo/rack-atx-controller

    10-host isolated ATX rack controller hardware planning, interactive 3D model and wiring documentation

    698 parts
  • 2 layers169.9 × 102.7 mmStm32h723zet6, the printed circuit board in 3D

    Stm32h723zet6

    mgalleguilloscespedes-png/proyectro-stm32

    en mi afán por el diseño 3d y la electrónica decidí crear mi propia versión de una placa controladora para impresora 3d, basándome en el microcontrolador stm32h723zet6 y la pcb de bigtreetech manta mp8

    587 parts

Questions

How big is the Dashboard plusplus carrier PCB?

It measures 95 × 55 mm and has 4 copper layers, 1.6 mm thick.

What parts does the Dashboard plusplus carrier use?

57 parts, built around the EL357N(B)(TA)-G, BZT52C15V, P6SMB33A and SMF4L33A. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.

Where can I download the Dashboard plusplus carrier KiCad files?

From the alefinot/Dashboard-V2 repository on GitHub, at the commit this page was made from: the board is hardware/dashboard-plusplus-carrier.kicad_pcb. PCBKit opens the board in the browser, with nothing to install.

Can I use the Dashboard plusplus carrier design in my own project?

Its repository publishes it under the MIT licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).