Browse Source

确定移动设备绑定逻辑

newdev_shunjiawei
liwei1dao 7 months ago
parent
commit
c6fe1b26b3
  1. 4
      lib/data/models/user_Info.dart
  2. 19
      lib/data/services/ble_manager.dart
  3. 5
      lib/modules/home/controllers/home_controller.dart
  4. 87
      lib/modules/mobile_elf/controllers/mobile_elf_controller.dart
  5. 19
      lib/modules/mobile_elf/views/mobile_elf_view.dart
  6. 107
      lib/modules/pairing/controllers/pairing_controller.dart
  7. 10
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt
  8. 20
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt
  9. 106
      local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/StsAgent.kt
  10. 29
      local_plugins/agent_service/lib/agent_service.dart

4
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<String, dynamic> 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,

19
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<dynamic, dynamic> 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<String>('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<String>('device_sts_code') ?? '';
final stsDeviceId = _storage.read<String>('sts_device_id') ?? '';
final stsDeviceSecret = _storage.read<String>('sts_device_secret') ?? '';

5
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<String>('device_agent_type')
?? AppConfig.env('AGENT_TYPE')
?? 'stt_llm_tts';
if (agentType == 'yidong') {
Get.toNamed(Routes.mobileElf);
} else {

87
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<AgentServiceEvent>? _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<void> _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<void> 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<void> 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<int>('sts_heartbeat_time') ?? 0;
if (DateTime.now().millisecondsSinceEpoch - last > 3600000) {
AgentService.reportHeartbeat().catchError((_) {});
}
}
Future<void> 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();

19
lib/modules/mobile_elf/views/mobile_elf_view.dart

@ -143,6 +143,17 @@ class MobileElfView extends GetView<MobileElfController> {
/// 右上角状态胶囊 + 通话计时
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<MobileElfController> {
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<MobileElfController> {
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),

107
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<String>('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',

10
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()

20
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<Boolean>("canWakeUp")

106
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<ByteArray>()
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() {

29
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<void> reportHeartbeat() async {
try {
await _channel.invokeMethod('reportHeartbeat');
} on PlatformException catch (e) {
throw AgentServiceException(e.code, e.message ?? '心跳上报失败', e.details);
}
}
/// 主动触发认证检查(移动精灵页面打开时调用)
/// 若凭证不存在则重新请求,结果通过事件流返回(credentialFailed / heartbeatReported)
static Future<void> fetchCredentials() async {
try {
await _channel.invokeMethod('fetchCredentials');
} on PlatformException catch (_) {
// 忽略异常,认证结果通过事件流反馈
}
}
/// 中断当前响应
///
/// 停止语音合成和AI流输出

Loading…
Cancel
Save