Skip to content

Important Functions

Plumbing patterns and macros you will use whenever you add a new variable, a new timed function, or a new persistent setting. These patterns are uniform across the firmware — getting one of them wrong is the most common source of silent bugs (dropped CSV fields, missing telemetry, lost settings on reboot).

The canonical recipe lives in CLAUDE.md "Adding New Variables — Plumbing Patterns". This page is the public version, augmented with the rationale for why each pattern looks the way it does.


SSE Telemetry Channels — Pick the Right One

Channel Cadence Use for
CSV1 (CSVData) every webgaugesinterval (~100 ms) High-rate live numbers: control-loop variables, fast UI gauges, anything the dashboard plots in real time. Examples: BatteryV, MeasuredAmps, RPM, dutyCycle, setpointLimited, Icv, IBV, pidOutput
CSV2 (CSVData2) 5 s (500 ms during sysID) Slower-changing status, diagnostics, counters, per-session worsts, function timing (ft_*), runtime state flags, accumulated stats, IMU summaries. Examples: MaxAlternatorTemperatureF, loadDumpActive, voltageControlActive, cv_I, all ft_* _win / _ses pairs, nvsCommitWorstMs
CSV3 (CSVData3) Event-driven on settingsDirty rising edge, 60 s heartbeat User-configurable settings echo + per-setting tuning constants. Fires only when the user touches a form input (or every 60 s as a heartbeat). Examples: BulkVoltage, absorptionCompleteTime, VoltageKp, VoltageKi, SlopeBleedK, IExcessK
TS (TimestampData) 3 s Staleness watchdogs — millis() of last update per sensor source so the UI can grey out fields that have stopped arriving. Add here ONLY for inputs with their own staleness concept (NMEA fields, Victron VE, etc.)

Priority gating: CSV1 fires first per tick, then console, then CSV2 (gated behind CSV1), then CSV3, then TS. Only one channel sends per loop iteration. This means adding a field doesn't increase per-tick CPU — it just makes the eventual payload longer when that channel's turn comes up.

But misplacing a settings echo into CSV2 means it re-sends every 5 s for no reason, fills the dashboard with redundant traffic, and makes the dirty-flag echo useless. Classify first, then plumb.


Three Variable Patterns

Decide which one applies before you start writing code. The wrong pattern is the bug.

Pattern When
A1 — High-rate live telemetry Updates faster than 5 s and the UI needs to see it live. → CSV1
A2 — Slower telemetry / diagnostic / runtime state 5-second cadence is fine. No user input. → CSV2
B — User-configurable setting + echo Has a form input. Persists across reboot. UI needs to display current value. → CSV3 echo

Pattern A1 — High-rate live telemetry → CSV1

Four steps. No LittleFS, no server handler, no echo — A1 is read-only telemetry the firmware computes every tick.

  1. Global declaration in Xregulator.ino:

    float setpointLimited = 0.0f;
    

  2. CSV1 payload in 3_functions.ino inside int payload1Len = snprintf(...) — append a %d to the format string and the argument with a slot-index comment:

    SafeInt(setpointLimited, 100),    // 18
    

  3. CSV1 JS index map in web_src/script.js, CSV1_FIELDS array — append at the matching index:

    "setpointLimited",   // 18
    

  4. JS usage — wherever the value is displayed, divide by the same scale factor used in SafeInt(...).

Pattern A2 — Slower telemetry / diagnostic → CSV2

Same shape as A1 but on the CSV2 stream. Reference example: inAbsorptionStage (sent as 0/1):

  1. bool inAbsorptionStage = false;
  2. (int)inAbsorptionStage, // <slot> appended to payload2Len snprintf.
  3. "inAbsorptionStage", // <slot> appended to CSV2_FIELDS in JS.
  4. Whatever display logic consumes it.

Pattern B — User-configurable setting + echo → CSV3 + LittleFS

Persistence choice before you start:

  • LittleFS is correct for true user-edited settings — they only get written when the user submits a form, so the wear pattern is benign.
  • NVS is correct for auto-fire scalars — values the firmware updates in a loop (extrema, accumulators, counters). LittleFS wear-leveling stalls Core 1 ~300 ms for those. See plan1_storage_migration.md for the migration record and the prev_* shadow-global change-detection pattern.

Pattern B below shows the LittleFS version because that's right for almost every form-input setting. For auto-fire scalars, use the NVS paths in saveNVSData() / loadNVSData() instead of steps 2 and 3.

