如何让Flutter App获取FTMS蓝牙健身车的实时运动数据?
Flutter蓝牙连接健身车后无法采集实时运动数据
已完成健身车与Flutter App的蓝牙连接功能,连接成功跳转至新页面后,无法实时显示健身车的时间、距离、速度等运动数据。无设备官方文档,通过nRF Connect App获取了设备特征UUID,但仍无法实现数据采集与显示。
设备特性信息
Fitness Machine UUID:0x1826 PRIMARY SERVICE Fitness Machine Feature UUID: 0x2ACC Properties: READ Value: Fitness Machine Features: Average Speed Supported Cadence Supported Total Distance Supported Inclination Supported Elevation Gain Supported Pace Supported Step Count Supported Resistance Level Supported Stride Count Supported Expended Energy Supported Heart Rate Measurement Supported Metabolic Equivalent Supported Elapsed Time Supported Remaining Time Supported Power Measurement Supported Force on Belt and Power Output Supported Reserved for Future Use: 0x00800000 Target Setting Features: Speed Target Setting Supported Inclination Target Setting Supported Resistance Target Setting Supported Power Target Setting Supported Heart Target Setting Supported Targeted Expended Energy Configuration Supported Targeted Step Number Configuration Supported Targeted Stride Number Configuration Supported Targeted Distance Configuration Supported Targeted Training Time Configuration Supported Targeted Time in Two Heart Rate Zones Configuration Supported Targeted Time in Three Heart Rate Zones Configuration Supported Targeted Time in Five Heart Rate Zones Configuration Supported Indoor Bike Simulation Parameters Supported Wheel Circumference Configuartion Supported Spin Down Control Supported Reserved for Future Use:0x00800000
可正常运行的蓝牙连接代码
import 'package:flutter/material.dart'; import 'package:flutter_blue/flutter_blue.dart'; import 'package:eco/devicepage.dart'; class RemoPage extends StatefulWidget { @override _RemoPageState createState() => _RemoPageState(); } class _RemoPageState extends State<RemoPage> { final FlutterBlue flutterBlue = FlutterBlue.instance; List<BluetoothDevice> devicesList = []; bool isSearching = false; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Remo'), centerTitle: true, ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ GestureDetector( child: Icon(Icons.bluetooth_searching, size: 100), onTap: () async { setState(() { isSearching = true; }); await searchBluetoothDevices(); setState(() { isSearching = false; }); showModalBottomSheet( context: context, builder: (BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: <Widget>[ Padding( padding: EdgeInsets.only(top: 20), child: Text( 'Dispositivos encontrados:', style: TextStyle( fontSize: 20, color: Colors.black, ), ), ), SizedBox(height: 20), Expanded( child: ListView.builder( shrinkWrap: true, itemCount: devicesList.length, itemBuilder: (BuildContext context, int index) { BluetoothDevice device = devicesList[index]; return ListTile( title: Text(device.name ?? 'Unknown Device'), subtitle: Text(device.id.toString()), ); }, ), ), Padding( padding: EdgeInsets.only(bottom: 20), ), ], ); }, ); }, onTapDown: (TapDownDetails details) async { if (devicesList.isNotEmpty) { int index = details.localPosition.dy ~/ 70; if (index < devicesList.length) { BluetoothDevice device = devicesList[index]; await Navigator.of(context).push(MaterialPageRoute( builder: (BuildContext context) => DevicePage(device: device), )); } } }, ), SizedBox(height: 20), isSearching ? CircularProgressIndicator() : Container(), ], ), ), ); } Future<void> searchBluetoothDevices() async { devicesList.clear(); flutterBlue.startScan(timeout: Duration(seconds: 4)); flutterBlue.scanResults.listen((results) { for (ScanResult result in results) { BluetoothDevice device = result.device; if (device.name.startsWith("DFIT")) { devicesList.add(device); } } }); await flutterBlue.stopScan(); } }
无法正常运行的实时数据显示代码
import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_blue/flutter_blue.dart'; class DevicePage extends StatefulWidget { final BluetoothDevice device; DevicePage({required this.device}); @override _DevicePageState createState() => _DevicePageState(); } class _DevicePageState extends State<DevicePage> { late StreamSubscription<List<int>> _subscription; String _distance = ''; String _time = ''; String _spm = ''; String _calories = ''; @override void initState() { super.initState(); _connectToDevice(); } @override void dispose() { super.dispose(); _subscription?.cancel(); } void _connectToDevice() async { await widget.device.connect(); List<BluetoothService> services = await widget.device.discoverServices(); services.forEach((service) { if (service.uuid.toString().toUpperCase() == '00001826-0000-1000-8000-00805f9b34fb') { service.characteristics.forEach((characteristic) { if (characteristic.uuid.toString().toUpperCase() == '00002a28-0000-1000-8000-00805f9b34fb') { _subscribeToCharacteristic(characteristic); } }); } }); } void _subscribeToCharacteristic(BluetoothCharacteristic characteristic) { _subscription = characteristic.value.listen((value) { List<int> data = value.sublist(1); double distance = _parseDistance(data); int time = _parseTime(data); int spm = _parseSPM(data); int calories = _parseCalories(data); setState(() { _distance = '$distance m'; _time = '$time s'; _spm = '$spm spm'; _calories = '$calories kcal'; }); print("Distance: $_distance"); print("Time: $_time"); print("SPM: $_spm"); print("Calories: $_calories"); }); characteristic.setNotifyValue(true); } double _parseDistance(List<int> data) { int value = data[3] << 24 | data[2] << 16 | data[1] << 8 | data[0]; return value / 10.0; } int _parseTime(List<int> data) { return data[5] << 8 | data[4]; } int _parseSPM(List<int> data) { return data[6]; } int _parseCalories(List<int> data) { return data[11] << 8 | data[10]; } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('Data'), centerTitle: true, ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('Distance: $_distance'), SizedBox(height: 20), Text('Time: $_time'), SizedBox(height: 20), Text('SPM: $_spm'), SizedBox(height: 20), Text('Calories: $_calories'), ], ), ), ); } }
问题排查与修复方案
1. 特征UUID选择错误
你当前订阅的0x2A28是系统ID特征,并非健身车的实时运动数据特征。根据BLE标准的Fitness Machine Service(UUID 0x1826),实时室内单车数据对应的特征是Indoor Bike Data,UUID为0x2AD2。
修改_connectToDevice中的特征判断逻辑:
if (characteristic.uuid.toString().toUpperCase() == '00002AD2-0000-1000-8000-00805f9b34fb') { _subscribeToCharacteristic(characteristic); }
2. 数据解析不符合BLE规范
Indoor Bike Data的数据包开头是标志位(1字节),用于标识后续存在哪些数据字段,不同设备的字段存在情况和顺序由标志位决定,不能直接固定索引解析。以下是符合规范的解析示例:
void _subscribeToCharacteristic(BluetoothCharacteristic characteristic) async { _subscription = characteristic.value.listen((value) { if (value.isEmpty) return; int flags = value[0]; int offset = 1; // 解析标志位,确定存在的字段 bool hasSpeed = (flags & 0x01) != 0; bool hasCadence = (flags & 0x02) != 0; bool hasHeartRate = (flags & 0x04) != 0; bool hasPower = (flags & 0x08) != 0; bool hasExpendedEnergy = (flags & 0x10) != 0; bool hasElapsedTime = (flags & 0x20) != 0; bool hasDistance = (flags & 0x40) != 0; double speed = 0; int cadence = 0; int heartRate = 0; int power = 0; int calories = 0; int elapsedTime = 0; double distance = 0; if (hasSpeed) { speed = (value[offset] | (value[offset+1] << 8)) / 100; // 单位:km/h offset += 2; } if (hasCadence) { cadence = value[offset] | (value[offset+1] << 8); // 单位:rpm offset += 2; } if (hasHeartRate) { heartRate = value[offset]; offset += 1; } if (hasPower) { power = value[offset] | (value[offset+1] << 8); // 单位:W offset += 2; } if (hasExpendedEnergy) { calories = value[offset] | (value[offset+1] << 8); // 单位:kcal offset += 2; } if (hasElapsedTime) { elapsedTime = value[offset] | (value[offset+1] << 8); // 单位:s offset += 2; } if (hasDistance) { distance = (value[offset] | (value[offset+1] << 8) | (value[offset+2] << 16) | (value[offset+3] << 24)) / 10; // 单位:m offset += 4; } setState(() { _distance = distance > 0 ? '$distance m' : ''; _time = elapsedTime > 0 ? '$elapsedTime s' : ''; _spm = cadence > 0 ? '$cadence rpm' : ''; _calories = calories > 0 ? '$calories kcal' : ''; }); print("Distance: $_distance"); print("Time: $_time"); print("Cadence: $_spm"); print("Calories: $_calories"); }); // 先设置notify为true,确保设备发送数据 await characteristic.setNotifyValue(true); }
3. 连接流程优化
在_connectToDevice中添加错误处理,并确保discoverServices完成后再操作特征:
void _connectToDevice() async { try { await widget.device.connect(autoConnect: false); List<BluetoothService> services = await widget.device.discoverServices(); for (var service in services) { if (service.uuid.toString().toUpperCase() == '00001826-0000-1000-8000-00805f9b34fb') { for (var characteristic in service.characteristics) { if (characteristic.uuid.toString().toUpperCase() == '00002AD2-0000-1000-8000-00805f9b34fb') { // 检查特征是否支持notify属性 if (characteristic.properties.notify) { await _subscribeToCharacteristic(characteristic); } else { print("该特征不支持Notify属性"); } } } } } } catch (e) { print("连接或服务发现失败: $e"); } }
4. 额外验证步骤
- 使用nRF Connect重新连接设备,找到
0x1826服务下的0x2AD2特征,确认该特征支持Notify属性,并且订阅后能收到数据。 - 打印设备返回的原始字节数组,根据实际数据调整解析逻辑,不同厂商可能对字段顺序有微调。
内容的提问来源于stack exchange,提问作者Gustavo Fonsêca
相关产品推荐
相关产品推荐

