DIY Indoor Bike Smart Trainer

Introduction: DIY Indoor Bike Smart Trainer

About: I use a lot of code from examples and projects online, so if I make something that could save others some time, I share it.


This project started as a simple modification to a Schwinn IC Elite indoor bike which uses a simple screw and felt pads for the resistance settings. The problem I wanted to solve was that the pitch of the screw was to large, so the range from not being able to pedal to the wheel spinning completely free was just a couple degrees on the resistance knob. At first I changed the screw to M6, but then I would have to make a knob, so why not just use a left over NEMA 17 stepper mottor to change the resistance? If there is already some electronics, why not add a crank power meter and a bluetooth connection to a computer to make a smart trainer?

This proved more dificult than expected, because there were no examples on how to emulate a power meter with an arduino and bluetooth. I ended up spending around 20h on programming and interpreting the BLE GATT specifications. I hope that by providing an example I can help someone not waste this much time on trying to understand what exactly "Service Data AD Type Field" means...


The whole project is on GitHub:

I highly recommend using Visual Studio with a VisualGDB plugin if you plan on doing something more serious than just copy-pasting my code.

If you have questions about the program, please ask, I know that my minimalistic comments may not help much.


Thanks to stoppi71 for his guide on how to make a power meter. I did the crank according to his design.


The materials for this project highly depend on what bike are you are modifying, but there are some universal parts.


  1. ESP32 Module
  2. HX711 Weight sensor ADC
  3. Strain gauges
  4. MPU - gyroscope
  5. A small Li-Po battery (around 750mAh)
  6. Heat shrink sleeve
  7. A4988 Stepper driver
  8. 5V regulator
  9. An arduino barrel jack
  10. 12V arduino power supply


  1. NEMA 17 stepper (needs to be quite powerful, >0.4Nm)
  2. M6 rod
  3. 12864 lcd
  4. WeMos LOLIN32
  5. Tact switches


For doing this you probably could get away with using only a 3D printer, however you can save a lot of time by laser cutting the case, and you also can make PCBs. The DXF and gerber files are on GitHub, so you can order those locally. The coupler from the threaded rod to the motor was turned on a lathe and this might be the only problem, as the part needs to be quite strong to pull on the pads, but there is not a lot of space in this particular bike.

