蓝牙增强+个更换绑定逻辑接口
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
|
||||
2
lib/core/env/env_config.dart
vendored
2
lib/core/env/env_config.dart
vendored
@@ -1,7 +1,7 @@
|
||||
class EnvConfig {
|
||||
static const String environment = String.fromEnvironment(
|
||||
'ENV',
|
||||
defaultValue: 'test',
|
||||
defaultValue: 'prod',
|
||||
);
|
||||
|
||||
static String get sentryDsn {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
57
test/ble_protocol_decoder_test.dart
Normal file
57
test/ble_protocol_decoder_test.dart
Normal 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)');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user