Adding a Telemetry Value¶
Telemetry is sent 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). A 5-second cadence looks frozen on those gauges, which is why they have their own 2 Hz channel. State codes the UI must reflect within half a second are sent here too, such as the limiter code that sets the header banner tint and the charge stage that sets the Plots mode ribbon. - 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" behavior the settings UI depends on.
A fifth channel (TimestampData) carries staleness ages — how long ago each external sensor source last updated, in milliseconds, with 999999 meaning never seen, so the UI can gray out stale inputs. You only change it when adding a whole new sensor source, not for ordinary values.
The recipe (CSV1, CSV2, and CSV4 have the same structure)¶
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 displayed as current. 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 steps, all of which must stay in sync:
-
Declare the global variable in the firmware (typically in
Xregulator.ino) and update it wherever your computation runs. 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 the one "not available" value every channel shares; the dashboard turns that back into not-a-number and prints a dash. To say "no reading," leave the value not-a-number and letSafeInt()convert it rather than inventing a magic number. 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. The browser identifies each value only by its position; these frames contain no field names. -
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).
A new CSV2 field needs one more decision before the web build passes. CSV2 is the slow, derived channel, so the build requires every CSV2 field to be classified for cloud upload: uploaded (naming where the cloud stores it), derived (naming what the cloud rebuilds it from), or not needed, with a reason. The build failure names the field and the list to add it to.
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 receives a frame whose actual length doesn't match the declared count and rejects the entire frame, discarding every other value on that channel along with your one wrong field (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, so the web build (compress_web.sh) now checks every channel for both count and order (enum names against JavaScript names, position by position) and refuses to build on a mismatch, printing the offending slot. Count every specifier type (%u and %lu as well as %d) when you check by hand.
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.
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.