Skip to content

System Overview

Foundational architecture: hardware target, partition map, task layout, control-tick scheduling, and the mode/state machines that the rest of this section refers to by name. This page is the map; subsystem pages are the streets.


Hardware Target

Item Value
MCU ESP32-S3-WROOM-1U-N16R8 (dual-core Xtensa LX7 @ 240 MHz)
Flash 16 MB QSPI
PSRAM 8 MB OPI (octal) — PSRAMMode=enabled in build FQBN
Internal RAM ~327 KB total, ~200 KB free heap typical after boot
Loop task stack 20 KB (SET_LOOP_TASK_STACK_SIZE(20 * 1024) — bumped from default 8 KB for TLS handshakes)

PSRAM is mandatory — almost every large buffer is ps_malloc'd at boot (sensor ring, payload buffers, console queue, thermal/PID/CV logs, IMU ring, efficiency matrix, tuning records, cached gz web files). Internal RAM is reserved for stacks, TLS handshakes (~32–40 KB contiguous required by mbedTLS), and the small fast-path globals that the control loop touches every tick.


Flash Partition Map (partitions.csv)

nvs         0x09000   20 KB   Non-volatile storage (auto-fire scalars, version flags, learning tables)
otadata     0x0e000    8 KB   OTA boot-slot selection
factory     0x10000   2.5 MB  Factory firmware (recovery slot)
ota_0       0x290000  2.5 MB  Production firmware (OTA-updated)
factory_fs  0x510000   1 MB   Factory web files (LittleFS — index.html, script.js, styles.css, uPlot.*)
prod_fs     0x610000   1 MB   Production web files (LittleFS, OTA-replaced)
userdata    0x710000  8.88 MB User settings and logs (LittleFS — one .txt per setting)
coredump    0xff0000  64 KB   Crash dumps (`esp_core_dump_*`)

Two app slots (factory/ota_0) and two web slots (factory_fs/prod_fs) form a complete A/B pair. ensurePreferredBootPartition() runs at boot to roll back if a freshly-flashed OTA slot is unhealthy. ensureWebFS() mounts the matching web partition. switchToFactoryWebFiles() is the runtime fallback when production web files fail validation.


Task / Core Layout

Three tasks across two cores. All long-running blocking I/O — TLS handshakes, OneWire conversions, NVS commits with sector erase — is partitioned off Core 1 so the control tick stays responsive.

Task Core Stack Priority Created In Owns
Arduino loop() 1 20 KB 1 implicit Sensors, control loop, alarms, telemetry, web SSE dispatch
TempTask 0 4 KB 1 setup() DS18B20 OneWire conversion (5 s blocking wait)
httpsTask 0 20 KB 1 setup() All HTTPS uploads (sensor history, config, OTA, weather, version reports)
FreeRTOS IDLE × 2, WiFi/TCP/IP, async-tcp 0 system system system Driver-level work

Communication between Core 1 and Core 0 happens through:

  • httpsQueue (FreeRTOS queue, depth 2) — Core 1 enqueues HttpsRequest records; Core 0 drains them one at a time. The queue is intentionally shallow so the dashboard's "queue depth" indicator is a reliable backpressure signal.
  • consoleQueue (PSRAM circular, CONSOLE_QUEUE_SIZE = 10) — any task can call queueConsoleMessage*; dispatched to the browser by trySendConsoleSSE() from Core 1.
  • core0Busy flag — set by Core 0 during an in-flight HTTPS request. Read by Core 1 to gate WiFi-off transitions and OTA entry.

TempTask updates lastTempTaskHeartbeat on every iteration. Core 1's checkTempTaskHealth() watches it: if the heartbeat goes silent for TEMP_TASK_TIMEOUT (20 s), the buzzer fires unconditionally and the temperature staleness gate (T5 in Safeties) cuts the field via MARK_FRESH starvation on IDX_ALTERNATOR_TEMP.


Boot Sequence (setup())

