
简介这份资源面向嵌入式开发与室内骑行训练爱好者提供一套基于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 外设把健身车伪装成标准 FTMSFitness 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 Feature0x2ACC声明设备支持哪些能力必需Indoor Bike Data0x2AD2广播速度、踏频、功率等实时数据必需Fitness Machine Control Point0x2AD9App 下发控制指令如设置目标可选Fitness Machine Status0x2ADA设备上报状态变化可选对最小可用方案先把 Feature 和 Indoor Bike Data 做对Control Point 可以后补。2.2 Indoor Bike Data 的位标志一个字段决定 App 读不读得到Indoor Bike Data 不是固定长度结构它开头有一个 16 位的 Flags 字段每一位表示“后面的数据里有没有这一项”。App 解析时先读 Flags再按位去取对应字段。这是最容易翻车的地方Flags 和实际数据必须严格对应多声明一位少写一段数据解析就会错位。室内自行车常用的位定义按规范顺序bit 0More Data通常置 0bit 1Average Speed 是否存在bit 2Instantaneous Speed 是否存在bit 3Average Cadence 是否存在bit 4Instantaneous Cadence 是否存在bit 5Total Distance 是否存在bit 6Resistance Level 是否存在bit 7Instantaneous Power 是否存在bit 8Average Power 是否存在bit 9Expended Energy 是否存在bit 10Heart Rate 是否存在一个常见的最小组合是Instantaneous Speedbit 2 Instantaneous Cadencebit 4 Instantaneous Powerbit 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接中断算速度电源VCC3.3V确认传感器工作电压地GNDGND共地如果传感器是 5V 供电信号线进 Arduino 前必须分压到 3.3V 以内。4. 用 ArduinoBLE 写出可被 App 发现的 FTMS 外设4.1 初始化服务和特征值先建 FTMS 服务再加 Feature 和 Indoor Bike Data 两个特征值。Feature 是只读的用来声明能力Indoor Bike Data 要支持 notifyApp 订阅后才会收到数据。#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; // 功率单位 Wsint16 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 属性如果只写了BLEReadApp 就无法订阅。另外有些 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 Point6.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 Point0x2AD9。它是写指示Write Indicate特征值App 写入操作码设备执行后通过 Indicate 回响应。常见操作码有Request Control0x00、Set Target Resistance Level0x04、Start0x07、Stop0x08。实现时要维护一个状态机记录当前是否已被 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先在纸上把位和数据段的对应关系画出来再动代码。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取