简介:面向硬件创客与骑行训练爱好者的Arduino BLE室内自行车健身机项目资料包,基于双ESP32实现低成本室内训练器和功率计方案:一块ESP32测量曲柄力量与踏频,另一块接收数据并模拟训练器,同时根据计算机下发的参数调节电阻螺钉位置,支持ERG模式改阻力、模拟模式换挡,还能通过SD卡加载阻力时间表,配套图形化配置器可自定义模拟档位到电阻的映射规则。压缩包共30个文件,总大小仅1.24MB,其中dxf与stl分别用于机械加工图纸与3D打印件,f3d是Fusion 360可编辑模型,brd/sch对应Eagle电路板与原理图,ino/py/h分别承载Arduino固件、配置工具和头文件,另有README辅助上手。已有758人学习下载。资料完整呈现从结构件、电路设计到固件逻辑与配置工具的全链路实现,适合希望复刻室内骑行台或深入研究BLE Fitness Machine Service(FTMS)规范的开发者作为参考,也适合作为嵌入式课程设计的进阶范例。 自己在骑行台上练了几个月,最烦的就是手机里的训练App压根读不到数据。速度、踏频全靠眼睛看码表,心率要另外开一个App,功率更是想都别想。后来我才搞清楚,问题不是出在骑行台本身,而是出在通信协议上。大多数普通磁阻骑行台走的是私有协议,手机App比你更想知道你的数据,但人家只认蓝牙SIG定下的FTMS(Fitness Machine Service)标准。搞清楚这个之后,我干脆做了一个叫 ble-ftms 的小装置:用Arduino接收霍尔传感器的脉冲信号,算好速度、踏频、功率,再通过BLE的0x1826服务上报,任何支持FTMS的App都能直接识别,Zwift、Kinomap这些都可以。
这篇文章把整个项目的来龙去脉、硬件选型、协议细节、代码实现和调试踩坑都展开讲一遍。代码基于ESP32和NimBLE-Arduino,但我也会说明为什么nRF52840也能用,以及哪些板子是用不了的,方便不同基础的读者少走弯路。
1. 骑行台的数据为什么互不认:FTMS在解决什么问题
很多人以为只要设备带蓝牙,手机App就能自动识别,其实完全不是这么回事。蓝牙只是个管道,管道两端得说同一种语言。市面上几百块的室内健身车、磁阻骑行台,大多只有一个简单的踏频传感器或者轮速传感器,传输格式是厂商自己定的。厂商自己的App能读,第三方的Zwift根本不搭理你。
1.1 健身器材的"普通话"到底是谁定的
蓝牙SIG在几年前发布了Fitness Machine Service,服务UUID是0x1826。它把跑步机、椭圆机、划船机、室内自行车的数据格式统一了,包括瞬时速度、平均速度、瞬时踏频、功率、心率、累计距离等都有明确的字段定义和单位标准。任何一台设备只要广播了0x1826服务,App只要扫描到这个服务,就知道该怎么读取和解析数据。
这套标准之所以好用,是因为它不只规定了特征值,还规定了数据的位标志和编码格式。也就是说,不管是哪家做的骑行台,发给App的数据帧长什么样都是一样的。这就像不管你是什么牌子的电视遥控器,只要按下音量键,电视就认这个红外编码。
1.2 普通磁阻骑行台离"智能"就差一个协议转换器
普通磁阻骑行台有轮子、有磁阻、有飞轮,人在上面蹬的时候轮子会转。你只需要在车架上固定一个霍尔传感器,在轮辐上贴一颗小磁铁,就能把轮子的转动变成电脉冲。Arduino拿到脉冲间隔,算一下轮周长,速度就出来了。再把踩踏频率也算出来,组一个FTMS数据帧,通过BLE发出去,一台普通的磁阻骑行台就瞬间变成了标准智能设备。
ble-ftms这个项目本质上是做一个协议转换器:把廉价的脉冲信号翻译成FTMS标准语言。我做它的目标很明确,不追求炫技,只想要一个功能稳定的"翻译官"。
2. 硬件选型:我为什么把ESP32排在第一个
刚开始我想省事,直接用Arduino Uno加一个HM-10蓝牙模块。但深入一想,这条路走不通。Uno主控是ATmega328P,本身没有BLE协议栈,HM-10只是把串口转成BLE,而且HM-10内部已经固定好了透明的串口服务,根本没有办法注册一个0x1826的自定义GATT服务。你要是强行用透传方案,手机端就得自己写一个解析App,那项目性质就变了。
2.1 三种方案的对比
最终我在三个平台之间做了对比,按"能不能实现自定义GATT服务"和"性价比"两个维度来选。
| 方案 | BLE协议栈 | 自定义GATT服务 | 实现难度 | 价格参考 | 适合场景 |
|---|---|---|---|---|---|
| Arduino Uno + HM-10 | 模块内置,固定透传 | 不支持 | 简单但无用 | 50元左右 | 不适合本项目 |
| ESP32 DevKit | NimBLE / Bluedroid | 支持 | 中等 | 20-30元 | 首选,开发资料多 |
| nRF52840 (如ItsyBitsy) | 原生SoftDevice | 支持 | 中等 | 100-150元 | 低功耗、小体积需求 |
ESP32用的是双核240MHz的Xtensa处理器,干这点活的富余量很大。更关键的是NimBLE-Arduino库的出现,让ESP32上跑BLE服务的开发体验接近nRF52,内存占用也小很多。我实测下来,NimBLE跑一个FTMS服务加上不到10个特征的BLE连接,内存占用只有二三十KB,对ESP32来说绰绰有余。
2.2 我实际用到的硬件清单
我用的不是成品的智能骑行台,而是自制的骑行台支架加磁阻单元。硬件清单比较常规,主控ESP32 DevKit C型开发板一块,霍尔传感器选的A3144开关型霍尔,三颗磁铁贴在轮辐上均布,还有3.7V锂电池和TP4056充电模块供电。
接线方面,A3144是NPN开漏输出,工作电压5V,但输出端经过上拉后可以直接接ESP32的3.3V引脚。信号线接到GPIO4,GND共地,VCC接5V或3.3V都可以,A3144的最低工作电压可以到3.3V。5V供电时记得串一个1k电阻再进GPIO做保护。
2.3 霍尔传感器和干簧管的区别:为什么别用干簧管
很多复古教程喜欢用干簧管做转速检测,因为它便宜且接线极简,两根线搞定。但干簧管是机械触点,磁铁每次经过时触点闭合再断开,在高速旋转下会有触点抖动,而且寿命有限,标称几百万次,听着多,一天训练两小时用不了几个月就开始误触发。
霍尔传感器是电子开关,没有机械触点,输出波形干净,配合上拉电阻几乎不需要额外消抖。多花两块钱能省掉后面一箩筐的滤波烦恼,这个钱值得花。
3. 核心数据链路:轮速、踏频与功率怎么进BLE
整台设备的灵魂是数据链路。传感器脉冲进来,经过计算变成标准单位,再按FTMS协议组帧发送。这一步只要有一个地方出错,App那边要么显示异常数值,要么干脆连接后没有数据。
3.1 从脉冲到速度:周长、时间差与滤波
速度计算的核心逻辑很简单:记录两次脉冲之间的时间差,用轮周长除以时间差,就得到瞬时速度。轮周长的准确性直接决定速度准不准,这个在后面的校准部分我专门讲。
霍尔信号接在GPIO4上,我用中断方式记录脉冲时刻。实现思路是这样:
volatile unsigned long lastWheelPulseMicros = 0; volatile unsigned long lastWheelIntervalMicros = 0; void IRAM_ATTR wheelISR() { unsigned long now = micros(); unsigned long interval = now - lastWheelPulseMicros; if (interval > 1000) { // 过滤掉触点噪声和过短的间隔 lastWheelIntervalMicros = interval; } lastWheelPulseMicros = now; }等一下,滤波条件里的1000微秒是下限,也就是说两次脉冲间隔不到1毫秒的会被忽略。但真正要处理的其实是反过来的问题:如果车停下来了,没有新脉冲,那lastWheelIntervalMicros还是上一次的旧值,速度会虚报。所以我在主循环里加了一个超时判断,超过1.2秒没有新脉冲,就把速度直接归零。1.2秒这个值也不是随便定的,它对应大约0.8m/s以下的低速检测边界,既能快速归零,又不至于在慢速踩踏时误判静止。
速度的换算公式也很直接:速度km/h = 轮周长(m) / 时间间隔(s) × 3.6。有了速度之后,如果三颗磁铁均匀贴在轮辐上,踏频也可以顺带算出来,因为每个周期会有3次脉冲,踏频 = 60 / (平均脉冲间隔 × 3)。
3.2 Indoor Bike Data数据帧组包格式
FTMS服务里有好几个特征,和本项目最相关的是Indoor Bike Data特征,UUID是0x2AD2。这个特征的数据格式不是固定长度,而是由第一个字段Flags来决定后面带哪些数据。
以最常见的组合为例:带瞬时速度、带踏频、带功率、带心率。Flags字段是2个字节,bit0表示瞬时速度存在,bit2表示瞬时踏频存在,bit6表示瞬时功率存在,bit9表示心率存在。
那这个flags的值就等于:bit0(0x0001) + bit2(0x0004) + bit6(0x0040) + bit9(0x0200) = 0x0245。Flags之后按顺序填字段:速度是uint16,单位0.001km/h;踏频是uint16,单位0.5rpm,也就是说实际90rpm编码成180;功率是int16,单位W;心率是uint8,单位bpm。
组一帧包含速度、踏频、功率、心率的完整数据,就是:
45 02 // flags 10 27 // 速度 10000,表示10.000km/h B4 00 // 踏频 180,表示90rpm 54 00 // 功率 84W 64 // 心率 100bpm看到这里你就明白了,之所以不能用简单的BLEIntCharacteristic来发数据,是因为FTMS数据帧是变长的,字段个数随flags变化。你必须把一个特征定义成原始字节串,自己逐字节填充。
3.3 为什么不能每个数据各占一个特征
有的朋友可能会问,直接把速度设成1个特征、踏频设成1个特征,App不也能读吗?理论上有GATT调试工具,确实能读。但Zwift、Peloton这类App是按照FTMS规范来找数据的,它只认0x2AD2这一个特征,而且会主动读取Fitness Machine Feature特征(0x2ACC)来确认设备支持哪些能力。如果你不提供0x2AD2,App根本就不会把你识别成骑行设备。
所以做FTMS设备,不能贪图编程上的省事,必须严格按规范提供服务。这也是我前面强调不能用HM-10透传方案的原因:透传只解决了数据通路,解决不了数据语义的问题。
4. ble-ftms的代码实现:从传感器到GATT通知
整个工程的代码量不大,但结构要清晰。我分成三块来写:配置参数的config.h、传感器读取与数据组帧的sensors.cpp、BLE服务注册和数据广播的ble-ftms.ino。
4.1 配置文件:所有可调参数集中放
轮周长、传感器引脚、踏频磁铁数量这些参数如果散落在代码里,后期调试会非常痛苦。我单独建了一个config.h,把和硬件相关的参数全部集中起来:
#define WHEEL_CIRCUMFERENCE_MM 2120.0f #define SENSOR_PIN 4 #define MAGNETS_PER_REV 3 #define IDLE_TIMEOUT_MS 1200这里WHEEL_CIRCUMFERENCE_MM不是轮胎标称直径算出来的,而是实测滚一圈的距离,后面第6节会细讲。
4.2 传感器读取与数据组帧
数据组帧函数是整个项目最核心的一段。输入是霍尔脉冲计算出来的速度、踏频、功率,输出是符合FTMS规范字节序的std::vector<uint8_t>或者普通数组。
void buildIndoorBikeData(uint8_t* buf, size_t* len, float speedKmh, float cadenceRpm, int16_t powerW, uint8_t hr) { size_t idx = 0; uint16_t flags = 0x0245; // 速度 + 踏频 + 功率 + 心率 buf[idx++] = flags & 0xFF; buf[idx++] = (flags >> 8) & 0xFF; uint16_t speedEncoded = (uint16_t)(speedKmh * 1000.0f); buf[idx++] = speedEncoded & 0xFF; buf[idx++] = (speedEncoded >> 8) & 0xFF; uint16_t cadenceEncoded = (uint16_t)(cadenceRpm * 2.0f); buf[idx++] = cadenceEncoded & 0xFF; buf[idx++] = (cadenceEncoded >> 8) & 0xFF; buf[idx++] = powerW & 0xFF; buf[idx++] = (powerW >> 8) & 0xFF; buf[idx++] = hr; *len = idx; }编码时特别要注意大小端。BLE GATT特征值统一用小端序,所以uint16都是先写低字节,再写高字节。很多初学者一上来就踩这个坑,编出来的人工解析能看懂,但App解析出来就是个几百倍的数字。
4.3 注册FTMS服务并周期通知
BLE部分我用NimBLE-Arduino库,理由前面说过,内存占用小,自定义特征非常灵活。初始化GATT服务的代码大致如下:
NimBLEService* ftmsService; NimBLECharacteristic* indoorBikeChar; void setupBle() { NimBLEDevice::init("FTMS"); NimBLEDevice::setPower(ESP_PWR_LVL_P9); NimBLEDevice::setSecurityAuth(false); NimBLEServer* server = NimBLEDevice::createServer(); ftmsService = server->createService("1826"); // Fitness Machine Feature,只读,声明设备支持瞬时速度/踏频/功率/心率 NimBLECharacteristic* featureChar = ftmsService->createCharacteristic("2ACC", NIMBLE_PROPERTY::READ); uint8_t featureData[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; featureChar->setValue(featureData, 8); // Indoor Bike Data,可读 + 通知 indoorBikeChar = ftmsService->createCharacteristic("2AD2", NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); ftmsService->start(); NimBLEDevice::startAdvertising(); }设置广播名的时候注意,直接叫"FTMS"是最省事的。很多App在扫描列表里会做名称匹配,你叫个"ESP32_BLE"虽然GATT服务一样,但部分App的扫描过滤逻辑差,直接不显示。
主循环里我做了两次计算:每200ms算一次速度,每500ms组一次帧并执行通知。不要每毫秒都发数据,App端处理不过来,而且也没必要,训练App的显示刷新率通常也就2到5Hz。
unsigned long lastNotifyTime = 0; const unsigned long notifyInterval = 500; void loop() { checkSensorIdle(); unsigned long now = millis(); if (now - lastNotifyTime >= notifyInterval) { uint8_t frame[32]; size_t frameLen; buildIndoorBikeData(frame, &frameLen, getSpeedKmh(), getCadenceRpm(), getPowerW(), getHeartRate()); indoorBikeChar->setValue(frame, frameLen); indoorBikeChar->notify(); lastNotifyTime = now; } }4.4 先别焊线,用Wokwi仿真一遍
如果你手头零件还没到齐,可以在Wokwi仿真平台上先搭一个ESP32的虚拟环境,GPIO上接一个按钮模拟霍尔脉冲。Wokwi的虚拟BLE功能可以配合手机上的BLE调试工具做完整测试。我开发的时候就是用Wokwi验证了数据帧格式没问题,再在真机上调的,省了不少时间。
5. 连接实战:MTU、广播名与绑定bonding的坑
代码跑通之后,真正的折腾才开始。BLE连接和配对的问题比想象中多,而且每种App的容忍度不一样。我把实际踩过的坑列出来,这些都是比较容易被忽略但影响很大的问题。
5.1 MTU太小,数据包一长就断
MTU是BLE链路层一次能传输的最大数据包长度。默认MTU是23字节,去掉3字节包头,用户数据只有20字节。FTMS单帧数据在只带速度的时候很小,才4个字节,完全没问题。但如果像前面那样速度、踏频、功率、心率全带上,也就9个字节,仍然在20字节以内。
但如果之后再加累计距离、消耗能量、流逝时间这些字段,数据帧长度很快接近20字节上限。一旦超过20字节,通知就会失败,有些App表现得特别粗暴,直接断开连接。
解决思路有两条。一是把不必要的高频字段去掉,优先保证速度、踏频、功率这些核心数据,Expended Energy这类低频数据可以通过另一个特征单独上报。二是在连接建立后主动发起MTU协商请求,NimBLE默认会自动协商到更高的MTU值,但如果你用的是别的库,需要自己实现这步。
5.2 广播名和服务UUID的可见性
这个坑主要出现在Android手机上。iOS端的App扫描BLE设备时对设备名宽容一些,只要GATT服务匹配就能连上。但部分Android App在扫描列表只显示名称包含FTMS的设备,导致你明明在nRF Connect里能看到服务,但App里就是找不到。
解决方法是把广播名设置为FTMS,同时在广播数据里显式添加0x1826这个服务UUID。NimBLE里可以通过NimBLEDevice::init("FTMS")设置名字,服务启动后自动会在广播包里带上服务UUID吗?实际上不是所有库都会自动带,建议在开发时用BLE调试工具看一眼广播包内容,确认里面有"1826"这个UUID再放心。
5.3 bonding绑定失败的排查链路
有一些训练App在连接后会触发加密请求,要求设备配对。这里就会遇到典型的bonding问题。如果你的BLE服务端没有实现安全回调,设备在收到配对请求时可能表现为假死,App一直转圈,最后超时。
我当时的排查链路是这样的:先用BLE调试助手连接设备,看配对请求是否弹出。如果弹出,说明GATT服务没问题,问题在安全策略;如果不弹出,说明App在扫描阶段就把你过滤了,问题回到广播。
处理配对弹窗的方法是设置Security IO能力为NoInputNoOutput,让设备选择Just Works配对方式。也就是用户在App上确认一下,两边不需要输入PIN码。如果有需求,你也可以在onAuthenticationComplete回调里打印绑定状态,确认bonding是否成功。绑定成功后再次连接就不会重复弹窗了,连接速度也会快很多。
6. 校准和调优:天天被App甩锅"数据异常"
设备连上以后,新的问题又来了,App提示数据异常,速度跳变,踏频偶尔翻倍。这些问题看起来是编码错误,但真正原因多半在校准和滤波上。
6.1 轮径别用轮胎标称去算
轮胎上的标称直径,比如700x25c,是按照公路车在平路上滚动时的标准算的。但在骑行台上,轮胎会压进滚轮和阻力轮之间,受力变形,实际滚动半径可能比理论值小1%到3%。如果按理论值算速度,最直接的后果就是App显示的速度比实际速度偏高或偏低,差值完全取决于你胎压和台子的压紧程度。
最靠谱的方法是实测:把骑行台架起来,在轮子上做个标记,用手转动轮子转一整圈,量标记走过的地面长度。多量几次取平均值,测出来的就是真实滚动周长,精度比用公式高得多。我测出来的值比理论值短了将近4cm,折算到速度上大约是2%的差异,这个误差在训练App里已经能感受到了。
6.2 踏频滤波与低速不归零的问题
踏频跳变最常见的原因是霍尔传感器的脉冲间隔不稳定。磁铁贴的位置稍有偏差,或者轮子转速慢时霍尔信号边沿不够陡,都会导致多个脉冲挤在一起。我一开始在主中断里只判断了1ms的最小间隔,但实测下来,在低速踩踏时偶尔会出现几百微秒的毛刺。后来我把最小间隔阈值加大到5ms,再把连续两次间隔的偏差限制在20%以内,超过就丢弃当前值,踏频立刻稳定很多。
低速不归零的问题前面提过,本质上是一个停滞检测逻辑。不要偷懒只用上一次的间隔值,一定要加一个超时清零的条件。很多App会持续计算平均功率,速度不归零会导致平均功率虚高。
6.3 功率估算只能做参考,想严谨还是上功率计
老实说,ble-ftms不带功率计的话,瞬时功率是不存在的物理量。我用的是估算方案,根据速度和磁阻力旋钮的位置查一个预先标定的阻力曲线,再乘上速度算功率。这个输出适合作为训练参考,能看心率区间的相对变化,但绝对数值精度确实有限。
如果预算允许,推荐上一个真正的功率计骑行台,或者至少用带功率的曲柄组。把功率计的ANT+或BLE数据接进系统,再通过FTMS转发到App,那就是完全不同的体验了。代码结构上,我预留了getPowerW()函数,往后替换真实功率数据只改这一个函数就行。
6.4 我实测的一轮数据
拿整台设备在Zwift里实测,50分钟骑行,蓝牙连接稳定,数据帧以2Hz上报,速度曲线平滑,踏频误差在1rpm以内。Wyze App偶尔会出现速度跳变,排查后发现是手机同时连了WiFi和BLE,ESP32的BLE射频和WiFi共享天线时在2.4GHz频段会有干扰。这个问题的解决方法是把ESP32的WiFi彻底关掉,只保留BLE。代码里执行WiFi.mode(WIFI_OFF),实测BLE连接稳定性提升明显。
最后分享一个调试技巧:无论遇到什么诡异问题,先不要直接上训练App,用BLE调试助手看原始特征值。你看到的速度、踏频、功率字节对不对,一眼就能判断是自己设备的问题还是App解析的问题。这一步能帮你省下大量和App兼容性纠缠的时间。
整个项目最让我满意的地方,是它真的把一个看起来很高端的"智能骑行台"概念降到了几十元成本,而且完全使用标准协议。如果你也想折腾,我建议先跑通一个最简版本,哪怕只上报速度和踏频,然后再逐步加功率和心率,每一步都用BLE调试助手核对数据帧,最后再连训练App做整体验证。这个顺序能让你在遇到问题的时候,明确知道问题出在哪里。
本文还有配套的精品资源,点击获取