Order matters — peripherals and stored settings must be live before WiFi or the control loop can run safely.

  1. Serial init — 230400 baud, 4 KB TX buffer, 2 KB RX.
  2. PSRAM allocationsconfigPayloadBuffer, payloadBuffer, tempBuffer, filenameBuffer, timestampBuffer, messageBuffer, consoleQueue, sensorRing (1000 × SensorSnapshot ≈ 700 KB), taskArray, ch1Ring (5000 entries), currentWindow/imuWindow structs, imuRingBuffer, tuningLog/cvTuningLog/thermalTuningLog, liveScoreBuckets[4]/cvLiveScoreBuckets[4]/thermalLiveScoreBuckets[4]. Any failure prints FATAL: ... ps_malloc failed and (for currentWindow/imuWindow) halts.
  3. Device identity & tokeninitializeDeviceId(), checkDeviceUIDChange(), loadAuthToken() (Supabase auth).
  4. Version parseFIRMWARE_VERSION ("0.0.32" at time of writing) parsed into firmwareVersionInt for cloud reporting.
  5. StorageinitializeNVS(), loadFuelTableFromNVS(), ensureLittleFS() (halts on failure), initSensorBuffer(), restoreSensorRingFromLittleFS() (drains any backup file left by Phase-4 shutdown on previous power-down).
  6. Function-timing structs — every ft_* FuncTiming struct zeroed.
  7. NVS loadcaptureResetReason(), ensurePreferredBootPartition(), loadNVSData() (~120 persistent scalars, learning tables, IMU stats, vessel info), initNVSCache() (seeds change-detection shadows so saveNVSDataFull() skips no-op writes).
  8. First NVS commitsaveNVSDataFull() runs synchronously to lock boot-time adjustments (e.g. totalPowerCycles++) before loop() starts.
  9. Pins — GPIO4 OUT (Field Enable, LOW), GPIO5 IN (WiFi wake button), GPIO2 OUT (LED), GPIO1 IN (Ignition), GPIO21 OUT (Buzzer, LOW), GPIO42 IN (BMS).
  10. Settings loadInitSystemSettings() (LittleFS one-file-per-setting), initWeatherModeSettings(), loadTuningLog(), loadCVTuningLog(), loadThermalTuningLog(), loadPasswordHash().
  11. OTA-wake check — reads update_req/wake_flag from NVS; if set, extends wifiWakeStart so the device stays connected long enough to receive the pending update.
  12. WindowsresetSensorWindow(), resetAccelWindow() re-seed hi/low watermarks.
  13. Network upsetupWiFi() reads currentMode (MODE_CONFIG/MODE_AP/MODE_CLIENT) and joins/hosts accordingly. Captive portal lives behind a separate web server set up by setupWiFiConfigServer() (used in MODE_CONFIG).
  14. Tasks createdxTaskCreatePinnedToCore(TempTask, …, 0) and xTaskCreatePinnedToCore(httpsTask, …, 0). Both pinned to Core 0.
  15. NTP sync — only in MODE_CLIENT with WiFi up.
  16. Hardware initinitializeHardware() brings up ADS1115, INA228, BMP388, LSM6DSOX (IMU), NMEA2K CAN, DS18B20 OneWire.
  17. Watchdogesp_task_wdt_init/reconfigure with timeout_ms = 16000, trigger_panic = true. Main task added with esp_task_wdt_add(NULL). On timeout the chip panics and reboots; GPIO4 returns to LOW (field off) on reset.
  18. FinalloadLearningTableFromNVS(), two priming ReadAnalogInputs() calls, tempPID_init(), thermalLog_init(), pidLog_init(), cvLog_init(), tuningScore_init(), thermalScore_init(), then === SETUP COMPLETE ===.

Main Loop (loop())

Single iteration is target-bounded by the watchdog (16 s) and rate-bounded by the ADS1115 state machine (CH1 sample every ~4.7 ms in steady state). A nominal iteration runs in single-digit milliseconds; MaximumLoopTime and loopTime5sWindow are tracked in microseconds and reported on the dashboard.

