Adding a User Setting¶
A user setting is a value the boat owner types into the dashboard, that survives a reboot, and whose current value the dashboard displays back ("echoes") so the user can see what the XREG-010 is using. This page walks through every code change using one real, complete example you can trace end to end in the source: absorptionCompleteTime — how long charging current must stay below the tail-current threshold before the absorption stage ends. It is stored internally in milliseconds but shown to the user in seconds, which demonstrates the unit-conversion rules.
Settings persist in the chip's small key-value flash store (NVS), written as strings through three helpers: settingExists(), settingRead(), and settingWrite(). There is no file-per-setting storage — if you find old examples that write settings to individual files, do not copy them.
The eight steps¶
1. Global variable with a factory default¶
Declare the variable where the firmware globals are declared (mostly Xregulator.ino), initialized to the value a fresh device should ship with. Store it in the natural internal unit the control code uses — milliseconds here — never the display unit.
Illustrative only — search the source for absorptionCompleteTime to see the real declaration:
uint32_t absorptionCompleteTime = 30000UL; // tail current hold before float (ms)
2. Flash-storage key name (the NK_* macro)¶
Every setting needs a key name for the flash store, and key names are limited to 15 characters. To keep that constraint in one auditable place, each setting gets a macro in the NK_ key block in 2_functions.ino. Search for NK_absorptionCompleteTime and you will find:
#define NK_absorptionCompleteTime "absorptnCmpltTm"
The macro name carries the full variable name; the string is the 15-character-or-shorter key (often abbreviated by dropping vowels). Always go through the macro — never write the raw key string at a call site.
3. Web server handler (where the dashboard's submit is handled)¶
When the user clicks Set, the browser sends a plain web request with the setting as a URL parameter (/get?absorptionCompleteTime=30&...). The big parameter-handling block in 3_functions.ino has one if per setting — search for hasParam("absorptionCompleteTime"):
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
settingWrite(NK_absorptionCompleteTime, String(absorptionCompleteTime).c_str());
}
Three things to copy faithfully: the unit conversion happens here (UI seconds in, internal milliseconds stored); settingWrite() persists the value (it compares first, so re-submitting an unchanged value costs no flash wear); and setting foundParameter = true also marks the settings as changed (settingsDirty), which makes the settings-echo channel send on its next cycle — that is how the dashboard's echo label updates within a second or two of a successful submit, with no extra code from you.
4. Boot load¶
On startup the firmware must read the stored value back into the global. All settings load in one function — InitSystemSettings() in 4_functions.ino. The pattern, verbatim from the real absorptionCompleteTime block (search for settingExists(NK_absorptionCompleteTime):
if (!settingExists(NK_absorptionCompleteTime)) {
settingWrite(NK_absorptionCompleteTime, String(absorptionCompleteTime).c_str());
} else {
absorptionCompleteTime = settingRead(NK_absorptionCompleteTime).toInt();
}
First boot writes the compiled-in default so the key always exists; every later boot reads it back. Use .toInt() or .toFloat() to match your variable's type — values are stored as strings.
5. Settings-echo payload (CSV3)¶
The dashboard receives the device's current settings from the third telemetry channel (CSVData3, "CSV3"), which fires when any setting changes and otherwise about once a minute as a heartbeat. Unlike the other channels, CSV3 is declared once: the CSV3_LIST macro in 3_functions.ino expands into the position-index enum, the format string, and the argument list, and the web build (compress_web.sh) regenerates the CSV3_FIELDS array in web_src/script.js from it. Adding your setting is one line in that list, immediately above the trailing sessionId / sendMs pair that closes every channel (search 3_functions.ino for X(absorptionCompleteTime):
X(absorptionCompleteTime, "%d", SafeInt(absorptionCompleteTime)) \
Then raise CSV3_EXPECTED_FIELDS, defined just above the macro, by one. A compile-time check compares it with the expanded field count, and the web build checks the macro against the JavaScript array, so a miscount fails the build instead of shifting every later setting by one position. Comments inside the macro must be /* */ block comments: a // comment runs to the end of the whole macro, because the backslashes join its lines into one line, and silently drops every entry after it. Never edit CSV3_FIELDS by hand; the next build overwrites it.
6. Echo registration in the dashboard¶
The dashboard keeps one declarative list mapping each echoed setting to the page element that displays it — search web_src/script.js for echoUpdates. Each entry is the setting's name (key), the page element to write into (id), and a display-conversion function (transform). The real entry:
{ key: 'absorptionCompleteTime', id: 'absorptionCompleteTime_echo', transform: v => Math.round(v / 1000) },
The transform function performs all unit conversion for the echo label — here, stored milliseconds back to displayed seconds. If no conversion is needed, use transform: v => v. Do not add settings-echo conversion to the telemetry display code paths; it is done here and only here.
7. The form row in the page¶
Add the input the user types into. In web_src/index.html, copy an existing parameter block (a div with class form-row — the absorptionCompleteTime row is a good one to copy; search for absorptionCompleteTime_echo) and rename every occurrence. The pieces that matter:
- the echo span inside the label —
<span id="absorptionCompleteTime_echo">?</span>— which entry 6 above keeps updated; - the named input — the
nameattribute must exactly match the parameter your server handler checks in step 3 — with sensibletype,step,min,max; - the submit button using the shared
btn-primaryclass.
The absorptionCompleteTime row submits through the battery-settings validation wrapper (onsubmit="return validateAndSubmitBatteryNumber(...)"). For an ordinary field, drop that attribute and give the submit button onclick="submitMessage()" as most rows do.
Every parameter row is separated from the next by an <hr />. Keep that — it is a deliberate layout convention across the whole settings page.
8. Config-sharing manifest¶
Every persisted setting must be listed in CONFIG_MANIFEST[] in 8_functions.ino — the single allowlist used by both the shareable config export (/exportConfig) and the device's cloud configuration snapshot. The real entry:
{ "absorptionCompleteTime", NK_absorptionCompleteTime, 1 },
The last field is the tier: 1 for a setting that is both exported and imported (install topology, per-install calibration, owner modes, and commissioning status included), 3 for a value that is exported for the fleet snapshot and support but never imported. Tier 3 is for this unit's own bookkeeping with the cloud and for the keys of an equipment record, which import with that record instead. Anything harmless to import is tier 1. There is no tier 2. An import leaves the regulator as the source regulator would be at its next boot, and the owner unticks anything they want to keep in the import preview. Per-device state is still exported rather than excluded, because the snapshot is also used as a remote debugging record. Only a true alias (its stored key already exports), a momentary action that persists no key of its own, or an identity or credential value goes instead into an exclusion list in config_drift_check.py at the repository root, with a reason.
Then run python3 config_drift_check.py from the repository root. It exits non-zero if any persisted key is in neither the manifest nor an exclusion list, checking /get parameter names, every NK_ macro (which also enforces the 15-character cap and refuses two macros sharing one key), bare key strings, and the learning-table blobs. The web build runs the same check and stops on a failure. A setting missing from the manifest is silently absent from shared configs and from the cloud snapshot — the check exists so that gap is a hard failure rather than a quiet one. No cloud or database change is needed: the snapshot stores the manifest as one JSON document.
Unit-conversion conventions¶
Store in the natural internal unit; convert only at the display layer (the handler converts UI → internal on the way in; the transform converts internal → UI on the way out).
| Stored as | Displayed as | Display conversion |
|---|---|---|
| milliseconds | seconds | / 1000 |
| milliseconds | minutes | / 60000 |
| milliseconds | hours | / 3600000 |
| value × 100 (scaled integer) | decimal number | / 100 |
| raw | raw | none |
Checklist¶
Before finishing: global default, NK_* macro (15-character key), /get handler with settingWrite(), boot load in InitSystemSettings(), one CSV3_LIST line inserted above the sessionId / sendMs pair with CSV3_EXPECTED_FIELDS raised by one, echoUpdates entry with the right transform, the form row with its <hr />, and the CONFIG_MANIFEST[] entry with python3 config_drift_check.py passing. Then flash, change the value from the dashboard, watch the echo update, reboot, and confirm the value survived.