Lean iOS app for controlling smart trainers (Kickr & co.) via the open FTMS Bluetooth standard (Fitness Machine Service) — not a Wahoo-proprietary protocol, so it works in principle with any FTMS-capable trainer.
MIT licensed. If Unchain's useful to you, a GitHub Sponsors or Buy Me a Coffee contribution toward keeping it going (mainly an Apple Developer Program membership) is welcome, always optional — the app itself never asks.
Unchain has grown through two rough phases – MVP bike-trainer control, then treadmill/FTMS/workout-file support and beyond – tracked as a running, dated decision log rather than a snapshot. See STATUS.md for a list of supported smart trainers, the full feature history and the App Store readiness checklist.
The Xcode project itself isn't kept in the repo; it's generated from
project.yml via XcodeGen
(the usual way to avoid .xcodeproj merge conflicts).
brew install xcodegen
cd /Users/olau/Workspace/bahoo-kickass
make generate
open Unchain.xcodeprojThen, in Xcode, select your own team under Signing & Capabilities.
make generate is xcodegen generate plus one more step:
Scripts/stamp-build-info.sh stamps the
current git describe string, an auto-incrementing build number (total
commit count), and a build timestamp into the freshly (re)generated
Generated/Info.plist — shown at the bottom of the Devices screen (see
AppVersionInfo) so a specific build's origin is always identifiable at a
glance. Running plain xcodegen generate instead still works, it just
leaves that footer showing whatever it last said.
Two issues have already come up here and been fixed — in case they recur after an Xcode update, here are the fixes:
xcodebuild/swift/Xcode.app crash withSymbol not found: _XPCTypeBool(CoreDevice.framework vs. Mercury.framework) → fixed withsudo xcodebuild -runFirstLaunch. If that doesn't help, additionally runsudo rm -rf /Library/Developer/PrivateFrameworks/CoreDevice.frameworkand run the command again.- "The developer disk image could not be mounted on this device" when running
on a real iPhone → usually a stale cache. Fix with:
Then restart Xcode and reconnect the device — Xcode automatically downloads a fresh disk image from Apple.
mv ~/Library/Developer/DeveloperDiskImages ~/Library/Developer/DeveloperDiskImages.bak
A ramp test for FTP estimation is bundled with the app (Program tab → "Load
Sample Ramp Test"). For anything else, you need ready-made .erg/.mrc
files – most
providers require an account for that – one exception:
- cyclingintervals.com — free structured
workouts available directly as
.erg,.mrc,.zwo, or.fitdownloads, no registration/login required. - trainerday.com (formerly ErgDB) — very large library, but downloads usually require login.
Bluetooth doesn't work in the simulator — the app must run on a real iPhone.
DEVICE auto-detects whichever iPhone is currently connected via USB
(connectionProperties.transportType == 'wired' in xcrun devicectl list devices – distinguishes a live USB connection from a Wi-Fi-paired-but-not-
connected device, which otherwise looks the same in the device list), so
make run picks up Oliver's phone or his wife's without needing
DEVICE=... every time it's the other one. Falls back to a fixed default
if nothing's connected by USB right now; override on the command line the
same way as always for a not-currently-plugged-in device, e.g. make run DEVICE=Eipättpro.
make run/make install/make debug only ever push Unchain.app (with
UnchainWatch.app embedded inside it) to the iPhone – that's not the same
as the paired Watch picking up the new embedded build, which normally
depends on the Watch app's own "Automatic App Install" sync and doesn't
reliably (or quickly) reflect a fresh development build. Use
make install-watch/make run-watch/make debug-watch to push straight to
the paired Watch instead, the same way the plain targets do for the iPhone
(WATCH_DEVICE defaults to watchOLA, override the same way DEVICE
works). Bike vs. treadmill behavior on the Watch itself still can't be
tested in the Simulator either – needs a real, paired Watch.
GPX-route riding (see STATUS.md) is deliberately offline-only for now: a track missing elevation data is rejected rather than filled in from the internet. If that turns out to be too limiting in practice:
- Plain OpenStreetMap has no elevation data itself (it's a vector map, not a DEM), so this needs an actual elevation dataset. The best option found so far is Copernicus DEM GLO-30 — 30 m resolution, near-global coverage, distributed as ready-to-use Cloud-Optimized GeoTIFF tiles directly on AWS S3 (AWS's Open Data Sponsorship Program: no login, no cost, freely licensed).
- Rather than a live per-point API call, the nicer fit for this app's offline-first design is a one-time regional tile download: fetch the 1°×1° GeoTIFF tile(s) covering a track's bounding box once, cache them on-device, and do the elevation lookup locally against the cached raster afterwards. Every later GPX import in that region then needs no network at all — including out on a ride with no signal — which a live lookup wouldn't give you. iOS has no built-in GeoTIFF/COG reader (no MapKit/CoreLocation support for this), but a point lookup via the raster's own geotransform only needs a small, purpose-built parser, not a general raster library.
- Fallback if that turns out to be impractical: a live SRTM-based API like Open-Elevation or Open Topo Data (both open-source, self-hostable) — simpler to integrate, but back to a network call on every import, indefinitely, not just the first one per region.
- Either way, this would be the first feature needing network access at all — the app has been Bluetooth/HealthKit/local-file-only until now, worth flagging explicitly to the user rather than adding quietly.
- Not every trainer supports Indoor Bike Simulation — check via the feature overlay (ℹ️ next to the trainer name, look for "Indoor Bike Simulation (Grade)" under Adjustable Targets) before relying on it; a graceful fallback (e.g. resistance-% approximated from the gradient) isn't implemented, since FTMS doesn't guarantee a fixed, predictable relationship between resistance level and felt difficulty (see the resistance-mapping fix earlier in this project's history) the way it does for grade simulation.
TrainerDay has an "HR+" mode: set a target heart rate, and it continuously
adjusts the power sent to the trainer to hold you there — a closed-loop
controller, unlike anything else in this app (Power/Resistance/Grade/Program
are all either manual or a fixed, pre-planned schedule; nothing else reacts
live to a sensor's own noisy signal). Deliberately not building this:
heart rate is a poor control input, however useful it is as a monitoring
one (which is exactly what HeartRateZone already covers – live and
post-workout, never driving anything). It's confounded by too much that has
nothing to do with the current training stimulus – riding position, sleep,
heat, caffeine, daily form, cardiac drift even at genuinely constant power
over time – so a controller reacting to it in real time would be reacting to
noise as much as signal, and heart rate's own lag behind a power change
(tens of seconds, more at low fitness) makes naive proportional control
(raise power while HR is below target) prone to overshoot: it keeps pushing
power up while HR is still catching up, then over-corrects down once it
finally arrives. Power and Grade are already exactly the two direct,
unconfounded control variables a trainer can hit precisely — there's
nothing an HR-based layer on top would add that either doesn't already
provide more reliably.