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 bundler; the build runs consistency checks, regenerates one field list in script.js, and gzips the files before flashing.
The Three-File Model¶
All client code is 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 structure:
- 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 build script.
compress_web.shchecks that the firmware's telemetry payloads and the JavaScript field arrays agree, regeneratesCSV3_FIELDSinscript.jsfrom the firmware's settings-echo declaration, and gzipsweb_src/intodata/, which is what gets flashed. A failed check stops the build before anything indata/is written. 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 and state of charge, alternator and battery amps, alternator and battery temperature, RPM and ignition, charging-system health), the Alternator Enable OFF / ON control, and the settings-unlock row (the Unlock Controls button, which opens the device's settings write window until a lock or a reboot). Hidden notices appear below it when needed:#protections-bannerwhen tuning-mode protections are disabled,#dvcc-bannerwhen charge-limit follow is on but nothing is clamping, and#sensor-bannerfor a sensor fault. The header minimizes to a one-line status strip (#header-collapsed-strip). - 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 — checkindex.htmlfor 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 selects which pill to highlight 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 functions can never toggle 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 saved |
TimestampData (TS) |
every few seconds | Per-sensor staleness ages, used to gray out stale 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. CSV3_FIELDS is generated by the build from the firmware's single CSV3 declaration; the other four are edited by hand. 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 starts 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 matched in count and order is a hard requirement of every field change, and the web build enforces it: it compares count and order for every channel and refuses to build on a mismatch.
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 shared between handlers goes through an explicit global (for example window.sensorAges), and code that reads 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 loads in the hidden iframe instead of navigating. - The named input itself, plus a submit button whose
onclickcallssubmitMessage()— which provides the visual press feedback; the actual transmission is the native form submit.
The device accepts or rejects each write based on whether its settings window is currently unlocked (armed via /armSettings from the Unlock Controls button; see the settings arm gate). Two values are added to every request without any per-control code: buildURL() and syncWriteUidInputs() add the identity of the regulator the page is showing (uid) to every /get request and form, and the firmware refuses a write addressed to a different regulator. With the optional settings password on, unlocking also returns a token (tok) that the same two functions attach, and only requests carrying a currently valid token count as unlocked.
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 are written through the normal
/get+ CSV3 echo path and stored on the regulator itself, shared by every browser that connects. - Browser-side: per-viewer preferences such as autoscale toggles and history-window choices are stored in the browser's local storage (
localStorage) and apply only in that browser, not on the device.
When adding a plot control, decide which of the two it is before implementing 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. initializeEventSource() creates and manages the connection.
- Connected — the
openevent resets the retry counter and callsupdateInlineStatus(true), returning the connection dot to its steady 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 closed-connection retries, orMAX_SSE_CONNECTING_ERRORS(5) consecutive CONNECTING failures, the client runs one network re-scan (rediscoverAfterLoss()), which recovers a regulator that returned at a new address. The re-scan runs in the app and in a browser alike, wherever the page can probe the local network. If it fails, or was already tried for this loss, the client stops retrying and shows the Connection Lost dialog, offering Retry Connection (full reload) or Continue Offline.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 mobile app is backgrounded, so a backgrounded phone does not drain battery retrying a closed connection. On return to the foreground (the app's state change, or the page becoming visible again)
foregroundReconnect()runs one reconnect if the stream is closed or has received no events for more than 5 seconds.
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) runs every second. After 3.5 seconds without a packet it shows a non-interactive stall notice that counts the age of the last packet; past 9 seconds it also calls updateInlineStatus(false) and setReadingsStale(true), which grays every reading. The notice stays off while the page is hidden, in offline mode, and before the first packet.
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 defines 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 |
- Offline mode takes precedence over "down." While
isOfflineModeis set,updateInlineStatus(false)sets the dot orange, not red. The staleness watchdog keeps firingupdateInlineStatus(false)every second after a loss, so without that branch the user's explicit offline choice would be shown as a plain failure. enterOfflineMode()/exitOfflineMode()are inverses. Both walk the same selector set to disable and re-enable inputs.exitOfflineMode()does not change 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 connection is lost, 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 are sent to the serving regulator; inside the app there is no origin, so
buildURL()prefixes every request with the regulator's address: the last one that worked, re-resolved by a network scan at launch and after a connection loss, orhttp://alternator.localbefore any is known. - App lifecycle — a native app-state listener marks the page backgrounded/foregrounded so reconnect attempts are suppressed while backgrounded, and reconnects immediately on return to foreground if the stream closed.
- 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 implementation — Telemetry Pipeline
- Theming, buttons, responsive layout — Styling and Theming