Terminal Workouts

Rebuilding (a tiny part of) Zwift in my own image

Replacing Zwift

I'd used Zwift since the COVID lock-downs but realised I only ever touched one feature: structured workouts. No group rides, no racing, no virtual scenery. Just a trainer, and intervals. For what amounts to "send target watts to trainer, show numbers", a 3D renderer draining the battery and spinning up the fans felt unnecessary.

So I built the lightest possible alternative. Terminal-based, Bluetooth-connected, under 15 seconds from launch to pedalling. No GPU, no account, no virtual world. Just Python, Bleak for BLE, and Textual for the UI.

Talking to Hardware

Indoor trainers communicate over BLE using standardised GATT services: Fitness Machine Service (0x1826) for control, Cycling Power (0x1818), and Heart Rate (0x180D). The interesting bit is parsing the byte arrays - BLE fitness specs use flag-driven variable-length formats where each bit in a flags field determines which data fields follow. Get the offset arithmetic wrong and you're briefly displaying 40,000 watts.

Controlling the trainer means writing opcodes to Fitness Machine Service (FTMS's) Control Point characteristic. The spec says trainers should acknowledge commands. In practice, some just don't. The code waits two seconds for a response and treats silence as success - pragmatism born from testing on real hardware.

Workouts use Zwift's ZWO format (XML), with power targets as fractions of FTP. A WorkoutRunner ticks every second and sends updated targets to the trainer whenever the interval changes.

Threading and the UI

BLE callbacks fire from Bleak's asyncio context. Textual's UI runs its own event loop. You can't touch widgets from a BLE callback. The bridge is call_later() - every BLE notification parses bytes into a TrainerData dataclass, then hands it to the UI thread. From there, Textual's reactive attributes handle the re-render automatically.

The workout screen shows live power, target, cadence, and heart rate as large-format numbers. A zone-coloured ASCII workout profile scrolls with progress. Each bar is a Unicode block character coloured by training zone - blue for endurance, yellow for sweet spot, red for VO2max. I had to build this from scratch since Textual's built-in sparkline only supports single-colour gradients.

When Things Go Wrong

Bluetooth drops. Your body is between the laptop and the trainer, the motor is electrically noisy. Both connections poll at 10Hz and auto-reconnect with a 5-second backoff. The UI shows -- for stale data rather than frozen values.

For crashes, every data point writes to a JSONL temp file with immediate flush. Clean exit converts to TCX for Strava. Crash mid-ride? Next launch recovers the orphaned file automatically. You never lose more than a second of data.

The Result

~5,600 lines of Python, 144 tests, two dependencies. No database, no web server, no GPU. Launch to pedalling in about 15 seconds cold, under 5 with saved devices. The architecture splits into core/ (BLE, workout logic, recording - fully testable without UI) and textual_ui/ (purely presentational). A BLE replay system lets you develop and test without being on the bike.

Takeaways

BLE specs are flag-driven puzzles - read the Bluetooth SIG docs carefully before writing parsers. Thread safety needs an explicit bridge - a single call_later handoff keeps things clean. Hardware doesn't follow the spec - timeouts and pragmatic defaults beat strict compliance. Design crash recovery on day one - it's trivial to build early, painful to retrofit. And terminal UIs can be genuinely good - the text grid constraint forces you to be deliberate about information hierarchy.

Intro to AI

Scribbles I've used when introducing AI

Intro to AI

Over the past year I’ve been using a simple visual framework to help schools and non-technical audiences make sense of AI - cutting through hype and focusing on how it actually works.

In a short session, it explains AI as a “fuzzy guessing machine”, shows how prompts become responses, and maps where guardrails sit - from the interface through to the training data

Most importantly, it reframes AI as a set of assistants that can accelerate work and increase capacity, as long as we stay in control.

Dyslexic Friendly Content

Introducing concepts using content that's easier to consume

Dyslexic

The idea was to help kids who struggle to engage with written content by giving them something genuinely interesting to read. The topics are deliberately varied and unconnected, covering everything from prehistoric sharks to absolute zero to how car engines work. The hope is that over time, exposure to these disparate concepts encourages the reader to start making connections between ideas on their own, building curiosity as a habit rather than treating reading as a chore. Crucially, even where the content is challenging, the format keeps the reader engaging with written text as a primary form of communication - because that's a skill that matters regardless of how difficult it might be.

The document uses dyslexia-friendly design throughout - clean sans-serif fonts, generous line spacing, short paragraphs, and bold text reserved for structural signposts rather than cluttering the page. The layout is deliberately predictable so the reader always knows what's coming next, which cuts down on the cognitive effort of just navigating a page. Vocabulary is introduced gently too, with three words per article defined in plain language and then reinforced through the writing prompt that follows. The sample here covers six topics but the full collection is significantly larger, all following the same template.

What makes the format effective is that consistency. Because the structure never changes, the reader can put all their attention into the actual content rather than figuring out where they are or what's expected of them. The interactive elements and writing prompts also shift things from passive reading to active participation - copying out a favorite fact, imagining scenarios, drawing - which makes a real difference for engagement and retention. It's a format that works well for the reader and is straightforward to repeat for anyone creating new entries.