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.