Per-tick top of loop()

  1. Feed watchdogesp_task_wdt_reset().
  2. Ignition + overrideIgnition = !digitalRead(1) (optocoupler inverts); IgnitionOverride lets bench testing force ON/OFF.
  3. WiFi wake buttonWiFiWakeButton = !digitalRead(5). Press extends wifiWakeStart; the 5-min wake window keeps WiFi alive at 240 MHz after ignition cut.
  4. One-shot OTA checks — after 5 s post-boot in MODE_CLIENT, checkForPendingUpdateNonBlocking() and executeCheckForcedUpdate() run once each, enqueued onto httpsQueue if WiFi.RSSI() >= -76.
  5. starttime = esp_timer_get_time() — per-iteration wall-clock baseline.

Periodic (2 s) batched work — gated by SOCUpdateInterval

UpdateEngineRuntime, UpdateEngineFuel, UpdateBatterySOC, UpdateTravelStatistics, UpdateDistanceThisInterval, UpdateBoardTempPressureMaximums, handleSocGainReset, handleAltZeroReset, then calculateChargeTimes unconditionally. All wrapped in TIMED_CALL(ft_*, …) for the Function Timing dashboard table.

Deferred I/O drain — gated by safeToFlushIO()

A bank of pendingSave*/pendingClear* flags set by Core-0 SSE button handlers. The flush only runs after the system has been 5 consecutive seconds out of the "critical zone" (inCriticalZone() is true while voltage is near BulkVoltage or an electrical record was set within 5 s). This keeps NVS commits (~150–300 ms with sector erase) and LittleFS writes from landing on top of CV control activity.

Power management — Ignition state machine

State CPU WiFi TempTask Notes
Ignition ON 240 MHz per currentMode resumed Normal operation
Ignition OFF, wifiWakeActive 240 MHz on (unchanged) User monitoring window (5 min from button press)
Ignition OFF, pendingShutdownFlush Phase 1 240 MHz on (unchanged) Field still ramping — hold full speed until gpio4IsLow
Ignition OFF, Phase 2 (field just cut) 240 MHz on (unchanged) saveNVSDataFull(), saveEfficiencyMatrix(), saveCurrentSessionHealth(), uploadSensorHistory(), drain pendingSave* flags
Ignition OFF, Phase 3 (within 30-min cloud window) 240 MHz on (unchanged) NTP, weather, buffered-record drain, config snapshot
Ignition OFF, Phase 4 (30 min elapsed) 80 MHz off suspended If ring not empty, dumpSensorRingToLittleFS() first. Then low-power idle.
Queue still draining 240 MHz on (unchanged) Capped at 5 min by queueDrainHoldStart — bad WiFi can't hold the device awake forever

shutdownCloudDeadlineMs = millis() + 1800000 is set when Phase 2 enters. Phase 4's 80 MHz transition does WiFi-off first, then vTaskSuspend(tempTaskHandle), then setCpuFrequencyMhz(80) — order matters because suspending TempTask before clocking down avoids racing on its I2C bus access.