Reference example: absorptionCompleteTime (stored in ms, UI in seconds — canonical full-pattern setting).

  1. Global declaration with the units stored:

    uint32_t absorptionCompleteTime = 30000UL;  // default, stored in ms
    

  2. Server handler in /get dispatcher (3_functions.ino):

    else if (request->hasParam("absorptionCompleteTime")) {
      foundParameter = true;
      inputMessage = request->getParam("absorptionCompleteTime")->value();
      uint32_t seconds = (uint32_t)inputMessage.toInt();
      absorptionCompleteTime = seconds * 1000UL;  // UI is seconds, internal is ms
      writeFile(LittleFS, "/absorptionCompleteTime.txt", String(absorptionCompleteTime).c_str());
    }
    
    foundParameter = true also bumps settingsDirty which triggers CSV3 echo on the next dispatch tick.

  3. Boot init in InitSystemSettings():

    if (!fsExists("/absorptionCompleteTime.txt")) {
      writeFile(LittleFS, "/absorptionCompleteTime.txt", String(absorptionCompleteTime).c_str());
    } else {
      absorptionCompleteTime = readFile(LittleFS, "/absorptionCompleteTime.txt").toInt();
    }
    

  4. CSV3 payload in int payload3Len = snprintf(...):

    SafeInt(absorptionCompleteTime),   // <slot>
    

  5. CSV3 JS index map in CSV3_FIELDS:

    "absorptionCompleteTime",  // <slot>
    

  6. Echo registration in JS:

    { key: 'absorptionCompleteTime', id: 'absorptionCompleteTime_echo', transform: v => Math.round(v / 1000) },
    
    transform handles unit conversion for the echo label. v => v means no conversion. Do not add this variable to the generic unit-adjustment block — that block is for pure telemetry, not echo labels.

  7. HTML form row in web_src/index.html:

    <div class="form-row">
      <div class="form-label" style="display: flex; align-items: center; justify-content: space-between;">
        <span>Absorption Completion Time (sec) (<span id="absorptionCompleteTime_echo">?</span>):</span>
        <span class="tooltip" onclick="this.classList.toggle('active')">ℹ️
          <span class="tooltip-box">Tooltip text.</span>
        </span>
      </div>
      <div class="form-input">
        <form action="/get" method="GET" target="hidden-form">
          <input type="hidden" name="password" class="password_field">
          <input name="absorptionCompleteTime" type="number" step="1" min="1" max="999999" />
          <input onclick="submitMessage()" type="submit" value="Set" class="btn-primary" />
        </form>
      </div>
    </div>
    


CSV Payload Integrity — Mandatory Verification

Every time a field is added to or removed from CSVData/2/3 or TimestampData, three things must match:

  1. Enum sentinel (CSV1_FIELD_COUNT, CSV2_FIELD_COUNT, CSV3_FIELD_COUNT, TS_FIELD_COUNT) — declared count.
  2. Format string %d count in snprintf — must equal <declared_count> + 1 (the +1 is the count field itself).
  3. JS array length (CSV1_FIELDS.length, CSV2_FIELDS.length, etc.) — must equal the declared count.

A mismatch between the format string's specifier count and the argument count causes snprintf to silently drop trailing fields. The browser then sees declared = N, received = N − 1, rejects the entire packet, and the dashboard stops updating. There is no error message at the firmware end — just silence on that channel.

This has bitten this project at least three times. Do not skip the check. A grep-based verifier sits at the top of CLAUDE.md.


SafeInt(v, scale) — The Pack Helper

int SafeInt(float f, int scale = 1) {
    // NaN guard, inf guard, rounded multiply, returns int
    
}

Multiplies a float by scale, rounds, returns an int. Handles NaN and inf so a momentarily-bad sensor value doesn't poison the entire payload.

Conventions used in this codebase:

Scale Use for Example
1 Integers, RPM, raw counts SafeInt(RPM)
100 Two-decimal floats (V, A, °F, %, V/s) SafeInt(BatteryV, 100) → 12.34 V sent as 1234
1000 Three-decimal floats (g, V/s, A/s) SafeInt(imu_vertical_accel_g, 1000)

The browser un-scales by the same factor in script.js.


Unit Conversion Conventions

Stored as Displayed as Conversion
milliseconds seconds / 1000
milliseconds minutes / 60000
milliseconds hours / 3600000
float × 100 float / 100
raw raw none

Always store in the natural internal unit (almost always ms for time, A for current, V for voltage, °F for temperature). Always convert only at the display layer.


Cross-checking CSV2 for Misplaced Settings

If a variable has a form input AND a /get?<name>= handler AND persists to LittleFS — it belongs in CSV3, not CSV2. CSV2's 5-second cadence wastes bandwidth re-sending settings that haven't changed, and the echo mechanism on the dashboard expects CSV3 dispatch.

Audit recipe for finding existing misplacements:

