A CircuitPython sim-racing telemetry dashboard driven by SimHub over USB. Plug a CircuitPython display board into your PC, point SimHub's Custom Serial Device plugin at it, and get a live dash: speed, gear, RPM shift-bar, lap times, delta, fuel, position, flags and tyre temps/pressures.
As far as I can tell, this is the only CircuitPython SimHub dashboard — the SimHub microcontroller ecosystem is otherwise entirely Arduino/C++.
Two reference devices included and tested:
Board Chip Screen Status Adafruit PyPortal Titano SAMD51 + ESP32 3.5″ 480×320 (SPI) LilyGo T-Display S3 ESP32-S3 1.9″ 320×170 (parallel)
The project is split into a communication layer (shared, board-agnostic)
and a presentation layer (one per device, because screens and chips differ
wildly). They meet at a single data object, Telemetry.
USB Custom Serial (one ';'-delimited line per frame)
│
┌─────────────────────▼──────────────────────┐
│ COMMUNICATION LAYER (shared, identical) │
│ shared/simhub_telemetry.py │
│ SimHubReader.poll() ──► Telemetry │
└─────────────────────┬──────────────────────┘
│ Telemetry (the contract)
┌─────────────────────▼──────────────────────┐
│ PRESENTATION LAYER (per device) │
│ shared/dashboard_base.py (reusable tools) │
│ devices/<board>/dashboard.py (the layout) │
└─────────────────────────────────────────────┘
shared/simhub_telemetry.py— owns the USB serial channel, buffers and decodes the freshest line into a reusedTelemetryobject. No display code. Copy it to every board unchanged.shared/dashboard_base.py—BaseDashboard: the reusable presentation plumbing (value-level change detection, batched single-refresh-per-frame, status overlay, widget helpers). Board-agnostic.devices/<board>/dashboard.py— subclass ofBaseDashboardthat defines only the screen layout (_build) and theTelemetry→widget mapping (update).
shared/
simhub_telemetry.py # communication layer (copy to every device)
dashboard_base.py # presentation toolkit (copy to every device)
devices/
pyportal_titano/ # copy THIS folder (incl. fonts/) + shared/ to CIRCUITPY
boot.py code.py dashboard.py theme.py fonts/
lilygo_tdisplay_s3/
boot.py code.py display.py dashboard.py theme.py fonts/
simhub/custom_serial_formula.js # the SimHub formula (same for all devices)
tools/
fake_sender.py # test the screen with no sim running
ttf_to_bdf.py # regenerate the bitmap fonts from any .ttf
Fonts are per-device (devices/<board>/fonts/) because the two screens want
different sizes; both sets are Geist Mono, named font-big/med/sml.bdf.
- Display
auto_refreshis off; the screen is pushed once per frame and only when something changed (a dirty flag). - Change detection compares raw values, so an unchanged field never even builds a formatted string — this kills per-frame heap churn (the main cause of GC stutter).
- The
Telemetryobject is reused, not reallocated each frame.
SimHub has no built-in way to broadcast chosen telemetry as UDP/JSON to an arbitrary device — its UDP forwarding only relays a game's native binary packets. The robust, documented path to a microcontroller is the Custom Serial Device plugin over USB, which is what this uses. (WiFi is possible but needs a PC-side bridge — see Going wireless below.)
Install CircuitPython (9.x or 10.x) for your board from
circuitpython.org. The board mounts as a
USB drive named CIRCUITPY.
From the matching CircuitPython Library Bundle:
| Board | Libraries needed in lib/ |
|---|---|
| PyPortal Titano | adafruit_display_text, adafruit_bitmap_font |
| LilyGo T-Display S3 | adafruit_display_text, adafruit_bitmap_font, adafruit_st7789 |
Easiest is circup: circup install adafruit_display_text adafruit_st7789
(it pulls adafruit_bitmap_font automatically). board, displayio,
terminalio, vectorio, usb_cdc, digitalio and paralleldisplaybus are
built into CircuitPython.
Fonts: copy your board's devices/<board>/fonts/ folder to CIRCUITPY/fonts/.
These are Geist Mono bitmap fonts
(font-big/med/sml.bdf), subset to ASCII. If a font file is missing the
dashboard still runs, just falling back to the built-in font. To use a different
typeface or size, regenerate them with python tools/ttf_to_bdf.py YOUR.ttf --sizes 18 32 64 --out devices/<board>/fonts (then rename to the font-* slots).
Copy both the shared files and your board's device files to the root
of CIRCUITPY (CircuitPython runs everything from the root):
CIRCUITPY/
├── boot.py (from devices/<board>/)
├── code.py (from devices/<board>/)
├── dashboard.py (from devices/<board>/)
├── theme.py (from devices/<board>/)
├── display.py (T-Display S3 only)
├── simhub_telemetry.py (from shared/)
├── dashboard_base.py (from shared/)
├── fonts/ (from devices/<board>/fonts/)
└── lib/ ...
boot.py only runs at hard reset. Fully unplug and replug (a soft reload is
not enough). The board now presents two COM ports: the REPL/console port and
a new data port. SimHub talks to the data port.
- Device Manager → Ports (COM & LPT) shows two entries for the board. The
CircuitPython data port is labelled like
CircuitPython CDC2(the higher COM number). Unplug/replug to see which two appear/disappear.
- Settings → Plugins → Custom Serial devices → enable (restart SimHub).
- Custom serial devices → Add new serial device.
- Serial settings: Serial port = your data port; Baudrate =
115200(ignored on native USB, set it anyway); enable Automatic reconnect. - Protocol definition → Update messages → EDIT: turn ON Use javascript,
paste the entire contents of
simhub/custom_serial_formula.js, Ok. Set that row's rate to 30 Hz.- Leave Message after device connect and before disconnect empty (putting the formula there sends it once → "SIGNAL LOST").
- Load a session (or hit Replay). The dash goes live within ~1 second.
SimHub holds the COM port, so toggle the device off first, then:
pip install pyserial
python tools/fake_sender.py COM5 # your data port
Animated dash = the board side is perfect; any remaining issue is SimHub config.
- Create
devices/<your_board>/withboot.py(copy any),theme.py,dashboard.py,code.py, and adisplay.pyif the board doesn't auto-createboard.DISPLAY. - In
dashboard.py, subclassBaseDashboardand implement just two methods:from dashboard_base import BaseDashboard, fmt_laptime import theme class Dashboard(BaseDashboard): def _build(self): # create widgets with self._text(...) / self._bg(...) # MUST assign self.status = self._text(...) (the overlay) ... def update(self, t): c = self._changed if c("spd", t.speed): self.spd.text = str(t.speed) ...
code.pyjust builds the display, then runs the standard loop (copy one of the examples). The communication layer and loop are identical everywhere.
The full Telemetry field list is documented at the top of
shared/simhub_telemetry.py.
- Colours, flag labels, shift-light & tyre bands:
devices/<board>/theme.py. - Units (mph / °F / psi): change the value in the SimHub formula and the
matching label (
SPEED_UNITetc.) intheme.py. - Layout:
Dashboard._build()in your board'sdashboard.py— plain x/y. - Add/remove fields: the field order is the contract between
custom_serial_formula.jsandsimhub_telemetry.py; change both together.
One ;-delimited ASCII line per update, $-prefixed, \n-terminated:
$speed;gear;rpm;maxrpm;fuel;curlap_ms;lastlap_ms;bestlap_ms;delta_s;pos;lap;totallaps;flag;tFL;tFR;tRL;tRR;pFL;pFR;pRL;pRR\n
| Symptom | Fix |
|---|---|
| "ENABLE usb_cdc.data" / "NEED usb_cdc.data" | boot.py didn't run — copy it to root and power-cycle (full unplug). |
| Stuck on "WAITING FOR SIMHUB" | SimHub on the console port, not data; or formula in the wrong box. Try tools/fake_sender.py. |
| "SIGNAL LOST" right after connecting | Formula is in Message after device connect — move it to Update messages. |
Lap times stay 0 |
Game returns lap time as a number, not a TimeSpan — edit the ms() helper in the JS formula. |
A field shows 0 |
Property name differs for your game — use SimHub's "…" picker for the right $prop('…'). |
| ESP32-S3: safe mode, "USB devices need more endpoints than are available" | Native USB has too few endpoints for console-CDC + data-CDC + the drive. boot.py handles this: it disables HID/MIDI and, on ESP32 chips, drops the console channel (so an S3 shows a single COM port = the data port, and has no USB REPL). Hard-reset to clear safe mode after editing boot.py. |
| T-Display S3: dark screen | adafruit_st7789 missing from lib/, or power pin — display.py drives LCD_POWER_ON; if your panel offset looks wrong, adjust colstart/rotation. Lower frequency to 15_000_000 if glitchy. |
The presentation layer is fully decoupled from the transport. To go untethered,
keep your Dashboard and swap SimHubReader for one that HTTP-polls JSON, plus
a small PC bridge that reads the same Custom Serial line and serves it. Open an
issue if you want this added as a second transport.
- SimHub by Wotever — Custom serial devices wiki
- PyPortal Titano: Adafruit
- T-Display S3 CircuitPython display setup: todbot's gist, LilyGo T-Display-S3
- Fonts: Geist Mono by Vercel (OFL-1.1), rasterized to BDF via
tools/ttf_to_bdf.py
MIT.