Skip to content

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 core0Busy is true.
  • TempTask: skips its read cycle when core0Busy so the OneWire bus isn't competing with HTTPS for I/O time.
  • AdjustFieldLearnMode: holds field-on for up to 10 s if core0Busy || queueDepth > 0 to 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 — never HTTPClient because that library has a known getString() 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 in plan1_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()

  1. Kill httpsTask (esp_task_wdt_delete(httpsTaskHandle), vTaskDelete(httpsTaskHandle), httpsTaskHandle = NULL).
  2. Kill TempTask similarly.
  3. Validate heap integrity (validateHeapIntegrity()).
  4. Mark otaInProgress = true so 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 Update API (firmware.bin) or webFS writes (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_KEY embedded in Xregulator.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