Mode switch — MODE_CONFIG / MODE_AP / MODE_CLIENT

  • MODE_CONFIG (boot-time captive portal — entered if WiFi config wire is grounded): dnsHandleRequest() then return from loop(). No alternator operation possible — this is intentional, safe by default.
  • MODE_AP (hotspot — entered if hotspot wire is grounded at boot): dnsHandleRequest() then falls through to MODE_CLIENT code (break is intentionally absent).
  • MODE_CLIENT (normal operation):
  • checkTempTaskHealth() if hardwarePresent == 1.
  • ReadAnalogInputs() (real hardware) or ReadAnalogInputs_Fake() (dev mode).
  • calculateDerivedMetrics() — true wind, leeway, VMG, duty cycles, etc.
  • ReadVEData() if VeData == 1; NMEA2000.ParseMessages() if NMEA2KData == 1.
  • CheckAlarms() every tick (internal 250 ms throttle).
  • calculateThermalStress() — alternator lifetime modeling.
  • checkAutoZeroTriggers() / processAutoZero()must run before AdjustFieldLearnMode because they steer the CV setpoint.
  • updateWeatherMode() if connected.
  • AdjustFieldLearnMode()the control loop (see next section).
  • efficiencyTracker_tick(), drainIMUFifo() (skipped in fake mode — 15 ms I2C timeout per call would flood the loop).
  • logDashboardValues(), updateSystemHealthStats(), updateSensorWindow(), updateAccelMetrics() (if accelEnabled).
  • Field-off NVS drain (5 s) and matrix flush (13 s) — staggered to avoid commit/file-write collision.
  • CloudFeatures block — time sync, sensor upload, buffered-record upload, config snapshot.
  • ch1_compute_stats(), SendWifiData().
  • checkWiFiConnection() if MODE_CLIENT and (Ignition or wake window active).

Per-tick bottom

Per-function rolling-window resets every 5 s, endtime capture, three independent loop-time maxima updated (loopTime5sWindow, MaximumLoopTime, MaxLoopTime — the last persists to NVS as prevSessionMaxLoopTime next boot), 5-min printTempDebugStatus() heartbeat, 10-s LED duty visualizer, checkAndRestart() (24 h maintenance restart), final esp_task_wdt_reset().


The Control Loop — AdjustFieldLearnMode()

Despite the legacy name, this function runs in all modes (auto, manual, fault, off) and is the single entry point for every field-control decision. The leading "LearnMode" is a relic — Learning Mode itself is obsolete and only the table-lookup machinery survives.

Tick structure

  1. thermalLog_tick() — runs unconditionally at the top so temperature history accumulates even during sysID, lockout, or shutdown. Internal 1 Hz throttle.
  2. buildTickSnapshot() — gathers every input used downstream (voltages, currents, temp, RPM, RPM-min-duty, charging-enabled flags, lockout state, thresholds) into a single TickSnapshot struct so decisions are made from a frozen, consistent picture.
  3. Pre-gate immediate-cut checkselectFieldEventReason() + shouldImmediatelyCutGPIO4(). Catches INA228 hardware OV, hard overcurrent, RPM gate, T3 (critical temp). Runs before the CH1 freshness gate so these never get blocked by a stalled current sample.
  4. Fast voltage safety override — computes fastOvCurrentCap from Group 1 (predictive Vpred), Group 2 (measured IBV vs target), iExcess, and load dump. Sets g_fastOvHardActive/g_fastOvClampActive. Rising edge resets the inner-PID integrator (currentPID.ResetIntegratorTo(0.0)). See Safeties for full per-protection details.
  5. Limp HomeLimpHome == 1 short-circuits to handleLimpHome() and returns. 30 % duty, all safeties bypassed except INA228 hardware ALERT.
  6. CH1 freshness gate — non-manual modes return if ch1FreshFlag is false. PidSampleDivisor lets the user step down the control rate (default 1 = every CH1 sample, ~213 Hz / ~4.7 ms).
  7. Full mode/reason selectionselectFieldControlMode() returns one of six FieldControlMode values; selectFieldEventReason() returns one of seventeen FieldEventReason values.
  8. Override edge detectionMaintainMode and TargetVoltageMode entry/exit edges drive one-shot resets.
  9. Charging stageupdateChargingStage() runs in AUTO with no override active. Manages bulk → absorption → float/idle / re-bulk transitions.
  10. Branch — based on mode:
    • MODE_NORMAL_AUTO_PID — full target-from-RPM-table → thermal-PID outer → current-PID inner → governor → PWM path.
    • MODE_NORMAL_MANUALManualDutyTarget slewed by the governor at DutyRampRate.
    • Anything else — runShutdownPath() (Phase 1 fast ramp → optional Phase 2 hold → Phase 3 slow ramp → Phase 4 settle → GPIO4 cut → lockout).

