• Type:

Unbricking a $2k bike with a $10 Raspberry Pi

The Flywheel Home Bike. Image: Flywheel Sports.

The Flywheel Home Bike. Image: Flywheel Sports.

A few years ago I splurged on an exercise bike. It’s a pretty expensive
item imho, both in upfront and ongoing subscription costs. But I was able
to justify it by riding it all the time so that per-ride it wasn’t that
expensive. I’m happy to say this was a huge boon for my fitness and it
was worth every penny.

The bike is the Flywheel Home Bike by Flywheel Sports. You open up the app,
pick a class, and start riding. The app shows you a live video stream of an
instructor, your position on the leaderboard so you can compete with the
other people in the class, and your realtime stats like power (watts) and
cadence (rpm). There’s all the usual motivational badges that come with
fitness apps these days, and each ride is logged so you can track your
progress over time.

Or, that’s how it used to work anyway.

Flywheel recently and abruptly shut down the Home Bike service following a legal battle with their competitor, Peloton. The bike does still work in that you can still pedal and adjust the resistance and technically get a workout. But the app is no longer so there are no classes, no competition, and no stats.

Flywheel customers left wanting a bit more had a few options:

  1. Swap it for a free refurbished Peloton. Join Peloton for the same monthly fee and take their classes instead. Not a bad deal and arguably an upgrade.
  2. Use the free LifeFitness ICG Training App. The Flywheel Home Bike is a rebranded LifeFitness IC5 so it just happens to work with this app. This gives you live stats like power and cadence and the ability to track your progress but doesn’t provide any live classes or competition, nor is it officially supported.
  3. Add a set of power meter pedals ($). Use the bike with the massively-multiplayer online cycling simulator Zwift and other training apps.
  4. Reverse engineer the bike. Set its data free to be used with Zwift and other training apps. No power meter pedals required.

The rest of the post is a walk-through of my experience writing some code that enables the Flywheel Home Bike to work with Zwift and other training apps. It likely also works for the LifeFitness IC5 and support for other bikes should be easy to add.

The finished program is called Gymnasticon and the code is open-source on GitHub.

Project goals

The primary goal is to make the Flywheel Home Bike work with Zwift. It would be great if it also works with other cycling apps like TrainerRoad and Rouvy.

The solution should be easy to use for a non-developer and non-destructive to the bike.

The plan to get there is broken down into 3 parts:

  1. Write some code to get power and cadence data out of the bike’s proprietary protocol
  2. Write some code to send power and cadence data to Zwift emulating a Bluetooth bike
  3. Put the two together into a final working solution

Part 1 – Getting data out of the bike

Only two pieces of information are needed to make a bike work with Zwift: power (watts) and cadence (rpm). Power enables Zwift to calculate your speed and position in the game. Cadence improves the experience of cadence-based workouts and enables Zwift to accurately animate your character.

We know from experience that this bike is capable of producing that information and that it communicates with the official Flywheel app using Bluetooth so the first step
is to open up a Bluetooth service explorer and see what’s available.

Bluetility on macOS shows the services offered by the Flywheel Home Bike.

The bike advertises a single service with two characteristics (aka data values). A web
search for the service UUID tells us this is Nordic UART Service which
is a custom service defined by Nordic Semiconductor. It allows sending
arbitrary data back and forth, emulating a serial port.

The characteristics are named from the bike’s perspective. To transmit
data to the bike we write to the receive (RX) characteristic. To receive data
from the bike we subscribe to the transmit (TX) characteristic.

Clicking subscribe on the (TX) characteristic shows that the bike is already sending some data. It is a bit hard to see the data here though. The short JavaScript program below uses the noble Bluetooth client library to connect to the bike and dump all the received data to the console so we can begin to analyze it.

 * Connect to the Flywheel Home Bike's Bluetooth UART service and log
 * received data to the console.

import noble from '@abandonware/noble';
import {on, once} from 'events';

