简介:基于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 FTMS(Fitness 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 还是 NimBLE
ESP32 的 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 Data(0x2AD2)是室内自行车上报数据的核心特征值,它用“位掩码 + 可变长度字段”的方式组织数据。前两个字节是 flags,声明后面带了哪些字段;之后按固定顺序排列各字段。
我用得比较多的几个字段见下表:
| 字段 | flags 位 | 单位 | 类型 | 说明 |
|---|---|---|---|---|
| flags | - | - | uint16 | 位掩码,声明后面哪些字段存在 |
| Instantaneous Speed | bit0 | 0.01 km/h | uint16 | 当前速度 |
| Instantaneous Cadence | bit2 | rpm | uint8 | 当前踏频 |
| Instantaneous Power | bit6 | W | uint16 | 当前功率 |
| Heart Rate | bit9 | bpm | uint8 | 心率 |
比如我要上报速度 25.6 km/h、踏频 80 rpm、功率 200 W、心率 120 bpm,那么 flags 就是0x0001 | 0x0004 | 0x0040 | 0x0200 = 0x0245,小端存储就是0x45 0x02。后面按顺序接0x00 0x0A(25.6 变成 2560,低字节 0x00 高字节 0x0A)、0x50(80 rpm)、0xC8 0x00(200 W)、0x78(120 bpm)。
这个打包格式很容易出错的一点是字节序:BLE 里多字节数值一律小端,速度、功率都是先发低字节。我见过太多人按大端发送,结果骑行软件里速度显示成几千,就是这个原因。
2.3 Trainer Control Point:从“只看数据”到“可被控制”
如果只是上报数据,设备就是个“传感器盒子”。要做到被 App 控制阻力、目标功率,还需要实现 Trainer Control Point(0x2AD9)。这是一个可写的特征值,客户端写入指令,设备处理后用 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 Data,20 字节绰绰有余;但如果后面要加总距离、消耗能量、坡度等一堆字段,超过 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.3V,GND 接地,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 Point,App 会用“可控(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 一边调试一边对照规范里的字段顺序和单位,比盲改代码快得多。把这个最小框架跑通之后,后面加任何功能都是在稳定的协议层上做增量,不会推倒重来。
本文还有配套的精品资源,点击获取