简介:一套基于Arduino与双ESP32的BLE室内自行车健身机开源方案,面向嵌入式开发者、骑行爱好者以及体育器材DIY玩家,旨在用较低成本模拟专业骑行台与功率计的核心功能。方案拆分为测量端与模拟端:一个ESP32采集曲柄力量与踏频,另一个ESP32接收数据、模拟训练器并转发,同时控制电阻螺丝与12864屏幕;用户可在ERG模式调节阻力、在模拟模式切换档位,或从SD卡加载预设阻力曲线。资源共30个文件、压缩包约1.24MB,包含2份Arduino固件代码、1个Python配置器、1份说明文档,以及Eagle电路设计、Fusion 360三维模型、3D打印STL与DXF加工图纸,覆盖电子、结构、控制与配置工具,可直接复刻或二次开发。配套文档还介绍了“健身机器服务”规范,便于理解BLE FTMS广播数据格式;目前已有758人学习下载,适合希望深入室内骑行设备通信协议并快速搭建软硬件原型的开发者。 把一台踩起来咯吱作响的老式室内骑行台变成能被 Zwift、运动 App 和手机直接识别的“智能健身机”,这种事情听起来像改装达人才能干,但用 Arduino 加一个霍尔传感器就能做到。我最近刚把这个项目从想法跑到能稳定连接 Zwift 出数据,核心就是围绕 ble-ftms 这套 BLE 标准做开发。这篇文章是整个实现过程的完整复盘,包括 BLE 协议里那些容易把人绕晕的概念、硬件怎么选、转速怎么算、数据包怎么组,以及测试过程中我踩过的几个大坑,给准备自己动手做室内骑行数据方案的朋友做个参考。
如果你手头有一台没有蓝牙数据输出的老款骑行台,或者只是享受把代码和机械件拼在一起的乐趣,这套方案能让你用很少的硬件成本换到完整的室内训练数据链路。整篇不讨论把数据接到大屏软件之外的商业玩法,只讲从传感器到 GATT 数据上报这条最务实的主线。
1. 项目概述:给骑行台加“嘴”让它说话
1.1 骑行台加装BLE到底解决了什么
室内骑行训练最大的痛点不是汗流浃背,而是练完了你不知道自己刚才到底踩出了什么数据。速度、踏频、距离、功率,这些是衡量训练效果的基本指标,但很多老款室内自行车上的机械码表只能显示一个大概的时速,根本没有办法把数据给手机 App 或者 Zwift 这类虚拟骑行软件用。
加装 BLE 模块之后,骑行台的数据就可以变成标准化信号。手机上的极核 BLE 助手、Zwift、Peloton、甚至自己写的调试工具,都能把它当成一台真正的智能健身器械来用。你不需要为了一个踏频功能去换一台几千块的智能骑行台,只需要在原有机械结构上加一个传感器,再加一块几块钱的 Arduino 兼容开发板。
另外还有个很实际的应用场景:很多骑友在室内训练时喜欢用手机上的训练计划,或者边骑边在投屏上看实时数据。没有蓝牙数据接口,这些 App 就等于瞎了。BLE-FTMS 方案能让你把一台普通阻力骑行台变成“标准协议设备”,几乎所有支持 Fitness Machine Service 的客户端都可以直接识别。
1.2 这套方案的选型逻辑
先说主控。项目标题里写的是 Arduino,但我的建议是尽量选带 BLE 模块的开发板,最省事的是 ESP32。ESP32 的 Arduino 支持已经非常成熟,自带双模蓝牙模块,功耗也能接受,价格在十几块钱以内,性能跑一个小小的 FTMS 外设绰绰有余。相比之下,Arduino Uno 或 Nano 本身不带蓝牙,需要外挂串口透传模块,这会额外引入波特率配置、AT 指令、模块配对等一堆问题,不推荐新手走这条路。
再说传感器。室内骑行台的转动件一般就两个地方可以测:飞轮和曲柄。用霍尔传感器加磁铁的方式最简单,不接触、无磨损,成本不到五块钱。A3144 霍尔开关模块直接输出数字电平,接一个 GPIO 就能检测磁铁经过的信号。
关于为什么用 FTMS 标准而不用自定义 BLE 协议,后面会专门展开。简单说,自定义协议意味着手机端和骑行软件端都得跟着造轮子,数据格式每次都要对齐,而 FTMS 是蓝牙技术联盟定义好的健身器械服务,UUID 固定、字段顺序固定,客户端天然支持,你只需要做“数据上报”这一件事。
2. BLE-FTMS协议核心:把数据翻译成App听得懂的语言
2.1 BLE基础概念:广播、连接、MTU、绑定
做这个项目之前,我先把 BLE 的几个基础概念理清了一遍,如果不搞清楚,后面调试起来会很茫然。
BLE 和经典蓝牙 BR/EDR 的区别,简单概括就是:经典蓝牙像打电话,建立连接后持续占线,适合音频这类需要大吞吐的场景;BLE 像发微信消息,连接后通过不定期的事件交换数据,功耗低,特别适合传感器这类低频短数据的传输。FTMS 走的就是 BLE 这条路线。
BLE 外设的工作流程大体是:先广播,然后等待中心设备扫描到并发起连接,连接建立后再通过 GATT 服务交换数据。广播阶段有几个概念容易混,比如“广播类型”。如果你只是想让手机在扫描列表里看到设备,用普通可连接广播就行;如果想让 App 自动识别设备类型,可以在广播数据里加入服务 UUID。很多骑行软件就是靠广播包里的 0x1826 这个 Fitness Machine Service UUID 来确定设备类型的,这个细节在第三章代码部分展开讲。
MTU 是另一个容易踩坑的概念。BLE 默认情况下单包数据最多 23 字节,其中包含 3 字节蓝牙协议头,留给应用层的只有 20 字节。如果你的数据包里字段很多,超过 20 字节,就需要 MTU 协商。MTU 协商是由连接发起方(手机 App)主动请求的,Arduino 作为外设只能接受请求,不能主动发起。我的代码里把数据字段数量控制在 20 字节以内,就是为了规避这个坑,实际测试下来也确实最稳定。
绑定(Bond)在调试时也可能出现。部分 App 连接设备后要求执行配对绑定,这时候如果你在 Arduino 端没有处理配对回调,连接就会卡在等待配对状态。后面我会专门讲这个问题的处理办法。
2.2 FTMS服务结构和Indoor Bike Data数据格式
FTMS 是 BLE GATT 服务里专门为健身器械定义的一类服务,服务 UUID 是 0x1826。在这个服务下面,不同的特征值负责不同功能,对我们做室内自行车来说,最重要的特征值就是 Indoor Bike Data,UUID 是 0x2AD2。
这个特征值的数据是周期性上报的,上报格式有严格规定。最常用的字段顺序如下:
| 字段 | 位数 | 单位 | 说明 |
|---|---|---|---|
| Flags | 2字节 | - | 用每一位标记后面哪些字段存在 |
| Instantaneous Speed | 2字节 | km/h | 速度值,实际数值乘以100存储 |
| Instantaneous Cadence | 2字节 | rpm | 曲柄当前踏频 |
| Total Distance | 4字节 | m | 累计距离 |
| Instantaneous Power | 2字节 | W | 当前功率 |
| Elapsed Time | 2字节 | 秒 | 设备启动后的累计时间 |
Flags 的前面几位决定后面字段是否出现在数据包里。比如我们要上报速度和踏频,Flags 就置位第0位和第1位,数据包里就按顺序放速度、踏频两个字段。这样设计的好处是省流量,不是必要的字段完全可以不填充,客户端也会根据 Flags 自动解析。
如果你的骑行台有磁阻或风阻结构,还可以通过功率模型估算瞬时功率,然后把第3位置位,上报功率字段。不过这个模型依赖阻力标定,做精准了比较复杂,MVP 阶段可以先不上报功率字段,等基础链路稳定后再加。
2.3 为什么选择标准FTMS而不是自定义协议
在做这个项目时我考虑过直接把传感器数据通过自定义 BLE Characteristic 上报,手机端再写个 App 接收,当时觉得这样自由度更高。后来发现纯粹是给自己找麻烦。
自定义协议的问题在于每一个使用环节都需要适配。你用 Zwift 测试,Zwift 不认识你的自定义 UUID,根本不会把你的设备识别成自行车;你想换一个骑行 App,另一个 App 也不认识,所有逻辑又得重写。而 FTMS 是现成的“通用语言”,只要你的服务 UUID 是 0x1826,Indoor Bike Data 特征的数据格式符合规范,主流虚拟健身软件就能秒认设备。
而且 FTMS 的特征结构是固定的,开发时反而省心。你不需要自己设计“第几个字节是什么数据”这样的协议,照着官方文档顺序打包就行。调试的时候,随便找一个支持 FTMS 的调试工具就能验证数据是否正确。用标准协议,相当于让全球开发者一起帮你抽象好了兼容层。
3. 硬件选型与Arduino环境搭建
3.1 主控、传感器与供电方案
硬件清单非常简单,我把它们列全:
- ESP32 开发板一块(我用的是 WROOM-32 经典款,任何 30 引脚的 ESP32 板都行)
- A3144 霍尔开关模块一个
- 直径 3mm 到 5mm 的钕磁铁几颗
- 杜邦线若干
- 移动电源或者 USB 电源适配器(骑行台附近需要供电)
霍尔传感器的安装位置很关键。我是把磁铁用强力胶固定在飞轮边缘,霍尔模块固定在骑行台支架上,让磁铁每次转到霍尔元件正面时能给一个低电平脉冲。安装时要注意磁铁与霍尔元件的间距,一般控制在 5mm 到 10mm 之间会比较稳定,太远信号丢,太近会碰到。
测踏频的话,可以把磁铁固定在曲柄臂上,霍尔模块固定在车架上。这个位置测出的脉冲频率就是踏频本身(rpm),后续计算会简单很多。我的代码同时兼容这两种接法,区别只在计算环节,下面在核心实现部分说明。
供电方面,ESP32 的电流峰值接近 500mA,普通 USB 口完全能带起来。室内骑行台一般固定在室内,直接插一个 USB 充电头就能长期供电。如果想做到完全无线,可以用一块 18650 锂电池接一个 AMS1117-3.3 稳压板给 ESP32 供电,但要注意电池容量,我实测一颗 2000mAh 的电池能用七八个小时。
3.2 Arduino IDE配置与库目录管理的坑
软件环境方面,我用的还是老牌的 Arduino IDE,版本 2.x 和 1.8.x 都行。安装 ESP32 开发板支持有两种方法:一种是在“开发板管理器”里搜索 esp32 by Espressif,另一种是离线安装包。离线安装包的好处是可以绕过国内下载慢的问题,直接解压到 Arduino 的 hardware 目录。
库的管理有个细节值得说:如果你用 Windows,Arduino 默认的库目录在“文档/Arduino/libraries”下面,所有第三方库都装在这里。如果你像我一样喜欢把工程目录放到别的盘,可以在“文件 -> 首选项”里修改“项目文件位置”,但库目录和项目文件位置是两个独立概念,别弄混。想让库目录也换位置,可以通过设置环境变量或者修改 Arduino15 相关的配置文件来实现,但对普通用户来说,最稳妥的方案是保持默认库目录,项目目录随意改就好了。
ESP32 的 BLE 功能不需要额外安装第三方库,官方在 esp32 core 里自带了 BLEDevice 库,直接用即可。如果你用的是 Arduino Uno 加外挂 BLE 模块,那就要根据模块型号找对应库和 AT 指令集了,复杂度会明显升高。
最后是我实际遇到的一个小坑:Arduino IDE 2.x 在首次编译 ESP32 工程时,会从服务器拉取 esp32 工具链,下载时间可能特别长。解决办法是提前下载好 esp32 离线包手动安装,或者在等它下载的时候耐心多试几次,中间一旦出现“Json invalid”多数是网络问题,重试即可,别乱改配置。
4. 核心实现:转速采集到FTMS数据上报
4.1 霍尔传感器转速采集与计算
代码主要分三部分:转速数据采集、BLE 服务创建、数据打包上报。我先说数据采集。
霍尔传感器接在 ESP32 的 GPIO4 上,用中断方式检测磁铁经过的信号。磁铁每扫过一次霍尔元件,就触发一次中断,我在中断服务函数里记录相邻两次脉冲的时间差,用来计算瞬时转速。
const int hallPin = 4; volatile uint32_t lastHallMicros = 0; volatile uint32_t hallIntervalMicros = 0; void IRAM_ATTR hallISR() { uint32_t now = micros(); uint32_t delta = now - lastHallMicros; if (delta > 2000 && delta < 2000000) { hallIntervalMicros = delta; } lastHallMicros = now; } void setup() { pinMode(hallPin, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(hallPin), hallISR, FALLING); }代码里加了两个时间判断:间隔太短(小于2毫秒)的脉冲丢弃,这是为了滤掉霍尔模块在磁铁刚靠近时可能产生的接触抖动;间隔太长(大于2秒)的脉冲也丢弃,防止静止状态下上一次的数据一直占用。
主循环里计算转速时,把相邻脉冲间隔换算成 RPM。如果只有一个磁铁,一分钟内的转数就是每分钟脉冲数:
float intervalSec = hallIntervalMicros / 1000000.0; float flywheelRpm = 60.0 / intervalSec;如果飞轮上装了多个磁铁,要除以磁铁数量。这个 RPM 是飞轮转数。如果传感器装在曲柄上,这个值就是踏频 Cadence 本身。我代码里用了一个宏来判断传感器安装位置:
#define SENSOR_ON_FLYWHEEL 1 #define MAGNET_COUNT 24.2 构建GATT服务与特征
BLE 部分直接用 ESP32 的 BLEDevice 库创建服务。服务 UUID 填 0x1826,特征 UUID 填 0x2AD2,属性设置为可通知(NOTIFY),这样客户端通过订阅通知就能持续收到数据。
BLEServer *pServer = BLEDevice::createServer(); BLEService *pFtmsService = pServer->createService(BLEUUID("0x1826")); BLECharacteristic *pIndoorBikeData = pFtmsService->createCharacteristic( BLEUUID("0x2AD2"), BLECharacteristic::PROPERTY_NOTIFY );还需要注册一个特征回调,在客户端订阅/退订通知的时候记录状态。很多新手漏掉这个回调也没报错,但调试进度判断会麻烦一些。
class BikeDataCallbacks : public BLECharacteristicCallbacks { void onSubscribe(BLECharacteristic *pCharacteristic, uint16_t cccd) override { if (cccd & 0x0001) { Serial.println("client subscribed"); } } }; pIndoorBikeData->setCallbacks(new BikeDataCallbacks());广播配置是容易忽略的重点。为了让 Zwift 和手机 App 自动识别设备,广播包里必须加入 0x1826 这个服务 UUID。这样扫码的时候,客户端会直接看到这是一个“Fitness Machine”设备,而不是一堆未知设备里的某一个。
BLEAdvertising *pAdv = pServer->getAdvertising(); pAdv->addServiceUUID(BLEUUID("0x1826")); pAdv->start();4.3 数据打包与通知上报
每次上报数据时,先组装 FTMS 标准数据包,再用 notify 推送给客户端。因为我只上报速度和踏频两个字段,数据包只有 6 字节,完全在默认 20 字节的 MTU 范围内,不需要处理 MTU 协商。
const uint16_t FLAG_SPEED = 0x0001; const uint16_t FLAG_CADENCE = 0x0002; void sendIndoorBikeData(float speedKmh, float cadenceRpm) { uint8_t data[6]; int idx = 0; uint16_t flags = FLAG_SPEED | FLAG_CADENCE; data[idx++] = flags & 0xFF; data[idx++] = (flags >> 8) & 0xFF; uint16_t speedVal = (uint16_t)(speedKmh * 100.0f); data[idx++] = speedVal & 0xFF; data[idx++] = (speedVal >> 8) & 0xFF; uint16_t cadenceVal = (uint16_t)cadenceRpm; data[idx++] = cadenceVal & 0xFF; data[idx++] = (cadenceVal >> 8) & 0xFF; pIndoorBikeData->setValue(data, idx); pIndoorBikeData->notify(); }上报频率我设置在每 500 毫秒一包。这个频率对传感器数据来说足够平滑,也不会给 BLE 连接造成压力。如果上报太快,部分手机 App 反而会因为收包太多出现卡顿或者延迟。
把飞轮转速换算成速度的时候,我用的是飞轮周长乘以 RPM 再除以时间系数,这个换算依赖于飞轮的实测尺寸。我车上的飞轮周长大约是 2.1 米,换算逻辑如下:
float flywheelCircumference = 2.10f; // 单位:米 float speedKmh = flywheelRpm * flywheelCircumference * 60.0f / 1000.0f;如果传感器装在曲柄上,则不需要这一步换算,踏频 RPM 直接就填到 Cadence 字段里。
4.4 控制点与状态处理(进阶)
FTMS 服务里还有一个 Fitness Machine Control Point 特征,UUID 是 0x2AD9,用来接收客户端下发的开始/停止、设定阻力等控制指令。这个特征需要支持 WRITE 属性,并且要按协议规定返回状态码。
MVP 阶段可以先不实现控制点,因为大部分骑行软件在“仅读取数据”模式下不会强求控制点。但是如果后续你想让 Zwift 控制阻力、模拟坡度,就必须实现。控制点的消息结构较复杂,包括 Op Code、参数、操作码状态等,建议在基础数据链路稳定后再单独加,避免一次引入太多变量,出了问题不好定位。
5. 调试实录:搜不到、断连、数据跳变怎么查
5.1 App/Bike软件搜不到设备
这是遇到最多的问题。先检查广播是否真的在跑,最简单的方法是用手机上的 BLE 调试助手扫描,看有没有出现自定义名称的设备。如果扫描列表里根本没有,大概率是广播没有启动,或者开发板没有正确运行到广播那行代码。
如果已经能看到设备名,但 Zwift 不识别,那就是广播包里没有服务 UUID 0x1826,App 不知道这是健身器械。我见过很多人的代码里服务是加了,但广播配置里忘了加 UDP,结果只能手动去设备列表里找通用蓝牙设备,而 Zwift 这类软件只认 FTMS 服务,所以直接过滤掉了。
另外手机系统权限也可能导致扫描不到设备。Android 12 以上版本对蓝牙权限要求很严,需要在应用设置里打开“附近设备”权限,iOS 则需要在系统设置里允许对应 App 使用蓝牙。这个不属于设备端问题,但很常被忽略。
5.2 MTU不足导致数据截断或无法订阅
如果数据包超过 20 字节,客户端又没有发起 MTU 协商,就会出现数据被截断或者订阅通知失败的情况。我的经验是,刚开始做原型时尽量避免把全部字段都放进数据包,先只上报速度和踏频,等链路稳定后再研究扩展字段。
如果你一定要上报距离、功率、时间等完整字段,那就要在代码里监听 MTU 变化事件。ESP32 的 BLEDevice 库提供了 setMTU(int mtu) 接口,但注意这是外设端对被动的 MTU 请求的响应上限设置,真正的协商还得看客户端。连接按钮旁边通常会有日志显示当前 MTU,调试助手类的工具一般都有这个展示,可以用来确认协商结果。
5.3 数据跳变与频率问题
霍尔传感器数据跳变的原因,第一是中断抖动没有滤干净。我的代码里用相邻两次脉冲间隔最小值做过滤,即小于 2ms 的间隔直接忽略,能解决大部分脉冲抖动问题。
第二是磁铁安装方向不对。霍尔元件有感应面,磁铁的北极或南极靠近时输出极性不同。如果磁铁装反了,信号脉冲可能不稳定,表现为 RPM 数值偶尔突然飙到几千。
第三是上报频率和设备处理速度不匹配。如果主循环里做了太多串口打印或者延迟操作,会导致中断数据没有被及时读取,上报给手机的数据就忽高忽低。串口打印是调试时必要的,但记得在最终版本里关掉,至少降低打印频率。
5.4 绑定与配对异常
如果你的调试 App 连接后一直处于“正在绑定”状态,问题出在配对处理。ESP32 的 BLEDevice 库需要你主动注册安全回调,才能正确处理配对和绑定请求。一个简单的处理方式是只接受 No Bond 模式,不存储配对信息:
BLEDevice::setSecurityCallbacks(new MySecurityCallback());如果 App 强制要求 Bond,而你这边没响应,连接就会卡住。大部分健身类 App 用的是 Just Works 配对,不需要输入 PIN 码,但如果遇到强制绑定,最直接的排查办法是换一个 BLE 调试助手试试,先排除 App 本身行为导致的兼容性问题。
我目前的代码里把绑定回调设为始终接受并返回 No Bond,Zwift、Decathlon 的室内骑行 App、极核助手都试过,连接都很稳定。
最后再分享一个我在实际项目中的体会:室内自行车改装 BLE 最容易让人卡住的地方,不是硬件接线,也不是代码语法,而是对 FTMS 数据格式的理解偏差。Flags 每一位代表什么、数据按什么顺序排,这些细节一旦对齐了,剩下的工作就是按部就班的组装。做完成之后,我用 Zwift 连续骑了一小时,速度、踏频数据全程没有断流,那一刻确实体会到了动手做一个“标准化外设”的乐趣。这个项目后续还可以往功率估算、自动阻力控制、独立供电等方向扩展,如果你也正在捣鼓同类的改装,可以先按照第五部分的问题清单排查一遍,大概率能找到你卡住的点。
本文还有配套的精品资源,点击获取