Skip to content

Dashboard Architecture

The dashboard is a single web page served directly from the regulator: 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/ into data/, which is what gets flashed. Never edit anything in data/ — it is regenerated output.

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, field and charge-stage status, live sensor chips (battery voltage, amps, temperature, RPM), the alternator master-enable toggle, and the settings-unlock password row. A hidden warning banner (#protections-banner) appears when tuning-mode protections are disabled.
  • A top-level tab bar (.main-tabs) switches between the major sections: live readings, settings, control-loop tuning, real-time plots, a console log, and cloud features. Each tab button calls showMainTab(), which toggles the .active class on the matching .tab-content pane.
  • 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 — treat index.html as the source of truth for what exists today rather than any list written here.

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 four 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
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, 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:

  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. 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.
  • A hidden password input (class="password_field") that JavaScript fills from the stored interface password before submission.
  • 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.

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; queuePlotUpdate() coalesces draw calls so multiple data arrivals produce one redraw, and reinitializePlotsWithNewTiming() rebuilds plots when the user changes the streaming interval or time window. Theme changes re-style the canvases through updateUplotTheme().

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

initializeEventSource() owns the connection. The lifecycle, as implemented:

  • Connected — the open event resets the retry counter and flips the inline status indicator green (updateInlineStatus()).
  • Error — the error event inspects the EventSource ready-state. A transient stall (CONNECTING) is logged; a closed connection schedules a retry on a fixed short interval (no exponential backoff) and flips the indicator red.
  • Give-up — after a capped number of consecutive failures the client stops retrying and shows a Connection Lost dialog offering a full-reload retry or a "Continue Offline" mode that disables inputs. manualReconnect() (wired to a button) resets the counter and tries again.
  • 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 the arrival time of each event, which feeds the connection indicator and diagnostics.


Mobile and the Capacitor App

The exact same three files run inside the iOS app — the 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.local by 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, and native biometric storage for the interface password. 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