A real-time telemetry dashboard for Assetto Corsa. PyQt6 desktop app that speaks AC's built-in UDP remote-telemetry protocol — no in-game plugin required, no AC config edits, no firewall holes for same-machine setups.
- Speed gauge, RPM bar with shift indicator, gear display
- Throttle / brake input bars
- Current / best / last lap times with delta vs. best
- G-forces visualised via the existing read-outs
- Auto-handshake: starts polling AC every 2 s, connects the moment a session begins, reconnects after a session restart
- Dark / light mode, km/h or mph, configurable max RPM, per-widget visibility toggles
OnTrack/
├── pyproject.toml # Package metadata, deps, entry points
├── ontrack_dashboard/ # Dashboard package (Python 3.10+)
│ ├── __main__.py # `python -m ontrack_dashboard`
│ ├── main.py # Entry point + logging setup
│ ├── app.py # QMainWindow
│ ├── telemetry.py # AC RTCarInfo binary parser
│ ├── network/udp_receiver.py# AC UDP client (handshake + subscribe)
│ ├── widgets/ # QPainter-based gauges
│ └── settings/ # Persisted config + settings dialog
└── tests/
└── test_telemetry.py # Protocol parser tests (pytest)
AC ships an embedded Python 3.3 interpreter without the _socket C
extension, which makes shipping a UDP-broadcasting plugin painful (you
end up vendoring a binary .pyd from a long-EOL Python build). AC also
ships a first-party UDP telemetry server on port 9996 that requires
no enablement — sending a 12-byte handshake to that port makes AC start
streaming RTCarInfo packets at the simulation frame rate. The
dashboard uses that protocol directly. See ontrack_dashboard/telemetry.py
for the byte layout, derived from the canonical Romagnoli/Kunos doc and
cross-checked against
rickwest/ac-remote-telemetry-client.
The trade-off: RTCarInfo doesn't carry tyre temperatures or fuel level
— those are only available via AC's shared-memory interface, which is
same-machine only. The tyre-temps widget will render in its idle (cool)
colour scheme until a shared-memory bridge is added.
Python 3.10+ required.
pip install -e . # runtime only
pip install -e ".[dev]" # plus pytest + ruffRun:
ontrack # console script (preferred)
python -m ontrack_dashboard # module form, identical behaviour- Launch Assetto Corsa, enter a session (any car/track).
- In another terminal:
ontrack. - Within ~2 s the dashboard handshakes AC and starts receiving frames.
Defaults are ac_ip = 127.0.0.1, ac_port = 9996 — same-machine works
out of the box.
- On the Windows machine, find the LAN IP:
ipconfig→ look at the IPv4 address of the active adapter (e.g.192.168.1.50). - On the other machine, run
ontrack, open File → Settings, set AC server IP to that address, Apply.
The dashboard will keep retrying the handshake every two seconds, so it will auto-connect as soon as AC enters a session.
File → Settings exposes:
- AC server IP —
127.0.0.1for same-machine, otherwise the LAN IP of the box running AC. - AC UDP port —
9996(don't change unless you know AC has been configured otherwise). - Max RPM — calibrates the RPM bar's redline marker.
- Speed unit — km/h or mph.
- Dark mode — palette toggle.
- Widget visibility — show / hide individual gauges.
Settings live at ~/.config/ontrack/settings.json on all platforms.
AC's remote telemetry uses a tiny custom protocol:
| Direction | Payload | Size |
|---|---|---|
| Client → AC | <3i: (identifier, version, operation) |
12 B |
AC → Client (after op=0) |
Handshake response (4× UTF-16LE strings + 2 ints) | 408 or 808 B |
AC → Client (after op=1) |
RTCarInfo stream |
328 B / packet |
Operations: 0=HANDSHAKE, 1=SUBSCRIBE_UPDATE, 2=SUBSCRIBE_SPOT,
3=DISMISS. The dashboard sends 0 until it sees a handshake reply,
then sends 1 to start the stream, and sends 3 at shutdown.
RTCarInfo byte layout (the fields the dashboard actually reads): see
_RT_CAR_INFO_FMT in ontrack_dashboard/telemetry.py:78 — verified
against RT_CAR_INFO_SIZE == 328.
The protocol layer is fully unit-tested with synthetic byte buffers:
pip install -e ".[dev]"
pytest12 tests covering the handshake encoder, struct layout sanity, decoding
of all consumed RTCarInfo fields, RPM rounding, fields-not-in-protocol
defaulting to zero, packet immutability, and rejection of malformed
datagrams.
Dashboard logs UDP receiver listening then no telemetry arrives.
That message confirms the socket is up but says nothing about AC. Check:
- AC is in a session — handshakes only get a response once the physics engine is running (post-loading-screen).
ac_ipmatches the box running AC. For same-machine that's127.0.0.1; the AC machine's own LAN IP also works.- Nothing else is listening on UDP 9996 on the AC box (other telemetry apps can compete for the port).
Dashboard runs but stays connected to a stale AC session. The receiver detects packet timeout after 5 s of silence and drops back to handshake retries. Just keep it running through your session switch.
Cross-machine setup not connecting.
Windows Firewall on the AC machine blocks inbound UDP by default. Allow
acs.exe on the Private network profile, or temporarily disable the
firewall on the AC adapter to confirm it's the cause.
- Python 3.10+ for the dashboard (
from __future__ import annotations,X | None,match,tuple[...]generics in runtime contexts). - Single source of truth for the wire format is
ontrack_dashboard/telemetry.py. Widgets consume the typedTelemetryPacketdataclass; no field-name stringly-typed dict access anywhere in the rendering path. - Logging via
logging(configured inmain.py); neverprint. - Threading via
QThread, emittingpyqtSignal(object)carrying immutableTelemetryPacketinstances — safe to consume on the GUI thread. - Lint via ruff (config in
pyproject.toml); tests via pytest.
Free to use and modify.