You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 09:34:53