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 regulator is actually using. This page walks the full plumbing path 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 seven stops¶
1. Global variable with a sane default¶
Declare the variable where the firmware globals live (mostly Xregulator.ino), initialized to the value a fresh device should ship with. Store it in the natural internal unit the control code wants — 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 alphabetized 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 lands)¶
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 fire on its next turn — 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 pull 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 learns 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. Adding your setting here is mechanically identical to adding a telemetry field — same payload builder, same position-index enum, same JavaScript array, and the same three-way sync rule described in Adding a Telemetry Value (count sentinel CSV3_FIELD_COUNT, format-string specifier count, and JavaScript array length must all agree, or the browser rejects the whole frame).
Concretely: add an enum entry (search CSV3_absorptionCompleteTime in 3_functions.ino), append the format specifier and argument to the CSV3 payload builder (search payload3Len = snprintf; the real argument is SafeInt(absorptionCompleteTime)), and append "absorptionCompleteTime"-style name to the CSV3_FIELDS array in web_src/script.js.
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 owns 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 bolt settings-echo conversion into the telemetry display code paths; it lives 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 donor; 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; - a hidden password input (
class="password_field") so the device's settings-change gate is satisfied; - 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.
Every parameter row is separated from the next by an <hr />. Keep that — it is a deliberate layout convention across the whole settings page.
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(), CSV3 payload + enum + CSV3_FIELDS entry (re-verify the three-way sync), echoUpdates entry with the right transform, and the form row with its <hr />. Then flash, change the value from the dashboard, watch the echo update, reboot, and confirm the value survived.