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 enqueuesHttpsRequestrecords; 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 callqueueConsoleMessage*; dispatched to the browser bytrySendConsoleSSE()from Core 1.core0Busyflag — 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.
- Serial init — 230400 baud, 4 KB TX buffer, 2 KB RX.
- PSRAM allocations —
configPayloadBuffer,payloadBuffer,tempBuffer,filenameBuffer,timestampBuffer,messageBuffer,consoleQueue,sensorRing(1000 ×SensorSnapshot≈ 700 KB),taskArray,ch1Ring(5000 entries),currentWindow/imuWindowstructs,imuRingBuffer,tuningLog/cvTuningLog/thermalTuningLog,liveScoreBuckets[4]/cvLiveScoreBuckets[4]/thermalLiveScoreBuckets[4]. Any failure printsFATAL: ... ps_malloc failedand (forcurrentWindow/imuWindow) halts. - Device identity & token —
initializeDeviceId(),checkDeviceUIDChange(),loadAuthToken()(Supabase auth). - Version parse —
FIRMWARE_VERSION("0.0.32"at time of writing) parsed intofirmwareVersionIntfor cloud reporting. - Storage —
initializeNVS(),loadFuelTableFromNVS(),ensureLittleFS()(halts on failure),initSensorBuffer(),restoreSensorRingFromLittleFS()(drains any backup file left by Phase-4 shutdown on previous power-down). - Function-timing structs — every
ft_*FuncTimingstruct zeroed. - NVS load —
captureResetReason(),ensurePreferredBootPartition(),loadNVSData()(~120 persistent scalars, learning tables, IMU stats, vessel info),initNVSCache()(seeds change-detection shadows sosaveNVSDataFull()skips no-op writes). - First NVS commit —
saveNVSDataFull()runs synchronously to lock boot-time adjustments (e.g.totalPowerCycles++) beforeloop()starts. - Pins — GPIO4 OUT (Field Enable, LOW), GPIO5 IN (WiFi wake button), GPIO2 OUT (LED), GPIO1 IN (Ignition), GPIO21 OUT (Buzzer, LOW), GPIO42 IN (BMS).
- Settings load —
InitSystemSettings()(LittleFS one-file-per-setting),initWeatherModeSettings(),loadTuningLog(),loadCVTuningLog(),loadThermalTuningLog(),loadPasswordHash(). - OTA-wake check — reads
update_req/wake_flagfrom NVS; if set, extendswifiWakeStartso the device stays connected long enough to receive the pending update. - Windows —
resetSensorWindow(),resetAccelWindow()re-seed hi/low watermarks. - Network up —
setupWiFi()readscurrentMode(MODE_CONFIG/MODE_AP/MODE_CLIENT) and joins/hosts accordingly. Captive portal lives behind a separate web server set up bysetupWiFiConfigServer()(used inMODE_CONFIG). - Tasks created —
xTaskCreatePinnedToCore(TempTask, …, 0)andxTaskCreatePinnedToCore(httpsTask, …, 0). Both pinned to Core 0. - NTP sync — only in
MODE_CLIENTwith WiFi up. - Hardware init —
initializeHardware()brings up ADS1115, INA228, BMP388, LSM6DSOX (IMU), NMEA2K CAN, DS18B20 OneWire. - Watchdog —
esp_task_wdt_init/reconfigurewithtimeout_ms = 16000,trigger_panic = true. Main task added withesp_task_wdt_add(NULL). On timeout the chip panics and reboots; GPIO4 returns to LOW (field off) on reset. - Final —
loadLearningTableFromNVS(), two primingReadAnalogInputs()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()¶
- Feed watchdog —
esp_task_wdt_reset(). - Ignition + override —
Ignition = !digitalRead(1)(optocoupler inverts);IgnitionOverridelets bench testing force ON/OFF. - WiFi wake button —
WiFiWakeButton = !digitalRead(5). Press extendswifiWakeStart; the 5-min wake window keeps WiFi alive at 240 MHz after ignition cut. - One-shot OTA checks — after 5 s post-boot in
MODE_CLIENT,checkForPendingUpdateNonBlocking()andexecuteCheckForcedUpdate()run once each, enqueued ontohttpsQueueifWiFi.RSSI() >= -76. 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()thenreturnfromloop(). 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 toMODE_CLIENTcode (breakis intentionally absent).MODE_CLIENT(normal operation):checkTempTaskHealth()ifhardwarePresent == 1.ReadAnalogInputs()(real hardware) orReadAnalogInputs_Fake()(dev mode).calculateDerivedMetrics()— true wind, leeway, VMG, duty cycles, etc.ReadVEData()ifVeData == 1;NMEA2000.ParseMessages()ifNMEA2KData == 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()(ifaccelEnabled).- Field-off NVS drain (5 s) and matrix flush (13 s) — staggered to avoid commit/file-write collision.
CloudFeaturesblock — time sync, sensor upload, buffered-record upload, config snapshot.ch1_compute_stats(),SendWifiData().checkWiFiConnection()ifMODE_CLIENTand (Ignitionor 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¶
thermalLog_tick()— runs unconditionally at the top so temperature history accumulates even during sysID, lockout, or shutdown. Internal 1 Hz throttle.buildTickSnapshot()— gathers every input used downstream (voltages, currents, temp, RPM, RPM-min-duty, charging-enabled flags, lockout state, thresholds) into a singleTickSnapshotstruct so decisions are made from a frozen, consistent picture.- Pre-gate immediate-cut check —
selectFieldEventReason()+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. - Fast voltage safety override — computes
fastOvCurrentCapfrom Group 1 (predictiveVpred), Group 2 (measuredIBVvs target), iExcess, and load dump. Setsg_fastOvHardActive/g_fastOvClampActive. Rising edge resets the inner-PID integrator (currentPID.ResetIntegratorTo(0.0)). See Safeties for full per-protection details. - Limp Home —
LimpHome == 1short-circuits tohandleLimpHome()and returns. 30 % duty, all safeties bypassed except INA228 hardware ALERT. - CH1 freshness gate — non-manual modes return if
ch1FreshFlagis false.PidSampleDivisorlets the user step down the control rate (default 1 = every CH1 sample, ~213 Hz / ~4.7 ms). - Full mode/reason selection —
selectFieldControlMode()returns one of sixFieldControlModevalues;selectFieldEventReason()returns one of seventeenFieldEventReasonvalues. - Override edge detection —
MaintainModeandTargetVoltageModeentry/exit edges drive one-shot resets. - Charging stage —
updateChargingStage()runs in AUTO with no override active. Manages bulk → absorption → float/idle / re-bulk transitions. - Branch — based on
mode:MODE_NORMAL_AUTO_PID— full target-from-RPM-table → thermal-PID outer → current-PID inner → governor → PWM path.MODE_NORMAL_MANUAL—ManualDutyTargetslewed by the governor atDutyRampRate.- 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_wdtconfigured 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 - starttimein µs; if > 5,000,000 (5 s) a console warning fires.MaximumLoopTime(session) andMaxLoopTime(NVS-persistent →prevSessionMaxLoopTimenext boot) are dashboard-visible. - Function timing:
TIMED_CALL(ft_xxx, fn())wraps every long-running function. Theft_xxxstruct trackslastCall,worstWindow(5 s rolling), andworstSession(since boot or since "Reset Peak Values" button). Surfaced on the Function Timing dashboard table. - TempTask heartbeat:
lastTempTaskHeartbeatupdated 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:
coredumppartition (64 KB) captures backtrace + register state on panic; usable withxtensa-esp32s3-elf-addr2lineagainst the.elf(recipe inCLAUDE.md).
Cross-references¶
- Mode/reason semantics, per-protection trigger conditions, threshold cascade → Safeties and Protections
- PID structure, RPM table, governor, charging-stage logic → Field Control
- Sensor wiring, ADS gain selection, INA shunt config → Sensor Systems, Hardware: ADS1115, Hardware: INA228
- Cloud upload, OTA, WiFi mode selection → Network & Web System, Communication Interfaces
- CSV payload field maps, telemetry plumbing patterns → Important Functions
- Memory profile, PSRAM allocations → memory section of CLAUDE.md (the source of truth at any given moment)