Since making the first bike, I acquired a milling machine which allows me to make slots for the sensors in the crank. It makes gluing them a bit easier and also protects them if something were to hit the crank. (I've had these sensors fall of a few times so I wanted to be safe.)

Step 1: The Crank:

It's best to just follow this tutorial:

You basically need to glue the sensors to the crank in four places and connect those to the sides of the board.

The proper connections are already there so you just have to solder the pairs of wires directly to these eight pads on the board.

To connect to the sensors use the thinnest wire possible - the pads are very easy to lift. You need to glue the sensors first and leave just enough of them outside to solder, then cover the rest with epoxy. If you try to solder before gluing, they curl and break.

To assemble the PCB:

  1. Insert goldpins from the bottom (the side with traces) into all holes except those vertical near the bottom.
  2. Place the three boards (ESP32 on top, then MPU, the HX711 on the bottom) so the goldpins stick through both holes.
  3. Solder the headers to the boards on top
  4. Cut off the goldpins from the bottom. (Try cutting them first before assembly, so you know your "goldpins" aren't steel inside - it makes them nearly impossible to cut and you need to file or grind them)
  5. solder the remaining goldpins to the bottom of the board.
  6. Upload the firmware for the crank

The last step is to pack the whole crank with heat shrink sleeve.

This method of making the board isn't ideal, as the boards take up a lot of space in which you could fit other things. The best would be to solder all the components to the board directly, but I lack the skill to solder these small SMD myself. I would need to order it assembled, and I would probably make some mistakes and end up ordering them three times and waiting a year before they arrive.

If someone would be able to design the board, It would be great if it had some battery protection circutry and a sensor which would turn the ESP on if the crank started moving.


The HX711 sensor by default is set to 10Hz - it is much to slow for the power measurement. You need to lift pin 15 from the board and connect it to pin 16. This drives the pin HIGH and enables the 80Hz mode. This 80Hz, by the way, sets the rate of the whole arduino loop.


The ESP32 is programmed to go to sleep after 30s with no bluetooth device connected. To turn it back on you need to press the reset button. The sensors are also powered from a digital pin, which turns LOW in the sleep mode. If you want to test the sensors with the example code from libraries you need to drive the pin HIGH and wait a bit before the sensors turn on.

After assembly the sensors need to be calibrated by reading the value with no force and then with a weight applied (I used a 12kg or 16kg kettlebell hung on the pedal). These values need to be put in the powerCrank code.

It's best to tare the crank before each ride - it shouldn't be able to tare itself when someone is pedaling, but better safe than sorry and it is possible to tare it only once per turning on. If you notice some strange power levels you need to repeat this process:

  1. Put the crank straight down untill the light starts blinking.
  2. After a couple seconds the light will stay on - don't touch it then
  3. When the light turns off it sets the current force detected as a new 0.

If you want to just use the crank, without the console, the code is here on github. Everything else works the same.

Step 2: The Console

The case is cut from 3mm acrylic, the buttons are 3D printed and there are spacers for the LCD, cut from 5mm acrylic. It is glued with hot glue (it sticks quite well to the acrylic) and there is a 3D printed "bracket" to hold the PCB to the LCD. The pins for the LCD are soldered from the bottom side so it does not interfere with the ESP.

The ESP is soldered upside-down, so the USB port fits in the case

The separate button PCB is glued with hot glue, so the buttons are captured in their holes, but they still press the switches. The buttons are connected to the board with JST PH 2.0 connectors and the pin order is easy to deduce from the schematic

It is very important to mount the stepper driver in the correct orientation (the potentiometer near the ESP)

The whole portion for the SD card is disabled, as no one used it in the first version. The code needs to be updated with some UI settings like rider weight and difficulty setting.

The console is mounted using lasercut "arms" and zipties. The little teeth dig into the handlebars and hold the console.

Step 3: The Motor

The motor holds itself in the place of the adjustor knob with a 3D printed bracket. To its shaft is mounted a coupler - one side has a 5mm hole with set screws to hold the shaft, the other has a M6 thread with set screws to lock it. If you want, you can probably make it in a drill press from some 10mm round stock. It doesn't need to be extremely precise as the motor isn't mounted very tightly.

A piece of M6 threaded rod is screwed in the coupler and it pulls on a brass M6 nut. I machined it, but it can be as easily made from a piece of brass with a file. You can even weld some bits to a normal nut, so it wouldn't rotate. A 3D printed nut may also be a solution.

The thread needs to be finer than the stock screw. Its pitch is about 1.3mm, and for M6 it's 0.8mm. The motor doesn't have enough torque to turnon the stock screw.

The nut needs to be well lubricated, as the motor barely can turn the screw on the higher settings

Step 4: Configuration

To upload code to ESP32 from Arduino IDE you need to follow this tutorial:

The board is "WeMos LOLIN32", but the "Dev module" also works

I suggest using Visual Studio, but it can often break.

Before first use

The crank needs to be set up according to the "Crank" step

Using the "nRF Connect" app you need to check the MAC address of the crank ESP32 and set it in the BLE.h file.

In line 19 of indoorBike.ino you need to set, how many rotations of the screw are needed to set the resistance from completely loose to maximum. (The "maximum" is subjective on purpose, you adjust the difficulty with this setting.)

The smart trainer has "virtual gears" to set them up correctly, you need to calibrate it on lines 28 and 29. You need to pedal with a constant cadence on a given resistance setting, then read the power and set it in the file. Repeat this again with another setting.

The leftmost button switches from ERG mode (absolute resistance) to simulation mode (virtual gears). Simulation mode without a computer connection does nothing as there is no simulation data.

Line 36. sets the virtual gears - the number and ratios. You calculate them by dividing the number of teeth in the front gear by the number of teeth in the rear gear.

In line 12. you put the weight of the rider and the bike (In [newtons], mass times the gravitational acceleration!)

The whole physics part of this is probably too complicated and even I don't remember what it does exactly, but I calculate the required torque to pull the cyclist uphill or something like that (that's why the calibration).

These parameters are highly subjective, you need to set them up after a few rides for them to work correctly.

The debug COM port sends direct binary data received by bluetooth in quotes (' ') and simulation data.

The configurator

Because the configuration of the supposedly realistic physics turned out to be a huge hassle to make it feel realistic, I created a GUI configurator that should allow users to graphicaly define the function which converts from the grade of the hill to the absolute resistance level. It is not yet completely finished and I didn't have the opportunity to test it, but in the upcoming month I will be converting another bike, so I will polish it then.

On the "Gears" tab you can set the ratio of each gear by moving the sliders. You then need to copy the bit of code to replace the defined gears in the code.

On the "Grade" tab you are given a graph of a linear function (yes, it turns out the most hated subject in maths is actually useful) that takes the grade (vertical axis) and outputs absolute resistance steps (horizontal axis). I will go into the maths a bit later for those interested.

The user can define this function using the two points laying on it. On the right there is a place to change the current gear. The selected gear, as you might imagine, changes the way, how grade maps to resistance - on lower gears it is easier to pedal uphill. Moving the slider changes the 2nd coefficient, which influences how the selected gear changes the function. It is easiest to play with it a while to see how it behaves. You might also need to try a few different settings to find what works best for you.

It was written in Python 3 and should work with default libraries. To use it you need to uncomment the lines immediately after "uncomment these lines to use the configurator". As I said, it wasn't tested, so there might be some errors, but if anything comes up, please write a comment or open an issue, so I can correct it.

The maths (and physics)

The only way the controller can make it feel like you are going uphill is by turning the resistance screw. We need to convert the grade to the number of rotations. To make it easier to set up, the whole range from completely loose to not being able to turn the crank is divided into 40 steps, the same used in the ERG mode, but this time it uses real numbers instead of integers. This is done with a simple map function - you can look it up in the code. Now we are one step higher - instead of dealing with revolutions of the screw, we are dealing with imaginary steps.

Now how does it actually work when you go uphill on a bike (assuming a constant speed)? There obviously needs to be some force pushing you up, or else you would roll down. This force, as the first law of motion tells us, must be equal in magnitude but opposite in direction to the force pulling you down, for you to be in uniform motion. It comes from the friction between the wheel and the ground and if you draw the diagram of these forces, it needs to be equal the weight of the bike and the rider times the grade:


Now what makes the wheel apply this force? As we are dealing with gears and wheels, it is easier to think in terms of torque, which is simply the force times the radius:


As there are gears involved, you impart a torque on the crank, which pulls on the chain and turns the wheel. The torque needed to turn the wheel gets multiplied by the gear ratio:


and back from the torque formula we get the force required to turn the pedal


This is something that we can measure using the power meter in the crank. As dynamic friction is linearly related to the force and as this particular bike uses springs to impart this force, it is linear to the movement of the screw.

Power is force times the velocity (assuming the same direction of vectors)


and the linear velocity of the pedal is related to the angular velocity:


and so we can calculate the force required to turn the pedals on a set resistance level. As everything is linearly related, we can use proportions to do so.

This was essentially what the software needed to calculate during the calibration and using a roundabout way to get us a complicated composite, but a linear function relating grade to resistance. I wrote everything on paper calculated the final equation and all the constants became three coefficients.

This is technicaly a 3D function representing a plane (I think) that takes the grade and the gear ratio as the arguments, and these three coefficients are related to those needed to define a plane, but as the gears are discrete numbers, it was easier to make it a parameter instead of dealing with projections and such. The 1st and 3rd coefficients can be defined by a single line and (-1)* the 2nd coefiicient is the X coordinate of the point, where the line "rotates" around when changing gears.

In this visualisation the arguments are represented by the vertical line and the values by the horizontal one, and I know that this might be annoying, but it was more intuitive for me and it fit the GUI better. That is probably the reason why the economists draw their graphs this way.

Step 5: Finish!

Now you need some apps to ride with on your new trainer (which saved you around $900 :)). Here are my opinions on some of them.

  • RGT Cycling - in my opinion the best - it has a completely free option, but has a bit few tracks. Deals with the connection part the best, because your phone connects via bluetooth and a PC displays the track. Uses realistic video with an AR cyclist
  • Rouvy - a lot of tracks, paid subscription only, for some reason the PC app doesn't work with this, you need to use your phone. There may be problems when your laptop uses the same card for bluetooth and WiFi, it often lags and doesn't want to load
  • Zwift - an animated game, paid only, works quite well with the trainer, but the UI is quite primitive - the launcher uses Internet Explorer to display the menu.

