
Test 09. With ESP module
henkjannl/RoomThermostat
Opentherm room thermostat that can be controlled through Telegram, but also locally

Dashboard++ V2
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.
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.
Two generations are described in these pages, and they are not the same product:
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.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.
Dashboard++ replaces legacy analog or basic digital gauges with an automotive-grade telemetry console. Key capabilities include:
.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.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).The system leverages the ESP32's Xtensa dual-core processor via FreeRTOS tasks to guarantee deterministic sensor sampling without visual stuttering or UI delays.
┌─────────────────────────────────────────────────────────────────────────────────┐
│ 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.
btTaskis 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.setover the link (BT_F_WEATHERon the RPC characteristic) with temperature, humidity, wind, sunrise/sunset as epoch seconds (converted toHH:MMthroughTZ_OFFSET_HOURS+TZ_DST_RULE), the city label and avalidflag, and the result lands ing_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 pushestime.setonce per link-up, and a GNSS fix sets it too.
[!NOTE] Every FreeRTOS task is started through
startTask()insrc/main.cpp, which checks thexTaskCreatePinnedToCore()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 afailedTasksMaskand 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.
| Component | Part / Model | Protocol / Signal | Specifications |
|---|---|---|---|
| Microcontroller | ESP32-WROOM-32 | Xtensa 32-bit LX6 | Dual-core 240 MHz, 520 KB SRAM, 4 MB SPI Flash, RTC IO |
| Display Panel | ILI9488 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 Backlight | LED 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 Module | BZGNSS 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 Sensor | Hall Effect Interrupt | GPIO33 (Input Pullup) | Any-edge ISR stamps the edges; interval/filter logic runs on the sensor task, microsecond timing |
| Fuel Level Sensor | Marine 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 Sensor | NTC Thermistor (10k/100k) | GPIO36 (ADC1_CH0) | Analog 0–3.3V, Steinhart-Hart equation, voltage divider balance |
| Battery Voltage | Voltage Divider (5.7:1) | GPIO35 (ADC1_CH7) | Analog 0–3.3V, range 0–18.8V DC, sampled every 500 ms |
| Power / Ignition | Ignition Key Sense Line | GPIO4 | High=Ignition ON, Low=Power Lost → Animated Deep Sleep |
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 Pin | Function Name | Peripheral Type | Signal Direction | Hardware Configuration & Notes |
|---|---|---|---|---|
| GPIO0 | BOOT | Factory Reset | Input (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) |
| GPIO1 | Console TX | Debug Console | Output | Serial.setPins(1, 3) pins the console explicitly (arduino-esp32 3.x moved the UART1 default to GPIO26/27) |
| GPIO3 | Console RX | Debug Console | Input | Same Serial.setPins(1, 3) call |
| GPIO4 | POWER_SENSE_PIN | Power Sense | Input (No Pull) | Ignition sense line; triggers EXT0 RTC wake up from deep sleep |
| GPIO5 | CS_DISPLAY | SPI Chip Select | Output | Hardware SPI CS for ILI9488 Display |
| GPIO12 | BL_DISPLAY | Backlight PWM | Output | Pin-based ledcAttach(BL_DISPLAY, BL_PWM_HZ, 8) — frequency is the BL_PWM_HZ knob (default 12 kHz, above hearing), 8-bit resolution |
| GPIO14 | SPI_RST | Display Reset | Output | Active-Low hardware reset line for ILI9488 |
| GPIO16 | GNSS_UART2_RX_PIN | GPS Serial RX (UART2) | Input | Connected to the GNSS module TX pin. Receive-only: the firmware never transmits to the module |
| GPIO17 | GNSS_UART2_TX_PIN | GPS Serial TX (UART2) | Output | Connected to the GNSS module RX pin; configured but idle - no UBX command is ever sent |
| GPIO18 | cfg.pin_sclk | SPI Clock | Output | Hardcoded in gfx.cpp (SPI3_HOST), 60 MHz default |
| GPIO23 | cfg.pin_mosi | SPI Master Out | Output | Hardcoded in gfx.cpp, LCD data/command stream |
| GPIO25 | TRIP_RESET_PIN | Trip Reset Button | Input (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 |
| GPIO27 | SPI_DC | Data / Command | Output | High = Data, Low = Command for ILI9488 controller |
| GPIO32 | FUEL_TOUCH_PIN | Fuel sender input | Input | Dedicated ADC1 Channel 4 pin: 3V3 → FUEL_EXC_RES_OHM → GPIO32 → sender → tank ground (capacitive touch removed in v1.3.6) |
| GPIO33 | HALL_SENSOR_PIN | Hall Interrupt | Input (Pullup) | Any-edge (CHANGE) hardware interrupt: the ISR timestamps both transitions, the filter chain validates them on the sensor task |
| GPIO34 | LIGHT_SENSOR_PIN | Ambient Light | Input (No Pull) | LDR ambient light sensor for auto-brightness (calibrated via /api/ambient/cal-dark / cal-bright) |
| GPIO35 | BATTERY_SENSE_PIN | Battery ADC | Input (No Pull) | Connected to 5.7:1 precision resistor divider node |
| GPIO36 | TEMP_SENSE_PIN | Engine Temp ADC | Input (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.hpin defines and the hardcoded display SPI pins insrc/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.
Dashboard++ features a dual-source speed calculation engine that combines low-latency wheel rotation timing with absolute satellite GPS telemetry.
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:
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=.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.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.
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.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:
0.0 km/h, eliminating GPS drift at traffic lights.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.
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).
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).len > 92) rather than overflowing the payload buffer, and a bad checksum drops exactly one frame before resyncing.ubxSyncSeen, ubxCkFail, ubxOversize are reported in the GPS debug telemetry so a framing problem is visible instead of looking like lost satellites.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.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).
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.
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$$
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.
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 badgesConthrax_SemiBold_16px.vlw → Conthrax_SemiBold_16px_vlw.h — Unit labels and sidebar textConthrax_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.
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):
3V3 ─── FUEL_EXC_RES_OHM ─── GPIO32 (ADC1_CH4) ─── sender ─── tank groundFUEL_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.
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.
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:
| Condition | Reported as |
|---|---|
FUEL_INPUT_ENABLED off | no 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 maximum | open 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).
$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).
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$).
$$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)$$
$$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.
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:
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.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.NTC_TEMP_OFFSET. NTC_R25, NTC_R_BALANCE and NTC_BETA are under Advanced.BATTERY_OFFSET. BATTERY_SCALE (divider ratio) is under Advanced.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.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.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):
┌─────────┐ Speed >= StartSpeed ┌─────────┐
│ READY │ ─────────────────────────> │ RUNNING │
└─────────┘ └─────────┘
▲ │
│ Speed < Start (Reset) │ Speed >= TargetSpeed OR Timeout
└──────────────────────────────────────┼───────────────────────┐
▼ │
┌──────────┐ │
│ FINISHED │ <────────────────┘
└──────────┘To balance processing performance, power consumption, and thermal stability, Dashboard++ includes dynamic CPU management and ignition sensing.
The governor uses a 3-state machine with hysteresis deadbands to prevent frequency oscillation:
┌──────────────────────────────┐
│ 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 MHzWhen ENABLE_POWER_SENSE is enabled and POWER_SENSE_PIN (GPIO4) stays continuously LOW for POWER_SENSE_OFF_MS (default 10 s, 500–120000 ms):
showGoodbyeScreen(true)).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).
[!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) andsrc/bt_rpc.cpp(the JSON methods).
| Object | UUID | Properties | Access |
|---|---|---|---|
| Service | d9a1f000-6c1e-4b8a-9f2d-3e7c1a5b9d40 | — | — |
| Telemetry (unit → phone) | d9a1f001-6c1e-4b8a-9f2d-3e7c1a5b9d40 | notify (+ CCCD) | read/notify encrypted |
| RPC (both ways) | d9a1f002-6c1e-4b8a-9f2d-3e7c1a5b9d40 | write, write-nr, notify (+ CCCD) | ESP_GATT_PERM_WRITE_ENC_MITM |
| OTA (firmware image) | d9a1f003-6c1e-4b8a-9f2d-3e7c1a5b9d40 | write-nr | ESP_GATT_PERM_WRITE_ENC_MITM |
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
DPP-* advertising".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.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.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.
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 endianPayload 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.
| Type | Name | Direction / meaning |
|---|---|---|
| 1 | BT_F_REQ | phone → unit, one JSON request |
| 2 | BT_F_RESP | unit → phone, one complete JSON response |
| 3 | BT_F_TELEM | unit → phone, the 1 Hz snapshot, unprompted |
| 4 | BT_F_WEATHER | phone → unit, the weather the panel shows |
| 5 | BT_F_TIME | phone → unit, epoch clock set |
| 6/7 | BT_F_PING / BT_F_PONG | link keepalive |
| 8 | BT_F_OTA | firmware image frames on the OTA characteristic |
| 9 | BT_F_LOG | unit → phone, serial-ring log lines |
| 10 | BT_F_CHUNK | one 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.
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:
| Method | What it does | Replaces |
|---|---|---|
snapshot, live, sensors, fuel, ambient, noise, perf, health, boot | the 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.get | full NVS export, streamed as BT_F_CHUNK frames | GET /api/config (backup download) |
config.set | apply a config object; a save whose NVS writes partly failed answers with nvsErrors, nvsFailedKeys, nvsAvailable | POST /api/config (restore upload) |
config.reset, factory.reset | reset the settings / clear settings and bonds | POST /api/reset |
odo.get, odo.set, trip.reset | odometer read/write, trip zero (same reset as the GPIO25 button) | /api/odo, /api/trip/reset |
reboot, sleep | graceful restart, immediate deep sleep | /api/reboot, /api/sleep |
cal.ambient (dark/bright) | ambient-light calibration points | /api/ambient/cal-dark, -bright |
bench.run, noise | start the 4-run ADC noise bench / read its last result | POST/GET /api/noisematrix |
log.get | the 4 KB serial ring, pulled on demand | GET /api/serial (which streamed) |
weather.set, time.set | the unit has no radio of its own: the phone supplies the weather widget and the clock | nothing — the unit used to fetch weather itself and sync NTP; both fetchers are deleted |
ping | keepalive | — |
ota.begin, ota.end, ota.status, ota.abort | firmware 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) |
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.
| Step | On the wire |
|---|---|
| Open | RPC 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 |
| Data | The 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 |
| Watch | RPC ota.status — bytes on flash, declared size, where the unit stands (opening / active / rebooting / failed / off); ota.abort hands the slot back |
| Close | RPC 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:
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.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.ota.end.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".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.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.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.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.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.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.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.
[!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.htmlandscripts/gzip_webui.pywere deleted on 2026-11-02 and there is nobuild_src_filterline left to put them back — a unit flashed from this tree has noDashboard_Confighotspot, no192.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).
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).[!IMPORTANT] Access model (deliberate). The config portal and the REST API have no login — access control is the Wi-Fi itself.
AP_PASSWORDmust 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.
The management portal features a modern grouped card-based layout:
ADV_MODE)src/bt_ota.cpp; see Firmware over the link)GET /api/config as dashboard_backup.json, import posts it back to POST /api/config)| Endpoint | Method | Description | Request Payload / Params | Content-Type |
|---|---|---|---|---|
/ | GET | Serves pre-gzipped single-page Web UI | None | text/html |
/debug | GET | Dump first bytes of the pre-gzipped UI buffer (build sanity check) | None | text/plain |
/api/config | GET | Exports complete NVS configuration plus read-only build_version | None | application/json |
/api/config | POST | Updates 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 object | application/json |
/api/wifi/scan | GET | Cached 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 radio | None | application/json |
/api/wifi/scan | POST | Queues 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 result | None | application/json ({"status":"requested"} / {"status":"scanning"}) |
/api/wifi/join | POST | Stores 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 slot | application/json (status, slot, ssid, nvsOk, overwrite) |
/api/wifi/forget | POST | Removes 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/time | POST | Syncs system clock from browser | JSON body {"timestamp":1700000000} (integer epoch s; must fall in 2020–2100) | application/json ({"status":"ok"} / 400 with reason when out of range) |
/api/odo | GET | Reads odometer distance in km | None | application/json |
/api/odo | POST | Sets odometer distance | {"km": 123.45} | application/json |
/api/trip/reset | POST | Zeros the trip stats (same reset as the GPIO25 button); the odometer and session max speed are untouched | None | application/json |
/api/reboot | POST | Triggers graceful device restart | None | text/plain |
/api/sleep | POST | Triggers immediate deep sleep | None | text/plain |
/api/reset | POST | Factory reset — clears the cfg namespace; WiFi credentials and the odometer survive | None | text/plain |
/api/ambient | GET | Reads raw ambient light sensor value | None | application/json |
/api/sensors | GET | Reads calibrated battery voltage (v) and coolant temperature (t) | None | application/json |
/api/fuel | GET | Reads the fuel input: raw averaged ADC code, liters, pct, ohm sender resistance, st input state (0 = disabled, 1 = ok, 2 = open circuit, 3 = shorted) | None | application/json |
/api/live | GET | Single 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 instant | None | application/json |
/api/snapshot | GET | Full 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/live | None | application/json |
/api/noisematrix | GET | Noise-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) | None | application/json |
/api/noisematrix | POST | Starts 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 running | None | application/json |
/api/ambient/cal-dark | POST | Sets dark-reference ambient light value (auto-brightness floor) | None | application/json ({"status":"ok"|"saved-ram-only","value":N} — saved-ram-only means the value is applied but the NVS write failed) |
/api/ambient/cal-bright | POST | Sets bright-reference ambient light value (auto-brightness ceiling) | None | application/json (same shape as cal-dark) |
/api/ota | POST | Firmware 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 logged | application/json |
/api/ota/check | GET | Reports install state for whoever is pushing the image: state (`idle | staging | receiving |
/api/serial | GET | Streams internal 4 KB ring buffer logs | None | text/plain |
/api/perf | GET | Live 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 DHCP | None | application/json |
/api/health | GET | Health probe: heap, FPS, partition layout and the NVS entry budget (nvs_bytes, nvs_entries_used / _available / _total) | None | application/json |
/api/boot | GET | Boot/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) | None | application/json |
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.
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.
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.
nan, inf) fall back to the parameter's default rather than poisoning a calculation.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.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).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.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.
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.
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.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.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.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_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_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.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.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.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.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.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.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_PASSWORD | WIFI_SSID, WIFI_PWD | the station credentials the unit joined (plus the multi-slot list on older builds) |
AP_PASSWORD | AP_PWD | the Dashboard_Config hotspot passphrase |
WIFI_ATTEMPT_SECONDS | WIFI_ATTEM_S | how long one network got per join try |
NTP_ENABLED, NTP_SERVER | NTP_EN, NTP_SRV | the SNTP client |
WEATHER_CITY, WEATHER_LAT, WEATHER_LON, WEATHER_REFRESH_MIN, WEATHER_LOCALE | WEATH_CITY, WEATH_LAT, WEATH_LON, WEATH_RFR, WEATH_LOCALE | the unit's own Open-Meteo forecast |
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.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.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.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.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).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.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.
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:
| Slot | AppTab | Screen | |
|---|---|---|---|
| 0 | LIVE | LiveTab | The app's own live gauges, drawn from the telemetry the unit pushes (1 Hz) |
| 1 | TRIPS | TripsTab | Recorded drives, badges, filter, detail |
| 2 | VEHICLES | VehicleScreen | The garage, and the only place a pairing or a connection is made. centre and home: the app opens here. |
| 3 | WEB | DashboardSettingsScreen | The 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" |
| 4 | SETTINGS | SettingsScreen | This 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.
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).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.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).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:
mac is that card, whatever BLE address it advertises at.mac is matched by its stored BLE address instead — the narrower rule that keeps a unit whose firmware predates perf.mac usable.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.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.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:
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-B594The 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.
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.
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.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.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 see | What it means | What to do |
|---|---|---|
| Nothing in the scan list | The 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 range | Move closer; check the unit's boot log for BT: advertising as …; if another phone holds the link, disconnect there first and wait f |
| Qty | Value | Footprint | References |
|---|---|---|---|
| 10 | 100n | dpp:C_0805_2012Metric | C2, C4, C5, C6, C7, C8, C9, C10, C11, C13 |
| 7 | 10k | dpp:R_0805_2012Metric | R1, R3, R6, R7, R9, R11, R12 |
| 4 | 1k | dpp:R_0805_2012Metric | R4, R8, R10, R13 |
| 4 | MOUNT | dpp:MOUNT | H1, H2, H3, H4 |
| 2 | EL357N-B | dpp:SO-4_4.4x3.9mm_P2.54mm | OK1, OK2 |
| 1 | 220R | dpp:R_0805_2012Metric | R5 |
| 1 | 220u/50V | dpp:CP_Elec_10x12.5 | C1 |
| 1 | 470u/35V | dpp:CP_Elec_10x12.5 | C3 |
| 1 | 47k | dpp:R_0805_2012Metric | R2 |
| 1 | 5x20 2A | dpp:Fuseholder_Clip-5x20mm_Littelfuse_521_Lateral_P17.00x5.00mm_D1.30mm_Horizontal | F1 |
| 1 | BAT_SNS | dpp:TestPoint_Pad_D1.5mm | TP3 |
| 1 | BZT52C15V | dpp:D_SOD-123 | D1 |
| 1 | DEBUG | dpp:DEBUG | J13 |
| 1 | DPP_DCDC_MODULE | dpp:DPP_DCDC_MODULE | U4 |
| 1 | ESP32-DEVKIT-A | dpp:PinSocket_1x15_P2.54mm_Vertical | J10a |
| 1 | ESP32-DEVKIT-B | dpp:PinSocket_1x15_P2.54mm_Vertical | J10b |
| 1 | FUEL_SNS | dpp:TestPoint_Pad_D1.5mm | TP4 |
| 1 | GND | dpp:TestPoint_Pad_D1.5mm | TP5 |
| 1 | GPS_PADS | dpp:GPS_PADS | J8 |
| 1 | HALL | dpp:JST_PH_S3B-PH-SM4-TB_1x03-1MP_P2.00mm_Horizontal | J9 |
| 1 | HALL | dpp:TestPoint_Pad_D1.5mm | TP2 |
| 1 | IGN + COOLANT | dpp:JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal | J17 |
| 1 | IGN_SENSE | dpp:TestPoint_Pad_D1.5mm | TP1 |
| 1 | IRFR9540N | dpp:TO-263-3_TabPin2 | Q1 |
| 1 | JP1_PADS_3 | dpp:JP1_PADS_3 | JP1 |
| 1 | LDR + FUEL | dpp:JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal | J6 |
| 1 | P6SMB33A | dpp:D_SMB | D2 |
| 1 | P6SMB6V2A | dpp:D_SMB | D3 |
| 1 | SMF4L33A | dpp:D_SOD-123F | D4 |
| 1 | SPARE | dpp:SPARE | J15 |
| 1 | SPARE N | dpp:SPARE_N | J16 |
| 1 | SPARE S | dpp:SPARE_S | J18 |
| 1 | TFT-ILI9488 | dpp:PinSocket_1x14_P2.54mm_Vertical | J3 |
| 1 | TRIP + EN | dpp:JST_PH_S4B-PH-SM4-TB_1x04-1MP_P2.00mm_Horizontal | J14 |
| 1 | XT30U-M | dpp:AMASS_XT30U-M_1x02_P5.0mm_Vertical | J1 |

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

m47812/Custom-3D-Printer-Controller
A fully custom 3D Printer controller board based on an stm32f7 controller with TMC2209 stepper drivers

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).

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.


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.

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

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
It measures 95 × 55 mm and has 4 copper layers, 1.6 mm thick.
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.
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.
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).