蓝牙增强+个更换绑定逻辑接口

This commit is contained in:
2026-09-11 09:06:20 +08:00
parent 3ba480f63f
commit 356c2a1925
9 changed files with 196 additions and 60 deletions

View File

@@ -68,27 +68,38 @@ class BleProtocolDecoder {
static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) {
final fields = <BleField>[];
// 🔥 关键修复:协议帧为 AB AA 02 [24个逗号字段] CRC_lo CRC_hi AA AB。
// ProtocolParser 只剥离了帧尾 AA AB,payload 末尾仍带 2 字节 CRC16(小端)。
// 障碍物标志位是最后一个字段(fields[23]),若不先去掉 CRC,
// 这 2 字节会粘到障碍物字段上导致乱码。此处先剥离尾部 2 字节 CRC。
final Uint8List body =
data.length > 2 ? Uint8List.sublistView(data, 0, data.length - 2) : data;
try {
final text = utf8.decode(data);
// allowMalformed 兜底:非法字节不再抛异常、不再落入 latin1 造成整段乱码
var text = utf8.decode(body, allowMalformed: true);
// 去掉控制字符(\0 \r \n 等)与替换符(U+FFFD):固件常在串尾补 \0,
// 或残留 CRC 被解码成非法字符,粘在最后一个字段(障碍物)上造成乱码
text = text.replaceAll(RegExp(r'[\u0000-\u001F\uFFFD]'), '');
if (text.contains(',')) {
final parts = text.split(',');
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
}
} catch (e) {
try {
final text = latin1.decode(data);
if (text.contains(',')) {
final parts = text.split(',');
return BleDecodedResult(
fields: _parseStatusFields(parts),
rawHex: hex,
// 诊断:仅当控制模式槽位(parts[20])出现协议外码(如6)时,打印全字段索引对齐,
// 用于判断是固件扩展码还是字段错位;正常包不打印,避免刷屏
if (parts.length > 20 &&
!const ['0', '1', '2', '3', '4'].contains(parts[20].trim())) {
developer.log(
'[BLE-0x02] 字段对齐诊断: ' +
List.generate(parts.length, (i) => '[$i]=${parts[i]}')
.join(' '),
name: 'BleProtocolDecoder',
);
}
} catch (_) {}
}
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
}
} catch (_) {}
if (data.isNotEmpty) {
final status = data[0];
if (body.isNotEmpty) {
final status = body[0];
fields.add(
BleField(
label: '状态码',
@@ -96,8 +107,8 @@ class BleProtocolDecoder {
),
);
}
if (data.length >= 2) {
final mode = data[1];
if (body.length >= 2) {
final mode = body[1];
const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
fields.add(
BleField(
@@ -106,10 +117,10 @@ class BleProtocolDecoder {
),
);
}
if (data.length >= 3)
fields.add(BleField(label: '电量', value: '${data[2]}%'));
if (data.length >= 4) {
final fault = data[3];
if (body.length >= 3)
fields.add(BleField(label: '电量', value: '${body[2]}%'));
if (body.length >= 4) {
final fault = body[3];
fields.add(
BleField(
label: '故障状态',
@@ -119,13 +130,13 @@ class BleProtocolDecoder {
),
);
}
if (data.length >= 6) {
final speed = (data[5] << 8) | data[4];
if (body.length >= 6) {
final speed = (body[5] << 8) | body[4];
fields.add(BleField(label: '速度', value: '$speed'));
}
if (data.length > 8) {
if (body.length > 8) {
try {
final text = String.fromCharCodes(data.sublist(8));
final text = String.fromCharCodes(body.sublist(8));
if (text.isNotEmpty && !text.contains('\x00')) {
fields.add(BleField(label: '附加', value: text));
}
@@ -141,24 +152,32 @@ class BleProtocolDecoder {
parts.add('');
}
// 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口;
// 先按整数解析(容忍前后空格),有映射的一律显示对应文字;
// 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式
String controlModeText(String v) {
return switch (v) {
'0' => '待机',
'1' => '遥控',
'2' => '自动',
'3' => '急停',
_ => v.isNotEmpty ? v : '--',
final n = int.tryParse(v.trim());
if (n == null) return v.isEmpty ? '--' : v;
return switch (n) {
0 => '无控制',
1 => '本地遥控',
2 => '蓝牙',
3 => 'TCP',
4 => '其他接口',
_ => '未知($n)',
};
}
// 协议定义 定位质量: 0无效 1GPS单点 2DGPS差分/SBAS 4RTK固定解 5RTK浮点解 7手动输入
String qualText(String v) {
final q = int.tryParse(v) ?? -1;
return switch (q) {
0 => '无效',
1 => '单点定位',
2 => '差分定位',
4 => '固定解',
5 => '浮点解',
1 => 'GPS 单点定位',
2 => 'DGPS 差分/SBAS',
4 => 'RTK 固定解',
5 => 'RTK 浮点解',
7 => '手动输入模式',
_ => v.isNotEmpty ? v : '--',
};
}
@@ -173,11 +192,14 @@ class BleProtocolDecoder {
}
String obstacleText(String v) {
final o = int.tryParse(v) ?? -1;
return switch (o) {
// 障碍物标志位为单个数字 0/1;字段尾部可能粘有 CRC/\0 等杂质,
// 取第一个数字字符作为标志,无数字则显示 '--',杜绝杂质乱码
final d = RegExp(r'[0-9]').firstMatch(v);
if (d == null) return '--';
return switch (int.parse(d.group(0)!)) {
0 => '无障碍',
1 => '有障碍',
_ => v.isNotEmpty ? v : '--',
_ => v,
};
}

View File

@@ -79,6 +79,7 @@ import '../../features/v2/device_list/domain/repositories/device_repository.dart
import '../../features/v2/device_list/domain/usecases/get_device_status_data_usecase.dart'
as device_v2_usecase;
import '../../features/v2/device_list/domain/usecases/get_all_devices_usecase.dart';
import '../../features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/device_status_bloc.dart'
as device_v2_bloc;
import '../../features/v2/device_list/data/datasources/drone_station_datasource.dart';
@@ -380,6 +381,9 @@ Future<void> init() async {
sl.registerLazySingleton<GetAllDevicesUseCase>(
() => GetAllDevicesUseCase(repository: sl()),
);
sl.registerLazySingleton<GetDeviceBySpiltCodeUseCase>(
() => GetDeviceBySpiltCodeUseCase(repository: sl()),
);
sl.registerFactory<device_v2_bloc.DeviceStatusBloc>(
() => device_v2_bloc.DeviceStatusBloc(sl()),
);

View File

@@ -1,7 +1,7 @@
class EnvConfig {
static const String environment = String.fromEnvironment(
'ENV',
defaultValue: 'test',
defaultValue: 'prod',
);
static String get sentryDsn {

View File

@@ -14,6 +14,9 @@ abstract class DeviceRemoteDataSource {
String? typeFilter,
});
Future<List<DeviceDataModel>> getAllDevices();
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null
Future<DeviceDataModel?> getDeviceBySpiltCode(String code);
}
/// 设备远程数据源实现类 - 从真实 API 获取数据
@@ -97,6 +100,44 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
}
}
@override
Future<DeviceDataModel?> getDeviceBySpiltCode(String code) async {
try {
final response = await dio.get(
HttpApiConsts.getDeviceBySpiltCode,
queryParameters: {'code': code},
);
if (response.statusCode != 200) {
throw Exception('网络请求失败: ${response.statusCode}');
}
final responseData = response.data;
if (responseData['code'] != 200) {
throw Exception(responseData['msg'] ?? '业务异常');
}
// 未查到时后端仅返回 {msg, code},无 data 字段
final data = responseData['data'];
if (data == null) {
debugPrint(
'>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 未查到设备 code=$code',
);
return null;
}
// data 结构与 getDeviceList 的单条 row 一致,复用解析(deviceName 即 serialNumber)
final model = _parseDeviceFromJson(Map<String, dynamic>.from(data as Map));
debugPrint(
'>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 命中 code=$code serialNumber=${model.name}',
);
return model;
} catch (e) {
debugPrint('>>> [DeviceRemoteDataSource] getDeviceBySpiltCode error: $e');
rethrow;
}
}
Future<List<DeviceDataModel>> _fetchDevicesFromAPI(int siteId) async {
final response = await dio.get(
HttpApiConsts.getSiteDeviceList,

View File

@@ -26,4 +26,10 @@ class DeviceRepositoryImpl implements DeviceRepository {
final dataModels = await remoteDataSource.getAllDevices();
return dataModels.map((model) => model.toEntity()).toList();
}
@override
Future<DeviceEntity?> getDeviceBySpiltCode(String code) async {
final dataModel = await remoteDataSource.getDeviceBySpiltCode(code);
return dataModel?.toEntity();
}
}

View File

@@ -6,4 +6,7 @@ abstract class DeviceRepository {
Future<DeviceStatusEntity> getDeviceStatus();
Future<List<DeviceEntity>> getDeviceList({int? siteId, String? typeFilter});
Future<List<DeviceEntity>> getAllDevices();
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null
Future<DeviceEntity?> getDeviceBySpiltCode(String code);
}

View File

@@ -0,0 +1,13 @@
import '../entities/device_entity.dart';
import '../repositories/device_repository.dart';
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备用例(用于蓝牙绑定匹配)
class GetDeviceBySpiltCodeUseCase {
final DeviceRepository repository;
const GetDeviceBySpiltCodeUseCase({required this.repository});
Future<DeviceEntity?> execute(String code) async {
return await repository.getDeviceBySpiltCode(code);
}
}

View File

@@ -13,7 +13,7 @@ import '../../../../../core/protocol/machine_protocol_constants.dart';
import 'package:get_it/get_it.dart';
import '../../../../../core/services/device_permission_service.dart';
import '../../domain/entities/device_entity.dart';
import '../../domain/usecases/get_all_devices_usecase.dart';
import '../../domain/usecases/get_device_by_spilt_code_usecase.dart';
import 'bind_device_page.dart';
class BleDeviceDetailPage extends StatefulWidget {
@@ -290,32 +290,22 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
);
try {
final getAllDevicesUseCase = GetIt.instance<GetAllDevicesUseCase>();
final devices = await getAllDevicesUseCase.execute();
// 用蓝牙名(时间戳)作为 code,调 getDeviceBySpiltCode 精确查询设备
final getDeviceBySpiltCodeUseCase =
GetIt.instance<GetDeviceBySpiltCodeUseCase>();
final DeviceEntity? matchedDevice =
await getDeviceBySpiltCodeUseCase.execute(bleName);
if (loadingCtx != null && Navigator.canPop(loadingCtx!)) {
Navigator.pop(loadingCtx!);
}
debugPrint(
'>>> [BindDevice] bleName=$bleName, devices=${devices.length}',
'>>> [BindDevice] bleName=$bleName, matched=${matchedDevice == null ? "未查到" : matchedDevice.name}',
);
for (int i = 0; i < devices.length && i < 10; i++) {
debugPrint(
'>>> [BindDevice] device[$i]: id=${devices[i].deviceId}, name=${devices[i].name}',
);
}
// 用蓝牙名(时间戳)匹配设备名中包含该时间戳的设备
DeviceEntity? matchedDevice;
for (final device in devices) {
if (device.name.contains(bleName)) {
matchedDevice = device;
break;
}
}
if (matchedDevice == null) {
// 接口未查到(返回无 data)→ 与之前一致弹窗提示
if (matchedDevice == null || matchedDevice.name.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
@@ -334,11 +324,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
}
if (!mounted) return;
// 复用扫一扫的绑定页面流程
// 复用扫一扫的绑定页面流程(scanResult 传序列号 serialNumber)
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => BindDevicePage(scanResult: matchedDevice!.name),
builder: (_) => BindDevicePage(scanResult: matchedDevice.name),
),
);
} catch (e) {

View File

@@ -0,0 +1,57 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:maibu_satabot_v2/core/bluetooth/ble_protocol_decoder.dart';
import 'package:maibu_satabot_v2/core/protocol/machine_protocol_constants.dart';
void main() {
// 协议表示例:24 个逗号字段,控制模式=2(蓝牙),障碍物=1(有障碍)
const sheet =
'52.2,2000,2000,190,190,45.2,46.6,40.5,40.5,40,50,5,5,32,5,1,'
'120.772,32.078,2026-09-10 12:34:56,2000,2,98,12.65,1';
Uint8List payloadWith(String body, List<int> tail) =>
Uint8List.fromList([...utf8.encode(body), ...tail]);
String valueOf(BleDecodedResult r, String label) =>
r.fields.firstWhere((f) => f.label == label).value;
test('0x02 带2字节CRC:控制模式/障碍物解析正确', () {
final r = BleProtocolDecoder.decode(
MachineProtocolConstants.cmdStatusInfo,
payloadWith(sheet, [0x12, 0x34]), // 尾部 CRC16 小端
);
expect(valueOf(r, '控制模式'), '蓝牙');
expect(valueOf(r, '障碍物'), '有障碍');
expect(valueOf(r, '电压'), '52.20 V'); // _tryParseDouble 保留两位小数
expect(valueOf(r, '电量'), '98%');
});
test('0x02 串尾带\\0再加CRC:仍解析正确(不乱码)', () {
final r = BleProtocolDecoder.decode(
MachineProtocolConstants.cmdStatusInfo,
payloadWith('$sheet\x00', [0x12, 0x34]),
);
expect(valueOf(r, '控制模式'), '蓝牙');
expect(valueOf(r, '障碍物'), '有障碍');
});
test('0x02 控制模式带空格(如" 3"):仍映射为文字 TCP', () {
final spaced = sheet.replaceFirst(',2000,2,98', ',2000, 3,98');
final r = BleProtocolDecoder.decode(
MachineProtocolConstants.cmdStatusInfo,
payloadWith(spaced, [0x12, 0x34]),
);
expect(valueOf(r, '控制模式'), 'TCP');
});
test('0x02 控制模式为协议外码6:显示 未知(6) 而非裸6', () {
final bad = sheet.replaceFirst(',2000,2,98', ',2000,6,98');
final r = BleProtocolDecoder.decode(
MachineProtocolConstants.cmdStatusInfo,
payloadWith(bad, [0x12, 0x34]),
);
expect(valueOf(r, '控制模式'), '未知(6)');
});
}