ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

ESP32+BLE FTMS:DIY普通健身车变身Zwift智能骑行台

ESP32+BLE FTMS:DIY普通健身车变身Zwift智能骑行台 简介基于Arduino与双ESP32的BLE室内自行车健身机项目面向嵌入式开发者和健身硬件DIY人群以低成本复刻商用室内骑行训练器与功率计为核心目标。资源包共30个文件压缩包仅1.24MB内容覆盖完整工程链条ino/h等C固件源码负责曲柄力与踏频采集、BLE FTMS服务广播、训练器模拟以及电阻执行器控制f3d/dxf/stl文件提供3D机身与零件图纸brd/sch为电路设计py脚本则是图形化配置器方便按模拟档位映射阻力曲线。已有758人学习下载适合想深入BLE通信、传感器融合或自制智能骑行设备的开发者。通过该项目可理解双ESP32分工协作架构掌握ERG/模拟模式下的阻力调节逻辑并参考硬件结构快速搭建原型。 如果你家里有一台“数据只在自己屏幕上滚动”的普通室内健身车或骑行台想让它变成 Zwift、Fulgaz 里能实时上报速度、功率、踏频甚至心率还能接受 App 设置阻力或目标功率的“智能健身设备”那你需要的就是一个基于 ble-ftms 思路的 BLE 桥接方案。我这次做的 Arduino BLE 室内自行车健身机核心就一句话用 ESP32 接上霍尔传感器和阻力控制模块把自己刷成一个符合 BLE FTMSFitness Machine Service标准的 GATT Server让骑行软件像连接大牌智能骑行台一样连接它。这套方案适合三类人一是想在原有健身车上低成本解锁智能联动的骑友二是想系统搞懂 BLE GATT、FTMS 数据打包和广播过程的 Arduino 玩家三是需要快速给非智能健身设备接入标准化 BLE 协议栈的创客。后面我会把协议、固件、传感器计算、联调避坑全部过一遍尽量做到看完整篇文章你能直接抄出一个能用的固件。1. 方案设计为什么要用 FTMS而不是自己造一套协议1.1 普通健身设备缺的是什么绝大多数家用健身车和入门级骑行台本机屏幕能显示速度、里程、踏频、阻力档位但数据是“死的”。骑行软件想读这些数据必须通过蓝牙、ANT 或者线缆协议来获取。你当然可以自己定义一套私有 BLE 服务把速度和功率塞进去但问题是 Zwift 这类软件不可能为你的私有协议适配。FTMS 就是蓝牙 SIG 制定的“健身器材标准服务”服务 UUID 是0x1826。只要设备暴露了这个服务并且按照规范上报 Indoor Bike Data特征值 UUID0x2AD2骑行软件就能直接识别而不需要你写任何 PC 端、手机端插件。这也是我选 FTMS 而不是自己造协议的最重要原因标准化意味着所有主流运动 App 开箱即用。另一个原因是开发成本。实现一个完整的 FTMS 数据上报功能固件代码量大约只需要几百行难点集中在数据打包字节序和控制指令而不是通信架构。对个人 DIY 项目来说这个工作量完全可控。1.2 硬件选型为什么首选 ESP32项目标题里写着 Arduino但如果你真的去买一块 Arduino Uno会发现它根本没有 BLE 功能还得外挂 HM-10、JDY-23 之类的串口蓝牙模块而且处理广播、Notification 效率和稳定性都差点意思。我最终选的是 ESP32 开发板原因有三个自带双模蓝牙BLE 从机/主机都能做不需要额外模块。Arduino 生态直接支持BLEDevice这些库装好就能写跟写普通 Arduino 程序一样。性能充足接霍尔传感器、处理中断、跑 BLE 协议栈、控制 PWM 阻力都毫不吃力价格还便宜。如果你手上只有 Arduino Nano 33 BLE 或者 Arduino Uno BLE Shield也可以跑通但 ESP32 是最省心的选择。后面的代码我全部按 ESP32 写。1.3 软件/库选型Bluedroid 还是 NimBLEESP32 的 Arduino 核心自带两套 BLE 实现默认的 Bluedroid 和可选的 NimBLE。对于这种单设备、单连接的场景两者都能用。但我个人建议用 Arduino 官方 BLE 库基于 Bluedroid做原型因为网上资料最多、Debug 最方便等你有经验了想降低内存占用再切换到 NimBLE。需要注意一点Arduino 库默认安装位置在“文档/Arduino/libraries”。如果你想把库放到别的目录可以在 IDE 2.x 的项目文件夹设置里改或者直接配置环境变量ARDUINO_LIBRARIES不然每次换电脑都要重新放一遍库文件。2. FTMS 协议里必须吃透的四个细节2.1 GATT 模型和广播/连接的区别在写代码之前先理清 BLE 的两个工作阶段广播阶段设备对外喊“我是谁”广播包里可以包含服务 UUID方便手机/电脑去扫。连接阶段客户端作为 GATT Client 连上来设备是 GATT Server双方通过 Service、Characteristic 交换数据。很多新手把这两个阶段混在一起导致“广播开着但 App 连不上”或“连上了但拿不到数据”。记住一点广播是门牌号连接之后的数据交换才是在屋子里开会。FTMS 的数据主要通过 Characteristic 的 Notification/Indication 机制推送连接是前提。2.2 Indoor Bike Data 的数据打包规则Indoor Bike Data0x2AD2是室内自行车上报数据的核心特征值它用“位掩码 可变长度字段”的方式组织数据。前两个字节是 flags声明后面带了哪些字段之后按固定顺序排列各字段。我用得比较多的几个字段见下表字段flags 位单位类型说明flags--uint16位掩码声明后面哪些字段存在Instantaneous Speedbit00.01 km/huint16当前速度Instantaneous Cadencebit2rpmuint8当前踏频Instantaneous Powerbit6Wuint16当前功率Heart Ratebit9bpmuint8心率比如我要上报速度 25.6 km/h、踏频 80 rpm、功率 200 W、心率 120 bpm那么 flags 就是0x0001 | 0x0004 | 0x0040 | 0x0200 0x0245小端存储就是0x45 0x02。后面按顺序接0x00 0x0A25.6 变成 2560低字节 0x00 高字节 0x0A、0x5080 rpm、0xC8 0x00200 W、0x78120 bpm。这个打包格式很容易出错的一点是字节序BLE 里多字节数值一律小端速度、功率都是先发低字节。我见过太多人按大端发送结果骑行软件里速度显示成几千就是这个原因。2.3 Trainer Control Point从“只看数据”到“可被控制”如果只是上报数据设备就是个“传感器盒子”。要做到被 App 控制阻力、目标功率还需要实现 Trainer Control Point0x2AD9。这是一个可写的特征值客户端写入指令设备处理后用 Indication 回一个响应报文。常用 opcode 可以这样定义enum FtmsOpcode : uint8_t { OP_REQUEST_CONTROL 0x00, // 请求控制权 OP_RESET 0x01, // 复位 OP_SET_TARGET_SPEED 0x02, // 设置目标速度 OP_SET_TARGET_CADENCE 0x03, OP_SET_TARGET_POWER 0x04, // 设置目标功率 OP_SET_TARGET_RESISTANCE_LEVEL 0x05, OP_SET_TARGET_HEART_RATE 0x06, OP_START_OR_RESUME 0x07, // 开始/继续 OP_STOP_OR_PAUSE 0x08, // 停止/暂停 OP_RESPONSE 0x80 };在开发时我强烈建议把 opcode 定义单独抽成 enum不要散落在 switch 里。不同骑行软件可能对某些扩展 opcode 有额外要求集中管理好改。响应报文的格式是第一个字节0x80第二个字节是原始请求的 opcode第三个字节是结果码0x01 表示成功0x02 表示不支持。2.4 配对、MTU 和连接稳定性FTMS 设备在骑行软件里通常会触发配对Pairing/Bonding。配对过程对新手来说是个大坑有时候手机上弹窗要求输入 PIN有时候会自动配对成功有时候明明配对过第二次连接又失效。这块我的经验是ESP32 默认的安全配置基本够用别额外加太复杂的安全需求。如果你做了 bond绑定设备端要能持久化配对信息否则每次重连都会重新弹窗。另外要注意 MTU。默认 ATT MTU 是 23 字节除去协议头用户数据只有 20 字节。我们只要发送 8 字节的 Indoor Bike Data20 字节绰绰有余但如果后面要加总距离、消耗能量、坡度等一堆字段超过 20 字节就必须先做 MTU 协商。ESP32 默认会尝试协商到较大 MTU所以大多数情况下不用你手动处理。3. Arduino/ESP32 固件实现一个能跑的最小版本3.1 准备工程和依赖我用的是 Arduino IDE 2.x esp32 by Espressif 开发板包。新建工程后不需要额外装第三方蓝牙库官方库已经包含 BLE 相关 API。如果你用 PlatformIO在platformio.ini里选好esp32dev环境也一样。核心依赖其实是两个BLEDevice和BLEUtils。前者负责初始化和创建服务后者用来构造 UUID。建议先别急着写功能先烧一个官方的BLE_server示例确认开发板、串口、蓝牙初始化都正常。3.2 BLE 服务端与广播配置下面是初始化 FTMS 服务的最小代码。我特意用uint16_t形式的短 UUID 来加广播服务这样广播包只占 2 字节扫描更快、更不容易撑爆广播包。#include BLEDevice.h #include BLEUtils.h #include BLEServer.h #define FTMS_SERVICE_UUID 00001826-0000-1000-8000-00805f9b34fb #define INDOOR_BIKE_DATA_UUID 00002ad2-0000-1000-8000-00805f9b34fb #define TRAINER_CTRL_POINT_UUID 00002ad9-0000-1000-8000-00805f9b34fb BLECharacteristic *bikeDataChar; BLECharacteristic *controlPointChar; void setupBleFtms() { BLEDevice::init(Arduino Bike Trainer); BLEServer *server BLEDevice::createServer(); BLEService *ftms server-createService(FTMS_SERVICE_UUID); bikeDataChar ftms-createCharacteristic( INDOOR_BIKE_DATA_UUID, BLECharacteristic::PROPERTY_NOTIFY); controlPointChar ftms-createCharacteristic( TRAINER_CTRL_POINT_UUID, BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_INDICATE); controlPointChar-setCallbacks(new TrainerControlPointCallbacks()); ftms-start(); BLEAdvertising *advertising BLEDevice::getAdvertising(); advertising-addServiceUUID((uint16_t)0x1826); BLEDevice::startAdvertising(); }这里有一个容易忽略的点如果需要支持骑行软件在连接后读取“设备能力”建议顺便创建 Fitness Machine Feature 特征值0x2ACC属性设为READ返回一个支持能力位掩码。不建这个特征值Zwift 等软件也能跑但有些严格按规范实现的 App 可能行为异常。3.3 骑行数据打包与通知主循环里读取传感器数据后调用下面的函数打包并推送给 App。核心就是把浮点数转成整数再按小端塞进字节数组。extern float speedKmh; extern float cadenceRpm; extern uint16_t powerW; extern uint8_t heartRate; void notifyIndoorBikeData() { uint8_t data[8]; // flags: 速度 踏频 功率 心率 uint16_t flags 0x0001 | 0x0004 | 0x0040 | 0x0200; data[0] flags 0xFF; data[1] (flags 8) 0xFF; uint16_t speed (uint16_t)(speedKmh * 100.0f); data[2] speed 0xFF; data[3] (speed 8) 0xFF; data[4] (uint8_t)(cadenceRpm 0.5f); data[5] powerW 0xFF; data[6] (powerW 8) 0xFF; data[7] heartRate; bikeDataChar-setValue(data, sizeof(data)); bikeDataChar-notify(); }如果没接心率传感器就把0x0200从 flags 里去掉data 长度改成 6 字节。不要保留心率位却发给 App 一个 0有些 App 会把 0 心理解成异常并弹警告。3.4 处理控制指令骑行软件连接后通常会先发 Request Control再发 Set Target Power 或 Start/Resume。设备需要正确响应否则 App 会一直认为“设备不可控”。下面是一个简单的回调处理class TrainerControlPointCallbacks : public BLECharacteristicCallbacks { void onWrite(BLECharacteristic *pCharacteristic) override { std::string value pCharacteristic-getValue(); if (value.empty()) return; uint8_t opcode value[0]; uint8_t response[3] {OP_RESPONSE, opcode, 0x01}; switch (opcode) { case OP_REQUEST_CONTROL: break; case OP_SET_TARGET_POWER: if (value.size() 3) { uint16_t targetPower value[1] | (value[2] 8); setTargetPower(targetPower); // 你的阻力控制逻辑 } break; case OP_START_OR_RESUME: startRiding(); break; case OP_STOP_OR_PAUSE: stopRiding(); break; default: response[2] 0x02; // 不支持 break; } controlPointChar-setValue(response, 3); controlPointChar-indicate(); } };真正的 ERG 模式根据目标功率自动调阻力是一个闭环控制实时估算功率和目标功率比较然后调 PWM 或电阻刹车。这个我建议单独做先把指令接收和响应跑通再慢慢加 PID 调节。4. 传感器接入与数据计算让数字是真实的4.1 霍尔传感器测速/踏频的接法与防抖速度、踏频用霍尔传感器最省事。我用的 A3144 霍尔模块VCC 接 3.3VGND 接地OUT 接 GPIO。在飞轮或曲柄上粘一颗小磁钢轮子每转一圈霍尔输出一个脉冲测脉冲间隔就能算速度。接线很简单但中断处理有几个坑引脚要支持中断ESP32 大部分 GPIO 都可以。中断服务函数里不要调delay()不要发 BLE 通知只更新volatile变量。一定要做防抖。霍尔输出在低速时会有抖动我实测至少加 5ms 的窗口不然速度偶尔会突然跳一下。核心测速代码大概是这样#define SPEED_PIN GPIO_NUM_27 #define CADENCE_PIN GPIO_NUM_26 volatile uint32_t lastSpeedUs 0; volatile float speedKmh 0.0f; volatile uint32_t lastCadenceUs 0; volatile float cadenceRpm 0.0f; const float WHEEL_CIRCUMFERENCE_M 2.105f; // 700C 外胎实测轮周长 void IRAM_ATTR onSpeedPulse() { uint32_t now micros(); uint32_t dt now - lastSpeedUs; if (dt 5000) { // 防抖 float speedMs WHEEL_CIRCUMFERENCE_M * 1000000.0f / dt; speedKmh speedMs * 3.6f; } lastSpeedUs now; } void IRAM_ATTR onCadencePulse() { uint32_t now micros(); uint32_t dt now - lastCadenceUs; if (dt 5000) { cadenceRpm 60.0f * 1000000.0f / dt; } lastCadenceUs now; }轮周长一定要实测。标称 700C 或 26 寸都不如拿卷尺量一圈准确。公式轮周长米乘以 1 秒内圈数再乘 3.6 就是 km/h。如果飞轮上装了多个磁钢速度公式要除以磁钢数量别忘了。4.2 功率从哪来三种获取路径功率是骑行软件最重要的指标之一但又是 DIY 最难做准的。我的建议按优先级选直接接功率计数据如果你本来就有功率计或智能骑行台那完全不愁把它的功率值读进来转发到 FTMS 即可。用阻力档位 踏频查表这是低成本方案。健身车的阻力档位对应一个基础阻力系数功率近似等于系数 * 踏频。用一组实测数据做标定能做出一个“够用但不顶级”的功率值。加应变片测力精度高但结构复杂需要机械加工不适合大多数 DIYer。我做的低成本版用了第二种。标定方法是某档位下80 rpm 踏频用骑行台功率计实测功率为 200W那系数就是200 / (80 * 档位权重)后面实时功率就是系数 * 当前踏频 * 档位权重。线性模型在中等踏频范围内误差能控制在 10% 以内对入门体验足够。4.3 数据平滑和停止检测霍尔测速在低速时抖动很大直接上报会让 App 里的数字像心电图。我的做法是对速度做 3~5 点滑动平均功率也可以平滑但踏频尽量别过度平滑因为骑行软件要靠踏频变化来判断你踩踏是否顺畅。停止也要主动处理连续 1 秒没有收到脉冲速度应该归零而不是一直保持最后那次速度。可以在主循环里判断if (micros() - lastSpeedUs 1000000UL) { speedKmh 0.0f; }这个逻辑很重要不然你停下来Zwift 里角色还在往前滑。5. 接入 Zwift 等骑行软件的正确姿势5.1 设备为什么“扫描不到”或“连不上”我第一次调通固件时手机上的 nRF Connect 能扫到设备但 Zwift 死活找不到。后来排查发现是广播包没带 FTMS 服务 UUID。Zwift 在扫描时只显示带有 FTMS 服务广播的设备如果你用别的私有 UUID 广播它直接过滤掉。解决办法就是代码里那句advertising-addServiceUUID((uint16_t)0x1826);。另外广播模式一定要是可连接的不要设置成不可连接广播。很多调试助手会提供一个“不可连接”的静态广播选项误选了设备就永远等不到 App 连接。5.2 关于绑定Bond和配对弹窗骑行 App尤其是 iOS 端在连接 FTMS 设备时经常触发配对。ESP32 默认的 Just Works 配对通常可以直接通过手机上会弹出“是否配对”点确认就行一般不需要输入密码。如果你发现每次连接都重新弹窗大概率是设备端没有持久化 bond 信息或者你上次是用调试工具配对的手机里存了旧的绑定记录。建议在测试阶段先用系统蓝牙设置里“忽略此设备”然后再重新扫描配对避免脏绑定导致连不上。5.3 App 侧参数轮周长、功率源、可控模式固件跑通后进 Zwift 或其他软件一般会有几项设置值得注意轮周长如果 App 允许覆盖速度来源确保和霍尔测速用的周长一致。功率源选“内置传感器”还是“FTP 估算”尽量选真实功率否则 ERG 模式无意义。可控模式如果设备支持 Trainer Control PointApp 会用“可控Controllable”模式显示而不是当作普通速度传感器。如果 App 显示设备是可控的说明 Request Control 和控制点响应已经通了。6. 实测中遇到的坑与排查速查表6.1 常见问题速查表现象可能原因处理方式扫描不到设备广播没启动或广播包没带 FTMS 服务 UUID检查addServiceUUID确认在startAdvertising前调用能扫描但连接不稳定设备被别的 App 占用或 bond 信息脏了断开所有连接在手机蓝牙设置里忽略设备再重连连上了但骑行软件没数据Notification 未启用或 flags 字段打包错误检查notify()是否被调用核对数据包字节顺序速度显示几十万字节序错误或轮周长单位错误确认小端发送轮周长用米踏频乱跳霍尔防抖不够或安装了多颗磁钢增大中断里的时间窗口检查磁钢数量并进行换算控制阻力没反应没实现 Request Control或 opcode 映射不对先响应 Request Control再接收目标功率指令每次连接都弹配对框设备没有持久化 bond检查 ESP32 的 bond 配置或者接受这种方式先验证功能6.2 几个容易忽略的细节第一个是供电。ESP32 的 3.3V 稳压本来就不算强霍尔模块加小显示屏还能撑住如果再接 PWM 驱动的磁阻制动器最好单独供电别直接从开发板取电。我遇到过一接刹车模块蓝牙就掉线的诡异问题最后发现是瞬间电流把 3.3V 拉垮了。第二个是更新频率。FTMS 数据通知不用太快我建议 100ms 到 200ms 发一次。太快不仅耗电还会把软件端的历史曲线搞得很难看因为大部分骑行软件自身会做更细的插值和平均。第三个是广播和连接释放。设备被连接后最好主动停止广播省电也减少信道占用。断开连接后再重新开广播等待下一次配对。7. 后续扩展从健身车到更多玩法7.1 显示、存储、OTA 和 Home Assistant固件跑通基础功能后可以继续加东西接一个小 OLED 显示当前速度、踏频、功率骑行时不掏手机也能看把总里程、校准系数存进 NVS断电不丢加 OTA 升级改算法不用拆机。如果想接入 Home Assistant可以让 ESP32 同时对外提供另一组只读特征值把数据转给 HA 做训练日志。还有一点值得试试给设备加一个档位编码器把健身车机械阻力档位也读进来这样功率估算会准很多因为档位不再是手动填写而是实时读数。7.2 快速移植到划船机/跑步机FTMS 不只是给自行车用的划船机的划船机数据特征值、跑步机的跑步机数据特征值都有对应的标准 UUID。同一个 BLE Server 框架换一个 Service、换一组字段打包规则就能把项目从室内自行车扩展到划船机、椭圆机甚至跑步机。代码结构基本不用大改核心还是前面说的把传感器的物理量翻译成 FTMS 规范里对应特征值的字节流。我个人做这个项目最大的体会是BLE 的坑多数不在通信本身而在“你以为发给 App 的数据是正确的”。用 nRF Connect 一边调试一边对照规范里的字段顺序和单位比盲改代码快得多。把这个最小框架跑通之后后面加任何功能都是在稳定的协议层上做增量不会推倒重来。本文还有配套的精品资源点击获取
返回列表