Skip to content

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.sh checks that the firmware's telemetry payloads and the JavaScript field arrays agree, regenerates CSV3_FIELDS in script.js from the firmware's settings-echo declaration, and gzips web_src/ into data/, which is what gets flashed. A failed check stops the build before anything in data/ is written. Never edit anything in data/ — 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-banner when tuning-mode protections are disabled, #dvcc-banner when charge-limit follow is on but nothing is clamping, and #sensor-banner for 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 calls showMainTab(), which toggles the .active class on the matching .tab-content pane. 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 — check index.html 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 .active on the matching .alt-panel, and it selects which pill to highlight from the button's data-alt-panel attribute rather than from its onclick, 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 the goTo* 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:

  1. Declared vs. actual length — catches firmware truncation bugs.
  2. 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 onclick calls submitMessage() — 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 open event resets the retry counter and calls updateInlineStatus(true), returning the connection dot to its steady teal.
  • Error — the error event inspects the EventSource ready-state and calls updateInlineStatus(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, or MAX_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 isOfflineMode is set, updateInlineStatus(false) sets the dot orange, not red. The staleness watchdog keeps firing updateInlineStatus(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 — the open handler has already called updateInlineStatus(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, or http://alternator.local before 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