Browse Source

上传代码

weicu
liwei1dao 11 months ago
parent
commit
4435bbdce8
  1. 21
      lib/data/services/ble_manager.dart
  2. 9
      lib/data/services/network/api.dart
  3. 100
      lib/data/services/network/dio_manager.dart
  4. 13
      lib/modules/settings/views/settings_view.dart
  5. 10
      lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart
  6. 387
      lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart
  7. 403
      lib/modules/wakeupvoice/views/wakeup_voice_view.dart
  8. 9
      lib/routes/app_pages.dart
  9. 1
      lib/routes/app_routes.dart
  10. 4
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt
  11. 4
      local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  12. 7
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleConst.kt
  13. 132
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleService.kt
  14. 22
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleServicePlugin.kt
  15. 493
      local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleWakeupVoicesUpgradeSender.kt
  16. 11
      local_plugins/ble_service/ios/ble_service/Sources/ble_service/SwiftBleServicePlugin.swift
  17. 15
      local_plugins/ble_service/lib/ble_service.dart
  18. 4
      local_plugins/ota/android/src/main/kotlin/com/example/ota/OtaPlugin.kt

21
lib/data/services/ble_manager.dart

@ -134,6 +134,8 @@ class BleManager extends GetxService {
// 设备信息缓存
final deviceInfo = Rx<DeviceInfo?>(null); // 使用 Rx 包装
final _wakeupVoiceUpgradeProgress = 0.obs; // 唤醒语音升级进度
RxInt get wakeupVoiceUpgradeProgress => _wakeupVoiceUpgradeProgress;
// 订阅
StreamSubscription<dynamic>? _statusSubscription;
StreamSubscription<dynamic>? _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<bool> startWakeupVoicesUpgrade(List<int> wakeupVoiceBytes) async {
try {
return await _bleService.startWakeupVoicesUpgrade(wakeupVoiceBytes);
} catch (e) {
Logger.error('开始唤醒词升级失败: ${e.toString()}');
return false;
}
}
}

9
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,
);
}
}

100
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<List<int>> 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<int> 返回
final data = resp.data;
if (data is List<int>) return data;
// 某些平台可能返回 Uint8List
if (data is List) {
return List<int>.from(data);
}
throw Exception('下载返回格式异常');
}
throw Exception('下载失败($status)');
}
/// 按 .env 配置决定是否允许忽略证书
Future<List<int>> downloadBytesWithEnv(String url) async {
final allow = (dotenv.env['ALLOW_INSECURE_SSL'] ?? 'false').toLowerCase() == 'true';
return downloadBytes(url, allowInsecure: allow);
}
/// 使用 Dio 的 download API 下载到临时文件并读出字节
Future<List<int>> 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<List<int>> 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';
}
}
}

13
lib/modules/settings/views/settings_view.dart

@ -607,6 +607,18 @@ class SettingsView extends GetView<SettingsController> {
// ? 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<SettingsController> {
onTap: () => Get.toNamed(Routes.OTA_UPGRADE),
isDarkMode: isDarkMode,
),
// if (Platform.isAndroid) ...[
Divider(
height: 1,

10
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>(() => WakeupVoiceController());
}
}

387
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<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;
}
}

403
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<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),
),
),
],
),
],
],
),
);
}
// 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计
}

9
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),
),
];
}

1
lib/routes/app_routes.dart

@ -69,4 +69,5 @@ abstract class Routes {
// 支付相关
static const payment = '/payment'; // 支付测试页面
static const wakeupvoice = '/wakeupvoice'; //唤醒词管理
}

4
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 接口实现
//============================================================================================

4
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<String, Any>) {
// 不处理设备信息
}
override fun onWakeupVoiceUpgradeProgressReceived(progress: Int) {
// 处理升级进度
}
}

7
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"

132
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<String, Any>)
// 升级唤醒词进度相关回调
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 {
}
}

22
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<List<Int>>("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, "发送设备信息异常")
}
}
override fun onWakeupVoiceUpgradeProgressReceived(progress: Int) {
// 升级进度通过状态通道发送
val resultMap = mapOf(
"type" to "wakeupVoiceUpgradeProgress",
"progress" to progress
)
// 发送升级进度事件
sendEvent(statusEventSink, resultMap, "发送升级进度异常")
}
}

493
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<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
}
}
}

11
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类

15
local_plugins/ble_service/lib/ble_service.dart

@ -528,4 +528,19 @@ class BleService {
return false;
}
}
/// 开始唤醒词升级
/// 在收到唤醒信号时启动语音识别
Future<bool> startWakeupVoicesUpgrade(List<int> wakeupVoiceBytes) async {
try {
final result = await _methodChannel.invokeMethod<bool>(
'startWakeupVoiceUpgrade',
{"bytes": wakeupVoiceBytes},
);
return result ?? false;
} catch (e) {
print('开始唤醒词升级失败: $e');
return false;
}
}
}

4
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) {
// 不处理升级进度
}
}

Loading…
Cancel
Save