
简介这份资源面向嵌入式开发与室内骑行爱好者提供一套基于Arduino与ESP32的BLE FTMS室内自行车健身机完整实现方案用两块ESP32分别模拟功率计与智能骑行台解决商用设备价格高昂、协议封闭的问题。压缩包共30个文件约1.24MB包含ino主程序与h头文件、Python配置脚本、md说明文档以及dxf、stl、f3d等结构件图纸和brd、sch电路设计文件覆盖固件、电路与机械装配全链路。目前已有763人学习下载。读者可获取曲柄功率与踏频采集、FTMS数据转发、电阻螺钉位置控制、12864屏幕显示等核心代码并支持ERG模式调阻、模拟模式换挡以及从SD卡加载阻力曲线配套的图形化配置器可将模拟档位映射为阻力函数便于按需标定骑行手感。适合具备C与ESP32基础、希望深入理解健身设备通信协议的开发者参考。1. 从一台“哑巴”骑行台到能被 App 读懂的 FTMS 设备很多人家里都有一台室内自行车健身机骑起来阻力顺滑、坐垫也舒服但它就是个“哑巴”——码表上的速度、踏频、功率只能自己看手机上的骑行 App 一个都连不上。想让它变成 Zwift、Strava 这类应用能识别的智能骑行台核心就卡在一个协议上FTMSFitness Machine Service。而ble-ftms:Arduino BLE室内自行车健身机这个方向讲的就是用一块支持 BLE 的 Arduino 板子把普通健身机改造成符合 FTMS 规范的蓝牙外设让手机、平板、电脑上的骑行软件把它当成一台标准智能骑行台来读数据、发指令。这件事的价值不在于“炫技”而在于它把一台几千块的设备从封闭生态里解放出来。你不需要换整台车只需要在控制板层面做一次协议翻译把霍尔传感器或光电传感器的脉冲换算成踏频和速度把阻力旋钮或电磁刹车映射成 FTMS 的阻力控制点再通过 BLE 的 GATT 服务暴露出去。适合谁适合手里有 Arduino、会一点 C、愿意看蓝牙 GATT 文档的硬件爱好者也适合做健身设备固件的工程师想快速验证 FTMS 兼容性。下面我按“协议先立住、再动手复现”的顺序把这条路走一遍。2. FTMS 协议栈拆解从 GATT 服务到室内自行车数据特征2.1 为什么是 FTMS而不是自己定义一串私有 UUID蓝牙低功耗BLE里设备之间靠 GATT通用属性协议通信。GATT 把数据组织成“服务Service”和“特征Characteristic”每个服务、每个特征都有一个 UUID。如果你自己拍脑袋定一串 UUID手机 App 根本不认识除非你同时改 App 端代码。FTMS 是蓝牙技术联盟定义的标准服务UUID 固定为0x1826里面规定了健身机应该暴露哪些特征、每个特征的数据格式是什么。主流骑行 App 只要看到0x1826就会按标准去读踏频、速度、功率并往控制点写阻力指令。选 FTMS 的另一个理由是“互操作性”。你不需要为每个 App 写适配层只要你的设备符合规范任何支持 FTMS 的软件都能连。常见做法是先实现 Indoor Bike Data 特征UUID0x2AD2把瞬时速度、踏频、功率按位域打包再实现 Fitness Machine Control PointUUID0x2AD9用来接收 App 发来的“设置目标阻力”“开始训练”“停止”等指令最后加上 Fitness Machine FeatureUUID0x2ACC和 Fitness Machine StatusUUID0x2ADA告诉 App 你支持哪些能力、当前状态是什么。2.2 Indoor Bike Data 的位域怎么填一个字节都不能错Indoor Bike Data 是一个变长特征前两个字节是“标志位Flags”后面跟着实际数据。标志位决定了哪些字段存在、哪些字段省略。比如 bit 0 表示“瞬时速度是否有效”bit 1 表示“平均速度是否存在”bit 2 表示“瞬时踏频是否存在”bit 3 表示“平均踏频是否存在”bit 4 表示“总距离是否存在”bit 5 表示“阻力等级是否存在”bit 6 表示“瞬时功率是否存在”bit 7 表示“平均功率是否存在”等等。你填错一位App 解析出来的数据就会整体错位表现就是“速度显示成踏频”或者“功率一直是 0”。我一般会先确定要上报哪些字段瞬时速度、瞬时踏频、瞬时功率、总距离、阻力等级。对应的标志位就是 bit 0、bit 2、bit 4、bit 5、bit 6 置 1其余置 0。然后按顺序把数据塞进去速度是 uint16单位 0.01 km/h踏频是 uint16单位 0.5 rpm总距离是 uint24单位米阻力等级是 sint16功率是 sint16单位瓦特。注意字节序是小端Little EndianArduino 上可以用lowByte和highByte手动拼也可以直接操作结构体但结构体对齐要小心。// 构造 Indoor Bike Data 数据包 uint8_t indoorBikeData[16]; uint8_t index 0; // Flags: 瞬时速度(bit0) 瞬时踏频(bit2) 总距离(bit4) 阻力(bit5) 瞬时功率(bit6) uint16_t flags 0x0000; flags | (1 0); // Instantaneous Speed Present flags | (1 2); // Instantaneous Cadence Present flags | (1 4); // Total Distance Present flags | (1 5); // Resistance Level Present flags | (1 6); // Instantaneous Power Present indoorBikeData[index] lowByte(flags); indoorBikeData[index] highByte(flags); // 瞬时速度单位 0.01 km/h例如 25.00 km/h - 2500 uint16_t speed 2500; indoorBikeData[index] lowByte(speed); indoorBikeData[index] highByte(speed); // 瞬时踏频单位 0.5 rpm例如 90 rpm - 180 uint16_t cadence 180; indoorBikeData[index] lowByte(cadence); indoorBikeData[index] highByte(cadence); // 总距离uint24单位米例如 12345 米 uint32_t totalDistance 12345; indoorBikeData[index] totalDistance 0xFF; indoorBikeData[index] (totalDistance 8) 0xFF; indoorBikeData[index] (totalDistance 16) 0xFF; // 阻力等级sint16例如 5 int16_t resistance 5; indoorBikeData[index] lowByte(resistance); indoorBikeData[index] highByte(resistance); // 瞬时功率sint16单位瓦特例如 200W int16_t power 200; indoorBikeData[index] lowByte(power); indoorBikeData[index] highByte(power); // 最终通过 BLE 特征 notify 出去 // bleIndoorBikeData.setValue(indoorBikeData, index); // bleIndoorBikeData.notify();这段代码的关键在于标志位和数据字段必须一一对应。如果你把 bit 4 置 1 但没填总距离App 会往后多读三个字节把后面的阻力、功率全部读错。参数上速度单位是 0.01 km/h踏频单位是 0.5 rpm这两个换算最容易翻车踏频 90 rpm 要写成 180不是 90。功率和阻力是有符号 16 位负值在 FTMS 里通常表示“无效”或“不支持”但有些 App 会直接显示负数所以尽量别发负值。2.3 Control Point 的请求-响应模型App 发指令你得回执Fitness Machine Control Point 是一个“写-指示Write, Indicate”特征。App 往这个特征写一条指令比如0x00表示“请求控制权”0x01表示“重置”0x02表示“设置目标阻力”0x07表示“开始/恢复”0x08表示“停止/暂停”。你收到之后必须通过 Indicate 回一条响应格式是第一个字节是请求的操作码第二个字节是结果码0x01表示成功0x02表示不支持0x03表示参数无效0x04表示操作失败。如果你不回App 会一直等最后超时断开。常见做法是在 BLE 的写回调里解析操作码执行对应动作然后立刻 Indicate 响应。比如 App 写0x00请求控制权你就把控制权标志置 1回0x00 0x01。App 写0x02设置目标阻力后面跟一个 sint16 的阻力值你解析出来调整电磁刹车或阻尼器回0x02 0x01。注意 Indicate 和 Notify 的区别Indicate 需要客户端确认Notify 不需要。Control Point 必须用 Indicate否则 App 可能收不到响应。// Control Point 写回调示例 void onControlPointWrite(uint8_t* data, uint16_t len) { if (len 1) return; uint8_t opCode data[0]; uint8_t response[2]; response[0] opCode; switch (opCode) { case 0x00: // Request Control hasControl true; response[1] 0x01; // Success break; case 0x01: // Reset totalDistance 0; response[1] 0x01; break; case 0x02: // Set Target Resistance Level if (len 3) { int16_t targetResistance (int16_t)(data[1] | (data[2] 8)); setResistance(targetResistance); response[1] 0x01; } else { response[1] 0x03; // Invalid Parameter } break; case 0x07: // Start or Resume isRunning true; response[1] 0x01; break; case 0x08: // Stop or Pause isRunning false; response[1] 0x01; break; default: response[1] 0x02; // Not Supported break; } // 通过 Indicate 发送响应 // bleControlPoint.setValue(response, 2); // bleControlPoint.indicate(); }参数说明hasControl是个布尔标志只有拿到控制权后App 才会发后续指令setResistance是你自己实现的硬件控制函数可能是 PWM 调占空比也可能是步进电机调位置isRunning控制数据上报是否更新。注意结果码0x01是成功0x02是不支持0x03是参数无效0x04是操作失败。如果你收到不认识的 opCode回0x02比不回要好至少 App 知道你不支持不会一直等。3. 用 Arduino 把传感器数据变成 FTMS 可读的字节流3.1 硬件选型哪块 Arduino 能跑 BLE 和 FTMS不是所有 Arduino 都支持 BLE。经典 Uno、Nano 用的是 ATmega328P没有蓝牙硬件你得外挂 HC-05 之类的模块但那些模块通常只支持经典蓝牙串口不支持 BLE GATT做不了 FTMS。常见做法是选带原生 BLE 的板子Arduino Nano 33 BLE、Arduino Nano 33 IoT需要额外配置、ESP32 系列Arduino 核心支持 BLE。其中 ESP32 性价比最高双核 240MHzBLE 协议栈成熟社区例子多。Nano 33 BLE 用的是 nRF52840功耗低但价格稍高。我一般会选 ESP32-WROOM-32 模组搭配一个霍尔传感器测踏频、一个光电传感器测轮速如果你有飞轮再加一个电磁刹车或舵机控制阻力。供电方面ESP32 用 3.3V 逻辑传感器如果是 5V 输出要加电平转换或分压电阻。BLE 天线尽量远离电机和电源线否则丢包率会明显上升。3.2 踏频和速度的计算从脉冲间隔到 0.5 rpm 的整数霍尔传感器每转一圈输出一个或多个脉冲。假设你用的是单极霍尔飞轮每转一圈触发一次那么两次脉冲之间的时间间隔就是一圈的时间。踏频rpm 60 / 圈时间秒。但 FTMS 要求踏频单位是 0.5 rpm所以你要把 rpm 乘以 2 再取整。速度则取决于轮径或飞轮等效周长速度km/h 圈周长米 × 圈数 / 时间秒 × 3.6。FTMS 速度单位是 0.01 km/h所以再乘以 100。实际写代码时不要用delay去等脉冲要用中断。ESP32 的attachInterrupt可以挂在 GPIO 上上升沿触发。在中断里记录micros()时间戳主循环里算差值。如果超过 2 秒没有新脉冲就把踏频和速度置 0否则 App 会一直显示上一次的值。下面是一个简化的计算逻辑volatile unsigned long lastPulseTime 0; volatile unsigned long pulseInterval 0; void IRAM_ATTR onPulse() { unsigned long now micros(); pulseInterval now - lastPulseTime; lastPulseTime now; } void updateFtmsData() { unsigned long now micros(); if (now - lastPulseTime 2000000) { // 超过 2 秒无脉冲 currentCadence 0; currentSpeed 0; currentPower 0; return; } if (pulseInterval 0) { // 踏频60 秒 / (脉冲间隔微秒 / 1e6) 60e6 / pulseInterval float rpm 60000000.0 / pulseInterval; currentCadence (uint16_t)(rpm * 2); // 单位 0.5 rpm // 速度假设飞轮等效周长 2.0 米 float wheelCircumference 2.0; float speedKmh (wheelCircumference / (pulseInterval / 1000000.0)) * 3.6; currentSpeed (uint16_t)(speedKmh * 100); // 单位 0.01 km/h // 功率估算简化模型实际应用需要功率计或查表 currentPower (int16_t)(rpm * 2.5); // 粗略估算 } }参数说明pulseInterval是两次脉冲之间的微秒数60000000.0是 60 秒换算成微秒wheelCircumference要根据你的飞轮或轮子实际周长改常见室内骑行台飞轮等效周长在 1.5 到 2.5 米之间功率估算只是占位真实功率需要应变片或电磁刹车电流反馈。注意中断里不要做浮点运算或串口打印否则会丢脉冲。3.3 BLE 服务注册与广播让 App 能搜到你ESP32 的 Arduino BLE 库BLEDevice可以注册服务和特征。你需要先创建 FTMS 服务UUID0x1826然后添加 Indoor Bike Data 特征0x2AD2属性设为READ | NOTIFY添加 Control Point 特征0x2AD9属性设为WRITE | INDICATE添加 Feature 特征0x2ACC属性READ添加 Status 特征0x2ADA属性NOTIFY。广播时把设备名设成“Indoor Bike”之类App 扫描时就能看到。BLEService ftmsService(1826); BLECharacteristic indoorBikeData(2AD2, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY); BLECharacteristic controlPoint(2AD9, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_INDICATE); BLECharacteristic feature(2ACC, BLECharacteristic::PROPERTY_READ); BLECharacteristic status(2ADA, BLECharacteristic::PROPERTY_NOTIFY); void setupBLE() { BLEDevice::init(Indoor Bike); BLEServer* server BLEDevice::createServer(); server-createService(ftmsService); ftmsService.addCharacteristic(indoorBikeData); ftmsService.addCharacteristic(controlPoint); ftmsService.addCharacteristic(feature); ftmsService.addCharacteristic(status); // 设置 Feature 特征的值告诉 App 支持哪些功能 uint8_t featureValue[8] {0x00}; featureValue[0] 0x02; // 支持 Cadence featureValue[1] 0x04; // 支持 Power feature.setValue(featureValue, 8); controlPoint.setCallbacks(new ControlPointCallbacks()); ftmsService.start(); BLEAdvertising* advertising BLEDevice::getAdvertising(); advertising-addServiceUUID(ftmsService.getUUID()); advertising-start(); }这段代码里featureValue的位域也要按 FTMS 规范填bit 1 表示支持踏频bit 2 表示支持功率bit 3 表示支持速度等等。如果你填错App 可能不显示某些数据。ControlPointCallbacks是自定义的回调类继承BLECharacteristicCallbacks在onWrite里处理 Control Point 的写入。广播时加上服务 UUIDApp 扫描过滤时能更快找到你。4. 避坑与排查FTMS 联调中最容易翻车的 5 个点4.1 现象App 能连上但速度、踏频全是 0原因通常是标志位和数据字段不匹配。比如你置了 bit 0瞬时速度但实际数据包里速度字段没填或者填了 0。另一种可能是字节序搞反了App 按小端解析你发的是大端。解决用手机上的 nRF Connect 或 LightBlue 读一下 Indoor Bike Data 的原始字节对照 FTMS 规范逐位检查。速度字段是 uint16小端单位 0.01 km/h如果你发的是 2500App 应该显示 25.00 km/h。4.2 现象Control Point 写进去没反应App 提示超时原因是你没有回 Indicate 响应或者响应格式不对。FTMS 要求每个写请求都要有对应的 Indicate第一个字节是请求的 opCode第二个字节是结果码。如果你只回一个字节或者用 Notify 代替 IndicateApp 可能收不到。解决确认特征属性包含INDICATE并且在onWrite里调用indicate()而不是notify()。另外Indicate 需要客户端确认如果你发得太快可能会丢包建议在回调里直接发不要延迟。4.3 现象踏频跳动很大有时突然跳到 200 以上原因是脉冲间隔测量被中断打断或者霍尔传感器抖动。机械开关式霍尔在临界点会多次触发导致pulseInterval变得极小算出来的 rpm 就很大。解决在中断里加去抖比如检测到脉冲后如果距离上次脉冲小于 50 毫秒就忽略。或者用硬件施密特触发器整形。另外micros()在中断里可能溢出虽然 70 分钟才溢出一次但长时间骑行要注意。4.4 现象BLE 连接几秒后自动断开原因可能是广播间隔太长、连接参数不匹配或者供电不足。ESP32 在 BLE 射频工作时电流会冲到 100mA 以上如果 USB 线质量差或电源纹波大电压跌落会导致重启。解决换一根短而粗的 USB 线或者在电源端并一个 470uF 电解电容。连接参数方面可以在BLEDevice::setMTU和updateConnParams里把连接间隔设成 15ms 到 30ms从机延迟设 0超时设 2 秒。4.5 现象功率显示负数或异常大原因是功率字段是有符号 16 位你发了一个超过 32767 的值或者负数没处理好。FTMS 里功率单位是瓦特sint16范围 -32768 到 32767。如果你用uint16_t存功率超过 32767 就会变成负数。解决用int16_t存功率并且在发送前做限幅比如限制在 0 到 2000 瓦之间。另外有些 App 会把负功率当成无效值忽略所以尽量不要发负值。5. 进阶用 FTMS 的 Training Status 和室内骑行数据做闭环验证5.1 把 Training Status 特征用起来让 App 知道你在“骑行中”FTMS 除了 Indoor Bike Data 和 Control Point还有一个 Training Status 特征UUID0x2AD3用来上报当前训练状态比如“空闲”“热身”“骑行中”“冷却”。很多 App 会根据这个状态决定是否开始记录。你可以用一个简单的状态机上电后是0x00Idle收到 Start 指令后切到0x01Warm Up检测到踏频大于 0 后切到0x02In Progress收到 Stop 后切到0x03Cool Down最后回到 Idle。Training Status 的数据格式是第一个字节是状态后面可以跟训练类型、目标等但最简单的做法是只发一个字节。void updateTrainingStatus(uint8_t status) { uint8_t data[1] {status}; bleTrainingStatus.setValue(data, 1); bleTrainingStatus.notify(); }参数说明status取值0x00空闲、0x01热身、0x02进行中、0x03冷却、0x04完成。注意这个特征是可选的但加上之后 App 的兼容性会更好。如果你发现某个 App 连上后不显示数据先检查它是否在等 Training Status。5.2 用 nRF Connect 做逐字节验证比盲猜快十倍联调阶段最怕“黑匣子”——App 说没数据你不知道是协议错了还是硬件坏了。我一般会先用 nRF Connect 连上设备展开 FTMS 服务逐个读特征值。Indoor Bike Data 如果是 Notify就点“订阅”然后看原始字节。对照 FTMS 规范手动解析前两个字节是 Flags后面按顺序排。如果速度字段是0x09 0x0C小端就是0x0C09 3081单位 0.01 km/h就是 30.81 km/h。如果读出来是0x0C 0x09那就是大端说明你发反了。Control Point 可以手动写0x00请求控制权看有没有 Indicate 回来。这个方法能快速定位是协议层还是应用层的问题。5.3 一个我踩过的坑别在 BLE 回调里做耗时操作早期我把阻力控制直接写在 Control Point 的onWrite回调里结果 App 发一条设置阻力的指令回调里驱动舵机转动 200 毫秒期间 BLE 协议栈被阻塞Indicate 响应发不出去App 直接超时断开。血泪经验BLE 回调里只做解析和标志位设置实际硬件动作放到主循环里用状态机去执行。比如回调里把targetResistance存下来主循环检测到变化再去驱动舵机。这样 BLE 响应能在几毫秒内发出App 不会超时。5.4 验证闭环用手机 App 骑 5 分钟看数据是否连续最后一步验证不是看单个字节而是实际骑 5 分钟观察 App 上的速度、踏频、功率曲线是否连续、有没有跳变。如果踏频在 60 到 90 之间波动速度在 20 到 30 km/h 之间功率在 100 到 200W 之间说明数据链路是通的。如果中间出现断崖式归零检查是不是脉冲丢失或 BLE 断连。我习惯在代码里加一个计数器每发一次 Indoor Bike Data 就加一串口打印出来看 5 分钟发了多少次。如果频率稳定在 1Hz 到 4Hz说明 Notify 正常。这个习惯帮我省了很多“后悔药”——与其事后猜不如一开始就把可观测性做进去。希望帮到你。本文还有配套的精品资源点击获取