简介:这份资源面向嵌入式开发与室内骑行训练爱好者,提供一套基于Arduino与ESP32的BLE FTMS室内自行车健身机实现方案,用两块ESP32分别模拟功率计与智能骑行台,解决商用骑行台价格高昂、协议封闭的问题。压缩包共30个文件,约1.24MB,包含ino主程序与h头文件、Python配置脚本、md说明文档,以及dxf、stl、f3d等结构件图纸和brd、sch电路设计文件,覆盖固件、电路与机械装配的完整链路。目前已有763人学习下载。读者可获得曲柄功率与踏频采集、BLE数据转发、ERG与模拟模式切换、12864屏幕交互、SD卡阻力曲线加载等核心代码,还能借助图形化配置器定义模拟等级到阻力的映射函数,并参考Fitness Machine Service规范深入理解FTMS协议,适合具备C++与ESP32基础、希望自建训练器或研究BLE健身设备协议的开发者。
1. 从一台“哑巴”健身车到能被 App 读懂的 FTMS 设备
家里那台室内自行车,仪表盘只会显示速度和“卡路里”,数据出不去也进不来。想让它跟 Zwift、Kinomap 这类训练软件联动,最省事的路径不是拆机换主板,而是让一块 Arduino 扮演 BLE 外设,把健身车伪装成标准 FTMS(Fitness Machine Service)设备。ble-ftms 这个方向解决的正是这件事:用 Arduino 的 BLE 能力,把室内自行车的心率、踏频、功率、速度按 GATT 特征值广播出去,让手机或平板上的训练 App 直接识别成“智能骑行台”。
它适合两类人:一是手里有台老健身车、想低成本接入互动训练软件的折腾型玩家;二是做 IoT 或 BLE 外设原型的嵌入式开发者,需要一个真实可跑的 GATT 服务样例。核心难点不在写代码,而在把 FTMS 的字段语义、数据格式和连接时序对齐——字段错一位,App 就只显示“已连接”却读不到任何数据。下面按“协议怎么立住 → 硬件怎么搭 → 代码怎么写 → 坑在哪 → 怎么验证”的顺序拆开讲。
2. FTMS 协议拆解:室内自行车到底要暴露哪些特征值
2.1 为什么是 FTMS,而不是自定义 GATT 服务
BLE 外设和 App 之间要通信,最直接的做法是自定义一个 128 位 UUID 的服务,自己定字段。但训练软件不会认你的私有协议,它只认 Bluetooth SIG 定义的标准服务。FTMS 就是 SIG 为健身设备定义的标准服务,UUID 是0x1826。室内自行车在 FTMS 里对应的是 Fitness Machine Feature 里的 Indoor Bike 类别。
选 FTMS 的核心理由是互操作性:只要你的设备正确实现了 FTMS 规定的几个必选特征值,任何支持 FTMS 的 App 都能发现并订阅它,不需要为每个 App 单独适配。代价是字段格式被标准锁死,不能随意增减,必须按规范填。
FTMS 里和室内自行车相关的特征值主要有这几个:
| 特征值 | UUID | 作用 | 室内自行车是否必需 |
|---|---|---|---|
| Fitness Machine Feature | 0x2ACC | 声明设备支持哪些能力 | 必需 |
| Indoor Bike Data | 0x2AD2 | 广播速度、踏频、功率等实时数据 | 必需 |
| Fitness Machine Control Point | 0x2AD9 | App 下发控制指令(如设置目标) | 可选 |
| Fitness Machine Status | 0x2ADA | 设备上报状态变化 | 可选 |
对最小可用方案,先把 Feature 和 Indoor Bike Data 做对,Control Point 可以后补。
2.2 Indoor Bike Data 的位标志:一个字段决定 App 读不读得到
Indoor Bike Data 不是固定长度结构,它开头有一个 16 位的 Flags 字段,每一位表示“后面的数据里有没有这一项”。App 解析时先读 Flags,再按位去取对应字段。这是最容易翻车的地方:Flags 和实际数据必须严格对应,多声明一位少写一段数据,解析就会错位。
室内自行车常用的位定义(按规范顺序):
- bit 0:More Data,通常置 0
- bit 1:Average Speed 是否存在
- bit 2:Instantaneous Speed 是否存在
- bit 3:Average Cadence 是否存在
- bit 4:Instantaneous Cadence 是否存在
- bit 5:Total Distance 是否存在
- bit 6:Resistance Level 是否存在
- bit 7:Instantaneous Power 是否存在
- bit 8:Average Power 是否存在
- bit 9:Expended Energy 是否存在
- bit 10:Heart Rate 是否存在
一个常见的最小组合是:Instantaneous Speed(bit 2)+ Instantaneous Cadence(bit 4)+ Instantaneous Power(bit 7)。对应 Flags 值就是0x0004 | 0x0010 | 0x0080 = 0x0094。数据段依次是:速度(uint16,单位 0.01 km/h)、踏频(uint16,单位 0.5 rpm)、功率(sint16,单位 W)。
注意:速度字段单位是 0.01 km/h,不是 0.1,也不是 m/s。踏频单位是 0.5 rpm,所以 90 rpm 要写 180。单位错一位,App 显示的速度会差 10 倍。
2.3 数据打包:小端序和缩放系数
BLE 的 GATT 特征值默认按小端序(Little Endian)传输。Arduino 上如果直接 memcpy 结构体,要注意字节序。更稳妥的做法是手动按字节写入缓冲区,每个字段先做单位换算再取整。
以速度 25.00 km/h、踏频 90 rpm、功率 200 W 为例:
- 速度:25.00 / 0.01 = 2500 →
0x09C4 - 踏频:90 / 0.5 = 180 →
0x00B4 - 功率:200 →
0x00C8
打包后字节序列(小端):94 00 C4 09 B4 00 C8 00。前两字节是 Flags,后面依次是三个字段。这个字节序列就是 notify 出去的内容。
3. 硬件选型与接线:哪块 Arduino 能扛住 BLE 外设
3.1 板子选择:原生 BLE 还是外挂模块
不是所有 Arduino 都能做 BLE 外设。UNO、Nano 这类经典 AVR 板子没有 BLE 射频,必须外挂模块。常见路线有三条:
- Arduino Nano 33 BLE / Nano 33 IoT:板载 BLE,用 ArduinoBLE 库,开发最顺,适合原型。
- ESP32 系列:BLE 和 WiFi 都有,用 NimBLE 或 ESP32 BLE Arduino 库,成本低,但库的 API 和 ArduinoBLE 不同。
- 经典 AVR + 外挂 BLE 模块:模块通常走串口 AT 指令,灵活度低,做标准 GATT 服务很别扭,不推荐。
如果目标是快速跑通 FTMS,优先选 Nano 33 BLE 或 ESP32。前者和 ArduinoBLE 库配合最省心,后者生态大、资料多。
3.2 传感器接入:踏频和功率从哪来
FTMS 只是“广播层”,数据得有来源。室内自行车上能拿到的原始信号通常有几种:
- 霍尔传感器 + 磁铁:装在曲柄或飞轮旁,每转一圈出一个脉冲,用来算踏频。
- 飞轮光电传感器:测飞轮转速,结合传动比换算踏频和速度。
- 功率计:如果车自带功率计,直接读;没有的话,用飞轮转速和阻力档位估算,精度有限但能用。
接线时注意电平匹配。霍尔传感器常见是开漏输出,需要上拉电阻;光电传感器输出可能是 5V,而 Nano 33 BLE 的 IO 是 3.3V,直接接会伤板子,要加电平转换或分压。
3.3 最小接线表
| 信号 | 传感器引脚 | Arduino 引脚 | 备注 |
|---|---|---|---|
| 踏频脉冲 | 霍尔输出 | D2 | 接中断,上升沿计数 |
| 飞轮脉冲 | 光电输出 | D3 | 接中断,算速度 |
| 电源 | VCC | 3.3V | 确认传感器工作电压 |
| 地 | GND | GND | 共地 |
如果传感器是 5V 供电,信号线进 Arduino 前必须分压到 3.3V 以内。
4. 用 ArduinoBLE 写出可被 App 发现的 FTMS 外设
4.1 初始化服务和特征值
先建 FTMS 服务,再加 Feature 和 Indoor Bike Data 两个特征值。Feature 是只读的,用来声明能力;Indoor Bike Data 要支持 notify,App 订阅后才会收到数据。
#include <ArduinoBLE.h> // FTMS 服务 UUID 0x1826 BLEService ftmsService("1826"); // Fitness Machine Feature 0x2ACC,只读 BLECharacteristic featureChar("2ACC", BLERead, 8); // Indoor Bike Data 0x2AD2,支持 notify BLECharacteristic bikeDataChar("2AD2", BLENotify, 8); void setup() { Serial.begin(115200); while (!Serial); if (!BLE.begin()) { Serial.println("BLE init failed"); while (1); } BLE.setLocalName("IndoorBike"); BLE.setAdvertisedService(ftmsService); ftmsService.addCharacteristic(featureChar); ftmsService.addCharacteristic(bikeDataChar); BLE.addService(ftmsService); // Feature 字段:声明支持踏频、功率等,具体位按规范填 uint8_t feature[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; featureChar.writeValue(feature, 8); BLE.advertise(); Serial.println("FTMS advertising"); }逻辑说明:BLE.begin()初始化射频,失败就直接停住,避免后面操作空对象。setAdvertisedService把 FTMS 服务放进广播包,App 扫描时才能按服务 UUID 过滤到设备。Feature 特征值先写 8 字节零值占位,实际项目里要按支持的字段置位。
参数说明:BLERead表示该特征值只允许读;BLENotify表示允许订阅通知。缓冲区长度 8 字节是 Indoor Bike Data 常见的最小长度,字段多了要相应加长。
4.2 按位打包 Indoor Bike Data 并 notify
主循环里定时采样传感器,换算成 FTMS 单位,打包后 notify。下面是一个 1 Hz 更新的骨架:
unsigned long lastNotify = 0; void loop() { BLE.poll(); // 处理 BLE 事件,必须持续调用 if (millis() - lastNotify >= 1000) { lastNotify = millis(); // 假设从传感器算出的原始值 float speedKmh = 25.0; // km/h float cadenceRpm = 90.0; // rpm int16_t powerW = 200; // W uint8_t buf[8]; // Flags: bit2 速度, bit4 踏频, bit7 功率 => 0x0094 uint16_t flags = 0x0094; buf[0] = flags & 0xFF; buf[1] = (flags >> 8) & 0xFF; // 速度单位 0.01 km/h uint16_t speedRaw = (uint16_t)(speedKmh / 0.01); buf[2] = speedRaw & 0xFF; buf[3] = (speedRaw >> 8) & 0xFF; // 踏频单位 0.5 rpm uint16_t cadenceRaw = (uint16_t)(cadenceRpm / 0.5); buf[4] = cadenceRaw & 0xFF; buf[5] = (cadenceRaw >> 8) & 0xFF; // 功率单位 W,sint16 buf[6] = powerW & 0xFF; buf[7] = (powerW >> 8) & 0xFF; bikeDataChar.writeValue(buf, 8); } }逻辑说明:BLE.poll()必须每轮循环调用,否则 BLE 协议栈事件不会被处理,表现为能广播但连不上或连上后很快断开。Flags 用0x0094对应速度、踏频、功率三项,和后面数据段一一对应。
参数说明:速度换算系数0.01,踏频0.5,这两个是 FTMS 规范固定值,不能改。功率是 sint16,支持负值(有些设备用负值表示制动),室内自行车一般用正值。
4.3 连接参数与广播间隔
广播间隔影响被发现的速度和功耗。ArduinoBLE 默认间隔偏保守,如果 App 扫描时半天找不到设备,可以调短。连接后的连接间隔(Connection Interval)由中心设备决定,外设只能建议。训练软件通常要求较短的连接间隔来保证数据实时性,如果 App 端支持,可以在连接后请求更短的间隔。
提示:调试阶段先把广播名设成固定值,方便在 nRF Connect 里按名字找设备,确认服务和特征值都正确后再改回正式名称。
5. 避坑与排查:FTMS 外设连得上却读不到数据的 5 个原因
5.1 现象:App 显示已连接,但速度踏频一直是 0
原因通常是 Flags 和数据段不匹配。比如 Flags 声明了速度(bit 2),但数据段第一个字段写的是踏频,App 按 Flags 去取速度,取到的是踏频的字节,解析出来就是乱值或 0。
解决:把 Flags 的每一位和后面数据段的顺序列成表,逐位核对。最稳妥的写法是先确定要发哪几项,算出 Flags,再按规范顺序依次写数据,不要凭记忆排顺序。
5.2 现象:nRF Connect 里能看到服务,但 Indoor Bike Data 是灰的不能订阅
原因通常是特征值的属性没设对。Indoor Bike Data 必须带 Notify 属性,如果只写了BLERead,App 就无法订阅。另外有些 App 要求特征值同时带 Read 和 Notify,只给 Notify 也可能被过滤。
解决:把bikeDataChar的属性改成BLENotify | BLERead,并在初始化时先写一个合法值,保证读的时候不报错。
5.3 现象:数据跳变严重,速度忽高忽低
原因是采样周期和传感器脉冲计数没做平滑。霍尔传感器每圈一个脉冲,低速时一圈间隔很长,直接按瞬时周期算速度会剧烈跳动。
解决:用滑动窗口或一阶滤波。常见做法是维护最近 N 个脉冲的时间戳,算平均周期;或者对输出值做低通滤波,让变化平缓。滤波系数根据骑行场景调,太强会迟钝,太弱会抖。
5.4 现象:连上一段时间后自动断开
原因可能是BLE.poll()被阻塞。如果 loop 里有delay()或长时间运算,BLE 协议栈得不到及时处理,中心设备会认为外设失联。
解决:去掉长delay(),把耗时操作拆成状态机分步执行。采样和 BLE 处理放在同一循环里,保证BLE.poll()的调用频率。
5.5 现象:换一台手机就搜不到设备
原因是广播包内容或名称不兼容。有些手机对广播包长度敏感,服务 UUID 加名称太长会截断,导致按服务过滤时匹配不到。
解决:精简广播内容,只放必要的服务 UUID;名称尽量短。如果还不行,把服务 UUID 放进扫描响应包(scan response)而不是广播包,给广播包留空间。
6. 验证与进阶:用 nRF Connect 逐字段核对,再谈 Control Point
6.1 用 nRF Connect 做最小验证
写代码之前,先用 nRF Connect 这类通用 BLE 调试 App 确认设备行为。步骤是:扫描 → 连接 → 展开 FTMS 服务 → 读 Feature → 订阅 Indoor Bike Data → 看 notify 的原始字节。把原始字节和你的打包逻辑对照,能快速定位是打包错还是 App 解析错。
一个实用的核对方法:在代码里固定发一组已知值(比如速度 25.00、踏频 90、功率 200),然后在 nRF Connect 里看收到的十六进制是不是94 00 C4 09 B4 00 C8 00。对不上就查换算和字节序。
6.2 加上 Control Point 让 App 能反向控制
最小方案只做广播,App 只能读不能控。要支持 App 设置目标阻力或目标功率,需要实现 Fitness Machine Control Point(0x2AD9)。它是写+指示(Write + Indicate)特征值,App 写入操作码,设备执行后通过 Indicate 回响应。
常见操作码有:Request Control(0x00)、Set Target Resistance Level(0x04)、Start(0x07)、Stop(0x08)。实现时要维护一个状态机,记录当前是否已被 App 取得控制权,没取得控制权就拒绝执行控制指令。
// Control Point 响应示例:操作码 + 结果码 // 结果码 0x01 表示成功,0x02 表示不支持,0x03 表示参数无效 uint8_t resp[3] = {0x80, 0x00, 0x01}; // 对 Request Control 的成功响应 controlPointChar.writeValue(resp, 3);逻辑说明:响应第一个字节是请求操作码加 0x80,表示这是对应请求的响应;第二个字节是请求参数(Request Control 没有参数,填 0);第三个字节是结果码。App 收到 Indicate 后才知道指令是否被接受。
6.3 数据精度和上报频率的取舍
上报频率越高,App 显示越流畅,但 BLE 带宽和功耗也越高。室内自行车场景下,1 Hz 到 4 Hz 是常见区间。踏频和速度用 1 Hz 足够,功率如果要做平滑曲线可以到 2 Hz 到 4 Hz。再高意义不大,因为人腿的输出变化没那么快,反而增加丢包风险。
精度方面,速度保留到 0.01 km/h、踏频到 0.5 rpm 是规范给的粒度,实际传感器精度往往达不到,没必要在换算上过度纠结,把滤波做好比抠小数位更有用。
我自己踩得最深的一次,是 Flags 里多声明了一位 Average Speed,但数据段没补对应字段,结果 App 把后面的踏频字节当成平均速度解析,显示出一个离谱的数值,排查了半天才回到 Flags 表上逐位对。从那以后我养成了一个习惯:每次改 Flags,先在纸上把位和数据段的对应关系画出来,再动代码。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取