Network & Web System¶
Three WiFi modes, two web servers, four SSE channels, an HTTPS background task on Core 0, and a streaming OTA pipeline with signature verification. Mostly in 3_functions.ino (web server, SSE dispatch, WiFi connection management) and 4_functions.ino (OTA, weather, version reporting).
WiFi Mode Selection — setupWiFi()¶
Three modes, selected at boot by GPIO pin reads. The first matching condition wins.
| Priority | Trigger | currentMode |
Behavior |
|---|---|---|---|
| 1 | GPIO45 LOW at boot | MODE_CONFIG |
Force configuration: AP up with default credentials (ALTERNATOR_WIFI / alternator123), captive portal active, alternator operation disabled (loop() returns early in this mode). Used for password recovery |
| 2 | GPIO46 LOW at boot | MODE_AP |
Operational AP: AP up with user-customizable credentials, full alternator interface served. CloudFeatures = 0 (no internet uploads). Emergency access without credentials |
| 3 | First boot (/first_config_done.txt missing) |
MODE_CONFIG |
Same as priority 1 but credentials shown so user can learn the defaults |
| 4 | No client credentials saved | MODE_CONFIG |
Same as priority 3 |
| 5 | (default) | MODE_CLIENT |
Connect to user's network using saved SSID/PSK with 10 s timeout. No AP fallback on failure — checkWiFiConnection() retries indefinitely |
enum DeviceMode { MODE_CONFIG, MODE_AP, MODE_CLIENT } — currentMode is sampled all over loop() to gate behaviors.
Captive portal — setupWiFiConfigServer()¶
Serves a single HTML form (WIFI_CONFIG_HTML in Xregulator.ino) for entering ship's network credentials. The form posts to /wifi; success writes /ssid.txt and /pass.txt to LittleFS and triggers an ESP.restart(). Captive-portal detection routes intercepted for iOS (/hotspot-detect.html), Android (/generate_204, /gen_204), Windows (/ncsi.txt, /connecttest.txt) — all redirect to the form.
AP credentials — loadAPCredentials(forceDefaults)¶
Custom AP SSID/PSK live in /ap_ssid.txt / /ap_password.txt. forceDefaults=true (CONFIG mode and first boot) returns hardcoded ALTERNATOR_WIFI / alternator123 regardless of stored values — so a user who forgot their custom AP password can ground GPIO45 and recover.
connectToWiFi(ssid, password, timeout)¶
10 s default timeout. WiFi.setSleep(false) to disable power save; WiFi.setAutoReconnect(false) because checkWiFiConnection() owns reconnection. Polls every 500 ms; feeds the watchdog. On success: MDNS.begin("alternator"), registers HTTP service so alternator.local resolves. Returns bool.
Re-connection — checkWiFiConnection()¶
Runs every loop iteration when currentMode == MODE_CLIENT and (Ignition == 1 OR within the WiFi-wake window). Tracks WiFi.status() transitions and increments wifiDisconnectCount / wifiReconnectsTotal on edges. Intelligent backoff inside — see source for the exact reconnection cadence.
Web Server — setupServer()¶
AsyncWebServer on port 80. All gz-compressed assets are served with Content-Encoding: gzip so the browser auto-decompresses. Mounted on webFS (separate LittleFS instance covering whichever of factory_fs / prod_fs is active — see System Overview → Partition Map).
Static asset routes (explicit)¶
GET / → /index.html.gz (gzip)
GET /index.html → /index.html.gz (gzip)
GET /styles.css → /styles.css.gz (gzip)
GET /uPlot.min.css → /uPlot.min.css.gz (gzip)
GET /uPlot.iife.min.js → /uPlot.iife.min.js.gz (gzip)
GET /script.js → /script.js.gz (gzip)
GET /favicon.ico → 1-byte blank (silences browser console error)
onNotFound is the fallback — does the auto-.gz lookup for any other path the front-end requests.
Settings endpoint — GET /get?<name>=<value>¶
One endpoint for every user-tunable parameter. The handler walks through request->hasParam("X") checks in order; the matched block parses the value, writes it to the appropriate LittleFS or NVS location, and sets settingsDirty = true (which schedules the CSV3 echo to fire on the next dispatch tick).
CLAUDE.md has the canonical recipe in "Adding New Variables — Plumbing Patterns" → Pattern B. See Important Functions for the public version.
Log download routes (streaming chunked responses)¶
| Route | Format | Source | Cadence guard |
|---|---|---|---|
GET /thermallog.csv |
CSV with header + constants row + N data rows | thermalLog[] (PSRAM, 7200 entries × 1 Hz = 2 h) |
Pauses thermalLog_tick() during transfer (thermalLogPaused) |
GET /thermallog.bin |
Binary: 8-byte header (count + interval_ms) + raw struct array | same | same |
GET /pidlog.csv |
CSV | pidLog[] (PSRAM, 2400 entries) |
Pauses pidLog_tick() |
GET /cvlog.bin |
Binary: 36-byte header + 50-byte entries | cvLog[] (PSRAM, 6000 entries × 50 bytes = 300 KB) |
Pauses cvLog_tick() |
GET /effmatrix.csv |
CSV (8 RPM × 7 temp × 7 field cells) | effMatrix (PSRAM) |
None — read snapshot |
GET /effmatrixstats |
JSON stats | sessionStats |
None |
GET /tuninglog |
JSON | tuningLog[] (PSRAM, 50 × ~48 B) |
None |
GET /cvtuninglog |
JSON | cvTuningLog[] (PSRAM, 50 × ~120 B) |
None |
GET /thermaltuninglog |
JSON | thermalTuningLog[] (PSRAM, 50 × ~80 B) |
None |
GET /vessel_info.json |
JSON | /vessel_info.json LittleFS |
None |
The AsyncWebServer chunked-response pattern keeps memory bounded — only one chunk-buffer worth of data is held at a time, the closure state machine tracks position into the source array, and the browser sees a streaming download. Pausing *_tick() while a download is in flight prevents the source data from being mutated during transfer.
Action endpoints (POST)¶
POST /factoryReset → performDeepFactoryReset() — reformats LittleFS, erases NVS, restarts
POST /resetlogs → clear thermalLog/pidLog/cvLog
POST /saveVesselInfo → write /vessel_info.json
POST /clearVesselInfo → delete /vessel_info.json
POST /setPassword → bcrypt hash → /password_hash.txt
POST /checkPassword → bcrypt verify (front-end gate)
POST /resetThermalPID → tempPID integrator → 0
POST /resetInnerPID → currentPID integrator → 0
POST /resetVoltageLoop → cvLoopResetRequested = true (cv_I=0 on next AdjustFieldLearnMode tick)
POST /resetVoltageProtectionCounters → g_inaOVCount/g_hardOCCount/g_voltSpikeCount/g_voltDisagree*Count → 0
POST /resetThermalProtectionCounters → g_tempCritCount/g_tempSustainedCount/g_tempStaleCount → 0
POST /resetTempTaskCounters → tempReadSuccessCount/tempReadFailCount/all temp* counters → 0
POST /resettuninglog, /resetcvtuninglog, /resetthermaltuninglog → clear the named score log
POST /registerProfile, /updateProfile, /deleteAllData → Supabase account ops
POST /checkRegistration → verify Supabase device token
GET /getAuthToken → return current Supabase JWT (for debugging)
POST /debugToken, /debug → various diagnostic dumps
Most action endpoints require password=<plaintext> in the form data and call validatePassword() (bcrypt verify) before doing anything destructive.
Server-Sent Events — /events¶
One SSE source AsyncEventSource events("/events"). Browser opens, server pushes named events. Four event types, dispatched by SendWifiData() every loop iteration with strict priority and cadence gating.
Per-tick gating logic¶
canSendNow = (now - lastEventSourceSend >= EVENTSOURCE_COOLDOWN) // 10 ms global floor
if (!canSendNow) return;
// PRIORITY 1: CSVData (every webgaugesinterval, default 100 ms)
if (!sentSomething && now - prev_millis5 >= webgaugesinterval && events.count() > 0) {
// build payload1, events.send("CSVData"); sentSomething = true;
}
// PRIORITY 2: console messages (1 s throttle, max 5 per dispatch)
trySendConsoleSSE(sentSomething, now);
// PRIORITY 3: CSVData2 (5 s, or 500 ms when sysID running. Gated behind CSV1.)
if ((sysIDRunning || !sentSomething) && now - lastpayload2send >= ...) {
// build payload2, events.send("CSVData2"); sentSomething = true;
}
// PRIORITY 4: CSVData3 (event-driven on settingsDirty, 60 s heartbeat fallback)
// PRIORITY 5: TimestampData (every 3 s)
events.count() is the number of connected SSE clients — payload-building is skipped when nobody is listening, so the cost of being disconnected is one events.count() lookup per tick.
Payload format — SafeInt-packed CSV¶
int payload1Len = snprintf(payload1, PAYLOAD1_SIZE,
"%d," // CSV1_FIELD_COUNT (declared field count, browser uses this to validate)
"%d,%d,...", // SafeInt-packed values
CSV1_FIELD_COUNT,
SafeInt(AlternatorTemperatureF, 100), // F × 100 for two-decimal precision
SafeInt(dutyCycle, 100),
…);
SafeInt(v, scale) returns (int)round(v * scale) with NaN/inf guards. Browser un-scales by the same factor in script.js. Format-string %d count must equal CSV*_FIELD_COUNT + 1 (the +1 is the count field itself). The browser rejects the entire payload on mismatch; a silent firmware bug here has bitten this project repeatedly — see the verification recipe in CLAUDE.md.
Channel cadence summary¶
| Channel | Event name | Cadence | Buffer size | Field count enum |
|---|---|---|---|---|
| CSV1 | "CSVData" |
webgaugesinterval (100 ms default) |
1400 B PSRAM | CSV1_FIELD_COUNT (enum sentinel) |
| CSV2 | "CSVData2" |
5 s (500 ms during sysID) | 3400 B PSRAM | CSV2_FIELD_COUNT |
| CSV3 | "CSVData3" |
Event-driven on settingsDirty rising edge, 60 s heartbeat |
(PSRAM) | CSV3_FIELD_COUNT |
| TS | "TimestampData" |
3 s | 700 B PSRAM | TS_FIELD_COUNT |
| Console | "console" |
Every dispatch, max 5/700 ms | 128 B/msg | — |
Console messages are dispatched as their own SSE event with the wall-clock timestamp the firmware queued them — but the browser display timestamp is the arrival time at the client. Mentioned because users have asked why log timestamps appear offset.
Connection-open hook¶
events.onConnect([](AsyncEventSourceClient *client) {
client->send("connected", "connected", millis(), 1000);
});
Triggers a CSV3 dispatch on the next tick (via settingsDirty) so the new client sees current setting values immediately rather than waiting up to 60 s for the heartbeat.
HTTPS Background Task — httpsTask (Core 0, 20 KB stack)¶
Drains httpsQueue (depth 2) of HttpsRequest records. Each request is one of six types:
enum HttpsRequestType {
HTTPS_UPLOAD_PAYLOAD, // sensor history record
HTTPS_UPLOAD_CONFIG, // configuration snapshot
HTTPS_FETCH_WEATHER, // weather API
HTTPS_UPDATE_FW_VERSION, // version reporting (Supabase)
HTTPS_CHECK_FORCED_UPDATE, // forced-OTA check (Supabase)
HTTPS_CLEAR_FORCED_UPDATE, // forced-OTA acknowledge
};
Failure handling¶
Consecutive HTTPS_UPLOAD_PAYLOAD failures (other than HTTP 400/401, which indicate bad data and are auto-deleted) accumulate. After MAX_CONSECUTIVE_FAILURES = 5, uploads are suspended for 30 s (uploadsSuspendedUntil). HTTP 400/401 do not count — those indicate a payload format problem that retry won't fix. Each failed upload adds a 3-second back-off before the next attempt.
Payload integrity checks¶
Before processing HTTPS_UPLOAD_PAYLOAD or HTTPS_UPLOAD_CONFIG:
strlen(payload) > 0 && < sizeof(payload)payload[0] == '{'— JSON starts with brace; corruption check
Bad records are skipped silently.
core0Busy flag¶
Set true while a request executes; cleared after. Read by:
- Main loop power-management: WiFi-off transition is held while
core0Busyis true. TempTask: skips its read cycle whencore0Busyso the OneWire bus isn't competing with HTTPS for I/O time.AdjustFieldLearnMode: holds field-on for up to 10 s ifcore0Busy || queueDepth > 0to keep the field from blipping through between consecutive queued uploads. 10 s safety timeout flushes the queue and proceeds.
TLS notes¶
- All HTTPS uses
WiFiClientSecure+ raw write — neverHTTPClientbecause that library has a knowngetString()hang bug. - Root CA is ISRG Root X1 (Let's Encrypt root, valid through 2035) — hardcoded in
Xregulator.ino. Using the root rather than the R10/R12 intermediate prevents validation failures when Let's Encrypt rotates intermediates. - mbedTLS needs ~32–40 KB of contiguous internal RAM for a handshake.
loop()stack was bumped from 8 KB to 20 KB specifically to make this reliable; see also the heap-fragmentation discussion inplan1_storage_migration.md.
Cloud Upload Cadence¶
All cadence work is gated on CloudFeatures == 1. From loop():
| What | Interval | Gate |
|---|---|---|
| Sensor window save | SENSOR_UPLOAD_INTERVAL (30 s for testing; production 5 min) |
None — saves currentWindow to PSRAM ring locally; no internet |
uploadBufferedRecords() (PSRAM ring → Supabase) |
BUFFER_UPLOAD_INTERVAL (13 s) |
fieldOffSettled(10 s) — 70 s total settle. OR forceCloudFlushPending bypass |
buildConfigPayload() + upload |
CONFIG_SNAPSHOT_INTERVAL (2400000 ms = 40 min) |
fieldOffSettled(10 s), MODE_CLIENT, WiFi.RSSI() >= -76, isRegistered |
NTP checkTimeSync() |
12 h internal | fieldOffSettled(0) |
| Weather fetch | triggerWeatherUpdate(), manual or scheduled |
MODE_CLIENT, WiFi connected |
The "Upload Cloud Now" dashboard button sets forceCloudFlushPending = true which bypasses both the field-off settle and the 13 s interval throttle, letting buffered records drain back-to-back (still rate-limited by httpsQueue depth).
fieldOffSettled(extraMs) returns true if the field has been off continuously for 60000 + extraMs ms. The 60 s baseline is intentional — it keeps a TLS handshake from landing on top of a control-loop transient.
OTA Update Pipeline¶
Sequenced for safety: signature-verified, partition-isolated, with explicit "preferred boot partition" so a bad image can be rolled back automatically.
Trigger¶
HTTPS_CHECK_FORCED_UPDATE runs once 3 s after boot in MODE_CLIENT with WiFi.RSSI() >= -76 and !core0Busy. Hits Supabase, looks up any user-requested update for this device, populates UpdateInfo (hasUpdate, version, firmwareUrl, signatureUrl, changelog, firmwareSize).
Pre-OTA — prepareForOTA()¶
- Kill
httpsTask(esp_task_wdt_delete(httpsTaskHandle),vTaskDelete(httpsTaskHandle),httpsTaskHandle = NULL). - Kill
TempTasksimilarly. - Validate heap integrity (
validateHeapIntegrity()). - Mark
otaInProgress = trueso any future tick of any task self-terminates.
Streaming download — performStreamingOTAUpdate(updateInfo, signatureBase64, client)¶
Used when running from the factory partition (the OTA writes the ota_0 slot). Tar-format firmware-plus-data bundle streamed with StreamingExtractor:
- 512 byte tar header per file.
- File contents streamed directly into either
UpdateAPI (firmware.bin) orwebFSwrites (data files). - SHA-256 incremental hash computed on every chunk.
- mbedTLS RSA verify on the chunked SHA-256 vs the bundle's signature file (
OTA_PUBLIC_KEYembedded inXregulator.ino). - 16 s watchdog reset every chunk so the multi-MB transfer doesn't trip the watchdog.
Post-OTA — otaRestoreNormalOperation(success)¶
If success and target partition is ota_0: esp_ota_set_boot_partition(ota_0), write update_req/wake_flag so the next boot wakes WiFi for verification, ESP.restart().
If failure (verify fail, heap exhaustion, network drop mid-stream): Update.abort(), esp_ota_set_boot_partition(factory), console warning, return to normal operation without restarting. The factory image remains the running image.
Boot-time rollback — ensurePreferredBootPartition()¶
On boot, checks whether the running partition matches the preferred one (stored in NVS bootpref/preferred). On mismatch, sets the preferred and reboots. Standard A/B-update safety pattern — a freshly-flashed slot that fails to boot reverts to the previous slot on the next reset.
Web-files fallback — switchToFactoryWebFiles()¶
Run by validateWebFilesystem() if the production web partition (prod_fs) is corrupt or missing key files. webFS is remounted on factory_fs. The dashboard still works (factory web files are immutable shipped artifacts) and the user sees a banner indicating they're on factory web.
Weather Mode¶
updateWeatherMode() runs from loop() when WiFi.status() == WL_CONNECTED. analyzeWeatherMode() and executeFetchWeatherData() (Open-Meteo API) populate solar-forecast data. When weatherModeEnabled == 1 and currentWeatherMode == 1 (sufficient solar forecast), buildTickSnapshot() sets chargingEnabled = false — i.e. the regulator stops charging when solar is plentiful. Implementation lives in 4_functions.ino.
Critical Variables — Quick Reference¶
| Variable | Meaning |
|---|---|
currentMode |
MODE_CONFIG / MODE_AP / MODE_CLIENT |
currentWiFiMode |
Library-level: WIFI_OFF / WIFI_AP / WIFI_STA |
wifiWakeStart, wifiWakeActive |
5-min wake window after WiFiWakeButton press |
core0Busy |
True while httpsTask is processing a request |
httpsQueue (depth 2) |
FreeRTOS queue Core 1 → Core 0 |
consoleQueue (size 10) |
PSRAM circular for browser-bound console messages |
settingsDirty |
Set by /get handler; triggers next CSV3 dispatch |
events.count() |
Connected SSE clients |
WifiHeartBeat |
Per-CSV1-send counter — UI uses to detect stale stream |
wifiDisconnectCount, wifiReconnectsTotal |
Lifetime counters for the Stats panel |
webgaugesinterval |
CSV1 cadence (ms; default 100) |
forceCloudFlushPending |
Bypass cloud-upload throttles for one drain pass |
otaInProgress |
Halts all background tasks (TempTask, httpsTask) |
isRegistered |
Supabase device-registration token present |
Cross-references¶
- CSV plumbing patterns and field-count verification → Important Functions, CLAUDE.md
- Partition map, OTA partition layout → System Overview → Flash Partition Map
- Per-tick task scheduling, power modes, shutdown phases → System Overview → Main Loop
- VE.Direct, NMEA2K wiring → Communication Interfaces
- Browser-side event handling → Client → JavaScript Logic