Mode and reason enums

enum FieldControlMode {
  MODE_CRITICAL_RAMP,             // Critical fault: skip Phase 1/3, drop to Phase 4
  MODE_WARNING_RAMP_AND_LOCKOUT,  // Warning: full ramp, then cooldown lockout
  MODE_LOCKOUT_RAMP,              // Auto-zero or post-fault cooldown — ramp to 0
  MODE_DISABLED_RAMP,             // User off — ramp to 0
  MODE_NORMAL_MANUAL,             // ManualFieldToggle path
  MODE_NORMAL_AUTO_PID            // PID control with charging-stage logic
};

enum SystemMode { SYS_MODE_OFF, SYS_MODE_MANUAL, SYS_MODE_AUTO, SYS_MODE_FAULT };
enum GovernorMode { GOV_NORMAL_SLEW, GOV_BYPASS_SLEW, GOV_HOLD };

enum ShutdownPhase {
  SHUTDOWN_PHASE_NONE = 0,
  SHUTDOWN_PHASE_1 = 1,   // Fast ramp at DutyRampRate (50 %/s)
  SHUTDOWN_PHASE_3 = 3,   // Slow ramp at DutySlowRampRate (1 %/s)
  SHUTDOWN_PHASE_4 = 4    // Settle at 0, then cut GPIO4 LOW
};

sysMode is the top-level state of the actuator; govMode is the per-tick slew-rate behavior; shutdownPhase is the current step within a shutdown ramp. They evolve independently.

Priority chain — selectFieldControlMode()

1. !chargingEnabled        → MODE_DISABLED_RAMP
2. manualMode              → MODE_NORMAL_MANUAL          (UNRESTRICTED — bypasses safeties below)
3. rpmBelowMinimum         → MODE_CRITICAL_RAMP
4. tempDataVeryStale       → MODE_CRITICAL_RAMP
   !voltagePlausible       → MODE_CRITICAL_RAMP
   voltageDisagreementCrit → MODE_CRITICAL_RAMP
   temp > limit + critExc  → MODE_CRITICAL_RAMP
5. testProt && V > hardSDV → MODE_WARNING_RAMP_AND_LOCKOUT
   voltageDisagreementWarn → MODE_WARNING_RAMP_AND_LOCKOUT
   temp > limit + warnExc  → MODE_WARNING_RAMP_AND_LOCKOUT
6. autoZeroActive          → MODE_LOCKOUT_RAMP
7. inLockout               → MODE_LOCKOUT_RAMP
8. (default)               → MODE_NORMAL_AUTO_PID

selectFieldEventReason() returns reasons in the same priority order with two extras at the very top: REASON_INA_OVERVOLTAGE (hardware latch) outranks everything, and REASON_HARD_OVERCURRENT (debounced by HardOCDebounceMs) is checked next. These two plus REASON_RPM_TOO_LOW and REASON_TEMP_CRITICAL are the four reasons that fire immediate cut (no ramp); see shouldImmediatelyCutGPIO4().


Sensor Pipeline

ADS1115 — 4-channel 16-bit ADC (I²C 0x48)

State Action
ADS_IDLE Set MUX to adsCurrentChannel, trigger single-shot conversion, capture adsStateEntered, go to ADS_WAIT
ADS_WAIT Time-based ready check — at 860 SPS conversion is 1.16 ms, ADS_CONVERSION_MS = 3 gives millis() granularity margin. Timeout (ADS_TIMEOUT_MS = 10) retries ADS_IDLE
ADS_READ_RESULT Raw I²C read of conversion register (skips adc.getConversion() to avoid its blocking poll). Channel-specific scaling and sanity check below. Then advance adsSeqIdx and immediately back-to-back trigger if ≥2 ms elapsed (saves one loop() call per step)

Sequence is adsSeq[] = {1, 0, 1, 2, 1, 3} — CH1 (alternator current) fires 3 of 6 steps for ~213 Hz / ~4.7 ms cadence; CH0/CH2/CH3 cycle every ~14 ms.

