diff --git a/lib/data/models/user_Info.dart b/lib/data/models/user_Info.dart index 87e2cfbce..2467ba7bd 100644 --- a/lib/data/models/user_Info.dart +++ b/lib/data/models/user_Info.dart @@ -212,6 +212,7 @@ class User { class UserDevice { int id; String uid; + int productid; int devicetype; String devicename; String devicemac; @@ -219,6 +220,7 @@ class UserDevice { UserDevice({ required this.id, required this.uid, + required this.productid, required this.devicetype, required this.devicename, required this.devicemac, @@ -228,6 +230,7 @@ class UserDevice { factory UserDevice.fromJson(Map json) => UserDevice( id: json['id'] as int, uid: json['uid'] as String, + productid: (json['productid'] as num).toInt(), devicetype: (json['devicetype'] as num).toInt(), devicename: json['devicename'] as String, devicemac: json['devicemac'] as String, @@ -237,6 +240,7 @@ class UserDevice { toJson() => { 'id': id, 'uid': uid, + 'productid': productid, 'devicetype': devicetype, 'devicename': devicename, 'devicemac': devicemac, diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index ddf05eedc..7327929b6 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_storage/get_storage.dart'; import 'package:get/get.dart'; import 'package:ble_service/ble_service.dart'; @@ -587,23 +586,17 @@ class BleManager extends GetxService { } } - /// 更新设备id + /// 更新设备id,并检测是否为移动精灵设备 void _updateProductIdFromData(Map data) { try { - // 确保数据中包含版本信息 if (!data.containsKey('productId')) { Logger.warning('版本信息数据不完整'); return; } - // 提取版本信息 final String productId = data['productId'] as String? ?? ''; - // 更新设备信息 - _updateDeviceInfoIfNeeded( - firmwareProductId: productId, - ); - + _updateDeviceInfoIfNeeded(firmwareProductId: productId); Logger.info('设备设备id已更新: 设备id=$productId'); } catch (e) { Logger.error('更新设备id失败: ${e.toString()}'); @@ -959,14 +952,18 @@ class BleManager extends GetxService { } // STS 模式参数(移动精灵) - final agentType = AppConfig.env('AGENT_TYPE') ?? 'yidong'; + // 优先使用设备检测结果(YIDONG_PID 匹配时写入),否则用服务端 AGENT_TYPE + final agentType = _storage.read('device_agent_type') + ?? AppConfig.env('AGENT_TYPE') + ?? 'stt_llm_tts'; final agentMode = (agentType == 'yidong') ? 'sts' : 'stt_llm_tts'; final agentId = (agentType == 'yidong') ? (AppConfig.env('MOBILE_ELF_AGENT_ID') ?? 'mobile_elf') : 'default_agent'; final stsProductId = AppConfig.env('MOBILE_ELF_PRODUCT_ID') ?? ''; final stsProductKey = AppConfig.env('MOBILE_ELF_PRODUCT_KEY') ?? ''; - final stsDeviceNo = dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? ''; + // 使用当前连接设备的 SN(配对时写入),认证失败由原生层上报 credential_failed 事件 + final stsDeviceNo = _storage.read('device_sts_code') ?? ''; final stsDeviceId = _storage.read('sts_device_id') ?? ''; final stsDeviceSecret = _storage.read('sts_device_secret') ?? ''; diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 7df97989b..55d431d59 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -256,7 +256,10 @@ class HomeController extends GetxController { } void exploreAIFeatures() { - final agentType = AppConfig.env('AGENT_TYPE') ?? 'yidong'; + // 优先读设备检测写入的 storage(YIDONG_PID 匹配时),否则降级到服务端 AGENT_TYPE + final agentType = GetStorage().read('device_agent_type') + ?? AppConfig.env('AGENT_TYPE') + ?? 'stt_llm_tts'; if (agentType == 'yidong') { Get.toNamed(Routes.mobileElf); } else { diff --git a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart index 6393fc975..e11b599fa 100644 --- a/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart +++ b/lib/modules/mobile_elf/controllers/mobile_elf_controller.dart @@ -3,6 +3,7 @@ import 'package:agent_service/agent_service.dart'; import 'package:chat_storage/chat_storage.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../../data/models/appconfig.dart'; import '../../../core/utils/logger.dart'; @@ -39,6 +40,9 @@ class MobileElfController extends GetxController { // 凭证状态(原生层负责,Dart 层始终无错) final credentialReady = true.obs; final credentialError = ''.obs; + // 设备认证失败(STS credential_failed 事件触发) + final credentialFailed = false.obs; + final credentialFailedReason = ''.obs; // ── 内部 ─────────────────────────────────────────────────────────────────── StreamSubscription? _eventSub; @@ -46,6 +50,13 @@ class MobileElfController extends GetxController { Timer? _pttTimer; bool _inCallMode = false; bool _callStarting = false; // 防止并发 startCall + // PTT 松开后仍有异步事件待处理(recognition_result / assistant_response / tts) + bool _pttResultPending = false; + // PTT 正在异步启动(await 权限期间用户已松手) + bool _pttStarting = false; + // BLE/AI 唤醒触发的会话(非通话、非PTT模式下的唤醒) + bool _bleWakeupActive = false; + final _storage = GetStorage(); // ═══════════════════════════════════════════════════════════════════════════ // 生命周期 @@ -62,6 +73,8 @@ class MobileElfController extends GetxController { ever(ttsEnabled, (bool enabled) { AgentService.setTtsMuted(!enabled); }); + // 主动触发认证检查(页面打开时,确保 credential_failed 事件不被错过) + AgentService.fetchCredentials(); } Future _loadChatHistory() async { @@ -107,11 +120,13 @@ class MobileElfController extends GetxController { void _onAgentEvent(AgentServiceEvent event) { Logger.info('$_tag event: ${event.type} data: ${event.data}'); - // 已挂断/停止时忽略原生层残留的状态事件 - final sessionActive = _inCallMode || pttRecording.value; + // PTT 松开后 pttRecording=false,但 fin_result/assistant_response/tts 仍异步到达 + // 用 _pttResultPending 保持会话激活,直到 ttsCompleted/error 才关闭 + final sessionActive = _inCallMode || pttRecording.value || _pttResultPending || _bleWakeupActive; switch (event.type) { case AgentServiceEventType.recognitionStarted: - if (!sessionActive) break; + // BLE/AI 唤醒:即使没有主动会话也激活 + if (!sessionActive) _bleWakeupActive = true; status.value = MobileElfStatus.recording; realtimeText.value = ''; micAlive.value = true; @@ -157,6 +172,8 @@ class MobileElfController extends GetxController { case AgentServiceEventType.ttsCompleted: if (!sessionActive) break; + _pttResultPending = false; // PTT 一轮结束 + _bleWakeupActive = false; // BLE 唤醒一轮结束 if (_inCallMode) { status.value = MobileElfStatus.recording; micAlive.value = true; @@ -169,12 +186,16 @@ class MobileElfController extends GetxController { case AgentServiceEventType.ttsCanceled: case AgentServiceEventType.ttsStopped: if (!sessionActive) break; + _pttResultPending = false; // TTS 被打断也视为结束 + _bleWakeupActive = false; status.value = _inCallMode ? MobileElfStatus.recording : MobileElfStatus.idle; micAlive.value = _inCallMode; break; case AgentServiceEventType.error: if (!sessionActive) break; + _pttResultPending = false; // 出错时结束等待 + _bleWakeupActive = false; final msg = event.data['message']?.toString() ?? '未知错误'; status.value = MobileElfStatus.error; errorMsg.value = msg; @@ -186,6 +207,24 @@ class MobileElfController extends GetxController { micRms.value = rms; break; + case AgentServiceEventType.heartbeatReported: + // 记录心跳上报时间 + final t = (event.data['time'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch; + _storage.write('sts_heartbeat_time', t); + Logger.info('$_tag 心跳上报成功,时间: $t'); + break; + + case AgentServiceEventType.credentialFailed: + final reason = event.data['reason']?.toString() ?? '设备未认证'; + Logger.error('$_tag 设备认证失败: $reason'); + credentialFailed.value = true; + credentialFailedReason.value = reason; + // 确保停止任何进行中的会话 + if (_inCallMode || pttRecording.value) { + disconnect(); + } + break; + default: break; } @@ -199,7 +238,18 @@ class MobileElfController extends GetxController { Future startCall() async { if (inCall.value || _callStarting) return; _callStarting = true; + _checkHeartbeat(); try { + // 若有正在播报的 PTT/BLE 唤醒会话,先强制中断 TTS/AI 流, + // 使原生层立刻进入可停止状态,避免 stopConversation 长时间等待 + if (_pttResultPending || _bleWakeupActive) { + _pttResultPending = false; + _bleWakeupActive = false; + await AgentService.interruptCurrentResponse(); + } + // 再确保原生层完全停止 + await AgentService.stopConversation(); + final granted = await PermissionUtil.instance.requestPermission( permissionType: Permission.microphone, permissionName: '麦克风', @@ -216,6 +266,9 @@ class MobileElfController extends GetxController { _callTimer = Timer.periodic( const Duration(seconds: 1), (_) => callSeconds.value++); await AgentService.startConversation(mode: 'phone_call'); + // startCall 返回即表示麦克风已打开、VAD 已在监听,直接显示聆听状态 + // (call 模式下 recognition_started 只在 VAD 检测到语音时才发出,不能等它来转状态) + if (_inCallMode) status.value = MobileElfStatus.recording; } finally { _callStarting = false; } @@ -224,6 +277,9 @@ class MobileElfController extends GetxController { Future disconnect() async { _callStarting = false; _inCallMode = false; + _pttStarting = false; + _pttResultPending = false; // 强制结束所有等待 + _bleWakeupActive = false; inCall.value = false; _callTimer?.cancel(); _callTimer = null; @@ -237,15 +293,30 @@ class MobileElfController extends GetxController { await AgentService.stopConversation(); } + /// 超过1小时未上报心跳则补报一次 + void _checkHeartbeat() { + final last = _storage.read('sts_heartbeat_time') ?? 0; + if (DateTime.now().millisecondsSinceEpoch - last > 3600000) { + AgentService.reportHeartbeat().catchError((_) {}); + } + } + Future startPtt() async { - if (pttRecording.value) return; + if (pttRecording.value || _pttStarting) return; + _pttStarting = true; + _checkHeartbeat(); final granted = await PermissionUtil.instance.requestPermission( permissionType: Permission.microphone, permissionName: '麦克风', explanationText: '移动精灵需要麦克风权限才能进行语音对话', permanentDenialText: '请在设置中开启麦克风权限以使用语音功能', ); - if (!granted) return; + // 权限等待期间用户已松手(stopPtt 将 _pttStarting 置 false),直接放弃 + if (!granted || !_pttStarting) { + _pttStarting = false; + return; + } + _pttStarting = false; pttRecording.value = true; pttSeconds.value = 0; status.value = MobileElfStatus.connecting; @@ -256,8 +327,14 @@ class MobileElfController extends GetxController { } void stopPtt() { + // 若仍在等待权限授予,取消本次启动 + if (_pttStarting) { + _pttStarting = false; + return; + } if (!pttRecording.value) return; pttRecording.value = false; + _pttResultPending = true; // 等待 fin_result/assistant_response/tts 异步回来 _pttTimer?.cancel(); _pttTimer = null; AgentService.stopConversation(); diff --git a/lib/modules/mobile_elf/views/mobile_elf_view.dart b/lib/modules/mobile_elf/views/mobile_elf_view.dart index da76aa701..dad19841f 100644 --- a/lib/modules/mobile_elf/views/mobile_elf_view.dart +++ b/lib/modules/mobile_elf/views/mobile_elf_view.dart @@ -143,6 +143,17 @@ class MobileElfView extends GetView { /// 右上角状态胶囊 + 通话计时 Widget _buildStatusPill() { + if (controller.credentialFailed.value) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 3.h), + decoration: BoxDecoration(color: const Color(0xFFFEF2F2), borderRadius: BorderRadius.circular(20.r)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.lock_outline_rounded, size: 11.sp, color: _red), + SizedBox(width: 4.w), + Text('认证失败', style: TextStyle(fontSize: 11.sp, color: _red, fontWeight: FontWeight.w500)), + ]), + ); + } final s = controller.status.value; final inCall = controller.inCall.value; final cfg = _pillConfig(s, inCall); @@ -444,6 +455,7 @@ class MobileElfView extends GetView { Widget _buildBottomBar(bool isDark) { return Obx(() { + final inCall = controller.inCall.value; final s = controller.status.value; final ptt = controller.pttRecording.value; @@ -621,9 +633,10 @@ class MobileElfView extends GetView { final s = controller.status.value; final ptt = controller.pttRecording.value; - final disabled = !ptt && s != MobileElfStatus.idle && s != MobileElfStatus.error; - final pttClickable = s == MobileElfStatus.speaking; - final sideDisabled = ptt || (disabled && !pttClickable); + final locked = controller.credentialFailed.value; + final disabled = locked || (!ptt && s != MobileElfStatus.idle && s != MobileElfStatus.error); + final pttClickable = !locked && s == MobileElfStatus.speaking; + final sideDisabled = locked || ptt || (disabled && !pttClickable); return Padding( padding: EdgeInsets.fromLTRB(12.w, 6.h, 12.w, 14.h), diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 2b63098c6..5156ea8b4 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -810,6 +810,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { orElse: () => UserDevice( id: -1, uid: '', + productid: -1, devicetype: -1, devicename: '', devicemac: '', @@ -840,15 +841,21 @@ class PairingController extends GetxController with WidgetsBindingObserver { orElse: () => UserDevice( id: -1, uid: '', + productid: -1, devicetype: -1, devicename: '', devicemac: '', license: ''), ); if (existingDevice.id != -1) { + _verifiedDevicePid = existingDevice.productid; _verifiedDeviceName = device.name; _verifiedDeviceAddress = device.address; - // 设备时ble 蓝牙 且已绑定 + // 已绑定设备直接用 license 作为 deviceCode(即绑定时扫到的 SN) + if (existingDevice.license.isNotEmpty) { + deviceCode.value = existingDevice.license; + Logger.i(_tag, '[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}'); + } return 10; } } @@ -885,14 +892,15 @@ class PairingController extends GetxController with WidgetsBindingObserver { code = extractAndReverseBytes(hexData, 13, 22); _verifiedDevicePid = extractAndReverseBytesToInt(hexData, 4, 5); - print('找到匹配设备,获取到 code: $code'); + final pidHexIos = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; + Logger.i(_tag, '[YIDONG] iOS BLE扫描 MAC匹配 device=${device.name} pidHex=$pidHexIos code=$code'); if (code != "") { _bleManager.stopScan(); deviceCode.value = code; String? uuids = deviceInfo["id"]; _verifiedDeviceName = device.name; _verifiedDeviceAddress = uuids; - print('找到匹配设备,获取到 uuids: $uuids'); + Logger.i(_tag, '[YIDONG] iOS deviceCode提取成功 code=$code uuid=$uuids'); //检查是否已经绑定 final existingDevice = User.instance.devices.firstWhere( (d) => @@ -902,6 +910,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { orElse: () => UserDevice( id: -1, uid: '', + productid: -1, devicetype: -1, devicename: '', devicemac: '', @@ -921,12 +930,14 @@ class PairingController extends GetxController with WidgetsBindingObserver { code = extractAndReverseBytes(hexData, 15, 24); _verifiedDevicePid = extractAndReverseBytesToInt(hexData, 6, 7); - print('找到匹配设备,获取到 code: $code'); + final pidHexAndroid = _verifiedDevicePid?.toRadixString(16).toUpperCase() ?? ''; + Logger.i(_tag, '[YIDONG] Android BLE扫描 MAC匹配 device=${device.name} mac=${device.address} pidHex=$pidHexAndroid code=$code'); if (code != "") { _bleManager.stopScan(); deviceCode.value = code; _verifiedDeviceName = device.name; _verifiedDeviceAddress = device.address; + Logger.i(_tag, '[YIDONG] Android deviceCode提取成功 code=$code mac=${device.address}'); //检查是否已经绑定 final existingDevice = User.instance.devices.firstWhere( (d) => @@ -936,6 +947,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { orElse: () => UserDevice( id: -1, uid: '', + productid: -1, devicetype: -1, devicename: '', devicemac: '', @@ -1178,26 +1190,92 @@ class PairingController extends GetxController with WidgetsBindingObserver { } } + /// 检测是否为移动精灵(YIDONG)设备,并写入 GetStorage + void _detectAndSaveYidongDevice() { + final yidongPid = AppConfig.env('YIDONG_PID') ?? ''; + if (yidongPid.isEmpty) { + Logger.i(_tag, '[YIDONG] 未配置 YIDONG_PID,跳过检测'); + return; + } + + // 优先使用本次扫描解析的 pid,否则从已绑定设备列表中查找 + int? pid = _verifiedDevicePid; + String pidSource = 'BLE扫描'; + if (pid == null) { + final mac = _verifiedDeviceAddress ?? _currentClassicDevice?.address ?? ''; + Logger.i(_tag, '[YIDONG] 本次未扫描到pid,从已绑定设备查找 mac=$mac'); + if (mac.isNotEmpty) { + final matched = User.instance.devices.firstWhere( + (d) => + d.devicemac.toUpperCase().replaceAll(':', '') == + mac.toUpperCase().replaceAll(':', ''), + orElse: () => UserDevice( + id: -1, uid: '', productid: -1, + devicetype: -1, devicename: '', devicemac: '', license: ''), + ); + if (matched.id != -1) { + pid = matched.productid; + pidSource = '已绑定设备'; + } + } + } + + if (pid == null) { + Logger.i(_tag, '[YIDONG] 无法获取设备pid,跳过检测'); + return; + } + + // 将 int pid 转为十六进制字符串(不补零,大写)与 YIDONG_PID 比对 + final pidHex = pid.toRadixString(16).toUpperCase(); + final isYidong = pidHex == yidongPid.toUpperCase(); + + Logger.i(_tag, '[YIDONG] 设备检测: pidHex=$pidHex yidongPid=${yidongPid.toUpperCase()} source=$pidSource matched=$isYidong'); + + if (isYidong) { + _storage.write('device_agent_type', 'yidong'); + // deviceCode 来源: + // 新设备 → BLE 扫描从广播包提取 + // 已绑定设备 → authbindingDevice 从 existingDevice.license 赋值 + if (deviceCode.value.isNotEmpty) { + final prevCode = _storage.read('device_sts_code') ?? ''; + // SN 变了说明换了设备,清除旧凭证,强制重新向移动平台认证 + if (prevCode != deviceCode.value) { + _storage.remove('sts_device_id'); + _storage.remove('sts_device_secret'); + Logger.i(_tag, '[YIDONG] SN 变更($prevCode → ${deviceCode.value}),已清除旧凭证,强制重新认证'); + } + _storage.write('device_sts_code', deviceCode.value); + Logger.i(_tag, '[YIDONG] 识别成功,写入 device_sts_code=${deviceCode.value}'); + } else { + // 既无扫描结果也无 license,清除所有凭证,认证失败会提示用户 + _storage.remove('device_sts_code'); + _storage.remove('sts_device_id'); + _storage.remove('sts_device_secret'); + Logger.i(_tag, '[YIDONG] deviceCode 为空,已清除所有凭证'); + } + } else { + _storage.remove('device_agent_type'); + _storage.remove('device_sts_code'); + _storage.remove('sts_device_id'); + _storage.remove('sts_device_secret'); + Logger.i(_tag, '[YIDONG] 非移动精灵设备,已清除相关存储'); + } + } + /// 导航到主页 void _navigateToHome() { - // Future.delayed(const Duration(milliseconds: 500), () async { + // 检测是否为移动精灵(YIDONG)设备 + _detectAndSaveYidongDevice(); // 启动AgentService服务 try { Logger.i(_tag, '启动AgentService服务'); _bleManager.startAgentService(); - // _bleManager.registerBackgroundScan(macAddress: _verifiedDeviceAddress); } catch (e) { Logger.e(_tag, '启动AgentService失败: $e'); } - //print('lxm-上一个路由: ${Get.previousRoute.toString()}'); - // if (Get.previousRoute == Routes.home) { - // 如果是从home页过来的,则直接返回 - // Get.back(); - // } else { - // 否则,跳转到home页并清空之前的路由栈 + // 已在 Home 页时不重复导航,避免两个 HomeView 同时存在导致 Duplicate GlobalKey + if (Get.currentRoute == Routes.home) return; Get.offAllNamed(Routes.home); - // } - // }); } // ==================== 用户操作 ==================== @@ -1259,6 +1337,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { UserDevice( id: 0, // 临时设备ID uid: User.instance.uid, + productid: _verifiedDevicePid ?? -1, devicetype: 1, devicemac: _currentClassicDevice?.address ?? '', devicename: _currentClassicDevice?.name ?? 'Unknown Device', diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 3a56af051..bbad03eaa 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -987,6 +987,16 @@ object AgentService : CoroutineScope { stsAgent?.ttsMuted = muted } + /** 手动触发心跳上报(Flutter 层调用) */ + fun reportHeartbeat() { + stsAgent?.reportHeartbeat() + } + + /** 主动触发认证检查(移动精灵页面打开时调用,确保认证事件被 Flutter 层接收) */ + fun fetchCredentials() { + stsAgent?.fetchCredentials() + } + fun interruptCurrentResponse() { if (agentMode == "sts") { stsAgent?.interruptSpeaking() diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index e6a08f893..b513800fa 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -349,6 +349,26 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } } + "reportHeartbeat" -> { + try { + AgentService.reportHeartbeat() + result.success(true) + } catch (e: Exception) { + Log.e(TAG, "心跳上报失败", e) + result.error("HEARTBEAT_ERROR", "心跳上报失败: ${e.message}", null) + } + } + + "fetchCredentials" -> { + try { + AgentService.fetchCredentials() + result.success(true) + } catch (e: Exception) { + Log.e(TAG, "触发认证检查失败", e) + result.error("FETCH_CREDENTIALS_ERROR", "触发认证检查失败: ${e.message}", null) + } + } + "setCanWakeUp" -> { try { val canWakeUp = call.argument("canWakeUp") diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt index 8454d3eab..1fbe7ec72 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt @@ -58,6 +58,7 @@ class StsAgent( override val coroutineContext = Dispatchers.IO + job init { + Log.i(TAG, "[YIDONG] StsAgent初始化: productId=${config.productId} deviceNo=${config.deviceNo} deviceId=${config.deviceId} agentId=${config.agentId} hasSecret=${config.deviceSecret.isNotEmpty()}") fetchCredentials() } @@ -89,6 +90,7 @@ class StsAgent( // ── 预缓冲(VAD 触发前 ~1.5s)──────────────────────────────────────────── private val preBuffer = ArrayDeque() + private val preBufferLock = Any() private val PRE_BUFFER_MAX_CHUNKS = 75 // ── 本地 VAD ────────────────────────────────────────────────────────────── @@ -153,8 +155,11 @@ class StsAgent( fun fetchCredentials() { if (config.deviceSecret.isNotEmpty()) { credentialReady = true + Log.i(TAG, "[YIDONG] fetchCredentials: 凭证已存在,跳过请求 deviceNo=${config.deviceNo} deviceId=${config.deviceId}") + reportHeartbeat() // 凭证已有,直接上报心跳 return } + Log.i(TAG, "[YIDONG] fetchCredentials: 开始请求密钥 productId=${config.productId} deviceNo=${config.deviceNo}") launch { try { val body = JSONObject().apply { @@ -180,14 +185,82 @@ class StsAgent( ) credentialReady = config.deviceSecret.isNotEmpty() if (credentialReady) { + Log.i(TAG, "[YIDONG] fetchCredentials: 密钥获取成功 deviceId=${config.deviceId} deviceNo=${config.deviceNo}") onCredentialsFetched?.invoke(config.deviceId, config.deviceSecret, config.deviceNo) + reportHeartbeat() // 凭证获取成功后立即上报心跳 + } else { + Log.e(TAG, "[YIDONG] fetchCredentials: 响应成功但deviceSecret为空") + emit("credential_failed", mapOf("reason" to "设备未认证,deviceSecret为空")) } } else { - Log.e(TAG, "凭证获取失败: ${json.optString("message")}") + val msg = json.optString("message", "未知错误") + Log.e(TAG, "[YIDONG] fetchCredentials: 密钥获取失败 code=${json.optString("code")} msg=$msg") + emit("credential_failed", mapOf("reason" to "设备未认证:$msg")) } } } catch (e: Exception) { - Log.e(TAG, "凭证获取异常: ${e.message}") + Log.e(TAG, "[YIDONG] fetchCredentials: 请求异常 ${e.message}") + emit("credential_failed", mapOf("reason" to "设备认证请求失败:${e.message}")) + } + } + } + + /** 向中国移动平台上报心跳 */ + fun reportHeartbeat() { + if (!credentialReady) { + Log.w(TAG, "[YIDONG] reportHeartbeat: 凭证未就绪,跳过上报") + return + } + Log.i(TAG, "[YIDONG] reportHeartbeat: 开始上报 deviceId=${config.deviceId} deviceNo=${config.deviceNo}") + launch { + try { + val netType = try { + val cm = context.getSystemService(android.content.Context.CONNECTIVITY_SERVICE) + as android.net.ConnectivityManager + val net = cm.activeNetworkInfo + when (net?.type) { + android.net.ConnectivityManager.TYPE_WIFI -> "WiFi" + android.net.ConnectivityManager.TYPE_MOBILE -> "5G" + else -> "WiFi" + } + } catch (_: Exception) { "WiFi" } + + val body = JSONObject().apply { + put("deviceId", config.deviceId) + put("deviceSecret", config.deviceSecret) + put("productId", config.productId) + put("productKey", config.productKey) + put("params", JSONObject().apply { + put("innerIp", JSONArray().put("127.0.0.1")) + put("netSpeed", "0Mbps") + put("netType", netType) + put("platform", "Android${android.os.Build.VERSION.RELEASE}") + put("sdkVersion", "ai_http_1.0") + put("firmwareVersion", "1.0.0") + put("imei", "") + put("cmei", config.deviceNo) // 非蜂窝设备使用 SN 作为 cmei + put("mac", "") + }) + }.toString().toRequestBody("application/json".toMediaType()) + + val req = Request.Builder() + .url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/report") + .post(body) + .build() + + okHttpClient.newCall(req).execute().use { resp -> + val json = JSONObject(resp.body!!.string()) + val code = json.optString("code") + val msg = json.optString("message") + if (code == "200") { + Log.i(TAG, "[YIDONG] reportHeartbeat: 上报成功 code=$code msg=$msg") + emit("heartbeat_reported", mapOf("time" to System.currentTimeMillis())) + } else { + Log.e(TAG, "[YIDONG] reportHeartbeat: 上报失败 code=$code msg=$msg") + } + } + } catch (e: Exception) { + Log.e(TAG, "[YIDONG] reportHeartbeat: 请求异常 ${e.message}") } } } @@ -202,6 +275,7 @@ class StsAgent( if (ttsPlaying) interruptTts() if (pttRecording) stopPtt(cancel = true) if (!credentialReady) { emitError("移动精灵凭证未就绪,请稍后重试"); return } + Log.i(TAG, "[YIDONG] startCall: 启动通话模式 deviceNo=${config.deviceNo} agentId=${config.agentId}") openMic() startMicWatchdog() inCall = true @@ -213,6 +287,7 @@ class StsAgent( if (pttRecording) return if (ttsPlaying) interruptTts() if (!credentialReady) { emitError("移动精灵凭证未就绪"); return } + Log.i(TAG, "[YIDONG] startPtt: 启动PTT模式 deviceNo=${config.deviceNo} agentId=${config.agentId}") pttRecording = true openMic() @@ -287,7 +362,7 @@ class StsAgent( currentPlayer = null } closeWs() - preBuffer.clear() + synchronized(preBufferLock) { preBuffer.clear() } clearSendBuffer() flushPendingAnswer() inCall = false @@ -400,8 +475,10 @@ class StsAgent( } private fun addToPreBuffer(chunk: ByteArray) { - preBuffer.addLast(chunk.copyOf()) - while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst() + synchronized(preBufferLock) { + preBuffer.addLast(chunk.copyOf()) + while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst() + } } // ═════════════════════════════════════════════════════════════════════════ @@ -429,7 +506,7 @@ class StsAgent( forwardingAudio = true; wsConnecting = false emit("recognition_started", emptyMap()) - val cached = preBuffer.toList(); preBuffer.clear() + val cached = synchronized(preBufferLock) { preBuffer.toList().also { preBuffer.clear() } } for (c in cached) feedSendBuffer(c) } @@ -445,6 +522,7 @@ class StsAgent( "?deviceNo=${config.deviceNo}&deviceId=${config.deviceId}" + "&productId=${config.productId}&productKey=${config.productKey}" + "&sign=$sign&sn=$sn&ts=$ts" + Log.i(TAG, "[YIDONG] connectWs: 连接WSS deviceNo=${config.deviceNo} deviceId=${config.deviceId} sn=$sn") val req = Request.Builder().url(url) .header("Origin", "https://ivs.chinamobiledevice.com:11443") @@ -462,6 +540,7 @@ class StsAgent( try { val msg = JSONObject(text) if (msg.optString("type") == "ready") { + Log.i(TAG, "[YIDONG] connectWs: 收到ready,WSS连接成功") if (!resolved) { resolved = true; cont.resume(Unit) {} } } else { launch { handleMessage(msg) } @@ -472,6 +551,7 @@ class StsAgent( override fun onMessage(ws: WebSocket, bytes: okio.ByteString) {} override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + Log.e(TAG, "[YIDONG] connectWs: WSS连接失败 ${t.message}") if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS if (!resolved) { resolved = true; cont.resumeWithException(t) } else launch { handleWsDone() } @@ -502,13 +582,21 @@ class StsAgent( forwardingAudio = false; wsConnecting = false when { sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() } - sessionEnded -> { /* PTT 结束,等 TTS 播完 */ } - inCall -> resetVad() + sessionEnded -> { + // PTT 结束;若无 TTS 排队或播放(如无语音输入场景),立即补发 tts_completed + // 避免 Flutter 侧 _pttResultPending 永久无法清除导致按钮锁死 + if (!ttsPlaying && ttsQueue.isEmpty()) emit("tts_completed", emptyMap()) + } + inCall -> resetVad() + // WS 意外关闭(session 未正常结束,如极速松手音频未发出),同样补发保底 + else -> if (!ttsPlaying && ttsQueue.isEmpty() && !ttsInterrupted) emit("tts_completed", emptyMap()) } } private fun resetVad() { - speechDetected = false; vadOnsetCount = 0; preBuffer.clear(); openMic() + speechDetected = false; vadOnsetCount = 0 + synchronized(preBufferLock) { preBuffer.clear() } + openMic() } private fun closeWs() { diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 1cdec8d65..9478c66fb 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -77,6 +77,12 @@ enum AgentServiceEventType { /// 麦克风音量(RMS) micRms, + /// 心跳上报成功 + heartbeatReported, + + /// STS 设备认证失败 + credentialFailed, + /// 未知事件 unknown } @@ -186,6 +192,10 @@ class AgentService { return AgentServiceEventType.stsCredentials; case 'mic_rms': return AgentServiceEventType.micRms; + case 'heartbeat_reported': + return AgentServiceEventType.heartbeatReported; + case 'credential_failed': + return AgentServiceEventType.credentialFailed; default: return AgentServiceEventType.unknown; } @@ -468,6 +478,25 @@ class AgentService { } } + /// 手动触发心跳上报(移动精灵) + static Future reportHeartbeat() async { + try { + await _channel.invokeMethod('reportHeartbeat'); + } on PlatformException catch (e) { + throw AgentServiceException(e.code, e.message ?? '心跳上报失败', e.details); + } + } + + /// 主动触发认证检查(移动精灵页面打开时调用) + /// 若凭证不存在则重新请求,结果通过事件流返回(credentialFailed / heartbeatReported) + static Future fetchCredentials() async { + try { + await _channel.invokeMethod('fetchCredentials'); + } on PlatformException catch (_) { + // 忽略异常,认证结果通过事件流反馈 + } + } + /// 中断当前响应 /// /// 停止语音合成和AI流输出