(async () => {
  // nordic uart service and characteristics
  const uuid = '6e400001b5a3f393e0a9e50e24dcca9e';
  const rxUuid = '6e400002b5a3f393e0a9e50e24dcca9e';
  const txUuid = '6e400003b5a3f393e0a9e50e24dcca9e';

  // wait for adapter
  const [state] = await once(noble, 'stateChange');
  if (state !== 'poweredOn') {
    throw new Error(`bluetooth adapter state ${state}`);

  // scan
  await noble.startScanningAsync([uuid], false);
  const [peripheral] = await once(noble, 'discover');
  await noble.stopScanningAsync();

  // connect
  await peripheral.connectAsync();
  const {characteristics: [tx, rx]} = await
      [txUuid, rxUuid]);

  // start receiving
  await tx.subscribeAsync();
  const packets = on(tx, 'read');

  // exit on ctrl-c
  let exit = false;
  process.on('SIGINT', () => { exit = true; });

  // print all received data
  const start = new Date();
  for await (const [packet] of packets) {
    if (exit)

    const t = new Date() - start;
    const mm = `${Math.floor(t/60)}`.padStart(2, '0');
    const ss = `${t % 60}`.padStart(2, '0');
    const mmss = `${mm}:${ss}`;
    console.log(mmss, packet);

  // stop receiving
  await tx.unsubscribeAsync();

  // disconnect
  await peripheral.disconnectAsync();

Running the program, we get our first clear look at the data the bike is sending:


Some initial observations on the data:

  • The bike sends two packets of data every second.
  • First packet is 20 bytes long, second is 14 bytes long.

Bluetooth LE 4.0 and 4.1 allow at most 20 bytes of application data per packet so this is likely a single 34-byte message sent in two chunks. A small change to the program joins the two chunks back together and adds a heading to make the output easier to read and reference.

Updated program output:

Offset         0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33


(started pedaling)


Some more observations:

  • When not pedaling, it’s almost all zeroes.
  • Upon pedaling, a few of the zero values change – a promising sign.
  • Some non-zero values (0, 1, 2, 33) remain constant.
  • Some values (27, 29, 31) are increasing monotonically at different rates.

Power and cadence are hopefully some of those values that become non-zero upon pedaling.

Finding the cadence

Our approach here is to record some data while riding at a known steady cadence
(60 rpm) for 30-60 seconds and then search for that value in the data.
We can repeat this process at a couple different cadences to verify and clear up any coincidences.

This metronome came in handy for keeping a steady cadence.

The first few seconds of data:

Offset         0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33


The recording begins and ends at standstill and otherwise should be approximately 60 rpm. Any offset with a max above 80rpm or below 50rpm or that doesn’t start and end with 0 is a non-match and can be discarded. We’re left with only two candidates:

It is clear from the chart that cadence is the uint8 at offset 12. A few extra tests at different cadences confirm this. There are non-zero values at offset 11 and 13 which confirms cadence is a uint8 and not the lower byte of a uint16.

This means the bike can report a maximum cadence of 255 rpm. What happens at 256 rpm?
Does it wrap to 0 or clamp to 255? It turns out it’s pretty hard to pedal that fast and so this remains a mystery.

Francois Pervis @ 260 RPM

Finding the power

The exact same approach could work here: ride at a steady known wattage for 30-60 seconds and then look for that value in the data. However I don’t have a good way to know exactly what wattage I’m doing.

So this time we’ll keep a steady cadence and just keep adding resistance every few seconds. The data should show a series of plateaus each higher than the last. The absolute values should also be within a reasonble range starting out around 100 watts and staying well under 1000 watts.

We are most likely looking for a 16-bit integer this time and we’ll have to consider both big and little endian encodings.

The first few seconds of data:

Offset         0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33


Any offset with a max below 100W or above 1000W, or that doesn’t start and end with 0 can be discarded. This time we’re left with three possibilities: offset 3, 5 and 13.

The absolute values suggest that offset 3 is the current power in watts. Based on my effort, offset 13 starts out too high and offset 5 doesn’t peak high enough.

So, what is the data at offset 5 and 13?

After some experimentation with the ICG Training App, I discovered that
the bike has some hidden features not exposed by the Flywheel app. One of those features
is that you can take a test to see the maximum power you can sustain over one hour of riding, known as Functional Threshold Power (FTP). The FTP result is stored on the bike and it reports your power as both watts (offset 3) and percentage of FTP (offset 5). The FTP% is frequently used in training programs. The default FTP stored in the bike appears to be 160 watts as when offset 3 ≈ 160, offset 5 ≈ 100.

The value at offset 13 is a very optimistic estimate of speed in km/h × 10. It could be that the rider weight defaults to 0.

As a bonus, when I was recording data I noticed that offset 15 is the position of the resistance dial ranging from 0 (easy) to 100 (hard). This was easy to see in the program’s output when adjusting the resistance while not pedaling as it was one of the only values changing. It’s not necessary for making the bike work with Zwift.

A bug in the bike?

Another thing I noticed during this testing is what appears to be a bug in the bike:

Offset         0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33

02:21 01 7b 00 ea 00 00 00 00 33 6c 01 f6 3a 00 00 00 00 00 00 00 00 00 00 00 7e 00 06 00 0e 67 55> 02:22 01 92 00 f8 00 00 00 00 36 6b 02 05 3b 00 00 00 00 00 00 00 00 00 00 00 7f 00 06 00 0e 6e 55> 02:23 01 96 00 fb 00 00 00 00 36 6b 02 08 3b 00 00 00 00 00 00 00 00 00 00 00 80 00 07 00 0f 9b 55> 02:24 00 00 00 00 00 00 00 00 00 6b 00 00 00 00 00 00 00 00 00 00 00 00 00 00 81 00 07 00 0f f1 55> 02:25 01 a2 01 02 00 00 00 00 38 6c 02 0f 3b 00 00 00 00 00 00 00 00 00 00 00 82 00 07 00 0f 5b 55> 02:26 01 97 00 fb 00 00 00 00 36 6c 02 08 3b 00 00 00 00 00 00 00 00 00 00 00 83 00 07 00 10 81 55> | | | power cadence resistance

The data at 2:24 shows a blip where power, resistance and some other values briefly drop to 0, while cadence
is unaffected. Then at 2:25 they’re back. It turns out there are several instances of this throughout the data.

What this means is that you could be riding at a very high effort and all of a sudden your power drops to 0 for a second. Not the end of the world but it has the potential to be annoying. It seems to happen once every few minutes.

Flywheel doesn’t publish any details on how the bike works internally but the original manufacturer does hint at how power is calculated. From the LifeFitness IC5 page:

WattRate® Power Meter Displays a precise measurement of the user’s effort in watts. This precision is achieved by a positioning sensor that measures the resistance applied to the magnetic brake system.

So the bike doesn’t actually measure power like a power meter would. Instead, it maps the position of the magnetic brake to a point on some factory calibrated curve or lookup table.

One possible explanation then is that there’s a hardware problem with the positioning sensor or, perhaps more likely, a firmware bug causing resistance to occasionally incorrectly read 0. The power value is derived
from the resistance reading and so it also ends up as 0.

A simple fix is to just use the previous power value if ever: the cadence is non-zero and the previous power is non-zero and the current power is zero. A slight improvement is to keep track of the slope and factor it in when calculating the predicted value. If the bug occurs during a fast acceleration or deceleration that should give a slightly better result.

The message format

What we know about the message so far:

Offset         0  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33

02:23 01 96 00 fb 00 00 00 00 36 6b 02 08 3b 00 00 00 00 00 00 00 00 00 00 00 80 00 07 00 0f 9b 55> | | | | | power | cadence | resistance power% speed
Offset Description Type
3 Power (watts) uint16
5 Power (percentage of FTP) uint16
12 Cadence (rpm) uint8
13 Speed (km/h × 10) uint16
15 Resistance (percentage) uint8

We already have what we need but we can make some educated guesses about some of the rest of the packet.

The name “Nordic UART Service” hints that this protocol was intended for use on a real UART and if so the first and last few bytes would be for serial frame synchronization and error detection.

Offset Description Type
0 Start of packet marker (?) uint8
1 Length (of bytes to follow, excl. end) (?) uint8
2 Type of payload (?) uint8
26 Active duration (seconds) (confirmed) uint16
28 16-bit counter (distance?) uint16
30 16-bit counter (distance?) uint16
32 Checksum (xor of 0 and 1..31 inclusive) (confirmed) uint8
33 End of packet marker (?) uint8

One last small change to our program’s output gives us a really primitive replacement for the Flywheel app’s “free ride” function.

power cadence resistance
  23W   63rpm   0%
  24W   73rpm   0%
  26W   73rpm   0%
  27W   82rpm   0%
  29W   82rpm   0%
  30W   89rpm   0%
  32W   89rpm   0%
  33W   99rpm   0%
  35W  110rpm   0%
  41W  110rpm  15%
  47W  119rpm  15%
  57W  119rpm  20%
  66W  121rpm  19%
  74W  121rpm  24%
  80W  119rpm  23%
  84W  118rpm  24%
 100W  118rpm  32%
 115W  117rpm  35%
 136W  117rpm  40%
 160W  107rpm  41%
 177W  107rpm  41%

Part 2 – Getting data into Zwift

Now that we can get realtime Power and Cadence data from the bike, it’s time to figure out how to communicate with Zwift.

The plan is to pick a bike that Zwift already supports and mimic it. The first step is to download Zwift and have a look over the website.

It turns out Zwift supports a lot of devices. It took some research to decide which one to emulate. We want to pick one that is easy to emulate and widely supported by other apps too.

From the Zwift website supported devices page:

There are many indoor bikes on the market that use proprietary communication channels. Zwift supports indoor bikes that broadcast power (watts) via ANT+ or Bluetooth Smart (BLE) using open standards.

After looking into ANT+ and Bluetooth LE, I decided to go with Bluetooth LE. ANT+ would have required extra hardware for no obvious benefit in this case.

Bluetooth LE Cycling Power Service

The Cycling Power Service is what we need to implement. The spec is available on the official Bluetooth website. It’s very detailed. Here is the relevant summary:

Cycling Power Service (UUID 0x1818):
Cycling Power Feature (UUID 0x2a65) (Read)
Cycling Power Measurement (UUID 0x2a63) (Notify)
Sensor Location (UUID 0x2a5d) (Read)

The Cycling Power Feature and Sensor Location characteristics are constants
that tell Zwift what extra features we have (cadence) and where the power measurement is being taken.

The Cycling Power Measurement characteristic is where the realtime data from the Flywheel bike goes. Power can go in as-is. Cadence needs to be provided in a different format. Rather than periodically telling Zwift an instantaneous cadence like “60 rpm” we need to send two values: the total count of crank revolutions (pedal strokes), and the timestamp of the last crank revolution, then Zwift will calculate the rpm itself.

noble, the Bluetooth client library we used earlier to talk to the bike has a companion library,
bleno, for writing Bluetooth servers/peripherals, which we’ll use here.

After some trial and error we end up at the following screen:

Sending random power and cadence data to Zwift over Bluetooth.

Part 3 – Putting it all together

We can talk to the bike and we can talk to Zwift. Now all that’s left to do is combine the code from parts 1 and 2 and get live data streaming from the bike directly into Zwift.

The final program:



For the most part this went as planned however I did run into one issue.

Bluetooth LE connection parameters

Shortly into the first test ride on the Raspberry Pi the bike lost its Bluetooth connection. I figured maybe it was some wireless interference but it continued to happen.

Debugging at the application level didn’t reveal any insights or useful error messages so I captured a btsnoop trace with btmon.

The excerpt below shows the disconnection reason. There is a relative timestamp (seconds) on the top-right of log entry.

> HCI Event: Disconnect Complete (0x05) plen 4                                #449 [hci0] 118.546006
        Status: Success (0x00)
        Handle: 64
        Reason: Unacceptable Connection Parameters (0x3b)

Scrolling back, there is about 30 seconds of successful communication:

> ACL Data RX: Handle 64 flags 0x02 dlen 27                                    #384 [hci0] 87.541516
      ATT: Handle Value Notification (0x1b) len 22
        Handle: 0x000b
          Data: ff1f0c0000000000000000000000002200000000
> ACL Data RX: Handle 64 flags 0x02 dlen 21                                    #385 [hci0] 87.543353
      ATT: Handle Value Notification (0x1b) len 16
        Handle: 0x000b
          Data: 0000000000000000000000003155

Then the bike asks to update connection parameters and the system rejects the request. The bike continues to send data for 30 more seconds then gives up and disconnects.

> ACL Data RX: Handle 64 flags 0x02 dlen 16                                    #386 [hci0] 88.440929
      LE L2CAP: Connection Parameter Update Request (0x12) ident 6 len 8
        Min interval: 16
        Max interval: 60
        Slave latency: 0
        Timeout multiplier: 400
< ACL Data TX: Handle 64 flags 0x00 dlen 10                                    #387 [hci0] 88.441103
      LE L2CAP: Connection Parameter Update Response (0x13) ident 6 len 2
        Result: Connection Parameters rejected (0x0001)

The connection parameters are used to control the tradeoff between data throughput and power consumption. The Texas Instruments BLE-Stack docs gives a good breakdown of how each parameter works.

So why is BlueZ rejecting these parameters and can we get it to accept them?

There are some debugfs endpoints that imply it is possible to control the range of acceptable connection parameters:


However I was unable to get them to have any impact. The connections continued to be created with unfavorable parameters and the update requests continued to be rejected.

Next, I tried an option in noble (HCI_CHANNEL_USER) to allow the application to handle these requests directly. This fixed the immediate problem but presented a new problem. HCI_CHANNEL_USER gives noble exclusive access to the Bluetooth adapter and prevents bleno from working. After reading over the noble and bleno code it didn’t seem like there was an easy way to have them co-operate on a single exclusive socket.

After reading over the linux-bluetooth mailing list and some searching, I stumbled upon this GitHub issue where a poster mentioned they had success using hcitool to update the parameters on a connection.

For the Flywheel bike the command used is:

hcitool lecup --handle 64 --min 16 --max 60 --latency 0 --timeout 400

(Note: the min and max are in units of 1.25ms and timeout is in units of 10ms.)

The log below shows the above command successfully updates the connection parameters:

@ RAW Open: hcitool (privileged) version 2.22                                     {0x0006} 81.324864
@ RAW Close: hcitool                                                              {0x0006} 81.328082
@ RAW Open: hcitool (privileged) version 2.22                              {0x0006} [hci0] 81.329693
< HCI Command: LE Connection Update (0x08|0x0013) plen 14                      #388 [hci0] 81.331279
        Handle: 64
        Min connection interval: 20.00 msec (0x0010)
        Max connection interval: 75.00 msec (0x003c)
        Connection latency: 0 (0x0000)
        Supervision timeout: 4000 msec (0x0190)
        Min connection length: 0.625 msec (0x0001)
        Max connection length: 0.625 msec (0x0001)
> HCI Event: Command Status (0x0f) plen 4                                      #389 [hci0] 81.333532
      LE Connection Update (0x08|0x0013) ncmd 1
        Status: Success (0x00)
> HCI Event: LE Meta Event (0x3e) plen 10                                      #392 [hci0] 81.841057
      LE Connection Update Complete (0x03)
        Status: Success (0x00)
        Handle: 64
        Connection interval: 75.00 msec (0x003c)
        Connection latency: 0 (0x0000)
        Supervision timeout: 4000 msec (0x0190)
@ RAW Close: hcitool                                                       {0x0006} [hci0] 81.842147
> ACL Data RX: Handle 64 flags 0x02 dlen 27                                    #393 [hci0] 82.441717
      ATT: Handle Value Notification (0x1b) len 22
        Handle: 0x000b
          Data: ff1f0c0000000000000000000000002100000000
> ACL Data RX: Handle 64 flags 0x02 dlen 21                                    #394 [hci0] 82.591155
      ATT: Handle Value Notification (0x1b) len 16
        Handle: 0x000b
          Data: 0000000000000000000000003255

30 seconds later we’re still connected and receiving data from the bike…

> ACL Data RX: Handle 64 flags 0x02 dlen 27                                   #453 [hci0] 112.441630
      ATT: Handle Value Notification (0x1b) len 22
        Handle: 0x000b
          Data: ff1f0c0000000000000000000000002100000000
> ACL Data RX: Handle 64 flags 0x02 dlen 21                                   #454 [hci0] 112.516077
      ATT: Handle Value Notification (0x1b) len 16
        Handle: 0x000b
          Data: 0000000000000000000000003255
> ACL Data RX: Handle 64 flags 0x02 dlen 27                                   #455 [hci0] 113.416650
      ATT: Handle Value Notification (0x1b) len 22
        Handle: 0x000b
          Data: ff1f0c0000000000000000000000002100000000
> ACL Data RX: Handle 64 flags 0x02 dlen 21                                   #456 [hci0] 113.566099
      ATT: Handle Value Notification (0x1b) len 16
        Handle: 0x000b
          Data: 0000000000000000000000003255

60 seconds later we’re still connected and receiving data from the bike…

> ACL Data RX: Handle 64 flags 0x02 dlen 27                                   #525 [hci0] 148.516576
      ATT: Handle Value Notification (0x1b) len 22
        Handle: 0x000b
          Data: ff1f0c0000000000000000000000002100000000
> ACL Data RX: Handle 64 flags 0x02 dlen 21                                   #526 [hci0] 148.666003
      ATT: Handle Value Notification (0x1b) len 16
        Handle: 0x000b
          Data: 0000000000000000000000003255

Problem solved! Fortunately this was the end of the disconnects.

The dependency on hcitool is not ideal. I’d still like to know what was going on with the debugfs endpoints or how to otherwise influence BlueZ to accept these connection parameters. If you have any ideas let me know.

It is worth noting that the HCI socket interface used by noble and bleno is deprecated by BlueZ in favor of the D-Bus API.


The final step is to set this up on the Raspberry Pi Zero W to start on boot and restart on failure. Whenever the Raspberry Pi is plugged-in and within range, we can jump on the bike and start the Zwift app and everything should just work.

First, the node binary needs permission to advertise Bluetooth services:

sudo setcap cap_net_raw+eip /usr/local/bin/node

And the following systemd unit file takes care of starting the application at boot and keeping it running:




The first test ride in action. Note the bike has a convenient USB charging port that can power the Pi.

Zwift working with the Flywheel Home Bike with the help of a Raspberry Pi.


This has been in daily use for over 2,000 miles and works really well.

Zwift is a lot of fun.

TrainerRoad and Rouvy work but I haven’t used them much.

The Raspberry Pi Zero W is very versatile and affordable. It’s great that it can be powered by USB.

The Flywheel Home Bike is a very solid build quality bike. I’m glad to have found a way to reuse it.


  1. Figure out the Bluetooth messages that need to be sent to calibrate the bike. The first pass at this would be to use the ICG Training App to perform the calibration procedure while using Bluetooth developer tools on the device or a sniffing proxy.

  2. Add a motor to the resistance dial and implement the Bluetooth Fitness Machine Service so Zwift can control the resistance.

Read More

Previous Post

Trump says he will ban TikTok through executive action

Next Post

BackerKit (YC S12) is hiring our second Product Manager

Leave a Reply

Your email address will not be published. Required fields are marked *

Scroll to top