Skip to content

Repository files navigation

odographe

Odographe (Fr., from the Greek ὁδός "road" + γράφω "I write") — an instrument that records a vehicle's journey.

The device

The device, wired up in a printed case

Grafana dashboard

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.

Hardware

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.rs connects 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.rs have 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.

3D-printed case

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.

Prerequisites

  • Rust (stable) with the riscv32imac-esp-espidf target and rust-src component — both are picked up automatically from rust-toolchain.toml.
  • ldproxy as the linker: cargo install ldproxy
  • espflash for flashing and the serial monitor: cargo install espflash
  • git, python3, cmake, ninja, and a libclang — needed by esp-idf-sys to 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.

Build

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.

Wi-Fi / MQTT configuration

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 --release

Optional: 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.

Power and trip-end timing

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 — see SyncTrigger::Boot in src/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.

OTA updates

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.

1. Build the image and note its version

cargo build --release
git describe --always --tags --dirty

The 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.

2. Produce a flashable image and a manifest

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 8080

3. Point the device at the manifest

Either 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 --release

As 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.

4. What happens on the device

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.

5. Confirm it worked

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.

Status LED

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.

Flash

With the board connected over USB:

cargo run --release

cargo 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/odographe

Add -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.

Serial monitor

To attach the monitor without reflashing (e.g. after a power cycle):

espflash monitor --chip esp32c6

Default baud is 115200, matching the firmware's log output. Press Ctrl+C to exit.

Erasing the LittleFS partition

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 littlefs

Next 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)).

Editor setup

.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.

Troubleshooting

  • 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 default libclang is newer than what bindgen expects, and it silently produces broken/opaque struct layouts instead of erroring. esp-idf-sys only auto-detects a working libclang via a symlink espup creates for the Xtensa esp Rust toolchain fork; since the ESP32-C6 uses the upstream toolchain instead (see CLAUDE.md), that auto-detection never kicks in here and a compatible libclang needs to be pointed to explicitly. This is already pinned in .cargo/config.toml (LIBCLANG_PATH) — if it's pointing at a libclang that doesn't exist on your machine, adjust that path to a libclang you 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/uucp group on Linux).

About

ESP32 firmware that records and syncs your car's OBD-II logs as trips

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages