Building a Real-Time Vehicle Diagnostic System
How a Raspberry Pi, a WiFi-to-CAN bridge, and a Pixel 6 Pro became a streaming health monitor for a 2026 Honda Accord.
Your car’s OBD-II port broadcasts 500+ CAN frames per second at 500kbps on an ISO 15765-4 bus with 11-bit addressing. Most diagnostic apps connect via Bluetooth, read ten standard PIDs at one or two hertz, and render them as gauges. They miss the bus entirely.
Rune is 26 Python files and 3,500 lines of production code running on a $35 Raspberry Pi 4B. It polls 14 OBD-II PIDs at 10Hz through a WiCAN Pro WiFi-to-CAN bridge, computes streaming anomaly detection using a from-scratch HalfSpaceTrees implementation, and never writes a single byte back to the CAN bus. A Pixel 6 Pro mounted on the dash renders four diagnostic screens as a React PWA over WebSocket.
This document walks through every engineering decision in the codebase: the safety gate that validates every command before it hits the wire, the three-layer health scoring engine, the drift-compensated 10Hz timing loop, and the thermal management system inspired by comma.ai’s OpenPilot.
The Pipeline
Rune is four devices connected in a strict unidirectional chain. The data pipeline runs as a single async producer loop in main.py:
WiCAN Pro (ELM327 TCP:3333) → SafeOBDConnection (whitelist gate) → OBDCollector(10Hz polling, circuit breaker) → VehicleSnapshot(Pydantic v2) → FuelCalculator (MAF-based) → HealthScorer(thresholds + HalfSpaceTrees + EWMA) → RuneDatabase (SQLite WAL) → ConnectionManager(WebSocket) → Pixel 6 Pro.
Nothing flows back. The phone receives pre-computed health scores. If compromised, it can only display wrong numbers. The Pi is the gatekeeper.
Bluetooth ELM327 caps at ~2Hz. WiCAN Pro sustains 10Hz over TCP. The OBDCollector uses raw asyncio.open_connection()because python-obd doesn’t support TCP to WiCAN’s ELM327 emulation port.
The collector polls 14 confirmed PIDs per cycle: 010C (RPM), 010D (speed), 0105 (coolant), 0104 (engine load), 0111 (throttle position), 010F (intake air temp), 010B (intake MAP), 0110 (MAF), 0106 (short-term fuel trim), 0107 (long-term fuel trim), 012F (fuel level), 013C (catalyst temp), 0142 (battery voltage), 015C (oil temp). Each decoded using ISO 15031-5 formulas.
Circuit breaker: 3 consecutive PID failures triggers disconnect. Exponential backoff reconnection (1s, 2s, 4s, max 8s). Half-open test every 30 seconds. If the WiCAN Pro drops off the network entirely, the system degrades gracefully -- last-known values persist with staleness markers.
Drift-compensated timing: the 10Hz producer loop measures actual elapsed time per iteration and adjusts the sleep duration to maintain exactly 100ms intervals. Jitter stays within 1-2% even under thermal throttling.
The Safety Covenant
SafeOBDConnection in obd_manager/connection.py is the first code written for the project. Every command must pass through a frozenset whitelist before any bytes hit the wire:
ALLOWED_MODES: frozenset({"01", "02", "03", "09", "22"})
Modes 01 (live data), 02 (freeze frames), 03 (DTCs), 09 (VIN), 22 (Honda proprietary). All read-only. Everything else raises BlockedCommandError. The system uses a positive whitelist, not a blocklist -- deny by default, allow explicitly.
Validation runs on every single command, 14 times per poll cycle, inside the tight 10Hz loop.
Any device on the bus can send any message. In 2015, researchers hijacked a Jeep Cherokee via CAN bus. Rune ensures the phone has zero write access. The frozensetcan’t be modified at runtime even by a bug.
Three-Layer Health Scoring
HealthScorer in health/scorer.py runs three analysis layers at 10Hz:
Layer 1: Threshold scoring via rules.py. Stepped deductions: 100 (normal), 85 (warning), 50 (critical), 20 (floor). Engine only scored at idle.
Layer 2: HalfSpaceTrees-- from-scratch implementation of Tan et al. (IJCAI 2011). River ML doesn’t compile on Python 3.13. 25 trees, height 6, window 500 samples, calibration ~5 minutes.
Layer 3: EWMA smoothing with sensor-specific alphas grounded in thermal mass: coolant 0.01 (~20s), catalyst 0.02 (~10s), battery 0.05 (~4s for Honda ELD cycles).
When an anomaly is flagged (score > 0.6), _identify_affected_subsystem() computes deviation from EWMA baseline normalized by physical range. A 50C catalyst swing is unremarkable (500C range), but a 0.5V voltage drop is serious (3V range).
Six subsystems scored independently with weighted combination: Engine (30%), Transmission (20%), Fuel (15%), Cooling (15%), Exhaust (10%), Electrical (10%). Per-subsystem DTC penalties: -15 for generic codes, -30 for critical. The overall score (0-100) updates at 10Hz but only persists to the database every 5 seconds.
Early warning (2-4 weeks lead time):catalyst efficiency trending down, fuel trim drift beyond +/-10%, thermostat failure (warming curve too slow), O2 sensor aging (response time increasing), alternator degradation (voltage trending down under load). Layer 3 trend regression flags p < 0.05 degradation over 7-30 day rolling windows.
Streaming algorithm -- each reading updates via l_mass/r_mass window swaps. Isolation Forest needs the full dataset. Rune starts cold every ignition.
Honda-Specific Engineering
Every threshold in health/thresholds.py is tuned for the 2026 Honda Accord SE (L15BE 1.5L VTEC Turbo, CVT, non-hybrid).
PID 015E does not exist on Honda. maf_to_fuel_rate_lph() computes from MAF: (MAF / 14.7 / 750) * 3600. Instant MPG capped at 199.9 (fuel-cutoff produces 600+ MPG).
CVT temp requires Mode 22. Command 22 2201, header 7E0, byte 27, byte - 40 = Celsius. Auto-disabled if ECU responds 7F.
ATSP6 is mandatory. 2025-2026 Honda CAN security breaks auto-detect. Init: ATZ, ATE0, ATL0, ATS0, ATSP6, ATSH7E0, ATCRA7E8.
Thermal-Aware Resource Management
ThermalManagerinspired by comma.ai’s OpenPilot. Five levels: GREEN (10Hz), YELLOW (warn), ORANGE (5Hz), RED (2Hz), DANGER (shutdown). Each has hysteresis bands -- ORANGE enters at 72C, exits at 65C.
IIR low-pass filter (tau=5s). Rate detection via linear regression over 60 samples. System monitoring reads /proc/stat and /proc/meminfo directly -- no psutil dependency.
“Getting warm in here. Armrest at 42 degrees.” “Full tank. 9.2 gallons back in me.” First-person voice is the core UX identity -- the car speaks about itself.
The Product
The Pixel 6 Pro is a dedicated display device, not a phone running an app. Vent-mounted in landscape via Miracase clip. Screen Wake Lock API keeps the display on. OLED burn-in prevention: pixel shift every 5 minutes, brightness dim after 30 minutes idle. Four screens via swipe navigation:
Screen 1: Telemetry. Interactive 3D Honda Accord model with 6 health zone markers (engine, CVT, cooling, fuel, exhaust, electrical) at real component positions. Tap a zone for cinematic camera zoom into the subsystem. Auto-rotate showroom when idle. Right panel: subsystem health cards with 5-minute sparklines.
Screen 2: Trace Matrix. 10-cell waveform mosaic in a 2-3-4 pyramid layout showing data Honda’s dashboard hides: fuel trims (STFT/LTFT), MAF, engine load, throttle position, catalyst temp, oil temp, battery voltage, intake pressure. Warm gold hero strip on the left: instant MPG, trip cost, $/mi, tank %, STFT volatility.
Screen 3: Trip Summary. Auto-popup on trip end. MPG arc gauge (green/amber/red severity). Receipt-style card: distance, cost, fuel, duration. Time split donut chart (idle vs city vs highway). Engine load and RPM bar charts. CO2 estimate.
Screen 4: Settings. Gas price wired to live $/mi calculation. Stream rate counter. Rune Voice toggle. Builder attribution card.
All four screens use imperative DOM updates at 10Hz -- zustand subscriptions bypass React reconciliation entirely. No virtual DOM diffing on the hot path.
The Pixel 6 Pro runs in kiosk mode via Tasker + Screen Pinning. Pi boots, WiFi comes up, Pixel auto-connects, Tasker launches Rune PWA fullscreen. The phone is a display appliance. Future: custom Rune Launcher APK to replace Tasker.
System Topology, Live Sensors, Thermal Management, 14 Diagnostic Checks, Trip History, and Log Viewer -- shown with realistic operational data.
Fuel Intelligence
Honda doesn’t show instant MPG on the dashboard -- only rolling averages. FuelCalculator computes it from MAF every 100ms: (speed_mph) / (fuel_rate_lph * 0.264172), capped at 199.9 (fuel-cutoff during deceleration produces 600+ MPG).
Trip detectionuses two signals: speed > 5 kph starts a trip, RPM = 0 for 10 consecutive seconds ends it (catches engine off, not red lights). Fallback: speed = 0 for 5 minutes. Minimum distance 0.05 mi filters junk starts.
Fill-up detectionwatches for fuel level jumps > 20%. Auto-logs date, estimated gallons, and MPG since last fill. Zero manual input. The system tracks trip cost in real dollars using a configurable gas price, and estimates CO2 from fuel consumption.
Everything runs locally. The Pi creates a private WiFi network (SSID “Rune”, 192.168.4.1/24). No data leaves the car. No account. No API keys. No monthly fee. Read-only OBD access is protected under the Magnuson-Moss Warranty Act -- it cannot void your warranty.
What’s Next
Rune v1 is the foundation. v2 through v5 map to concrete research directions: eco-scoring with fuzzy logic, acoustic anomaly detection via CNN autoencoder, PDF reports in mechanic-grade format, and on-device LSTM training via MLX. The three-device architecture: Pi collects, Pixel displays, Mac trains.
The Stack
Python 3.13 + FastAPI + uvicorn -- fully async on the Pi. HalfSpaceTrees built from scratch (River ML incompatible with 3.13). Pydantic v2 models for every data structure: VehicleSnapshot (14 PIDs + Pi sensors), HealthSnapshot (overall + 6 subsystem scores), FuelSnapshot (instant MPG, trip totals, tank %).
SQLite WAL -- not InfluxDB (50-70% CPU from TSM compaction). Four tables: sensor_readings (19 fields, 10Hz), trips (distance, fuel, cost, eco-score), fillups (auto-detected), health_scores (per-subsystem). Pragmas: synchronous=NORMAL, cache_size=-8000, mmap_size=30MB, wal_autocheckpoint=500. Batch writes via executemany(). 90-day retention with hourly cleanup.
API: GET /api/health, GET /api/trips, GET /api/fuel, WS /ws (10Hz vehicle stream), GET /diagnostics (19-check dashboard), POST /control/go-live (hot-swap simulator to real OBD without restart -- all simulator data purged, health scorer calibration restarts from zero).
React 19 + Three.js + R3F -- PWA offline-first. zustand for 10Hz state. DPR capped at 2x. Float32Array circular buffers for sparklines. Web Generic Sensor API (60Hz accelerometer as secondary vibration source). Workbox precache service worker.
Development simulator: 379-line HondaAccordSimulator with realistic L15BE physics -- exponential coolant warmup curve (tau=180s), RPM settling from cold-start high idle (1200 RPM) to normal (700 RPM), driving phases (cold start, idle, city, highway, acceleration, deceleration). Configurable anomaly injection: coolant spikes, fuel trim drift, voltage drops, RPM instability, catalyst degradation. Swappable with real OBD at runtime via go-live.
systemd hardening -- ProtectSystem=strict, MemoryMax=512M, CPUQuota=80%, NoNewPrivileges=true. Private WiFi (SSID “Rune”, 192.168.4.1/24) via NetworkManager AP mode. Static DHCP leases for WiCAN Pro (192.168.4.100) and Pixel 6 Pro. Witty Pi 4 for 12V-to-5V with RTC-based graceful shutdown on ignition off. mDNS at rune.local. OverlayFS prepared for SD card write protection against power loss.
Performance:<100ms OBD poll-to-screen latency. 8-15% sustained CPU. ~400MB RAM (150MB OS + 250MB app + ML). <500MB database after 90 days. Producer loop jitter within 1-2%.
352 tests across 3,619 lines. Record-replay regression testing from real sensor data. 5 anomaly injection types. Parametrized boundary tests. mypy strict + TypeScript strict. 150+ SafeOBD validation cases. Hardware: $200.86 total.