System Architecture¶
As-shipped, May 2026. Three tiers, all running on a single rooted Pixel 10 in cabin (or on a laptop for development). No cloud round-trips in the coaching loop.
┌─────────────────────── Pixel 10 (Termux) ────────────────────────────┐
│ │
│ AiM MXP dash logger ─► CANable 2.0 (SLCAN 1 Mbit/s) ─► /dev/ttyACM0 │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ Python bridge (Flask, src/pitwall/) │ │
│ │ can_reader → telemetry_bus → SSE → PWA │ │
│ │ can_reader → SQLite (or DuckDB) → parquet → PWA │ │
│ │ bp_replay → reads SQLite recording → telemetry_bus │ │
│ │ bp_coaching → LocalLLM (OpenAI-compat) → brief/debrief │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ LocalLLM APK │◄────────┤ bridge (litert_coach)│ │
│ │ :8080 /v1/chat/… │ │ POST /v1/chat/… │ │
│ │ Gemma 4 E2B (LiteRT) │ └──────────────────────┘ │
│ └──────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ PWA (Vue 3 + Pinia + DuckDB-wasm + Leaflet + Chart.js) │ │
│ │ Chrome on the same phone, or any laptop via adb reverse │ │
│ └──────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
ADK paddock agents (/coach/ask, /coach/agents, /coach/traces) are
opt-in and currently not installed on the phone. Manylinux wheels
of google-adk don't match Termux's android wheel tag, and DNS in the
adb su exec context is too unreliable for pip install. The endpoints
honestly return {available: false} until that gap closes. On a laptop
with pip install google-adk they wire up automatically via
state.has_adk.
Tier 1 — Bridge (Python)¶
src/pitwall/ is a single Flask app composed of eleven blueprints
registered in src/pitwall/__init__.py. Each blueprint owns one domain:
| Blueprint | Routes |
|---|---|
features/bp_core.py |
/health, /analyze |
features/bp_cars.py |
/cars |
features/bp_leaderboard.py |
/leaderboard |
features/bp_diagnostics.py |
/diagnostics/* |
features/session/bp_session.py |
session CRUD, ingest, parquet export, /laps |
features/session/bp_analysis.py |
lap-time table, sectors, pedal stats, … |
features/session/bp_replay.py |
/session/replay/{start,stop,status} |
features/coaching/bp_coaching.py |
brief / debrief / ask / traces / agents |
features/telemetry/bp_signals.py |
ADR-015 tall sink + capabilities |
features/track/bp_track.py |
markers, weather, elevation, evolution |
features/realtime/bp_realtime.py |
SSE bus + spectator tokens |
Storage: SQLite on Termux, DuckDB on laptops¶
The DuckDB Python wheel doesn't build for aarch64 Termux. The bridge
detects this at boot and falls back to SQLite (the rest of the code
keeps using db_conn() / state.has_duckdb, which both backends
satisfy). Specific accommodations:
- DDL is SQLite-compatible: integer autoincrement sequences instead
of
nextval(),TEXTinstead ofVARCHAR, noDEFAULT now(). - Parquet export branches on
db_backend(): DuckDB uses nativeCOPY (…) TO 'file' (FORMAT PARQUET); SQLite streams rows through pyarrow in 50k-rowRecordBatches so the 3M-row tall sink doesn't materialise as a Python list. pyarrowis installed via Termuxpkg(not pip) and exposed to the venv via atermux_system.pthshim. Same fornumpy. Seedeploy/phone/10-termux-packages.shand30-python-deps.sh.
Telemetry path: live CAN, replay, and simulator¶
Three publishers, one bus, same SSE shape downstream:
- Live CAN.
features/telemetry/can_reader.pyopens the CANable over SLCAN at 1 Mbit/s, decodes AiM MXP SmartyCam v3.0 frames viadata/cars/bmw_e46_m3.yaml+data/dbc/pitwall.dbc, and flushes wide canonicals intotelemetryevery 100 ms. Tall (signal-name) samples land intelemetry_signalsvia the ADR-015 sink. - Replay (
bp_replay.py). Reads a recorded session'stelemetryandtelemetry_signalstables, walks them in timestamp order, and republishes totelemetry_busat?speed=Ncadence (optionally looping). Used when there's no live CAN plugged in. Stops any running CAN reader on start so the two don't race. - Simulator (
--simulate). In-process AiM MXP synthetic generator with configurable--simulate-speedand--simulate-lap-seconds. Mutually exclusive with live CAN.
Coach path¶
- Hot path (
/analyze):sonic_model+RuleCoachcanonical phrases. No LLM. Sub-50 ms. - Warm path (
/coach/brief,/coach/debrief):litert_coach.pycalls LocalLLM's OpenAI-compatible endpoint athttp://localhost:8080/v1/chat/completions. The_templated_pre_brieftemplated fallback was removed in the no-fake-fallbacks sweep — when LocalLLM is unreachable, the response is honest. - Paddock (
/coach/ask): 18 ADK agents — only whengoogle-adkis importable. Returnsavailable: falseon the phone today.
Tier 2 — PWA (Vue)¶
src/pwa/ is a Vue 3 + Pinia PWA built with Vite 8. 27 pages under
src/pages/, plus widgets, entities (Pinia stores), and shared lib.
Built dist/ is served by the bridge for the phone deployment.
Notable pieces of this sprint:
- Analysis Hall (
pages/analysis-hub/AnalysisHub.vue) — tabbed workbench with six tabs (Overview / Telemetry / Systems / IMU / Signals / Modules). Each tab lazy-loads its chart bundle. Data flow: bridge → parquet → DuckDB-wasm in OPFS → SQL → Chart.js + Leaflet. - DuckDB-wasm worker is bundled same-origin (no jsdelivr CDN, no CORS prompt at boot).
- On-Track HUD (
pages/on-track-hud/) — cockpit-minimal redesign. useLapDerivationcomposable deriveslap_numberclient-side from cumulativedistance / track_length_m(the bridge SSE doesn't emitlap_number).AgentTraceDrawer.vue(toggled byT) polls/coach/traces.CoachVoiceButtonFAB (toggled byM) wires Web Speech API STT +speechSynthesisTTS to a conversational loop.- WAITING-FOR-DATA overlay if no SSE frame within a 3 s grace window.
- Eight pages newly tabbed for Pixel 10 landscape: Settings, CarSetup, Calibration, HardwareDetail, PitStall, QuestLog, EndOfDay, PreBrief, LivePitWall.
- InstallPrompt + FullscreenToggle — sticky banner from
beforeinstallprompt, fullscreen persisted via thefullscreen-togglewidget. - PWA icons —
public/icons/icon-{192,512}.png,icon-maskable-{192,512}.png,apple-touch-icon-180.png. Manifest config is invite.config.ts.
No-fake-fallback sweep¶
Templated mock data was purged in this sprint. The user-visible effect: empty stores and offline banners look "empty" instead of "fake-full".
entities/quest/model/medalStore.ts— honest empty state.entities/session/model/telemetryStore.ts—startSimulation()(Math.sin/cos fake frames) removed.pages/garage-hub/CarSetup.vue— wired to real/cars.pages/track-atlas/TrackAtlas.vue— wired to real/track/markers,/track/danger_zones,/track/elevation,/track/weather.pages/lap-times-hall/LapTimesHall.vue— type-drift fixed.
Tier 3 — LocalLLM (sibling APK)¶
Pitwall doesn't ship an LLM. It expects LocalLLM
(or any OpenAI-compatible server) to be running on http://localhost:8080/v1.
The bridge talks to it via litert_coach.py for brief/debrief and via
ADK's LiteLlm wrapper for paddock Q&A (when ADK is installed).
Environment knobs (read by litert_coach.py and the ADK wiring):
| Var | Default |
|---|---|
PITWALL_ADK_OPENAI_URL |
http://localhost:8080/v1 |
PITWALL_ADK_OPENAI_MODEL |
gemma-4-e2b |
PITWALL_ADK_OPENAI_API_KEY |
local |
Deployment ladder (rooted Pixel 10)¶
deploy/phone/ is a numbered shell-script ladder (00 → 99) plus
_common.sh (shared helpers) and status.sh (probe). Each step is
idempotent and prints a clear OK / FAIL marker. See
deploy/phone/README.md for the per-step contract.
Mode selection in 70-start-bridge.sh:
SIM=1→ built-in synthetic AiM MXP generatorNO_CAN=1→ start in replay-only mode- otherwise → auto-detect
/dev/ttyACM*for live CAN
What this architecture is not¶
- Not cloud-attached. No hosted LLM is ever called. The driver gets the same coaching with the cell radio off.
- Not a Garmin Catalyst clone. There is a real-time loop, not just post-session review.
- Not dependent on DuckDB. SQLite-on-Termux is a first-class backend.
- Not dependent on ADK. The paddock Q&A is an extra tier; everything else works without it.
See docs/api.md for the endpoint contract and
docs/hardware.md for the in-car wiring.