Skip to content

Networking and Web Server

The XREG-010 serves its own dashboard: a web application hosted on the device, streamed live data over server-sent events, and a single settings endpoint that handles every setting change and most commands.

WiFi modes

setupWiFi() picks one of three modes at boot. Two recovery pins are sampled first, then the saved state selects the mode.

Mode How you get there What runs
Configuration portal (MODE_CONFIG) GPIO45 held low at boot, first boot, or no saved network credentials A minimal access point and a captive setup page. After GPIO45 or on first boot the access point uses the default credentials; when only the ship's network credentials are missing it keeps the owner's own access-point credentials. The alternator is disabled in this mode — it exists to enter WiFi credentials and recover lost WiFi passwords.
Operational access point (MODE_AP) GPIO46 held low at boot The device runs its own WiFi network (user-customizable credentials) with the full dashboard and full alternator function — no ship's network or internet needed. The dashboard is at 192.168.4.1, and alternator.local also resolves on devices that support mDNS on an access point. Cloud features are off.
Station (MODE_CLIENT) The default once credentials are saved Joins the ship's WiFi. The device also registers itself via mDNS so the dashboard is reachable at alternator.local, and at a per-unit name, xreg-<six hex digits of the chip ID>.local, that stays unambiguous when two regulators share a network.

The distinction between the two button modes is deliberate: GPIO45 is "I need to reconfigure and the alternator must stay off"; GPIO46 is "emergency access with full charging, no credentials required." The strap wiring for these pins — cable, wire color, connector pin — is tabulated in Troubleshooting → Forced recovery.

In station mode, checkWiFiConnection() owns reconnection — the library's auto-reconnect is disabled (WiFi.setAutoReconnect(false)). It is a non-blocking presence-scan engine with deliberately no backoff and no retry limit: while disconnected it starts a scan for the stored network every two seconds (WIFI_SCAN_CADENCE_MS) on the last-known channel, sweeping all channels every fifth scan (WIFI_FULL_SWEEP_EVERY) because a phone hotspot picks a fresh channel each session. The scan is active and accepts hidden networks: an access point with SSID broadcast disabled beacons with a blank name, so only a directed probe carrying the stored SSID will make it answer with its real one. Without both of those, a hidden network could be joined at boot — where the driver probes for the name directly — but never rejoined after a drop, because the engine will not attempt a join until a scan has found the name. Finding the name starts a non-blocking join, polled for completion, that returns to scanning if association and DHCP have not completed within fifteen seconds (WIFI_JOIN_TIMEOUT_MS). The fixed two-second cadence is deliberate and each scan costs little: a hotspot can reappear at any moment, and a device on a router is almost always connected, so it rarely scans at all. Three consecutive all-channel sweeps with no sighting print a one-time serial hint that the network name is case-sensitive. Disconnect and reconnect totals for the diagnostics panel are tallied separately, by a five-second status poll in loop().

Configuration portal

setupWiFiConfigServer() serves a single setup form and intercepts the captive-portal probe URLs that phones and laptops fire when they join a network — Apple's probe (/hotspot-detect.html) is answered with the form itself, and the Android (/generate_204, /gen_204) and Windows (/ncsi.txt, /connecttest.txt) probes are redirected to it. Submitting the form posts to /wifi; the handler stores the credentials with settingWrite() — settings are stored in the device's non-volatile key-value store (NVS), not as files — marks first-time setup complete, and restarts; with network credentials stored, it comes back up in station mode. The default access-point credentials and the user-facing recovery procedure are in Troubleshooting → Forgotten passwords.

A blank station SSID means "I only came here for the access-point settings", so the stored station credentials are left untouched (an unconditional write here used to wipe working WiFi). Erasing them is an explicit act: a forget_client checkbox on the form, honored only when the SSID field is blank, calls settingRemove() on both keys and clears the cached copies. The three cases produce three different confirmation messages, so the user can tell which one happened.

