简介这份资源面向嵌入式开发与室内骑行训练爱好者提供一套基于Arduino与ESP32的BLE FTMS室内自行车健身机完整实现方案用两块ESP32分别模拟功率计与智能骑行台解决商用设备价格高昂、协议封闭的问题。压缩包共30个文件约1.24MB包含9个dxf与5个stl结构件图纸、4个f3d三维模型、3个brd与3个sch电路设计文件、2个ino主程序、1个h头文件、1个py配置脚本及1个md说明文档覆盖从机械结构、PCB到固件的完整链路。已有763人学习下载。读者可获得曲柄功率与踏频采集、训练台数据转发、电阻螺钉位置控制、12864屏幕显示、ERG与模拟模式切换、SD卡阻力曲线加载等核心代码以及图形化定义模拟等级到阻力映射的配置器并可按FTMS健身机器服务规范深入理解BLE通信协议适合具备C与Arduino基础的中高级开发者参考复现。1. 从一块开发板到骑行台ble-ftms 到底能跑通什么如果你手头有一块支持 BLE 的 Arduino 开发板、一个霍尔传感器或者带脉冲输出的健身车飞轮却不知道怎么让它被 Zwift、Kinomap 这类骑行 App 认出来那 ble-ftms 这个项目就是冲着你来的。它用 C 把 Fitness Machine ServiceFTMS协议栈跑在 Arduino 上让一块几十块钱的开发板变成标准 BLE 室内骑行台。说白了它解决的是「我的破健身车怎么变成智能骑行台」这个问题。适合两类人一是想自己 DIY 智能骑行台的硬件爱好者二是想学 BLE GATT 服务端开发、拿 FTMS 当练手项目的嵌入式新手。你不需要懂蓝牙协议栈底层但得会烧录 Arduino、能看懂 C 类的基本结构。2. FTMS 协议栈拆解从 GATT 服务到骑行数据特征2.1 为什么是 FTMS而不是自己定义一套 BLE 服务BLE 外设和 App 之间要通信最省事的做法是自己定义一个私有 GATT 服务App 端也自己解析。但问题是你没法让 Zwift 或者 Kinomap 去适配你的私有协议。FTMS 是 Bluetooth SIG 定义的标准化服务UUID 是 0x1826主流骑行 App 都认这个。只要你的设备广播了 FTMS 服务App 就会主动来读特征值。FTMS 的核心特征值有这么几个特征值UUID方向作用Fitness Machine Feature0x2ACC读声明设备支持哪些能力Indoor Bike Data0x2AD2通知实时推送速度、踏频、功率Fitness Machine Control Point0x2AD9写/指示App 下发控制指令如设置阻力Fitness Machine Status0x2ADA通知设备状态变化上报ble-ftms 做的事情就是把这些特征值用 C 类封装好你只需要在loop()里更新传感器数据剩下的打包、通知、响应控制指令都由库来处理。常见做法是继承它提供的基类重写几个数据获取的虚函数。2.2 数据帧结构Indoor Bike Data 到底怎么打包Indoor Bike Data 这个特征值的 payload 不是固定长度的它用了一个 flags 字段来标记后面跟了哪些数据。flags 是 16 位每一位对应一个字段是否存在。比如bit 0瞬时速度是否存在单位 km/h分辨率 0.01bit 1平均速度bit 2瞬时踏频单位 rpm分辨率 0.5bit 3平均踏频bit 4总距离单位 mbit 5阻力等级bit 6瞬时功率单位 Wbit 7平均功率ble-ftms 内部会根据你启用了哪些数据字段自动拼出正确的 flags 和 payload。你不需要手动去移位操作但得知道这个结构因为调试的时候如果 App 显示的数据不对大概率是 flags 和实际数据对不上。下面是一个典型的数据更新调用假设你用的是霍尔传感器测踏频、用飞轮阻力估算功率// 在 loop() 中周期性调用建议 1Hz 或 4Hz void updateBikeData() { // 从传感器读取原始值 float crankRpm readCadence(); // 踏频单位 rpm float wheelSpeed readSpeed(); // 速度单位 km/h int16_t power estimatePower(crankRpm, wheelSpeed); // 估算功率 // 调用 ble-ftms 提供的方法更新数据 // setIndoorBikeData 会自动处理 flags 和字节序 fitnessMachine.setIndoorBikeData( wheelSpeed, // 瞬时速度 km/h crankRpm, // 瞬时踏频 rpm power, // 瞬时功率 W 0, // 平均速度暂不提供 0, // 平均踏频 0 // 总距离 ); // 通知订阅了 Indoor Bike Data 的客户端 fitnessMachine.notifyIndoorBikeData(); }这里的关键参数是速度和踏频的分辨率。FTMS 规定速度分辨率是 0.01 km/h踏频是 0.5 rpm。如果你传进去的是整数 rpm库内部会做转换但要注意精度损失。功率是 sint16单位 W范围 -32768 到 32767对骑行台来说完全够用。2.3 控制点App 怎么反过来控制你的阻力FTMS 不只是单向上报数据App 还可以通过 Fitness Machine Control Point 下发指令。最常见的指令是Set Target Resistance Level设置目标阻力等级和Set Indoor Bike Simulation Parameters设置模拟参数比如坡度、风阻。ble-ftms 的处理方式是回调。你需要注册一个回调函数当 App 下发指令时库会调用你的回调你在回调里根据指令去调整阻力电机或者电磁阻力器。// 注册控制点回调 fitnessMachine.onControlPointRequest([](uint8_t opCode, const uint8_t* params, uint16_t len) { switch (opCode) { case 0x04: { // Set Target Resistance Level // params[0..1] 是阻力等级uint16分辨率 0.1 uint16_t rawLevel params[0] | (params[1] 8); float level rawLevel / 10.0f; applyResistance(level); // 你的硬件控制函数 return FTMS_SUCCESS; } case 0x11: { // Set Indoor Bike Simulation Parameters // params 包含坡度、风阻等具体解析略 float grade parseGrade(params); applySimulation(grade); return FTMS_SUCCESS; } default: return FTMS_NOT_SUPPORTED; } });注意回调的返回值必须是 FTMS 定义的状态码比如FTMS_SUCCESS0x01、FTMS_NOT_SUPPORTED0x02、FTMS_INVALID_PARAMETER0x03。如果返回错误码App 会显示操作失败。我见过有人回调里什么都不返回结果 App 一直转圈等响应这就是血泪经验。3. 从零搭建Arduino 端代码结构与传感器接入3.1 硬件选型与接线别在第一步就翻车ble-ftms 对硬件的要求不高但也不是随便一块 Arduino 都行。核心要求是支持 BLE。常见的方案ESP32 系列自带 BLE性能足够社区支持最好推荐 ESP32-WROOM-32 或 ESP32-C3Arduino Nano 33 BLE / BLE Sense官方 BLE 支持但价格偏高其他带 BLE 模块的板子需要确认 BLE 库是否兼容传感器方面踏频和速度可以用霍尔传感器加磁铁每转一圈产生一个脉冲。功率如果没有功率计只能用速度加踏频做估算精度有限但能用。阻力控制如果是电磁阻力器通常需要 PWM 或 DAC 输出。接线示例以 ESP32 为例功能ESP32 引脚传感器/执行器踏频脉冲输入GPIO 34霍尔传感器输出速度脉冲输入GPIO 35霍尔传感器输出阻力 PWM 输出GPIO 25电磁阻力器驱动电源3.3V / GND传感器供电注意霍尔传感器输出可能是 5V 电平ESP32 的 GPIO 是 3.3V 耐压直接接会烧。常见做法是加一个电平转换模块或者用电阻分压。这个坑我踩过一块 ESP32 就这么没了。3.2 代码骨架setup 和 loop 里该放什么ble-ftms 的典型用法是创建一个FitnessMachine对象在setup()里初始化 BLE 和传感器在loop()里周期性更新数据。#include ble-ftms.h FitnessMachine fitnessMachine; // 传感器中断计数 volatile uint32_t cadencePulses 0; volatile uint32_t speedPulses 0; void IRAM_ATTR onCadencePulse() { cadencePulses; } void IRAM_ATTR onSpeedPulse() { speedPulses; } void setup() { Serial.begin(115200); // 初始化传感器引脚 pinMode(34, INPUT_PULLUP); pinMode(35, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(34), onCadencePulse, FALLING); attachInterrupt(digitalPinToInterrupt(35), onSpeedPulse, FALLING); // 初始化 BLE FTMS // 设备名称会出现在 App 的搜索列表里 fitnessMachine.begin(DIY-Bike-01); // 声明设备支持的能力 fitnessMachine.setSupportedFeatures( FTMS_FEATURE_CADENCE, FTMS_FEATURE_POWER, FTMS_FEATURE_RESISTANCE ); // 注册控制点回调 fitnessMachine.onControlPointRequest(handleControlPoint); } void loop() { static uint32_t lastUpdate 0; uint32_t now millis(); // 每 250ms 更新一次数据即 4Hz if (now - lastUpdate 250) { lastUpdate now; updateBikeData(); } // BLE 协议栈需要定期处理事件 fitnessMachine.poll(); }这里有几个关键点。第一中断服务函数要加IRAM_ATTR否则 ESP32 在 flash 操作时可能崩溃。第二fitnessMachine.poll()必须定期调用它负责处理 BLE 协议栈的事件队列不调用的话连接会断。第三数据更新频率建议 1Hz 到 4Hz太高了 BLE 带宽吃不消太低了 App 显示会卡顿。3.3 踏频和速度的计算从脉冲到 rpm 和 km/h中断里只负责计数实际计算在updateBikeData()里做。踏频的计算逻辑是每转一圈曲柄产生一个脉冲所以 rpm 脉冲数 / 时间(分钟)。但实际骑行中曲柄可能倒转或者停止需要做超时处理。float readCadence() { static uint32_t lastCalcTime 0; static uint32_t lastPulseCount 0; uint32_t now millis(); if (now - lastCalcTime 1000) { // 每秒计算一次 uint32_t pulses cadencePulses - lastPulseCount; lastPulseCount cadencePulses; lastCalcTime now; // 每秒脉冲数乘以 60 得到每分钟转数 float rpm pulses * 60.0f; // 如果超过 3 秒没有脉冲认为停止 if (pulses 0) { static uint32_t zeroStart 0; if (zeroStart 0) zeroStart now; if (now - zeroStart 3000) { return 0.0f; } } else { zeroStart 0; } return rpm; } return lastRpm; // 返回上次计算值 }速度的计算类似但需要知道轮径。假设轮径是 700c周长约 2.1 米每转一圈前进 2.1 米km/h 脉冲数 × 2.1 × 3.6 / 秒数。提示霍尔传感器的安装位置很关键。磁铁和传感器之间的间隙建议在 5mm 以内太远了脉冲会丢表现为数据跳变或者直接归零。4. 避坑与排查连不上、数据跳、控制无响应怎么查4.1 App 搜不到设备现象打开 Zwift 或 Kinomap搜索不到你的设备名称。原因通常有三个一是 BLE 广播没有正确启动二是设备名称太长或者包含特殊字符三是广播间隔太长。FTMS 要求设备在广播包里包含 0x1826 服务 UUID如果库没有自动加需要手动设置。解决先确认fitnessMachine.begin()的返回值如果是 false 说明 BLE 初始化失败。然后用手机上的 nRF Connect 这类通用 BLE 扫描工具看能不能看到设备。如果 nRF Connect 能看到但 Zwift 看不到大概率是广播包里缺 FTMS 服务 UUID。检查库的广播配置确保advertiseService(0x1826)被调用。4.2 连接后数据不更新或跳变现象App 显示已连接但速度、踏频一直是 0或者数值乱跳。原因数据不更新通常是notifyIndoorBikeData()没有被调用或者调用频率太低。数据跳变多半是传感器脉冲丢失或者中断被阻塞。解决在updateBikeData()里加串口打印确认数据确实在变化。如果串口有数据但 App 没有检查notifyIndoorBikeData()是否在setIndoorBikeData()之后调用。数据跳变的话检查中断里有没有耗时操作中断服务函数应该只做计数不做计算。另外ESP32 的 GPIO 34-39 是只输入引脚没有内部上拉如果传感器输出是开漏的需要外接上拉电阻。4.3 控制点指令无响应现象App 下发阻力设置指令设备没有反应App 显示超时。原因控制点回调没有注册或者回调返回了错误的状态码或者回调处理时间太长导致 BLE 超时。解决确认onControlPointRequest()在begin()之后、poll()之前注册。回调里不要做耗时操作比如驱动电机如果响应慢应该设置一个目标值然后让loop()去执行而不是在回调里阻塞等待。回调必须返回 FTMS 状态码返回 0 或者不返回都会导致 App 认为操作失败。4.4 连接不稳定频繁断开现象设备连上几秒或几分钟后自动断开App 提示连接丢失。原因poll()调用不及时或者 BLE 协议栈内存不足或者电源供电不足。解决确保loop()里没有delay()超过 100ms 的阻塞操作。如果用了 RTOS 任务BLE 处理任务优先级要足够高。电源方面ESP32 在 BLE 工作时峰值电流可能超过 200mA如果用 USB 供电确保 USB 口能提供足够电流劣质充电线压降太大会导致复位。4.5 功率估算偏差太大现象App 显示的功率和实际体感完全对不上要么偏高要么偏低。原因功率估算本身就是个玄学用速度和踏频估算功率误差 30% 以上很正常。另外如果速度传感器安装位置不对速度值本身就不准。解决如果有条件用带功率计的骑行台做一次对比校准拟合一个修正曲线。没有功率计的话至少确保速度传感器的轮径设置正确。另外FTMS 的功率字段是 sint16如果你传了负数或者超过 32767 的值会溢出变成奇怪的数字。5. 进阶技巧用 nRF Connect 验证 FTMS 服务与自定义数据字段调试 BLE 项目最怕的就是黑匣子——App 不显示数据你也不知道是设备没发还是 App 没解析。我的习惯是先用 nRF Connect 把 FTMS 服务从头到尾验一遍确认设备端没问题再去排查 App 端。具体做法用 nRF Connect 连接你的设备找到 0x1826 服务展开所有特征值。先读 Fitness Machine Feature0x2ACC确认返回的字节和你setSupportedFeatures()声明的一致。然后订阅 Indoor Bike Data0x2AD2看通知数据是否按预期更新。最后在 Control Point0x2AD9上写一条指令看设备是否正确响应。这里有一个容易被忽略的点FTMS 的 Control Point 是带指示Indicate的不是通知Notify。指示需要客户端确认收到所以响应会慢一点。如果你在代码里把 Control Point 配置成了 NotifyApp 可能收不到响应。另外如果你想在标准字段之外加自定义数据比如踏频平滑度或者左右平衡FTMS 允许在 Indoor Bike Data 的 flags 里设置 bit 15自定义字段存在然后在 payload 末尾追加自定义数据。但要注意App 不一定认这个自定义字段所以只适合自己写 App 解析的场景。// 启用自定义字段的示例 fitnessMachine.setIndoorBikeData( speed, cadence, power, 0, 0, 0, true, // 启用自定义字段 customData, // 自定义数据指针 customDataLen // 自定义数据长度 );最后说一个验证习惯每次改完代码不要直接连 App 测先用 nRF Connect 确认 GATT 层没问题。从那以后我每次调试 BLE 项目都强制走一遍「nRF Connect 验证 → App 联调」的流程能省掉至少一半的排查时间。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?