Adding a Telemetry Value¶
This page shows how to send a new live number from the XREG-010 firmware to the web dashboard. Telemetry travels over five one-way browser-push channels (Server-Sent Events, or SSE), and the single most important decision — the one contributors get wrong most often — is picking the right channel before writing any code.
Step zero: classify your value¶
Ask one question first: is this a number the firmware computes, or a value the user types in?
- Fast-moving live value (changes many times per second, the dashboard should plot or display it in real time — battery voltage, field duty, control-loop outputs) → the high-rate channel (
CSVData, called CSV1, sent roughly 10 times per second). - Mid-rate nav / wind / solar / fuel value (a navigation, wind, solar, or fuel readout that drives a dial, compass, speed, solar, or fuel gauge and needs ~2 Hz to look live — heading, speed over ground, apparent/true wind, velocity made good, Victron solar/battery current, fuel rate) → the live nav channel (
CSVData4, called CSV4, the "NavStream", sent every 500 ms). These values were originally on the 5-second diagnostics channel and looked frozen, which is why they have their own 2 Hz channel. - Slow-moving status, diagnostic, or counter (a once-per-5-seconds update is plenty — peak temperatures, error counters, runtime state flags, timing statistics) → the diagnostics channel (
CSVData2, called CSV2, sent every 5 seconds). - User-configurable setting (it has a form input, persists across reboot) → that is not telemetry. It belongs on the settings-echo channel (CSV3) with persistence — follow Adding a User Setting instead. Putting a setting in CSV2 makes it re-send every 5 seconds for no reason and breaks the "echo on change" behaviour the settings UI depends on.
A fifth channel (TimestampData) carries staleness watchdogs — how long ago each external sensor source last updated, in milliseconds, with 999999 meaning never seen, so the UI can grey out dead inputs. You only touch it when adding a whole new sensor source, not for ordinary values.
The recipe (CSV1, CSV2, and CSV4 are the same shape)¶
These channels are a comma-separated frame of scaled integers (CSV4/NavStream uses the identical pattern — payload4Len = snprintf in 3_functions.ino, the Csv4Index enum with its CSV4_FIELD_COUNT sentinel, and the CSV4_FIELDS array in web_src/script.js). The firmware builds the frame with one big formatted-print call (snprintf), and the browser splits it and maps each position to a name using a JavaScript array.
Every channel ends with the same two fields: the boot identity (sessionId, from g_sessionId) and the build time of the payload (sendMs, millis()). The dashboard ages every other channel against CSV1's sendMs and compares session ids, which is how a stale cached block is detected instead of silently trusted. A new field goes immediately above that trailing pair — never after it — in the enum, the format string and argument list, and the JavaScript array alike.
Four stops, all of which must stay in sync:
-
Declare the global variable in the firmware (typically in
Xregulator.ino) and update it wherever your computation lives. Floats are fine — they get scaled to integers at send time. -
Insert the value into the channel's payload builder in
3_functions.ino. Search forpayload1Len = snprintf(CSV1) orpayload2Len = snprintf(CSV2). Two edits in the same statement: add one more conversion specifier (usually%d) to the format string, and add the matching argument to the argument list — both immediately above the trailingsessionId/sendMspair (g_sessionIdandmillis()are the last two arguments of every builder). Values go through theSafeInt()helper, which multiplies by a scale factor and turns not-a-number and infinite values into-1. Also add your field's name to the matching position-index list (theCsv1Index/Csv2Indexenumnear the top of3_functions.ino) — the new entry goes immediately aboveCSV1_sessionId/CSV2_sessionId, which sit just above the count sentinel (CSV1_FIELD_COUNT/CSV2_FIELD_COUNT).Illustrative only — your real code should follow the current pattern in the source; search
3_functions.inofor the named example:SafeInt(setpointLimited, 100), // sent as value x100, two decimal places preserved -
Insert the matching name into the channel's JavaScript field array in
web_src/script.js. Search forCSV1_FIELDSorCSV2_FIELDS. The name goes immediately above the trailing"sessionId","sendMs"entries, in the same position your argument occupies in the firmware payload. Position is everything — these frames have no labels on the wire. -
Consume it in the UI. The dispatcher in
script.jsturns each frame into named values; wherever you display yours, divide by the same scale factor you used inSafeInt()on the firmware side (a value sent asSafeInt(x, 100)is read back asvalue / 100).
The three-way sync rule
Every channel carries its own declared field count as the first value of each frame, taken from the count sentinel at the bottom of the channel's position-index list (CSV1_FIELD_COUNT, CSV2_FIELD_COUNT, ...). Three things must always agree:
- the count sentinel in the firmware enum,
- the number of conversion specifiers in the format string (which is count + 1, because the count itself is the first field), and
- the length of the JavaScript field array (
CSV1_FIELDS/CSV2_FIELDS).
If the format string has fewer specifiers than arguments, the trailing fields are silently dropped — the browser sees a frame whose actual length doesn't match the declared count and rejects the entire frame, taking every other value on that channel down with your one mistake (and, when the declared count disagrees with the JavaScript array length, raises the Interface Out of Date dialog). This failure mode has recurred repeatedly in this project. Re-count all three after every change; count every specifier type (%u and %lu as well as %d).
Worked examples to search for¶
- CSV1 (high-rate):
setpointLimited— the voltage-controller's rate-limited target. Search forCSV1_setpointLimitedin3_functions.ino,SafeInt(setpointLimited, 100)in the CSV1 payload builder, and"setpointLimited"inCSV1_FIELDSinweb_src/script.js. Note the UI divides by 100 wherever it reads this value. - CSV2 (diagnostics):
MaxAlternatorTemperatureF— a per-session peak. Search forCSV2_MaxAlternatorTemperatureFin3_functions.ino,SafeInt(MaxAlternatorTemperatureF)in the CSV2 payload builder, and"MaxAlternatorTemperatureF"inCSV2_FIELDSinweb_src/script.js.
One practical note on cost: only one channel transmits per pass through the firmware's main loop, so adding a field does not add per-tick CPU load — it only makes that channel's frame slightly longer. The real cost of a misplaced field is wasted repetition (a never-changing setting re-sent every 5 seconds) and a broken echo workflow, which is why classification comes first.