news 2026/10/15 15:36:25

Arduino BLE FTMS 实战:让老健身车接入 Zwift 等训练 App

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Arduino BLE FTMS 实战:让老健身车接入 Zwift 等训练 App

简介:这份资源面向嵌入式开发与室内骑行训练爱好者,提供一套基于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 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 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接中断,算速度
电源VCC3.3V确认传感器工作电压
地GNDGND共地

如果传感器是 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,先在纸上把位和数据段的对应关系画出来,再动代码。希望这个习惯也能帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:36:12

Loop Engineering:从“提示 AI”到“设计自运转系统”

1. 引言 当我们谈论 AI 辅助开发时&#xff0c;最常见的画面是&#xff1a;开发者打开对话框&#xff0c;输入一段提示词&#xff0c;等待模型生成代码&#xff0c;复制粘贴&#xff0c;再继续下一个问题。这种“提示-响应”模式虽然高效&#xff0c;却把人类牢牢钉在循环的中心…

作者头像 李华
网站建设 2026/10/15 15:35:38

Kotlin变量声明深度解析:var、val、const val与不可变性

1. 从 var 和 val 说起&#xff1a;Kotlin 变量声明的底层逻辑1.1 为什么 Kotlin 要把变量声明拆成两个关键字刚从 Java 转到 Kotlin 的人&#xff0c;第一个卡壳的地方往往不是协程&#xff0c;也不是扩展函数&#xff0c;而是var和val这两个关键字。Java 里一个int a 1;就完…

作者头像 李华
网站建设 2026/10/15 15:35:37

Python程序员必备的Linux命令实战指南:排查日志、管理进程与稳定部署

凌晨一点半&#xff0c;线上服务日志突然刷屏&#xff0c;同事在群里连环你&#xff0c;说接口超时率肉眼可见地在涨。你一边打开浏览器控制台&#xff0c;一边手忙脚乱地在服务器上乱敲命令——如果你有过这种经历&#xff0c;你应该能理解我为什么总跟团队里的小朋友说&#…

作者头像 李华
网站建设 2026/10/15 15:35:10

STM32寄存器级开发实战:从时钟树到外设的完整教程

简介&#xff1a;这份STM32教程PPT资源面向嵌入式初学者与进阶开发者&#xff0c;围绕STM32微控制器的架构原理与实战应用展开&#xff0c;帮助读者从寄存器级操作逐步过渡到外设驱动开发。内容覆盖初识STM32、RCC时钟配置&#xff08;HSE/HSI&#xff09;、新建寄存器工程、SR…

作者头像 李华
网站建设 2026/10/15 15:34:33

JavaWeb简易购物车源代码:Servlet+JSP+MySQL实现购物车状态管理

简介&#xff1a;这套JavaWeb简易购物车源代码面向初学Web开发的开发者&#xff0c;围绕在线购物车场景&#xff0c;系统展示了Servlet与JSP分工、HTTP请求处理、Session保存购物车状态、JDBC操作MySQL等核心技术&#xff0c;可帮助读者打通从页面展示到数据持久化的完整业务流…

作者头像 李华
网站建设 2026/10/15 15:32:39

京东商品价格实时监控:从抓取到告警的完整实现

简介&#xff1a;这是一份面向电商价格监控场景的实战型工具包&#xff0c;适合对爬虫、数据采集与自动化通知感兴趣的开发者及运营人员参考。资源围绕京东商品价格实时跟踪展开&#xff0c;涵盖数据抓取、价格存储与邮件提醒等核心环节&#xff0c;可帮助读者理解价格监控从采…

作者头像 李华