Skip to content

JavaScript Logic

web_src/script.js (~12,400 lines, ~247 top-level functions) is not really "UI code" — it is a streaming data ingestion pipeline with UI rendering bolted onto it. The core pattern:

raw SSE event → parse CSV into a data object → selectively push values into DOM spans →
                                           → separately push values into plot arrays →
                                           → separately update echos / toggles / status widgets

Whitelist arrays (criticalFields, otherFields, CSV*_FIELDS) decide what gets rendered. updateFields() is the core renderer, but the formatting rules are per-payload — there's no universal formatter. Get that right in your head before you go editing.


The Four SSE Streams

Plus one out-of-band stream for log output.

Event name Cadence Length Purpose
CSVData every webgaugesinterval (100 ms default) CSV1_FIELDS.length (currently 34) Fast live values — the main dashboard and real-time plots
CSVData2 5 s (500 ms during sysID) CSV2_FIELDS.length (several hundred) State, settings, counters, max values, weather, lifetime, mode flags, IMU summary, function timing
CSVData3 Event-driven on settingsDirty rising edge, 60 s heartbeat CSV3_FIELDS.length (several hundred) User-configurable settings echo + per-setting tuning constants
TimestampData 3 s TS_FIELDS.length (~28) Per-sensor staleness ages (ms since last update). Drives the greying-out logic
console (out of band) Up to 5 per 700 ms one message at a time Console-pane log line, queued by queueConsoleMessage*() on the firmware side

Plus three event names for the field-bucketer:

  • EffMatrix — every 5 s, change-triggered — current bucket state for the matrix display
  • EffRed — every 5 s — live red-dot position
  • EffHistory — every 5 s — 30-session efficiency-history sparkline

The firmware-side priority gating (CSV1 → console → CSV2 → CSV3 → TS) means at most one telemetry channel arrives per loop() iteration. The dispatcher in the firmware is described in Network & Web System → Server-Sent Events.

Header validation

Every CSV payload starts with the field count as the first value:

const declaredCount = parseInt(values[0]);
if (values.length !== declaredCount) {
    console.warn(`[CSV1] length mismatch: declared=${declaredCount}, actual=${values.length}`);
    return;
}
if (declaredCount !== CSV1_FIELDS.length) {
    console.warn(`[CSV1] schema mismatch: ESP32=${declaredCount}, UI=${CSV1_FIELDS.length}`);
    return;
}