Channel meanings and scaling:

Ch Source Scaling
0 Battery voltage divider (1 MΩ / 49.9 kΩ → ratio ~21.04) BatteryV = Raw / 32768 * 6.144 * 21.0401 V
1 Hall clamp (QNHCK1-21, ratiometric ±2 V around 2.5 V) Scale 100 / 150 / 250 A·V⁻¹ for AmpSensorRange 0 / 1 / 2 → MeasuredAmps. Sanity bands 250 / 370 / 600 A
2 Stator pulse → RPM via RPMScalingFactor (with LM2907 R-mod) RPM = Channel2V. < 100 RPM forced to 0
3 NTC thermistor via voltage divider temperatureThermistor in °F (converted from °C)

CH1 also feeds ch1Ring (5000-entry PSRAM ring for the 2-minute bucketed history), the iAmpRing (I_RING_SIZE = 10) for MA-N filters used by the iExcess supervisor and inner PID, and the EMA filters MeasuredAmps_filtered (InputFilterTC) and g_pidI_filtered (OutputPIDFilterTC). cvLog_tick() is also called here so CV-loop logs are pinned to actual sample arrival.

INA228 — Battery voltage/current monitor (I²C 0x40)

Dynamic conversion-time switching keeps the bus voltage signal both fast and quiet:

Field state AVG reg Conv-time reg Update cadence Used for
ON (gpio4IsLow == false) 4 (4 samples) 4 (540 µs each) ~4.3 ms (INA_FAST_INTERVAL_MS = 5) Fast voltage loop, dvdt, Group 1/2 OV protection
OFF (gpio4IsLow == true) 4×128 (128 samples) 7 (4120 µs each) ~1054 ms (INA_SLOW_INTERVAL_MS = 1100) Idle monitoring, hardware ALERT averaging

Transition writes only 2 I²C registers; no cost on steady state. IBV_filtered (EMA at VoltageFilterTC) is reseeded to raw IBV on the field-on transition so the CV loop starts clean. g_dBcur_dt is computed on every read for the load-dump supervisor. The hardware ALERT pin maps to GPIO4 (open-drain) — overvoltage tripping is hardware-level and runs even if firmware hangs.

DS18B20 — Alternator temperature (OneWire on GPIO13)

Runs entirely on Core 0 in TempTask. The 5-second blocking conversion would otherwise destroy loop() timing. Heartbeat in lastTempTaskHeartbeat; the T4 protection and T5 staleness gate both watch it.

BMP388 — Barometric pressure + ambient temp (I²C 0x76)

ambientTemp and baroPressure updated by a non-blocking state machine in ReadAnalogInputs. Feeds the weather-mode tolerance and the alternator-cooling thermal model.

LSM6DSOX — IMU (accel + gyro)

Configured for FIFO mode at boot in imuInit(). drainIMUFifo() runs each tick when hardwarePresent == 1 (skipped in fake mode — a 15 ms I²C timeout per attempt would flood loop()). Sample windowing in updateAccelMetrics produces heel/pitch deviation, motion-sickness index, slam count, and anchorage comfort score for the cloud UI.

Data freshness — MARK_FRESH / IS_STALE

enum DataIndex { IDX_HEADING_NMEA, IDX_LATITUDE_NMEA, , IDX_IMU, MAX_DATA_INDICES = 35 };
unsigned long dataTimestamps[MAX_DATA_INDICES];
#define MARK_FRESH(index) dataTimestamps[index] = millis()
#define IS_STALE(index)   (millis() - dataTimestamps[index] > DATA_TIMEOUT)  // 10 s default

Every read site that produces a validated value calls MARK_FRESH(IDX_*). The dashboard greys out fields whose stamp is older than per-source thresholds defined in script.js (see JavaScript Logic). IDX_MEASURED_AMPS staleness (>10 s with field on) triggers the REASON_CURRENT_STALE critical ramp.


Storage Strategy — NVS vs LittleFS

