├── .gitignore ├── Hall_Sensor_FTMS_server.ino ├── LICENSE ├── README.md ├── curaSettings.jpg ├── esp32Tach.jpg └── tachMount.stl /.gitignore: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | *.d 3 | 4 | # Object files 5 | *.o 6 | *.ko 7 | *.obj 8 | *.elf 9 | 10 | # Linker output 11 | *.ilk 12 | *.map 13 | *.exp 14 | 15 | # Precompiled Headers 16 | *.gch 17 | *.pch 18 | 19 | # Libraries 20 | *.lib 21 | *.a 22 | *.la 23 | *.lo 24 | 25 | # Shared objects (inc. Windows DLLs) 26 | *.dll 27 | *.so 28 | *.so.* 29 | *.dylib 30 | 31 | # Executables 32 | *.exe 33 | *.out 34 | *.app 35 | *.i*86 36 | *.x86_64 37 | *.hex 38 | 39 | # Debug files 40 | *.dSYM/ 41 | *.su 42 | *.idb 43 | *.pdb 44 | 45 | # Kernel Module Compile Results 46 | *.mod* 47 | *.cmd 48 | .tmp_versions/ 49 | modules.order 50 | Module.symvers 51 | Mkfile.old 52 | dkms.conf 53 | -------------------------------------------------------------------------------- /Hall_Sensor_FTMS_server.ino: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | Based on Neil Kolban example for IDF: https://github.com/nkolban/esp32-snippets/blob/master/cpp_utils/tests/BLE%20Tests/SampleServer.cpp 4 | Ported to Arduino ESP32 by Evandro Copercini 5 | updates by chegewara 6 | */ 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | 14 | // See the following for generating UUIDs: 15 | // https://www.uuidgenerator.net/ 16 | 17 | #define FTMS_UUID "00001826-0000-1000-8000-00805f9b34fb" 18 | #define FITNESS_MACHINE_FEATURES_UUID "00002acc-0000-1000-8000-00805f9b34fb" 19 | #define INDOOR_BIKE_DATA_CHARACTERISTIC_UUID "00002ad2-0000-1000-8000-00805f9b34fb" 20 | #define LED_BUILTIN 2 21 | 22 | bool deviceConnected = false; 23 | bool oldDeviceConnected = false; 24 | class MyServerCallbacks : public BLEServerCallbacks 25 | { 26 | void onConnect(BLEServer *pServer) 27 | { 28 | deviceConnected = true; 29 | }; 30 | 31 | void onDisconnect(BLEServer *pServer) 32 | { 33 | deviceConnected = false; 34 | } 35 | }; 36 | 37 | void setup() 38 | { 39 | pinMode(LED_BUILTIN, OUTPUT); // initialize digital pin LED_BUILTIN as an output. 40 | setupBluetoothServer(); 41 | setupHalSensor(); 42 | } 43 | 44 | BLECharacteristic *fitnessMachineFeaturesCharacteristic = NULL; 45 | BLECharacteristic *indoorBikeDataCharacteristic = NULL; 46 | BLEServer *pServer = NULL; 47 | void setupBluetoothServer() 48 | { 49 | Serial.begin(115200); 50 | Serial.println("Starting BLE work!"); 51 | BLEDevice::init("IC Bike"); 52 | pServer = BLEDevice::createServer(); 53 | pServer->setCallbacks(new MyServerCallbacks()); 54 | BLEService *pService = pServer->createService(FTMS_UUID); 55 | fitnessMachineFeaturesCharacteristic = pService->createCharacteristic( 56 | FITNESS_MACHINE_FEATURES_UUID, 57 | BLECharacteristic::PROPERTY_READ | 58 | BLECharacteristic::PROPERTY_WRITE | 59 | BLECharacteristic::PROPERTY_NOTIFY | 60 | BLECharacteristic::PROPERTY_INDICATE); 61 | indoorBikeDataCharacteristic = pService->createCharacteristic( 62 | INDOOR_BIKE_DATA_CHARACTERISTIC_UUID, 63 | BLECharacteristic::PROPERTY_READ | 64 | BLECharacteristic::PROPERTY_WRITE | 65 | BLECharacteristic::PROPERTY_NOTIFY | 66 | BLECharacteristic::PROPERTY_INDICATE); 67 | // BLE2803 and BLE2902 are the UUIDs for Characteristic Declaration (0x2803) and Descriptor Declaration (0x2902). 68 | fitnessMachineFeaturesCharacteristic->addDescriptor(new BLE2902()); 69 | indoorBikeDataCharacteristic->addDescriptor(new BLE2902()); 70 | pService->start(); 71 | // BLEAdvertising *pAdvertising = pServer->getAdvertising(); // add this for backwards compatibility 72 | BLEAdvertising *pAdvertising = BLEDevice::getAdvertising(); 73 | pAdvertising->addServiceUUID(FTMS_UUID); 74 | pAdvertising->setScanResponse(true); 75 | pAdvertising->setMinPreferred(0x06); // functions that help with iPhone connections issue 76 | pAdvertising->setMinPreferred(0x12); 77 | BLEDevice::startAdvertising(); 78 | Serial.println("Waiting for a client connection to notify..."); 79 | } 80 | 81 | int digitalPin = 18; 82 | int analogPin = 19; 83 | bool magStateOld; 84 | void setupHalSensor() 85 | { 86 | pinMode(analogPin, INPUT); 87 | pinMode(digitalPin, INPUT); 88 | Serial.begin(9600); 89 | magStateOld = digitalRead(digitalPin); 90 | } 91 | 92 | //incrementRevolutions() used to synchronously update rev rather than using an ISR. 93 | inline bool positiveEdge(bool state, bool &oldState) 94 | { 95 | bool result = (state && !oldState);//latch logic 96 | oldState = state; 97 | return result; 98 | } 99 | 100 | double calculateRpmFromRevolutions(int revolutions, unsigned long revolutionsTime) 101 | { 102 | double ROAD_WHEEL_TO_TACH_WHEEL_RATIO = 20.68; 103 | double instantaneousRpm = revolutions * 60 * 1000 / revolutionsTime / ROAD_WHEEL_TO_TACH_WHEEL_RATIO; 104 | // Serial.printf("revolutionsTime: %d, rev: %d , instantaneousRpm: %2.9f \n", 105 | // revolutionsTime, revolutions, instantaneousRpm); 106 | return instantaneousRpm; 107 | } 108 | 109 | double calculateKphFromRpm(double rpm) 110 | { 111 | double WHEEL_RADIUS = 0.00034; // in km 112 | double KM_TO_MI = 0.621371; 113 | 114 | double circumfrence = 2 * PI * WHEEL_RADIUS; 115 | double metricDistance = rpm * circumfrence; 116 | double kph = metricDistance * 60; 117 | double mph = kph * KM_TO_MI; 118 | // Serial.printf("rpm: %2.2f, circumfrence: %2.2f, metricDistance %2.5f , imperialDistance: %2.5f, mph: %2.2f \n", 119 | // rpm, circumfrence, metricDistance, imperialDistance, mph); 120 | return kph; 121 | } 122 | 123 | unsigned long distanceTime = 0; 124 | double calculateDistanceFromKph(unsigned long distanceTimeSpan, double kph) 125 | { 126 | double incrementalDistance = distanceTimeSpan * kph / 60 / 60 / 1000; 127 | // Serial.printf("mph: %2.2f, distanceTimeSpan %d , incrementalDistance: %2.9f \n", 128 | // mph, distanceTimeSpan, incrementalDistance); 129 | return incrementalDistance; 130 | } 131 | 132 | double tireValues[] = {0.005, 0.004, 0.012}; //Clincher, Tubelar, MTB 133 | double aeroValues[] = {0.388, 0.445, 0.420, 0.300, 0.233, 0.200}; //Hoods, Bartops, Barends, Drops, Aerobar 134 | unsigned long caloriesTime = 0; 135 | double calculatePowerFromKph(double kph) 136 | { 137 | //double velocity = mph * 0.44704; // translates to meters/second 138 | double velocity = kph * 0.277778; // translates to meters/second 139 | double riderWeight = 72.6; //165 lbs 140 | double bikeWeight = 11.1; //Cannondale road bike 141 | int theTire = 0; //Clinchers 142 | double rollingRes = tireValues[theTire]; 143 | int theAero = 1; //Bartops 144 | double frontalArea = aeroValues[theAero]; 145 | double grade = 0; 146 | double headwind = 0; // converted to m/s 147 | double temperaturev = 15.6; // 60 degrees farenheit 148 | double elevation = 100; // Meters 149 | double transv = 0.95; // no one knows what this is, so why bother presenting a choice? 150 | 151 | /* Common calculations */ 152 | double density = (1.293 - 0.00426 * temperaturev) * exp(-elevation / 7000.0); 153 | double twt = 9.8 * (riderWeight + bikeWeight); // total weight in newtons 154 | double A2 = 0.5 * frontalArea * density; // full air resistance parameter 155 | double tres = twt * (grade + rollingRes); // gravity and rolling resistance 156 | 157 | // we calculate power from velocity 158 | double tv = velocity + headwind; //terminal velocity 159 | double A2Eff = (tv > 0.0) ? A2 : -A2; // wind in face so you must reverse effect 160 | return (velocity * tres + velocity * tv * tv * A2Eff) / transv; 161 | } 162 | 163 | double calculateCaloriesFromPower(unsigned long caloriesTimeSpan, double powerv) 164 | { 165 | double JOULE_TO_KCAL = 0.238902957619; 166 | // From the formula: Energy (Joules) = Power (Watts) * Time (Seconds) 167 | double incrementalCalories = powerv * caloriesTimeSpan / 60 / 1000 * JOULE_TO_KCAL; 168 | double wl = incrementalCalories / 32318.0; // comes from 1 lb = 3500 Calories 169 | return incrementalCalories; 170 | } 171 | 172 | void indicateRpmWithLight(int rpm) 173 | { 174 | if (rpm > 1) 175 | { 176 | digitalWrite(LED_BUILTIN, HIGH); // turn the LED on (HIGH is the voltage level) 177 | } 178 | else 179 | { 180 | digitalWrite(LED_BUILTIN, LOW); // turn the LED off by making the voltage LOW 181 | } 182 | } 183 | 184 | //Used for debugging, i.e. `printArray(bikeData, sizeof(bikeData));` 185 | //NOTE: sizeOfArray parameter is necessary 186 | //This is because `sizeOf()` will return the size of the pointer to the array, not the array 187 | void printArray(byte input[], int sizeOfArray) 188 | { 189 | for (size_t i = 0; i < sizeOfArray; i++) 190 | { 191 | Serial.print(input[i]); 192 | Serial.print(' '); 193 | } 194 | } 195 | 196 | byte features[] = {0x07,0x52,0x00,0x00}; 197 | // 0x07,0x52 (0x48,0x4a in big-endian) are flags for 198 | // avgSpeed (0), cadence (1), total distance (2), expended energy (9), elapsed time (12), power measurement (14) 199 | void transmitFTMS(double rpm, double avgRpm, double kph, double avgKph, double power, double avgPower, 200 | double runningDistance, double runningCalories, unsigned long elapsedTime) 201 | { 202 | uint16_t transmittedKph = (uint16_t) (kph * 100); //(0.01 resolution) 203 | uint16_t transmittedTime = (uint16_t) (elapsedTime / 1000);//(1.0 resolution) 204 | uint16_t transmittedAvgKph = (uint16_t) (avgKph * 100); //(0.01 resolution) 205 | uint16_t transmittedRpm = (uint16_t) (rpm * 2); //(0.5 resolution) 206 | uint16_t transmittedAvgRpm = (uint16_t) (avgRpm * 2); //(0.1 resolution) 207 | uint16_t transmittedPower = (uint16_t) (power * 2); //(1.0 resolution) 208 | uint16_t transmittedAvgPower= (uint16_t) (avgPower * 2); //(1.0 resolution) 209 | uint32_t transmittedDistance= (uint32_t) (runningDistance * 1000);// runningDistance in km, need m 210 | uint16_t transmittedTotalCal= (uint16_t) (runningCalories * 10); //(1.0 resolution) 211 | uint16_t transmittedCalHr = (uint16_t) (runningCalories * 60 * 60 / elapsedTime);//(1.0 resolution) 212 | uint8_t transmittedCalMin = (uint8_t) (runningCalories * 60 / elapsedTime); //(1.0 resolution) 213 | 214 | bool disconnecting = !deviceConnected && oldDeviceConnected; 215 | bool connecting = deviceConnected && !oldDeviceConnected; 216 | 217 | byte bikeData[20]={0x56,0x09, // these bytes are the flags for 218 | // instSpeed (0 counts as true here), avgSpeed(1), instCadence (2), 219 | // total distance (4), instPower (6), expended energy (8), elapsed time (11) 220 | (uint8_t)transmittedKph, (uint8_t)(transmittedKph >> 8), 221 | (uint8_t)transmittedAvgKph, (uint8_t)(transmittedAvgKph >> 8), 222 | (uint8_t)transmittedRpm, (uint8_t)(transmittedRpm >> 8), 223 | //(uint8_t)transmittedAvgRpm, (uint8_t)(transmittedAvgRpm >> 8), //NOTE: commented out to avoid exceeding MTU 224 | (uint8_t)transmittedDistance, (uint8_t)(transmittedDistance >> 8),(uint8_t)(transmittedDistance >> 16), 225 | (uint8_t)transmittedPower, (uint8_t)(transmittedPower >> 8), //NOTE: Actually SINT16, but my bike can't peddle backwards 226 | //(uint8_t)transmittedAvgPower,(uint8_t)(transmittedAvgPower >> 8), //NOTE: commented out to avoid exceeding MTU 227 | (uint8_t)transmittedTotalCal, (uint8_t)(transmittedTotalCal >> 8), 228 | (uint8_t)transmittedCalHr, (uint8_t)(transmittedCalHr >> 8), 229 | transmittedCalMin, 230 | (uint8_t)transmittedTime, (uint8_t)(transmittedTime >> 8) 231 | }; 232 | if (deviceConnected) 233 | { 234 | //NOTE: Even though the ATT_MTU for BLE is 23 bytes, android by default only captures the first 20 bytes. 235 | indoorBikeDataCharacteristic->setValue((uint8_t *)&bikeData, 20); 236 | indoorBikeDataCharacteristic->notify(); 237 | } 238 | 239 | if (disconnecting) // give the bluetooth stack the chance to get things ready & restart advertising 240 | { 241 | delay(500); 242 | pServer->startAdvertising(); 243 | Serial.println("start advertising"); 244 | oldDeviceConnected = deviceConnected; 245 | } 246 | 247 | if (connecting) // execute one time notification of supported features 248 | { 249 | oldDeviceConnected = deviceConnected; 250 | fitnessMachineFeaturesCharacteristic->setValue((byte*)&features, 4); 251 | fitnessMachineFeaturesCharacteristic->notify(); 252 | } 253 | } 254 | 255 | unsigned long elapsedTime = 0; 256 | unsigned long elapsedSampleTime = 0; 257 | int rev = 0; 258 | double intervalEntries = 0; 259 | double totalRpm = 0; 260 | double totaKph = 0; 261 | double totalPower = 0; 262 | double runningCalories = 0.0; 263 | double runningDistance = 0.0; 264 | void loop() 265 | { 266 | unsigned long intervalTime = millis() - elapsedTime; 267 | unsigned long sampleTime = millis() - elapsedSampleTime; 268 | bool state = digitalRead(digitalPin); 269 | if (sampleTime > 5 && state != magStateOld) 270 | { 271 | rev += (int)positiveEdge(state, magStateOld); 272 | elapsedSampleTime = millis(); 273 | } 274 | if (intervalTime > 500) 275 | { 276 | double rpm = calculateRpmFromRevolutions(rev, intervalTime); 277 | double kph = calculateKphFromRpm(rpm); 278 | double power = calculatePowerFromKph(kph); 279 | 280 | intervalEntries++; 281 | totalRpm += rpm; 282 | totaKph += kph; 283 | totalPower += power; 284 | 285 | double avgRpm = totalRpm / intervalEntries; 286 | double avgKph = totaKph / intervalEntries; 287 | double avgPower = totalPower / intervalEntries; 288 | runningDistance += calculateDistanceFromKph(intervalTime, kph); 289 | 290 | runningCalories += calculateCaloriesFromPower(intervalTime, power); 291 | Serial.println("\n----------------------------------------------------"); 292 | Serial.printf("elapsedTime: %d, rev: %d \n", elapsedTime, rev); 293 | Serial.printf("rpm: %2.2f, avgRpm: %2.2f \n", rpm, avgRpm); 294 | Serial.printf("kph: %2.2f, avgKph: %2.2f \n", kph, avgKph); 295 | Serial.printf("power: %2.2f watts, avgPower: %2.2 watts \n", power, avgPower); 296 | Serial.printf("distance: %2.2f, calories: %2.5f \n", runningDistance, runningCalories); 297 | 298 | indicateRpmWithLight(rpm); 299 | // bluetooth becomes congested if too many packets are sent. In a 6 hour test I was able to go as frequent as 3ms. 300 | transmitFTMS(rpm,avgRpm,kph,avgKph,power,avgPower,runningDistance,runningCalories,elapsedTime); 301 | 302 | rev = 0; 303 | elapsedTime = millis(); 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 James Taylor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # esp32-ftms-server 2 | 3 | ![Tachometer](./esp32Tach.jpg) 4 | 5 | This is an ESP-32 based tachometer that I built for my stationary bike stand. It uses the [Indoor Bike Data characteristic](https://www.bluetooth.com/wp-content/uploads/Sitecore-Media-Library/Gatt/Xml/Characteristics/org.bluetooth.characteristic.indoor_bike_data.xml) of the FTMS BLE service. 6 | 7 | ## Parts List 8 | 9 | * [MELIFE 2 Pack for ESP32](https://www.amazon.com/MELIFE-Development-Dual-Mode-Microcontroller-Integrated/dp/B07Q576VWZ/ref=sr_1_1?dchild=1&keywords=MELIFE+2+Pack+for+ESP32+ESP-32S+Development+Board+2.4GHz+Dual-Mode&qid=1619282031&s=electronics&sr=1-1) 10 | * [ELEGOO 32 Pcs Double Sided PCB Board Prototype Kit](https://www.amazon.com/ELEGOO-Prototype-Soldering-Compatible-Arduino/dp/B072Z7Y19F/ref=sr_1_1?dchild=1&keywords=ELEGOO+32+Pcs+Double+Sided+PCB+Board+Prototype+Kit+for+DIY+Soldering+with+5+Sizes+Compatible+with+Arduino+Kits&qid=1619282054&s=industrial&sr=1-1) 11 | * [ELEGOO Upgraded 37 in 1 Sensor Modules Kit](https://www.amazon.com/ELEGOO-Upgraded-Tutorial-Compatible-MEGA2560/dp/B01MG49ZQ5/ref=sr_1_1?dchild=1&keywords=ELEGOO+Upgraded+37+in+1+Sensor+Modules+Kit+with+Tutorial+Compatible+with+Arduino+IDE+UNO+R3+MEGA2560+Nano&qid=1619282073&s=electronics&sr=1-1) 12 | * [HATCHBOX ABS 3D Printer Filament](https://www.amazon.com/HATCHBOX-3D-Filament-Dimensional-Transparent/dp/B00M0CS6HA/ref=sr_1_4?dchild=1&keywords=Hatchbox+ABS&qid=1619282235&s=electronics&sr=1-4-catcorr) 13 | 14 | ## 3d Print Settings 15 | 16 | The Cura 3d print settings for the tachMount.stl are below: 17 | 18 | ![Cura settings](./curaSettings.jpg) 19 | -------------------------------------------------------------------------------- /curaSettings.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesjmtaylor/esp32-ftms-server/dd1cd09a1c186096b0b6ee6962168d9d424b75ed/curaSettings.jpg -------------------------------------------------------------------------------- /esp32Tach.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesjmtaylor/esp32-ftms-server/dd1cd09a1c186096b0b6ee6962168d9d424b75ed/esp32Tach.jpg -------------------------------------------------------------------------------- /tachMount.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesjmtaylor/esp32-ftms-server/dd1cd09a1c186096b0b6ee6962168d9d424b75ed/tachMount.stl --------------------------------------------------------------------------------