The access-point fields follow the same rule. The form never pre-fills them, so a blank hotspot name or password leaves the stored value untouched (writing the default on blank used to reset a custom hotspot on every save, including a visit made only to change the ship's network). Returning to the defaults is the explicit act here: a reset_ap checkbox calls settingRemove() on NK_apssid and NK_appass for whichever hotspot field is blank, and loadAPCredentials() reads an absent key as ALTERNATOR_WIFI- plus the unit's six-character ID (defaultApSsid()) and alternator123. A typed value takes priority over the checkbox. A password shorter than 8 characters is rejected with a 400 before anything is written.

In the operational access point mode, by contrast, every probe is answered as though the internet were reachable, so no captive sheet opens automatically — the user opens the dashboard deliberately. Each family needs its own answer, and the catch-all handler in setupServer() gives it one: the Android/ChromeOS paths get an empty HTTP 204 (a 200 with a body reads as "sign in" there, and Android may then drop the network), /ncsi.txt and /connecttest.txt get the literal Microsoft NCSI and Microsoft Connect Test strings Windows expects, and everything else — Apple's probe included — gets a 200 "Success" page.

The web server

The dashboard is served by an asynchronous web server (ESPAsyncWebServer) on port 80. The interface files — HTML, JavaScript, CSS, and the plotting library — are stored gzip-compressed in on-device flash (a LittleFS web partition), copied into RAM at boot (cacheWebAssets()), and served with a Content-Encoding: gzip header (serveCachedAsset()). Browsers decompress transparently, so the page references plain filenames like script.js while the wire carries the compressed bytes.

There are two copies of the web bundle: the production set, replaced by software updates, and an immutable factory set. At boot ensureWebFS() mounts the set that belongs to the running firmware and checks it with validateWebFilesystem(). If the production files are missing or truncated, the device rolls back to the factory firmware, which serves the factory copies; if the production area will not mount at all, the factory copies are mounted in its place. Either way a failed update cannot leave the device without a dashboard.

The settings endpoint: /get

Every user-tunable parameter and most one-shot commands go through a single endpoint, GET /get?<name>=<value>. The handler checks each known parameter name; a match parses the value, updates the live variable, and persists it with settingWrite() into the device's non-volatile key-value store (NVS) — a compare-first write (see Memory and Persistence).

Applying any setting also raises a "settings changed" flag (settingsDirty), which causes the settings-echo channel (see below) to re-broadcast current values — that is how the dashboard's echo labels confirm a change was applied.

Two things are handled before the settings arm gate:

  • Field off is always allowed. A request to turn the alternator off (OnOff=0) is honored even while settings are locked — safety takes precedence over access control. Turning the field back on falls through to the gate like everything else.
  • Silencing the alarm is always allowed. SilenceAlarm changes no setting, and a locked dashboard still has to be able to quiet the buzzer.

A request whose uid= names a different regulator is refused whole (HTTP 409) ahead of both exemptions, field off included: it reached this board through a name both units answer to, such as alternator.local, while the user was looking at the other one.

Everything else, including the software-update trigger (UpdateToVersion), requires the gate to be unlocked. The update path is deliberately no exception: a stale or replayed update URL must never be able to reboot the device into an installer. The dashboard's update flows arm the window on the user's confirm click, immediately before the update request. See Over-the-Air Updates.

The settings arm gate

Settings changes and destructive actions are gated by an unlock window (an "arm" window) held on the device, not in the browser. The dashboard's Unlock Controls button calls /armSettings?arm=1, which opens the window during which mutating requests are accepted; ?arm=0 (the Lock Controls button) closes it, and a parameter-less /armSettings reports the current state, which the dashboard polls so its lock indication tracks the device. Nothing times it out: the window stays open until a lock from any client or a reboot. It is held in RAM only, so a reboot always relocks.

By default there is no password. Any client on the regulator's WiFi network is trusted, so the gate is an acknowledgment that settings are about to change rather than authentication. What it does provide: a rejected write while locked is logged to the console stream, and because the gate is server-side, a stale browser tab replaying an old settings URL does nothing unless someone has deliberately unlocked. Accidental and automated replays are rejected for as long as the gate stays locked.

Settings password (optional, Setup → System → Settings Password). For a shared network such as marina WiFi, the owner can put a password on the gate. With it on, /armSettings?arm=1 needs pw= in the POST body and answers with a 16-character session token; every mutating request must then carry tok=, and the state reply reports whether that client's token is live rather than the global flag. One client's unlock therefore no longer opens the gate for every page on the network. Only a salted SHA-256 of the password is stored, five wrong attempts refuse every attempt for 60 seconds, and the token is never written to the console log. The field-off and alarm-silence writes stay exempt. /setPassword (POST, arm-gated, and requiring the current password for a change or a turn-off) sets or clears it; the GPIO45 recovery boot clears it too.

The two WiFi passwords are unrelated to this gate; the configuration portal (GPIO45) recovers those (see Troubleshooting → Forgotten passwords).

Live data: server-sent events

The dashboard receives all live data over one server-sent-events stream (/events, an AsyncEventSource). The firmware-side dispatcher, SendWifiData(), runs every loop pass and sends at most one event per pass in strict priority order — fast telemetry first, then the live nav channel, console messages, slow diagnostics, the settings echo, and freshness timestamps. The channel list, cadences, priority ladder, integer scaling, and the field-count integrity check are canonical in the Telemetry Pipeline.

When a new browser connects, the connect handler triggers a send on the settings-echo channel so the client sees current settings immediately instead of waiting for the heartbeat. Payload building is skipped entirely when no client is connected.

Background internet work

All outbound HTTPS except the update download itself (see Over-the-Air Updates) runs in a dedicated task (httpsTask) on the second core, fed by a small queue: cloud telemetry history, configuration backup, weather forecast fetches, software-update checks, and the account registration/profile calls, all of them opt-in, user-visible features. This keeps TLS handshakes, which are slow and need large memory buffers, entirely off the control core.

The four account endpoints (/checkRegistration, /registerProfile, /updateProfile, /deleteAllData) reach that worker by a different route from the rest, because the browser needs their response, which the other jobs do not return. Each stages its request payload into a slot, queues one HTTPS_CLOUD_OP job, and answers the browser 202 Accepted immediately; the worker runs the call plus all of the old handler's response handling (token save or clear, the post-registration version and forced-update checks, the configuration-snapshot request), and the client polls /cloudOpState for the finished status code and body. One operation is in flight at a time — a second attempt gets 503. Doing the call inline in the web handler, as these four used to, blocked every other endpoint for up to about 45 seconds whenever the internet connection was down.

  • A core0Busy flag is set while a request is in flight; other subsystems consult it before doing anything that would compete for resources (for example, WiFi power-down transitions wait for it to clear).
  • Cloud uploads are gated on the alternator field having been off and settled for a while (fieldOffSettled()), so a TLS handshake never runs during a charging transient; the gating details and the dashboard "upload now" bypass are covered in Advanced Features → Upload path.

Failure handling is conservative: repeated upload failures temporarily suspend uploads, while client-side rejections (malformed payloads) are dropped rather than retried forever.