Architecture¶
PlayStation (GT7) ──UDP 33740──▶ Telemetry service (FastAPI, Python 3.12)
▲ ├─ Salsa20 decrypt + typed packet parser
└──heartbeat 33739──────── ├─ Lap detection, session grouping, metrics
├─ SQLite storage (SQLAlchemy async)
├─ REST API (/api/…) – history & analysis
└─ WebSocket (/ws/live) – 30 Hz live stream
│
▼
React 18 + TypeScript + Vite + Tailwind
(ECharts for high-frequency chart rendering)
One process, one event loop¶
The whole backend is a single-process, single-threaded asyncio application (FastAPI + uvicorn). There are no worker threads or subprocesses. The data flow is:
UdpTelemetrySource (or SimTelemetrySource)
│ decrypt + parse (~60 Hz)
▼
asyncio.Queue (600 packets ≈ 10 s buffer)
│ single consumer task — strictly ordered
▼
TelemetryService._on_packet
├──▶ LapProcessor.feed ──▶ lap/session callbacks ──▶ SQLite (async)
└──▶ WebSocket broadcast (rate-limited to GT7_WS_RATE, default 30 Hz)
Design points worth knowing:
- Strict ordering — exactly one consumer task drains the packet queue, so lap detection never runs concurrently with itself. Overlapping feeds could duplicate a lap save while a database write is in flight.
- Backpressure that favors freshness — if the queue fills (e.g. a slow disk stalls a
write), the oldest packet is dropped and counted in
packets_dropped, so the live view always shows current data. - Commit before await — inside the lap processor, all state transitions (lap counter,
sample buffer, fuel/engine aggregates) are committed before any
await. At 60 Hz, a lap boundary that stayed "open" across an await would re-trigger and duplicate laps.
Why FastAPI?¶
Native async fits a 60 Hz UDP stream plus WebSocket fan-out in one process; Pydantic
gives typed models and settings; and the Python ecosystem has mature Salsa20 support
(pycryptodome).
Storage¶
SQLite via SQLAlchemy 2.x async engine (sqlite+aiosqlite). Because the storage
layer only sees a SQLAlchemy URL, SQLite can be swapped for Postgres
(postgresql+asyncpg://…) by changing GT7_DB_PATH — no code changes.
Five tables:
| Table | Contents |
|---|---|
sessions |
one row per driving session: start time, car ID/name/category, note, track name |
laps |
one row per lap: timing, per-lap metrics, detected events (JSON), gearing (JSON), and the full 60 Hz sample series (JSON) |
tracks |
named track signatures for auto-identification (length + bounding box) |
layouts |
saved overlay/dashboard grid configs (JSON) |
settings |
runtime overrides set from the Admin view (console IP, source, log level, webhook URL) |
Schema evolution¶
Migrations are Alembic revisions under backend/app/migrations/, run to head on
every startup by init_db. The app builds its Alembic config in Python and passes the
already-open connection through AsyncConnection.run_sync, so nothing opens a second
writer against the SQLite file; backend/alembic.ini exists only for the CLI
(alembic revision --autogenerate).
Before Alembic, schema changes were a hand-rolled list of ALTER TABLE ADD COLUMN
statements applied at startup — which could only ever add columns, and grew by one
entry per feature. That list survives, frozen, for exactly one job: a database predating
migrations is brought up to the baseline revision by it and then stamped, so an
existing install and a fresh one converge on the same schema and everything after that
is an ordinary revision. Every recorded lap survives the transition; see
backend/tests/test_migrations.py, which asserts it against a first-release database.
To add a column: change the ORM model, then
cd backend && .venv/bin/alembic revision --autogenerate -m "add whatever"
review the generated file (SQLite cannot alter a column in place — render_as_batch is
on, which rewrites the table instead) and commit it. Keep one head: two make
upgrade head ambiguous, and a test guards against it.
No downsampling on write: every 60 Hz tick of a lap is stored. Downsampling happens
at read time — the analysis endpoints resample onto a uniform distance grid (default
5 m steps), and the live WebSocket stream is capped at GT7_WS_RATE (default 30 Hz).
Frontend¶
React 18 + TypeScript, built with Vite, styled with Tailwind. High-frequency charts (the stacked analysis panels, race line map, sparklines) render with ECharts, which handles thousands of points per series without breaking a sweat. State lives in small stores (settings, telemetry, analysis); the current analysis view is fully encoded in the URL hash so links reproduce exact comparisons.
In production the backend serves the built frontend from frontend/dist — one container,
one port. GET /overlay returns the SPA directly (a plain path, because some streaming
apps reject URLs with #fragments).
The simulator¶
GT7_SOURCE=sim replaces the UDP listener with a deterministic synthetic source: a
3,200 m rounded-rectangle circuit driven at 60 Hz with two braking zones, deliberate
front lockups under braking, rear wheelspin on slow launches, a kerb strike, and
TCS/ASM/rev-limiter activity. Lap detection, event detection, charts, and the overlay
all get realistic data with no console — it's how the project is developed, demoed, and
screenshotted.
The simulated car is internally consistent, which matters more than it sounds: the
broadcast yaw rate is the actual turn rate of the line being drawn, the accelerometer is
v × ω and the real speed delta, and the filtered pedals differ from the raw ones
exactly while an aid is intervening. Features that check one channel against another —
the g-g diagram's calibration, the ABS/TCS intervention traces — can only be exercised
without a console if the synthetic data would pass the same check real data does.