简介面向硬件创客与骑行训练爱好者的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 ServiceFTMS规范的开发者作为参考也适合作为嵌入式课程设计的进阶范例。 自己在骑行台上练了几个月最烦的就是手机里的训练App压根读不到数据。速度、踏频全靠眼睛看码表心率要另外开一个App功率更是想都别想。后来我才搞清楚问题不是出在骑行台本身而是出在通信协议上。大多数普通磁阻骑行台走的是私有协议手机App比你更想知道你的数据但人家只认蓝牙SIG定下的FTMSFitness 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 DevKitNimBLE / 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引脚。信号线接到GPIO4GND共地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)来确认设备支持哪些能力。如果你不提供0x2AD2App根本就不会把你识别成骑行设备。所以做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::vectoruint8_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和BLEESP32的BLE射频和WiFi共享天线时在2.4GHz频段会有干扰。这个问题的解决方法是把ESP32的WiFi彻底关掉只保留BLE。代码里执行WiFi.mode(WIFI_OFF)实测BLE连接稳定性提升明显。最后分享一个调试技巧无论遇到什么诡异问题先不要直接上训练App用BLE调试助手看原始特征值。你看到的速度、踏频、功率字节对不对一眼就能判断是自己设备的问题还是App解析的问题。这一步能帮你省下大量和App兼容性纠缠的时间。整个项目最让我满意的地方是它真的把一个看起来很高端的智能骑行台概念降到了几十元成本而且完全使用标准协议。如果你也想折腾我建议先跑通一个最简版本哪怕只上报速度和踏频然后再逐步加功率和心率每一步都用BLE调试助手核对数据帧最后再连训练App做整体验证。这个顺序能让你在遇到问题的时候明确知道问题出在哪里。本文还有配套的精品资源点击获取