Building Churned: a subscription-free app to sync my indoor Peloton Bike data to Apple Fitness

How I turned a Peloton Original Bike's head unit into my own personal bike computer— with a companion iOS + Apple Watch app that lands every ride in Apple Fitness. No root required.

TL;DR

In order to track workouts and sync data between the bike and Apple Fitness, Peloton requires you to have an active subscription and you can only use their app.

So I "vibe-coded" an app that runs on the tablet, taps into the sensors, and records workouts. Then I made a companion iOS/Apple Watch app to sync my heartrate between both devices (over Bluetooth) and log my indoor rides to Apple Fitness as actual native HealthKit workouts.


In 2023, I obtained a Peloton Original Bike (Gen 1) via a health insurance perk as a 1 year lease/subscription. When the year was up– I paid/used the Peloton Buyout program to keep the bike, but I cancelled the subscription.

The way I ride is simple - hop on the bike, spin for 20-30 minutes, cooldown for a few, and done. I didn't do the Peloton classes, I didn't ride with the outdoor videos, my daily cardio is simply spinning. After cancelling my subscription, I was incredibly frustrated to find out that in order to sync the data from the bike to my Apple Fitness, Peloton forces you to have a subscription. If your bike is not logged in with an active subscription, you have the option to "free ride", but none of the workouts are tracked and none of the data can be synced anywhere.

So I built myself Churned, and it has three surfaces:

The key unlock: the head unit's serial port is locked down, but Peloton's own sensor service is exported without a permission guard, so an ordinary sideloaded app can bind it and receive the same metrics feed the stock app uses. Everything else is ordinary Android and watchOS development. I spent a handful of evenings building it with Claude Code + Fable high.

Credit where it's due — this project stands on two others: PeloMon by Imran Haque, whose reverse-engineering of the bike's speed model I use directly, and Grupetto by selalipop, which proved the whole concept of an overlay app plus BLE power broadcast on a Peloton head unit. More on both below.


Prior art, and why I still wrote my own

Two projects shaped Churned:

