☰
基于Arduino的BLE FTMS智能骑行台DIY:从协议栈到代码实战
2026/10/15 18:42:09 网站建设 项目流程

简介:这份资源面向嵌入式开发与室内骑行训练爱好者,提供一套基于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 Service(FTMS)协议栈跑在 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.01)
  • bit 1:平均速度
  • bit 2:瞬时踏频(单位 rpm,分辨率 0.5)
  • bit 3:平均踏频
  • bit 4:总距离(单位 m)
  • bit 5:阻力等级
  • bit 6:瞬时功率(单位 W)
  • bit 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_SUCCESS(0x01)、FTMS_NOT_SUPPORTED(0x02)、FTMS_INVALID_PARAMETER(0x03)。如果返回错误码,App 会显示操作失败。我见过有人回调里什么都不返回,结果 App 一直转圈等响应,这就是血泪经验。

3. 从零搭建:Arduino 端代码结构与传感器接入

3.1 硬件选型与接线:别在第一步就翻车

ble-ftms 对硬件的要求不高,但也不是随便一块 Arduino 都行。核心要求是支持 BLE。常见的方案:

  • ESP32 系列:自带 BLE,性能足够,社区支持最好,推荐 ESP32-WROOM-32 或 ESP32-C3
  • Arduino 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 Feature(0x2ACC),确认返回的字节和你setSupportedFeatures()声明的一致。然后订阅 Indoor Bike Data(0x2AD2),看通知数据是否按预期更新。最后在 Control Point(0x2AD9)上写一条指令,看设备是否正确响应。

这里有一个容易被忽略的点:FTMS 的 Control Point 是带指示(Indicate)的,不是通知(Notify)。指示需要客户端确认收到,所以响应会慢一点。如果你在代码里把 Control Point 配置成了 Notify,App 可能收不到响应。

另外,如果你想在标准字段之外加自定义数据,比如踏频平滑度或者左右平衡,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 联调」的流程,能省掉至少一半的排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询