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