PeloMon (Imran Haque's blog series) is a beautiful piece of reverse engineering — an Arduino that taps the bike's RS-232 line and reconstructs everything the head unit knows. The bike has no speed sensor; Peloton derives speed from power with a piecewise cubic model, and PeloMon recovered the exact coefficients. Churned uses that model verbatim, on both the Android and watchOS sides:

/** Peloton V1 bike speed model, from PeloMon. Input watts, output mph. */
fun speedFromPowerV1(power: Float): Float {
    if (power < 0.1f) return 0f
    val s = sqrt(power)
    return if (power < 26f) {
        0.057f - 0.172f * s + 0.759f * s * s - 0.079f * s * s * s
    } else {
        -1.635f + 2.325f * s - 0.064f * s * s + 0.001f * s * s * s
    }
}

Grupetto (selalipop/grupetto) is a system-wide ride-stats overlay for Peloton head units that also re-broadcasts the bike as a BLE power meter. I didn't reuse its code (Churned is an independent implementation with a different architecture and a different sensor-access path), but the existence proof mattered enormously: before writing anything I knew the head unit could run an overlay and act as a BLE peripheral, because Grupetto had done it.


Part 1 — Getting the data: the service Peloton left unlocked

The obvious approaches were all dead ends or too invasive:

The path that worked: Peloton's own architecture does the privileged work for us. The stock software is split into packages, and the one that actually reads the bike — com.onepeloton.affernetservice — is a standalone service with the SERIAL_PORT permission that the consumer apps bind to. Crucially, its AffernetService is exported with no permission guard. Any sideloaded app can bind it.

There's no public AIDL, so the client speaks raw Binder.transact using the interface recovered by inspecting the service:

That parcelable is the gnarliest artifact in the whole project. The service writes it field-by-field — around fifty fields including load-cell calibration tables, error histories, and firmware serials — and a parcel has no schema, so the client has to read every field in the exact write order just to keep the cursor aligned, even though Churned only consumes four:

val rpm = p.readLong()          // cadence, RPM
val power = p.readLong()        // centiwatts — divide by 100 for watts
/* stepperMotorPosition */ p.readLong()
/* loadCellReading */ p.readLong()
val currentResistance = p.readInt()
// ... ~45 more reads purely for alignment ...

Two data-quality quirks handled at the source: power arrives in centiwatts (÷100), and resistance occasionally spikes for a single sample (an ADC artifact), so Churned takes the minimum of the last three readings. Speed is computed from power with the PeloMon model above.

The standing risk, worth stating plainly: this interface is undocumented and a Peloton OTA update could change or lock it at any time. Which is why device setup starts with...


Part 2 — Taming the head unit

The head unit is a PLTN-RB1VQ running Android 11. Goal state: it boots straight to a normal launcher, runs media apps, never updates itself, and never shows Peloton's UI. All of this is plain adb over USB (Developer Options → USB debugging), no root, and reversible. The highlights:

Freeze OTA updates first — before the tablet ever gets back online — because an update could kill the sensor interface or revert developer mode:

adb shell pm disable-user --user 0 com.onepeloton.OTAService
adb shell pm disable-user --user 0 com.onepeloton.bgupdater
adb shell pm disable-user --user 0 com.onepeloton.fwupdateservice
adb shell pm disable-user --user 0 com.peloton.updater
adb shell pm disable-user --user 0 com.onepeloton.dm.android

I verified the bike sensor still works with all five disabled. Do not disable com.onepeloton.affernetservice or com.peloton.service.SensorData — those are the sensors. Belt and suspenders: block *.onepeloton.com and the update CDNs at the router.

Replace the launcher. Lawnchair gives a Pixel-style, touch-first home screen and runs fine on Android 11 without Google services:

adb install -r Lawnchair.apk
adb shell cmd package set-home-activity app.lawnchair/.LawnchairLauncher

Kill the "Activate your Bike" screen. Even with a new launcher, the stock app relaunches its activation flow over the home screen on every boot, from a BOOT_COMPLETED receiver that the adb shell is not allowed to disable at the component level (SecurityException: Shell cannot change component state). The trick is disabling the whole package instead — safe, because the sensor service lives in a different package and com.peloton.activity is only a client of it:

adb shell pm disable-user --user 0 com.peloton.activity

Media apps. There are no Google Play Services on this device, so the official YouTube app is out; SmartTube works great. Netflix is pre-installed.

After this, the head unit is just a big Android tablet bolted to a bike — which is exactly what the rest of the project needs.


Part 3 — The overlay MVP

The first version of the app did one thing: float live metrics over whatever else is on screen. The Android recipe:

One design decision that paid off immediately: the UI never talks to the sensor directly. It depends on a four-flow interface:

interface MetricsSource {
    val status: Flow<SensorStatus>
    val power: Flow<Float>       // watts
    val cadence: Flow<Float>     // rpm
    val resistance: Flow<Float>  // smoothed
    val speed: Flow<Float>       // mph, derived from power
    fun start(); fun stop()
}

Three implementations: AffernetV1Source (the real bind), DummySource (synthetic but realistic rider data), and MetricsRouter, a runtime-switchable delegate. Launching with --ez fake true swaps in fake data, which means the entire UI — overlay, dashboard, charts, even the watch integration later — was built and iterated on a desk, no bike required. If you build something like this, build the fake source on day one.


Part 4 — Rides become data

A floating display is nice; a training log is better. This phase added a session layer and a fullscreen app around it.

The recorder is a small state machine — Idle → Recording ⇄ Paused — that samples the metrics flows at 1 Hz into a Room database:

The behavioral details are where the polish lives: recording auto-pauses after ~5 s of zero cadence and auto-resumes when you pedal, while a manual pause stays paused; unfinished rides survive a crash and are recovered on restart; and a ride is only kept if it has both meaningful duration and actual power, so test taps don't pollute history. Pause events being wall-clock-timestamped looked like over-engineering at the time and became load-bearing in Phase 2, when the watch needed to reconstruct the same timeline.

A mean-max pass computes best rolling 5 s / 1 min / 5 min / 20 min power — instant personal records for every ride. The recorder, the mean-max math, and the speed model are the three things with real logic in them, so they're the three things with unit tests.

The fullscreen app grew around this: a home screen with ride history cards, an in-ride dashboard (power hero with a live rolling graph, other metrics stacked beside it, session totals along the bottom), and a ride detail screen with summary stats, per-measure charts (custom Compose Canvas, no chart library), and PRs.

The overlay and the app share one trick I'm fond of: they're two hosts for the same panel. While the app is in the foreground, live metrics dock as a fixed sidebar down the right edge; background the app to watch a movie and the same panel pops out as the floating overlay (the app's foreground/background lifecycle is what docks one and floats the other). Both windows read the same app-scoped singletons, so there's exactly one session, one sensor bind, and one source of truth no matter which surface you're looking at.


Part 5 — The bike becomes a Bluetooth sensor

Here's where Grupetto's second idea comes in: the head unit has a Bluetooth radio, so nothing stops it from re-broadcasting the bike as a standard BLE sensor that any watch or fitness app can pair with.

The GATT sensor server (hosted by the always-running overlay service) advertises a standard Cycling Power Service (0x1818) under the name "Peloton Bike", notifying power at 1 Hz with crank revolution data riding along in the same measurement. Two spec-level details worth knowing before you implement CPS yourself:

A packaging gotcha that cost an evening: a legacy BLE advertisement is 31 bytes. Flags, the 16-bit CPS UUID, and the device name fit; the 128-bit UUID of the custom service (below) does not — it goes in the scan response, where iOS still finds it when scanning filtered by service UUID. And one debugging gotcha specific to this device: the head unit drops all log priorities below W globally, so adb shell setprop persist.log.tag.<YourTag> I is required to see your own logs.

With just this much, an Apple Watch pairs with the bike in Settings → Bluetooth → Health Devices, and the native Workout app records indoor rides with real power and cadence. That was the plan for about a day — until it became obvious the interesting version was a watch app that follows the tablet's ride sessions automatically.


Part 6 — The Apple side: one link, two planes

Phase 2's goal: rides controlled on the tablet become real Apple Fitness workouts, hands off, with wrist heart rate flowing back to the bike's display. I wrote a design doc first (it survived contact with reality surprisingly well) built around one topology decision:

                 one physical BLE link, two logical planes
   ┌──────────────┐                                  ┌─────────────┐
   │   TABLET     │  data plane: Cycling Power 0x1818│    WATCH    │
   │ (peripheral, │ ────────────────────────────────►│  (central)  │
   │ GATT server) │                                  │             │
   │              │  control plane: Churned service  │ HKWorkout-  │
   │              │ ──── session state (notify) ────►│ Session +   │
   │              │ ◄─── heart rate (write) ─────────│ CoreBT      │
   └──────────────┘                                  └──────┬──────┘
                                                            │ HealthKit sync
                                                            ▼ (automatic)
                                                     ┌─────────────┐
                                                     │   IPHONE    │
                                                     │ Fitness app │
                                                     └─────────────┘

The tablet↔watch link is the only live connection. On top of the CPS data plane, a custom 128-bit "Churned" GATT service adds the control plane with two characteristics: session-state (read + notify: idle/recording/paused plus the ride's start epoch) and heart-rate-in (write-without-response, one byte of BPM at 1 Hz). The iPhone has no ride-time role at all — HealthKit syncs the workout to the Fitness app on its own; the thin iOS companion app exists mainly because a watchOS app needs one to ship inside.

Just as important was deciding who owns which truth, so no two-way sync is ever needed:

Concern Owner
Ride control (start/pause/end, auto-pause) Tablet — the recorder already lives there; the watch mirrors it
Full-fidelity archive (1 Hz, incl. resistance) Tablet's Room DB — richer than HealthKit can store
Apple "workout of record" (rings, Fitness) Watch HKWorkoutSession
Heart rate Watch wrist sensor, streamed back to the tablet

A sequencing trick that made this painless: the tablet side (GATT service, HR plumbing, Room schema migration, HR tiles in the UI) was built and fully tested before any Swift existed, using the nRF Connect phone app as a fake watch. By the time Xcode opened, the peripheral was a known-good target.

The watch app itself (SwiftUI, watchOS 10+) is two objects:

Three real-world problems and their fixes, since this is where most of the debugging lived:

  1. Late join. You will forget to open the watch app before pedaling. The session-state characteristic carries startedAtEpochMs, so on connect the watch backdates beginCollection(withStart:) to the true ride start — clamped to at most 4 hours ago and never the future, with a retry from "now" if HealthKit rejects the backdate.
  2. Stale identities. The tablet advertises with a rotating random BLE address, so after its Bluetooth stack restarts, connecting to the watch's remembered peripheral would wait forever. BikeLink connects to the last known identity and scans by service UUID in parallel, taking whichever answers — the link self-heals under any address.
  3. The design doc's one wrong assumption. I expected watchOS to auto-connect the paired CPS sensor during our workout and feed power/cadence into the builder by itself. In practice, our app holds the BLE connection, so the system never collects bike metrics on its own. The doc had flagged exactly this as a spike with a fallback — parse CPS ourselves, we own both ends — and the fallback became the shipped design: BikeLink parses the power measurements it's already subscribed to, and WorkoutController writes time-weighted cyclingPower/cadence/speed/distance samples into the workout, clocked by real notify arrivals rather than a timer. Write your risky assumptions down with fallbacks; future you will be grateful.

The shape of the final system

One ride now looks like this: hop on — the overlay is already running (boot receiver) — tap Start on the tablet, and tap the Churned complication so the watch app is awake (watchOS can't remote-launch apps; that one tap is the whole ritual, before or after Start both work). While you ride, the head unit plays whatever you like with metrics floating on top; the watch mirrors the session into HealthKit, showing live power/cadence/HR on the wrist; your heart rate flows back onto the tablet's display and into the 1 Hz archive. Stop pedaling to grab water and both recorders pause together; pedal and they resume. End the ride and it's simultaneously a rich local record (charts, PRs, resistance curves) and a normal Apple Fitness workout that closes your rings.

bike UART ─► AffernetService ─► AffernetV1Source ─► MetricsRouter ─┬─► overlay / dashboard UI
   (locked)    (Peloton's,          (raw Binder)      (real│fake)  ├─► RideRecorder ─► Room
                exported)                                          └─► GattSensorServer ─► ⌚

If you build one yourself

The honest checklist:

  1. A Peloton Original Bike whose head unit you can put in developer mode, a computer with adb, and comfort sideloading. Nothing needs root.
  2. Lock down OTA before the tablet goes online. The whole sensor path depends on an undocumented interface that an update could remove.
  3. Prove the sensor bind first with a throwaway spike — log watts to logcat while pedaling. Everything else is worthless without it.
  4. Build the fake data source immediately; develop everything else at a desk.
  5. Ship the overlay before the database; a live display is motivating in a way schemas aren't.
  6. Test the BLE peripheral with nRF Connect before writing any watch code.
  7. Read the PeloMon series and the Grupetto source code, even if you end up writing your own.

And the standing caveats: the Affernet interface could change with any OTA (hence the lockdown); BLE peripheral mode is chipset-dependent (this head unit supports it; verify yours early); and none of this touches Peloton's cloud — that's the point — so if you want their classes, this isn't that.


Thanks

Churned is a personal project for my own bike. It isn't affiliated with or endorsed by Peloton. If you tinker with yours, you accept the risk of breaking things that a factory reset may not fix.