Dashboard Architecture¶
The dashboard is a single web page served directly from the XREG-010: one index.html for all structure, one script.js for all behavior, one styles.css for all appearance. There is no framework, no router, and no build step beyond gzip compression before flashing — this page explains how data flows through that page and where the moving parts live.
The Three-File Model¶
Everything the client does lives in three hand-edited files under web_src/:
| File | Role |
|---|---|
index.html |
The entire page structure — every tab, form, and readout, all in one document |
script.js |
All behavior — stream parsing, DOM updates, plots, forms, connection management |
styles.css |
All styling, including dark mode and mobile layout (see Styling and Theming) |
The only third-party code is the charting library (uPlot — uPlot.iife.min.js and uPlot.min.css), which is vendored alongside and never modified.
Why this shape:
- Zero dependencies. Every byte is served from the microcontroller (ESP32-S3) itself, including in access-point mode with no internet. No CDN fonts, no package manager, no bundler.
- AI-assisted editing. Plain HTML/JS/CSS in three flat files is a tractable target for tooling and for new contributors.
- One compression step. A gzip pass compresses
web_src/intodata/, which is what gets flashed. Never edit anything indata/— it is regenerated output. (How the device serves the compressed bundle and falls back to factory copies: Networking & Web Server.)
Page Structure¶
index.html is one document with sibling content panes that JavaScript shows and hides — tab switching never reloads or navigates the page.
- A permanent header (
.permanent-header) stays visible above everything: brand wordmark led by the connection dot (.conn-dot, see Connection Lifecycle), field and charge-stage status, live sensor chips (battery voltage, amps, temperature, RPM), the alternator master-enable toggle, and the settings-unlock row (the Unlock Settings button, which opens the device's 30-minute settings write window). A hidden warning banner (#protections-banner) appears when tuning-mode protections are disabled. - A top-level tab bar (
.main-tabs) switches between the four major sections: Live Data, Setup, Plots, and Cloud Features. Each tab button callsshowMainTab(), which toggles the.activeclass on the matching.tab-contentpane. Two things people expect to find here are nested a level down instead: the console log is a Live Data sub-tab, and both the commissioning wizard and control-loop tuning are alt-tabs inside the Setup → Alternator row. - Sub-tabs exist inside the larger sections (Live Data and Settings especially), switched by
showSubTab()the same way, with a third nesting level in a few places. The sub-tab layout changes often — treatindex.htmlas the source of truth for what exists today rather than any list written here. - Alt-tabs are that third level, and the Setup → Alternator row is where they matter.
showAltTab(group, panelId)toggles.activeon the matching.alt-panel, and it decides which pill to light from the button'sdata-alt-panelattribute rather than from itsonclick, because a button whose handler is a deep-link helper never names its own panel id. Commissioning is one of those panels (#alt-panel-commissioning, first in the row); Tuning is another, and Tuning alone nests one level deeper again, with a private switcher (showTuningPanel()over.tuning-panel) so the generic sub-tab machinery can never reach its panels. Deep-link through thegoTo*helpers —goToCommissioning(),goToTuning(subTab),goToCVMode(),goToDiagSettings()— which set the main tab, sub-tab and alt-tab in order before revealing the inner panel; calling a switcher directly from elsewhere leaves the outer levels wrong.
The first thing in <body> is a hidden iframe named hidden-form. Every settings form targets it, so submitting a form sends the request without navigating the page.
Data In: Server-Sent Events¶
All telemetry arrives over a single one-way push connection (Server-Sent Events, via the browser's EventSource API) opened against the regulator's /events endpoint. The firmware splits telemetry into five comma-separated-value channels, each delivered as its own named event:
| Event | Cadence | Carries |
|---|---|---|
CSVData (CSV1) |
high rate (~10 Hz) | Fast live values — the numbers the dashboard and real-time plots update continuously |
CSVData4 (CSV4, "NavStream") |
every 500 ms (2 Hz) | Mid-rate nav/wind/solar/fuel readouts for the dial, compass, speed, solar, and fuel gauges (field list: telemetry pipeline) |
CSVData2 (CSV2) |
slow (seconds) | Diagnostics, counters, runtime state, per-session worsts |
CSVData3 (CSV3) |
on settings change, with a slow heartbeat | Echo of every user-configurable setting, so the UI can display what is actually saved |
TimestampData (TS) |
every few seconds | Per-sensor staleness ages, driving the greyed-out treatment of dead readings |
The firmware side of this split — what belongs in which channel and why — is described in the telemetry pipeline, which is the canonical reference.
Positional dispatch and validation¶
Each channel has a field-name array near the top of script.js (CSV1_FIELDS, CSV2_FIELDS, CSV3_FIELDS, CSV4_FIELDS, TS_FIELDS) listing field names in payload order. The handler turns the raw value array into a named object (Object.fromEntries(...)), so downstream code reads data.BatteryV rather than a magic index.
Every payload leads with its own field count, and each handler runs a two-tier check before rendering anything:
- Declared vs. actual length — catches firmware truncation bugs.
- Declared vs. UI array length — catches schema drift, where the firmware added a field but the JavaScript array was not updated.
If either check fails the payload is dropped and a warning goes to the browser console — the dashboard never renders partial data. A schema mismatch also raises the Interface Out of Date dialog once per page load (noteSchemaMismatch()): in the app it offers to sync the interface bundle from the regulator, in a browser it offers a reload. Keeping the firmware payload and the JS array in lockstep is a hard requirement of every field change.
A newer, self-describing pattern also exists alongside the positional channels: some subsystems (alternator health, vessel performance) publish their field names through a schema endpoint that the client fetches first, so those streams need no hardcoded array. New subsystems should prefer this registry pattern.
Listener scope warning¶
Each SSE listener callback is its own function scope. A helper or local variable defined inside the CSV1 handler is not visible inside the CSV2 or TS handlers. Anything that must cross handlers goes through an explicit global (for example window.sensorAges), and hooks into another handler's data should be wrapped in try/catch.
Data Out: Settings and Commands¶
All writes go the other way as plain HTTP GET requests to the /get endpoint with named parameters. The pattern, repeated for every setting in index.html:
- A
<form action="/get" method="GET" target="hidden-form">so the response lands in the hidden iframe instead of navigating. - The named input itself, plus a submit button wired to
submitMessage()— which provides the visual press feedback; the actual transmission is the native form submit.
There is no per-request credential: the device accepts or rejects each write based on whether its settings window is currently unlocked (armed via /armSettings from the Unlock Settings button; see the settings arm gate).
Confirmation is not the HTTP response. When the firmware accepts a setting it persists the value and re-sends the settings-echo channel (CSV3); the client maps each echoed value onto a small label element next to the input (conventionally id="<name>_echo"). A declarative registration list inside updateAllEchosOptimized() pairs each echo with a per-setting display transform (for example milliseconds stored internally, seconds shown), and updateEchoIfChanged() avoids redundant DOM writes. An updated echo confirms the regulator persisted the value.
Staleness UX¶
The TS channel carries, for each external sensor source, the time since its last update. The handler publishes these into the shared window.sensorAges object, and applyStaleStyleByAge() greys out each affected readout once its age passes a threshold (a default of a few seconds, with a longer allowance for slow sensors like temperature). This is purely a display behavior — the firmware enforces its own, separate staleness rules for control decisions. The thresholds and the rationale for their floor (the TS payload itself only arrives every few seconds) are commented at the top of script.js.
Plots¶
All charts are uPlot instances fed from rolling arrays that the CSV1 handler appends to. Redraws are not driven from the frame-arrival path: startInterpLoop() runs one requestAnimationFrame loop that eases each series from its previous value toward the newly arrived one and calls setData() once per frame for every live plot, so a burst of arrivals cannot cause a burst of redraws. reinitializePlotsWithNewTiming() rebuilds plots when the user changes the streaming interval or time window, and theme changes re-style the canvases through refreshAllPlotThemes(). (An older per-plot coalescer, queuePlotUpdate(), is still defined in script.js but has no call sites — the interpolation loop replaced it.)
Every chart gets on-plot axis editing through the shared attachYAxisEdit() widget: clicking the Y axis reveals min/max input boxes directly on the plot, and clearing a value returns that axis to autoscale. Use this widget for any new chart — never add separate form fields for axis limits.
Plot view settings persist in two different places, deliberately:
- Device-side: the main plot axis limits are real firmware settings — they travel through the normal
/get+ CSV3 echo path and survive on the regulator itself, shared by every browser that connects. - Browser-side: per-viewer preferences such as autoscale toggles and history-window choices live in the browser's local storage (
localStorage) and follow the viewer, not the device.
When adding a plot control, decide which of the two it is before wiring it.
Connection Lifecycle¶
The user-facing meaning of the connection dot and the Connection Lost dialog is documented in Connecting to the Regulator → The connection dot; this section covers the implementation. initializeEventSource() owns the connection. The lifecycle, as implemented:
- Connected — the
openevent resets the retry counter and callsupdateInlineStatus(true), returning the connection dot to its resting teal. - Error — the
errorevent inspects theEventSourceready-state and callsupdateInlineStatus(false). A transient stall (CONNECTING) is logged; a closed connection schedules a retry on a fixed 2-second interval (no exponential backoff). - Give-up — after
MAX_SSE_RECONNECTS(10) consecutive failures the client stops retrying and shows the Connection Lost dialog, offering Retry Connection (full reload) or Continue Offline. In the Capacitor app,MAX_SSE_CONNECTING_ERRORSconsecutive CONNECTING failures first trigger one network re-scan (rediscoverAfterLoss()), which recovers a regulator that returned at a new address.manualReconnect()still exists and resets the counter, but its button (#reconnect-button) is kept hidden — the dialog is the only recovery affordance on screen, deliberately, so two competing ones never appear at once. - Background guard — reconnect attempts are suppressed while the page is hidden or the mobile app is backgrounded, so a backgrounded phone does not drain battery retrying a dead connection.
Recency of data is tracked separately: handlers stamp lastEventTime on arrival. A single watchdog interval (created once per page life — re-creating it on each reconnect leaked stacked copies) sweeps every 2 seconds and, past 9 seconds of silence, calls updateInlineStatus(false) and markAllReadingsStale().
The connection dot¶
updateInlineStatus() is the only writer of the user-visible connection state. It drives every .conn-dot element by class — there are two instances, one leading the collapsed header strip and one beside the wordmark, and only one is visible at a time. The CSS owns the appearance:
| State | Classes | Appearance |
|---|---|---|
| Live | (none) | Steady teal #00a19a |
| Stream down | .conn-dot--down |
Red #F44336, pulsing |
| Offline mode | .conn-dot--offline |
Orange #ff6600, pulsing |
Two behaviors are load-bearing:
- Offline mode wins over "down." While
isOfflineModeis set,updateInlineStatus(false)paints orange, not red. The staleness watchdog keeps firingupdateInlineStatus(false)every 2 seconds after a loss, so without that branch the user's explicit offline choice would be repainted as a plain failure. enterOfflineMode()/exitOfflineMode()are mirrors. Both walk the same selector set to disable and re-enable inputs.exitOfflineMode()does not touch the dot — theopenhandler has already calledupdateInlineStatus(true).
The pulse is disabled under prefers-reduced-motion. This dot replaced the old fixed WIFI CONNECTED / WIFI DISCONNECTED corner pill; the .corner-status classes that pill used now belong to the WiFi Standby countdown (#wifi-wake-status) only.
updateInlineStatus(false) has one side effect beyond the dot: it hides that countdown. Once the radio drops, no further updates arrive to tick it toward zero, so leaving it up would freeze a stale "WiFi off in M:SS" on screen.
Mobile and the Capacitor App¶
The exact same three files run inside the iOS and Android apps — each app is a native shell (Capacitor) around this page. Every layout decision must therefore work on a phone: single-column collapses at narrow widths, thumb-sized touch targets, tap-to-toggle tooltips instead of hover. See Styling and Theming for the mechanics.
The script detects the wrapper at startup (IS_CAPACITOR, set from window.Capacitor) and adapts:
- Request base URL — in a browser, relative paths hit the serving regulator; inside the app there is no origin, so
buildURL()prefixes every request with the regulator's address (http://alternator.localby default). - App lifecycle — a native app-state listener marks the page backgrounded/foregrounded so the reconnect logic behaves, and reconnects immediately on return to foreground if the stream died.
- Native plugins — when present, the app uses the phone's geolocation as a GPS fallback for the regulator. All such code is guarded so the same file runs unchanged in a plain browser.
Naming caution: the .cap-mode-* CSS classes are unrelated to Capacitor — they style the segmented charge-rate-cap toggle.
Cross-references¶
- Telemetry channels, cadences, and firmware-side plumbing — Telemetry Pipeline
- Theming, buttons, responsive layout — Styling and Theming