Page

Coldbrew: Unlocking a Dead Fitness Bike

Tue 21 April 2026Published

GitHub: carlkibler/coldbrew-expresso-bike-unlock

The Expresso HD is a recumbent exercise bike with an immersive steering interface — real handlebars, 60+ virtual worlds, racing games. The hardware is genuinely great. The problem: Expresso Fitness shut down their cloud service (eLive) in 2025. Without the cloud backend, the bikes lock themselves — most routes are blocked, login screens hang, and games refuse to start.

Coldbrew is the toolkit I built to restore full functionality. All 60+ worlds unlocked, all games accessible, no internet or login required. Offline, forever.

Repo · MIT License

What It Does

The bike enforces content access at five separate layers. Coldbrew disables all five:

Layer What breaks Fix
World availability 13 worlds locked at BRONZE tier sed across world Lua files
Subscription check BikeSubscription=5 blocks eLive content patch ef_global.conf
Login / account type Games require LOGIN_TYPE_ID; dead server returns GUEST Lua constant remapping
Route group threshold Game modes gated by server-fetched point thresholds comment out SetLevelGroupGUID
Runtime callhome Dispenser (cloud sync agent) rewrites fixes /etc/hosts blackhole + watchdog

The installer: rsync payload to the bike, back up every touched file, apply patches idempotently. Full recovery path via recover-coldbrew.sh.

The bike runs Ubuntu 12.04 on a Giada G300 mini-PC with a discrete GPU. It's a perfectly normal Linux box with an SSH server, and the game engine is Unreal Engine 3 with Lua scripting. Once you understand that, the rest follows.

How the Research Happened

This is the story of how Coldbrew got built — reconstructed from the research log and commit history.

Week one: dead ends and documentation. Before touching a bike, I mapped what was publicly known: a few forum threads, no reverse-engineering work, and a manufacturer that had gone dark. The only technical hints were from people who'd partially gotten WiFi working. I started building a picture of the app from the outside — binary names, what the cloud endpoints probably looked like, what a UE3 game's Lua scripting interface would expose.

Getting physical access — the Alt+F2 trick. The bike runs a locked-down GNOME session, but it's not locked down far. Plug in a USB keyboard, press Alt+F2, type xterm. You're in. From there: set a password for the expresso user, find the IP, SSH in from a real machine. This became the entry method documented in the quick-start — it's the kind of thing that's trivial once you know it and completely opaque before.

Finding the structure. /usr/local/expresso/ holds 13 versioned release directories. The active version (20180514001) runs three processes: Inca (the UE3 game engine), Dispenser (cloud sync agent), and Launcher (orchestrator). Inca consumes ~106% CPU during a ride and ~21% RAM. strace was available and cooperated.

Lock 1: World availability in Lua. The game's Lua files set each world's availability tier. Thirteen worlds had LEVEL_AVAILABILITY_BRONZE instead of LEVEL_AVAILABILITY_ALL. One grep -rl and one sed -i later, those worlds opened. This was the fast win.

Lock 2: The subscription config. IsELiveActive() — disassembled from the Inca binary — checks GetBikeSubscription() and accepts only values 1 or 4. The default config ships BikeSubscription = 5. One sed against ef_global.conf, restart, more content available. The dead server can't override a local config file.

Lock 3: Login type constants. Games check GetLoginType() <= LOGIN_TYPE_GUEST before allowing access. With no server, the in-memory login type is 0, which maps to LOGIN_TYPE_GUEST, which blocks everything. The fix: append to GlobalFunctions.lua to remap the constants downward by one — so the in-memory value of 0 now matches LOGIN_TYPE_ID. Games think you're logged in. This is a constant-shift trick, not a patch to a binary.

Lock 4: Route group thresholds. Game modes have SetLevelGroupGUID(...) in their Lua files. That GUID goes through a C++ call chain to fetch a points threshold from the server. With no server, FindRouteGroupByGuid() returns NULL, the threshold resolves to INT_MAX, and the game always locks. Fix: comment out SetLevelGroupGUID in all 30 affected files. Without a GUID, the threshold check is skipped entirely.

The runtime problem: Dispenser's callhome. Dispenser, the cloud sync agent, periodically rewrites in-memory state by querying services.expresso.net. After a restart, the unlocks held — but Dispenser would eventually pull fresh data and partially undo them. Solution: /etc/hosts blackhole for both Expresso domains, plus a watchdog that detects and kills Dispenser's callhome thread if it gets past the DNS block.

The MySQL seeding piece. Dispenser caches server responses in a local MySQL table: inca_requests. On restart, Inca reads that cache. Coldbrew pre-seeds the table with a crafted login_user response: account_type=3, full route_groups list. Inca boots, reads the cache, sees a valid login, proceeds.

Cracking the serial protocol. Not required for the unlock, but documented thoroughly: the resistance board (ttyUSB1) speaks a simple ASCII protocol. hr 1\r enables the output stage. mrd N\r sets resistance from 0–1888. The board has a watchdog — you must send continuously at ~50Hz or it cuts to zero. Inca does this on every loop tick. This is the foundation for Phase 2: an ANT+/BLE bridge that would let Zwift or TrainerRoad drive the bike.

Packaging and publishing. Once the five-mechanism unlock was stable, the installer went through several rounds of polish: dry-run mode by default, auto-detection of 2013 vs 2018 build vintages, backup-first with a full recovery path, optional branding (custom boot splash and rotating taglines). The xbike SSH wrapper was added to make iterative LLM-assisted debugging practical. The repo went public April 19, 2026.

Current Status

The unlock is production-stable on 2013 and 2018 build vintages. The serial protocol is fully documented. Phase 2 — an ANT+ FE-C / BLE FTMS bridge for Zwift/TrainerRoad compatibility — is in early development in mule/.

If you try this on your bike and it doesn't work, open an issue. The hardware is consistent enough that failures are usually diagnosable. If you fix something, contribute it back.