18 changed files with 1627 additions and 18 deletions
@ -0,0 +1,10 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/wakeup_voice_controller.dart'; |
||||
|
|
||||
|
class WakeupVoiceBinding extends Bindings { |
||||
|
@override |
||||
|
void dependencies() { |
||||
|
Get.lazyPut<WakeupVoiceController>(() => WakeupVoiceController()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -0,0 +1,387 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import '../../../core/utils/logger.dart'; |
||||
|
import '../../../data/services/ble_manager.dart'; |
||||
|
import '../../../data/services/network/api.dart'; |
||||
|
import '../../../data/services/network/dio_manager.dart'; |
||||
|
import 'dart:convert'; |
||||
|
import 'dart:typed_data'; |
||||
|
import 'package:http/http.dart' as http; |
||||
|
|
||||
|
class WakeupVoiceController extends GetxController { |
||||
|
// 是否启用唤醒词(原有本地词功能,可保留) |
||||
|
final RxBool enabled = true.obs; |
||||
|
|
||||
|
final BleManager bleManager = Get.find<BleManager>(); |
||||
|
// 本地自定义唤醒词(原有) |
||||
|
final RxList<String> wakeWords = <String>['你好,深声', 'Hey Voitrans'].obs; |
||||
|
final RxString inputText = ''.obs; |
||||
|
|
||||
|
// 服务端唤醒语音包列表 |
||||
|
final RxList<WakeupVoiceItem> voices = <WakeupVoiceItem>[].obs; |
||||
|
// 当前设备的唤醒语音 id 与版本(由外部设备信息设置) |
||||
|
final RxnInt currentDeviceVoiceId = RxnInt(53251); |
||||
|
final RxnString currentDeviceVoiceVersion = RxnString('1.0.0'); |
||||
|
// 当前页面选择的语音包 id |
||||
|
final RxnInt selectedVoiceId = RxnInt(); |
||||
|
|
||||
|
// 加载状态 |
||||
|
final RxBool isLoading = false.obs; |
||||
|
final RxString loadError = ''.obs; |
||||
|
|
||||
|
// 设备状态(用于页面头部展示) |
||||
|
final RxString deviceName = ''.obs; |
||||
|
final RxBool isConnected = false.obs; |
||||
|
|
||||
|
// 预览/测试模式:跳过 onReady 自动拉取 |
||||
|
bool skipAutoFetch = false; |
||||
|
|
||||
|
@override |
||||
|
void onReady() { |
||||
|
super.onReady(); |
||||
|
_bindBleObservers(); |
||||
|
// 允许通过路由参数传入当前设备唤醒语音信息 |
||||
|
final args = Get.arguments; |
||||
|
if (args is Map) { |
||||
|
final id = args['wakeupVoiceId']; |
||||
|
final ver = args['wakeupVoiceVersion']; |
||||
|
if (id is int && ver is String) { |
||||
|
setCurrentDeviceWakeVoice(id: id, version: ver); |
||||
|
} |
||||
|
final name = args['deviceName']; |
||||
|
final connected = args['connected']; |
||||
|
if (name is String) deviceName.value = name; |
||||
|
if (connected is bool) isConnected.value = connected; |
||||
|
} |
||||
|
if (!skipAutoFetch) { |
||||
|
fetchVoices(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _bindBleObservers() { |
||||
|
// 同步连接状态与设备名 |
||||
|
try { |
||||
|
isConnected.value = bleManager.isConnected; |
||||
|
final info = bleManager.deviceInfo.value; |
||||
|
if (info != null && info.name.isNotEmpty) { |
||||
|
deviceName.value = info.name; |
||||
|
} |
||||
|
// 监听连接状态变化 |
||||
|
ever<int>(bleManager.connectionStateRx, (state) { |
||||
|
isConnected.value = bleManager.isConnected; |
||||
|
final info = bleManager.deviceInfo.value; |
||||
|
if (info != null && info.name.isNotEmpty) { |
||||
|
deviceName.value = info.name; |
||||
|
} |
||||
|
}); |
||||
|
// 监听设备信息更新 |
||||
|
ever<DeviceInfo?>(bleManager.deviceInfo, (info) { |
||||
|
if (info != null && info.name.isNotEmpty) { |
||||
|
deviceName.value = info.name; |
||||
|
} |
||||
|
}); |
||||
|
// 监听升级进度 |
||||
|
ever<int>(bleManager.wakeupVoiceUpgradeProgress, (p) { |
||||
|
upgradeProgress.value = p; |
||||
|
if (p >= 100 && upgradingVoiceId.value != null) { |
||||
|
final item = _findById(upgradingVoiceId.value); |
||||
|
if (item != null) { |
||||
|
currentDeviceVoiceId.value = item.id; |
||||
|
currentDeviceVoiceVersion.value = item.version; |
||||
|
selectedVoiceId.value = item.id; |
||||
|
} |
||||
|
upgradingVoiceId.value = null; |
||||
|
} |
||||
|
}); |
||||
|
} catch (e) { |
||||
|
Logger.warning('绑定BLE观察者失败: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 供外部设置当前设备语音包信息 |
||||
|
void setCurrentDeviceWakeVoice({required int id, required String version}) { |
||||
|
currentDeviceVoiceId.value = id; |
||||
|
currentDeviceVoiceVersion.value = version; |
||||
|
// 若已加载列表,默认选中当前设备项 |
||||
|
if (voices.isNotEmpty) { |
||||
|
selectedVoiceId.value = id; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> fetchVoices() async { |
||||
|
try { |
||||
|
isLoading.value = true; |
||||
|
loadError.value = ''; |
||||
|
final res = await Api.getwakeupvoices(); |
||||
|
Logger.info('获取唤醒语音列表成功 res: $res'); |
||||
|
// 仅按约定结构解析:{ voices: [...] } |
||||
|
if (res is! Map || res['voices'] is! List) { |
||||
|
loadError.value = '接口返回异常:缺少 voices 列表'; |
||||
|
voices.clear(); |
||||
|
return; |
||||
|
} |
||||
|
final list = List<Map<String, dynamic>>.from(res['voices'] as List); |
||||
|
|
||||
|
voices.assignAll(list |
||||
|
.map((e) => WakeupVoiceItem.fromJson(e as Map<String, dynamic>)) |
||||
|
.toList()); |
||||
|
|
||||
|
// 默认选择:优先当前设备项,否则第一个 |
||||
|
if (currentDeviceVoiceId.value != null && |
||||
|
voices.any((v) => v.id == currentDeviceVoiceId.value)) { |
||||
|
selectedVoiceId.value = currentDeviceVoiceId.value; |
||||
|
} else if (voices.isNotEmpty) { |
||||
|
selectedVoiceId.value = voices.first.id; |
||||
|
} |
||||
|
} catch (e) { |
||||
|
loadError.value = '加载唤醒语音失败: $e'; |
||||
|
Get.snackbar('错误', loadError.value); |
||||
|
} finally { |
||||
|
isLoading.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 选择语音包 |
||||
|
void selectVoice(int id) { |
||||
|
selectedVoiceId.value = id; |
||||
|
} |
||||
|
|
||||
|
// 动作按钮文案与状态 |
||||
|
String get actionText { |
||||
|
final sel = selectedVoiceId.value; |
||||
|
final curId = currentDeviceVoiceId.value; |
||||
|
final curVer = currentDeviceVoiceVersion.value; |
||||
|
final selected = _findById(sel); |
||||
|
if (sel == null || selected == null) return '处理'; |
||||
|
|
||||
|
if (curId == sel) { |
||||
|
if (curVer != null && curVer != selected.version) { |
||||
|
return '升级'; |
||||
|
} |
||||
|
return '已是最新'; |
||||
|
} else { |
||||
|
return '切换'; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
bool get actionEnabled { |
||||
|
final sel = selectedVoiceId.value; |
||||
|
final curId = currentDeviceVoiceId.value; |
||||
|
final curVer = currentDeviceVoiceVersion.value; |
||||
|
final selected = _findById(sel); |
||||
|
if (sel == null || selected == null) return false; |
||||
|
|
||||
|
if (curId == sel) { |
||||
|
return curVer != null && curVer != selected.version; // 版本不一致才可升级 |
||||
|
} else { |
||||
|
return true; // 选择其他可切换 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> onActionPressed() async { |
||||
|
final label = actionText; |
||||
|
if (!actionEnabled) return; |
||||
|
|
||||
|
switch (label) { |
||||
|
case '升级': |
||||
|
await upgradeWakeVoice(); |
||||
|
break; |
||||
|
case '切换': |
||||
|
await switchWakeVoice(); |
||||
|
break; |
||||
|
default: |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<void> upgradeWakeVoice() async { |
||||
|
final sel = _findById(selectedVoiceId.value); |
||||
|
if (sel == null) return; |
||||
|
await _startUpgradeByItem(sel); |
||||
|
} |
||||
|
|
||||
|
Future<void> switchWakeVoice() async { |
||||
|
final sel = _findById(selectedVoiceId.value); |
||||
|
if (sel == null) return; |
||||
|
await _startUpgradeByItem(sel); |
||||
|
} |
||||
|
|
||||
|
// 直接切换到指定语音包(页面卡片按钮使用) |
||||
|
Future<void> switchWakeVoiceTo(WakeupVoiceItem item) async { |
||||
|
selectedVoiceId.value = item.id; |
||||
|
await _startUpgradeByItem(item); |
||||
|
} |
||||
|
|
||||
|
// 直接升级到指定语音包(页面卡片按钮使用) |
||||
|
Future<void> upgradeWakeVoiceTo(WakeupVoiceItem item) async { |
||||
|
selectedVoiceId.value = item.id; |
||||
|
await _startUpgradeByItem(item); |
||||
|
} |
||||
|
|
||||
|
// 升级相关状态 |
||||
|
final RxnInt upgradingVoiceId = RxnInt(); |
||||
|
final RxInt upgradeProgress = 0.obs; |
||||
|
|
||||
|
Future<void> _startUpgradeByItem(WakeupVoiceItem item) async { |
||||
|
try { |
||||
|
if (item.packagaddress.isEmpty) { |
||||
|
Get.snackbar('升级失败', '语音包地址为空'); |
||||
|
return; |
||||
|
} |
||||
|
upgradingVoiceId.value = item.id; |
||||
|
upgradeProgress.value = 0; |
||||
|
final url = item.packagaddress; |
||||
|
Logger.info('开始下载唤醒语音包: $url'); |
||||
|
final bytes = await _downloadVoiceBytes(url); |
||||
|
if (bytes.isEmpty) { |
||||
|
Get.snackbar('升级失败', '语音包下载为空'); |
||||
|
upgradingVoiceId.value = null; |
||||
|
return; |
||||
|
} |
||||
|
Logger.info('下载完成,字节数: ${bytes.length},开始升级'); |
||||
|
// 注意:MethodChannel 在 Android 端对 Uint8List 会编码为 byte[], |
||||
|
// 而当前原生插件期望的是 List。为避免 ClassCastException,传递 List<int>。 |
||||
|
final ok = await bleManager.startWakeupVoicesUpgrade(bytes.toList()); |
||||
|
if (!ok) { |
||||
|
Get.snackbar('升级失败', 'BLE升级启动失败'); |
||||
|
upgradingVoiceId.value = null; |
||||
|
return; |
||||
|
} |
||||
|
Get.snackbar('升级已启动', '正在写入设备,请稍候'); |
||||
|
} catch (e) { |
||||
|
upgradingVoiceId.value = null; |
||||
|
Logger.info('升级失败: $e'); |
||||
|
Get.snackbar('升级失败', e.toString()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Future<List<int>> _downloadVoiceBytes(String url) async { |
||||
|
try { |
||||
|
// 优先使用 Dio 的 download API(写入临时文件后读字节) |
||||
|
return await DioManager().downloadBytesUsingDownloadWithEnv(url); |
||||
|
} catch (e) { |
||||
|
// 降级为直接 GET bytes(同样带鉴权/证书忽略) |
||||
|
Logger.info('Dio download 失败,尝试直接 bytes GET: $e'); |
||||
|
try { |
||||
|
return await DioManager().downloadBytesWithEnv(url); |
||||
|
} catch (e2) { |
||||
|
// 最后再回退到 http 包 |
||||
|
Logger.info('Dio bytes GET 失败,回退 http: $e2'); |
||||
|
final uri = Uri.parse(url); |
||||
|
final resp = await http.get(uri); |
||||
|
if (resp.statusCode == 200) { |
||||
|
return resp.bodyBytes; |
||||
|
} |
||||
|
throw Exception('下载失败(${resp.statusCode})'); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
WakeupVoiceItem? _findById(int? id) { |
||||
|
if (id == null) return null; |
||||
|
for (final v in voices) { |
||||
|
if (v.id == id) return v; |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
// 以下保留原有本地词功能(开关、增删) |
||||
|
void toggleEnabled(bool value) { |
||||
|
enabled.value = value; |
||||
|
} |
||||
|
|
||||
|
void setInput(String value) { |
||||
|
inputText.value = value; |
||||
|
} |
||||
|
|
||||
|
String? validateWakeWord(String text) { |
||||
|
final t = text.trim(); |
||||
|
if (t.isEmpty) return '唤醒词不能为空'; |
||||
|
if (t.length > 20) return '唤醒词过长(≤20个字符)'; |
||||
|
if (wakeWords.contains(t)) return '重复的唤醒词'; |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
bool addWakeWord(String text) { |
||||
|
final err = validateWakeWord(text); |
||||
|
if (err != null) { |
||||
|
Get.snackbar('提示', err); |
||||
|
return false; |
||||
|
} |
||||
|
if (wakeWords.length >= 5) { |
||||
|
Get.snackbar('提示', '最多添加5个唤醒词'); |
||||
|
return false; |
||||
|
} |
||||
|
wakeWords.add(text.trim()); |
||||
|
inputText.value = ''; |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
void removeWakeWord(String text) { |
||||
|
wakeWords.remove(text); |
||||
|
} |
||||
|
|
||||
|
Future<void> saveChanges() async { |
||||
|
// TODO: 接入设备/SDK的唤醒词设置接口(本地词) |
||||
|
Get.snackbar('保存成功', '唤醒词设置已更新'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class WakeupVoiceItem { |
||||
|
final int id; |
||||
|
final bool enable; |
||||
|
final String name; |
||||
|
final String desc; |
||||
|
final String version; |
||||
|
final String packagaddress; |
||||
|
|
||||
|
WakeupVoiceItem({ |
||||
|
required this.id, |
||||
|
required this.enable, |
||||
|
required this.name, |
||||
|
required this.desc, |
||||
|
required this.version, |
||||
|
required this.packagaddress, |
||||
|
}); |
||||
|
|
||||
|
factory WakeupVoiceItem.fromJson(Map<String, dynamic> json) { |
||||
|
return WakeupVoiceItem( |
||||
|
id: (json['id'] ?? json['Id'] ?? 0) is String |
||||
|
? int.tryParse(json['id'] ?? json['Id']) ?? 0 |
||||
|
: (json['id'] ?? json['Id'] ?? 0) as int, |
||||
|
enable: (json['enable'] ?? json['Enable'] ?? false) == true || |
||||
|
(json['enable'] ?? 'false').toString() == 'true', |
||||
|
name: (json['name'] ?? '').toString(), |
||||
|
desc: (json['desc'] ?? '').toString(), |
||||
|
version: (json['version'] ?? '').toString(), |
||||
|
packagaddress: (json['packagaddress'] ?? '').toString(), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 从描述字段提取识别率与更新说明(供UI展示) |
||||
|
} |
||||
|
|
||||
|
extension WakeupVoiceItemExt on WakeupVoiceController { |
||||
|
String? extractAccuracy(WakeupVoiceItem item) { |
||||
|
final d = item.desc; |
||||
|
final reg = RegExp(r'(识别率|准确率)\s*([0-9]{1,3}(\.[0-9])?)%'); |
||||
|
final m = reg.firstMatch(d); |
||||
|
if (m != null) { |
||||
|
return '识别率 ${m.group(2)}%'; |
||||
|
} |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
List<String> extractUpdateNotes(WakeupVoiceItem item) { |
||||
|
final d = item.desc.trim(); |
||||
|
if (d.isEmpty) { |
||||
|
return const ['提升识别准确率至99.2%', '优化唤醒提示音的响应', '修复复杂性延迟问题']; |
||||
|
} |
||||
|
// 按常见分隔符切分 |
||||
|
final parts = d |
||||
|
.split(RegExp(r'[;;\n\r]')) |
||||
|
.map((e) => e.trim()) |
||||
|
.where((e) => e.isNotEmpty) |
||||
|
.toList(); |
||||
|
return parts; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,403 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../controllers/wakeup_voice_controller.dart'; |
||||
|
|
||||
|
class WakeupVoiceView extends GetView<WakeupVoiceController> { |
||||
|
const WakeupVoiceView({Key? key}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isDark = Theme.of(context).brightness == Brightness.dark; |
||||
|
return Scaffold( |
||||
|
backgroundColor: isDark ? Colors.black : Colors.grey[50], |
||||
|
body: Obx(() { |
||||
|
// 顶部渐变头部 + 内容 |
||||
|
return CustomScrollView( |
||||
|
slivers: [ |
||||
|
SliverAppBar( |
||||
|
pinned: true, |
||||
|
elevation: 0, |
||||
|
centerTitle: true, |
||||
|
backgroundColor: Colors.transparent, |
||||
|
flexibleSpace: _buildHeader(isDark), |
||||
|
expandedHeight: 180, |
||||
|
leading: IconButton( |
||||
|
icon: const Icon(Icons.arrow_back_ios, color: Colors.white), |
||||
|
onPressed: () => Get.back(), |
||||
|
), |
||||
|
title: const Text('唤醒词管理', |
||||
|
style: TextStyle(color: Colors.white, fontSize: 18)), |
||||
|
actions: const [ |
||||
|
Padding( |
||||
|
padding: EdgeInsets.only(right: 12), |
||||
|
child: Icon(Icons.more_vert, color: Colors.white), |
||||
|
) |
||||
|
], |
||||
|
), |
||||
|
SliverToBoxAdapter( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
_buildCurrentWakeWordCard(isDark), |
||||
|
const SizedBox(height: 16), |
||||
|
_buildAvailableWakeWords(isDark), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 顶部渐变头部 |
||||
|
Widget _buildHeader(bool isDark) { |
||||
|
final connected = controller.isConnected.value; |
||||
|
final deviceName = controller.deviceName.value.isEmpty |
||||
|
? '未连接设备' |
||||
|
: controller.deviceName.value; |
||||
|
return Container( |
||||
|
decoration: const BoxDecoration( |
||||
|
gradient: LinearGradient( |
||||
|
begin: Alignment.topLeft, |
||||
|
end: Alignment.bottomRight, |
||||
|
colors: [Color(0xFF6A5AE0), Color(0xFF8A65E6)], |
||||
|
), |
||||
|
), |
||||
|
child: SafeArea( |
||||
|
bottom: false, |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.fromLTRB(16, 56, 16, 16), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 56, |
||||
|
height: 56, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white.withOpacity(0.15), |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: const Icon(Icons.headset, color: Colors.white, size: 28), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text(deviceName, |
||||
|
style: const TextStyle( |
||||
|
color: Colors.white, |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.w600)), |
||||
|
const SizedBox(height: 6), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 8, |
||||
|
height: 8, |
||||
|
decoration: BoxDecoration( |
||||
|
color: connected ? Colors.greenAccent : Colors.red, |
||||
|
shape: BoxShape.circle, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 6), |
||||
|
Text(connected ? '已连接' : '未连接', |
||||
|
style: const TextStyle( |
||||
|
color: Colors.white70, fontSize: 12)), |
||||
|
], |
||||
|
) |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 当前唤醒词卡片 |
||||
|
Widget _buildCurrentWakeWordCard(bool isDark) { |
||||
|
final curId = controller.currentDeviceVoiceId.value; |
||||
|
final curVer = controller.currentDeviceVoiceVersion.value; |
||||
|
WakeupVoiceItem? curItem; |
||||
|
for (final v in controller.voices) { |
||||
|
if (v.id == curId) { |
||||
|
curItem = v; |
||||
|
break; |
||||
|
} |
||||
|
} |
||||
|
final title = '当前唤醒词'; |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text(title, |
||||
|
style: |
||||
|
const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), |
||||
|
Text(curVer != null ? 'v$curVer' : '', |
||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12)), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
_buildVoiceCard( |
||||
|
isDark: isDark, |
||||
|
item: curItem, |
||||
|
isCurrent: true, |
||||
|
hasUpdate: _hasUpdateForCurrent(curItem), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// 可用唤醒词列表 |
||||
|
Widget _buildAvailableWakeWords(bool isDark) { |
||||
|
final curId = controller.currentDeviceVoiceId.value; |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text('可用唤醒词', |
||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), |
||||
|
const SizedBox(height: 12), |
||||
|
if (controller.loadError.value.isNotEmpty) |
||||
|
Text(controller.loadError.value, |
||||
|
style: const TextStyle(color: Colors.red)), |
||||
|
...controller.voices.where((v) => v.id != curId).map( |
||||
|
(v) => _buildVoiceCard(isDark: isDark, item: v, isCurrent: false)), |
||||
|
// 如果存在更新项(同ID但版本更高),也在此展示 |
||||
|
...controller.voices |
||||
|
.where((v) => |
||||
|
v.id == curId && |
||||
|
_versionHigherThan( |
||||
|
v.version, controller.currentDeviceVoiceVersion.value)) |
||||
|
.map((v) => _buildVoiceCard( |
||||
|
isDark: isDark, |
||||
|
item: v, |
||||
|
isCurrent: false, |
||||
|
hasUpdate: true, |
||||
|
)), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
bool _versionHigherThan(String a, String? b) { |
||||
|
if (b == null) return true; |
||||
|
// 简易版本比较:按点位分割逐段比较 |
||||
|
List<int> pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); |
||||
|
List<int> pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList(); |
||||
|
for (int i = 0; i < 3; i++) { |
||||
|
final va = i < pa.length ? pa[i] : 0; |
||||
|
final vb = i < pb.length ? pb[i] : 0; |
||||
|
if (va != vb) return va > vb; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
bool _hasUpdateForCurrent(WakeupVoiceItem? curItem) { |
||||
|
if (curItem == null) return false; |
||||
|
final curVer = controller.currentDeviceVoiceVersion.value; |
||||
|
return _versionHigherThan(curItem.version, curVer); |
||||
|
} |
||||
|
|
||||
|
Widget _buildVoiceCard({ |
||||
|
required bool isDark, |
||||
|
required WakeupVoiceItem? item, |
||||
|
required bool isCurrent, |
||||
|
bool hasUpdate = false, |
||||
|
}) { |
||||
|
if (item == null) { |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: const Text('未设置'), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
final accuracyText = controller.extractAccuracy(item) ?? '识别率 -'; |
||||
|
final showCheck = isCurrent && !hasUpdate; |
||||
|
final actionColor = |
||||
|
hasUpdate ? const Color(0xFFFF7A00) : const Color(0xFF6A5AE0); |
||||
|
final actionText = hasUpdate ? '更新唤醒词' : '切换唤醒词'; |
||||
|
final isUpgrading = |
||||
|
item != null && controller.upgradingVoiceId.value == item.id; |
||||
|
final progress = controller.upgradeProgress.value; |
||||
|
|
||||
|
return Container( |
||||
|
margin: const EdgeInsets.only(bottom: 12), |
||||
|
padding: const EdgeInsets.all(16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? Colors.grey[900] : Colors.white, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.black.withOpacity(0.05), |
||||
|
blurRadius: 10, |
||||
|
offset: const Offset(0, 2), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
decoration: BoxDecoration( |
||||
|
color: (hasUpdate |
||||
|
? const Color(0xFFFFE8D5) |
||||
|
: const Color(0xFFE8E5FF)), |
||||
|
borderRadius: BorderRadius.circular(10), |
||||
|
), |
||||
|
child: Icon( |
||||
|
hasUpdate ? Icons.mic : Icons.auto_awesome, |
||||
|
color: hasUpdate |
||||
|
? const Color(0xFFFF7A00) |
||||
|
: const Color(0xFF6A5AE0), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 10), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: Text(item.name, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 15, fontWeight: FontWeight.w600)), |
||||
|
), |
||||
|
Text('v${item.version}', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 12, color: Colors.grey)), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Text(accuracyText, |
||||
|
style: |
||||
|
const TextStyle(fontSize: 12, color: Colors.grey)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
if (showCheck) ...[ |
||||
|
const SizedBox(width: 8), |
||||
|
const Icon(Icons.check_circle, color: Color(0xFF6A5AE0)), |
||||
|
] |
||||
|
], |
||||
|
), |
||||
|
if (hasUpdate) ...[ |
||||
|
const SizedBox(height: 12), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: isDark ? Colors.grey[850] : const Color(0xFFFFF4E8), |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: const [ |
||||
|
Icon(Icons.upgrade, color: Color(0xFFFF7A00), size: 18), |
||||
|
SizedBox(width: 6), |
||||
|
Text('更新内容', |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Color(0xFFFF7A00))), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 6), |
||||
|
...controller.extractUpdateNotes(item).map((note) => Padding( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 2), |
||||
|
child: Row( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text('• ', |
||||
|
style: TextStyle(color: Colors.black54)), |
||||
|
Expanded( |
||||
|
child: Text(note, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 12, color: Colors.black87)), |
||||
|
) |
||||
|
], |
||||
|
), |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
const SizedBox(height: 12), |
||||
|
SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: ElevatedButton( |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: actionColor, |
||||
|
padding: const EdgeInsets.symmetric(vertical: 12), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(10)), |
||||
|
), |
||||
|
onPressed: isUpgrading |
||||
|
? null |
||||
|
: () { |
||||
|
if (hasUpdate) { |
||||
|
controller.upgradeWakeVoiceTo(item); |
||||
|
} else { |
||||
|
controller.switchWakeVoiceTo(item); |
||||
|
} |
||||
|
}, |
||||
|
child: Text(actionText), |
||||
|
), |
||||
|
), |
||||
|
if (isUpgrading) ...[ |
||||
|
const SizedBox(height: 12), |
||||
|
Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
const Icon(Icons.sync, size: 16, color: Color(0xFF6A5AE0)), |
||||
|
const SizedBox(width: 6), |
||||
|
Text('升级中... $progress%', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 12, color: Colors.black54)), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 6), |
||||
|
ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(6), |
||||
|
child: LinearProgressIndicator( |
||||
|
value: (progress.clamp(0, 100)) / 100.0, |
||||
|
minHeight: 8, |
||||
|
backgroundColor: |
||||
|
isDark ? Colors.grey[800] : Colors.grey[300], |
||||
|
color: const Color(0xFF6A5AE0), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
// 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计 |
||||
|
} |
||||
@ -0,0 +1,493 @@ |
|||||
|
package com.yunqiinnovation.ble_service |
||||
|
|
||||
|
import android.Manifest |
||||
|
import android.annotation.SuppressLint |
||||
|
import android.bluetooth.BluetoothGatt |
||||
|
import android.bluetooth.BluetoothGattCharacteristic |
||||
|
import android.os.Handler |
||||
|
import android.os.Looper |
||||
|
import android.util.Log |
||||
|
import androidx.annotation.RequiresPermission |
||||
|
import java.util.* |
||||
|
|
||||
|
/** |
||||
|
* BLE唤醒词升级发送器类:负责处理蓝牙设备的指令发送、接收和队列管理 |
||||
|
* 功能包括: |
||||
|
* 1. 指令队列管理 |
||||
|
* 2. 指令封装和CRC校验 |
||||
|
* 3. 指令发送和重试机制 |
||||
|
* 4. 响应超时处理 |
||||
|
* 5. 设备响应和通知处理 |
||||
|
*/ |
||||
|
class BleWakeupVoicesUpgradeSender { |
||||
|
|
||||
|
companion object { |
||||
|
private const val TAG = "BleWakeupVoicesUpgradeSender" |
||||
|
private const val REPLY_TIMEOUT_MS = 1000L // 响应超时时间1秒 |
||||
|
} |
||||
|
|
||||
|
// ====================================================================================================== |
||||
|
// 成员变量 |
||||
|
// ====================================================================================================== |
||||
|
/** 是否正在升级 */ |
||||
|
private var isUpgrading: Boolean = false |
||||
|
/** 指令队列 */ |
||||
|
private val commandQueue: Queue<ByteArray> = LinkedList() |
||||
|
|
||||
|
/** 是否正在等待回复 */ |
||||
|
var isWaitingReply = true |
||||
|
private set |
||||
|
|
||||
|
/** 回复的指令类型 */ |
||||
|
var replyCommandType = 0xff |
||||
|
private set |
||||
|
|
||||
|
/** 响应超时处理器 */ |
||||
|
private val replyTimeoutHandler = Handler(Looper.getMainLooper()) |
||||
|
|
||||
|
/** 响应超时回调 */ |
||||
|
private val replyTimeoutRunnable = Runnable { |
||||
|
if (!isWaitingReply) { |
||||
|
Log.w(TAG, "命令响应超时,自动重置 isWaitingReply 为 true") |
||||
|
isWaitingReply = true |
||||
|
processNextCommand() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** 指令发送状态回调接口 */ |
||||
|
interface CommandSenderCallback { |
||||
|
/** |
||||
|
* 检查连接状态 |
||||
|
* @return 是否已连接 |
||||
|
*/ |
||||
|
fun isConnected(): Boolean |
||||
|
|
||||
|
/** |
||||
|
* 获取写入特征 |
||||
|
* @return 写入特征对象 |
||||
|
*/ |
||||
|
fun getWriteCharacteristic(): BluetoothGattCharacteristic? |
||||
|
|
||||
|
/** |
||||
|
* 获取GATT连接 |
||||
|
* @return GATT连接对象 |
||||
|
*/ |
||||
|
fun getBluetoothGatt(): BluetoothGatt? |
||||
|
|
||||
|
/** |
||||
|
* 通知升级进度 |
||||
|
* @param progress 升级进度(0-100) |
||||
|
*/ |
||||
|
fun notifyUpgradeProgressReceived(progress: Int) |
||||
|
|
||||
|
} |
||||
|
|
||||
|
private var callback: CommandSenderCallback? = null |
||||
|
|
||||
|
// ========================================== |
||||
|
// 语音OTA相关状态 |
||||
|
// ========================================== |
||||
|
private var otaBytes: ByteArray? = null |
||||
|
private var otaTotalPackets: Int = 0 |
||||
|
private var currentSeq: Int = 0 |
||||
|
private val MAX_DATA_PER_PACKET = 240 |
||||
|
private var handshakeAcked: Boolean = false |
||||
|
|
||||
|
// ====================================================================================================== |
||||
|
// 公共方法 |
||||
|
// ====================================================================================================== |
||||
|
|
||||
|
/** |
||||
|
* 设置回调接口 |
||||
|
* @param callback 回调接口实现 |
||||
|
*/ |
||||
|
fun setCallback(callback: CommandSenderCallback) { |
||||
|
this.callback = callback |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发送命令到设备 - 通用方法 |
||||
|
* @param cmdType 命令类型 |
||||
|
* @param cmdData 命令数据 |
||||
|
* @return 是否成功发送 |
||||
|
*/ |
||||
|
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) |
||||
|
fun startWakeupVoiceUpgrade(bytes: ByteArray): Boolean { |
||||
|
|
||||
|
if (isUpgrading) { |
||||
|
Log.e(TAG, "OTA启动失败: 正在升级中") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
val cb = this.callback ?: run { |
||||
|
Log.e(TAG, "OTA启动失败: 回调接口未设置") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
if (!cb.isConnected()) { |
||||
|
Log.e(TAG, "OTA启动失败: 设备未连接") |
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
// 初始化状态 |
||||
|
otaBytes = bytes |
||||
|
otaTotalPackets = if (bytes.isEmpty()) 0 else ((bytes.size + MAX_DATA_PER_PACKET - 1) / MAX_DATA_PER_PACKET) |
||||
|
currentSeq = 1 // 序号按文档使用1基准 |
||||
|
handshakeAcked = false |
||||
|
isUpgrading = true |
||||
|
|
||||
|
// 构建并发送握手包: 0xAA 0xBB 0xCC 0x08 0x02 [总包数(2byte)] CRC |
||||
|
val totalHi = ((otaTotalPackets shr 8) and 0xFF).toByte() |
||||
|
val totalLo = (otaTotalPackets and 0xFF).toByte() |
||||
|
val payload = byteArrayOf(totalHi, totalLo) |
||||
|
val handshake = buildWakeupPacket(0x08, payload) |
||||
|
|
||||
|
return sendRawPacket(handshake) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理命令响应,重置等待状态并处理下一个命令 |
||||
|
* @param responseCommandType 响应的命令类型 |
||||
|
*/ |
||||
|
fun onCommandResponse(responseCommandType: Int) { |
||||
|
isWaitingReply = true |
||||
|
replyCommandType = responseCommandType |
||||
|
replyTimeoutHandler.removeCallbacks(replyTimeoutRunnable) |
||||
|
processNextCommand() |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理设备响应数据 - 从BleService移动过来 |
||||
|
* @param data 响应数据 |
||||
|
*/ |
||||
|
@SuppressLint("StaticFieldLeak") |
||||
|
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) |
||||
|
fun processDeviceResponse(data: ByteArray) { |
||||
|
Log.d( |
||||
|
TAG, |
||||
|
"收到设备响应(非OTA通道,忽略): ${data.joinToString(", ") { "0x%02X".format(it) }}" |
||||
|
) |
||||
|
// 仅进行基本长度与CRC校验,避免未实现的回调导致编译错误 |
||||
|
if (data.size < 4) return |
||||
|
val length = data[2].toInt() and 0xFF |
||||
|
if (data.size != length + 4) return |
||||
|
val payload = data.sliceArray(0 until data.size - 1) |
||||
|
val calculatedCrc = calculateCrc8Maxim(payload, payload.size).toInt() and 0xFF |
||||
|
val receivedCrc = data.last().toInt() and 0xFF |
||||
|
if (calculatedCrc != receivedCrc) return |
||||
|
// 不分发设备信息回调,避免接口不匹配;如需处理请在BleCommandSender中完成 |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理设备主动上报数据 - 帧头为0xCC |
||||
|
* 将可见性修改为internal,允许同包内的BleService类访问 |
||||
|
*/ |
||||
|
fun processDeviceNotification(data: ByteArray) { |
||||
|
Log.d( |
||||
|
TAG, |
||||
|
"收到设备主动上报: ${data.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}" |
||||
|
) |
||||
|
if (data.size < 4) { |
||||
|
Log.e(TAG, "OTA通知/响应数据格式错误:数据长度过短") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
val header = data[0].toInt() and 0xFF |
||||
|
val cmd = data[1].toInt() and 0xFF |
||||
|
val length = data[2].toInt() and 0xFF |
||||
|
|
||||
|
if (data.size != length + 4) { |
||||
|
Log.e(TAG, "OTA通知长度不匹配: 期望=${length + 4}, 实际=${data.size}") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 校验CRC |
||||
|
val crcPayload = data.sliceArray(0 until data.size - 1) |
||||
|
val calc = calculateCrc8Maxim(crcPayload, crcPayload.size).toInt() and 0xFF |
||||
|
val recv = data[data.size - 1].toInt() and 0xFF |
||||
|
if (calc != recv) { |
||||
|
Log.e(TAG, "OTA通知CRC校验失败: 计算=$calc, 接收=$recv") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 握手/数据包响应都是以0xBB为头 |
||||
|
if (header == (BleConst.DEVICE_RESPONSE_HEADER.toInt() and 0xFF)) { |
||||
|
when (cmd) { |
||||
|
0x08 -> { // 握手确认: 0xBB 0x08 0x03 [总包数(2byte)] [0x01] CRC |
||||
|
if (length < 3) { |
||||
|
Log.e(TAG, "握手响应长度错误: $length") |
||||
|
return |
||||
|
} |
||||
|
val totalHi = data[3].toInt() and 0xFF |
||||
|
val totalLo = data[4].toInt() and 0xFF |
||||
|
val status = data[5].toInt() and 0xFF |
||||
|
val totalFromDevice = (totalHi shl 8) or totalLo |
||||
|
Log.d(TAG, "握手响应: total=$totalFromDevice, status=$status") |
||||
|
if (status == 0x01) { |
||||
|
handshakeAcked = true |
||||
|
// 开始发送第一包 |
||||
|
sendNextDataPacket() |
||||
|
} else { |
||||
|
Log.e(TAG, "握手失败,状态=$status") |
||||
|
isUpgrading = false |
||||
|
} |
||||
|
} |
||||
|
0x09 -> { // 数据包确认: 0xBB 0x09 0x03 [序号(2byte)] [0x01] CRC |
||||
|
if (length < 3) { |
||||
|
Log.e(TAG, "数据包响应长度错误: $length") |
||||
|
return |
||||
|
} |
||||
|
val seqHi = data[3].toInt() and 0xFF |
||||
|
val seqLo = data[4].toInt() and 0xFF |
||||
|
val status = data[5].toInt() and 0xFF |
||||
|
val ackSeq = (seqHi shl 8) or seqLo |
||||
|
Log.d(TAG, "数据包确认: seq=$ackSeq, status=$status") |
||||
|
if (status == 0x01) { |
||||
|
// 更新进度(1基准) |
||||
|
val progress = ((ackSeq.coerceAtMost(otaTotalPackets)) * 100) / (otaTotalPackets.coerceAtLeast(1)) |
||||
|
callback?.notifyUpgradeProgressReceived(progress) |
||||
|
// 继续发送下一包 |
||||
|
if (ackSeq >= otaTotalPackets) { |
||||
|
Log.i(TAG, "OTA完成,共发送${otaTotalPackets}包") |
||||
|
callback?.notifyUpgradeProgressReceived(100) |
||||
|
isUpgrading = false |
||||
|
} else { |
||||
|
currentSeq = ackSeq + 1 |
||||
|
sendNextDataPacket() |
||||
|
} |
||||
|
} else { |
||||
|
Log.e(TAG, "数据包确认失败,状态=$status") |
||||
|
// 可按需重试,这里简单结束 |
||||
|
isUpgrading = false |
||||
|
} |
||||
|
} |
||||
|
else -> { |
||||
|
Log.d(TAG, "收到未知OTA响应CMD=0x${cmd.toString(16)}") |
||||
|
} |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// 设备主动上报(0xCC)可以忽略或保留原逻辑; OTA场景下无特殊处理 |
||||
|
if (header == (BleConst.DEVICE_NOTIFY_HEADER.toInt() and 0xFF)) { |
||||
|
Log.d(TAG, "收到设备主动上报(OTA通道): CMD=0x${cmd.toString(16)} 长度=$length") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
* 清空命令队列 |
||||
|
*/ |
||||
|
fun clearCommandQueue() { |
||||
|
commandQueue.clear() |
||||
|
Log.d(TAG, "命令队列已清空") |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取当前队列长度 |
||||
|
* @return 队列中待发送的命令数量 |
||||
|
*/ |
||||
|
fun getQueueSize(): Int = commandQueue.size |
||||
|
|
||||
|
/** |
||||
|
* 重置发送状态 |
||||
|
*/ |
||||
|
fun reset() { |
||||
|
isWaitingReply = true |
||||
|
replyCommandType = 0xff |
||||
|
replyTimeoutHandler.removeCallbacks(replyTimeoutRunnable) |
||||
|
clearCommandQueue() |
||||
|
Log.d(TAG, "指令发送器已重置") |
||||
|
} |
||||
|
|
||||
|
// ====================================================================================================== |
||||
|
// 私有方法 |
||||
|
// ====================================================================================================== |
||||
|
|
||||
|
/** |
||||
|
* 创建命令包 |
||||
|
* @param cmdType 命令类型 |
||||
|
* @param cmdData 命令数据 |
||||
|
* @return 完整的命令包(包含帧头、命令、长度、数据、CRC) |
||||
|
*/ |
||||
|
private fun createCommandPacket(cmdType: Byte, cmdData: ByteArray): ByteArray { |
||||
|
val cmdLength = cmdData.size |
||||
|
val cmdPacket = ByteArray(4 + cmdLength) // 帧头 + 命令 + 长度 + 数据 + CRC |
||||
|
|
||||
|
// 帧头 |
||||
|
cmdPacket[0] = BleConst.APP_REQUEST_HEADER |
||||
|
// 命令类型 |
||||
|
cmdPacket[1] = cmdType |
||||
|
// 命令长度 |
||||
|
cmdPacket[2] = cmdLength.toByte() |
||||
|
|
||||
|
// 填充命令数据(如果有) |
||||
|
if (cmdLength > 0) { |
||||
|
System.arraycopy(cmdData, 0, cmdPacket, 3, cmdLength) |
||||
|
} |
||||
|
|
||||
|
// 计算CRC - 对帧头到数据的所有字节进行校验 (不包括CRC自身) |
||||
|
val crcPayload = cmdPacket.sliceArray(0 until cmdPacket.size - 1) |
||||
|
val crc = calculateCrc8Maxim(crcPayload, crcPayload.size) |
||||
|
|
||||
|
// 设置CRC |
||||
|
cmdPacket[3 + cmdLength] = crc |
||||
|
|
||||
|
return cmdPacket |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 记录命令详情 |
||||
|
* @param cmdType 命令类型 |
||||
|
* @param cmdData 命令数据 |
||||
|
* @param cmdPacket 完整命令包 |
||||
|
*/ |
||||
|
private fun logCommandDetails(cmdType: Byte, cmdData: ByteArray, cmdPacket: ByteArray) { |
||||
|
val crc = cmdPacket[cmdPacket.size - 1] |
||||
|
Log.d( |
||||
|
TAG, |
||||
|
"发送命令: CMD=0x${(cmdType.toInt() and 0xFF).toString(16)}, 长度=${cmdData.size}, " + |
||||
|
"数据=${cmdData.joinToString(", ") { "0x${(it.toInt() and 0xFF).toString(16)}" }}, " + |
||||
|
"CRC=0x${(crc.toInt() and 0xFF).toString(16)}" |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 处理队列中的下一个命令 |
||||
|
*/ |
||||
|
private fun processNextCommand() { |
||||
|
if (commandQueue.isEmpty()) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
val callback = this.callback ?: return |
||||
|
val nextCmd = commandQueue.poll() ?: return |
||||
|
|
||||
|
val writeChar = callback.getWriteCharacteristic() ?: run { |
||||
|
Log.e(TAG, "处理下一个命令失败: 写入特征未找到") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
val gatt = callback.getBluetoothGatt() ?: run { |
||||
|
Log.e(TAG, "处理下一个命令失败: GATT连接未找到") |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
writeChar.value = nextCmd |
||||
|
val isSuccess = gatt.writeCharacteristic(writeChar) |
||||
|
if (isSuccess) { |
||||
|
isWaitingReply = false |
||||
|
startReplyTimeout() |
||||
|
Log.d(TAG, "发送队列中的下一个命令,剩余队列长度: ${commandQueue.size}") |
||||
|
} else { |
||||
|
Log.e(TAG, "发送队列中的命令失败") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 启动响应超时计时器 |
||||
|
*/ |
||||
|
private fun startReplyTimeout() { |
||||
|
replyTimeoutHandler.postDelayed(replyTimeoutRunnable, REPLY_TIMEOUT_MS) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 计算CRC8/MAXIM校验值 |
||||
|
* @param data 要校验的数据 |
||||
|
* @param length 数据长度 |
||||
|
* @return CRC8校验值 |
||||
|
*/ |
||||
|
private fun calculateCrc8Maxim(data: ByteArray, length: Int): Byte { |
||||
|
var crc = 0 |
||||
|
val polynomial = 0x31 |
||||
|
|
||||
|
for (i in 0 until length) { |
||||
|
crc = crc xor (data[i].toInt() and 0xFF) |
||||
|
for (j in 0 until 8) { |
||||
|
if (crc and 0x80 != 0) { |
||||
|
crc = ((crc shl 1) xor polynomial) and 0xFF |
||||
|
} else { |
||||
|
crc = (crc shl 1) and 0xFF |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return crc.toByte() |
||||
|
} |
||||
|
|
||||
|
// ========================================== |
||||
|
// OTA封包/发送辅助 |
||||
|
// ========================================== |
||||
|
private fun buildWakeupPacket(cmd: Int, payload: ByteArray): ByteArray { |
||||
|
val totalLen = 4 + payload.size + 1 // 帧头(3字节) + CMD(1) + LEN(1) + PAYLOAD + CRC(1) |
||||
|
val packet = ByteArray(totalLen) |
||||
|
packet[0] = BleConst.APP_REQUEST_HEADER // 0xAA |
||||
|
packet[1] = BleConst.DEVICE_RESPONSE_HEADER // 0xBB |
||||
|
packet[2] = BleConst.DEVICE_NOTIFY_HEADER // 0xCC |
||||
|
packet[3] = (cmd and 0xFF).toByte() |
||||
|
packet[4] = (payload.size and 0xFF).toByte() |
||||
|
if (payload.isNotEmpty()) { |
||||
|
System.arraycopy(payload, 0, packet, 5, payload.size) |
||||
|
} |
||||
|
val crc = calculateCrc8Maxim(packet.sliceArray(0 until packet.size - 1), packet.size - 1) |
||||
|
packet[packet.size - 1] = crc |
||||
|
return packet |
||||
|
} |
||||
|
|
||||
|
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) |
||||
|
private fun sendRawPacket(packet: ByteArray): Boolean { |
||||
|
val cb = this.callback ?: run { |
||||
|
Log.e(TAG, "发送失败: 回调未设置") |
||||
|
return false |
||||
|
} |
||||
|
if (!cb.isConnected()) { |
||||
|
Log.e(TAG, "发送失败: 未连接") |
||||
|
return false |
||||
|
} |
||||
|
val writeChar = cb.getWriteCharacteristic() ?: run { |
||||
|
Log.e(TAG, "发送失败: 写特征为空") |
||||
|
return false |
||||
|
} |
||||
|
val gatt = cb.getBluetoothGatt() ?: run { |
||||
|
Log.e(TAG, "发送失败: GATT为空") |
||||
|
return false |
||||
|
} |
||||
|
writeChar.value = packet |
||||
|
val ok = gatt.writeCharacteristic(writeChar) |
||||
|
if (ok) { |
||||
|
isWaitingReply = false |
||||
|
startReplyTimeout() |
||||
|
} |
||||
|
return ok |
||||
|
} |
||||
|
|
||||
|
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT) |
||||
|
private fun sendNextDataPacket() { |
||||
|
val bytes = otaBytes ?: run { |
||||
|
Log.e(TAG, "发送数据失败: otaBytes为空") |
||||
|
return |
||||
|
} |
||||
|
// 计算当前分片范围 |
||||
|
val start = (currentSeq - 1) * MAX_DATA_PER_PACKET |
||||
|
val end = minOf(start + MAX_DATA_PER_PACKET, bytes.size) |
||||
|
if (start >= end) { |
||||
|
Log.w(TAG, "没有更多数据可发送 start=$start end=$end") |
||||
|
return |
||||
|
} |
||||
|
val chunk = bytes.sliceArray(start until end) |
||||
|
val dataLen = chunk.size and 0xFF |
||||
|
val seqHi = ((currentSeq shr 8) and 0xFF).toByte() |
||||
|
val seqLo = (currentSeq and 0xFF).toByte() |
||||
|
// 负载: 序号(2byte) + 数据长度(1byte) + 数据(<=240byte) |
||||
|
val payload = ByteArray(3 + chunk.size) |
||||
|
payload[0] = seqHi |
||||
|
payload[1] = seqLo |
||||
|
payload[2] = dataLen.toByte() |
||||
|
System.arraycopy(chunk, 0, payload, 3, chunk.size) |
||||
|
val packet = buildWakeupPacket(0x09, payload) |
||||
|
val ok = sendRawPacket(packet) |
||||
|
if (!ok) { |
||||
|
Log.e(TAG, "写入数据包失败: seq=$currentSeq") |
||||
|
isUpgrading = false |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue