diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index a94fd434f..f24a39457 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -134,6 +134,8 @@ class BleManager extends GetxService { // 设备信息缓存 final deviceInfo = Rx(null); // 使用 Rx 包装 + final _wakeupVoiceUpgradeProgress = 0.obs; // 唤醒语音升级进度 + RxInt get wakeupVoiceUpgradeProgress => _wakeupVoiceUpgradeProgress; // 订阅 StreamSubscription? _statusSubscription; StreamSubscription? _dataSubscription; @@ -217,6 +219,15 @@ class BleManager extends GetxService { } } + if (event['type'] == 'wakeupVoiceUpgradeProgress') { + // 唤醒语音升级进度 + Logger.info('lxm--处理唤醒语音升级进度事件: $event'); + _wakeupVoiceUpgradeProgress.value = + event['progress'] as int? ?? 0; + Logger.info( + 'lxm--唤醒语音升级进度: ${_wakeupVoiceUpgradeProgress.value}%'); + } + Logger.info('lxm--准备调用_processDeviceInfo处理设备信息'); // 处理设备信息 _processDeviceInfo(event); @@ -1143,4 +1154,14 @@ class BleManager extends GetxService { return false; } } + + /// 开始唤醒词升级 + Future startWakeupVoicesUpgrade(List wakeupVoiceBytes) async { + try { + return await _bleService.startWakeupVoicesUpgrade(wakeupVoiceBytes); + } catch (e) { + Logger.error('开始唤醒词升级失败: ${e.toString()}'); + return false; + } + } } diff --git a/lib/data/services/network/api.dart b/lib/data/services/network/api.dart index ced2a2c6f..f9831bdf9 100644 --- a/lib/data/services/network/api.dart +++ b/lib/data/services/network/api.dart @@ -216,4 +216,13 @@ class Api { params: params, ); } + + //获取唤醒词 + static getwakeupvoices([params]) { + return DioManager().request( + NWMethod.post, + '/api/home/user_getwakeupvoices', + params: params, + ); + } } diff --git a/lib/data/services/network/dio_manager.dart b/lib/data/services/network/dio_manager.dart index 0496c11f9..e4c92c11d 100644 --- a/lib/data/services/network/dio_manager.dart +++ b/lib/data/services/network/dio_manager.dart @@ -1,6 +1,10 @@ import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart' hide Response; +import 'package:flutter/foundation.dart'; +import 'dart:io'; +import 'package:path/path.dart' as p; import 'auth_interceptor.dart'; import 'nw_method.dart'; @@ -54,4 +58,100 @@ class DioManager { ); return response.data; } + + /// 下载二进制字节(支持可选忽略证书,仅限移动/桌面端) + Future> downloadBytes(String url, {bool allowInsecure = false}) async { + // 使用独立 Dio,避免全局适配器被修改 + final client = Dio(BaseOptions( + responseType: ResponseType.bytes, + followRedirects: true, + receiveTimeout: const Duration(seconds: 60), + connectTimeout: const Duration(seconds: 60), + )); + client.interceptors.add(AuthInterceptor()); + + // 仅在非Web端尝试忽略证书 + if (allowInsecure && !kIsWeb) { + client.httpClientAdapter = IOHttpClientAdapter(createHttpClient: () { + // ignore: unnecessary_new + final ioClient = new HttpClient(); + ioClient.badCertificateCallback = (cert, host, port) => true; + return ioClient; + }); + } + + final resp = await client.get(url); + final status = resp.statusCode ?? 0; + if (status >= 200 && status < 300) { + // Dio v5 将 bytes 直接作为 List 返回 + final data = resp.data; + if (data is List) return data; + // 某些平台可能返回 Uint8List + if (data is List) { + return List.from(data); + } + throw Exception('下载返回格式异常'); + } + throw Exception('下载失败($status)'); + } + + /// 按 .env 配置决定是否允许忽略证书 + Future> downloadBytesWithEnv(String url) async { + final allow = (dotenv.env['ALLOW_INSECURE_SSL'] ?? 'false').toLowerCase() == 'true'; + return downloadBytes(url, allowInsecure: allow); + } + + /// 使用 Dio 的 download API 下载到临时文件并读出字节 + Future> downloadBytesUsingDownload(String url, {bool allowInsecure = false}) async { + final client = Dio(BaseOptions( + followRedirects: true, + receiveTimeout: const Duration(seconds: 120), + connectTimeout: const Duration(seconds: 60), + )); + client.interceptors.add(AuthInterceptor()); + + if (allowInsecure && !kIsWeb) { + client.httpClientAdapter = IOHttpClientAdapter(createHttpClient: () { + final ioClient = HttpClient(); + ioClient.badCertificateCallback = (cert, host, port) => true; + return ioClient; + }); + } + + final tmpDir = await Directory.systemTemp.createTemp('voice_pkg_'); + final baseName = _fileNameFromUrl(url); + final savePath = p.join(tmpDir.path, baseName); + + await client.download( + url, + savePath, + options: Options(responseType: ResponseType.bytes), + ); + + final file = File(savePath); + final bytes = await file.readAsBytes(); + // 清理临时文件 + try { + await file.delete(); + await tmpDir.delete(recursive: true); + } catch (_) {} + return bytes; + } + + /// 读取 .env 决定是否忽略证书,使用 download API + Future> downloadBytesUsingDownloadWithEnv(String url) async { + final allow = (dotenv.env['ALLOW_INSECURE_SSL'] ?? 'false').toLowerCase() == 'true'; + return downloadBytesUsingDownload(url, allowInsecure: allow); + } + + String _fileNameFromUrl(String url) { + try { + final clean = url.split('?').first; + final last = clean.split('/').last; + if (last.isEmpty) return 'voice.pkg'; + return last; + } catch (_) { + return 'voice.pkg'; + } + } } diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 81a017434..d9e944d33 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -607,6 +607,18 @@ class SettingsView extends GetView { // ? Colors.white.withOpacity(0.1) // : Colors.grey[200]), // 测试ble耳机固件版本升级 + _buildSimpleNavigationSetting( + title: '唤醒词'.tr, // 固件升级 + subtitle: '唤醒词管理'.tr, // BLE耳机固件版本升级 + icon: Icons.voice_chat, + iconBgColor: isDarkMode + ? Colors.orange[900]!.withOpacity(0.3) + : Colors.orange[100]!, + iconColor: + isDarkMode ? Colors.orange[300]! : Colors.orange[600]!, + onTap: () => Get.toNamed(Routes.wakeupvoice), + isDarkMode: isDarkMode, + ), _buildSimpleNavigationSetting( title: 'firmwareUpgrade'.tr, // 固件升级 subtitle: 'bleHeadsetFirmwareUpgrade'.tr, // BLE耳机固件版本升级 @@ -619,6 +631,7 @@ class SettingsView extends GetView { onTap: () => Get.toNamed(Routes.OTA_UPGRADE), isDarkMode: isDarkMode, ), + // if (Platform.isAndroid) ...[ Divider( height: 1, diff --git a/lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart b/lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart new file mode 100644 index 000000000..68d4c590f --- /dev/null +++ b/lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart @@ -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()); + } +} + diff --git a/lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart b/lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart new file mode 100644 index 000000000..3d5c4c63c --- /dev/null +++ b/lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart @@ -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(); + // 本地自定义唤醒词(原有) + final RxList wakeWords = ['你好,深声', 'Hey Voitrans'].obs; + final RxString inputText = ''.obs; + + // 服务端唤醒语音包列表 + final RxList voices = [].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(bleManager.connectionStateRx, (state) { + isConnected.value = bleManager.isConnected; + final info = bleManager.deviceInfo.value; + if (info != null && info.name.isNotEmpty) { + deviceName.value = info.name; + } + }); + // 监听设备信息更新 + ever(bleManager.deviceInfo, (info) { + if (info != null && info.name.isNotEmpty) { + deviceName.value = info.name; + } + }); + // 监听升级进度 + ever(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 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>.from(res['voices'] as List); + + voices.assignAll(list + .map((e) => WakeupVoiceItem.fromJson(e as Map)) + .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 onActionPressed() async { + final label = actionText; + if (!actionEnabled) return; + + switch (label) { + case '升级': + await upgradeWakeVoice(); + break; + case '切换': + await switchWakeVoice(); + break; + default: + break; + } + } + + Future upgradeWakeVoice() async { + final sel = _findById(selectedVoiceId.value); + if (sel == null) return; + await _startUpgradeByItem(sel); + } + + Future switchWakeVoice() async { + final sel = _findById(selectedVoiceId.value); + if (sel == null) return; + await _startUpgradeByItem(sel); + } + + // 直接切换到指定语音包(页面卡片按钮使用) + Future switchWakeVoiceTo(WakeupVoiceItem item) async { + selectedVoiceId.value = item.id; + await _startUpgradeByItem(item); + } + + // 直接升级到指定语音包(页面卡片按钮使用) + Future upgradeWakeVoiceTo(WakeupVoiceItem item) async { + selectedVoiceId.value = item.id; + await _startUpgradeByItem(item); + } + + // 升级相关状态 + final RxnInt upgradingVoiceId = RxnInt(); + final RxInt upgradeProgress = 0.obs; + + Future _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。 + 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> _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 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 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 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; + } +} diff --git a/lib/modules/wakeupvoice/views/wakeup_voice_view.dart b/lib/modules/wakeupvoice/views/wakeup_voice_view.dart new file mode 100644 index 000000000..379723c7e --- /dev/null +++ b/lib/modules/wakeupvoice/views/wakeup_voice_view.dart @@ -0,0 +1,403 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import '../controllers/wakeup_voice_controller.dart'; + +class WakeupVoiceView extends GetView { + 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 pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); + List 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), + ), + ), + ], + ), + ], + ], + ), + ); + } + // 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计 +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 8d9b2ee2b..816c98986 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -49,6 +49,8 @@ import '../modules/pairing/bindings/pairing_binding.dart'; import './app_routes.dart'; import '../modules/settings/views/settings_view.dart'; import '../modules/settings/bindings/settings_binding.dart'; +import '../modules/wakeupvoice/views/wakeup_voice_view.dart'; +import '../modules/wakeupvoice/bindings/wakeup_voice_binding.dart'; import '../modules/permissions/views/permissions_view.dart'; import '../modules/permissions/bindings/permissions_binding.dart'; import '../modules/speech_test/views/speech_test_view.dart'; @@ -324,5 +326,12 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), + GetPage( + name: Routes.wakeupvoice, + page: () => const WakeupVoiceView(), + binding: WakeupVoiceBinding(), + transition: Transition.rightToLeft, + transitionDuration: Duration(milliseconds: 250), + ), ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index b513e0369..38e65a1af 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -69,4 +69,5 @@ abstract class Routes { // 支付相关 static const payment = '/payment'; // 支付测试页面 + static const wakeupvoice = '/wakeupvoice'; //唤醒词管理 } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 4d3f782c5..77c92bc80 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -161,6 +161,10 @@ Log.d(TAG, "手动启动语音识别: ") // 不处理设备信息 } + override fun onWakeupVoiceUpgradeProgressReceived(progress: Int) { + // 处理升级进度 + } + //============================================================================================ // AgentServiceListener 接口实现 //============================================================================================ diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 09fc9ebe6..36cef590f 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -1194,6 +1194,10 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin { override fun onDeviceInfoReceived(infoType: Int, infoData: Map) { // 不处理设备信息 } + + override fun onWakeupVoiceUpgradeProgressReceived(progress: Int) { + // 处理升级进度 + } } diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt index e0d572ed6..b028617cc 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt @@ -16,7 +16,7 @@ object BleConst { /** 通知特征UUID - 文档中定义为0000ABC2-0000-1111-2222-123456789ABC */ val NOTIFY_CHAR_UUID: UUID = UUID.fromString("0000abc2-0000-1111-2222-123456789abc") - /** 通话音频服务UUID - 文档中定义为0000ABC0-0001-1111-2222-123456789ABC */ + /** 通话音频服务UUID - 文档中定义为0000ABC0-0001-1111-2222-123456789ABC */ val CALL_AUDIO_SERVICE_UUID: UUID = UUID.fromString("0000ABC0-0001-1111-2222-123456789ABC") /** 通话写入音频特征UUID - 文档中定义为0000ABC1-0001-1111-2222-123456789ABC */ @@ -31,6 +31,11 @@ object BleConst { /** 客户端特征配置描述符UUID */ val CLIENT_CHAR_CONFIG_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + /** 唤醒语音服务UUID - 文档中定义为0000ABC0-0000-1111-2222-123456789ABC */ + val WakeupVoices_PRIMARY_SERVICE_UUID: UUID = UUID.fromString("0000ABC0-0002-1111-2222-123456789ABC") + val WakeupVoices_WRITE_UUID: UUID = UUID.fromString("0000ABC1-0002-1111-2222-123456789ABC") + val WakeupVoices_NOTIFY_UUID: UUID = UUID.fromString("0000ABC2-0002-1111-2222-123456789ABC") /** BLE扫描Action */ const val ACTION_SCAN_RESULT = "com.yunqiinnovation.ble_service.ACTION_BLE_SCAN" diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt index 71dea5336..ce2bbd8d6 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt @@ -56,6 +56,9 @@ object BleService { // 设备信息相关回调 - 统一回调接口 fun onDeviceInfoReceived(infoType: Int, infoData: Map) + + // 升级唤醒词进度相关回调 + fun onWakeupVoiceUpgradeProgressReceived(progress: Int) } // ====================================================================================================== @@ -70,10 +73,13 @@ object BleService { private var bluetoothAdapter: BluetoothAdapter? = null private var bluetoothLeScanner: BluetoothLeScanner? = null + // GATT连接相关 var bluetoothGatt: BluetoothGatt? = null private var notifyChar: BluetoothGattCharacteristic? = null private var writeChar: BluetoothGattCharacteristic? = null + private var wakeupVoicesNotifyChar: BluetoothGattCharacteristic? = null //唤醒词通知特征 + private var wakeupVoicesWriteChar: BluetoothGattCharacteristic? = null //唤醒词写入特征 private var audioChar: BluetoothGattCharacteristic? = null private var callAudioChar: BluetoothGattCharacteristic? = null private var callWriteChar: BluetoothGattCharacteristic? = null @@ -143,7 +149,8 @@ object BleService { /** 指令发送器实例 */ private val commandSender = BleCommandSender() - + /** 唤醒词升级指令发送器实例 */ + private var wakeupVoicesUpgradeSender = BleWakeupVoicesUpgradeSender() private var companionManager: BleCompanionManager? = null /** * 检查是否有蓝牙扫描权限 @@ -333,6 +340,40 @@ object BleService { return recordfile1 } }) + + // 初始化唤醒词升级指令发送器 + wakeupVoicesUpgradeSender.setCallback(object : BleWakeupVoicesUpgradeSender.CommandSenderCallback { + /** + * 检查是否已连接 + */ + override fun isConnected(): Boolean { + return bluetoothGatt != null && + connectionState.value == BleConst.STATE_CONNECTED && + wakeupVoicesWriteChar != null + } + + /** + * 获取写入特征 + */ + override fun getWriteCharacteristic(): BluetoothGattCharacteristic? { + return wakeupVoicesWriteChar + } + + /** + * 获取蓝牙GATT连接 + */ + override fun getBluetoothGatt(): BluetoothGatt? { + return bluetoothGatt + } + + /** + * 通知升级进度 + */ + override fun notifyUpgradeProgressReceived(progress: Int) { + // 使用外部类的方法来分发升级进度,避免无限递归 + this@BleService.notifyUpgradeProgressReceived(progress) + } + }) } /** @@ -705,6 +746,8 @@ object BleService { audioChar = null callAudioChar = null callWriteChar = null + wakeupVoicesNotifyChar = null + wakeupVoicesWriteChar = null } } @@ -840,8 +883,9 @@ object BleService { // 获取主服务特征 val primarySvc = g.getService(BleConst.PRIMARY_SERVICE_UUID) - notifyChar = primarySvc?.getCharacteristic(BleConst.NOTIFY_CHAR_UUID) - writeChar = primarySvc?.getCharacteristic(BleConst.WRITE_CHAR_UUID) + notifyChar = primarySvc?.getCharacteristic(BleConst.WakeupVoices_NOTIFY_UUID) + writeChar = primarySvc?.getCharacteristic(BleConst.WakeupVoices_WRITE_UUID) + // 获取音频服务特征 val audioSvc = g.getService(BleConst.AUDIO_SERVICE_UUID) @@ -864,21 +908,28 @@ object BleService { //要先设置音频服务的通知,否则接收不到 // 设置音频服务的通知(如果存在) if (callAudioChar != null && audioChar != null) { - setupNotifications(g, callAudioChar) - setupNotifications(g, audioChar) - - - Log.i(TAG, "通话音频服务特征找到并设置通知") + setupNotifications(g, callAudioChar) + setupNotifications(g, audioChar) + Log.i(TAG, "通话音频服务特征找到并设置通知") } else { Log.w(TAG, "通话音频服务特征未找到") } - setupNotifications(g, notifyChar) - + setupNotifications(g, notifyChar) + + // 获取唤醒词OTA主服务特征 + val wakeupVoicesSvc = g.getService(BleConst.WakeupVoices_PRIMARY_SERVICE_UUID) + wakeupVoicesNotifyChar = wakeupVoicesSvc?.getCharacteristic(BleConst.WakeupVoices_NOTIFY_UUID) + wakeupVoicesWriteChar = wakeupVoicesSvc?.getCharacteristic(BleConst.WakeupVoices_WRITE_UUID) + if (wakeupVoicesNotifyChar != null && wakeupVoicesWriteChar != null) { + wakeupVoicesWriteChar?.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + setupWakeupVoicesNotifications(g, wakeupVoicesNotifyChar) + }else{ + Log.w(TAG, "唤醒词OTA服务特征未找到") + } // 在服务发现完成后,请求最大MTU //requestMaxMtu(g) - } override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) { @@ -922,6 +973,10 @@ object BleService { } } } + BleConst.WakeupVoices_NOTIFY_UUID -> { + Log.i(TAG, "收到唤醒词OTA通知特征数据") + wakeupVoicesUpgradeSender.processDeviceNotification(data) + } // 其他未知特征 else -> { Log.d( @@ -1009,6 +1064,45 @@ object BleService { return true } + /** + * 设置唤醒词特征的通知功能 + * @param g BluetoothGatt实例 + * @param characteristic 要设置通知的特征 + */ + private fun setupWakeupVoicesNotifications(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic?) : Boolean{ + if (characteristic == null || characteristic.properties.and(BluetoothGattCharacteristic.PROPERTY_NOTIFY) == 0) { + Log.w(TAG, "唤醒词特征不支持通知功能: ${characteristic?.uuid}") + return false + } + + // 在本地启用特征通知监听 + val localResult = g.setCharacteristicNotification(characteristic, true) + if (!localResult) { + Log.e(TAG, "本地通知设置失败: ${characteristic.uuid}") + return false + } + Log.d(TAG, "本地通知设置成功: ${characteristic.uuid}") + + // 获取客户端特征配置描述符 + val desc = characteristic.getDescriptor(BleConst.CLIENT_CHAR_CONFIG_UUID) + if (desc == null) { + Log.w(TAG, "未找到客户端配置描述符: ${characteristic.uuid}") + return false + } + + // 设置描述符值为启用通知 + desc.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + + // 向远程设备写入描述符(异步操作,结果在onDescriptorWrite回调中处理) + val writeResult = g.writeDescriptor(desc) + if (writeResult) { + Log.d(TAG, "开始写入通知描述符: ${characteristic.uuid}") + } else { + Log.e(TAG, "通知描述符写入请求失败: ${characteristic.uuid}") + } + return true + } + /** * 请求最大MTU值 * @param gatt BluetoothGatt实例 @@ -1551,6 +1645,10 @@ object BleService { } ?: false } + fun startWakeupVoiceUpgrade(bytes: ByteArray): Boolean { + return wakeupVoicesUpgradeSender.startWakeupVoiceUpgrade(bytes) + } + // ====================================================================================================== // 资源清理 @@ -1669,6 +1767,16 @@ object BleService { } } + private fun notifyUpgradeProgressReceived(progress: Int) { + for (callback in callbacks) { + try { + callback.onWakeupVoiceUpgradeProgressReceived(progress) + } catch (e: Exception) { + Log.e(TAG, "分发升级进度回调异常", e) + } + } + } + /** * 获取PHY类型的字符串描述 */ @@ -1686,5 +1794,3 @@ object BleService { } } - - diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt index 75c61ff6c..322925cca 100644 --- a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt @@ -332,6 +332,12 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, Log.d(TAG, "getDeviceList $infos") result.success(infos) } + "startWakeupVoiceUpgrade" -> { + val bytes = call.argument>("bytes")?.map { it.toByte() }?.toByteArray() ?: ByteArray(0) + Log.d(TAG, "startWakeupVoiceUpgrade ${bytes.size}") + val ok = BleService.startWakeupVoiceUpgrade(bytes) + result.success(ok) + } "connect" -> { BleService.connect(); result.success(null) @@ -389,9 +395,19 @@ class BleServicePlugin : FlutterPlugin, MethodCallHandler, ActivityAware, val resultMap = infoData.toMutableMap() resultMap["type"] = "deviceInfo" resultMap["result"] = infoType - - + // 发送设备信息事件 sendEvent(statusEventSink, resultMap, "发送设备信息异常") } -} \ No newline at end of file + + override fun onWakeupVoiceUpgradeProgressReceived(progress: Int) { + // 升级进度通过状态通道发送 + val resultMap = mapOf( + "type" to "wakeupVoiceUpgradeProgress", + "progress" to progress + ) + + // 发送升级进度事件 + sendEvent(statusEventSink, resultMap, "发送升级进度异常") + } +} diff --git a/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeupVoicesUpgradeSender.kt b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeupVoicesUpgradeSender.kt new file mode 100644 index 000000000..01db4ac60 --- /dev/null +++ b/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeupVoicesUpgradeSender.kt @@ -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 = 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 + } + } +} diff --git a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift index 5b0b26fb2..8a706ff10 100644 --- a/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift +++ b/local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift @@ -263,6 +263,17 @@ extension SwiftBleServicePlugin: BleService.Callback { } } } + + public func onWakeupVoiceUpgradeProgressReceived(progress: Int) { + if let eventSink = statusEventSink { + DispatchQueue.main.async { + eventSink([ + "type": "upgradeProgress", + "progress": progress + ]) + } + } + } } // MARK: - 独立的StreamHandler类 diff --git a/local_plugins/ble_service/lib/ble_service.dart b/local_plugins/ble_service/lib/ble_service.dart index deedbc318..995ab4280 100644 --- a/local_plugins/ble_service/lib/ble_service.dart +++ b/local_plugins/ble_service/lib/ble_service.dart @@ -528,4 +528,19 @@ class BleService { return false; } } + + /// 开始唤醒词升级 + /// 在收到唤醒信号时启动语音识别 + Future startWakeupVoicesUpgrade(List wakeupVoiceBytes) async { + try { + final result = await _methodChannel.invokeMethod( + 'startWakeupVoiceUpgrade', + {"bytes": wakeupVoiceBytes}, + ); + return result ?? false; + } catch (e) { + print('开始唤醒词升级失败: $e'); + return false; + } + } } diff --git a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt index 2eecf9b5e..93c63aed4 100644 --- a/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt +++ b/local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt @@ -395,5 +395,7 @@ class OtaPlugin : BleService.Callback, FlutterPlugin, MethodCallHandler { // 不处理设备信息 } - + override fun onWakeupVoiceUpgradeProgressReceived(progress: Int) { + // 不处理升级进度 + } }