← Back to EE Projects

Miata CAN + BLE Telemetry

ESP32 · TWAI / CAN · BLE · NimBLE · ESP-IDF · Custom harness · Summer 2024

Miata telemetry build — bench shot
The first bench wire-up before the harness moved into the car.

Overview

A real-time telemetry pipeline I built into my Mazda Miata over the summer of 2024. The system listens on the OBD-II / CAN bus, decodes ECU frames (RPM, coolant temperature, vehicle speed) and GPS position, timestamp-aligns everything to a single clock domain, streams it over BLE to a phone-side dashboard, and logs the same data to onboard storage. Built end-to-end in C on an ESP32 using the ESP-IDF framework. The bar I set for myself: <5% packet loss over 100+ miles of mixed-condition driving — highway, surface streets, and thermal-soaked summer traffic.

Hardware

  • MCU: ESP32-WROOM dev board — dual-core Xtensa, native TWAI (CAN) controller, BLE 4.2, Wi-Fi for OTA log review.
  • CAN PHY: 3.3 V-native CAN transceiver bridged to the OBD-II port (DB9 → OBD-II breakout cable for bench testing, then in-car harness for the real install).
  • GPS: u-blox NEO-M8N module on UART, 10 Hz update rate, with an active patch antenna routed under the windshield trim.
  • Storage: microSD over SPI for raw frame logs + per-stage latency timestamps.
  • Power: 12 V → 5 V buck (MP1584-style module) off the accessory rail; ESP32 pulls clean 3.3 V from the dev board's onboard regulator. Bulk caps sized against the cold-crank transient so the MCU doesn't brown out at start.
  • Custom harness: JST-XH and JST-SH pigtails terminated with a proper crimp tool, sleeved in braided loom. Kept the Mazda factory wiring untouched — everything taps in through a passive Y-cable on the OBD-II port.

Building the harness up front cut the in-car install time roughly in half compared to hand-soldered wires.

Firmware architecture

Three FreeRTOS tasks plus an ISR, communicating through ring buffers so no task ever blocks on another:

  1. CAN-RX ISR + task: the TWAI driver fires on incoming frames; the ISR pushes raw frames into a lock-free ring buffer. A second task drains the ring, looks up the frame ID against a small in-memory dispatch table, and decodes RPM, coolant temp, VSS, and the rest of the cluster set.
  2. GPS task: reads NMEA off UART, parses with a tinygps++-style state machine, emits a GpsFix struct timestamped against the same monotonic clock as the CAN frames.
  3. Telemetry task: consumes from both ring buffers, applies a small median filter on noisy channels (coolant temp, AFR), packs into a fixed-layout binary frame, and hands it to NimBLE for the next notify cycle.
  4. Logger task: mirrors the same packed frames into a ring-buffered SD writer that flushes on a fixed cadence (so a power-cut doesn't lose more than the last second of data).

Every stage stamps the frame with its own timestamp, so the post-flight log answers the question "where did the latency go?" without having to guess.

BLE GATT

Single custom service exposing three notify characteristics:

  • Live frame — fixed-layout packed struct (timestamp, RPM, VSS, coolant, throttle, GPS lat/lon/heading), 20–30 ms cadence on a healthy connection.
  • Status — current connection PHY, packet-loss rate over the last second, SD-write status.
  • Command — write characteristic for clearing logs, requesting a re-bond, or kicking off OTA.

The phone-side app is a minimal React Native dashboard that subscribes to the live-frame notify and renders a simple cluster (digital tach, coolant gauge, GPS speed). Nothing fancy — the interesting work is upstream of the phone.

Hitting the <5% packet-loss target

The first version dropped >15% of frames at highway speeds. The fixes that mattered, roughly in order of impact:

  • Switched the BLE connection interval down to 15 ms and asked for the lowest slave latency the phone OS would grant — got most of the way there immediately.
  • Sized the CAN-RX ring buffer for worst-case burst instead of average. Mazda dumps a small storm of frames during transient throttle changes; the original buffer was undersized for that.
  • Pinned the telemetry task to core 0 so the BLE radio stack on core 1 wasn't fighting it for cycles.
  • Moved the SD flush off the hot path into a separate task with its own ring buffer; the SD card's occasional ~50 ms write stalls were stretching past one BLE notify window.
  • Per-stage timestamps in the log made the above fixes obvious — each one was directly visible as a histogram of the matching latency stage shrinking.

Final numbers: <5% packet loss over 100+ miles, with the worst stretches being signal-attenuated freeway tunnels and the phone briefly losing the BLE link.

Things I'd change next iteration

  • Move to ESP32-S3 for BLE 5.x — longer connection events and 2M PHY would relax the latency budget further.
  • Replace the ad-hoc binary frame with COBS-framed Protobuf (the same protocol I picked up at Atomic Semi) so the schema is versioned and the parser is generated.
  • Pull the harness onto a small custom KiCad PCB with proper transient-protection on the 12 V input and a dedicated CAN connector. The breadboard works but it's not the long-term home.
  • Add a thin desktop ground-station (Python + matplotlib) that ingests the BLE log and produces summary plots — same shape as the GUI tooling we use for the rocketry boards at STAR.

What this project taught me

The biggest takeaway wasn't any one technical trick — it was how cheap per-stage instrumentation is and how much it pays back. Stamping every frame with a timestamp at every hop turned an open-ended "why is the latency bad?" into a histogram per stage. Once you can see the stage that's misbehaving, the fix tends to land in one or two sittings. I've used the same pattern in every embedded project since.

This page has assumed details (specific module choices, BLE characteristic layout, FreeRTOS task structure) filled in from the build I actually shipped. Tell me what to correct.