Both live in flash, but their wear patterns and write semantics differ:

Concern NVS (storage, learning, update_req, etc. namespaces) LittleFS (/<setting>.txt)
What goes here Auto-fire scalars: extrema, accumulators, counters, IMU stats, peak loop times, learning tables, totalPowerCycles, vessel info One file per user-edited setting (BulkVoltage, KHard, TdPred, etc.) plus the sensor-ring backup blob
Write trigger Periodic saveNVSDataFull() — but only at the field-off edge (+5 s), shutdown phase 2, and a few one-shots. Periodic commits during field-on were removed because nvs_commit can block Core 1 ~300 ms during sector erase When the user submits a form (foundParameter = true in the /get handler, which calls writeFile())
Change detection initNVSCache() seeds prev_* shadows; saveNVSData* skips writes when shadows match (zero-wear no-ops) None needed — writes are user-rate, not loop-rate
Read loadNVSData() once at boot readFile() once at boot in InitSystemSettings()
Atomicity NVS API is transactional within a commit Single-file rewrite

plan1_storage_migration.md (top-level repo) records the rationale and stage-by-stage migration progress; see also the in-code rule in CLAUDE.md.


Telemetry Channels (Server-Sent Events over WiFi)

One SSE stream (/events) carries four payload types. Only one channel transmits per loop() iteration — priority is CSV1 first, then console, then CSV2 (gated behind CSV1), then CSV3, then TS. Adding a field to a payload doesn't increase per-tick CPU; it just lengthens the eventual packet when that channel's turn comes up.

Channel Cadence Source variable Used for
CSVData (CSV1) webgaugesinterval (100 ms default) High-rate live numbers Control-loop telemetry, fast UI gauges, real-time plots
CSVData2 (CSV2) 5 s (500 ms during sysID) Slower telemetry, function-timing (ft_*), counters, per-session worsts, IMU summaries Status panels, ESP32 stats, diagnostics
CSVData3 (CSV3) Event-driven on settingsDirty rising edge, 60 s heartbeat fallback User-configurable setting echoes + per-setting tuning constants Echo labels next to form inputs ("BulkVoltage = 14.4 V")
TimestampData (TS) 3 s millis() of last update per sensor source Staleness greying on the dashboard

SafeInt(v, scale) is the canonical packing helper — multiplies float by scale (commonly 100 for two-decimal voltages, 1000 for ms-as-seconds), rounds, returns an int. Format-string specifier count and the CSV*_FIELD_COUNT enum at the bottom of each declaration must match — there's a mandatory verification recipe in CLAUDE.md because a silent specifier mismatch drops trailing fields and the entire packet is rejected by the browser.


Watchdog and Recovery

  • Hardware watchdog: esp_task_wdt configured for 16 s, trigger_panic = true. Monitors the main loop task on Core 1. Reset → all GPIO LOW → field collapses through MOSFET driver → batteries safe.
  • Loop iteration tracking: every iteration measures LoopTime = endtime - starttime in µs; if > 5,000,000 (5 s) a console warning fires. MaximumLoopTime (session) and MaxLoopTime (NVS-persistent → prevSessionMaxLoopTime next boot) are dashboard-visible.
  • Function timing: TIMED_CALL(ft_xxx, fn()) wraps every long-running function. The ft_xxx struct tracks lastCall, worstWindow (5 s rolling), and worstSession (since boot or since "Reset Peak Values" button). Surfaced on the Function Timing dashboard table.
  • TempTask heartbeat: lastTempTaskHeartbeat updated every iteration; Core 1 watches it and fires the buzzer unconditionally if quiet for 20 s.
  • Scheduled maintenance restart: checkAndRestart() reboots every 24 h regardless of state — long-term memory-fragmentation hedge.
  • Crash dumps: coredump partition (64 KB) captures backtrace + register state on panic; usable with xtensa-esp32s3-elf-addr2line against the .elf (recipe in CLAUDE.md).

Cross-references