# Cross-reference CSV2 fields against /get handlers.
grep -oE 'CSV2_[A-Za-z_0-9]+' 3_functions.ino | sort -u > /tmp/csv2.txt
grep -oE 'hasParam\("[A-Za-z_0-9]+"\)' 3_functions.ino | sed 's/hasParam("\(.*\)")/\1/' | sort -u > /tmp/get.txt
while read f; do
    base="${f#CSV2_}"
    grep -q "^${base}$" /tmp/get.txt && echo "$f"
done < /tmp/csv2.txt

Inspect each hit: if the handler writes to LittleFS and the variable is user-set (not a momentary action like ResetAlarmLatch and not a fake-mode injection like LatitudeNMEA), migrate it to CSV3.


Timed Function Pattern — TIMED_CALL and FuncTiming

Every long-running function in the main loop is wrapped in TIMED_CALL so the dashboard's Function Timing table shows last-call, rolling-window worst, and session worst (since boot or since "Reset Peak Values" button).

struct FuncTiming {
    uint32_t lastCall;       // most recent duration (µs)
    uint32_t worstWindow;    // rolling 5-second worst (µs)
    uint32_t worstSession;   // session worst since perf-counters reset (µs)
};

#define TIMED_CALL(ft, call) \
    do { \
        uint32_t _t0 = (uint32_t)esp_timer_get_time(); \
        call; \
        uint32_t _dt = (uint32_t)esp_timer_get_time() - _t0; \
        (ft).lastCall = _dt; \
        if (_dt > (ft).worstWindow) (ft).worstWindow = _dt; \
        if (_dt > (ft).worstSession) (ft).worstSession = _dt; \
    } while (0)

5 steps to add a new timer

  1. Declare global: FuncTiming ft_MyFunction;
  2. Zero in setup(): memset(&ft_MyFunction, 0, sizeof(FuncTiming));
  3. Add to periodic window-reset block (the worstWindow = 0 cluster in loop()): ft_MyFunction.worstWindow = 0;
  4. Add to the Reset Peak Values handler (/get?ResetPerfCounters block — search for perfCountersResetMs = millis()): ft_MyFunction.worstSession = 0;. Skipping this means the timer's worstSession value survives the button press forever and the dashboard shows stale spikes.
  5. Replace call site: TIMED_CALL(ft_MyFunction, MyFunction());

Dashboard wiring

Both IDs (ft_MyFunction_win_ID and ft_MyFunction_ses_ID) must be wired into CSV2 payload — this is Pattern A2 above. The Function Timing table's column header uses the shared .session-window-label span which JS auto-formats from CSV1 slot 28 (perfCountersResetElapsedS) — no per-row formatting code needed.

Sub-block timing inside one function

Same pattern works for sub-trackers. Examples in ReadAnalogInputs(): ft_rai_ina228, ft_rai_ads_state, ft_rai_bmp_state, ft_rai_imu, ft_rai_total (the outer wrapper that captures total time including I²C timeouts the per-chip timers miss). Sub-trackers also need step 4 (Reset Peak Values handler entry).


Data Freshness — MARK_FRESH / IS_STALE

Adding a new sensor input that should grey out the UI when stale:

  1. Add a new entry to enum DataIndex (Xregulator.ino).
  2. Bump MAX_DATA_INDICES if you added at the bottom (it's the sentinel).
  3. Call MARK_FRESH(IDX_YOUR_INPUT) everywhere the input is updated with a validated value (never on parse failure or sanity-band-fail).
  4. Add a slot in TimestampData (CSV TS stream — Pattern A2 but on the TS channel) so the browser sees the staleness timestamp.
  5. In script.js, register the index in the staleness threshold map and the cell-greying logic.

The 10-second DATA_TIMEOUT is the default. Safety-critical sources (current, temperature) check their own per-source thresholds inside buildTickSnapshot() — see Safeties → T5.


Console Messages — queueConsoleMessage*()

Cross-task message queue (PSRAM circular, CONSOLE_QUEUE_SIZE = 10). Anything anywhere can call:

queueConsoleMessage("plain string");
queueConsoleMessageF("printf-style %s %d", strArg, intArg);  // also accepts const String &

Dispatched to the browser by trySendConsoleSSE() (priority 2 in SendWifiData). Max 5 messages per 700 ms — protects the SSE channel from a flood. Format width inside queueConsoleMessageF is capped to ~128 bytes per message.

Console message timestamps: the browser displays the arrival time at the client, not the time the firmware queued the message. The firmware itself does throttle some logs (e.g. "DS18B20 read failed" is rate-limited to once per 10 s by the caller). If you see "missing" log messages, check that you haven't accidentally landed inside someone's throttle window.


Cross-references

  • Full file-by-file map of what lives whereMain Code
  • Field-control variable list, what each one doesField Control
  • Storage policy: when to use LittleFS vs NVSSystem Overview → Storage Strategy
  • CSV payload field-level reference (kept up to date as variables migrate) → CLAUDE.md (project memory)