Project
OBD2 Dashboard
Real-time vehicle telemetry on a Raspberry Pi 5 — a .NET 8 + React system that streams live OBD2 data over WebSockets and is fully testable without a vehicle.
2026-05-12
A custom, in-car dashboard that reads live data from a vehicle’s OBD2 port and renders it in the browser in real time. It runs on a Raspberry Pi 5 mounted in the car, but the entire system — backend, frontend, and the OBD2 link itself — can be developed and tested on a laptop with no vehicle attached.
Why I built it
Off-the-shelf OBD2 apps are fine for reading codes, but they don’t give me the control I want for my Coyote-swapped Foxbody: custom gauges, the specific PIDs I care about, and a layout I can iterate on. I wanted a platform I could extend, test locally, and deploy to dedicated hardware in the car — not another app I’d outgrow in a week.
Architecture
Vehicle / Emulator Raspberry Pi 5 (Docker) Browser
┌──────────────────┐ ┌───────────────────────────┐ ┌──────────────┐
│ ELM327 adapter │ USB │ .NET 8 backend │ WS │ React 18 + TS │
│ over serial │ ─────▶ │ • AT command manager │ ────▶ │ MustangDash │
│ (/dev/ttyUSB0) │ serial │ • ObdService (PID poll) │ push │ useWebSocket │
│ │ │ • WebSocketService │ │ custom gauges │
└──────────────────┘ └───────────────────────────┘ └──────────────┘
▲ │ prod: nginx reverse proxy
│ socat virtual serial │
┌──────────────────┐ ▼
│ ELM327 emulator │ docker-compose (dev / prod)
│ (no car required) │
└──────────────────┘
- Backend — .NET 8 / C#. Owns the serial link to the OBD2 adapter, polls the
PIDs I care about, and pushes parsed readings to clients over a WebSocket. The
protocol handling lives in a small AT-command layer (
AtCommands.cs) sitting behind anObdService, so the rest of the app talks in domain terms (RPM, speed, coolant temp, gear) rather than raw ELM327 strings. - Frontend — React 18 + TypeScript. A
useWebSockethook manages the live connection and feeds a Mustang-themed gauge cluster. The UI is just a consumer of the stream, which keeps presentation and data acquisition cleanly separated. - Deployment — Docker Compose. The same containers I run locally deploy to
the Pi; a production compose file adds an nginx reverse proxy. A one-command
deploy-pi.shhandles the Pi side.
The decisions I’m proud of
Making the hardware optional in development. The thing that makes or breaks a
project like this isn’t the gauges — it’s that you can’t iterate if you need the
car running in the driveway every time you change a line. I run the Python
ELM327 emulator as a daemon and bridge it to a virtual serial port with socat,
so the backend talks to /virtual/usb1 exactly as it would to a real adapter.
The result: the full real-time pipeline — serial read, PID parse, WebSocket push,
gauge render — is exercisable on a laptop, in CI, and with a real adapter, with
no code path differences. That testability decision is what kept the project
moving.
A thin protocol seam. ELM327 quirks (timing, AT init sequences, mode/PID
encoding) are isolated in the command layer. Which PIDs get polled is
configuration (ObdPidConfiguration.cs), not code, so adding a reading is a data
change rather than a refactor.
Dev/prod parity from day one. Because deployment is containerized and ARM is a first-class target, “works on my machine” and “works on the Pi” are the same build. The prod compose file only adds the nginx layer.
Results
- End-to-end update latency (serial read → gauge update): [measure: ~__ ms]
- Sustained gauge refresh rate on the Pi 5: [measure: __ Hz]
- Cold-start to live data after
deploy-pi.sh: [measure: __ s] - PIDs streamed today: RPM, vehicle speed, coolant temperature, gear indicator
What I’d do next
- Persistence + replay. Log sessions to a time-series store so I can replay a drive and debug the UI against recorded data instead of live serial.
- Backpressure handling. Define explicit behavior when the adapter outpaces the render loop (drop-oldest vs. coalesce) rather than relying on WebSocket buffering.
- Reconnection semantics. Harden the
useWebSockethook’s retry/backoff and surface connection state in the UI as a first-class element. - A second display target. The browser UI is the prototype; a dedicated kiosk/full-screen mode for the in-dash screen is the real end state.