If you enjoyed the build (or not), please tell me in the comments and if you have any questions you can ask here or submit an issue to github. I will gladly explain everything as it is quite complicated.

Be the First to Share


    • Backyard Contest

      Backyard Contest
    • Build a Tool Contest

      Build a Tool Contest
    • Remote Control Contest

      Remote Control Contest



    Question 1 year ago on Step 1

    Awesome project exactly what i was searching for thanks for sharing. One question i just want to use the power meter for zwift but it says the power meter sends its data to another esp the bike trainer and then communicates to zwift. If i juct do the Crank PM by itself with your code do i need to alter the coding to talk directly to zwift? Or will it work as is?


    Answer 1 year ago

    If you only want to use the power meter, then you need to upload a different program to the esp on the crank. The current one isn't compatible, both because it is easier for me with the console and so that the apps don't try to connect to the crank, while the console is on. I haven't uploaded this code to github yet, but I'll do it soon. You can use that also for measuring power in a normal bike.


    Reply 2 months ago

    Many thanks for this wonderful project and the work done. I understand that a separate powermeter is a by-product, however, due to problems with some details, I was forced to stop implementing only this part. Hopefully temporarily.
    In the course of implementation, I found several shortcomings in the code.
    1) In cyclingPowerFeatureCharacteristicDef, bits 20 and 21 responsible for the value of the "Distributed System Support" flag are set to 00 (Legacy Sensor), which requires doubling the power, since we measure power from only one connecting crank. Theoretically, when writing the value 10 (Can be used in distributed system) to the specified bits, the program processing the values should double the power value itself if a second powermeter is not connected, but in my case it did not work (Wahoo RGT). So I just doubled the power in the same way as the main project.
    2) A more serious problem turned out to be with cadence. The proposed implementation is incorrect, as a result RGT displays the cadence values multiple of 30 (30, 60, 90, etc.) This is due to the fact that the sent value of the "Last Crank Event Time" is not what it should be, but coincides with the time the data was sent. I tried different ways to catch the revolutions directly, but they did not work correctly. If we catch the moments when the angle is close to 180 degrees, then the correct data were obtained only at a cadence less than ~45 rpm. Relying only on the accelerometer data, it was possible to obtain correct data up to ~85 rpm. Due to difficulties with debugging on such RPMs, the exact reasons for this behavior have not yet been clarified. However, I implemented an algorithm that correcting the "Last Crank Event Time" value and achieved correct data that coincided with a third-party cadence meter.
    In the aggregate, I made quite a few changes to the project code, so I will send it to you in a message.
    I also tried to add a battery level measurement and added Bluetooth BATTERY SERVICE support, but the leakage current through the voltage divider turned out to be too high and ate the battery charge in a couple of days, which is completely unacceptable. It may be worth trying to use something similar to the INA219 (CJMCU-219) module for this purpose, but my power meter is already assembled and installed and I did not want to carry out such an upgrade.


    Reply 2 months ago

    My mum uses the separate powermeter on her road bike and it shows the cadence correctly, so it should work.
    As I remember, the cadence wasn't implemented well in the bluetooth characteristic itself. It was made for those simple semsors that send a pulse whenever the crank passes it and I had to convert my direct measurement of angular velocity to what the characteristic specified, so it predicts how many revolutions the crank made during this time, but if it works for you, then great.

    Could you make a pull request on github, so I can more easily see what you changed, because code doesn't send well through the messages?
    Alternatively you can send it to me via email:
    I will gladly look through what you changed, so I can make improvements to mine.


    Reply 2 months ago

    I made pull request, but it looks not very well because of spaces and tabs, lots of my comments etc... =(
    I'm open for discussions and can explain my code if it helps


    Reply 1 year ago

    Hey thanks you for your reply i really appreciate it!!.
    You sent a link to your power meter code.
    Is that code all set up to communicate directly to apps?
    Can i download this to my ESP crank arm design (or i should say YOUR design) and not need to modify the bluetooth com part to handshake with zwift ( that is the part im trying to decipher from your 3 files of code as you have the crank arm sending power and cadence to your fitness controller. Then you have a bluetooth portion for com with zwift and i cant seem to figure out in the code where your controller gets the info from the crank and then sends to Zwift).

    My goal is to make my own crank powermeter similar to yours that will juts talk to Zwift sending only power and cadence ( so i want to take that bluetooth part from the other code and add it to your crank code)
    I have a wheel on smart trainer i can compare the numbers too but i dont think the wheel on is very accurate.

    Then later on i want to use this guys dumb trainer controller design and have Zwift control my dumb trainer as well.
    This way i can have semi accurate date with 2 power sources to compare on my smart trainer and i can make a separate trainer for my wife on my old dumb classic trainer.

    Thanks again for your time have a great weekend and stay safe!
    Sorry for bugging you as well im just not a very good coder..i can figure out most of it but the communication back and forth to zwift is giving me grief :(


    Reply 1 year ago

    The code in this repository I sent you, will emulate a comercial power measuring pedal or crank. You will be able to connect it to Zwift as a "Power source" or to other devices as a power meter. You can follow the crank design from here or do something on your own, but the most important part of the code are the definitions in the begining - they tell Zwift that this device can send power data, so it is able to connect. If I have time tomorrow, I may be able to add some more comments to the code. You can also find the information on the Bluetooth website - and compare to the values in the code to figure out what they mean.
    I have a bit of a mess with the sources to this project, so if something doesn't work, please comment or add an issue to github, so I can correct it - I have a lot of old and new versions and I don't completely know what I'm using now, but the bluetooth part of this code should work.


    Reply 1 year ago

    Fantastic!! Thansk so much i will give it a try adn let you know how she goes :)


    Question 6 months ago

    Hi Kswiorek,

    I really like your project. My complements.
    I build half of it and it already connects to RGT perfectly. I also added a ble heart rate sensor to the ESP32-console and it displays my heart rate on my (nextion) screen and I can see the data in the nrFconnect app.
    But for some reason RGT won't show the heart rate slider or display my heart rate... Am I missing something?

    These are the flags I'm setting:

    const uint16_t indoorBikeDataCharacteristicDef = 0b0000001001000100;
    const uint32_t fitnessMachineFeaturesCharacteristicsDef = 0b00000000000000000100010010000010;
    And the data I'm sending:
    indoorBikeDataCharacteristicData[2] = (uint8_t)(speedOut & 0xff);
    indoorBikeDataCharacteristicData[3] = (uint8_t)(speedOut >> 8);// speed value with little endian order
    indoorBikeDataCharacteristicData[4] = (uint8_t)((cadenceIn * 2) & 0xff);
    indoorBikeDataCharacteristicData[5] = (uint8_t)((cadenceIn * 2) >> 8);// cadence value
    indoorBikeDataCharacteristicData[6] = (uint8_t)(constrain(powerIn, 0, 4000) & 0xff);
    indoorBikeDataCharacteristicData[7] = (uint8_t)(constrain(powerIn, 0, 4000) >> 8);
    indoorBikeDataCharacteristicData[8] = (uint8_t)(hrPulseIn & 0xff);
    indoorBikeDataCharacteristicData[9] = (uint8_t)(hrPulseIn >> 8);
    indoorBikeDataCharacteristic.setValue(indoorBikeDataCharacteristicData, 10);
    indoorBikeDataCharacteristic.notify(); // device notified

    rgt.pngScreenshot_nRF Connect.jpg

    Answer 6 months ago

    Glad that it works for you! And thank you for the most detailed comment of yet :)
    RGT recently changed some things about the software and sometimes my mum's real heart rate sensor isn't detected as well. You could try connecting it to different apps/devices like Zwift or a smartwatch. It might be RGT's fault.
    It should also be possible to add a heart rate service separate from the fitness machine one, maybe then it would detect it. Try first programming the heart rate sensor separately and then copying the code to what you have, but I have not tried that and it's been a while since I did anything with this, so it might not work.
    From what I've seen in the apps they only support what they have to and not the whole BLE specification, and I haven't seen any commercial smart trainers with heart rate, so they might not have added it.


    Reply 6 months ago

    Thank you. I was hoping you could help me futher.
    I was trying to make an all-in-one device to connect to the computer instead of different sensors. That way I also have all the info on the console too.
    I installed Zwift and Zwift connects to all my devices, but the heart rate field stays '0', so I must be missing something I guess. But I have no idea what.
    I think I'm adding another ble-server to send the heart rate to RGT or Zwift then.

    How do you calibrate your powercrank? Does it have to be in a specific position when calibrating, like when taring?


    Reply 6 months ago

    It's strange that Zwift detects it but shows 0. If you followed all the specifications from the Bluetooth website, I have no idea what could be wrong, especially that NRF Connect detects and shows the heart rate correctly.
    As for the calibration, it is like taring, I use the MPU to sense the gravitational acceleration (thanks to Einstein and his equivalence principle) to measure the angle of the crank. Then if it is in some range - horizontally ± a few degrees and for a few seconds I subtract the current weight from the measurement essentially taring it. I used the LED on the ESP to show the user when they are in that range - it is blinking while waiting for it to stabilise and then on for a second to show that it just finished taring.
    The calibration itself is by using a known weight and Arduino's map function, so the min input is the sensor value with no weight, max input is the value with a set weight (a kettlebell or something heavy), min output is 0 and max is the weight. It assumes that the relationship is linear, but it pretty much is.
    Oh, and if you are making your crank, something I would have done if I were doing this again is to connect the battery + through a voltage divider to one of the analog pins, so you can measure the battery voltage and notify the user that it is low


    Reply 6 months ago

    Yeah, it's strange. I tried to follow the fitness machine specifications, but it is not clear about how to include the heart rate value. It looks like it is missing a "Sensor Contact Status bit" or something, but I can't find it in the documentation. The 'real' sensors are sending this so the client can determine if the heart rate is valid or not.
    Great idea for the battery detection. I'll implement it.
    Is there a reason for the extra pcb between the strain gauges? Can I just connect them with longer tiny wires to HX711 on the main pcb?


    Reply 6 months ago

    The strain gauges are very delicate, I did it this way so I could use very thin wires for the sensors and they are secured to the PCB. The wires from the PCB to the HX711 are much thicker and I can do what I want with them without risking damaging the gauges. So if you do that make sure to secure the wires well, so if you are moving the main board it doesnt pull on the gauges.
    Also from experimenting a bit, the sensors work much better if they are closer together, maybe 5-7 cm apart rather than like on those pictures


    Reply 6 months ago

    I finished the powercrank today, but the part with the strain gauges is not totally clear to me. I copied your PCB-layout and soldered the wires from the gauges onto the PCB. The ones near the axle on E- and the ones near the pedal to E+, but I got inverted readings in the serial monitor.
    When I put no weight on it, the value is -798640 and with weight applied to the crank it goes to -690736. While your reading goes from -200941 to -351117.
    I tried to solder the gauges to different positions on the pcb, but no luck.
    Any Idea what is going on here?


    Reply 6 months ago

    The exact values do not matter at all, its good that it changes, now you need to map your range of sensor values to the weight you used and everything should work right


    Reply 6 months ago

    That's what I thought at first. The weight indeed is changing in serial monitor.
    But when I connect to Zwift or RGT it gives me power when I only use my right pedal. I can get it to 500W if I turn it around fast enough. But When I put my foot on the left pedal the power reduces quickly and goes to 0 when I start pedalling with left. So there is something inverted.
    I also used your stand alone powercrank firmware... same result.


    Reply 6 months ago

    Did you make the left powermeter too? I only made one, because it is the only one that fit on the bike. If you made one, then it measures the power of only one leg and assumes that the other is the same. If you want to measure 2 pedals, then you need to make another one. Everything seems to work correctly from your description


    Reply 6 months ago

    I made only one, mounted on the left crank, just like you did.
    When I turn the right pedal around by hand with no resistance (and the left pedal with powermeter is rotating freely) I get power readings in Zwift. The faster I turn, the higher the power.
    When I start applying pressure on the left pedal, the power goes to 0 fast.