Two-tier check: declared-vs-actual catches firmware bugs (snprintf truncation); declared-vs-UI catches schema drift (firmware added a field but the JS index map wasn't updated). Both warnings go to the browser console. If either fires, the payload is dropped and the dashboard freezes for that channel — never silently render partial data.


CSV Index Maps

Top-of-file constants. These must be kept synchronized with the firmware's CSV payload field order — see Important Functions → CSV Payload Integrity.

const CSV1_FIELDS = [
    "AlternatorTemperatureF",  // 0
    "dutyCycle",               // 1
    "BatteryV",                // 2
    "MeasuredAmps",            // 3
    "RPM",                     // 4
    "Channel3V",               // 5
    "IBV",                     // 6
    "Bcur",                    // 7
    // …
];

CSV2_FIELDS and CSV3_FIELDS follow the same pattern with several hundred entries each. TS_FIELDS is for TimestampData.

The handlers use Object.fromEntries(CSV1_FIELDS.map((key, i) => [key, values[i]])) to turn values[] into a data object keyed by name. After that point, nothing downstream cares about the raw array index — everything is data.IBV, data.dutyCycle, etc.


CSVData Handler Path (the canonical example)

When CSVData arrives:

  1. Connection bookkeepinglastEventTime = Date.now(); the inline connection indicator flips to "connected."
  2. Parse — split on commas, parseFloat() everything.
  3. Validate — two-tier length check above.
  4. Mapdata = Object.fromEntries(CSV1_FIELDS.map(...)).
  5. Plot config (cadence-gated)updatePlotConfiguration(data) runs only every Nth packet, where N depends on webgaugesinterval. Pulls Y-axis limits, time-window, and color settings from data.
  6. Special status widgets (every packet) — field state text/class (Field: ON / OFF / LIMP / WAITING_CLOUD), charge-stage visibility (BULK / ABS / FLOAT / IDLE / MAINTAIN / TARGET-V / MANUAL / hidden).
  7. updateFields(criticalFields) — every packet — narrow whitelist of fields that must update at full rate (live amps, volts, RPM, the displayed setpoint, peak values relevant to the visible page).
  8. updateFields(otherFields) — every 4th packet — the rest. Saves DOM-write cost for less-time-sensitive numbers.
  9. Push to plot arrays — appends data.MeasuredAmps, data.BatteryV, etc. to the rolling uPlot data arrays. Shifts off old samples beyond plotTimeWindow.
  10. Interpolation loop wakeupstartInterpLoop() was already started; this handler just appends new samples.

processCSVDataOptimized(data) is the heart of step 9. It pre-allocates target arrays, does index math, and pushes new points without per-tick reallocation — exactly the pattern that lets the page render at 100 ms cadence without GC pauses.

Per-payload formatters

Each payload has its own formatter rules inside its local updateFields():

Payload Common transforms
CSV1 /100 for V, A, °F, %, PID terms; /10 for iiout; /1000000 for MaximumLoopTime (µs → s); label lookups for currentMode and fieldActiveStatus
CSV2 /100 for V, A, °F (consistent with CSV1); /1000 for some scaled ms; division for accumulated Wh display
CSV3 /1000 for ms-to-seconds settings (absorptionCompleteTime, bulkVoltageHoldMs, etc.); /100 for voltage caps; /60000 for ms-to-minutes (AbsorptionTimeoutMs) — see the per-setting transform registration

This per-payload formatter pattern is the single biggest source of confusion when adding a new field. The right rule is: whatever scale you used in SafeInt() in the firmware, divide by that here.


Echo System (CSV3)

When the user types a new value into a form input and submits, the firmware writes it to LittleFS, sets settingsDirty = true, and the next CSV3 dispatch echoes the saved value back. The dashboard updates a small <span id="...echo"> showing the saved value in parentheses next to the input.

Per-setting echo registration

In updateAllEchosOptimized(data), a single declarative array drives every echo update:

const ECHO_REGISTRATIONS = [
    { key: 'BulkVoltage',           id: 'BulkVoltage_echo',           transform: v => (v / 100).toFixed(2) },
    { key: 'absorptionCompleteTime', id: 'absorptionCompleteTime_echo', transform: v => Math.round(v / 1000) },
    { key: 'VoltageKp',              id: 'VoltageKp_echo',              transform: v => (v / 100).toFixed(3) },
    // … one entry per echoable setting
];

transform: v => v means no conversion (raw integer). Echoes only update when the value changes — updateEchoIfChanged(elementId, newValue) checks textContent before writing.

Do not put echo formatting in the unit-adjustment block at the top of the renderer — that block is for raw telemetry display, not echo labels. Mixing the two layers is one of the long-standing tech-debt items on the roadmap.


Staleness System

TimestampData carries millis() ages in milliseconds. The handler:

const data = Object.fromEntries(TS_FIELDS.map((key, i) => [key, values[i]]));
window.sensorAges = data;  // global; consulted by other code

window.sensorAges is then consulted by:

  • The cell-greying logic — any DOM span representing a sensor value is wrapped to grey itself out when its age exceeds the threshold.
  • The connection-status indicator — overall stream-stale check.

Thresholds (top of script.js):

const STALE_THRESHOLD_DEFAULT_MS = 6000;   // 6 s — everything except temp
const STALE_THRESHOLD_TEMP_MS    = 12000;  // 12 s — DS18B20 reads every 5 s, allows one missed read

IDX_ALTERNATOR_TEMP and IDX_THERMISTOR_TEMP use the higher threshold; everything else uses the default.


Reconnect Logic — initializeEventSource()

The EventSource instance lives in source at module scope. Key behavior:

  • open event — flip indicator green, reset sseReconnectAttempts = 0.
  • error event — log state (CONNECTING / OPEN / CLOSED), schedule reconnect with exponential backoff if CLOSED.
  • Reconnect capMAX_SSE_RECONNECTS = 10. If exceeded, stops retrying — user must call manualReconnect() (mapped to a button) to resume. Prevents infinite reconnect storms on a permanently-down regulator.
  • Background guarddocument.visibilitychange listener tracks isAppInBackground so reconnects don't fire when the browser tab is hidden (mobile battery hit).
  • Capacitor supportIS_CAPACITOR = !!window.Capacitor switches the base URL to http://alternator.local (otherwise relative paths work on the local server). See Capacitor iOS layout for the wrapped-mobile-app architecture.

Plot Management — uPlot

uPlot.iife.min.js and uPlot.min.css (third-party, do not modify). Several charts are instantiated:

Chart Data source Time window
Volts plot CSV1 BatteryV, IBV, target lines xTime (default 60 s)
Amps plot CSV1 MeasuredAmps, Bcur, setpointLimited, Icv, uTargetAmps xTime
Duty plot CSV1 dutyCycle, pidOutput xTime
PID terms CSV1 P/I/D contributions xTime
IMU plots CSV1 heel/pitch/yaw/accel xTime
Field-bucketer matrix EffMatrix event snapshot

queuePlotUpdate(plotName) debounces draw calls — multiple data appends within one animation frame coalesce into a single setData() call. updateUplotTheme(plot) runs on the global dark/light toggle.

reinitializePlotsWithNewTiming(data) re-creates plots whenever webgaugesinterval or plotTimeWindow changes (CSV1 carries these values; user changes them via form fields). uPlot doesn't support dynamic time-window resizing well, so destroying and rebuilding is the cleaner path.


Top-Level Functions, Grouped

There are 247 functions; the important ones break down as:

Connection / lifecycle

initializeEventSource(), manualReconnect(), cleanupResources(), setupDemoPasswordHandler(), enableDemoMode(), startDemoData(), sendFakeCSVData(), checkForDemoMode().

Rendering

updateFields() (local to each handler), updateAllEchosOptimized(), updateEchoIfChanged(), scheduleDOMUpdateOptimized(), updateWeatherAlerts(), updatePlotConfiguration(), processCSVDataOptimized(), formatSessionWindow() (used by all .session-window-label spans), updateAllTempUnitLabels() (cycles °F ↔ °C labels everywhere when the user toggles units).

Form / input

submitMessage(), handleAlternatorToggle(), togglePassword(), updatePasswordFields(), populateProfileForm(), handleProfileUpdate(), handleDeleteAllData(), convertTempFormIfNeeded().

Reset buttons

resetThermalPID(), resetInnerPID(), resetVoltageLoop(), resetTuningLog(), resetCVTuningLog(), resetThermalTuningLog(), restart actions.

Tuning logs

fetchTuningLog(), renderTuningLog(), commitTuningScore(), fetchCVTuningLog(), renderCVTuningLog(), commitCVTuningScore(), restartCVTest(), fetchThermalTuningLog(), renderThermalTuningLog().

Firmware / OTA

displayAvailableVersions(), confirmUpdate(), updateFirmwareVersion(), firmwareIntToString(), formatDeadline(), handleForcedUpdate(), disableAllInputs(), enableAllInputs(), triggerForcedUpdate().

Plot config

toggleTimeAxisMode(), reinitializeXAxisForNewMode(), queuePlotUpdate(), startInterpLoop(), lerp(), computeScaleRange(), reinitializePlotsWithNewTiming().

Diagnostics

setDiagnosticMode(), isDiagnosticMode(), diagLog(), diagWarn(), diagError(), devLog(), trackFrameTime(), profileOperation(), showPerformanceReport().

Utility

buildURL(), fetchWithTimeout(), setTrackedInterval(), setTrackedTimeout(), debounce(), toDisplayTemp(), tempUnitLabel(), setTempUnit().


Where to Modify When

Goal File / section to edit
Add a new live gauge CSV1_FIELDS (insert at index N); add HTML span with id="<key>"; add to criticalFields or otherFields
Add a new echo for a setting CSV3_FIELDS (insert at index N); add HTML span id="<key>_echo"; add entry to ECHO_REGISTRATIONS with transform
Add a new plot series processCSVDataOptimized() (push to a new array); add uPlot series config; add legend entry; update reinitializePlotsWithNewTiming if axis changes
Add a new staleness threshold TS_FIELDS + STALE_THRESHOLD_*_MS; wrap the corresponding DOM span in the greying-out logic
Add a new SSE event type source.addEventListener('YourEvent', ...) block in initializeEventSource()
Add a new button action <button onclick="myFunc()"> in HTML; function myFunc() in the appropriate section above
Tweak the dark / light theme updateUplotTheme(plot) and the corresponding CSS variables (see CSS Styling)

Cross-references