Skip to content

Draw real telemetry from the car, not a hardcoded literal - #3

Merged
PonderForge merged 2 commits into
mainfrom
feat/live-telemetry
Sep 26, 2026
Merged

PonderForge merged 2 commits into
mainfrom
feat/live-telemetry

Conversation

@taciturnaxolotl

Copy link
Copy Markdown
Contributor

Why

The dashboard has always run off a struct literal in main.c with a demo loop on top. There was no way to show what the car is actually doing.

What changed

It now reads live packets through the SensorHub library, and falls back to the demo when no serial device is present, so the simulator still works on a laptop with nothing attached:

./bin/main                 # demo, as before
./bin/main /dev/ttyUSB0    # live from the car

The demo loop moved into telemetry_source.c alongside the live path, so the dashboard has exactly one way in regardless of whether a car is attached.

Channel mapping is now explicit code. The Arduino sends anonymous channel0..4 and channelA0; what they mean is a wiring decision. The old Python server kept that in a JSON file under test/ that both a git pull and an MQTT message could rewrite, and in April a pull silently changed the column names mid-season. Here it is a struct you can read next to the code that depends on it.

distance_ft is integrated from speed, since the packet carries no odometer. It therefore restarts at zero whenever the process does, which is worth knowing before trusting it mid-run.

What I deliberately did not do

engine_armed and engine_on are left unmapped. The dashboard has drawn indicators for them since it was written, but no channel has ever carried them, and the old Python config never defined them either, so those lights have been dark in every run so far. The firmware README describes channels 3 and 4 as "switch pins" and 0 and 1 as "currently user input", which is not enough to pick from.

Guessing would replace a dark light with a confidently wrong one. It is a one-line change once someone confirms the wiring, and channel0, 2, 3, and 4 are all still free.

Build

SensorHub is optional on purpose, so this still builds standalone:

cmake -S . -B build                                  # finds ../SensorHub
cmake -S . -B build -DCD_WITH_SENSORHUB=OFF          # demo only
cmake -S . -B build -DCD_SENSORHUB_PATH=/path/to/it  # explicit

An explicit path that does not resolve is a hard error rather than a silent fallback, since quietly building demo-only would look identical to success.

CI

Host simulator on Linux and macOS, a warnings-as-errors pass over our sources, and both build modes. The live build checks with nm that the SensorHub symbols are genuinely linked, because a silent fallback to demo would otherwise pass.

There is also a startup smoke test that runs the binary under Xvfb for five seconds. LVGL will happily compile and then abort at runtime on a bad config, which a compile check never catches.

One incidental fix

.gitignore had .*/ under a comment promising "below exceptions" and then listed none, so .github/ was swallowed and no workflow could ever be committed. Added the exception.

Order

Needs HEEV/SensorHub#2 to merge first; CI here checks it out from main.

The dashboard has always run off a struct literal in main.c with a demo
loop on top. There was no way to show what the car is actually doing.

It now reads live packets through the SensorHub library and falls back to
the demo when no serial device is present, so the simulator still works on
a laptop with nothing attached. Pass a device to go live:

    ./bin/main /dev/ttyUSB0

Which physical channel carries what is now an explicit struct in
telemetry_source.c rather than a JSON file that both a git pull and an
MQTT message could rewrite behind your back, which is how the old Python
server lost its channel names in April.

engine_armed and engine_on are deliberately left unmapped. The dashboard
has drawn indicators for them since it was written, but no channel has
ever carried them, so they have been dark in every run so far. Guessing a
pin would replace a dark light with a confidently wrong one; the mapping
is one line once someone confirms the wiring.

Two incidental fixes the new CI forced out: .gitignore was swallowing
.github/, so no workflow could ever be committed, and the lap label's
buffer was one byte short of what a full int can print.
The dashboard has always run off a struct literal in main.c with a demo
loop on top. There was no way to show what the car is actually doing.

It now reads live packets through the SensorHub library and falls back to
the demo when no serial device is present, so the simulator still works on
a laptop with nothing attached. Pass a device to go live:

    ./bin/main /dev/ttyUSB0

Which physical channel carries what is now an explicit struct in
telemetry_source.c rather than a JSON file that both a git pull and an
MQTT message could rewrite behind your back, which is how the old Python
server lost its channel names in April.

engine_armed and engine_on are deliberately left unmapped. The dashboard
has drawn indicators for them since it was written, but no channel has
ever carried them, so they have been dark in every run so far. Guessing a
pin would replace a dark light with a confidently wrong one; the mapping
is one line once someone confirms the wiring.

Two incidental fixes the new CI forced out: .gitignore was swallowing
.github/, so no workflow could ever be committed, and the lap label's
buffer was one byte short of what a full int can print.
@taciturnaxolotl

Copy link
Copy Markdown
Contributor Author

Updated: the wire format now carries a version and a length byte from the start, rather than being retrofitted later.

The version byte exists for one specific failure. If the payload changes size, framing desyncs and you notice. But if it stays the same size and a field changes meaning, the checksum still passes and the receiver decodes confidently wrong data. That is exactly what happened on the Python side in April, when columns 5 and 6 silently went from Speed/button to voltage/timer_reset_button and nobody caught it for months.

The length byte lets an old receiver skip a packet from a newer sender and stay framed instead of desynchronising.

Payload also widened while it was cheap to do so, since breaking the format twice is much worse than once:

before now
temperatures 2 named floats 4 slots
analog 1 4 slots
digital 5 bytes, inputs only 8 inputs + 8 outputs, as bits
sequence none uint16_t

Outputs are reported now. The firmware drives a radiator fan and a water pump whose state appeared nowhere in telemetry, so there was no way to see or log what the car was doing to itself.

The sequence number makes dropped packets visible. A checksum cannot tell you about a packet that never arrived, so a link losing half its traffic looked identical to a healthy one.

Costs: frame 26 to 41 bytes, which takes the link from 4.5% to 7% utilisation, and the sketch from 18% to 19% of a Nano.

@PonderForge
PonderForge merged commit e58f59c into main Sep 26, 2026
5 of 6 checks passed
@taciturnaxolotl
taciturnaxolotl deleted the feat/live-telemetry branch September 26, 2026 15:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants