import 'dart:async'; import 'dart:convert'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:just_audio/just_audio.dart'; import 'package:record/record.dart'; import 'package:uuid/uuid.dart'; import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../../../core/utils/logger.dart'; import '../../../data/models/user_Info.dart'; import '../../../data/services/bluetooth_manager.dart'; enum MobileElfStatus { idle, connecting, recording, thinking, speaking, error } class MobileElfMessage { final bool isUser; final String text; final DateTime time; final String? ttsUrl; MobileElfMessage({ required this.isUser, required this.text, required this.time, this.ttsUrl, }); } class MobileElfController extends GetxController { // 状态 final status = MobileElfStatus.idle.obs; final messages = [].obs; final realtimeText = ''.obs; final errorMsg = ''.obs; // WebSocket WebSocketChannel? _channel; StreamSubscription? _wsSubscription; // 录音 final AudioRecorder _recorder = AudioRecorder(); StreamSubscription? _audioSubscription; final List _audioBuffer = []; static const int _frameSize = 5120; // 160ms @ 16kHz 16bit mono // 播放 final AudioPlayer _player = AudioPlayer(); // 配置 late String _deviceNo; late String _deviceId; late String _productId; late String _productKey; late String _deviceSecret; // A80 凭证状态 final credentialReady = false.obs; final credentialError = ''.obs; final _storage = GetStorage(); static const _kDeviceId = 'mobile_elf_device_id'; static const _kDeviceSecret = 'mobile_elf_device_secret'; static const _kDeviceNo = 'mobile_elf_device_no'; // 当前对话 ID String _dialogRequestId = ''; final _uuid = const Uuid(); @override void onInit() { super.onInit(); _loadConfig(); _fetchA80CredentialsIfNeeded(); } @override void onClose() { disconnect(); _recorder.dispose(); _player.dispose(); super.onClose(); } void _loadConfig() { _productId = dotenv.env['MOBILE_ELF_PRODUCT_ID'] ?? '1899007766144614402'; _productKey = dotenv.env['MOBILE_ELF_PRODUCT_KEY'] ?? 'd0aI2yidbUUXS'; // 优先使用缓存的 A80 凭证,否则降级到 .env 默认值 _deviceNo = _storage.read(_kDeviceNo) ?? dotenv.env['MOBILE_ELF_DEVICE_NO'] ?? '869700070000570'; _deviceId = _storage.read(_kDeviceId) ?? dotenv.env['MOBILE_ELF_DEVICE_ID'] ?? '1899013964170694658'; _deviceSecret = _storage.read(_kDeviceSecret) ?? dotenv.env['MOBILE_ELF_DEVICE_SECRET'] ?? ''; credentialReady.value = _deviceSecret.isNotEmpty; } /// 检测连接的耳机是否为 A80,如是则用其 license 换取平台凭证 Future _fetchA80CredentialsIfNeeded() async { try { final btManager = Get.find(); final deviceName = btManager.currentDevice?.name ?? ''; final deviceMac = btManager.currentDevice?.address ?? ''; final license = _findLicense(deviceMac); Logger.i('[MobileElf]', '=== 设备信息 ==='); Logger.i('[MobileElf]', '设备名称: $deviceName'); Logger.i('[MobileElf]', '设备MAC: $deviceMac'); Logger.i('[MobileElf]', '匹配license: $license'); Logger.i('[MobileElf]', '==============='); if (!deviceName.toUpperCase().contains('A80')) { credentialReady.value = _deviceSecret.isNotEmpty; return; } // 已有缓存凭证且设备号未变则直接使用 final cachedNo = _storage.read(_kDeviceNo) ?? ''; final cachedSecret = _storage.read(_kDeviceSecret) ?? ''; if (license == null || license.isEmpty) { Logger.w('[MobileElf]', 'A80 设备未找到 license,使用默认凭证'); credentialReady.value = _deviceSecret.isNotEmpty; return; } if (cachedNo == license && cachedSecret.isNotEmpty) { Logger.i('[MobileElf]', 'A80 凭证命中缓存,跳过请求'); credentialReady.value = true; return; } credentialError.value = ''; Logger.i('[MobileElf]', 'A80 设备 license=$license,开始请求平台凭证'); final dio = Dio(BaseOptions( baseUrl: 'https://ivs.chinamobiledevice.com:11443', connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 10), )); final resp = await dio.post( '/v2/customer/device/secret/info', data: { 'deviceNoType': 'SN', 'deviceNo': license, 'productId': _productId, 'productKey': _productKey, }, ); final body = resp.data as Map; if (body['code'].toString() == '200' && body['success'] == true) { final data = body['data'] as Map; _deviceNo = data['deviceNo'] as String? ?? license; _deviceId = data['deviceId'] as String? ?? _deviceId; _deviceSecret = data['deviceSecret'] as String? ?? ''; await _storage.write(_kDeviceNo, _deviceNo); await _storage.write(_kDeviceId, _deviceId); await _storage.write(_kDeviceSecret, _deviceSecret); credentialReady.value = _deviceSecret.isNotEmpty; Logger.i('[MobileElf]', 'A80 凭证获取成功 deviceId=$_deviceId'); } else { credentialError.value = body['message']?.toString() ?? '凭证请求失败'; Logger.e('[MobileElf]', '平台返回错误: ${body['message']}'); } } catch (e) { credentialError.value = '凭证请求异常: $e'; Logger.e('[MobileElf]', '获取 A80 凭证失败: $e'); } } /// 从用户设备列表中按蓝牙 MAC 匹配 license String? _findLicense(String? bluetoothMac) { if (!User.isLoggedIn()) return null; final devices = User.instance.devices; if (bluetoothMac != null && bluetoothMac.isNotEmpty) { final matched = devices.where((d) => d.devicemac.toUpperCase() == bluetoothMac.toUpperCase()).toList(); if (matched.isNotEmpty) return matched.first.license; } // 按设备名兜底匹配 final byName = devices.where((d) => d.devicename.toUpperCase().contains('A80')).toList(); return byName.isNotEmpty ? byName.first.license : null; } // ── 连接 ────────────────────────────────────────────────────────────────── Future _connect() async { if (_channel != null) return; status.value = MobileElfStatus.connecting; errorMsg.value = ''; try { final ts = DateTime.now().millisecondsSinceEpoch.toString(); final sn = _uuid.v4(); final sign = _calcSign(_deviceSecret, ts); final url = 'wss://ivs.chinamobiledevice.com:11443/app-ws/v2/asr' '?deviceNo=$_deviceNo' '&deviceId=$_deviceId' '&productId=$_productId' '&productKey=$_productKey' '&sign=$sign' '&sn=$sn' '&ts=$ts'; if (kIsWeb) { _channel = WebSocketChannel.connect(Uri.parse(url)); } else { _channel = IOWebSocketChannel.connect(Uri.parse(url)); } _wsSubscription = _channel!.stream.listen( _handleMessage, onError: _handleWsError, onDone: _handleWsDone, ); Logger.info('[MobileElf] WebSocket 连接中: $url'); } catch (e) { Logger.error('[MobileElf] 连接失败: $e'); _setError('连接失败: $e'); } } // ── 开始对话(录音) ───────────────────────────────────────────────────── Future startRecording() async { if (status.value == MobileElfStatus.recording) return; if (status.value == MobileElfStatus.thinking) return; await _connect(); final hasPermission = await _recorder.hasPermission(); if (!hasPermission) { _setError('无麦克风权限'); return; } _dialogRequestId = _uuid.v4(); realtimeText.value = ''; try { final stream = await _recorder.startStream( const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: 16000, numChannels: 1, ), ); _audioBuffer.clear(); _audioSubscription = stream.listen((chunk) { _audioBuffer.addAll(chunk); while (_audioBuffer.length >= _frameSize) { _sendAudioChunk( Uint8List.fromList(_audioBuffer.sublist(0, _frameSize))); _audioBuffer.removeRange(0, _frameSize); } }); status.value = MobileElfStatus.recording; Logger.info('[MobileElf] 开始录音'); } catch (e) { Logger.error('[MobileElf] 录音失败: $e'); _setError('录音失败: $e'); } } Future stopRecording() async { if (status.value != MobileElfStatus.recording) return; await _audioSubscription?.cancel(); _audioSubscription = null; // 发送剩余缓冲数据 if (_audioBuffer.isNotEmpty) { _sendAudioChunk(Uint8List.fromList(_audioBuffer)); _audioBuffer.clear(); } await _recorder.stop(); _sendFinish(); status.value = MobileElfStatus.thinking; Logger.info('[MobileElf] 停止录音,等待 AI 回答'); } // ── 发送帧 ──────────────────────────────────────────────────────────────── void _sendStartFrame() { if (_channel == null) return; final startFrame = { 'type': 'start', 'data': { 'cuid': _deviceId, 'format': 'pcm', 'sample': 16000, 'support_dcs': 2, 'chunk_size': 10240, 'support_tts': true, 'support_text2dcs': true, 'user_agent': 'voitrans/1.0', 'dialog_request_id': _dialogRequestId, 'access_rc': true, 'support_part_tts': true, 'need_stoplisten': true, 'need_dialogue_finish': true, 'result_trans2directive': false, 'rc_version': '5', 'messages': _buildContextMessages(), 'client_context': [], }, }; _channel!.sink.add(jsonEncode(startFrame)); Logger.info('[MobileElf] 已发送 start 帧'); } void _sendAudioChunk(Uint8List data) { try { _channel?.sink.add(data); } catch (e) { Logger.error('[MobileElf] 发送音频帧失败: $e'); } } void _sendFinish() { try { _channel?.sink.add(jsonEncode({'type': 'finish'})); Logger.info('[MobileElf] 已发送 finish 帧'); } catch (e) { Logger.error('[MobileElf] 发送 finish 帧失败: $e'); } } // ── 接收消息处理 ────────────────────────────────────────────────────────── void _handleMessage(dynamic raw) { try { if (raw is! String) return; final Map msg = jsonDecode(raw); final type = msg['type'] as String? ?? ''; switch (type) { case 'ready': Logger.info('[MobileElf] 收到 ready,发送 start 帧'); _sendStartFrame(); break; case 'mid_result': final result = msg['result'] as String? ?? ''; realtimeText.value = result; break; case 'fin_result': final result = msg['result'] as String? ?? ''; if (result.isNotEmpty) { realtimeText.value = result; // 用户消息在 fin_result 时确认写入 _addUserMessage(result); } final end = msg['end'] as int? ?? 0; if (end == 1) _handleSessionEnd(); break; case 'inside_rc': _handleInsideRc(msg); break; case 'dcs_decide': final end = msg['end'] as int? ?? 0; if (end == 1) _handleSessionEnd(); break; default: Logger.info('[MobileElf] 未知消息类型: $type'); } } catch (e) { Logger.error('[MobileElf] 处理消息失败: $e'); } } void _handleInsideRc(Map msg) { final data = msg['data'] as Map?; if (data == null) return; final code = data['code'] as int? ?? -1; if (code != 0) { Logger.error('[MobileElf] inside_rc 错误: ${data['msg']}'); return; } // 提取 AI 文本回答 final assistantAnswer = data['assistant_answer'] as String? ?? ''; String cleanAnswer = assistantAnswer; // assistant_answer 可能是 JSON 字符串 try { final decoded = jsonDecode(assistantAnswer) as Map; cleanAnswer = decoded['content'] as String? ?? assistantAnswer; } catch (_) {} // 提取 TTS URL(Speak 指令) String? ttsUrl; final directives = data['data'] as List? ?? []; for (final item in directives) { final directive = item as Map; final header = directive['header'] as Map?; final ns = header?['namespace'] as String? ?? ''; final name = header?['name'] as String? ?? ''; if (ns == 'ai.fxzsos.device_interface.voice_output' && name == 'Speak') { final payload = directive['payload'] as Map?; ttsUrl = payload?['url'] as String?; break; } } if (cleanAnswer.isNotEmpty) { _addAiMessage(cleanAnswer, ttsUrl: ttsUrl); } // 播放 TTS if (ttsUrl != null && ttsUrl.isNotEmpty) { playTts(ttsUrl); } final end = msg['end'] as int? ?? 0; if (end == 1) _handleSessionEnd(); } void _handleSessionEnd() { if (status.value != MobileElfStatus.speaking) { status.value = MobileElfStatus.idle; } realtimeText.value = ''; // 断开当前 WebSocket,下次对话重新连接(协议要求每次新对话重连) _closeWs(); } void _handleWsError(dynamic error) { Logger.error('[MobileElf] WebSocket 错误: $error'); _setError('连接错误: $error'); _closeWs(); } void _handleWsDone() { Logger.info('[MobileElf] WebSocket 关闭'); if (status.value == MobileElfStatus.connecting || status.value == MobileElfStatus.recording) { _setError('连接已断开'); } _channel = null; _wsSubscription = null; } // ── TTS 播放 ────────────────────────────────────────────────────────────── Future playTts(String url) async { try { status.value = MobileElfStatus.speaking; await _player.setUrl(url); await _player.play(); await _player.processingStateStream .firstWhere((s) => s == ProcessingState.completed); status.value = MobileElfStatus.idle; Logger.info('[MobileElf] TTS 播放完成'); } catch (e) { Logger.error('[MobileElf] TTS 播放失败: $e'); status.value = MobileElfStatus.idle; } } Future stopSpeaking() async { await _player.stop(); status.value = MobileElfStatus.idle; } // ── 断开连接 ────────────────────────────────────────────────────────────── void disconnect() { _audioSubscription?.cancel(); _audioSubscription = null; _recorder.cancel(); _closeWs(); status.value = MobileElfStatus.idle; } void _closeWs() { _wsSubscription?.cancel(); _wsSubscription = null; _channel?.sink.close(); _channel = null; } // ── 辅助方法 ────────────────────────────────────────────────────────────── String _calcSign(String deviceSecret, String ts) { final bytes = utf8.encode('$deviceSecret$ts'); return md5.convert(bytes).toString(); } List> _buildContextMessages() { return messages.map((m) => { 'role': m.isUser ? 'user' : 'assistant', 'content': m.text, }).toList(); } void _addUserMessage(String text) { // 避免重复添加 if (messages.isNotEmpty && messages.last.isUser && messages.last.text == text) { return; } messages.add(MobileElfMessage( isUser: true, text: text, time: DateTime.now(), )); } void _addAiMessage(String text, {String? ttsUrl}) { messages.add(MobileElfMessage( isUser: false, text: text, time: DateTime.now(), ttsUrl: ttsUrl, )); } void _setError(String msg) { errorMsg.value = msg; status.value = MobileElfStatus.error; } /// 重试获取 A80 凭证 Future retryCredentials() async { credentialError.value = ''; await _fetchA80CredentialsIfNeeded(); } /// 按钮主操作:空闲/错误 → 开始录音;录音中 → 停止;播放中 → 停止 Future onMicTap() async { switch (status.value) { case MobileElfStatus.idle: case MobileElfStatus.error: await startRecording(); break; case MobileElfStatus.recording: await stopRecording(); break; case MobileElfStatus.speaking: await stopSpeaking(); break; default: break; } } }