Real-time, fully on-device coaching for solo track-day drivers. Ingests CAN telemetry over USB from the car (via an AiM MXP dash logger), persists to SQLite/DuckDB, and serves a Vue 3 PWA over HTTP. Built to run on a rooted Pixel 10 with Termux — no cloud round-trips in the coaching loop.
The May 23, 2026 Sonoma Raceway field test is the load-bearing milestone.
┌──────────────────────── Pixel 10 (in cabin) ─────────────────────────┐
│ CANable 2.0 (SLCAN @ 1 Mbit/s) ─► Termux ─► Python bridge :8765 │
│ │ │
│ ▼ │
│ SQLite / DuckDB │
│ │ │
│ ▼ │
│ Chrome PWA ◄── SSE / HTTP ◄── Flask bridge ── LocalLLM :8080 │
│ (OpenAI-compat, Gemma 4 E2B) │
└───────────────────────────────────────────────────────────────────────┘
When no CAN cable is plugged in, the bridge can re-publish a recorded
session via POST /session/replay/start — same SSE frames, no live bus.
# Backend
pip install -e .[can,ops,dev]
python -m pitwall --track data/tracks/sonoma.json --port 8765
# Frontend
cd src/pwa
npm install
npm run dev # vite on :5173, proxies /api → :8765The deploy/phone/ ladder is the repeatable rooted-Pixel deployment path.
See deploy/phone/README.md for the full flow.
deploy/phone/00-check.sh # adb + Termux + root sanity
deploy/phone/10-termux-packages.sh # pkg install python pyarrow numpy …
deploy/phone/20-stage-repo.sh # rsync src + data → phone
deploy/phone/30-python-deps.sh # uv venv + pip install
deploy/phone/40-stage-recording.sh # push a SQLite recording for replay
deploy/phone/50-build-pwa.sh # vite build → dist/
deploy/phone/60-forward-ports.sh # adb reverse 8765
SIM=1 SIM_LAP_SECONDS=60 deploy/phone/70-start-bridge.sh # synthetic
deploy/phone/80-open-pwa.sh # `am start` Chrome at the PWA URL
deploy/phone/99-stop.sh # kill bridge
deploy/phone/status.sh # /health + recent logsBridge mode is auto-detected: live CAN on /dev/ttyACM0 if present;
otherwise SIM=1 enables the in-process AiM MXP synthetic generator,
or POST /session/replay/start republishes a recorded session.
Three-tier, all local:
| Tier | What | Where |
|---|---|---|
| Bridge | Flask + SQLite/DuckDB + pyarrow | src/pitwall/ |
| Frontend | Vue 3 + Pinia + DuckDB-wasm + Leaflet + Chart.js | src/pwa/ |
| LLM | LocalLLM Android APK (OpenAI-compat HTTP) | sibling APK on phone, http://localhost:8080/v1 |
ADK paddock agents (/coach/ask, /coach/agents) are opt-in — they
require google-adk to be importable. On the phone the dependency is
currently not installable (DNS in adb su exec context is unreliable, and
manylinux wheels don't match Termux's android wheel tag), so these
endpoints honestly return available: false. The bridge falls back to
LocalLLM for brief/debrief via litert_coach.py.
See docs/architecture.md for the as-shipped
topology and docs/api.md for every endpoint.
| Method | Path | What |
|---|---|---|
| GET | /health |
engine + coach + CAN status |
| GET | /cars |
every YAML in data/cars/; the loaded one is flagged |
| GET | /cues/stream |
SSE: live coaching cues |
| GET | /telemetry/stream |
SSE: live telemetry frames |
| POST | /session/replay/start |
re-publish a recorded session to telemetry_bus |
| POST | /session/replay/stop |
stop the replay thread |
| GET | /session/replay/status |
running / idle + frame index |
| GET | /session/<sid>/laps |
lap envelope (GPS / distance / stint detection) |
| GET | /session/<sid>/export.parquet |
parquet export for DuckDB-wasm (4 tables) |
| POST | /coach/brief, /coach/debrief |
LocalLLM-backed narratives |
| GET | /coach/traces |
poll ADK agent traces (returns available: false when ADK absent) |
| GET | /leaderboard?track=&limit= |
best laps grouped by driver/car/track |
pitwall/
├── src/pitwall/ # Flask bridge (Python 3.12)
│ ├── features/
│ │ ├── bp_core.py # /health, /analyze
│ │ ├── bp_cars.py # /cars
│ │ ├── bp_leaderboard.py # /leaderboard
│ │ ├── bp_diagnostics.py # /diagnostics/*
│ │ ├── session/
│ │ │ ├── bp_session.py # CRUD + parquet export + /session/<sid>/laps
│ │ │ ├── bp_analysis.py # lap-time table, sector splits, pedal stats …
│ │ │ └── bp_replay.py # /session/replay/{start,stop,status}
│ │ ├── coaching/ # bp_coaching.py + ADK orchestrator + litert_coach
│ │ ├── telemetry/ # CAN reader, ADR-015 sink, signal registry
│ │ ├── track/ # markers, weather, elevation, evolution
│ │ └── realtime/ # SSE bus + spectator tokens
│ └── __init__.py # register_blueprints + create_app
├── src/pwa/ # Vue 3 PWA (27 pages)
│ ├── src/pages/ # analysis-hub, on-track-hud, pit-stall, …
│ ├── src/entities/ # Pinia stores
│ ├── src/widgets/ # bridge-offline, install-prompt, fullscreen-toggle, coach-voice-button
│ └── public/icons/ # PWA icons (192/512/maskable + apple-touch)
├── data/ # cars/*.yaml, dbc/, tracks/, registry/, formulas/
├── deploy/phone/ # 00-99 + _common.sh + status.sh
├── docs/ # api.md, architecture.md, stack.md, hardware.md, …
└── tests/ # pytest
Field-test rig:
- Phone: rooted Pixel 10 (codename
frankel) with Termux + KernelSU - USB-CAN adapter: Jhoinrch CANable 2.0 RH-02 PRO, SLCAN @ 1 Mbit/s over USB-C OTG
- Dash logger: AiM MXP SmartyCam Enhanced CAN Protocol v3.0 (20 frames, 66 channels)
- Car: 2003 BMW E46 M3 with MSS54HP DME
See docs/hardware.md for the full wiring + signal map.
python -m pytest tests/ -qdocs/api.md— HTTP endpoint referencedocs/architecture.md— as-shipped 3-tier topologydocs/stack.md— Python / Node deps with versionsdocs/hardware.md— Pixel + CAN + dash logger specdata/README.md—cars/,dbc/,tracks/,registry/deploy/phone/README.md— deployment script ladder
Architecture decisions live under docs/adr/ and are frozen historical
records — refer but do not edit when documenting current behaviour.
TBD before any public release.