diff --git a/lib/core/bluetooth/ble_protocol_decoder.dart b/lib/core/bluetooth/ble_protocol_decoder.dart index 9ccfd4fb..52ab9cbe 100644 --- a/lib/core/bluetooth/ble_protocol_decoder.dart +++ b/lib/core/bluetooth/ble_protocol_decoder.dart @@ -68,27 +68,38 @@ class BleProtocolDecoder { static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) { final fields = []; + // 🔥 关键修复:协议帧为 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, }; } diff --git a/lib/core/di/injection.dart b/lib/core/di/injection.dart index 3b225e11..dcb408a2 100644 --- a/lib/core/di/injection.dart +++ b/lib/core/di/injection.dart @@ -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 init() async { sl.registerLazySingleton( () => GetAllDevicesUseCase(repository: sl()), ); + sl.registerLazySingleton( + () => GetDeviceBySpiltCodeUseCase(repository: sl()), + ); sl.registerFactory( () => device_v2_bloc.DeviceStatusBloc(sl()), ); diff --git a/lib/core/env/env_config.dart b/lib/core/env/env_config.dart index 3000f9a3..c9bdfd1d 100644 --- a/lib/core/env/env_config.dart +++ b/lib/core/env/env_config.dart @@ -1,7 +1,7 @@ class EnvConfig { static const String environment = String.fromEnvironment( 'ENV', - defaultValue: 'test', + defaultValue: 'prod', ); static String get sentryDsn { diff --git a/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart b/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart index a613764b..ad5ae442 100644 --- a/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart +++ b/lib/features/v2/device_list/data/datasources/device_remote_datasource.dart @@ -14,6 +14,9 @@ abstract class DeviceRemoteDataSource { String? typeFilter, }); Future> getAllDevices(); + + /// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null + Future getDeviceBySpiltCode(String code); } /// 设备远程数据源实现类 - 从真实 API 获取数据 @@ -97,6 +100,44 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource { } } + @override + Future 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.from(data as Map)); + debugPrint( + '>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 命中 code=$code serialNumber=${model.name}', + ); + return model; + } catch (e) { + debugPrint('>>> [DeviceRemoteDataSource] getDeviceBySpiltCode error: $e'); + rethrow; + } + } + Future> _fetchDevicesFromAPI(int siteId) async { final response = await dio.get( HttpApiConsts.getSiteDeviceList, diff --git a/lib/features/v2/device_list/data/repositories/device_repository_impl.dart b/lib/features/v2/device_list/data/repositories/device_repository_impl.dart index 4426bc1d..e1dddc05 100644 --- a/lib/features/v2/device_list/data/repositories/device_repository_impl.dart +++ b/lib/features/v2/device_list/data/repositories/device_repository_impl.dart @@ -26,4 +26,10 @@ class DeviceRepositoryImpl implements DeviceRepository { final dataModels = await remoteDataSource.getAllDevices(); return dataModels.map((model) => model.toEntity()).toList(); } + + @override + Future getDeviceBySpiltCode(String code) async { + final dataModel = await remoteDataSource.getDeviceBySpiltCode(code); + return dataModel?.toEntity(); + } } diff --git a/lib/features/v2/device_list/domain/repositories/device_repository.dart b/lib/features/v2/device_list/domain/repositories/device_repository.dart index 0e7687da..2e3fd3b0 100644 --- a/lib/features/v2/device_list/domain/repositories/device_repository.dart +++ b/lib/features/v2/device_list/domain/repositories/device_repository.dart @@ -6,4 +6,7 @@ abstract class DeviceRepository { Future getDeviceStatus(); Future> getDeviceList({int? siteId, String? typeFilter}); Future> getAllDevices(); + + /// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null + Future getDeviceBySpiltCode(String code); } diff --git a/lib/features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart b/lib/features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart new file mode 100644 index 00000000..c8ed7bc3 --- /dev/null +++ b/lib/features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart @@ -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 execute(String code) async { + return await repository.getDeviceBySpiltCode(code); + } +} diff --git a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart index 7cce897d..d544d1e0 100644 --- a/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart +++ b/lib/features/v2/device_list/presentation/pages/ble_device_detail_page.dart @@ -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 { ); try { - final getAllDevicesUseCase = GetIt.instance(); - final devices = await getAllDevicesUseCase.execute(); + // 用蓝牙名(时间戳)作为 code,调 getDeviceBySpiltCode 精确查询设备 + final getDeviceBySpiltCodeUseCase = + GetIt.instance(); + 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 { } if (!mounted) return; - // 复用扫一扫的绑定页面流程 + // 复用扫一扫的绑定页面流程(scanResult 传序列号 serialNumber) Navigator.push( context, MaterialPageRoute( - builder: (_) => BindDevicePage(scanResult: matchedDevice!.name), + builder: (_) => BindDevicePage(scanResult: matchedDevice.name), ), ); } catch (e) { diff --git a/test/ble_protocol_decoder_test.dart b/test/ble_protocol_decoder_test.dart new file mode 100644 index 00000000..c1635326 --- /dev/null +++ b/test/ble_protocol_decoder_test.dart @@ -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 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)'); + }); +}