Odographe (Fr., from the Greek ὁδός "road" + γράφω "I write") — an instrument that records a vehicle's journey.
The device, wired up in a printed case
Trip and telemetry data synced to a home MQTT broker, visualized in Grafana.
Self-contained ESP32-C6 firmware (Rust on ESP-IDF, std) that reads OBD-II data from a vLinker
MC+ adapter over BLE, detects trips and samples telemetry, buffers everything durably in
LittleFS, and syncs to a home MQTT broker (Home Assistant) only when Wi-Fi is in range.
See docs/firmware_architecture.md and docs/implementation_plan.md for the full design and
build sequencing. This README covers day-to-day build/flash/monitor commands only.
This project is only built and tested against one specific combination of parts. Other hardware may well work, but should be expected to need code changes, not just a recompile:
- MCU/RTC: an ESP32-C6 board (a Waveshare ESP32-C6-Zero, specifically) plus a DS3231 external
RTC breakout. This is the only combination ever flashed and run on real hardware. Building
without the RTC (
--no-default-features, see Build) is supported and falls back to the system clock, but that path is only build-verified, never run on-car. - Flash size: at least 8 MB is strongly recommended —
partitions.csv(dual OTA slots + LittleFS) is sized for it, and that's what every image-size measurement in this repo assumes. A 4 MB board may work but will need a smaller partition table, and hasn't been tried. - OBD adapter: a vLinker MC+ is the only adapter ever connected. It's an ELM327-class adapter
exposing OBD-II over BLE, and other adapters that do the same might work, but
src/ble_transport.rsconnects directly to a pinned service/characteristic UUID triple discovered specifically on the vLinker MC+ — a different adapter would very likely need that discovery redone. - Vehicle: the only car this has been driven in, and the only one the PIDs in
obd-core/src/profile.rshave been verified against, is a Peugeot 408 (P54). Other vehicles should work for the standard mode-01 PIDs (RPM, speed, fuel rate, coolant temp, etc.), but the odometer reading goes through a manufacturer-specific body-module header/DID switch that's very likely PSA-specific — expect to need to adjust or drop that part of the profile on a different make.
STL files and an editable FreeCAD project live in case/:
esp32c6_rtc_case-Tray.stl/esp32c6_rtc_case-Lid.stl— ready-to-print case halves that hold both boards (the ESP32-C6 and the DS3231 RTC) together.esp32c6_rtc_case.FCStd— the FreeCAD project the STLs were exported from.esp32c6_rtc_case_revD.FCMacro— a parametrized FreeCAD macro that generates the case, with dimensions (board sizes, wall thickness, hole placement, etc.) exposed as parameters at the top rather than baked into the model.
The case snaps together without screws, and has a cutout for the USB-C port plus ventilation
holes. To adjust the design (e.g. for a different board revision or enclosure size), open
esp32c6_rtc_case_revD.FCMacro in FreeCAD, edit the parameters at the top, and re-run the macro
to regenerate the model — then re-export the tray/lid to STL for printing.
Use PETG or ABS/ASA for a better temperature range and UV resistance.
- Rust (stable) with the
riscv32imac-esp-espidftarget andrust-srccomponent — both are picked up automatically fromrust-toolchain.toml. ldproxyas the linker:cargo install ldproxyespflashfor flashing and the serial monitor:cargo install espflashgit,python3,cmake,ninja, and alibclang— needed byesp-idf-systo fetch and build ESP-IDF itself and to generate bindings on first build.
Nothing else needs installing manually: the first cargo build downloads ESP-IDF and the
RISC-V toolchain into .embuild/ (project-local, gitignored). This takes a few minutes and
several GB of disk on a clean checkout.
cargo build # debug
cargo build --release # release (used by CI / what you'd normally flash)The DS3231 external RTC (external_rtc feature) is built in by default, since the RTC is wired
to every board this project targets. Building for a board without that hardware present needs
--no-default-features added to either command above.
The firmware reads its Wi-Fi and broker settings from NVS first (that's what the P7 config portal will write) and falls back to compile-time defaults baked in via environment variables — so until the portal exists you configure a unit by building it with:
WIFI_SSID=my-home-wifi \
WIFI_PASSWORD=... \
MQTT_URL=mqtt://192.168.1.10:1883 \
cargo build --releaseOptional: MQTT_USERNAME, MQTT_PASSWORD, DEVICE_ID (defaults to odographe-<mac>),
OTA_MANIFEST_URL (see OTA updates below).
Nothing is required: a build with none of these set boots, acquires, and buffers normally —
it just logs sync skipped: no Wi-Fi/broker configuration and never brings the radio up.
Credentials are never logged, only whether each one is set.
When Wi-Fi is used. The device associates on two triggers — at boot, and when a trip ends — and then holds the association until it's lost (i.e. until the car drives out of range), at which point the radio is powered down and not brought back until the next trigger. It never scans looking for the network. Whether the trip-end trigger ever actually gets a chance to run depends on how the device is powered — see Power and trip-end timing below.
In practice that gives you a maintenance window: turn the ignition on while parked at home and the device stays on Wi-Fi indefinitely, with no trip needed. That's the window remote diagnostics and OTA updates use.
Published topics (with the defaults obd2 / homeassistant):
| Topic | Retained | Contents |
|---|---|---|
obd2/<device_id>/trip |
yes | the most recent completed trip; HA discovery sensors template off it |
obd2/<device_id>/telemetry |
no | JSON array of timestamped samples, for a time-series sink (InfluxDB/Telegraf) |
obd2/<device_id>/state |
yes | latest odometer / fuel level / 12 V |
obd2/<device_id>/availability |
yes | online/offline diagnostic; deliberately not wired to HA entity availability |
obd2/<device_id>/diagnostics |
yes | uptime, free heap, unsynced counts, last-sync time, BLE/Wi-Fi state, error tallies, running firmware version, ESP32/RTC temperature — refreshed roughly every minute while Wi-Fi is held, not just on a sync |
homeassistant/sensor/<device_id>/*/config |
yes | HA discovery configs, published once per boot |
Dashboards. Two designs consume these topics, covered in their own docs rather than here:
docs/grafana_dashboard.md (Grafana + VictoriaMetrics via Telegraf —
the preferred path, with full trip/telemetry history, no retention cap, and partially
validated against a real deployment) and
docs/home_assistant_dashboard.md (pure HA, currently more
experimental — designed but not yet run against a live instance, and capped at the last 50
trips). See each doc's own status notes for exactly what's confirmed so far.
The device only has as much time to notice "the drive is over" and get data onto the broker as it has power for after ignition-off — there's no hidden battery buying it extra time by default. How much time that actually is depends entirely on what the device is plugged into:
- A switched USB port that dies at (or immediately after) ignition-off. This is the common
case, and what a stock build is tuned for. Power disappears before the powertrain ECU has even
stopped answering, let alone before the debounce that decides a trip is over — so trip-end is
not detected live. Instead, the device reboots on the next ignition-on, and
TripProcessor::recover_unfinalized()closes out the previous trip from its telemetry breadcrumbs at that point, immediately followed by the boot-time sync. Nothing is lost — the trip and its samples are still durable and still get uploaded — they just show up at the start of your next drive rather than shortly after you park. Because a device on this kind of supply can go several days between successful syncs (any boot away from home just re-buffers), the boot-time sync is deliberately budgeted to drain a multi-day backlog in one attempt rather than a single drive's worth — seeSyncTrigger::Bootinsrc/network.rs. - A "live"/accessory USB port, or an external power source (e.g. a USB power bank) that keeps the device powered continuously. If the device never loses power, trip-end is detected the way the acquisition/processing loop is actually written to do it: the ECU-quiet debounce runs to completion and a Wi-Fi sync is attempted while the car is still sitting there, so data can show up in Home Assistant within roughly a minute of arriving home. No firmware changes needed — it's purely a function of what you plug the board into.
- A supercap added to the 5 V rail (e.g. ~10 F/5.5 V) between the car's switched supply and the
device. This is a user modification — not something this project ships, tests, or maintains.
Sized to bridge the ECU-quiet debounce plus a bounded sync attempt, it buys most of the "live"
experience above from a supply that's still nominally switched. A supercap was evaluated and
deliberately not built into this project (
docs/firmware_architecture.md§5's Hardware Components table) on the assumption of a multi-second-to-a-minute power tail; on a genuinely instant-cut switched port there's no such tail, so anyone wanting live trip-end detection on that kind of port needs to add the hold-up capacitance themselves. - Do nothing and accept the deferred-to-next-boot behaviour. This is a legitimate choice, not a degraded one — see the first bullet above.
Whichever of these you're on, no data is ever lost to a power cut: obd-store's
append-then-mark log format and the crash-recovery breadcrumb scheme exist specifically so that a
trip mid-write survives an unplanned power loss exactly like it would survive a firmware crash.
The only thing that changes between these options is when a trip is detected as over and
uploaded — immediately, or at the next boot.
There's no cloud release server or CI here — updating a device means building on your own machine and serving the result from something on your own LAN. The device checks for an update periodically while its home Wi-Fi association is held (see "When Wi-Fi is used" above), and only when no trip is in progress; it's a plain HTTP(S) GET of a small JSON manifest, not something pushed through MQTT.
cargo build --release
git describe --always --tags --dirtyThe version string is whatever the second command prints. This isn't a manual convention you
need to maintain — it's ESP-IDF's own default PROJECT_VER (git describe --always --tags --dirty, run automatically at build time because this whole repo is the app's own git working
directory), so it's already baked into the binary you just built. Running the command yourself
just tells you what to write in the manifest.
Pushing a debug build instead of --release? Append -debug to whatever git describe
printed. The device appends that suffix itself at runtime (src/ota.rs::running_version()) for
any build with debug_assertions on, since a debug and a release build of the same commit would
otherwise report the identical version string — without it, the device would think it's already
running whatever the manifest offers and skip the update. This is what makes "flash a debug build
over OTA to investigate a field issue without physical access, then push release back" work: a
debug build fits the app slot with room to spare (see docs/optimisation_plan.md §4.5.1), so
there's no need to drive out to the car just to swap profiles. firmware_version in the
Diagnostics topic (step 5 below) will likewise show the -debug suffix while a debug build is
running, which is the quickest way to confirm which profile is actually on the device.
OTA needs the same esp_image-format .bin that espflash flash produces internally, not the
raw ELF cargo build leaves in target/:
espflash save-image --chip esp32c6 --flash-size 8mb \
target/riscv32imac-esp-espidf/release/odographe firmware.bin(For a debug build, use target/riscv32imac-esp-espidf/debug/odographe instead, and don't
forget the -debug suffix on version in the manifest below.)
Then write a manifest next to it, using the version string from step 1:
{
"version": "v0.1.0-12-gabc1234",
"url": "http://192.168.1.10:8080/firmware.bin"
}url just needs to be reachable from the device's home network — any static file server works.
For a one-off on the LAN:
python3 -m http.server 8080Either through the device's settings page (soft-AP on first boot, or reachable at the STA IP for as long as an association is held — see "When Wi-Fi is used" above) under "OTA update manifest URL", or at build time the same way Wi-Fi/MQTT settings can be:
OTA_MANIFEST_URL=http://192.168.1.10:8080/manifest.json cargo build --releaseAs with every other setting, NVS (the portal) always wins over the build-time default. Leaving it blank — the default — disables OTA entirely; there's no separate on/off flag.
Once a manifest URL is configured, the device compares the manifest's version against its own
running build every ~5 minutes while it has Wi-Fi (immediately on the first check of a fresh
maintenance window, so parking at home doesn't mean a long wait). A mismatch — plain string
inequality, not a semver "newer than" check, so re-publishing an older manifest is a valid way to
intentionally roll back — triggers a download straight into the inactive OTA slot and a reboot
into it. The comparison is entirely git describe strings, not build timestamps, so republishing
the exact same manifest after a device already applied it is a no-op.
This is safe to interrupt at any point: the currently running slot is never touched until the very last step, so a Wi-Fi drop or power loss mid-download just leaves a half-written inactive slot that gets erased again next attempt. It also never runs mid-trip (checked against the same signal the crash-recovery logic uses for "is a trip currently open") or interleaved with an MQTT sync. If a freshly applied image panics or hangs before finishing its own boot sequence, the bootloader automatically reverts to the previous, known-good slot on the next reset.
The retained obd2/<device_id>/diagnostics topic's firmware_version field reflects whatever
build is currently running — check that it matches the manifest's version after the device's
next maintenance window.
The board's on-board addressable RGB LED (GPIO8 on the Waveshare ESP32-C6-Zero) carries two independent signals -- colour for the OBD link, blink for the network link:
| Wi-Fi not associated | Wi-Fi associated | |
|---|---|---|
| BLE connected | solid blue | blinking blue |
| BLE disconnected | solid red | blinking red |
Red means the adapter isn't talking to us and the drive is not being recorded. Red at boot is normal — BLE isn't connected until the adapter is found, so the expected sequence is red, then blue after a few seconds. The blink tells you, from outside the car, whether the upload window opened on arrival.
If the board wires its LED to a different pin, LedPin in src/status_led.rs is the only thing
to change; a board with no such LED logs a warning at boot and runs normally.
With the board connected over USB:
cargo run --releasecargo run is wired (via .cargo/config.toml) to espflash flash --monitor, so it builds,
flashes, resets the board, and immediately attaches the serial monitor in one step.
To flash without immediately monitoring, or to control the port/baud explicitly:
espflash flash --chip esp32c6 target/riscv32imac-esp-espidf/release/odographeAdd -p /dev/ttyUSB0 (or whichever port the board enumerates as) if espflash doesn't
auto-detect it, or run espflash flash --list-all-ports to see candidates.
The on-device partition table (dual OTA slots + LittleFS, see partitions.csv) is applied by
espflash itself, picked up from espflash.toml in the project root — it's independent of the
ESP-IDF build, so it doesn't need idf.py or a Kconfig custom-partition-table setting.
To attach the monitor without reflashing (e.g. after a power cycle):
espflash monitor --chip esp32c6Default baud is 115200, matching the firmware's log output. Press Ctrl+C to exit.
To wipe persisted trip/telemetry data (e.g. to reset state between power-cut durability tests
— see docs/implementation_plan.md P4 "Validate") without reflashing or touching the OTA app
slots:
espflash erase-parts --partition-table partitions.csv littlefsNext boot will find no filesystem, log mount failed... formatting..., and come up with an
empty Data Store (0 trip record(s), 0 telemetry sample(s)).
.zed/settings.json configures rust-analyzer for Zed with the correct
cargo target (riscv32imac-esp-espidf) and the rustc build-script wrapper, so it can resolve
esp-idf-sys's generated bindings instead of falling back to the host target.
- First build is slow / large. Expected — it's fetching ESP-IDF + the RISC-V toolchain, not
just Rust crates. Subsequent builds reuse
.embuild/and are much faster. - Bindgen/compile errors mentioning missing struct fields in
esp-idf-hal/esp-idf-sys(e.g.spi_transaction_t,timeval) on a from-scratch environment setup. This has been seen when the host's defaultlibclangis newer than whatbindgenexpects, and it silently produces broken/opaque struct layouts instead of erroring.esp-idf-sysonly auto-detects a workinglibclangvia a symlinkespupcreates for the XtensaespRust toolchain fork; since the ESP32-C6 uses the upstream toolchain instead (seeCLAUDE.md), that auto-detection never kicks in here and a compatiblelibclangneeds to be pointed to explicitly. This is already pinned in.cargo/config.toml(LIBCLANG_PATH) — if it's pointing at alibclangthat doesn't exist on your machine, adjust that path to alibclangyou have installed. - No board detected. Confirm the USB cable carries data (not power-only), and that your user
has permission to the serial device (e.g. is in the
dialout/uucpgroup on Linux).

