diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6599a0b8a..40c8985ff 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,6 +6,7 @@ repositories { flatDir { dirs(project(":spotify_sdk").projectDir.resolve("libs")) dirs(project(":qq_music").projectDir.resolve("libs")) // 添加这行 + dirs(project(":device_jieli").projectDir.resolve("libs")) // 杰理 SDK 运行时 .aar } } @@ -152,6 +153,13 @@ dependencies { // 添加 CardView 依赖 implementation("androidx.cardview:cardview:1.0.0") + + // device_jieli 杰理 SDK 运行时依赖(.aar 在 device_jieli 模块只能 compileOnly, + // host app 必须自己引入,否则 RCSPController 等类在运行时找不到 → scanFeature 不会被初始化) + implementation(":jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224@aar") + implementation(":jl_audio_v2_V1.0.0_9-release@aar") + implementation(":jldecryption_v0.4-release@aar") + // jl_audio_decode 已经被 ble_service 通过 com.jieli.audio:jl_audio_decode:2.1.0 引入,无需重复 } flutter { diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index abc686570..2a8f2ca6a 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -52,10 +52,10 @@ class InitialBinding extends Bindings { () => MicrosoftTranslationService(), fenix: true); + // BleManager —— 提前 put(不用 lazyPut),让原生杰理 SDK 在 onInit 里立即开始 initialize + Get.put(BleManager(), permanent: true); // 蓝牙服务 Get.lazyPut(() => BluetoothManager(), fenix: true); - // BleManager - Get.lazyPut(() => BleManager(), fenix: true); // MusicManager Get.lazyPut(() => MusicManager(), fenix: true); diff --git a/lib/data/services/ast_service.dart b/lib/data/services/ast_service.dart index 992ab3545..d4bd5bf4e 100644 --- a/lib/data/services/ast_service.dart +++ b/lib/data/services/ast_service.dart @@ -36,10 +36,27 @@ abstract class AstService { /// 未实现的原生端应 no-op 并返回 true Future setBroadcastPeerTranslate(bool enabled); + /// 把外部捕获的 PCM(杰理 runtime 解码后的 SCO 上下行)推给 AST 流水线。 + /// [leg] 'A'/'uplink' = 本机麦克风;'B'/'downlink' = 对端通话音 + /// [pcm] 16k mono 16bit PCM little-endian + Future pushExternalAudio({required String leg, required Uint8List pcm}); + + /// AST 流水线 / E2E 服务合成的 TTS PCM 帧流。每帧包含 leg('A'|'B') + pcm。 + /// 调用方负责把它转发给设备(如 [Jielihome.feedTranslatedAudio])。 + Stream get astTtsFrames; + /// 释放资源 Future dispose(); } +/// AST 服务输出的 TTS PCM 帧(用于通话翻译双向回灌)。 +class AstTtsFrame { + /// 'A' = 己方→对端 译文(让对端听);'B' = 对端→己方 译文(让本机听) + final String leg; + final Uint8List pcm; + const AstTtsFrame({required this.leg, required this.pcm}); +} + // 识别事件类型 enum ASTEventType { /// 最终识别结果 diff --git a/lib/data/services/ble_manager.dart b/lib/data/services/ble_manager.dart index 14d137ccf..8686c6094 100644 --- a/lib/data/services/ble_manager.dart +++ b/lib/data/services/ble_manager.dart @@ -1,129 +1,47 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; -import 'package:get_storage/get_storage.dart'; import 'package:get/get.dart'; -import 'package:ble_service/ble_service.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:device_jieli/device_jieli.dart'; import 'package:agent_service/agent_service.dart'; -import '../models/user_Info.dart' as userInfo; -import '/data/models/appconfig_model.dart'; +import '/data/models/appconfig_model.dart'; +import '../models/user_Info.dart' as userInfo; import '../../data/models/appconfig.dart'; import '../../core/translations/app_translations.dart'; import '../../core/utils/logger.dart'; -import 'bluetooth_manager.dart'; import 'language_manager.dart'; import 'network/dio_manager.dart'; import '../../routes/app_routes.dart'; -// 重新导出BLE连接状态常量,让外部可以通过BleManager访问 -export 'package:ble_service/ble_service.dart' show BleConnectionState; - -/// 命令类型常量(与原生层保持一致) -class BleCommands { - static const int GET_VERSION = 0x01; - static const int GET_PRODUCT_ID = 0x02; - static const int GET_PRODUCT_COLOR = 0x03; - static const int GET_BATTERY_INFO = 0x04; - static const int CONTROL_CODEC = 0x05; - static const int GET_WAKEUP_VERSION = 0x08; - static const int GET_VOICE_WAKEUP_STATUS = 0x14; - static const int SET_VOICE_WAKEUP_STATUS = 0x15; - /// 设备上报开启通话翻译 0x16 (帧头0xCC, cmdLen=0x01, status=0xFF) - static const int CALL_TRANSLATION_ON = 0x16; - /// 设备上报关闭通话翻译 0x17 (帧头0xCC, cmdLen=0x01, status=0xFF) - static const int CALL_TRANSLATION_OFF = 0x17; -} - -/// 判断编解码控制指令是否成功 -/// -/// 根据 rawData 第二字节是否为非 0 来判断: -/// - 成功:第二字节不为 0 -/// - 失败或无法解析:返回 false -bool _isCodecControlSuccess(Map data) { - final dynamic raw = data['rawData']; - if (raw == null) return false; - - int? secondByte; - - if (raw is List) { - if (raw.length >= 2) { - final s = raw[1]; - if (s is int) { - secondByte = s; - } else if (s is num) { - secondByte = s.toInt(); - } else if (s is String) { - final t = s.trim(); - if (t.startsWith('0x') || t.startsWith('0X')) { - secondByte = int.tryParse(t.substring(2), radix: 16); - } else { - secondByte = int.tryParse(t); - } - } - } - } else if (raw is String) { - final normalized = raw.trim().replaceAll('[', '').replaceAll(']', ''); - final parts = - normalized.split(RegExp(r'[,\s]+')).where((e) => e.isNotEmpty).toList(); - if (parts.length >= 2) { - final t = parts[1].trim(); - if (t.startsWith('0x') || t.startsWith('0X')) { - secondByte = int.tryParse(t.substring(2), radix: 16); - } else { - secondByte = int.tryParse(t); - } - } - } else if (raw is Map) { - final dynamic s = raw['second'] ?? raw['status2'] ?? raw['result']; - if (s is int) { - secondByte = s; - } else if (s is num) { - secondByte = s.toInt(); - } else if (s is String) { - final t = s.trim(); - if (t.startsWith('0x') || t.startsWith('0X')) { - secondByte = int.tryParse(t.substring(2), radix: 16); - } else { - secondByte = int.tryParse(t); - } - } - } else { - // 无法解析的类型 - return false; - } - - return secondByte != null && secondByte != 0; +/// BLE 连接状态常量(保持外部 API 兼容) +class BleConnectionState { + static const int disconnected = 0; + static const int connecting = 1; + static const int connected = 2; + static const int disconnecting = 3; + static const int error = -1; } -/// 蓝牙设备信息缓存模型 +/// 设备信息缓存(精简后版本,去掉唤醒词字段) class DeviceInfo { final String address; final String name; - - // 整体电池电量 final int batteryLevel; - - // 详细电池信息 final int leftEarBattery; final int rightEarBattery; final int caseBattery; - - // 充电状态 final bool leftEarCharging; final bool rightEarCharging; final bool caseCharging; - final String firmwareVersion; final String firmwareProductId; final String firmwareProductColor; final DateTime lastUpdated; - //唤醒词版本 - final String wakeupLeftVersion; - final String wakeupRightVersion; - DeviceInfo({ required this.address, required this.name, @@ -138,11 +56,8 @@ class DeviceInfo { this.firmwareProductId = '', this.firmwareProductColor = '', DateTime? lastUpdated, - this.wakeupLeftVersion = '', - this.wakeupRightVersion = '', }) : lastUpdated = lastUpdated ?? DateTime.now(); - /// 创建更新后的设备信息副本 DeviceInfo copyWith({ String? address, String? name, @@ -156,8 +71,6 @@ class DeviceInfo { String? firmwareVersion, String? firmwareProductId, String? firmwareProductColor, - String? wakeupLeftVersion, - String? wakeupRightVersion, }) { return DeviceInfo( address: address ?? this.address, @@ -173,683 +86,614 @@ class DeviceInfo { firmwareProductId: firmwareProductId ?? this.firmwareProductId, firmwareProductColor: firmwareProductColor ?? this.firmwareProductColor, lastUpdated: DateTime.now(), - wakeupLeftVersion: wakeupLeftVersion ?? this.wakeupLeftVersion, - wakeupRightVersion: wakeupRightVersion ?? this.wakeupRightVersion, ); } @override - String toString() { - return 'DeviceInfo(address: $address, name: $name, ' - 'battery: $batteryLevel%, L: $leftEarBattery%, R: $rightEarBattery%, Case: $caseBattery%, ' - 'charging: L:$leftEarCharging, R:$rightEarCharging, C:$caseCharging, ' - 'version: $firmwareVersion, ' - 'wakeupLeftVersion: $wakeupLeftVersion, ' - 'wakeupRightVersion: $wakeupRightVersion)'; - } + String toString() => + 'DeviceInfo(addr: $address, name: $name, battery: $batteryLevel%, fw: $firmwareVersion)'; } -/// BLE管理器 - 专门处理BLE设备连接和信息管理 +/// 杰理蓝牙管理器(替换旧的 ble_service 实现,对外保留 BleManager 名字) class BleManager extends GetxService { static BleManager get to => Get.find(); - // BLE服务实例 - final BleService _bleService = BleService(); -// 经典蓝牙实例 - - // 当前连接的设备信息 + // ─── 连接信息 ──────────────────────────────────────── String? currentDeviceAddress; String? _currentDeviceName; + JieliDevice? _connectedDevice; + JieliDevice? get connectedDevice => _connectedDevice; - // BLE连接状态 final _connectionState = BleConnectionState.disconnected.obs; int get connectionState => _connectionState.value; - - // 公开的连接状态流,供外部监听 RxInt get connectionStateRx => _connectionState; - // 连接状态监听 final _isCall = false.obs; final _isConnected = false.obs; bool get isConnected => _isConnected.value; bool get isCall => _isCall.value; - // 设备信息缓存 - final deviceInfo = Rx(null); // 使用 Rx 包装 - //添加编码器状态跟踪 + final deviceInfo = Rx(null); + + // ─── 通话翻译/编解码器(杰理 startTranslation 模式状态)───── final _isCodecActive = false.obs; bool get isCodecActive => _isCodecActive.value; RxBool get isCodecActiveRx => _isCodecActive; - bool _awaitingCodecOpen = false; //是否正在等待编码器打开的成功应答 - bool _awaitingCodecClose = false; //是否正在等待编码器关闭的成功应答 - // 语音唤醒状态 - final _voiceWakeupEnabled = true.obs; // 唤醒开关状态,默认开启 - bool get voiceWakeupEnabled => _voiceWakeupEnabled.value; - RxBool get voiceWakeupEnabledRx => _voiceWakeupEnabled; - - final _voiceWakeupSupported = false.obs; // 耳机是否支持语音唤醒功能 - bool get voiceWakeupSupported => _voiceWakeupSupported.value; - RxBool get voiceWakeupSupportedRx => _voiceWakeupSupported; + // 设备主动上报通话翻译开关信号(耳机端按键) + final StreamController _callTranslationRequestController = + StreamController.broadcast(); + Stream get onCallTranslationRequest => + _callTranslationRequestController.stream; - // 订阅 - StreamSubscription? _statusSubscription; - StreamSubscription? _dataSubscription; + // 通话翻译模式下,杰理 runtime 解码后推上来的 PCM: + // - in.uplink = 本机用户麦克风(己方/A 路) + // - in.downlink = 对端通话音(对方/B 路) + // 由 translation_controller 转发到 azure_speech 的 AST 流水线。 + final StreamController _inUplinkPcmController = + StreamController.broadcast(); + final StreamController _inDownlinkPcmController = + StreamController.broadcast(); + Stream get inUplinkPcmStream => _inUplinkPcmController.stream; + Stream get inDownlinkPcmStream => _inDownlinkPcmController.stream; + + // ─── 扫描结果(供设备页扫描列表使用)───────────────────── + final RxList scannedDevices = [].obs; + final RxBool isScanning = false.obs; + + /// 每个地址最近一次扫到的 advertisement(电量等)。 + /// scannedDevices 只追加不更新,但电量在每次 advertisement 都可能变; + /// 这里保留最新一次,供连接成功时填到 DeviceInfo。 + final Map _latestAdvByAddress = {}; + + StreamSubscription? _eventSub; final GetStorage _storage = GetStorage(); - final _wakeupVoiceUpgradeProgress = 0.obs; // 唤醒语音升级进度 - RxInt get wakeupVoiceUpgradeProgress => _wakeupVoiceUpgradeProgress; + Future? _initFuture; + bool _initSucceeded = false; + String? _initError; + String? get initError => _initError; + bool get isInitialized => _initSucceeded; + + /// 确保杰理 SDK native 端已 initialize。失败则重试一次。 + /// 失败时抛出 [StateError] —— 调用方负责 try-catch / UI 反馈。 + Future ensureInitialized() async { + if (_initSucceeded) return; + // 上一次 future 还在 in-flight 或失败 → 重新启动一次 + if (_initFuture == null) { + _initFuture = _initializeJieli(); + } + try { + await _initFuture!.timeout(const Duration(seconds: 8)); + } catch (e) { + _initError = e.toString(); + _initFuture = null; // 允许下次重试 + throw StateError('杰理 SDK 初始化失败: $_initError'); + } + if (!_initSucceeded) { + _initFuture = null; + throw StateError('杰理 SDK 初始化失败: ${_initError ?? "unknown"}'); + } + } - final _wakeupVoiceUpgradeStatus = - 0.obs; // 唤醒语音升级状态 0: 未升级 1: 升级中 2: 升级成功 3: 升级失败 - RxInt get wakeupVoiceUpgradeStatus => _wakeupVoiceUpgradeStatus; + /// 兼容旧 API(部分调用点不关心初始化结果) + Future get ready async { + try { + await ensureInitialized(); + } catch (_) { + // 静默:让后续 native 调用自身报错 + } + } - /// 设备上报通话翻译开关信号流 - /// true = 设备上报开启通话翻译 (0x16),false = 设备上报关闭通话翻译 (0x17) - /// 外部(如 TranslationController)可监听该流,收到信号后启动/停止通话翻译功能。 - final StreamController _callTranslationRequestController = - StreamController.broadcast(); - Stream get onCallTranslationRequest => - _callTranslationRequestController.stream; + BleManager() { + print('[BLE] BleManager 构造,立即启动杰理 SDK 初始化'); + _initFuture = _initializeJieli(); + _setupEventListener(); + } - /// 初始化 @override void onInit() { super.onInit(); - Logger.info('BLE管理器初始化'); - - // 设置BLE事件监听 - _setupBleListener(); + print('[BLE] BleManager.onInit (init 已在构造时启动)'); } - /// 释放资源 @override void onClose() { - _statusSubscription?.cancel(); - _dataSubscription?.cancel(); + _eventSub?.cancel(); + _stopBatteryWatchdog(); _callTranslationRequestController.close(); + _inUplinkPcmController.close(); + _inDownlinkPcmController.close(); super.onClose(); } - /// 设置BLE事件监听 - void _setupBleListener() { - _statusSubscription?.cancel(); - _dataSubscription?.cancel(); - - try { - _statusSubscription = _bleService.onStatusUpdated.listen( - (event) { - if (event is! Map) return; - - if (event['type'] == 'connectionStateChanged') { - final state = event['state'] as int; - final oldState = _connectionState.value; - _connectionState.value = state; - Logger.i('BleManager', 'BLE状态更新: $oldState → $state'); - - if (state == BleConnectionState.connected) { - _isConnected.value = true; - } else if (state == BleConnectionState.disconnected || - state == BleConnectionState.error) { - _isConnected.value = false; - _awaitingCodecOpen = false; - _awaitingCodecClose = false; - _isCodecActive.value = false; - _voiceWakeupSupported.value = false; - _voiceWakeupEnabled.value = true; - Logger.i('BleManager', - '[VoiceWakeup] 断开连接,重置 supported=false, enabled=true'); - _updateDeviceInfoIfNeeded( - batteryLevel: 0, - leftEarBattery: 0, - rightEarBattery: 0, - caseBattery: 0, - leftEarCharging: false, - rightEarCharging: false, - caseCharging: false, - firmwareVersion: '', - firmwareProductId: '', - firmwareProductColor: '', - wakeupLeftVersion: '', - wakeupRightVersion: '', - ); - } - } - - if (event['type'] == 'wakeupVoiceUpgradeProgress') { - _wakeupVoiceUpgradeProgress.value = event['progress'] as int? ?? 0; - _wakeupVoiceUpgradeStatus.value = 1; - } - if (event['type'] == 'wakeupVoiceUpgradeSuccess') { - _wakeupVoiceUpgradeStatus.value = 2; - Logger.i('BleManager', '唤醒语音升级成功'); - } - - if (event['type'] == 'wakeupVoiceUpgradeFailure') { - _wakeupVoiceUpgradeStatus.value = 3; - Logger.w('BleManager', '唤醒语音升级失败'); - } - - _processDeviceInfo(event); - }, - onError: (error) { - Logger.e('BleManager', 'BLE状态事件流错误: $error'); - }, + /// 把外部 AST 服务返回的 TTS PCM 灌回耳机。 + /// - leg=='A' / 'uplink' → 对端听(SOURCE_E_SCO_UP_LINK) + /// - leg=='B' / 'downlink' → 本机用户听(SOURCE_E_SCO_DOWN_LINK) + /// + /// 拿到多少就送多少,不做节流也不补静音;底层 SDK / RCSP runtime 自己管 + /// 整段 OPUS 编码 + 串行 writeAudioData 节奏。 + Future feedAstTtsPcm({ + required String leg, + required Uint8List pcm, + int sampleRate = 16000, + int channels = 1, + bool isFinal = false, + }) async { + if (pcm.isEmpty) return true; + final upper = leg.toUpperCase(); + final streamId = (upper == 'A' || upper == 'UPLINK') + ? 'out.uplink' + : 'out.downlink'; + try { + return await Jielihome.instance.feedTranslatedAudio( + streamId: streamId, + pcm: pcm, + sampleRate: sampleRate, + channels: channels, + isFinal: isFinal, ); } catch (e) { - Logger.e('BleManager', '设置BLE事件监听器失败: ${e.toString()}'); + Logger.w('BleManager', 'feedAstTtsPcm leg=$leg failed: $e'); + return false; } } - /// 处理来自原生层的设备信息 - void _processDeviceInfo(Map event) { + Future _initializeJieli() async { try { - if (!event.containsKey('command') || !event.containsKey('success')) { + print('[BLE] [INIT] 调用 Jielihome.initialize ...'); + // 与参考项目 ai-agent-client 保持一致:使用 SDK 默认配置(multiDevice=true, skipNoNameDev=false) + // 不要清空 BluetoothOption.scanFilterData —— 那是杰理协议广播标识,清空反而扫不到 + await Jielihome.instance.initialize(); + _initSucceeded = true; + _initError = null; + print('[BLE] [INIT] 杰理 SDK 初始化完成'); + } catch (e, s) { + _initSucceeded = false; + _initError = e.toString(); + print('[BLE] [INIT] 杰理 SDK 初始化失败: $e\n$s'); + rethrow; + } + } + + void _setupEventListener() { + print('[BLE] [EVT] 准备订阅 Jielihome.events ...'); + _eventSub?.cancel(); + _eventSub = Jielihome.instance.events.listen( + (event) { + // 总开关日志:每一个事件都打一次,便于诊断 native → dart 通道是否畅通 + print('[BLE] [EVT] ${event.runtimeType}'); + if (event is AdapterStatusEvent) { + print('[BLE] [EVT] 适配器状态 enabled=${event.enabled} hasBle=${event.hasBle}'); + } else if (event is ScanStatusEvent) { + print('[BLE] [EVT] 扫描状态变化 ble=${event.ble} started=${event.started}'); + isScanning.value = event.started; + } else if (event is DeviceFoundEvent) { + final found = event.device; + final exists = scannedDevices.any((d) => d.address == found.address); + // 每次广播都用最新数据覆盖(电量/充电状态会变) + _latestAdvByAddress[found.address] = found; + print('[BLE] [EVT] deviceFound name="${found.name}" addr=${found.address} ' + 'rssi=${found.rssi} L=${found.leftBattery}${found.leftCharging == true ? "+" : ""} ' + 'R=${found.rightBattery}${found.rightCharging == true ? "+" : ""} ' + 'Case=${found.caseBattery}${found.caseCharging == true ? "+" : ""} ' + '(已存在=$exists, 当前列表 size=${scannedDevices.length})'); + if (!exists) { + scannedDevices.add(found); + print('[BLE] [EVT] 新设备已加入列表,size=${scannedDevices.length}'); + } + } else if (event is ConnectionStateEvent) { + _handleConnectionState(event); + } else if (event is RcspInitEvent) { + print('[BLE] [EVT] RCSP 初始化: ${event.address} success=${event.success}'); + if (event.success) { + // RCSP init 完成后 SDK 才开始往缓存里填电量;这里启动轮询直到拿到非 0 + _startBatteryWatchdog(); + } + } else if (event is BatteryEvent) { + Logger.i('BleManager', + '[EVT] BatteryEvent addr=${event.address} level=${event.level}'); + _handleBattery(event); + } else if (event is PhoneCallStatusEvent) { + _isCall.value = event.status != 0; + } else if (event is ExpandFunctionEvent) { + _handleExpandFunction(event); + } else if (event is TranslationAudioEvent) { + _handleTranslationAudio(event); + } else if (event is UnknownJieliEvent) { + print('[BLE] [EVT] 未识别事件: ${event.raw}'); + } + }, + onError: (e) => print('[BLE] [EVT] 杰理事件流错误: $e'), + onDone: () => print('[BLE] [EVT] 杰理事件流被关闭 (onDone)'), + ); + print('[BLE] [EVT] 订阅完成 sub=$_eventSub'); + } + + void _handleConnectionState(ConnectionStateEvent e) { + Logger.i('BleManager', 'JL 连接状态: ${e.address} → ${e.state}'); + if (e.state == ConnectionStateEvent.connectionOk) { + currentDeviceAddress = e.address; + _connectionState.value = BleConnectionState.connected; + _isConnected.value = true; + _ensureDeviceInfoStub(); + // 用扫描期间最新一条 advertisement 立刻填上电量(SDK BleScanMessage 里就有 + // leftDeviceQuantity / rightDeviceQuantity / chargingBinQuantity / 各 charging 位) + _fillBatteryFromAdvertisement(e.address); + // 不在这里 fetchDeviceInfo —— RCSP 还没 init,SDK 缓存里的电量是 0; + // 改到 RcspInitEvent 成功时再拉一次 + 之后再轮询几次保险。 + } else if (e.state == ConnectionStateEvent.connectionConnecting) { + _connectionState.value = BleConnectionState.connecting; + } else { + _connectionState.value = BleConnectionState.disconnected; + _isConnected.value = false; + _isCodecActive.value = false; + _stopBatteryWatchdog(); + } + } + + // 连接 + RCSP init 之后 SDK 缓存才开始填电量;BatteryEvent 在某些设备上 + // 又只在变化时才推。所以连上后主动轮询几次拉快照,拿到非 0 就停。 + Timer? _batteryWatchdog; + int _batteryWatchdogTicks = 0; + static const int _batteryWatchdogMaxTicks = 12; // 12 × 2.5s = 30s + static const Duration _batteryWatchdogInterval = Duration(milliseconds: 2500); + + void _startBatteryWatchdog() { + _stopBatteryWatchdog(); + _batteryWatchdogTicks = 0; + Logger.i('BleManager', '[BAT] 启动电量轮询'); + // 立刻先拉一次,再启周期任务 + Future.microtask(fetchDeviceInfo); + _batteryWatchdog = Timer.periodic(_batteryWatchdogInterval, (t) async { + _batteryWatchdogTicks++; + final lvl = deviceInfo.value?.batteryLevel ?? 0; + if (lvl > 0) { + Logger.i('BleManager', + '[BAT] 拿到电量=$lvl%,停止轮询 (tick=$_batteryWatchdogTicks)'); + _stopBatteryWatchdog(); return; } - - final bool success = event['success'] as bool; - if (!success) { - Logger.w('BleManager', '设备命令执行失败: ${event['command']}'); + if (_batteryWatchdogTicks >= _batteryWatchdogMaxTicks) { + Logger.w('BleManager', + '[BAT] 30s 内未拿到电量,停止轮询 (tick=$_batteryWatchdogTicks)'); + _stopBatteryWatchdog(); return; } + Logger.d('BleManager', + '[BAT] 轮询拉快照 tick=$_batteryWatchdogTicks'); + await fetchDeviceInfo(); + }); + } + + void _stopBatteryWatchdog() { + _batteryWatchdog?.cancel(); + _batteryWatchdog = null; + } + + void _handleBattery(BatteryEvent e) { + final level = e.level ?? 0; + _ensureDeviceInfoStub(); + // 杰理 SDK 的 BatteryInfo 只有总电量;左右耳/盒子的精细数据来自 BleScanMessage(见 _fillBatteryFromAdvertisement)。 + // 这里更新总电量;如果当前左右耳还是 0,也镜像一下作为兜底。 + final cur = deviceInfo.value; + if (cur == null) return; + deviceInfo.value = cur.copyWith( + batteryLevel: level, + leftEarBattery: cur.leftEarBattery > 0 ? cur.leftEarBattery : level, + rightEarBattery: cur.rightEarBattery > 0 ? cur.rightEarBattery : level, + ); + } - final command = event['command']; - if (command is int) { - _processDeviceInfoByCommandId(command, event); - } else if (command is String) { - _processDeviceInfoByCommandName(command, event); - } else { - Logger.w('BleManager', '未知的命令类型: ${command.runtimeType}'); - } - } catch (e, s) { - Logger.e('BleManager', '处理设备信息异常: $e', e, s); - } + /// 用扫到的 advertisement 数据立刻填 DeviceInfo 的双端电量/充电状态。 + /// 在 connectionOk 时调用,无需等 RCSP 拉快照。 + void _fillBatteryFromAdvertisement(String address) { + final adv = _latestAdvByAddress[address]; + if (adv == null) { + Logger.w('BleManager', '[BAT] 没有 $address 的 advertisement 数据可用'); + return; + } + final cur = deviceInfo.value; + if (cur == null) return; + // 单耳设备:rightDeviceQuantity 通常是 0 → 把左耳的值镜像过去,避免 UI 显示 0% + final l = adv.leftBattery ?? 0; + final r = adv.rightBattery ?? 0; + final caseBat = adv.caseBattery ?? 0; + final fallbackR = (r == 0 && l > 0) ? l : r; + deviceInfo.value = cur.copyWith( + batteryLevel: l > 0 ? l : (r > 0 ? r : 0), + leftEarBattery: l, + rightEarBattery: fallbackR, + caseBattery: caseBat, + leftEarCharging: adv.leftCharging ?? false, + rightEarCharging: adv.rightCharging ?? false, + caseCharging: adv.caseCharging ?? false, + ); + Logger.i('BleManager', + '[BAT] 来自广播: L=$l R=$fallbackR Case=$caseBat ' + '(charging L=${adv.leftCharging} R=${adv.rightCharging} Case=${adv.caseCharging})'); } - /// 根据命令ID处理设备信息 - void _processDeviceInfoByCommandId( - int commandId, Map data) { - switch (commandId) { - case BleCommands.GET_VERSION: - _updateVersionInfoFromData(data); - break; - case BleCommands.GET_PRODUCT_ID: - _updateProductIdFromData(data); - break; - case BleCommands.GET_PRODUCT_COLOR: - break; - case BleCommands.GET_BATTERY_INFO: - _updateBatteryInfoFromData(data); - break; - case BleCommands.CONTROL_CODEC: - final bool codecSuccess = _isCodecControlSuccess(data); - if (_awaitingCodecOpen) { - _isCodecActive.value = codecSuccess; - _awaitingCodecOpen = false; - } else if (_awaitingCodecClose) { - if (codecSuccess) { - _isCodecActive.value = false; - } - _awaitingCodecClose = false; + void _handleTranslationAudio(TranslationAudioEvent e) { + // streamId 来自 native TranslationStreams:in.uplink / in.downlink / in.mic ... + // 仅转发通话翻译两条输入流;out.* 由这里产生回灌指令而不是接收。 + switch (e.streamId) { + case 'in.uplink': + if (!_inUplinkPcmController.isClosed) { + _inUplinkPcmController.add(e.pcm); } - if (!codecSuccess) { - Logger.w('BleManager', '编解码控制失败'); - } - break; - case BleCommands.GET_WAKEUP_VERSION: - _updateWakeupVersionFromData(data); - break; - case BleCommands.GET_VOICE_WAKEUP_STATUS: - _updateVoiceWakeupStatusFromData(data); break; - case BleCommands.SET_VOICE_WAKEUP_STATUS: - _updateVoiceWakeupSetResultFromData(data); - break; - case BleCommands.CALL_TRANSLATION_ON: - _handleCallTranslationRequest(true, data); - break; - case BleCommands.CALL_TRANSLATION_OFF: - _handleCallTranslationRequest(false, data); + case 'in.downlink': + if (!_inDownlinkPcmController.isClosed) { + _inDownlinkPcmController.add(e.pcm); + } break; - default: - Logger.w('BleManager', '未处理的命令ID: $commandId'); } } - /// 处理设备上报的通话翻译开关指令 (0x16 / 0x17) - /// - [enable] true=开启通话翻译, false=关闭 - /// 行为: - /// 1. 打印 [CALL_TRANS_CMD] 日志; - /// 2. 向 [onCallTranslationRequest] 流广播信号(TranslationController 监听并启停); - /// 3. 未在翻译页时,自动导航到 Routes.translation 并以 call 模式 + autoStart 进入。 - void _handleCallTranslationRequest( - bool enable, Map data) { - final status = data['status']; - Logger.i('BleManager', - '[CALL_TRANS_CMD] 收到设备上报${enable ? "开启" : "关闭"}通话翻译 cmd=0x${(enable ? BleCommands.CALL_TRANSLATION_ON : BleCommands.CALL_TRANSLATION_OFF).toRadixString(16)} status=$status'); + void _handleExpandFunction(ExpandFunctionEvent e) { + if (e.opCode == 0x16) { + _emitCallTranslationRequest(true); + } else if (e.opCode == 0x17) { + _emitCallTranslationRequest(false); + } + } - // 广播给监听者(如 TranslationController 已在 call 模式可直接启/停) + void _emitCallTranslationRequest(bool enable) { + Logger.i('BleManager', + '[CALL_TRANS_CMD] 设备上报${enable ? "开启" : "关闭"}通话翻译'); if (!_callTranslationRequestController.isClosed) { _callTranslationRequestController.add(enable); } - - // 开启:若当前不在翻译页,则导航并请求自动开始 if (enable) { final currentRoute = Get.currentRoute; if (currentRoute != Routes.translation) { - Logger.i('BleManager', - '[CALL_TRANS_CMD] 当前路由=$currentRoute,导航到翻译页(call 模式, autoStart=true)'); Get.toNamed( Routes.translation, arguments: {'translationId': 'call', 'autoStart': true}, ); - } else { - Logger.i('BleManager', - '[CALL_TRANS_CMD] 已在翻译页,由 TranslationController 监听信号自行启动'); } } } - /// 根据命令名称处理设备信息 - void _processDeviceInfoByCommandName( - String commandName, Map data) { - switch (commandName) { - case 'batteryInfo': - _updateBatteryInfoFromData(data); - break; - case 'versionInfo': - _updateVersionInfoFromData(data); - break; - case 'productId': - _updateProductIdFromData(data); - break; - case 'productColor': - break; - case 'Error': - Logger.w('BleManager', '收到 Error 命令'); - break; - default: - Logger.w('BleManager', '未处理的命令名称: $commandName'); + void _ensureDeviceInfoStub() { + if (deviceInfo.value == null && currentDeviceAddress != null) { + deviceInfo.value = DeviceInfo( + address: currentDeviceAddress!, + name: _currentDeviceName ?? '', + ); } } - /// 更新电池信息 - void _updateBatteryInfoFromData(Map data) { + // ─── 扫描 / 连接 ──────────────────────────────────── + Future startScan({ + Duration timeout = const Duration(seconds: 30), + }) async { + print('[BLE] [SCAN] BleManager.startScan 入口'); + _lastScanError = null; try { - if (!data.containsKey('leftEarBattery') || - !data.containsKey('rightEarBattery') || - !data.containsKey('caseBattery')) { - Logger.w('BleManager', '电池信息数据不完整'); - return; - } - - final int leftBattery = (data['leftEarBattery'] as int? ?? 0) == 127 - ? -1 - : (data['leftEarBattery'] as int? ?? 0) > 100 - ? 0 - : (data['leftEarBattery'] as int? ?? 0); - - final int rightBattery = (data['rightEarBattery'] as int? ?? 0) == 127 - ? -1 - : (data['rightEarBattery'] as int? ?? 0) > 100 - ? 0 - : (data['rightEarBattery'] as int? ?? 0); - - final int caseBattery = (data['caseBattery'] as int? ?? 0) == 127 - ? -1 - : (data['caseBattery'] as int? ?? 0) > 100 - ? 0 - : (data['caseBattery'] as int? ?? 0); - final bool leftCharging = data['leftEarCharging'] as bool? ?? false; - final bool rightCharging = data['rightEarCharging'] as bool? ?? false; - final bool caseCharging = data['caseCharging'] as bool? ?? false; - - final int overallBattery = data['batteryLevel'] as int? ?? leftBattery; - - _updateDeviceInfoIfNeeded( - batteryLevel: overallBattery, - leftEarBattery: leftBattery, - rightEarBattery: rightBattery, - caseBattery: caseBattery, - leftEarCharging: leftCharging, - rightEarCharging: rightCharging, - caseCharging: caseCharging, - ); + await ensureInitialized(); + } catch (e) { + print('[BLE] [SCAN] 杰理 SDK 未初始化: $e'); + _lastScanError = e.toString(); + isScanning.value = false; + return false; + } + print('[BLE] [SCAN] init 已通过,清空列表并发送 native startScan'); + scannedDevices.clear(); + isScanning.value = true; + try { + await Jielihome.instance.startScan(timeout: timeout); + print('[BLE] [SCAN] native startScan 返回成功,等待 deviceFound 事件'); + return true; } catch (e, s) { - Logger.e('BleManager', '更新电池信息失败: $e', e, s); + print('[BLE] [SCAN] 启动扫描失败: $e\n$s'); + isScanning.value = false; + _lastScanError = e.toString(); + return false; } } - /// 更新版本信息 - void _updateVersionInfoFromData(Map data) { - try { - // 确保数据中包含版本信息 - if (!data.containsKey('version')) { - Logger.warning('版本信息数据不完整'); - return; - } - - // 提取版本信息 - final String version = data['version'] as String? ?? ''; - - // 更新设备信息 - _updateDeviceInfoIfNeeded( - firmwareVersion: version, - ); + String? _lastScanError; + String? get lastScanError => _lastScanError; - Logger.info('设备版本信息已更新: 版本=$version'); + Future stopScan() async { + await ready; + try { + await Jielihome.instance.stopScan(); } catch (e) { - Logger.error('更新版本信息失败: ${e.toString()}'); + Logger.e('BleManager', '停止扫描失败: $e'); + } finally { + isScanning.value = false; } } - /// 更新设备id,并检测是否为移动精灵设备 - void _updateProductIdFromData(Map data) { + Future connectDevice(JieliDevice device) async { + await ready; + _currentDeviceName = device.name; + currentDeviceAddress = device.address; + _connectedDevice = device; + _connectionState.value = BleConnectionState.connecting; try { - if (!data.containsKey('productId')) { - Logger.warning('版本信息数据不完整'); - return; - } - - final String productId = data['productId'] as String? ?? ''; - - _updateDeviceInfoIfNeeded(firmwareProductId: productId); - Logger.info('设备设备id已更新: 设备id=$productId'); + await Jielihome.instance.connect(device); } catch (e) { - Logger.error('更新设备id失败: ${e.toString()}'); + Logger.e('BleManager', '连接设备失败: $e'); + _connectionState.value = BleConnectionState.error; } } - /// 更新唤醒语音版本信息 - void _updateWakeupVersionFromData(Map data) { + Future disconnect() async { + await ready; try { - // 确保数据中包含版本信息 - if (!data.containsKey('leftVersionDec') || - !data.containsKey('rightVersionDec')) { - Logger.warning('唤醒语音版本信息数据不完整'); - return; + final addr = currentDeviceAddress; + if (addr != null) { + await Jielihome.instance.disconnect(addr); } - - // 提取版本信息 - final String wakeupLeftVersion = data['leftVersionDec'] as String? ?? ''; - final String wakeupRightVersion = - data['rightVersionDec'] as String? ?? ''; - - // 更新设备信息 - _updateDeviceInfoIfNeeded( - wakeupLeftVersion: wakeupLeftVersion, - wakeupRightVersion: wakeupRightVersion, - ); - - Logger.info('设备唤醒语音版本信息已更新: 左=$wakeupLeftVersion, 右=$wakeupRightVersion'); } catch (e) { - Logger.error('更新唤醒语音版本信息失败: ${e.toString()}'); + Logger.e('BleManager', '断开连接失败: $e'); } } - /// 更新语音唤醒状态(0x14 查询响应) - void _updateVoiceWakeupStatusFromData(Map data) { + Future getConnectionState() async { + final addr = currentDeviceAddress; + if (addr == null) return BleConnectionState.disconnected; try { - if (!data.containsKey('voiceWakeupEnabled')) { - Logger.warning('[VoiceWakeup] 查询响应数据不完整,缺少 voiceWakeupEnabled'); - return; - } - final bool enabled = data['voiceWakeupEnabled'] as bool? ?? true; - _voiceWakeupEnabled.value = enabled; - _voiceWakeupSupported.value = true; - Logger.info('[VoiceWakeup] 查询成功 → supported=true, enabled=${enabled ? "开启" : "关闭"}'); + final connected = await Jielihome.instance.isConnected(addr); + _connectionState.value = + connected ? BleConnectionState.connected : BleConnectionState.disconnected; + _isConnected.value = connected; + return _connectionState.value; } catch (e) { - Logger.error('[VoiceWakeup] 解析查询响应异常: ${e.toString()}'); + Logger.e('BleManager', '查询连接状态失败: $e'); + return BleConnectionState.error; } } - /// 处理设置语音唤醒的响应(0x15 设置响应) - void _updateVoiceWakeupSetResultFromData(Map data) { + Future isBleConnected() async { + final addr = currentDeviceAddress; + if (addr == null) return false; try { - final bool setSuccess = data['voiceWakeupSetSuccess'] as bool? ?? false; - if (setSuccess) { - Logger.info('[VoiceWakeup] 设置成功 → enabled=${_voiceWakeupEnabled.value ? "开启" : "关闭"}'); - } else { - Logger.warning('[VoiceWakeup] 设置失败 → supported=false,开关置灰'); - _voiceWakeupSupported.value = false; - } - } catch (e) { - Logger.error('[VoiceWakeup] 解析设置响应异常: ${e.toString()}'); + return await Jielihome.instance.isConnected(addr); + } catch (_) { + return false; } } - /// 查询语音唤醒状态 - Future queryVoiceWakeupStatus() async { - Logger.info('[VoiceWakeup] 发送查询命令(0x14)...'); - final result = await _bleService.getVoiceWakeupStatus(); - if (!result) { - _voiceWakeupSupported.value = false; - Logger.warning('[VoiceWakeup] 查询命令发送失败 → supported=false,设备可能不支持'); - } else { - Logger.info('[VoiceWakeup] 查询命令发送成功,等待设备响应...'); + /// 探查当前是否已经有杰理设备连着(用于登录/启动后判断是否需要跳转扫描页)。 + /// 通过 [Jielihome.connectedDevice] 同步 SDK 已知的连接状态。 + Future hasActiveConnection() async { + await ready; + try { + final dev = await Jielihome.instance.connectedDevice(); + if (dev != null && dev.address.isNotEmpty) { + currentDeviceAddress = dev.address; + _currentDeviceName = dev.name; + _connectedDevice = dev; + _isConnected.value = true; + _connectionState.value = BleConnectionState.connected; + _ensureDeviceInfoStub(); + return true; + } + } catch (e) { + Logger.w('BleManager', 'hasActiveConnection 探查失败: $e'); } - return result; + return false; } - /// 设置语音唤醒状态 - Future setVoiceWakeupStatus(bool enabled) async { - Logger.info('[VoiceWakeup] 发送设置命令(0x15), enabled=${enabled ? "开启" : "关闭"}...'); - _voiceWakeupEnabled.value = enabled; // 乐观更新 - final result = await _bleService.setVoiceWakeupStatus(enabled); - if (!result) { - _voiceWakeupSupported.value = false; - Logger.warning('[VoiceWakeup] 设置命令发送失败 → supported=false,开关置灰'); - } else { - Logger.info('[VoiceWakeup] 设置命令发送成功,等待设备响应...'); - } - return result; + // ─── 设备信息 ─────────────────────────────────────── + String getDeviceName() { + return deviceInfo.value?.name ?? _currentDeviceName ?? ''; } - /// 更新设备信息(如果已存在设备) - void _updateDeviceInfoIfNeeded({ - int? batteryLevel, - int? leftEarBattery, - int? rightEarBattery, - int? caseBattery, - bool? leftEarCharging, - bool? rightEarCharging, - bool? caseCharging, - String? firmwareVersion, - String? firmwareProductId, - String? firmwareProductColor, - String? wakeupLeftVersion, - String? wakeupRightVersion, - }) { - // 如果设备信息已存在,则更新 - if (deviceInfo.value != null) { - deviceInfo.value = deviceInfo.value!.copyWith( - batteryLevel: batteryLevel ?? deviceInfo.value?.batteryLevel, - leftEarBattery: _getValidBatteryValue( - leftEarBattery, deviceInfo.value?.leftEarBattery), - rightEarBattery: _getValidBatteryValue( - rightEarBattery, deviceInfo.value?.rightEarBattery), - caseBattery: caseBattery ?? deviceInfo.value?.caseBattery, - leftEarCharging: leftEarCharging ?? deviceInfo.value!.leftEarCharging, - rightEarCharging: - rightEarCharging ?? deviceInfo.value!.rightEarCharging, - caseCharging: caseCharging ?? deviceInfo.value!.caseCharging, - firmwareVersion: firmwareVersion ?? deviceInfo.value!.firmwareVersion, - firmwareProductId: - firmwareProductId ?? deviceInfo.value!.firmwareProductId, - firmwareProductColor: - firmwareProductColor ?? deviceInfo.value!.firmwareProductColor, - wakeupLeftVersion: - wakeupLeftVersion ?? deviceInfo.value!.wakeupLeftVersion, - wakeupRightVersion: - wakeupRightVersion ?? deviceInfo.value!.wakeupRightVersion, + Future fetchDeviceInfo() async { + final addr = currentDeviceAddress; + if (addr == null) return; + try { + final snap = await Jielihome.instance.deviceSnapshot(addr) ?? {}; + final name = (snap['name'] as String?) ?? _currentDeviceName ?? ''; + final battery = (snap['battery'] as num?)?.toInt() ?? + (snap['batteryLevel'] as num?)?.toInt() ?? + 0; + final fw = (snap['firmwareVersion'] as String?) ?? + (snap['version'] as String?) ?? + ''; + final pid = (snap['productId'] as String?) ?? ''; + _ensureDeviceInfoStub(); + // 同 _handleBattery:杰理 SDK 只暴露单个总电量,把它同时镜像到左右耳, + // home_view 才能从 leftEarBattery / rightEarBattery 读到非零值。 + deviceInfo.value = (deviceInfo.value ?? DeviceInfo(address: addr, name: name)) + .copyWith( + name: name, + batteryLevel: battery, + leftEarBattery: battery, + rightEarBattery: battery, + firmwareVersion: fw, + firmwareProductId: pid, ); - } - // 创建设备信息 - // 如果设备信息不存在,但有地址和名称,则创建新的设备信息 - else if (deviceInfo.value == null && - _isConnected.value && - (firmwareVersion != null || batteryLevel != null)) { - String deviceAddress = currentDeviceAddress ?? ""; - String deviceName = _currentDeviceName ?? ""; - Logger.i('BleManager', - '创建新的设备信息对象: address=$deviceAddress, name=$deviceName'); - deviceInfo.value = DeviceInfo( - address: deviceAddress, - name: deviceName, - batteryLevel: batteryLevel ?? 0, - leftEarBattery: leftEarBattery ?? 0, - rightEarBattery: rightEarBattery ?? 0, - caseBattery: caseBattery ?? 0, - leftEarCharging: leftEarCharging ?? false, - rightEarCharging: rightEarCharging ?? false, - caseCharging: caseCharging ?? false, - firmwareVersion: firmwareVersion ?? '', - firmwareProductId: firmwareProductId ?? '', - firmwareProductColor: firmwareProductColor ?? '', - wakeupLeftVersion: wakeupLeftVersion ?? '', - wakeupRightVersion: wakeupRightVersion ?? '', - ); + '设备快照: name=$name battery=$battery fw=$fw pid=$pid'); + } catch (e) { + Logger.w('BleManager', '获取设备快照失败: $e'); } } - // 新增智能电池值选择方法 - int? _getValidBatteryValue(int? newValue, int? currentValue) { - // 如果新值为null,使用当前值 - if (newValue == null) return currentValue; - - // 如果当前值为null或0,使用新值(即使新值为0) - if (currentValue == null || currentValue == 0) return newValue; - - // 如果新值为0但当前值不为0,保持当前值(避免有效数据被0覆盖) - if (newValue == 0 && currentValue > 0) { - Logger.warning('检测到电池数据异常(新值为0),保持当前值: $currentValue'); - return currentValue; - } - - // 其他情况使用新值 - return newValue; - } + Future fetchBatteryInfo() async => true; + Future fetchDeviceVersion() async => true; + Future getProductId() async => true; + Future getBatteryLevel() async => + deviceInfo.value?.batteryLevel ?? 0; - /// 清除设备信息(用于完全断开连接或切换设备时) void clearDeviceInfo() { - Logger.info('清除设备信息'); deviceInfo.value = null; currentDeviceAddress = null; _currentDeviceName = null; + _connectedDevice = null; } - /// 连接成功后获取设备信息 - Future fetchDeviceInfo() async { - Logger.info('开始获取BLE设备详细信息'); + // ─── 编解码器 / 通话翻译模式 ─────────────────────────── + // 杰理:openA2DPDecoder/closeCodec 映射到 startTranslation/stopTranslation。 + // 其余 codec 入口按现有调用语义保留乐观成功,以保持上层兼容。 - try { - // 确保设备信息对象存在 - if (deviceInfo.value == null && _isConnected.value) { - // 创建初始设备信息对象 - String deviceAddress = currentDeviceAddress ?? ""; - String deviceName = _currentDeviceName ?? ""; - - Logger.info('初始化设备信息对象: address=$deviceAddress, name=$deviceName'); - deviceInfo.value = DeviceInfo( - address: deviceAddress, - name: deviceName, - batteryLevel: 0, - leftEarBattery: 0, - rightEarBattery: 0, - caseBattery: 0, - leftEarCharging: false, - rightEarCharging: false, - caseCharging: false, - firmwareVersion: '', - firmwareProductId: '', - firmwareProductColor: '', - wakeupLeftVersion: '', - wakeupRightVersion: '', - ); - } - - // 并行获取所有设备信息,不使用延迟 - final results = await Future.wait([ - _bleService.getBatteryInfo(), - _bleService.getDeviceVersion(), - _bleService.getProductId(), - _bleService.getVoiceWakeupStatus(), - ]); - - Logger.info( - '设备信息获取结果: 电池=${results[0]}, 版本=${results[1]}, 产品ID=${results[2]}, 唤醒状态=${results[3]}'); + Future openEncoder() async { + _isCodecActive.value = true; + return true; + } - if (!results[0]) { - Logger.warning('电池信息获取指令发送失败'); - } - if (!results[1]) { - Logger.warning('版本信息获取指令发送失败'); - } - if (!results[2]) { - Logger.warning('产品ID获取指令发送失败'); - } - if (!results[3]) { - Logger.warning('语音唤醒状态获取指令发送失败'); - _voiceWakeupSupported.value = false; - } - } catch (e) { - Logger.error('获取BLE设备信息失败: ${e.toString()}'); - } + Future openDecoder() async { + _isCodecActive.value = true; + return true; } - /// 检查BLE连接状态 - Future getConnectionState() async { + Future openA2DPDecoder() async { + Logger.i('CALL_TRANS_CMD', '启动通话翻译模式 (杰理 startTranslation)'); try { - final isConnected = await _bleService.isConnected(); - _connectionState.value = isConnected - ? BleConnectionState.connected - : BleConnectionState.disconnected; - _isConnected.value = isConnected; - - return _connectionState.value; + await Jielihome.instance.startTranslation( + modeId: TranslationModeIds.callTranslation, + ); + _isCodecActive.value = true; + return true; } catch (e) { - Logger.error('获取BLE连接状态失败: ${e.toString()}'); - return BleConnectionState.error; + Logger.e('CALL_TRANS_CMD', '启动通话翻译模式失败: $e'); + _isCodecActive.value = false; + return false; } } - String getDeviceName() { - if (deviceInfo.value != null) { - return deviceInfo.value!.name; - } - return ""; + Future openCallRecordDecoder() async { + _isCodecActive.value = true; + return true; } - // 加载TTS偏好设置 - Future _loadTtsInterrupe() async { + Future closeCodec() async { + _isCodecActive.value = false; try { - if (!_storage.hasData("agent_tts_interrupt")) { - // 获取设备类型以确定检查策略 - String deviceName = getDeviceName(); - DBProduct? product = AppConfig.getproduct(deviceName); - if (deviceName != "" && product != null) { - // Logger.d('AgentController', - // 'liwe----------product $deviceName ${product.toJson()}'); - return !product.iscontinuouschat!; - } else { - return false; - } - } else { - return _storage.read('agent_tts_interrupt') ?? false; - } - } catch (e) { - return false; + await Jielihome.instance.stopTranslation(); + } catch (_) { + // 静默失败:未启动时调 stop 是常态 } + return true; } - /// 启动BLE服务 + Future playA2DP() async => true; + + // ─── OTA ─────────────────────────────────────────── + /// 杰理 OTA 通过 Jielihome.otaStart 触发,此处保留空实现以兼容旧调用点。 + Future setEnterOta(bool enterOta) async {} + + // ─── 兼容存根 ─────────────────────────────────────── + Future requestBluetoothPermission() async => true; + + /// 旧 Companion 关联 / 后台扫描接口已废弃 — 保留空实现以兼容 + /// 即将被移除的调用点(pairing 模块已删除)。 + Future clearPairedDevices() async { + await disconnect(); + return true; + } + + // ─── AgentService 启动 / 停止(保持原逻辑) ─────────── Future startAgentService() async { try { - // 从.env文件直接读取环境变量 var serverurl = DioManager().baseUrl; - var jsonString = _storage.read("logintoken"); final azureSpeechKey = AppConfig.env('AZURE_SPEECH_KEY') ?? ''; @@ -875,25 +719,19 @@ class BleManager extends GetxService { final xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? ''; final xunfeiAccessKeySecret = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? ''; - // 从Flutter资源包中加载.mcp.json final jsonData = AppConfig.mcpConfig(); - Logger.info('启动BLE服务,musicServiceType: $musicServiceType'); + Logger.info('启动 AgentService,musicServiceType: $musicServiceType'); final isTtsInterruptEnabled = await _loadTtsInterrupe(); final vipExpiryTime = userInfo.User.instance.vipexptime; - Logger.info('liwei------ vipexptime 1: $vipExpiryTime'); - // 根据APP语言动态配置语音识别语言 List supportedLanguages = []; try { final storage = GetStorage(); final languageManager = Get.find(); - - // 从存储中读取用户设置的语言 String? savedLanguage = storage.read('language'); if (savedLanguage != null && savedLanguage != 'system') { - // 用户设置了特定语言 final parts = savedLanguage.split('_'); if (parts.length == 2) { final lang = parts[0].toLowerCase(); @@ -903,14 +741,12 @@ class BleManager extends GetxService { (country == 'HK' || country == 'TW' || country == 'CN')) { supportedLanguages.add('zh-$country'); } else if (lang == 'tl' && country == 'PH') { - // 菲律宾语特殊处理,映射为azure语言码 supportedLanguages.add('fil-PH'); } else { supportedLanguages.add('$lang-$country'); } } } else { - // 跟随系统语言或未设置 final appLocale = Get.locale ?? AppTranslations.deviceLocale(); if (appLocale != null) { final lang = appLocale.languageCode.toLowerCase(); @@ -943,33 +779,26 @@ class BleManager extends GetxService { } } - // 最终兜底 if (supportedLanguages.isEmpty) { supportedLanguages.add('en-US'); } - - // Logger.info('动态配置语音识别语言: ${supportedLanguages.join(', ')}'); } catch (e) { Logger.error('获取APP语言配置失败,使用默认语言: $e'); supportedLanguages = ['zh-CN', 'en-US']; } - // STS 模式参数(移动精灵) - // 优先使用设备检测结果(YIDONG_PID 匹配时写入),否则用服务端 AGENT_TYPE - final agentType = _storage.read('device_agent_type') - ?? AppConfig.env('AGENT_TYPE') - ?? 'stt_llm_tts'; + 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') ?? ''; - // 使用当前连接设备的 SN(配对时写入),认证失败由原生层上报 credential_failed 事件 final stsDeviceNo = _storage.read('device_sts_code') ?? ''; final stsDeviceId = _storage.read('sts_device_id') ?? ''; final stsDeviceSecret = _storage.read('sts_device_secret') ?? ''; - // 从 UserDevice 获取当前连接设备的 CMEI final mac = currentDeviceAddress?.toUpperCase() ?? ''; String stsCmei = ''; try { @@ -978,10 +807,8 @@ class BleManager extends GetxService { ); stsCmei = matchedDevice.cmei; } catch (_) {} - Logger.i('BleManager', '当前设备 CMEI: $stsCmei (MAC=$mac)'); final config = { - // STS 模式路由 'agentMode': agentMode, 'agentId': agentId, 'stsProductId': stsProductId, @@ -990,7 +817,6 @@ class BleManager extends GetxService { 'stsDeviceId': stsDeviceId, 'stsDeviceSecret': stsDeviceSecret, 'stsCmei': stsCmei, - // 语音代理配置参数 'azureSpeechKey': azureSpeechKey, 'azureSpeechRegion': azureSpeechRegion, 'xunfeiAppId': xunfeiAppId, @@ -1008,33 +834,13 @@ class BleManager extends GetxService { 'usertoken': jsonString, 'supportedLanguages': supportedLanguages, 'musicServiceType': musicServiceType, - "navigationMode": navigationMode, - "isttsinterrupt": isTtsInterruptEnabled ? "true" : "false", - "vipexptime": vipExpiryTime, //会员过期时间 - "callingToolText": "callingTool".tr, - "insufficientIntegralText": "insufficientIntegral".tr, + 'navigationMode': navigationMode, + 'isttsinterrupt': isTtsInterruptEnabled ? 'true' : 'false', + 'vipexptime': vipExpiryTime, + 'callingToolText': 'callingTool'.tr, + 'insufficientIntegralText': 'insufficientIntegral'.tr, }; - Logger.info('启动BLE服务,配置参数键名: ${config.keys.toList()}'); - if (xunfeiAppId.isEmpty || - xunfeiAccessKeyId.isEmpty || - xunfeiAccessKeySecret.isEmpty) { - Logger.warning('XUNFEI_ASR_* 未完整配置,ASR 将不会切到讯飞'); - } - - if (agentMode != 'sts' && (azureSpeechKey.isEmpty || openaiApiKey.isEmpty)) { - Logger.warning('缺少必要的API密钥,请检查.env文件配置'); - - // 检查.env中相关密钥是否存在 - if (azureSpeechKey.isEmpty) { - Logger.warning('AZURE_SPEECH_KEY未配置在.env文件中'); - } - if (openaiApiKey.isEmpty) { - Logger.warning('OPENAI_API_KEY未配置在.env文件中'); - } - } - - // 设置原生日志回调,将 Agent 底层日志写入文件 AgentService.nativeLogCallback ??= (level, message) { switch (level) { case 'ERROR': @@ -1051,381 +857,64 @@ class BleManager extends GetxService { } }; - // STS 模式:监听凭证获取事件并缓存到 GetStorage if (agentMode == 'sts') { - AgentService.events.where((e) => e.type == AgentServiceEventType.stsCredentials).listen((e) { + AgentService.events + .where((e) => e.type == AgentServiceEventType.stsCredentials) + .listen((e) { final deviceId = e.data['deviceId']?.toString() ?? ''; final deviceSecret = e.data['deviceSecret']?.toString() ?? ''; final deviceNo = e.data['deviceNo']?.toString() ?? ''; if (deviceId.isNotEmpty) _storage.write('sts_device_id', deviceId); - if (deviceSecret.isNotEmpty) _storage.write('sts_device_secret', deviceSecret); + if (deviceSecret.isNotEmpty) { + _storage.write('sts_device_secret', deviceSecret); + } if (deviceNo.isNotEmpty) _storage.write('sts_device_no', deviceNo); - Logger.info('STS 凭证已缓存: deviceId=$deviceId deviceNo=$deviceNo'); }); } final result = await AgentService.startAgentService(config); Logger.i('BleManager', 'AgentService.startAgentService 结果: $result'); - - if (result) { - final isConnected = await _bleService.isConnected(); - if (isConnected) { - _isConnected.value = true; - _connectionState.value = BleConnectionState.connected; - } - } else { - Logger.e('BleManager', 'BLE服务启动失败'); - } - return result; } catch (e) { - Logger.error('启动BLE服务失败: ${e.toString()}'); + Logger.error('启动 AgentService 失败: $e'); return false; } } - /// 停止BLE服务 Future stopAgentService() async { try { final result = await AgentService.stopAgentService(); - if (result) { - Logger.info('BLE服务停止成功'); - _isConnected.value = false; - _connectionState.value = BleConnectionState.disconnected; - // 停止服务时清除设备信息 + Logger.info('AgentService 停止成功'); clearDeviceInfo(); - } else { - Logger.error('BLE服务停止失败'); } - - return result; - } catch (e) { - Logger.error('停止BLE服务失败: ${e.toString()}'); - return false; - } - } - - /// 获取电池电量 - Future getBatteryLevel() async { - try { - return await _bleService.getBatteryLevel(); - } catch (e) { - Logger.error('获取电池电量失败: ${e.toString()}'); - return 0; - } - } - - /// 主动获取电池信息 - Future fetchBatteryInfo() async { - try { - //Logger.info('lxm--fetchBatteryInfo被调用,准备向设备发送获取电池信息指令'); - final result = await _bleService.getBatteryInfo(); - //Logger.info('lxm--getBatteryInfo指令发送结果: $result'); - return result; - } catch (e) { - //Logger.error('lxm--获取电池信息失败: ${e.toString()}'); - return false; - } - } - - /// 主动产品id - Future getProductId() async { - try { - return await _bleService.getProductId(); - } catch (e) { - Logger.error('获取设备版本失败: ${e.toString()}'); - return false; - } - } - - /// 主动获取设备版本信息 - Future fetchDeviceVersion() async { - try { - return await _bleService.getDeviceVersion(); - } catch (e) { - Logger.error('获取设备版本失败: ${e.toString()}'); - return false; - } - } - - /// 打开编码器 - Future openEncoder() async { - try { - return await _bleService.openEncoder(); - } catch (e) { - Logger.error('打开编码器失败: ${e.toString()}'); - return false; - } - } - - /// 打开解码器 - Future openDecoder() async { - try { - _awaitingCodecOpen = true; - _awaitingCodecClose = false; - _isCodecActive.value = false; - return await _bleService.openDecoder(); - } catch (e) { - Logger.error('打开解码器失败: ${e.toString()}'); - _awaitingCodecOpen = false; - return false; - } - } - - /// 打开A2DP解码器 - Future openA2DPDecoder() async { - try { - _awaitingCodecOpen = true; - _awaitingCodecClose = false; - _isCodecActive.value = false; - Logger.i('CALL_TRANS_CMD', - '准备发送通话翻译指令 cmd=0x05 sub=0xA2(A2DP_PLAY) → openA2DPDecoder'); - final ok = await _bleService.openA2DPDecoder(); - Logger.i('CALL_TRANS_CMD', - '通话翻译指令下发结果: ${ok ? "成功" : "失败"} (等待设备回包确认)'); - return ok; - } catch (e) { - Logger.e('CALL_TRANS_CMD', '通话翻译指令异常: ${e.toString()}'); - _awaitingCodecOpen = false; - return false; - } - } - - /// 打开解码器 - Future openCallRecordDecoder() async { - try { - return await _bleService.openCallRecordDecoder(); - } catch (e) { - Logger.error('打开解码器失败: ${e.toString()}'); - return false; - } - } - - /// 关闭编解码器 - Future closeCodec() async { - try { - _awaitingCodecOpen = false; - _awaitingCodecClose = true; - _isCodecActive.value = false; - return await _bleService.closeCodec(); - } catch (e) { - Logger.error('关闭编解码器失败: ${e.toString()}'); - _awaitingCodecClose = false; - return false; - } - } - - /// 播放A2DP - Future playA2DP() async { - try { - return await _bleService.playA2DP(); - } catch (e) { - Logger.error('播放A2DP失败: ${e.toString()}'); - return false; - } - } - - // ==================== 配对相关方法 ==================== - - /// 检查设备是否支持Companion Device Manager - Future isCompanionSupported() async { - try { - return await _bleService.isCompanionSupported(); - } catch (e) { - Logger.error('检查Companion支持失败: ${e.toString()}'); - return false; - } - } - - /// 获取已配对的MAC地址 - Future getPairedMacAddress() async { - try { - return await _bleService.getPairedMacAddress(); - } catch (e) { - Logger.error('获取已配对MAC地址失败: ${e.toString()}'); - return null; - } - } - - /// 检查BLE连接状态 - Future isBleConnected() async { - try { - return await _bleService.isBleConnected(); - } catch (e) { - Logger.error('检查BLE连接状态失败: ${e.toString()}'); - return false; - } - } - - /// 设置是否是ota模式 - Future setEnterOta(bool enterOta) async { - try { - await _bleService.setEnterOta(enterOta); - } catch (e) { - Logger.error('设置是否是ota模式失败: ${e.toString()}'); - } - } - - /// 断开连接 - Future disconnect() async { - try { - await _bleService.disconnect(); - } catch (e) { - Logger.error('断开连接失败: ${e.toString()}'); - } - } - - /// 验证设备是否可以配对 - Future startScan(List uuids) async { - try { - await _bleService.startScan(uuids); - } catch (e) { - Logger.error('开始扫描设备: ${e.toString()}'); - } - } - - /// 验证设备是否可以配对 - Future stopScan() async { - try { - await _bleService.stopScan(); - } catch (e) { - Logger.error('停止扫描设备: ${e.toString()}'); - } - } - - Future?> getDeviceInfo(String deviceName) async { - try { - final result = _bleService.getDeviceInfo(deviceName); - return result; - } catch (e) { - Logger.error('获取设备信息: ${e.toString()}'); - return null; - } - } - - Future>?> getDeviceList(String deviceName) async { - try { - final result = _bleService.getDeviceList(deviceName); return result; } catch (e) { - Logger.error('获取设备信息: ${e.toString()}'); - return null; - } - } - - /// 验证设备是否可以配对 - Future verifyDeviceCanPair(String macAddress) async { - try { - return true; - } catch (e) { - Logger.error('验证设备配对失败: ${e.toString()}'); + Logger.error('停止 AgentService 失败: $e'); return false; } } - /// 启动设备关联 - Future startDeviceAssociation({ - required String deviceNamePattern, - required String macAddress, - }) async { - try { - return await _bleService.startDeviceAssociation( - deviceNamePattern: deviceNamePattern, - macAddress: macAddress, - ); - } catch (e) { - Logger.error('启动设备关联失败: ${e.toString()}'); - return false; - } - } - - /// 启动设备关联 - Future connect() async { - try { - await _bleService.connect(); - } catch (e) { - Logger.error('连接设备失败: ${e.toString()}'); - } - } - - /// 注册后台扫描 - Future registerBackgroundScan({ - String? macAddress, - List? uuids, - }) async { + Future _loadTtsInterrupe() async { try { - // 在注册新设备前先清除旧设备信息 - if (macAddress != null && macAddress != currentDeviceAddress) { - clearDeviceInfo(); // 清除上一个设备的信息 - } - - // 保存设备地址信息 - if (macAddress != null) { - currentDeviceAddress = macAddress; - // 从蓝牙管理器获取设备名称 - final bluetoothManager = Get.find(); - _currentDeviceName = bluetoothManager.currentDevice?.name ?? ""; - - Logger.info( - '保存设备信息: address=$currentDeviceAddress, name=$_currentDeviceName'); + if (!_storage.hasData('agent_tts_interrupt')) { + String deviceName = getDeviceName(); + DBProduct? product = AppConfig.getproduct(deviceName); + if (deviceName != '' && product != null) { + return !product.iscontinuouschat!; + } + return false; } - - return await _bleService.registerBackgroundScan( - macAddress: macAddress, - uuids: uuids, - ); - } catch (e) { - Logger.error('注册后台扫描失败: ${e.toString()}'); + return _storage.read('agent_tts_interrupt') ?? false; + } catch (_) { return false; } } - /// 注销后台扫描 - Future unregisterBackgroundScan() async { - try { - return await _bleService.unregisterBackgroundScan(); - } catch (e) { - Logger.error('注销后台扫描失败: ${e.toString()}'); - return false; - } - } - - /// 清除BLE配对设备 - Future clearPairedDevices() async { - try { - return await _bleService.clearAssociations(); - } catch (e) { - Logger.error('清除BLE配对设备失败: ${e.toString()}'); - return false; - } - } - - /// 注册服务 static Future init() async { if (!Get.isRegistered()) { Get.put(BleManager(), permanent: true); } return Future.value(Get.find()); } - - /// 请求蓝牙权限 - Future requestBluetoothPermission() async { - try { - return await _bleService.requestBluetoothPermission(); - } catch (e) { - Logger.error('请求蓝牙权限失败: ${e.toString()}'); - return false; - } - } - - /// 开始唤醒词升级 - Future startWakeupVoicesUpgrade(List wakeupVoiceBytes) async { - try { - return await _bleService.startWakeupVoicesUpgrade(wakeupVoiceBytes); - } catch (e) { - Logger.error('开始唤醒词升级失败: ${e.toString()}'); - return false; - } - } } diff --git a/lib/data/services/bluetooth_manager.dart b/lib/data/services/bluetooth_manager.dart index 7075fa3d4..c68e98975 100644 --- a/lib/data/services/bluetooth_manager.dart +++ b/lib/data/services/bluetooth_manager.dart @@ -1,10 +1,7 @@ import 'dart:async'; import 'package:get/get.dart'; import 'package:classic_bluetooth/classic_bluetooth.dart'; -import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; -import '../../routes/app_routes.dart'; -import 'ble_manager.dart'; // 重新导出蓝牙设备类型,让外部可以通过BluetoothManager访问 export 'package:classic_bluetooth/classic_bluetooth.dart' show BluetoothDevice; @@ -21,8 +18,6 @@ class BluetoothManager extends GetxService { // 当前经典蓝牙设备 final Rx currentDeviceRx = Rx(null); BluetoothDevice? get currentDevice => currentDeviceRx.value; - final BleManager _bleManager = Get.find(); - final GetStorage _storage = GetStorage(); // 订阅 StreamSubscription? _bluetoothSubscription; @@ -77,16 +72,7 @@ class BluetoothManager extends GetxService { currentDeviceRx.value = newDevice; - // 检查当前是否在OTA升级界面,如果是则不跳转 - if (_isLoggedIn() && - !_isInOtaUpgradePage() && - !_bleManager.isConnected && - Get.currentRoute != Routes.pairing) { - Get.toNamed(Routes.pairing, arguments: newDevice); - } else { - Logger.info('当前在OTA升级界面,跳过配对页面跳转'); - } - // 正常模式:更新当前设备并触发监听器 + // 经典蓝牙连接事件:仅记录日志,不再跳转配对页(绑定流程已下线) Logger.info('经典蓝牙设备已连接: ${newDevice.name} (${newDevice.address})'); } else if (event.type == 'deviceDisconnected' && event.device != null) { @@ -153,25 +139,4 @@ class BluetoothManager extends GetxService { } } - /// 检查当前是否在OTA升级界面 - /// 返回true表示当前在OTA界面,不应该跳转到配对页面 - bool _isInOtaUpgradePage() { - try { - final currentRoute = Get.currentRoute; - return currentRoute == Routes.OTA_UPGRADE; - } catch (e) { - Logger.error('检查当前路由失败: ${e.toString()}'); - return false; - } - } - - /// 是否已登录 - bool _isLoggedIn() { - try { - final token = _storage.read('logintoken'); - return token != null && token is String && token.isNotEmpty; - } catch (e) { - return false; - } - } } diff --git a/lib/data/services/novice_guide_service.dart b/lib/data/services/novice_guide_service.dart index 10e9195a2..3fb83bece 100644 --- a/lib/data/services/novice_guide_service.dart +++ b/lib/data/services/novice_guide_service.dart @@ -1,7 +1,5 @@ -import 'dart:async'; import 'dart:io'; -import 'package:ble_service/ble_service.dart'; import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -11,12 +9,10 @@ import '../../core/utils/permission_util.dart'; class NoviceGuideService extends GetxService { final GetStorage _storage = GetStorage(); - final BleService _bleService = BleService(); - StreamSubscription? _statusSubscription; - bool isHomeGuide = false; //false代表未完成新手引导,true代表已经完成 bool isAIGuide = false; + /// AI 唤醒已下线,保留字段以兼容 home_view 引导覆盖层;始终为 false。 RxBool isWakeup = false.obs; int aiStep = 0; @@ -28,13 +24,11 @@ class NoviceGuideService extends GetxService { 'sendKey': 'guideWeatherSend', 'endKey': 'guideWeatherEnd', 'method': () async { - // 天气查询需要位置权限 await PermissionUtil.instance.requestPermission( permissionType: Permission.location, - permissionName: 'locationInfo'.tr, // 位置信息 - explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息 - permanentDenialText: - 'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限 + permissionName: 'locationInfo'.tr, + explanationText: 'locationPermissionDesc'.tr, + permanentDenialText: 'locationPermissionDetailDesc'.tr, ); }, }, @@ -51,13 +45,11 @@ class NoviceGuideService extends GetxService { 'sendKey': 'guideNavSend', 'endKey': 'guideNavEnd', 'method': () async { - // 导航功能需要位置权限 await PermissionUtil.instance.requestPermission( permissionType: Permission.location, - permissionName: 'locationInfo'.tr, // 位置信息 - explanationText: 'locationPermissionDesc'.tr, // 用于获取当前位置信息 - permanentDenialText: - 'locationPermissionDetailDesc'.tr, // 获取位置信息需要位置权限 + permissionName: 'locationInfo'.tr, + explanationText: 'locationPermissionDesc'.tr, + permanentDenialText: 'locationPermissionDetailDesc'.tr, ); }, }, @@ -67,13 +59,11 @@ class NoviceGuideService extends GetxService { 'sendKey': 'guideMeetingSend', 'endKey': 'guideMeetingEnd', 'method': () async { - //创建会议提醒需要日历权限 await PermissionUtil.instance.requestPermission( permissionType: Permission.calendarFullAccess, - permissionName: 'calendarFullAccess'.tr, // 日历写入 - explanationText: 'calendarFullAccessPermissionDesc'.tr, // 用于创建日历事件和提醒 - permanentDenialText: - 'calendarFullAccessPermissionDetailDesc'.tr, // 创建日历事件需要日历写入权限 + permissionName: 'calendarFullAccess'.tr, + explanationText: 'calendarFullAccessPermissionDesc'.tr, + permanentDenialText: 'calendarFullAccessPermissionDetailDesc'.tr, ); }, }, @@ -84,20 +74,17 @@ class NoviceGuideService extends GetxService { 'endKey': 'guidePhoneEnd', 'method': () async { if (Platform.isAndroid) { - // 拨打电话需要电话权限 await PermissionUtil.instance.requestPermission( permissionType: Permission.phone, - permissionName: 'phone'.tr, // 电话 - explanationText: 'phonePermissionDesc'.tr, // 用于拨打电话功能 - permanentDenialText: 'phonePermissionDetailDesc'.tr, // 拨打电话功能需要电话权限 + permissionName: 'phone'.tr, + explanationText: 'phonePermissionDesc'.tr, + permanentDenialText: 'phonePermissionDetailDesc'.tr, ); - //拨打电话还需要联系人权限 await PermissionUtil.instance.requestPermission( permissionType: Permission.contacts, - permissionName: 'contacts'.tr, // 联系人 - explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息 - permanentDenialText: - 'contactsPermissionDetailDesc'.tr, // 拨打电话功能需要联系人权限 + permissionName: 'contacts'.tr, + explanationText: 'contactsPermissionDesc'.tr, + permanentDenialText: 'contactsPermissionDetailDesc'.tr, ); } }, @@ -109,13 +96,11 @@ class NoviceGuideService extends GetxService { 'endKey': 'guideSmsEnd', 'method': () async { if (Platform.isAndroid) { - //发送短信还需要联系人权限 await PermissionUtil.instance.requestPermission( permissionType: Permission.contacts, - permissionName: 'contacts'.tr, // 联系人 - explanationText: 'contactsPermissionDesc'.tr, // 用于访问联系人信息 - permanentDenialText: - 'contactsPermissionDetailDesc'.tr, // 发送短信功能需要联系人权限 + permissionName: 'contacts'.tr, + explanationText: 'contactsPermissionDesc'.tr, + permanentDenialText: 'contactsPermissionDetailDesc'.tr, ); } }, @@ -129,12 +114,6 @@ class NoviceGuideService extends GetxService { isAIGuide = _storage.read('ai_guide') ?? false; } - @override - void onClose() { - closeWakeupListen(); - super.onClose(); - } - void startHomeGuide() async { if (!isHomeGuide) { await Future.delayed(const Duration(milliseconds: 200)); @@ -153,17 +132,7 @@ class NoviceGuideService extends GetxService { } } - void startWakeupListen() { - _bleService.setWakeup(false); - _statusSubscription = _bleService.onStatusUpdated.listen((event) { - if (event is Map && event['type'] == 'wakeup') { - isWakeup.value = true; - } - }); - } - - void closeWakeupListen() { - _bleService.setWakeup(true); - _statusSubscription?.cancel(); - } + /// 唤醒功能已移除,下面两个方法保留为空实现以兼容旧调用点。 + void startWakeupListen() {} + void closeWakeupListen() {} } diff --git a/lib/data/services/speech_impl/azure_ast_service.dart b/lib/data/services/speech_impl/azure_ast_service.dart index e75215a59..71748b5a6 100644 --- a/lib/data/services/speech_impl/azure_ast_service.dart +++ b/lib/data/services/speech_impl/azure_ast_service.dart @@ -13,6 +13,10 @@ class AzureAstService extends GetxService implements AstService { static const MethodChannel _channel = MethodChannel('azure_speech/ast'); static const EventChannel _eventChannel = EventChannel('azure_speech/ast_events'); + // 通话翻译模式下,A/B 路 TTS PCM 走这条 EventChannel 抛回 Dart, + // 由调用方(translation_controller)转发给 Jielihome.feedTranslatedAudio。 + static const EventChannel _astTtsEventChannel = + EventChannel('azure_speech/ast_tts_events'); bool _isInitialized = false; late final String _subscriptionKey; @@ -424,6 +428,44 @@ class AzureAstService extends GetxService implements AstService { } } + @override + Future pushExternalAudio({ + required String leg, + required Uint8List pcm, + }) async { + try { + await _channel.invokeMethod('pushExternalAudio', { + 'leg': leg, + 'pcm': pcm, + }); + } catch (e) { + Logger.error('pushExternalAudio leg=$leg failed: $e'); + } + } + + // AST TTS 输出帧流(lazy + broadcast,多方订阅安全) + StreamController? _astTtsController; + StreamSubscription? _astTtsRawSub; + @override + Stream get astTtsFrames { + final c = _astTtsController ??= StreamController.broadcast( + onCancel: () { + // 没有订阅者时不必停 native event channel:原生侧自身有 sink null 检查。 + }, + ); + _astTtsRawSub ??= _astTtsEventChannel.receiveBroadcastStream().listen( + (raw) { + if (raw is! Map) return; + final leg = raw['leg'] as String?; + final pcm = raw['pcm']; + if (leg == null || pcm is! Uint8List) return; + if (!c.isClosed) c.add(AstTtsFrame(leg: leg, pcm: pcm)); + }, + onError: (e) => Logger.error('astTtsFrames stream error: $e'), + ); + return c.stream; + } + @override void setLowVolumeThreshold(int threshold) { try { diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 0faf5f592..47a40cc61 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -18,7 +18,6 @@ import '/core/utils/upload_oss.dart'; import '/data/models/user_Info.dart'; import '/data/services/music_manager.dart'; import '/data/services/navigation_manager.dart'; -import 'package:ble_service/ble_service.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:agent_service/agent_service.dart'; @@ -118,9 +117,6 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 按住说话模式状态 - 用于区分是否处于按住说话模式(即使当前没有按住) //final isPushToTalkMode = false.obs; // 是否处于按住说话模式 - // 蓝牙唤醒状态 - final isBleWakeupActive = false.obs; // 是否正在进行蓝牙唤醒对话 - // 键盘状态 final isKeyboardVisible = false.obs; // 键盘是否可见 @@ -130,9 +126,6 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 导航流事件订阅 StreamSubscription? _amapeventSubscription; - // BLE事件订阅 - StreamSubscription? _bleEventSubscription; - // 进度更新定时器 Timer? _progressTimer; // 用于跟踪AI回复状态 @@ -197,7 +190,6 @@ class AgentController extends GetxController with WidgetsBindingObserver { _setupScrollListener(); //设置滚动监听器 _loadChatHistory(); //加载聊天历史记录 _subscribeToEvents(); //订阅代理服务事件 - // _subscribeToBleEvents(); //订阅BLE事件 _initMusicStatus(); //初始化音乐状态 _setupMediaSessionCallbacks(); //设置媒体会话回调 _setupTextInputListener(); //设置文本输入监听器 @@ -250,7 +242,6 @@ class AgentController extends GetxController with WidgetsBindingObserver { scrollController.dispose(); _eventSubscription?.cancel(); _amapeventSubscription?.cancel(); - _bleEventSubscription?.cancel(); Intro.of(Get.context!).dispose(); // 取消应用生命周期监听 @@ -633,21 +624,6 @@ class AgentController extends GetxController with WidgetsBindingObserver { case AgentServiceEventType.recognitionStopped: isListening.value = false; - // 处理蓝牙唤醒结束 - if (isBleWakeupActive.value) { - isBleWakeupActive.value = false; - // 蓝牙唤醒结束后,恢复之前的输入模式 - _restorePreviousInputMode(); - Logger.i(TAG, '蓝牙唤醒对话结束,恢复之前的输入模式'); - } else { - // 只有在非按住说话模式且非打电话模式下才自动切换到文字模式 - // 按住说话模式下应该保持在语音模式,等待下次按住 - // if (!isPhoneCallMode.value && !isPushToTalkActive.value) { - // Logger.i(TAG, '切换文本输入模式'); - // isTextInputMode.value = false; - // } - } - Logger.i(TAG, '识别停止'); break; @@ -1154,66 +1130,6 @@ class AgentController extends GetxController with WidgetsBindingObserver { }); } - // 订阅BLE事件 - void _subscribeToBleEvents() { - try { - _bleEventSubscription = BleService().onStatusUpdated.listen((event) { - if (event is Map && event['type'] == 'wakeup') { - _handleBleWakeup(); - } - }); - Logger.i(TAG, 'BLE事件监听已设置'); - } catch (e) { - Logger.e(TAG, 'BLE事件监听设置失败: $e'); - } - } - - // 处理BLE唤醒信号 - void _handleBleWakeup() { - Logger.i(TAG, '🎧 收到BLE唤醒信号'); - - // 检查是否可以启动蓝牙唤醒 - if (!_canStartBleWakeup()) { - Logger.w(TAG, '当前状态不允许BLE唤醒'); - return; - } - - // 保存当前输入模式 - _saveCurrentInputMode(); - - // 设置蓝牙唤醒状态 - isBleWakeupActive.value = true; - - // 注意:这里不需要调用startVoiceInput(),因为原生层已经启动了语音识别 - // 我们只需要同步Flutter层的状态 - //isTextInputMode.value = false; - - Logger.i(TAG, 'BLE唤醒状态已设置,等待原生层语音识别结果'); - } - - // 检查是否可以启动BLE唤醒 - bool _canStartBleWakeup() { - // 如果已经在BLE唤醒状态,不允许重复启动 - if (isBleWakeupActive.value) { - Logger.w(TAG, '已在BLE唤醒状态,忽略重复唤醒'); - return false; - } - - // 如果正在打电话模式,不允许BLE唤醒 - if (isPhoneCallMode.value) { - Logger.w(TAG, '正在打电话模式,忽略BLE唤醒'); - return false; - } - - // 如果正在处理TTS,暂时不允许启动 - if (isSpeaking.value) { - Logger.w(TAG, '正在播放TTS,延迟BLE唤醒'); - return false; - } - - return true; - } - // 发送消息(包括所有支持的消息类型) //场景1:用户在文本输入框中输入文本,点击发送按钮,调用sendMessage()方法。 //场景2:当用户选择图片后在文本框输入内容并发送时,调用sendMessage()转发给sendImageMessage()。 diff --git a/lib/modules/devices/controllers/devices_controller.dart b/lib/modules/devices/controllers/devices_controller.dart index afca3899d..2c7ed43ac 100644 --- a/lib/modules/devices/controllers/devices_controller.dart +++ b/lib/modules/devices/controllers/devices_controller.dart @@ -1,110 +1,211 @@ -// import 'dart:ffi'; // Not supported on web; not needed here +import 'dart:async'; -import '/data/models/appconfig_model.dart'; +import 'package:device_jieli/device_jieli.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; import '/core/utils/logger.dart'; -import '/data/models/user_Info.dart'; import '/data/services/ble_manager.dart'; -import '/data/services/network/api.dart'; import '/routes/app_routes.dart'; -import 'package:get/get.dart'; -/* -设备管理 -*/ +/// 设备页控制器:扫描杰理设备、点击连接、断开。 +/// 旧的绑定 / 解绑流程已下线。 class DevicesController extends GetxController { late final BleManager _bleManager; - // 修改为响应式变量 - final RxMap> devices = >{}.obs; + // 扫描结果(直接绑定到 BleManager 内部 Rx 列表) + RxList get scannedDevices => _bleManager.scannedDevices; + RxBool get isScanning => _bleManager.isScanning; + + // 当前连接地址(响应式) + RxInt get connectionStateRx => _bleManager.connectionStateRx; - // 或者使用这种方式 - // final Rx>> devices = Rx>>({}); + String? get currentDeviceAddress => _bleManager.currentDeviceAddress; + + // 正在连接的目标地址(用于 tile UI 反馈) + final RxnString connectingAddress = RxnString(); @override void onInit() { super.onInit(); - print("初始化设备管理1"); _bleManager = Get.find(); - print("初始化设备管理2"); - // 在初始化时加载设备数据 - _loadDevices(); + Future.microtask(startScan); } - // 加载设备数据 - void _loadDevices() async { - Logger.info("加载设备"); - final response = await Api.getdevices(); - if (response != null) { - if (response['devices'] != null) { - var devices = (response['devices'] as List) - .map((e) => UserDevice.fromJson(e as Map)) - .toList(); - User.instance.devices = devices; - } else { - User.instance.devices = []; - } - Logger.info("加载设备 ${User.instance.devices.length}"); + @override + void onClose() { + _bleManager.stopScan(); + super.onClose(); + } + + Future startScan() async { + print('[BLE] [SCAN] DevicesController.startScan 被调用'); + final granted = await _ensureBluetoothPermission(); + print('[BLE] [SCAN] 权限申请结果: granted=$granted'); + if (!granted) { + Get.snackbar('tip'.tr, 'pleaseGrantBluetoothPermission'.tr); + return; } - devices.clear(); - for (var device in User.instance.devices) { - if (!devices.containsKey(device.devicetype)) { - devices[device.devicetype] = []; - } - devices[device.devicetype]!.add(device); + final ok = await _bleManager.startScan(); + if (!ok) { + final err = _bleManager.lastScanError ?? 'unknown'; + print('[BLE] [SCAN] 扫描启动失败: $err'); + Get.snackbar('error'.tr, '${'startScanFailed'.tr}: $err'); } - devices.refresh(); } - @override - void onClose() { - super.onClose(); - // onClose 中不应该修改数据,这里应该是清理资源 + Future stopScan() async { + await _bleManager.stopScan(); } - void unbindearphone(UserDevice device) async { - final response = await Api.unbinddevice({ - "id": device.id, - }); - Logger.info("解绑设备 $response"); - if (response != null) { - // 响应式更新 - User.instance.devices.remove(device); - devices.clear(); - for (var device in User.instance.devices) { - if (!devices.containsKey(device.devicetype)) { - devices[device.devicetype] = []; + Future connect(JieliDevice device) async { + if (connectingAddress.value != null) { + Logger.info('连接进行中,忽略重复点击: ${device.address}'); + return; + } + // 已连上同一台设备 → 直接进首页,避免卡在「连接中」对话框 + if (_bleManager.isConnected && + _bleManager.currentDeviceAddress == device.address) { + Logger.info('设备已处于连接状态,直接进入首页: ${device.address}'); + Get.offAllNamed(Routes.home); + return; + } + + Logger.info('连接设备: ${device.name} ${device.address}'); + connectingAddress.value = device.address; + await _bleManager.stopScan(); + + // 必须 BLE 连接 OK + RCSP init 成功 才算「真正连上」。 + // 任一环节失败立即判失败,避免 dialog 关掉之后又被断开。 + final completer = Completer(); + bool gotConnectionOk = false; + + final evtSub = Jielihome.instance.events.listen((evt) { + if (evt is ConnectionStateEvent) { + if (evt.address != device.address) return; + if (evt.state == ConnectionStateEvent.connectionOk) { + gotConnectionOk = true; + } else if (evt.state == ConnectionStateEvent.connectionDisconnect) { + if (!completer.isCompleted) completer.complete(false); + } + } else if (evt is RcspInitEvent) { + if (evt.address != device.address) return; + if (evt.success && gotConnectionOk) { + if (!completer.isCompleted) completer.complete(true); + } else if (!evt.success) { + if (!completer.isCompleted) completer.complete(false); } - devices[device.devicetype]!.add(device); } - devices.refresh(); - Logger.info( - "解绑设备 ${device.devicemac} 当前使用设备:${_bleManager.currentDeviceAddress}"); - if (device.devicemac == _bleManager.currentDeviceAddress) { - Logger.info("解绑设备 $device 重新匹配!"); - User.instance.removeUserDevice(device.id); - _bleManager.clearPairedDevices(); - _bleManager.disconnect(); - await Future.delayed(const Duration(seconds: 1)); - Get.toNamed(Routes.pairing); + }); + + _showConnectingDialog(device); + + try { + await _bleManager.connectDevice(device); + final ok = await completer.future + .timeout(const Duration(seconds: 20), onTimeout: () => false); + + if (Get.isDialogOpen ?? false) Get.back(); + + if (ok) { + Get.offAllNamed(Routes.home); + } else { + Get.snackbar('error'.tr, 'connectionFailure'.tr); } - return; - } else { - return; + } catch (e) { + Logger.error('连接异常: $e'); + if (Get.isDialogOpen ?? false) Get.back(); + Get.snackbar('error'.tr, 'connectionFailure'.tr); + } finally { + await evtSub.cancel(); + connectingAddress.value = null; } } - // 添加设备 - void addDevice(UserDevice device) { - if (!devices.containsKey(device.devicetype)) { - devices[device.devicetype] = []; - } - devices[device.devicetype]!.add(device); - devices.refresh(); // 触发UI更新 + void _showConnectingDialog(JieliDevice device) { + Get.dialog( + PopScope( + canPop: false, + child: Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: EdgeInsets.symmetric(horizontal: 40.w), + child: Container( + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 28.h), + decoration: BoxDecoration( + color: Get.isDarkMode + ? const Color(0xFF1C1C1E) + : Colors.white, + borderRadius: BorderRadius.circular(16.r), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 44.w, + height: 44.w, + child: const CircularProgressIndicator( + strokeWidth: 3, + valueColor: + AlwaysStoppedAnimation(Color(0xFF0066CC)), + ), + ), + SizedBox(height: 18.h), + Text( + 'connectingDevice'.tr, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: Get.isDarkMode ? Colors.white : Colors.black87, + ), + ), + SizedBox(height: 6.h), + Text( + device.name.isEmpty ? device.address : device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + color: Get.isDarkMode ? Colors.white54 : Colors.black54, + ), + ), + ], + ), + ), + ), + ), + barrierDismissible: false, + ); } - // 获取指定类型的设备列表 - List getDevicesByType(int deviceType) { - return devices[deviceType] ?? []; + Future disconnect() async { + await _bleManager.disconnect(); + } + + bool isConnected(JieliDevice device) { + return _bleManager.isConnected && + _bleManager.currentDeviceAddress == device.address; + } + + Future _ensureBluetoothPermission() async { + try { + // Android 12+:BLUETOOTH_SCAN 已声明 neverForLocation,所以不强制要求定位。 + // 但实际不少 ROM 仍要求定位才能扫到 BLE 设备 —— 同时申请,谁拒绝就报错。 + final results = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + print('[BLE] [SCAN] 权限申请详情: $results'); + // 至少 scan + connect 必须授权;location 缺失警告但不一定阻塞 + final scanOk = results[Permission.bluetoothScan]?.isGranted ?? false; + final connectOk = results[Permission.bluetoothConnect]?.isGranted ?? false; + return scanOk && connectOk; + } catch (e) { + Logger.error('请求蓝牙权限失败: $e'); + return false; + } } } diff --git a/lib/modules/devices/views/devices_view.dart b/lib/modules/devices/views/devices_view.dart index 30b5570ee..9e6b8b5e4 100644 --- a/lib/modules/devices/views/devices_view.dart +++ b/lib/modules/devices/views/devices_view.dart @@ -1,619 +1,466 @@ -import '../../../routes/app_routes.dart'; +import 'package:device_jieli/device_jieli.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; -import '/core/theme/app_colors.dart'; -import '/data/models/user_Info.dart'; -import '/data/services/ble_manager.dart'; + import '../controllers/devices_controller.dart'; -// 设备类型枚举 -enum DeviceType { - Unknown(0), - Classic_bluetooth_Headset(1), - BLE_Bluetooth_Headset(2), - Smart_Voice_Recorder(3), - SmartEye(4); +class DevicesView extends StatefulWidget { + const DevicesView({Key? key}) : super(key: key); - const DeviceType(this.value); - final int value; + @override + State createState() => _DevicesViewState(); +} - String get displayName { - switch (this) { - case DeviceType.Unknown: - return "deviceTypeUnknown".tr; - case DeviceType.Classic_bluetooth_Headset: - return "deviceTypeClassicBluetoothHeadset".tr; - case DeviceType.BLE_Bluetooth_Headset: - return "deviceTypeBleBluetoothHeadset".tr; - case DeviceType.Smart_Voice_Recorder: - return "deviceTypeSmartVoiceRecorder".tr; - case DeviceType.SmartEye: - return "deviceTypeSmartEye".tr; - } - } +class _DevicesViewState extends State + with TickerProviderStateMixin { + late AnimationController _pulseController; + late AnimationController _rotationController; + late Animation _pulseAnimation; + late Animation _rotationAnimation; - static DeviceType fromValue(int value) { - return DeviceType.values.firstWhere( - (type) => type.value == value, - orElse: () => DeviceType.Unknown, - ); - } + DevicesController get controller => Get.find(); - IconData get icon { - switch (this) { - case DeviceType.Classic_bluetooth_Headset: - case DeviceType.BLE_Bluetooth_Headset: - return Icons.headphones; - case DeviceType.Smart_Voice_Recorder: - return Icons.mic; - case DeviceType.SmartEye: - return Icons.visibility; - default: - return Icons.device_unknown; - } + @override + void initState() { + super.initState(); + _pulseController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(reverse: true); + _rotationController = AnimationController( + duration: const Duration(milliseconds: 2000), + vsync: this, + )..repeat(); + _pulseAnimation = + Tween(begin: 0.8, end: 1.2).animate(CurvedAnimation( + parent: _pulseController, + curve: Curves.easeInOut, + )); + _rotationAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _rotationController, curve: Curves.linear), + ); } -} -class DevicesView extends GetView { - const DevicesView({super.key}); - - bool _isConnected(UserDevice device) { - try { - final bleManager = Get.find(); - return bleManager.currentDeviceAddress != null && - bleManager.currentDeviceAddress == device.devicemac; - } catch (_) { - return false; - } + @override + void dispose() { + _pulseController.dispose(); + _rotationController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { final isDark = Theme.of(context).brightness == Brightness.dark; - final bg = isDark ? const Color(0xFF0A0A0F) : const Color(0xFFF5F6FA); - final topBg = isDark ? const Color(0xFF0F1018) : const Color(0xFFF0F1F6); - return Scaffold( - backgroundColor: bg, - body: Column( - children: [ - // 顶部区域 - _buildTopArea(context, isDark, bg, topBg), - // 设备列表 - Expanded(child: _buildDevicesList(isDark, topBg)), - ], + backgroundColor: isDark ? Colors.black : Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildTopSection(isDark), + Expanded(child: _buildDeviceListSection(isDark)), + _buildBottomActions(isDark), + ], + ), ), ); } - Widget _buildTopArea( - BuildContext context, bool isDark, Color bg, Color topBg) { - return Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 8.h, - left: 20.w, - right: 20.w, - bottom: 24.h, - ), - decoration: BoxDecoration(color: bg), + // ─── 顶部:标题 + 扫描动画 + 状态文字 ──────────────────── + Widget _buildTopSection(bool isDark) { + return Padding( + padding: EdgeInsets.only(top: 40.h, left: 24.w, right: 24.w), child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 导航栏 - Row( - children: [ - GestureDetector( - onTap: () => Get.back(), - child: Container( - width: 40.w, - height: 40.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isDark - ? Colors.white.withOpacity(0.06) - : Colors.black.withOpacity(0.04), - ), - child: Icon( - Icons.arrow_back_ios_new, - size: 18.sp, - color: isDark ? Colors.white : const Color(0xFF222222), - ), - ), - ), - SizedBox(width: 16.w), - Text( - "deviceManagement".tr, - style: TextStyle( - fontSize: 26.sp, - fontWeight: FontWeight.w800, - color: isDark ? Colors.white : const Color(0xFF111111), - letterSpacing: -0.5, - ), - ), - ], + Text( + 'Voitrans', + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.bold, + color: isDark ? Colors.white : Colors.black, + ), ), - SizedBox(height: 20.h), - // 摘要卡片 + SizedBox(height: 6.h), + Text( + 'homeSlogan'.tr, + style: TextStyle( + fontSize: 14.sp, + color: isDark ? Colors.white60 : Colors.black54, + ), + ), + SizedBox(height: 24.h), + _buildScanAnimation(isDark), + SizedBox(height: 16.h), Obx(() { - final totalDevices = controller.devices.values - .fold(0, (sum, list) => sum + list.length); - final connectedCount = controller.devices.values - .expand((list) => list) - .where((d) => _isConnected(d)) - .length; - return Row( - children: [ - _buildSummaryCard( - number: totalDevices.toString(), - label: "boundDevices".tr, - isActive: true, - isDark: isDark, - ), - SizedBox(width: 12.w), - _buildSummaryCard( - number: connectedCount.toString(), - label: "currentConnection".tr, - isActive: false, - isDark: isDark, - ), - ], + final scanning = controller.isScanning.value; + return Text( + scanning ? 'scanning'.tr : 'scanIdle'.tr, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: isDark ? Colors.white70 : Colors.black87, + ), ); }), + SizedBox(height: 6.h), + Text( + 'ensureHeadsetOnAndPairingMode'.tr, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + color: isDark ? Colors.white38 : Colors.black45, + ), + ), ], ), ); } - Widget _buildSummaryCard({ - required String number, - required String label, - required bool isActive, - required bool isDark, - }) { - final activeGradient = isDark - ? [ - const Color(0xFF6366F1).withOpacity(0.15), - const Color(0xFF8B5CF6).withOpacity(0.1), - ] - : [const Color(0xFFEEF2FF), const Color(0xFFF5F3FF)]; - - return Expanded( - child: Container( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 14.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16.r), - gradient: isActive ? LinearGradient(colors: activeGradient) : null, - color: isActive - ? null - : (isDark - ? Colors.white.withOpacity(0.04) - : Colors.white), - border: Border.all( - color: isActive - ? (isDark - ? const Color(0xFF6366F1).withOpacity(0.2) - : const Color(0xFF6366F1).withOpacity(0.15)) - : (isDark - ? Colors.white.withOpacity(0.06) - : Colors.black.withOpacity(0.04)), - ), - boxShadow: isDark - ? null - : [ - BoxShadow( - color: Colors.black.withOpacity(0.04), - blurRadius: 4, - offset: const Offset(0, 1), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + Widget _buildScanAnimation(bool isDark) { + const primaryColor = Color(0xFF0066CC); + const secondaryColor = Color(0xFF5E5CE6); + return SizedBox( + width: 180.w, + height: 180.w, + child: Obx(() { + final scanning = controller.isScanning.value; + return Stack( + alignment: Alignment.center, children: [ - Text( - number, - style: TextStyle( - fontSize: 24.sp, - fontWeight: FontWeight.w700, - color: isActive - ? (isDark - ? const Color(0xFFA5B4FC) - : const Color(0xFF6366F1)) - : (isDark ? Colors.white : const Color(0xFF222222)), + if (scanning) _buildPulse(primaryColor, secondaryColor), + if (scanning) _buildRotatingBorder(primaryColor), + Container( + width: 92.w, + height: 92.w, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [primaryColor, secondaryColor], + ), + shape: BoxShape.circle, ), - ), - SizedBox(height: 2.h), - Text( - label, - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w500, - color: isActive - ? (isDark - ? const Color(0xFFA5B4FC).withOpacity(0.6) - : const Color(0xFF818CF8)) - : (isDark - ? Colors.white.withOpacity(0.35) - : const Color(0xFF999999)), + child: Icon( + Icons.headphones, + color: Colors.white, + size: 44.w, ), ), ], - ), - ), + ); + }), ); } - Widget _buildDevicesList(bool isDark, Color topBg) { - return Obx(() { - final groupedDevices = controller.devices; + Widget _buildPulse(Color primary, Color secondary) { + return AnimatedBuilder( + animation: _pulseAnimation, + builder: (_, __) { + final v = _pulseAnimation.value; + final opacity = 0.4 - (_pulseController.value * 0.3); + return Container( + width: 160.w * v, + height: 160.w * v, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + primary.withValues(alpha: opacity), + secondary.withValues(alpha: opacity), + ], + ), + shape: BoxShape.circle, + ), + ); + }, + ); + } - if (groupedDevices.isEmpty) { - return _buildEmptyState(isDark); - } + Widget _buildRotatingBorder(Color primary) { + return AnimatedBuilder( + animation: _rotationAnimation, + builder: (_, __) { + return Transform.rotate( + angle: _rotationAnimation.value * 2 * 3.14159, + child: SizedBox( + width: 110.w, + height: 110.w, + child: CircularProgressIndicator( + strokeWidth: 3.w, + valueColor: AlwaysStoppedAnimation(primary), + backgroundColor: Colors.grey.withValues(alpha: 0.18), + value: 0.7, + ), + ), + ); + }, + ); + } - return ListView( - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 20.h), + // ─── 底部:扫描结果列表 ──────────────────────────────── + Widget _buildDeviceListSection(bool isDark) { + return Container( + margin: EdgeInsets.only(top: 20.h, left: 16.w, right: 16.w), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.03) + : const Color(0xFFF5F6FA), + borderRadius: BorderRadius.circular(16.r), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ...groupedDevices.entries.map((entry) { - final deviceType = DeviceType.fromValue(entry.key); - final deviceList = entry.value; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + Padding( + padding: + EdgeInsets.only(left: 16.w, right: 16.w, top: 14.h, bottom: 8.h), + child: Row( children: [ - // 分类标题 - Padding( - padding: EdgeInsets.only(left: 8.w, bottom: 12.h), - child: Text( - deviceType.displayName.toUpperCase(), - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: isDark - ? Colors.white.withOpacity(0.25) - : const Color(0xFFAAAAAA), - letterSpacing: 1.5, - ), + Text( + 'discoveredDevices'.tr, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white70 : Colors.black87, ), ), - ...deviceList.map((device) => _buildDeviceCard( - device: device, - deviceType: deviceType, - isDark: isDark, + const Spacer(), + Obx(() => Text( + '${controller.scannedDevices.length}', + style: TextStyle( + fontSize: 12.sp, + color: isDark ? Colors.white54 : Colors.black54, + ), )), - SizedBox(height: 16.h), ], - ); - }), - ], - ); - }); - } - - Widget _buildEmptyState(bool isDark) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.devices_other, - size: 80.sp, - color: isDark ? Colors.white54 : Colors.grey[400], - ), - SizedBox(height: 20.h), - Text( - "noDevicesBound".tr, - style: TextStyle( - fontSize: 16.sp, - color: isDark ? Colors.white70 : Colors.grey[600], ), ), - SizedBox(height: 12.h), - Text( - "pleaseBindDeviceFirst".tr, - style: TextStyle( - fontSize: 14.sp, - color: isDark ? Colors.white54 : Colors.grey[500], - ), + Expanded( + child: Obx(() { + final devices = controller.scannedDevices; + if (devices.isEmpty) { + return Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 32.h), + child: Text( + 'noDevicesFound'.tr, + style: TextStyle( + fontSize: 13.sp, + color: isDark ? Colors.white38 : Colors.black45, + ), + ), + ), + ); + } + return ListView.separated( + padding: EdgeInsets.fromLTRB(8.w, 4.h, 8.w, 12.h), + itemCount: devices.length, + separatorBuilder: (_, __) => SizedBox(height: 6.h), + itemBuilder: (_, i) => _buildDeviceTile(devices[i], isDark), + ); + }), ), ], ), ); } - Widget _buildDeviceCard({ - required UserDevice device, - required DeviceType deviceType, - required bool isDark, - }) { - final connected = _isConnected(device); - final connectedColor = const Color(0xFF22C55E); + Widget _buildDeviceTile(JieliDevice device, bool isDark) { + return Obx(() { + // 订阅连接状态、当前连接地址、正在连接的目标,让 tile 实时刷新 + controller.connectionStateRx.value; + final connected = controller.isConnected(device); + final connectingAddr = controller.connectingAddress.value; + final connecting = connectingAddr == device.address; + final disabled = connectingAddr != null && !connecting; + return _buildDeviceTileContent( + device, + isDark, + connected: connected, + connecting: connecting, + disabled: disabled, + ); + }); + } - return GestureDetector( - onTap: () => Get.toNamed(Routes.eqSettings), - child: Container( - margin: EdgeInsets.only(bottom: 10.h), - padding: EdgeInsets.all(20.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20.r), - color: isDark - ? (connected - ? connectedColor.withOpacity(0.04) - : Colors.white.withOpacity(0.03)) - : (connected - ? connectedColor.withOpacity(0.03) - : Colors.white), - border: Border.all( - color: connected - ? connectedColor.withOpacity(0.2) - : (isDark - ? Colors.white.withOpacity(0.05) - : Colors.black.withOpacity(0.04)), + Widget _buildDeviceTileContent( + JieliDevice device, + bool isDark, { + required bool connected, + required bool connecting, + required bool disabled, + }) { + const connectedColor = Color(0xFF22C55E); + final canTap = !connecting && !disabled; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12.r), + onTap: canTap ? () => controller.connect(device) : null, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.r), + color: isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.white, + border: Border.all( + color: connected + ? connectedColor.withValues(alpha: 0.3) + : (isDark + ? Colors.white.withValues(alpha: 0.05) + : Colors.black.withValues(alpha: 0.05)), + ), ), - boxShadow: isDark - ? null - : [ - BoxShadow( - color: Colors.black.withOpacity(0.03), - blurRadius: 12, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - children: [ - // 上部:图标 + 名称/MAC + 解绑 - Row( - children: [ - // 设备图标 - Container( - width: 46.w, - height: 46.w, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14.r), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: connected - ? [connectedColor, const Color(0xFF16A34A)] - : [const Color(0xFF6366F1), const Color(0xFF8B5CF6)], - ), - boxShadow: [ - BoxShadow( - color: (connected ? connectedColor : const Color(0xFF6366F1)) - .withOpacity(0.3), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ], - ), - child: Icon( - deviceType.icon, - color: Colors.white, - size: 22.sp, - ), + child: Row( + children: [ + Container( + width: 36.w, + height: 36.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: connected + ? connectedColor.withValues(alpha: 0.15) + : const Color(0xFF0066CC).withValues(alpha: 0.12), ), - SizedBox(width: 14.w), - // 设备名 + MAC - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - device.devicename.isNotEmpty - ? device.devicename - : "deviceTypeUnknown".tr, + child: Icon( + Icons.headphones, + size: 18.sp, + color: connected + ? connectedColor + : const Color(0xFF0066CC), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + device.name.isEmpty + ? 'unnamedDevice'.tr + : device.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w700, - color: isDark - ? Colors.white - : const Color(0xFF1A1A2E), + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: isDark ? Colors.white : Colors.black87, ), ), - if (connected) ...[ - SizedBox(width: 8.w), - Container( - width: 6.w, - height: 6.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: connectedColor, - boxShadow: [ - BoxShadow( - color: connectedColor.withOpacity(0.6), - blurRadius: 8, - ), - ], - ), + ), + if (connected) + Container( + padding: EdgeInsets.symmetric( + horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.r), + color: connectedColor.withValues(alpha: 0.12), ), - SizedBox(width: 6.w), - Container( - padding: EdgeInsets.symmetric( - horizontal: 8.w, vertical: 3.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6.r), - color: connectedColor.withOpacity(0.1), - ), - child: Text( - "connected".tr, - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w600, - color: connectedColor, - ), + child: Text( + 'connected'.tr, + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w600, + color: connectedColor, ), ), - ], - ], - ), - SizedBox(height: 3.h), - Text( - device.devicemac.isNotEmpty - ? device.devicemac - : "noMacAddress".tr, - style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w500, - color: isDark - ? Colors.white.withOpacity(0.3) - : const Color(0xFF999999), - fontFamily: 'Menlo', - letterSpacing: 0.5, - ), - ), - ], - ), - ), - // 解绑按钮 - GestureDetector( - onTap: () => _showUnbindDialog(device, isDark), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: 14.w, vertical: 8.h), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10.r), - border: Border.all( - color: isDark - ? Colors.white.withOpacity(0.08) - : Colors.black.withOpacity(0.08), - ), + ), + ], ), - child: Text( - "unbind".tr, + SizedBox(height: 2.h), + Text( + '${device.address}${device.rssi != null ? ' RSSI ${device.rssi}' : ''}', style: TextStyle( - fontSize: 12.sp, - fontWeight: FontWeight.w600, - color: isDark - ? Colors.white.withOpacity(0.4) - : const Color(0xFF999999), + fontSize: 10.sp, + color: isDark ? Colors.white38 : Colors.black45, + fontFamily: 'Menlo', ), ), - ), - ), - ], - ), - // 下部:授权码 / CMEI - if (device.license.isNotEmpty || device.cmei.isNotEmpty) ...[ - SizedBox(height: 16.h), - Container( - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12.r), - color: isDark - ? Colors.white.withOpacity(0.03) - : const Color(0xFFF7F7FA), - ), - child: Column( - children: [ - if (device.license.isNotEmpty) - _buildFieldRow( - label: "licenseCode".tr, - value: device.license, - isDark: isDark, - ), - if (device.license.isNotEmpty && device.cmei.isNotEmpty) - SizedBox(height: 8.h), - if (device.cmei.isNotEmpty) - _buildFieldRow( - label: "CMEI", - value: device.cmei, - isDark: isDark, - ), ], ), ), + if (connected) + TextButton( + onPressed: controller.disconnect, + style: TextButton.styleFrom( + minimumSize: Size(0, 28.h), + padding: EdgeInsets.symmetric(horizontal: 8.w), + ), + child: Text( + 'disconnect'.tr, + style: TextStyle(fontSize: 11.sp, color: Colors.red), + ), + ) + else if (connecting) + SizedBox( + width: 18.w, + height: 18.w, + child: const CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Color(0xFF0066CC)), + ), + ) + else + ElevatedButton( + onPressed: + disabled ? null : () => controller.connect(device), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0066CC), + foregroundColor: Colors.white, + minimumSize: Size(0, 30.h), + padding: EdgeInsets.symmetric(horizontal: 12.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.r), + ), + elevation: 0, + ), + child: Text( + 'connect'.tr, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w600, + ), + ), + ), ], - ], + ), ), ), ); } - Widget _buildFieldRow({ - required String label, - required String value, - required bool isDark, - }) { - return Row( - children: [ - SizedBox( - width: 44.w, - child: Text( - label, - style: TextStyle( - fontSize: 10.sp, - fontWeight: FontWeight.w600, - color: isDark - ? Colors.white.withOpacity(0.2) - : const Color(0xFFBBBBBB), - letterSpacing: 0.5, - ), - ), - ), - SizedBox(width: 8.w), - Expanded( - child: Text( - value, - style: TextStyle( - fontSize: 11.sp, - fontWeight: FontWeight.w500, - color: isDark - ? Colors.white.withOpacity(0.5) - : const Color(0xFF666666), - fontFamily: 'Menlo', - letterSpacing: 0.3, + // ─── 底部:扫描操作按钮 ──────────────────────────────── + Widget _buildBottomActions(bool isDark) { + return Padding( + padding: EdgeInsets.fromLTRB(24.w, 12.h, 24.w, 20.h), + child: Obx(() { + final scanning = controller.isScanning.value; + return SizedBox( + width: double.infinity, + height: 48.h, + child: ElevatedButton.icon( + onPressed: scanning ? controller.stopScan : controller.startScan, + icon: Icon(scanning ? Icons.stop : Icons.refresh, size: 18.sp), + label: Text( + scanning ? 'stopScan'.tr : 'startScan'.tr, + style: TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600), ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - - void _showUnbindDialog(UserDevice device, bool isDark) { - Get.dialog( - AlertDialog( - backgroundColor: isDark ? AppColors.cardBackground : Colors.white, - title: Text( - "confirmUnbind".tr, - style: TextStyle( - color: isDark ? Colors.white : Colors.black87, - ), - ), - content: Text( - "confirmUnbindMessage" - .tr - .replaceAll('{deviceName}', device.devicename), - style: TextStyle( - color: isDark ? Colors.white70 : Colors.black54, - ), - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text( - "cancel".tr, - style: TextStyle( - color: isDark ? Colors.white70 : Colors.grey[600], + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0066CC), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.r), ), + elevation: 0, ), ), - TextButton( - onPressed: () { - Get.back(); - controller.unbindearphone(device); - }, - child: Text( - "confirmUnbindAction".tr, - style: const TextStyle(color: Colors.red), - ), - ), - ], - ), + ); + }), ); } } diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index fa75943b7..d47400f2d 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -203,7 +203,7 @@ class HomeController extends GetxController { } void rescanDevices() { - Get.toNamed(Routes.pairing); + Get.toNamed(Routes.devices); } void openTranslation() { diff --git a/lib/modules/login/controllers/login_controller.dart b/lib/modules/login/controllers/login_controller.dart index 9f721e94b..d3487fb5a 100644 --- a/lib/modules/login/controllers/login_controller.dart +++ b/lib/modules/login/controllers/login_controller.dart @@ -17,6 +17,7 @@ import 'appleLogin.dart'; import '../../../routes/app_routes.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; +import '../../../data/services/ble_manager.dart'; import '../../../data/services/network/api.dart'; import '../../../../data/models/user_Info.dart'; import '../../../data/models/channel_app_model.dart'; @@ -666,7 +667,12 @@ class LoginController extends GetxController { // 获取app的配置信息 await getAppConfig(); await _initMeetingTemplate(); - Get.offAllNamed(Routes.pairing); + // 登录成功 → 检查 BLE 设备是否已连接,未连接则进入扫描页 + bool connected = false; + try { + connected = await BleManager.to.hasActiveConnection(); + } catch (_) {} + Get.offAllNamed(connected ? Routes.home : Routes.devices); } else { throw Exception('接口返回错误: ${response['msg']}'); } diff --git a/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart b/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart index 4e58c24e4..350b1cc66 100644 --- a/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart +++ b/lib/modules/ota_upgrade/views/ota_upgrade_popup_view.dart @@ -80,7 +80,7 @@ class OtaUpgradePopUp { ElevatedButton( onPressed: () { Get.back(); - Get.offAllNamed(Routes.pairing); + Get.offAllNamed(Routes.devices); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.green[600], diff --git a/lib/modules/pairing/bindings/pairing_binding.dart b/lib/modules/pairing/bindings/pairing_binding.dart deleted file mode 100644 index cd57e4ac2..000000000 --- a/lib/modules/pairing/bindings/pairing_binding.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/pairing_controller.dart'; - -class PairingBinding extends Bindings { - @override - void dependencies() { - Get.put(PairingController()); - } -} \ No newline at end of file diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart deleted file mode 100644 index 5156ea8b4..000000000 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ /dev/null @@ -1,1486 +0,0 @@ -import 'dart:io'; -import 'dart:async'; - -import 'package:app_settings/app_settings.dart'; -import '/data/models/appconfig_model.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:get/get.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:get_storage/get_storage.dart'; -import '../../../routes/app_routes.dart'; -import '../../../core/utils/logger.dart'; -import '/data/models/appconfig.dart'; -import '/data/models/user_Info.dart'; -import '/data/services/network/api.dart'; -import '../../../data/services/bluetooth_manager.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../core/utils/permission_util.dart'; -import '../../../data/services/version_update_service.dart'; -import '../../../data/models/channel_app_model.dart'; - -/// 配对状态枚举 - 简化版本 -enum PairingState { - idle, // 空闲/初始状态 - checking, // 检查中(蓝牙、权限等) - waitingClassic, // 等待经典蓝牙连接 - verifying, // 验证设备 - binding, //确认绑定设备 - manualbinding, //手动绑定设备 - associating, // 正在进行BLE设备关联 - waitingBle, //等待BLE连接 - completed, //配对完成 - error, // 错误状态 -} - -/// 错误类型枚举 -enum ErrorType { - none, - bluetoothOff, // 蓝牙关闭 - noPermission, // 无权限 - noClassicDevice, // 无经典蓝牙设备 - deviceNotMatch, // 设备不匹配 - deviceBound, // 设备已被绑定 - associationFailed, // 关联失败 - bleTimeout, // BLE连接超时 - authnFailed, // 授权失败 - networkError, // 网络错误 -} - -/// 配对控制器 - 基于简化状态机的实现 -class PairingController extends GetxController with WidgetsBindingObserver { - static const String _tag = 'PairingCtrl'; - static PairingController get to => Get.find(); - final _storage = GetStorage(); - - // ========== 状态管理 ========== - final _currentState = PairingState.idle.obs; - final _errorType = ErrorType.none.obs; - final _errorMessage = ''.obs; - - PairingState get currentState => _currentState.value; - ErrorType get errorType => _errorType.value; - String get errorMessage => _errorMessage.value; - - // ========== UI状态 ========== - final statusText = ''.obs; - final isLoading = false.obs; - final showConnectButton = false.obs; - final showRetryButton = false.obs; - final showBindButton = false.obs; - final showRetryChectButton = false.obs; - final showAutoCodeInput = false.obs; - final showSkipButton = false.obs; - // 增加临时跳过绑定按钮状态 - final showSkipBindingButton = false.obs; // 新增临时跳过绑定按钮 - - final connectButtonText = 'connectDevice'.tr.obs; //连接设备 - final retryButtonText = 'retry'.tr.obs; - final deviceCode = ''.obs; - - bool isShowSkipButton = false; //是否显示跳过按钮 - - final availableDevices = [].obs; // 可用设备列表 - final selectedDevice = Rx(null); // 用户选中的设备 - - // ========== 监听器管理 ========== - final List _workers = []; - Timer? _bleTimeoutTimer; - - // ========== 依赖服务 ========== - late final BluetoothManager _bluetoothManager; - late final BleManager _bleManager; - - // ========== 当前设备信息 ========== - BluetoothDevice? _currentClassicDevice; - int? _verifiedDevicePid; - String? _verifiedDeviceName; - String? _verifiedDeviceAddress; - TextEditingController codeInputController = TextEditingController(); - - // Public getter for view access - BluetoothDevice? get currentDevice => _currentClassicDevice; - - //华为设备权限检查标记 - bool isHuaweiHasChecked = false; - // 缓存的设备品牌信息 - final versionService = Get.find(); - bool isHuaweiDevice = false; - - @override - void onInit() { - super.onInit(); - Logger.i(_tag, '配对控制器初始化'); - - // 注册应用生命周期监听器 - Logger.i(_tag, '正在注册应用生命周期监听器'); - WidgetsBinding.instance.addObserver(this); - Logger.i(_tag, '应用生命周期监听器注册完成'); - - isHuaweiDevice = _isHuaweiDevice(); - - // 确保服务已初始化 - _bluetoothManager = Get.find(); - _bleManager = Get.find(); - - // 集中设置所有监听器 - _setupAllListeners(); - - if (isHuaweiDevice) { - //检查华为设备是否检查权限检查过 - isHuaweiHasChecked = _storage.read('isHuaweiHasChecked') ?? false; - } - - // 开始初始状态检查 - // 延迟稍长时间,确保BluetoothManager有足够时间获取已连接设备 - Future.delayed(const Duration(milliseconds: 300), () { - _startInitialCheck(); - }); - - // final app_authcode_jump = AppConfig.env('APP_AUTHCODE_JUMP') ?? ""; - //print('app_authcode_jump: $app_authcode_jump'); - // 如果APP_AUTHCODE_JUMP为true,或者用户已登录且有绑定设备,启用跳过按钮 - final allowSkipDeviceBinding = versionService.cachedAllowSkipDeviceBinding; - if (allowSkipDeviceBinding || - (User.isLoggedIn() && User.instance.devices.isNotEmpty)) { - isShowSkipButton = true; // 启用跳过按钮 - } - } - - @override - void onClose() { - Logger.i(_tag, '配对控制器销毁'); - - // 移除应用生命周期监听器 - WidgetsBinding.instance.removeObserver(this); - - // 清理所有Worker - for (var worker in _workers) { - worker.dispose(); - } - _workers.clear(); - - // 清理定时器 - _bleTimeoutTimer?.cancel(); - - super.onClose(); - } - - /// 应用生命周期状态变化回调 - @override - void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); - // 使用多种日志级别确保输出 - Logger.i(_tag, '=== 应用生命周期状态变化: $state ==='); - print('PairingCtrl: 应用生命周期状态变化: $state'); // 直接使用print确保输出 - - switch (state) { - case AppLifecycleState.resumed: - // 应用从后台切回前台 - Logger.i(_tag, '=== 应用从后台切回前台 ==='); - print('PairingCtrl: 应用从后台切回前台'); - _onAppResumed(); - break; - case AppLifecycleState.paused: - // 应用切到后台 - Logger.i(_tag, '应用切到后台'); - print('PairingCtrl: 应用切到后台'); - _onAppPaused(); - break; - case AppLifecycleState.inactive: - // 应用处于非活跃状态(如来电话时) - Logger.i(_tag, '应用处于非活跃状态'); - print('PairingCtrl: 应用处于非活跃状态'); - break; - case AppLifecycleState.detached: - // 应用即将被销毁 - Logger.i(_tag, '应用即将被销毁'); - print('PairingCtrl: 应用即将被销毁'); - break; - case AppLifecycleState.hidden: - // 应用被隐藏(iOS 13+) - Logger.i(_tag, '应用被隐藏'); - print('PairingCtrl: 应用被隐藏'); - break; - } - } - - /// 应用从后台切回前台时的处理逻辑 - void _onAppResumed() { - Logger.i(_tag, '处理应用从后台切回前台事件'); - - // 刷新蓝牙连接状态 - _bluetoothManager.refreshDeviceConnection(); - - // 根据当前配对状态执行相应操作 - switch (_currentState.value) { - case PairingState.waitingClassic: - // 如果在等待经典蓝牙连接,重新检查连接状态 - Logger.i(_tag, '重新检查经典蓝牙连接状态'); - _checkCurrentStatus(); - break; - case PairingState.waitingBle: - // 如果在等待BLE连接,重新检查BLE状态 - Logger.i(_tag, '重新检查BLE连接状态'); - _checkBleConnectionAfterResume(); - break; - case PairingState.error: - // 如果在错误状态,根据错误类型决定是否自动重试 - if (_errorType.value == ErrorType.bluetoothOff) { - Logger.i(_tag, '检查蓝牙是否已重新开启'); - _checkCurrentStatus(); - } - break; - default: - // 其他状态暂不处理 - break; - } - } - - /// 应用切到后台时的处理逻辑 - void _onAppPaused() { - Logger.i(_tag, '处理应用切到后台事件'); - - // 可以在这里暂停一些不必要的操作,如蓝牙扫描等 - if (_currentState.value == PairingState.associating) { - Logger.i(_tag, '应用切到后台,暂停BLE扫描'); - _bleManager.stopScan(); - } - } - - /// 应用恢复后检查BLE连接状态 - Future _checkBleConnectionAfterResume() async { - try { - final currentBleState = await _bleManager.getConnectionState(); - Logger.i(_tag, '应用恢复后BLE连接状态: $currentBleState'); - - if (currentBleState == 2) { - // BLE已连接,直接完成配对 - Logger.i(_tag, '检测到BLE已连接,完成配对'); - _bleTimeoutTimer?.cancel(); - _transition(PairingState.completed); - } else { - // BLE未连接,重新启动连接流程 - Logger.i(_tag, '重新启动BLE连接流程'); - _startBleConnection(); - } - } catch (e) { - Logger.e(_tag, '检查BLE连接状态失败: $e'); - } - } - - // ==================== 监听器设置 ==================== - - /// 集中设置所有监听器 - void _setupAllListeners() { - // 1. 监听蓝牙开关状态 - _setupBluetoothStateListener(); - - // 2. 监听经典蓝牙连接状态 - _setupClassicConnectionListener(); - - // 3. 监听BLE连接状态 - _setupBleConnectionListener(); - } - - /// 监听蓝牙开关状态 - void _setupBluetoothStateListener() { - _workers.add(ever(_bluetoothManager.bluetoothStateRx, (String state) { - Logger.d(_tag, '蓝牙状态变化: $state'); - _handleBluetoothStateChange(state); - })); - } - - /// 监听经典蓝牙连接状态 - void _setupClassicConnectionListener() { - _workers - .add(ever(_bluetoothManager.currentDeviceRx, (BluetoothDevice? device) { - Logger.d(_tag, '经典蓝牙设备变化: ${device?.name}'); - _handleClassicDeviceChange(device); - })); - } - - /// 监听BLE连接状态 - void _setupBleConnectionListener() { - _workers.add(ever(_bleManager.connectionStateRx, (int state) { - Logger.d(_tag, 'BLE连接状态变化: $state'); - _handleBleStateChange(state); - })); - } - - // ==================== 状态处理 ==================== - - /// 处理蓝牙开关状态变化 - void _handleBluetoothStateChange(String state) { - if (state == 'off') { - _setError( - ErrorType.bluetoothOff, 'connectDeepSoundViaSystemBluetooth'.tr); - } else if (state == 'on' && _errorType.value == ErrorType.bluetoothOff) { - // 蓝牙重新开启,恢复流程 - Logger.i(_tag, '检测到蓝牙已开启,自动恢复配对流程'); - _clearError(); - // 延迟一下确保蓝牙服务完全就绪 - Future.delayed(const Duration(milliseconds: 500), () { - _checkCurrentStatus(); - }); - } - } - - /// 处理经典蓝牙设备变化 - void _handleClassicDeviceChange(BluetoothDevice? device) { - _currentClassicDevice = device; - - switch (_currentState.value) { - case PairingState.waitingClassic: - if (device != null) { - // 有设备连接,进入验证阶段 - Logger.d(_tag, '检测到经典蓝牙设备连接,开始验证'); - _transition(PairingState.verifying); - _verifyDevice(device); - } - break; - - case PairingState.error: - // 如果在错误状态下检测到设备连接,且错误是无设备连接,则自动恢复 - if (device != null && _errorType.value == ErrorType.noClassicDevice) { - Logger.i(_tag, '检测到设备重新连接,自动恢复配对流程'); - _clearError(); - _transition(PairingState.verifying); - _verifyDevice(device); - } - break; - - case PairingState.verifying: - case PairingState.waitingBle: - case PairingState.completed: - if (device == null) { - // 设备断开,返回等待连接状态 - _setError(ErrorType.noClassicDevice, 'deviceDisconnected'.tr); //设备已断开 - } - break; - - default: - break; - } - } - - /// 处理BLE状态变化 - void _handleBleStateChange(int state) { - if (_currentState.value == PairingState.waitingBle) { - if (state == 2) { - // BLE已连接 - _bleTimeoutTimer?.cancel(); - _transition(PairingState.completed); - } - } - - // 如果在完成状态下BLE断开,显示警告但不改变状态 - if (_currentState.value == PairingState.completed && state != 2) { - Logger.w(_tag, 'BLE连接已断开,但配对流程已完成'); - } - } - - // ==================== 状态转换 ==================== - - /// 状态转换 - void _transition(PairingState newState) { - final oldState = _currentState.value; - _currentState.value = newState; - Logger.i(_tag, '状态转换: $oldState -> $newState'); - - // 根据新状态执行相应操作 - _onStateChanged(newState); - } - - /// 状态变化处理 - void _onStateChanged(PairingState state) { - // 重置UI状态 - isLoading.value = false; - showConnectButton.value = false; - showRetryButton.value = false; - showSkipBindingButton.value = false; // 重置临时跳过绑定按钮 - showSkipButton.value = false; - showAutoCodeInput.value = false; - showBindButton.value = false; - showRetryChectButton.value = false; - switch (state) { - case PairingState.idle: - statusText.value = 'readyToStartPairing'.tr; - break; - - case PairingState.checking: - statusText.value = 'checkingEnvironment'.tr; - isLoading.value = true; - break; - - case PairingState.waitingClassic: - statusText.value = 'ensureHeadsetOnAndPairingMode'.tr; - showConnectButton.value = true; - showSkipButton.value = isShowSkipButton; - connectButtonText.value = 'goToBluetoothSettings'.tr; - break; - - case PairingState.verifying: - statusText.value = 'verifyingDevice'.tr; - isLoading.value = true; - break; - case PairingState.binding: - statusText.value = 'confirmBinding'.tr; - isLoading.value = true; - showBindButton.value = true; - showRetryChectButton.value = true; - break; - case PairingState.associating: - statusText.value = 'associatingDevice'.tr; - isLoading.value = true; - // 延迟执行关联,确保状态已更新 - Future.delayed(const Duration(milliseconds: 200), () { - _startDeviceAssociation(); - }); - break; - - case PairingState.waitingBle: - statusText.value = 'establishingSecureConnection'.tr; - isLoading.value = true; - // 延迟一下再调用,给BLE管理器时间更新状态 - Future.delayed(const Duration(milliseconds: 200), () { - _startBleConnection(); - }); - break; - case PairingState.manualbinding: - statusText.value = 'needManualBindingAuthCode'.tr; - isLoading.value = false; - showAutoCodeInput.value = true; - // showSkipButton.value = isShowSkipButton; - showSkipBindingButton.value = true; // 显示临时跳过绑定按钮 - break; - case PairingState.completed: - statusText.value = 'pairingSuccessful'.tr; - isLoading.value = false; - _navigateToHome(); - break; - - case PairingState.error: - statusText.value = _errorMessage.value; - isLoading.value = false; - showSkipButton.value = isShowSkipButton; - - // 根据错误类型决定显示哪个按钮和按钮文本 - switch (_errorType.value) { - case ErrorType.bluetoothOff: - showConnectButton.value = true; - connectButtonText.value = 'goToBluetoothSettings'.tr; //开启蓝牙 - break; - case ErrorType.noClassicDevice: - showConnectButton.value = true; - connectButtonText.value = 'goToBluetoothSettings'.tr; //前往蓝牙设备 - break; - case ErrorType.noPermission: - showRetryButton.value = true; - retryButtonText.value = 'grantPermissions'.tr; //授予权限 - break; - case ErrorType.associationFailed: - showRetryButton.value = true; - retryButtonText.value = 'retryAssociation'.tr; //重新关联 - break; - case ErrorType.authnFailed: - showRetryButton.value = true; - retryButtonText.value = 'retryAuthorization'.tr; //重新授权 - break; - default: - showRetryButton.value = true; - retryButtonText.value = 'retry'.tr; //重试 - break; - } - break; - } - } - - // ==================== 错误处理 ==================== - - /// 设置错误状态 - void _setError(ErrorType type, String message) { - _errorType.value = type; - _errorMessage.value = message; - _transition(PairingState.error); - } - - /// 清除错误状态 - void _clearError() { - _errorType.value = ErrorType.none; - _errorMessage.value = ''; - } - - // ==================== 核心流程 ==================== - - /// 开始初始检查 - void _startInitialCheck() { - // _transition(PairingState.checking); - _checkCurrentStatus(); - } - - /// 检查当前状态并决定下一步 - void _checkCurrentStatus() async { - try { - // 1. 检查蓝牙状态 - final results = await Future.wait([ - _checkBluetoothState(), // 检查蓝牙状态 - _checkPermissions(), // 检查权限 - _bluetoothManager.refreshDeviceConnection(), // 刷新设备连接 - ]); - - final isBluetoothOk = results[0] as bool; - final arePermissionsOk = results[1] as bool; - - if (!isBluetoothOk) { - _setError( - ErrorType.bluetoothOff, 'connectDeepSoundViaSystemBluetooth'.tr); - return; - } - - // 2. 检查权限 - if (!arePermissionsOk) { - _setError(ErrorType.noPermission, 'pleaseGrantNecessaryPermissions'.tr); - return; - } - - // 3. 先刷新设备连接状态,确保获取最新的连接信息 - await _bluetoothManager.refreshDeviceConnection(); - - // 4. 检查经典蓝牙连接 - final classicDevice = _bluetoothManager.currentDevice; - if (classicDevice == null) { - _transition(PairingState.waitingClassic); - return; - } - - // 5. 如果有设备,进行验证 - _currentClassicDevice = classicDevice; - _transition(PairingState.verifying); - await _verifyDevice(classicDevice); - } catch (e) { - Logger.e(_tag, '状态检查失败: $e'); - _setError(ErrorType.networkError, 'checkFailedRetry'.tr); - } - } - - /// 新增一个辅助方法来检查蓝牙状态 - Future _checkBluetoothState() async { - return _bluetoothManager.bluetoothState != 'off'; - } - - /// 检查是否为华为设备或者华为渠道 - bool _isHuaweiDevice() { - final deviceBrand = versionService.cachedDeviceBrand; - final channel = versionService.cachedChannel; - return deviceBrand.isNotEmpty && - (deviceBrand.contains('huawei') || deviceBrand.contains('华为')) || - (channel == UserChannel.CHINA_HUAWEI); - } - - /// 检查权限 - Future _checkPermissions() async { - if (isHuaweiDevice) { - isHuaweiHasChecked = _storage.read('isHuaweiHasChecked') ?? false; - } - //如果是华为设备,且已经检查过,直接返回权限状态 - if (isHuaweiDevice && isHuaweiHasChecked) { - final bluetoothScanStatus = await Permission.bluetoothScan.status; - final bluetoothConnectStatus = await Permission.bluetoothConnect.status; - final locationStatus = await Permission.locationWhenInUse.status; - - return bluetoothScanStatus.isGranted && - bluetoothConnectStatus.isGranted && - locationStatus.isGranted; - } - - // Android权限检查 - if (Platform.isAndroid) { - Logger.i(_tag, '开始检查Android权限'); - // 检查蓝牙相关权限 - final bluetoothScanGranted = - await PermissionUtil.instance.requestPermission( - permissionType: Permission.bluetoothScan, - permissionName: 'bluetoothScan'.tr, - explanationText: 'bluetoothScanExplanation'.tr, - permanentDenialText: 'bluetoothScanPermanentDenial'.tr, - ); - final bluetoothConnectGranted = - await PermissionUtil.instance.requestPermission( - permissionType: Permission.bluetoothConnect, - permissionName: 'bluetoothConnect'.tr, - explanationText: 'bluetoothConnectExplanation'.tr, - permanentDenialText: 'bluetoothConnectPermanentDenial'.tr, - ); - final locationGranted = await PermissionUtil.instance.requestPermission( - permissionType: Permission.locationWhenInUse, - permissionName: 'locationInfo'.tr, - explanationText: 'locationForBluetoothExplanation'.tr, - permanentDenialText: 'locationPermanentDenial'.tr, - ); - - if (isHuaweiDevice) { - _storage.write('isHuaweiHasChecked', true); - } - return bluetoothScanGranted && bluetoothConnectGranted && locationGranted; - } - return true; - } - - /// 验证设备 - Future _verifyDevice(BluetoothDevice device) async { - try { - Logger.i(_tag, '开始验证设备: ${device.name} (${device.address})'); - final result = await authbindingDevice(device); - if (result == 10) { - //验证成功 直接连接ble - await connectble(device); - } else if (result == 9) { - Logger.i(_tag, 'BLE已连接,直接完成配对'); - _transition(PairingState.completed); - } else if (result == 1) { - //跳转绑定界面 - //验证失败 进入手动授权 - if (currentDevice != null) { - _transition(PairingState.binding); - } else { - _transition(PairingState.checking); - _checkCurrentStatus(); - } - } else { - final authcode_open = AppConfig.env("APP_AUTHCODE_OPEN") ?? ""; - if (authcode_open == "true") { - Logger.i(_tag, '进入手动验证流: ${device.name} (${device.address})'); - //验证失败 进入手动授权 - _transition(PairingState.manualbinding); - } else { - Logger.i(_tag, '设备不支持: ${device.name} (${device.address})'); - _setError(ErrorType.deviceNotMatch, - 'pleaseConnectDeepSoundDevice'.tr); //请连接DeepSound设备 - } - } - } catch (e) { - Logger.e(_tag, '设备验证异常: $e'); - _setError(ErrorType.networkError, 'verificationFailedRetry'.tr); //验证失败请重试 - } - } - - //连接ble - Future connectble(BluetoothDevice device) async { - Logger.i(_tag, '设备验证通过'); - // _verifiedDeviceAddress = device.address; - // 检查是否已有BLE关联 - final pairedMacAddress = await _bleManager.getPairedMacAddress(); - Logger.i(_tag, '已关联的MAC地址: $pairedMacAddress'); - - if (pairedMacAddress != null && - pairedMacAddress.toUpperCase() == - _verifiedDeviceAddress?.toUpperCase()) { - // 已有关联,检查BLE连接状态 - Logger.i(_tag, '设备已关联,检查BLE连接状态'); - print('fdp--connectble--pairedMacAddress: $pairedMacAddress'); - final currentBleState = await _bleManager.getConnectionState(); - Logger.i(_tag, '验证时BLE状态: $currentBleState'); - print('fdp--connectble--currentBleState: $currentBleState'); - if (currentBleState == 2) { - // BleConnectionState.connected - Logger.i(_tag, 'BLE已连接,直接完成配对'); - _transition(PairingState.completed); - } else { - Logger.i(_tag, 'BLE未连接,等待BLE连接'); - _transition(PairingState.waitingBle); - } - } else { - // 没有关联或关联的是其他设备,需要进行关联 - Logger.i(_tag, '需要进行BLE设备关联'); - _transition(PairingState.associating); - } - return; - } - - /// 开始设备关联 - void _startDeviceAssociation() async { - try { - if (_verifiedDeviceAddress == null) { - _setError( - ErrorType.associationFailed, 'invalidDeviceAddress'.tr); //无效的设备地址 - return; - } - - // Logger.i(_tag, '开始BLE设备关联: $_verifiedDeviceName:$_verifiedDeviceAddress'); - if (Platform.isIOS) { - // 调用BLE管理器的关联方法 - final success = await _bleManager.startDeviceAssociation( - deviceNamePattern: _verifiedDeviceName!, - macAddress: _verifiedDeviceAddress!, - ); - - if (success) { - Logger.i(_tag, 'BLE设备关联成功'); - //关联成功后,转到等待BLE连接状态 - _transition(PairingState.waitingBle); - } else { - _setError(ErrorType.associationFailed, - 'bleDeviceAssociationFailed'.tr); //BLE设备关联失败 - } - } else { - _transition(PairingState.waitingBle); - } - } catch (e) { - Logger.e(_tag, 'BLE设备关联异常: $e'); - _setError(ErrorType.associationFailed, - 'deviceAssociationFailedRetry'.tr); //设备关联失败,请重试 - } - } - - /// 开始BLE连接 - void _startBleConnection() async { - try { - // 设置超时定时器 - _bleTimeoutTimer?.cancel(); - _bleTimeoutTimer = Timer(const Duration(seconds: 20), () async { - final currentBleState = await _bleManager.getConnectionState(); - if (currentBleState == 2) { - _bleTimeoutTimer?.cancel(); - _transition(PairingState.completed); - return; - } - if (_currentState.value == PairingState.waitingBle) { - _setError(ErrorType.bleTimeout, - 'bleConnectionTimeoutRetry'.tr); //BLE连接超时,请重启耳机后再试 - } - }); - - // 检查是否已连接 - 直接从原生层获取最新状态 - final currentBleState = await _bleManager.getConnectionState(); - Logger.i(_tag, 'BLE当前连接状态: $currentBleState'); - - if (currentBleState == 2) { - // BleConnectionState.connected - Logger.i(_tag, 'BLE已经连接,直接完成配对'); - _bleTimeoutTimer?.cancel(); - _transition(PairingState.completed); - return; - } - - // 注册后台扫描以开始BLE配对 - if (_verifiedDeviceAddress != null) { - // 先注销后台扫描,确保重新开始 - await _bleManager.unregisterBackgroundScan(); - final success = await _bleManager.registerBackgroundScan( - macAddress: _verifiedDeviceAddress, - uuids: AppConfig.allproductUuids(), - ); - if (!success && _currentState.value == PairingState.waitingBle) { - _setError( - ErrorType.bleTimeout, 'blePairingStartupFailed'.tr); //BLE配对启动失败 - } - } - } catch (e) { - Logger.e(_tag, 'BLE连接失败: $e'); - _setError(ErrorType.bleTimeout, 'bleConnectionFailed'.tr); //BLE连接失败 - } - } - - //自动绑定设备 0 未合作商 1 合作商 9 经典蓝牙已绑定 10,ble蓝牙已绑定 - Future authbindingDevice(BluetoothDevice device) async { - // 检查设备名称是否包含 DeepSound 或 DeapSound - final deviceName = device.name; - DBProduct? product; - // 检查是否已有BLE绑定的设备 - // 检查是否已有BLE绑定的设备 - if (AppConfig.config.products.isNotEmpty) { - final match = AppConfig.config.products.where( - (d) => d.devicename.contains(deviceName), - ); - product = match.isNotEmpty ? match.first : null; - } - final currentBleState = await _bleManager.getConnectionState(); - final names = AppConfig.allproductNames(); - final uuids = AppConfig.allproductUuids(); - // 检查是否已有绑定的设备 - print("device.address${device.address}"); - if (User.instance.devices.isNotEmpty) { - final existingDevice = User.instance.devices.firstWhere( - (d) => - d.devicetype == 1 && - d.devicename.contains(deviceName) && - d.devicemac == device.address, - orElse: () => UserDevice( - id: -1, - uid: '', - productid: -1, - devicetype: -1, - devicename: '', - devicemac: '', - license: ''), - ); - if (existingDevice.id != -1) { - // 设备是经典蓝牙,且已绑定 - return 9; - } - } else { - //未绑定设备 - _bleManager.clearPairedDevices(); - Logger.i(_tag, '验证时BLE状态: $currentBleState'); - if (currentBleState == 2) { - //已连接 清理 - // BleConnectionState.connected - Logger.i(_tag, 'BLE已连接,断开并清理缓存'); - _bleManager.disconnect(); - } - } - // 检查是否已有BLE绑定的设备 - if (Platform.isAndroid) { - final existingDevice = User.instance.devices.firstWhere( - (d) => - d.devicetype != 1 && - d.devicename.contains(deviceName) && - d.devicemac == device.address, - 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; - // 已绑定设备直接用 license 作为 deviceCode(即绑定时扫到的 SN) - if (existingDevice.license.isNotEmpty) { - deviceCode.value = existingDevice.license; - Logger.i(_tag, '[YIDONG] 已绑定设备,从license提取 deviceCode=${existingDevice.license}'); - } - return 10; - } - } - print( - '获取到 deviceName: $deviceName address: ${device.address} uuids:$uuids'); - if (product != null && product.devicetype != 1) { - if (currentBleState == 2) { - //已连接直接返回 - return 9; - } - await _bleManager.startScan(uuids); - await Future.delayed(const Duration(seconds: 1)); - // 尝试最多读取 3 次设备信息,每次间隔 1 秒 - List>? deviceinfos; - for (int i = 0; i < 15; i++) { - // 使用设备名称获取设备列表 - deviceinfos = await _bleManager.getDeviceList(deviceName); - print('获取到 deviceinfos: $deviceinfos'); - - if (deviceinfos != null && deviceinfos.isNotEmpty) { - // 循环读取每个设备的hex数据 - for (var deviceInfo in deviceinfos) { - String? hexData = deviceInfo["hex"]; - if (hexData != null && hexData.length >= 28) { - // 确保hex数据长度足够(14字节 = 28个字符) - // 读取9-14字节的MAC地址 - String macFromHex = extractMacFromHex(hexData, device.address); - if (macFromHex.isNotEmpty) { - String code = ""; - - // 找到匹配的设备,提取授权码 - if (Platform.isIOS) { - // iOS设备,提取13-22字节的授权码 - code = extractAndReverseBytes(hexData, 13, 22); - _verifiedDevicePid = - extractAndReverseBytesToInt(hexData, 4, 5); - 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; - Logger.i(_tag, '[YIDONG] iOS deviceCode提取成功 code=$code uuid=$uuids'); - //检查是否已经绑定 - final existingDevice = User.instance.devices.firstWhere( - (d) => - d.devicetype != 1 && - d.license != "" && - d.devicemac == device.address, - orElse: () => UserDevice( - id: -1, - uid: '', - productid: -1, - devicetype: -1, - devicename: '', - devicemac: '', - license: ''), - ); - if (existingDevice.id != -1) { - _bleManager.stopScan(); - // 已经绑定 - return 10; - } else { - _bleManager.stopScan(); - return 1; - } - } - } else if (Platform.isAndroid) { - // Android设备,提取15-24字节的授权码 - code = extractAndReverseBytes(hexData, 15, 24); - _verifiedDevicePid = - extractAndReverseBytesToInt(hexData, 6, 7); - 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) => - d.devicetype != 1 && - d.license != "" && - d.devicemac == device.address, - orElse: () => UserDevice( - id: -1, - uid: '', - productid: -1, - devicetype: -1, - devicename: '', - devicemac: '', - license: ''), - ); - if (existingDevice.id != -1) { - _bleManager.stopScan(); - // 已经绑定 - return 10; - } else { - _bleManager.stopScan(); - return 1; - } - } - } else { - break; - } - } - } - } - } - final currentBleState = await _bleManager.getConnectionState(); - if (currentBleState == 2) { - _bleManager.clearPairedDevices(); - _bleManager.disconnect(); - } - await Future.delayed(Duration(seconds: 1)); - } - _bleManager.stopScan(); - // if (product != null && product.devicetype != 1) { - // //ble设备 必须要扫描到结果 - // return 0; - // } - } - return 0; - //可以跳转1 - } - - // 新增方法:从hex数据中提取MAC地址并与当前设备MAC对比 - String extractMacFromHex(String hexData, String deviceMac) { - try { - // 将hex字符串转换为字节数组 - List bytes = []; - for (int i = 0; i < hexData.length; i += 2) { - bytes.add(int.parse(hexData.substring(i, i + 2), radix: 16)); - } - - // 读取9-14字节(索引8-13)的MAC地址 - if (bytes.length < 14) { - return ""; - } - - List macBytes = []; - List tempBytes = []; - if (Platform.isIOS) { - for (int i = 7; i <= 12; i++) { - // 每个字节异或0x5A - int decodedByte = bytes[i] ^ 0x5A; - macBytes.add(decodedByte); - } - } else if (Platform.isAndroid) { - for (int i = 9; i <= 14; i++) { - tempBytes.add(bytes[i]); - // 每个字节异或0x5A - int decodedByte = bytes[i] ^ 0x5A; - macBytes.add(decodedByte); - } - } - - // 将MAC字节转换为MAC地址字符串格式 - String extractedMac = macBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(':'); - String tempMac = tempBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(':'); - // 格式化当前设备MAC地址(确保格式一致) - String formattedDeviceMac = deviceMac.replaceAll(':', '').toUpperCase(); - String formattedExtractedMac = - extractedMac.replaceAll(':', '').toUpperCase(); - - print('设备MAC: $deviceMac, 提取的MAC: $extractedMac tempMac: $tempMac'); - - // 对比MAC地址 - if (formattedDeviceMac == formattedExtractedMac) { - print('MAC地址匹配成功!'); - return extractedMac; - } else { - print('MAC地址不匹配'); - return ""; - } - } catch (e) { - print('提取MAC地址时出错: $e'); - return ""; - } - } - - //确认绑定设备 - void confirmBindDevice() async { - if (currentDevice == null) { - Logger.e(_tag, '当前设备为空!'); - _transition(PairingState.verifying); - return; - } - - final response = await Api.binddevice({ - "pid": _verifiedDevicePid, - "code": deviceCode.value, - "devicename": currentDevice?.name, - "devicemac": currentDevice?.address, // 使用 address 替代 deviceId - }); - Logger.i(_tag, '手动授权结果 ${response}'); - if (response != null) { - Logger.i(_tag, '绑定设备成功 $response'); - if (response['device'] != '') { - // 根据API设计调整 - final device = UserDevice.fromJson(response['device']); - User.instance.addUserDevice(device); - _transition(PairingState.verifying); - _verifyDevice(_currentClassicDevice!); - } - /* - message UserBindDeviceResp { - DBUserDivice device = 1; //绑定的耳机信息 - bool isgiveaway = 2; //@go_tags(`gorm:"isgiveaway" json:"isgiveaway"`) 是否是第一次绑定 - int32 viplv = 3; //@go_tags(`gorm:"viplv:50" json:"viplv"`) viplv - int64 vipexptime = 4; //@go_tags(`gorm:"vipexptime" json:"vipexptime"`) 会员过期时间 - int64 aichatintegral = 5; //@go_tags(`gorm:"aichatintegral" json:"aichatintegral"`) ai聊天积分 - int64 aichattotalintegral = 6; //@go_tags(`gorm:"aichattotalintegral" json:"aichattotalintegral"`) ai聊天总积分 - int64 tradeintegral = 7; //@go_tags(`gorm:"tradeintegral" json:"tradeintegral"`) 翻译积分 - int64 tradetotalintegral = 8; //@go_tags(`gorm:"tradetotalintegral" json:"tradetotalintegral"`) 翻译总积分 - int64 meetintegral = 9; //@go_tags(`gorm:"meettotalintegral" json:"meettotalintegral"`) 会议积分 - int64 meettotalintegral = 10; //@go_tags(`gorm:"meettotalintegral" json:"meettotalintegral"`) 会议总积分 - } - */ - // 需要读取返回的数据判断 isgiveaway 是否 为 true,是的话 需要读取 后续的数据 更新到用户数据上面去 - if (response['isgiveaway'] == true) { - User.instance.viplv = response['viplv'] ?? 0; - User.instance.vipexptime = response['vipexptime'] ?? 0; - User.instance.aichatintegral = response['aichatintegral'] ?? 0; - User.instance.aichattotalintegral = - response['aichattotalintegral'] ?? 0; - User.instance.tradeintegral = response['tradeintegral'] ?? 0; - User.instance.tradetotalintegral = response['tradetotalintegral'] ?? 0; - User.instance.meetintegral = response['meetintegral'] ?? 0; - User.instance.meettotalintegral = response['meettotalintegral'] ?? 0; - final lv = User.instance.viplv; - final vipexptime = User.instance.vipexptime; - DateTime? expiry; - if (vipexptime > 0) { - expiry = DateTime.fromMillisecondsSinceEpoch(vipexptime * 1000); - } - final y = expiry?.year.toString().padLeft(4, '0') ?? ''; - final m = expiry?.month.toString().padLeft(2, '0') ?? ''; - final d = expiry?.day.toString().padLeft(2, '0') ?? ''; - final expiryText = expiry != null ? '$y-$m-$d' : 'N/A'; - Get.dialog( - AlertDialog( - title: Text('bindRewardTitle'.tr), - content: Text('bindRewardSimpleDesc'.tr), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text('ok'.tr), - ), - ], - ), - barrierDismissible: true, - ); - } - return; - } else { - Logger.e(_tag, '绑定设备失败!'); - return; - } - } - - int reverseBits(int byte) { - // 这里是 nibble swap 还是 bit 反转?看你需求,先保持 nibble swap - return ((byte & 0x0F) << 4) | ((byte & 0xF0) >> 4); - } - - String extractAndReverseBytes(String hex, int start, int end) { - List bytes = []; - for (int i = 0; i < hex.length; i += 2) { - bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); - } - List slice = bytes.sublist(start, end + 1); - List reversed = slice.reversed.toList(); - return reversed - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .toUpperCase(); - } - - int extractAndReverseBytesToInt(String hex, int start, int end) { - List bytes = []; - // 将十六进制字符串解析为字节列表 - for (int i = 0; i < hex.length; i += 2) { - bytes.add(int.parse(hex.substring(i, i + 2), radix: 16)); - } - - // 截取指定范围的字节(包含end索引) - List slice = bytes.sublist(start, end + 1); - // 反转字节顺序 - List reversed = slice.toList(); - - String pid = reversed - .map((b) => b.toRadixString(16).padLeft(2, '0')) - .join() - .toUpperCase(); - Logger.i(_tag, 'liwei-------------- 解析道Pid $pid'); - // 将反转后的字节列表转换为整数 - int result = 0; - for (int b in reversed) { - result = (result << 8) | (b & 0xFF); // 按字节拼接成整数 - } - return result; - } - - /// 用户点击授权码输入框 - void onAutoCodeInputTap() async { - // 调用BLE管理器的自动绑定方法 - final response = await Api.bindauthcode({ - "code": codeInputController.text.trim(), - "earphonname": currentDevice?.name, - "earphonemac": currentDevice?.address, // 使用 address 替代 deviceId - }); - Logger.i(_tag, '手动授权结果 ${response}'); - if (response != null) { - // User.instance.earphonemac = currentDevice?.address ?? ''; // 使用 address - // User.instance.earphonename = currentDevice?.name ?? ''; - // User.instance.earphoneauthcode = codeInputController.text.trim(); - _transition(PairingState.verifying); - _verifyDevice(_currentClassicDevice!); - } else { - Logger.e(_tag, '手动绑定失败!'); - _setError(ErrorType.authnFailed, - 'deviceAuthorizationFailedCheckCode'.tr); //设备授权失败,请检查授权码 - } - } - - /// 检测是否为移动精灵(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() { - // 检测是否为移动精灵(YIDONG)设备 - _detectAndSaveYidongDevice(); - // 启动AgentService服务 - try { - Logger.i(_tag, '启动AgentService服务'); - _bleManager.startAgentService(); - } catch (e) { - Logger.e(_tag, '启动AgentService失败: $e'); - } - // 已在 Home 页时不重复导航,避免两个 HomeView 同时存在导致 Duplicate GlobalKey - if (Get.currentRoute == Routes.home) return; - Get.offAllNamed(Routes.home); - } - - // ==================== 用户操作 ==================== - - /// 用户点击连接按钮 - void onConnectTap() { - Logger.i(_tag, '用户点击连接按钮'); - - // 根据当前状态或错误类型决定行为 - if (_currentState.value == PairingState.error) { - switch (_errorType.value) { - case ErrorType.bluetoothOff: - // 引导用户去开启蓝牙 - Logger.i(_tag, '引导用户开启蓝牙'); - _openBluetoothSettings(); - break; - case ErrorType.noClassicDevice: - // 引导用户去蓝牙设置连接设备 - Logger.i(_tag, '引导用户去蓝牙设置'); - _openBluetoothSettings(); - break; - default: - // 其他错误情况,刷新并重试 - _bluetoothManager.refreshDeviceConnection(); - _checkCurrentStatus(); - break; - } - } else if (_currentState.value == PairingState.waitingClassic) { - // 等待经典蓝牙连接状态,引导去设置 - Logger.i(_tag, '等待经典蓝牙连接,引导用户去蓝牙设置'); - _openBluetoothSettings(); - } else { - // 其他情况,刷新设备连接 - _bluetoothManager.refreshDeviceConnection(); - _checkCurrentStatus(); - } - } - - /// 用户点击重试按钮 - void onRetryTap() { - Logger.i(_tag, '用户点击重试按钮'); - - // 如果是权限问题,引导去设置 - if (_errorType.value == ErrorType.noPermission) { - Logger.i(_tag, '引导用户去应用设置授予权限'); - AppSettings.openAppSettings(); - } else { - // 其他情况,清除错误并重试 - _clearError(); - _startInitialCheck(); - } - } - - /// 用户点击跳过绑定按钮 - void onSkipBindingTap() { - Logger.i(_tag, '用户选择临时跳过手动绑定'); - - User.instance.addUserDevice( - UserDevice( - id: 0, // 临时设备ID - uid: User.instance.uid, - productid: _verifiedDevicePid ?? -1, - devicetype: 1, - devicemac: _currentClassicDevice?.address ?? '', - devicename: _currentClassicDevice?.name ?? 'Unknown Device', - license: "", // 临时授权码 - ), - ); - _transition(PairingState.verifying); - _verifyDevice(_currentClassicDevice!); - } - - /// 用户点击跳过按钮 - void onSkipTap() { - Logger.i(_tag, '用户选择跳过配对'); - - // 启动AgentService服务 - try { - Logger.i(_tag, '启动AgentService服务'); - _bleManager.startAgentService(); - } catch (e) { - Logger.e(_tag, '启动AgentService失败: $e'); - } - - Get.offAllNamed(Routes.home); - } - - /// 打开蓝牙设置 - void openBluetoothSettings() { - _openBluetoothSettings(); - } - - /// 打开蓝牙设置 - 简化版本 - void _openBluetoothSettings() async { - if (Platform.isIOS) { - const url = 'App-Prefs:'; - //拉起iOS系统设置app到前台,使用旧接口生效 - if (await canLaunch(url)) { - await launch(url); - } else { - // 如果无法直接打开,回退到显示指导对话框 - _showBluetoothGuideDialog(); - } - } else { - // Android平台使用原有方式 - AppSettings.openAppSettings(type: AppSettingsType.bluetooth); - } - } - - /// 显示蓝牙设置指导对话框 - void _showBluetoothGuideDialog() { - Get.dialog( - AlertDialog( - title: Text('bluetoothGuideTitle'.tr), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('bluetoothGuideSteps'.tr), - SizedBox(height: 16.h), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('1. ', - style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 14.sp)), - Expanded( - child: Text('bluetoothGuideStep1'.tr, - style: TextStyle(fontSize: 14.sp))), - ], - ), - SizedBox(height: 8.h), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('2. ', - style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 14.sp)), - Expanded( - child: Text('bluetoothGuideStep2'.tr, - style: TextStyle(fontSize: 14.sp))), - ], - ), - SizedBox(height: 8.h), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('3. ', - style: TextStyle( - fontWeight: FontWeight.bold, fontSize: 14.sp)), - Expanded( - child: Text('bluetoothGuideStep3'.tr, - style: TextStyle(fontSize: 14.sp))), - ], - ), - SizedBox(height: 16.h), - Container( - padding: EdgeInsets.all(12.w), - decoration: BoxDecoration( - color: Colors.blue.withOpacity(0.1), - borderRadius: BorderRadius.circular(8.r), - border: Border.all(color: Colors.blue.withOpacity(0.3)), - ), - child: Row( - children: [ - Icon(Icons.info_outline, color: Colors.blue, size: 20.sp), - SizedBox(width: 8.w), - Expanded( - child: Text( - 'bluetoothGuideTip'.tr, - style: - TextStyle(fontSize: 12.sp, color: Colors.blue[700]), - ), - ), - ], - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Get.back(), - child: Text('iKnow'.tr, style: TextStyle(fontSize: 14.sp)), - ), - ], - ), - ); - } - - /// 清除设备关联(在需要重新配对时调用) - Future clearDeviceAssociation() async { - try { - Logger.i(_tag, '清除设备关联'); - await _bleManager.clearPairedDevices(); - } catch (e) { - Logger.e(_tag, '清除设备关联失败: $e'); - } - } - - // 用户选择设备后调用 - void onDeviceSelected(BluetoothDevice device) { - Logger.i(_tag, '用户选择了设备: ${device.name} (${device.address})'); - selectedDevice.value = device; - _currentClassicDevice = device; - _transition(PairingState.verifying); - _verifyDevice(device); - } -} diff --git a/lib/modules/pairing/views/pairing_view.dart b/lib/modules/pairing/views/pairing_view.dart deleted file mode 100644 index 141a8b4a4..000000000 --- a/lib/modules/pairing/views/pairing_view.dart +++ /dev/null @@ -1,879 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../controllers/pairing_controller.dart'; - -class PairingView extends StatefulWidget { - const PairingView({Key? key}) : super(key: key); - - @override - State createState() => _PairingViewState(); -} - -class _PairingViewState extends State - with TickerProviderStateMixin { - late AnimationController _pulseController; - late AnimationController _rotationController; - late Animation _pulseAnimation; - late Animation _rotationAnimation; - - PairingController get controller => Get.find(); - - @override - void initState() { - super.initState(); - - // 脉搏动画控制器 - _pulseController = AnimationController( - duration: const Duration(milliseconds: 1500), - vsync: this, - ); - - // 旋转动画控制器 - _rotationController = AnimationController( - duration: const Duration(milliseconds: 2000), - vsync: this, - ); - - // 脉搏动画 - _pulseAnimation = Tween( - begin: 0.8, - end: 1.2, - ).animate(CurvedAnimation( - parent: _pulseController, - curve: Curves.easeInOut, - )); - - // 旋转动画 - _rotationAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _rotationController, - curve: Curves.linear, - )); - - // 开始动画 - _pulseController.repeat(reverse: true); - _rotationController.repeat(); - } - - @override - void dispose() { - _pulseController.dispose(); - _rotationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - body: SafeArea( - child: Column( - children: [ - // 固定顶部区域 - 不受下方内容影响 - _buildFixedTopSection(isDarkMode), - - // 可滚动的动态内容区域 - Expanded( - child: _buildDynamicContentSection(isDarkMode), - ), - ], - ), - ), - ); - } - - // 固定顶部区域 - Widget _buildFixedTopSection(bool isDarkMode) { - return Padding( - padding: EdgeInsets.only(top: 60.h, left: 24.w, right: 24.w), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 标题部分 - _buildHeader(isDarkMode), - SizedBox(height: 20.h), - - _buildDeviceAnimation(isDarkMode), - ], - ), - ); - } - - // 动态内容区域 - Widget _buildDynamicContentSection(bool isDarkMode) { - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Padding( - padding: EdgeInsets.symmetric(horizontal: 24.w), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: 30.h), - - // 状态信息 - _buildStatusInfo(isDarkMode), - - SizedBox(height: 40.h), - - // 底部按钮 - _buildActionButtons(isDarkMode), - - SizedBox(height: 20.h), - ], - ), - ), - ); - } - - // 头部标题 - Widget _buildHeader(bool isDarkMode) { - return Column( - children: [ - Text( - 'Voitrans', - style: TextStyle( - fontSize: 30.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - SizedBox(height: 8.h), - Text( - 'homeSlogan'.tr, // 连接世界,超越声音 - style: TextStyle( - fontSize: 16.sp, - color: isDarkMode ? Colors.white60 : Colors.black54, - ), - ), - ], - ); - } - - // 设备动画 - 根据状态显示不同的动画效果 - Widget _buildDeviceAnimation(bool isDarkMode) { - return Obx(() { - final state = controller.currentState; - final isLoading = controller.isLoading.value; - // 根据状态决定动画颜色 - Color primaryColor = const Color(0xFF0066CC); - Color secondaryColor = const Color(0xFF5E5CE6); - - // 错误状态使用红色 - if (state == PairingState.error) { - primaryColor = Colors.red; - secondaryColor = Colors.redAccent; - } - // 成功状态使用绿色 - else if (state == PairingState.completed) { - primaryColor = Colors.green; - secondaryColor = Colors.greenAccent; - } - - // 待机状态的特殊处理 - if (state == PairingState.idle || state == PairingState.waitingClassic) { - return SizedBox( - width: 240.w, - height: 240.w, - child: Stack( - alignment: Alignment.center, - children: [ - // 静态背景圆圈 - Container( - width: 200.w, - height: 200.w, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - primaryColor.withValues(alpha: 0.1), - secondaryColor.withValues(alpha: 0.1), - ], - ), - shape: BoxShape.circle, - ), - ), - // 设备图标 - Container( - width: 120.w, - height: 120.w, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [primaryColor, secondaryColor], - ), - shape: BoxShape.circle, - ), - child: Icon( - Icons.headphones, - color: Colors.white, - size: 60.w, - ), - ), - ], - ), - ); - } - - return SizedBox( - width: 240.w, - height: 240.w, - child: Stack( - alignment: Alignment.center, - children: [ - // 连续脉搏动画 - 只在加载时显示 - if (isLoading) _buildPulseAnimation(primaryColor, secondaryColor), - - // 特殊的安全连接动画 - 只在建立安全连接时显示 - if (state == PairingState.waitingBle) - _buildSecureConnectionAnimation(primaryColor, secondaryColor), - - // 设备关联动画 - 只在关联设备时显示 - if (state == PairingState.associating) - _buildAssociatingAnimation(primaryColor, secondaryColor), - - // 旋转边框 - 只在加载时显示 - if (isLoading) _buildRotatingBorder(primaryColor), - - // 设备图标 - Container( - width: 120.w, - height: 120.w, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [primaryColor, secondaryColor], - ), - shape: BoxShape.circle, - ), - child: Icon( - _getIconForState(state), - color: Colors.white, - size: 60.w, - ), - ), - ], - ), - ); - }); - } - - // 构建连续脉搏动画 - Widget _buildPulseAnimation(Color primaryColor, Color secondaryColor) { - return AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final value = _pulseAnimation.value; - final opacity = - 0.4 - (_pulseController.value * 0.3); // 0.4 到 0.1 的透明度变化 - - return Container( - width: 200.w * value, - height: 200.w * value, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - primaryColor.withValues(alpha: opacity), - secondaryColor.withValues(alpha: opacity), - ], - ), - shape: BoxShape.circle, - ), - ); - }, - ); - } - - // 构建旋转边框动画 - Widget _buildRotatingBorder(Color primaryColor) { - return AnimatedBuilder( - animation: _rotationAnimation, - builder: (context, child) { - return Transform.rotate( - angle: _rotationAnimation.value * 2 * 3.14159, // 完整旋转 - child: SizedBox( - width: 140.w, - height: 140.w, - child: CircularProgressIndicator( - strokeWidth: 3.w, - valueColor: AlwaysStoppedAnimation(primaryColor), - backgroundColor: Colors.grey.withValues(alpha: 0.2), - value: 0.7, // 显示70%的圆弧 - ), - ), - ); - }, - ); - } - - // 构建安全连接特殊动画 - 多层波纹效果 - Widget _buildSecureConnectionAnimation( - Color primaryColor, Color secondaryColor) { - return Stack( - alignment: Alignment.center, - children: [ - // 第一层波纹 - AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final value = _pulseAnimation.value; - final opacity = 0.3 - (_pulseController.value * 0.2); - - return Container( - width: 180.w * value, - height: 180.w * value, - decoration: BoxDecoration( - border: Border.all( - color: primaryColor.withValues(alpha: opacity), - width: 2.w, - ), - shape: BoxShape.circle, - ), - ); - }, - ), - - // 第二层波纹 - 延迟动画 - AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final delayedValue = (_pulseController.value + 0.3) % 1.0; - final value = 0.8 + (delayedValue * 0.4); - final opacity = 0.25 - (delayedValue * 0.15); - - return Container( - width: 160.w * value, - height: 160.w * value, - decoration: BoxDecoration( - border: Border.all( - color: secondaryColor.withValues(alpha: opacity), - width: 1.5.w, - ), - shape: BoxShape.circle, - ), - ); - }, - ), - - // 第三层波纹 - 更大延迟 - AnimatedBuilder( - animation: _pulseAnimation, - builder: (context, child) { - final delayedValue = (_pulseController.value + 0.6) % 1.0; - final value = 0.8 + (delayedValue * 0.4); - final opacity = 0.2 - (delayedValue * 0.1); - - return Container( - width: 140.w * value, - height: 140.w * value, - decoration: BoxDecoration( - border: Border.all( - color: primaryColor.withValues(alpha: opacity), - width: 1.w, - ), - shape: BoxShape.circle, - ), - ); - }, - ), - ], - ); - } - - // 根据状态获取图标 - IconData _getIconForState(PairingState state) { - switch (state) { - case PairingState.completed: - return Icons.check_circle; - case PairingState.associating: - return Icons.link; - case PairingState.error: - final errorType = controller.errorType; - switch (errorType) { - case ErrorType.bluetoothOff: - case ErrorType.noPermission: - return Icons.bluetooth_disabled; - case ErrorType.noClassicDevice: - case ErrorType.deviceNotMatch: - return Icons.headphones_outlined; - case ErrorType.associationFailed: - return Icons.link_off; - default: - return Icons.error_outline; - } - default: - return Icons.headphones; - } - } - - // 状态信息 - Widget _buildStatusInfo(bool isDarkMode) { - return Obx(() { - final status = controller.statusText.value; - final state = controller.currentState; - final device = controller.currentDevice; - - return Column( - children: [ - // 状态文本 - 更大更醒目 - Text( - status, - style: TextStyle( - fontSize: 20.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white : Colors.black, - ), - textAlign: TextAlign.center, - ), - - SizedBox(height: 10.h), - - // 子状态提示文本 - 根据状态显示不同提示 - Text( - _getSubtitleForState(state), - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.white60 : Colors.grey[600], - ), - textAlign: TextAlign.center, - ), - - // 当前连接设备(有设备时才显示) - if (device != null && - (state == PairingState.verifying || - state == PairingState.binding || - state == PairingState.waitingBle || - state == PairingState.completed)) ...[ - SizedBox(height: 24.h), - _buildConnectedDevice(device, isDarkMode), - ], - ], - ); - }); - } - - // 根据状态获取副标题 - String _getSubtitleForState(PairingState state) { - switch (state) { - case PairingState.idle: - return 'readyToPair'.tr; // 准备开始配对 - case PairingState.checking: - return 'preparingPairingEnvironment'.tr; // 正在准备配对环境... - case PairingState.waitingClassic: - return 'connectDeepSoundViaSystemBluetooth'.tr; // 请先通过系统蓝牙连接Voitrans设备 - case PairingState.verifying: - return 'verifyingDevice'.tr; // 正在验证设备... - case PairingState.binding: - return 'deviceBindingConfirm'.tr; // 设备绑定确认... - case PairingState.associating: - return 'establishingDeviceAssociation'.tr; // 正在建立设备关联... - case PairingState.waitingBle: - return 'waitingCompletePairing'.tr; // 请稍候,正在完成配对... - case PairingState.completed: - return 'pairingSuccessRedirecting'.tr; // 配对成功!即将跳转到主页... - case PairingState.error: - final errorType = controller.errorType; - switch (errorType) { - case ErrorType.bluetoothOff: - return 'connectDeepSoundViaSystemBluetooth'.tr; // 蓝牙已关闭,请打开蓝牙 - case ErrorType.noPermission: - return 'lackPermissions'.tr; // 缺少蓝牙权限和位置权限 - case ErrorType.noClassicDevice: - return 'connectDeepSoundViaSystemBluetooth' - .tr; // 请先通过系统蓝牙连接Voitrans设备 - case ErrorType.deviceNotMatch: - return 'ensureConnectedDeepSoundDevice'.tr.trArgs( - [controller.currentDevice?.name ?? '']); // 请确保连接的是Voitrans设备 - case ErrorType.deviceBound: - return 'deviceAlreadyBoundToOtherAccount'.tr; // 该设备已被其他账号绑定 - case ErrorType.associationFailed: - return 'deviceAssociationFailed'.tr; // 设备关联失败,请重试 - case ErrorType.bleTimeout: - return 'scanTimeoutEnsureDeviceNearby'.tr; // 扫描超时,请确保设备在附近 - case ErrorType.networkError: - return 'networkError'.tr; // 网络错误 - case ErrorType.authnFailed: - return 'authorizationFailedCheckCode'.tr; // 授权失败,请检查授权码是否正确 - default: - return 'pairingFailedRetry'.tr; // 配对失败,请重试 - } - default: - return ''; - } - } - - // 已连接设备信息 - Widget _buildConnectedDevice(device, bool isDarkMode) { - return SizedBox( - width: double.infinity, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'currentlyConnected'.tr, // 当前已连接: - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: isDarkMode ? Colors.white70 : Colors.black87, - ), - ), - SizedBox(height: 8.h), - _buildDeviceItem(device, isDarkMode), - ], - ), - ); - } - - // 设备项 - Widget _buildDeviceItem(device, bool isDarkMode) { - return Container( - margin: EdgeInsets.only(bottom: 8.h), - padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 12.h), - decoration: BoxDecoration( - color: isDarkMode ? Colors.grey[900] : Colors.grey[100], - borderRadius: BorderRadius.circular(8.r), - ), - child: Row( - children: [ - Icon( - Icons.headphones, - color: isDarkMode ? Colors.white70 : Colors.black54, - size: 24.w, - ), - SizedBox(width: 12.w), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - device.name ?? 'unknown'.tr, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - SizedBox(height: 4.h), - Text( - device.address ?? '', - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white54 : Colors.black45, - ), - ), - ], - ), - ), - Icon( - Icons.bluetooth_connected, - color: Colors.green, - size: 20.w, - ), - ], - ), - ); - } - - // 获取连接按钮图标 - IconData _getConnectButtonIcon() { - final buttonText = controller.connectButtonText.value; - if (buttonText.contains('bluetooth'.tr)) { - return Icons.bluetooth; - } else if (buttonText.contains('settings'.tr)) { - return Icons.settings; - } - return Icons.bluetooth_searching; - } - - // 操作按钮 - Widget _buildActionButtons(bool isDarkMode) { - return Obx(() { - final showConnectButton = controller.showConnectButton.value; - final showRetryButton = controller.showRetryButton.value; - final showBindButton = controller.showBindButton.value; - final showRetryChectButton = controller.showRetryChectButton.value; - final showSkipButton = controller.showSkipButton.value; - final showAutoCodeInput = controller.showAutoCodeInput.value; - final showSkipBindingButton = controller.showSkipBindingButton.value; - return SizedBox( - width: double.infinity, - child: Column( - children: [ - // 显示连接按钮 - if (showConnectButton) - ElevatedButton( - onPressed: () => controller.onConnectTap(), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0066CC), - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - elevation: 0, - minimumSize: Size(double.infinity, 56.h), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(_getConnectButtonIcon(), size: 20.w), - SizedBox(width: 8.w), - Text( - controller.connectButtonText.value, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - if (showBindButton) - ElevatedButton( - onPressed: () => controller.confirmBindDevice(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - elevation: 0, - minimumSize: Size(double.infinity, 56.h), - ), - child: Text( - "bind".tr, // 绑定 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - ), - ), - ), - SizedBox(height: 12.h), - // 显示重试按钮 - if (showRetryButton) - ElevatedButton( - onPressed: () => controller.onRetryTap(), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, - foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 16.h), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16.r), - ), - elevation: 0, - minimumSize: Size(double.infinity, 56.h), - ), - child: Text( - controller.retryButtonText.value, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - ), - ), - ), - if (showAutoCodeInput) - Column( - children: [ - // 输入框 - SizedBox( - width: double.infinity, - height: 48.h, - child: TextField( - controller: controller - .codeInputController, // 你可以在 Controller 中声明这个 - decoration: InputDecoration( - hintText: 'enterCode'.tr, // 请输入代码 - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12.r), - ), - contentPadding: EdgeInsets.symmetric(horizontal: 16.w), - ), - ), - ), - SizedBox(height: 12.h), - SizedBox( - width: double.infinity, - height: 48.h, - child: ElevatedButton( - onPressed: controller.onAutoCodeInputTap, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(Get.context!).primaryColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), - ), - elevation: 0, - ), - child: Text( - 'authorizeBind'.tr, // 授权绑定 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), - ], - ), - // 自动输入代码按钮 - - // 跳过按钮 - if (showSkipButton) ...[ - SizedBox(height: 12.h), - TextButton( - onPressed: () => controller.onSkipTap(), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF0066CC), - padding: EdgeInsets.symmetric(vertical: 12.h), - minimumSize: Size(double.infinity, 48.h), - ), - child: Text( - 'talkLater'.tr, // 稍后再说 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - - // 跳过绑定按钮 - if (showSkipBindingButton) ...[ - SizedBox(height: 12.h), - TextButton( - onPressed: () => controller.onSkipBindingTap(), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF0066CC), - padding: EdgeInsets.symmetric(vertical: 12.h), - minimumSize: Size(double.infinity, 48.h), - ), - child: Text( - 'skipBinding'.tr, // 跳过绑定 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - //重新检测 - if (showRetryChectButton) ...[ - SizedBox(height: 12.h), - TextButton( - onPressed: () => controller.onRetryTap(), - style: TextButton.styleFrom( - foregroundColor: const Color(0xFF0066CC), - padding: EdgeInsets.symmetric(vertical: 12.h), - minimumSize: Size(double.infinity, 48.h), - ), - child: Text( - 'rescan'.tr, // 重新扫描 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ], - ), - ); - }); - } - - // 构建设备关联动画 - 连接线条效果 - Widget _buildAssociatingAnimation(Color primaryColor, Color secondaryColor) { - return AnimatedBuilder( - animation: _rotationAnimation, - builder: (context, child) { - return Stack( - alignment: Alignment.center, - children: [ - // 旋转的连接线条 - Transform.rotate( - angle: _rotationAnimation.value * 2 * 3.14159, - child: Container( - width: 160.w, - height: 160.w, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: primaryColor.withValues(alpha: 0.6), - width: 2.w, - ), - ), - child: Stack( - children: [ - // 连接点1 - Positioned( - top: 10.w, - left: 80.w - 4.w, - child: Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - color: primaryColor, - shape: BoxShape.circle, - ), - ), - ), - // 连接点2 - Positioned( - bottom: 10.w, - left: 80.w - 4.w, - child: Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - color: secondaryColor, - shape: BoxShape.circle, - ), - ), - ), - // 连接点3 - Positioned( - top: 80.w - 4.w, - left: 10.w, - child: Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - color: primaryColor, - shape: BoxShape.circle, - ), - ), - ), - // 连接点4 - Positioned( - top: 80.w - 4.w, - right: 10.w, - child: Container( - width: 8.w, - height: 8.w, - decoration: BoxDecoration( - color: secondaryColor, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/modules/pairing/views/pairing_view_new.dart b/lib/modules/pairing/views/pairing_view_new.dart deleted file mode 100644 index 5c40d39ce..000000000 --- a/lib/modules/pairing/views/pairing_view_new.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import '../controllers/pairing_controller.dart'; - -/// 配对视图 - 简化版本 -class PairingView extends GetView { - const PairingView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; - - return Scaffold( - backgroundColor: isDarkMode ? Colors.black : Colors.white, - body: SafeArea( - child: Padding( - padding: EdgeInsets.all(24.w), - child: Column( - children: [ - // 顶部标题 - _buildHeader(isDarkMode), - - // 中间内容 - Expanded( - child: Center( - child: Obx(() => _buildContent(isDarkMode)), - ), - ), - - // 底部按钮 - Obx(() => _buildButtons(isDarkMode)), - - SizedBox(height: 20.h), - ], - ), - ), - ), - ); - } - - /// 构建标题 - Widget _buildHeader(bool isDarkMode) { - return Column( - children: [ - Text( - 'devicePairing'.tr, // 设备配对 - style: TextStyle( - fontSize: 24.sp, - fontWeight: FontWeight.bold, - color: isDarkMode ? Colors.white : Colors.black, - ), - ), - SizedBox(height: 8.h), - Text( - 'connectYourDeepSoundHeadset'.tr, // 连接您的 Voitrans 耳机 - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - SizedBox(height: 40.h), - ], - ); - } - - /// 构建主要内容 - Widget _buildContent(bool isDarkMode) { - final state = controller.currentState; - final isLoading = controller.isLoading.value; - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 状态图标 - _buildStateIcon(state, isLoading, isDarkMode), - - SizedBox(height: 40.h), - - // 状态文本 - Text( - controller.statusText.value, - style: TextStyle( - fontSize: 16.sp, - color: isDarkMode ? Colors.white : Colors.black, - ), - textAlign: TextAlign.center, - ), - - // 错误详情 - if (state == PairingState.error) ...[ - SizedBox(height: 16.h), - Container( - padding: EdgeInsets.all(16.w), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12.r), - border: Border.all(color: Colors.red.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - Icon( - Icons.error_outline, - color: Colors.red, - size: 20.sp, - ), - SizedBox(width: 12.w), - Expanded( - child: Text( - controller.errorMessage, - style: TextStyle( - fontSize: 14.sp, - color: Colors.red, - ), - ), - ), - ], - ), - ), - ], - ], - ); - } - - /// 构建状态图标 - Widget _buildStateIcon(PairingState state, bool isLoading, bool isDarkMode) { - if (isLoading) { - return CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation( - Theme.of(Get.context!).primaryColor, - ), - ); - } - - IconData iconData; - Color iconColor; - - switch (state) { - case PairingState.idle: - case PairingState.checking: - iconData = Icons.bluetooth_searching; - iconColor = Colors.blue; - break; - - case PairingState.waitingClassic: - iconData = Icons.bluetooth; - iconColor = Colors.grey; - break; - - case PairingState.verifying: - iconData = Icons.verified_user; - iconColor = Colors.orange; - break; - case PairingState.binding: - iconData = Icons.blind; - iconColor = Colors.blue; - break; - case PairingState.associating: - iconData = Icons.link; - iconColor = Colors.blue; - break; - - case PairingState.waitingBle: - iconData = Icons.sync; - iconColor = Colors.blue; - break; - case PairingState.manualbinding: - iconData = Icons.blind; - iconColor = Colors.blue; - break; - case PairingState.completed: - iconData = Icons.check_circle; - iconColor = Colors.green; - break; - - case PairingState.error: - iconData = Icons.error; - iconColor = Colors.red; - break; - } - - return Container( - width: 100.w, - height: 100.w, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: Icon( - iconData, - size: 60.sp, - color: iconColor, - ), - ); - } - - /// 构建按钮 - Widget _buildButtons(bool isDarkMode) { - final showConnectButton = controller.showConnectButton.value; - final showBindButton = controller.showBindButton.value; - final showRetryButton = controller.showRetryButton.value; - final showAutoCodeInput = controller.showAutoCodeInput.value; - final showSkipButton = controller.showSkipButton.value; - - return Column( - children: [ - // 主要操作按钮 - if (showConnectButton) - _buildPrimaryButton( - 'connectDevice'.tr, // 连接设备 - controller.onConnectTap, - isDarkMode, - ), - - // 绑定按钮 - if (showBindButton) - TextButton( - onPressed: controller.confirmBindDevice, - child: Text( - 'bind'.tr, // 绑定 - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - - if (showRetryButton) - _buildPrimaryButton( - 'retry'.tr, // 重试 - controller.onRetryTap, - isDarkMode, - ), - - SizedBox(height: 12.h), - - if (showAutoCodeInput) - // 自动输入代码按钮 - SizedBox( - width: double.infinity, - height: 48.h, - child: ElevatedButton( - onPressed: controller.onAutoCodeInputTap, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(Get.context!).primaryColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), - ), - elevation: 0, - ), - child: Text( - 'autoInputCode'.tr, // 自动输入代码 - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ), - - // 跳过按钮 - if (showSkipButton) - TextButton( - onPressed: controller.onSkipTap, - child: Text( - 'skip'.tr, // 跳过 - style: TextStyle( - fontSize: 14.sp, - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - ), - ), - ), - ], - ); - } - - /// 构建主要按钮 - Widget _buildPrimaryButton( - String text, VoidCallback onPressed, bool isDarkMode) { - return SizedBox( - width: double.infinity, - height: 48.h, - child: ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(Get.context!).primaryColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12.r), - ), - elevation: 0, - ), - child: Text( - text, - style: TextStyle( - fontSize: 16.sp, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ); - } -} diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index 9766a8956..7b10aaf1b 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/lib/modules/settings/controllers/settings_controller.dart @@ -17,7 +17,6 @@ import '../models/app_supported_languages.dart'; import '../../../data/services/language_manager.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/services/version_update_service.dart'; -import '../../../data/services/ble_manager.dart'; import '../../common/widgets/version_update_dialog.dart'; class SettingsController extends GetxController { @@ -46,13 +45,6 @@ class SettingsController extends GetxController { DateTime? _lastClickTime; // 上次点击时间 final RxBool showDebugInfo = false.obs; // 是否显示调试信息 - // 语音唤醒相关 - late final BleManager _bleManager; - final RxBool isVoiceWakeupEnabled = true.obs; - final RxBool isBleConnected = false.obs; - final RxBool isVoiceWakeupSupported = false.obs; - final RxBool isWakeupEnableByProduct = false.obs; // 产品配置是否支持唤醒开关 - @override void onInit() { super.onInit(); @@ -68,98 +60,6 @@ class SettingsController extends GetxController { // 获取应用版本信息 getAppVersion(); - - // 语音唤醒相关初始化 - _initVoiceWakeup(); - } - - /// 初始化语音唤醒功能 - void _initVoiceWakeup() { - _bleManager = Get.find(); - - // 同步初始连接状态 - isBleConnected.value = _bleManager.isConnected; - isVoiceWakeupSupported.value = _bleManager.voiceWakeupSupported; - isVoiceWakeupEnabled.value = _bleManager.voiceWakeupEnabled; - - // 如果当前已连接,检查产品配置 - if (_bleManager.isConnected) { - _checkProductWakeupEnable(); - } - - // 监听BLE连接状态变化 - // 注意:ever 回调同步触发,此时 _isConnected 可能尚未更新, - // 因此直接用 state 参数判断连接状态 - ever(_bleManager.connectionStateRx, (state) { - final connected = state == BleConnectionState.connected; - isBleConnected.value = connected; - if (connected) { - // 连接后检查产品配置 - _checkProductWakeupEnable(); - // 连接后延迟查询唤醒状态 - Future.delayed(const Duration(milliseconds: 500), () { - if (_bleManager.isConnected) { - _queryVoiceWakeupStatus(); - } - }); - } else { - isWakeupEnableByProduct.value = false; - } - }); - - // 监听语音唤醒状态变化 - ever(_bleManager.voiceWakeupEnabledRx, (enabled) { - isVoiceWakeupEnabled.value = enabled; - }); - - // 监听语音唤醒支持状态变化 - ever(_bleManager.voiceWakeupSupportedRx, (supported) { - isVoiceWakeupSupported.value = supported; - }); - - // 如果当前已连接,立即查询 - if (_bleManager.isConnected) { - _queryVoiceWakeupStatus(); - } - } - - /// 查询语音唤醒状态 - Future _queryVoiceWakeupStatus() async { - try { - await _bleManager.queryVoiceWakeupStatus(); - } catch (e) { - Logger().e('查询语音唤醒状态失败: $e'); - } - } - - /// 检查当前连接设备的产品配置是否支持唤醒开关 - void _checkProductWakeupEnable() { - try { - final deviceName = _bleManager.getDeviceName(); - if (deviceName.isEmpty || !AppConfig.isInitialized()) { - isWakeupEnableByProduct.value = false; - return; - } - final product = AppConfig.getproduct(deviceName); - isWakeupEnableByProduct.value = product?.wakeupenable ?? false; - } catch (e) { - Logger().e('检查产品唤醒配置失败: $e'); - isWakeupEnableByProduct.value = false; - } - } - - /// 切换语音唤醒状态 - Future toggleVoiceWakeup(bool enabled) async { - try { - final success = await _bleManager.setVoiceWakeupStatus(enabled); - if (!success) { - // 命令发送失败,BleManager 已将 supported 设为 false - Logger().w('语音唤醒设置命令发送失败'); - } - } catch (e) { - Logger().e('设置语音唤醒状态失败: $e'); - _bleManager.voiceWakeupSupportedRx.value = false; - } } // 获取应用版本信息的方法 diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index e2a3cc56a..3b04d550e 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -437,48 +437,6 @@ class SettingsView extends GetView { ? const Color(0xFF2C2C2C) : Colors.grey[200]), - // 语音唤醒开关 - 仅在连接设备且产品配置支持时显示 - Obx( - () { - final connected = controller.isBleConnected.value; - final wakeupEnable = - controller.isWakeupEnableByProduct.value; - // 未连接设备或产品不支持唤醒开关时隐藏 - if (!connected || !wakeupEnable) { - return const SizedBox.shrink(); - } - final supported = controller.isVoiceWakeupSupported.value; - return Column( - children: [ - _buildToggleSetting( - title: 'voiceWakeup'.tr, - subtitle: !supported - ? 'deviceNotSupportFeature'.tr - : 'voiceWakeupDesc'.tr, - icon: Icons.mic, - iconBgColor: isDarkMode - ? Colors.teal[900]!.withValues(alpha: 0.3) - : Colors.teal[100]!, - iconColor: isDarkMode - ? Colors.teal[300]! - : Colors.teal[600]!, - value: controller.isVoiceWakeupEnabled.value, - onChanged: supported - ? (enabled) => - controller.toggleVoiceWakeup(enabled) - : null, - isDarkMode: isDarkMode, - ), - Divider( - height: 1, - color: isDarkMode - ? const Color(0xFF2C2C2C) - : Colors.grey[200]), - ], - ); - }, - ), - // 用户反馈 _buildSimpleNavigationSetting( title: 'feedback'.tr, // 用户反馈 diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index 674f39c1b..88d733bc0 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -12,6 +12,7 @@ import '../../../data/models/appconfig_model.dart'; import '../../../data/models/user_Info.dart'; import '../../../routes/app_routes.dart'; import '../../../data/services/network/api.dart'; +import '../../../data/services/ble_manager.dart'; import '../../../data/services/version_update_service.dart'; import '../../../modules/common/widgets/version_update_dialog.dart'; import '../../../modules/common/widgets/privacy_policy_dialog.dart'; @@ -262,13 +263,18 @@ class SplashController extends GetxController { } /// 根据最终状态跳转到目标页面 - void _navigateToTargetPage() { + Future _navigateToTargetPage() async { // 导航时直接检查最终的数据状态,而不是依赖中间变量 final token = _storage.read('logintoken'); final isAppConfigInitialized = AppConfig.isInitialized(); // 检查登录状态和配置初始化状态 if (token != null && token.isNotEmpty && isAppConfigInitialized) { - Get.offAllNamed(Routes.pairing); + // 登录成功 → 检查 BLE 设备是否已连接,未连接则进入扫描页 + bool connected = false; + try { + connected = await BleManager.to.hasActiveConnection(); + } catch (_) {} + Get.offAllNamed(connected ? Routes.home : Routes.devices); } else { _storage.remove('meeting_upload_list'); _storage.remove('operation_records'); diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index 316f0f492..b2b3f7538 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -155,6 +155,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { StreamSubscription? _astEventSubscription; StreamSubscription? _voiceInteractionSubscription; StreamSubscription? _deviceCallTranslationSubscription; + // 通话翻译 ↔ 杰理桥接: + // inUplink/inDownlink → AstService.pushExternalAudio + // AstService.astTtsFrames → BleManager.feedAstTtsPcm + StreamSubscription? _jieliCallInUplinkSub; + StreamSubscription? _jieliCallInDownlinkSub; + StreamSubscription? _jieliCallAstTtsSub; Timer? _translationDebounceTimer; // ==================== 重新初始化守卫 ==================== @@ -963,6 +969,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _voiceInteractionSubscription?.cancel(); _deviceCallTranslationSubscription?.cancel(); _deviceCallTranslationSubscription = null; + _detachJieliCallTranslationBridge(); _translationDebounceTimer?.cancel(); restoreOtherServices(); // 移除对服务的 dispose,避免频繁销毁/重建造成卡顿 @@ -1226,9 +1233,48 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _astService.setBroadcastPeerTranslate(broadcastPeer); Logger.info('[CALL] broadcastPeerTranslate=$broadcastPeer'); + // 把杰理 SCO 双向 PCM ↔ AST 流水线接上: + // in.uplink → AST.A(己方→对端语种) + // in.downlink → AST.B(对端→己方语种) + // AST.A TTS → out.uplink(对端听) + // AST.B TTS → out.downlink(己方听) + _attachJieliCallTranslationBridge(); + Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); } + void _attachJieliCallTranslationBridge() { + _detachJieliCallTranslationBridge(); + _jieliCallInUplinkSub = bleManager.inUplinkPcmStream.listen( + (pcm) { + _astService.pushExternalAudio(leg: 'A', pcm: pcm); + }, + onError: (e) => Logger.error('[CALL-BRIDGE] in.uplink stream error: $e'), + ); + _jieliCallInDownlinkSub = bleManager.inDownlinkPcmStream.listen( + (pcm) { + _astService.pushExternalAudio(leg: 'B', pcm: pcm); + }, + onError: (e) => Logger.error('[CALL-BRIDGE] in.downlink stream error: $e'), + ); + _jieliCallAstTtsSub = _astService.astTtsFrames.listen( + (frame) { + bleManager.feedAstTtsPcm(leg: frame.leg, pcm: frame.pcm); + }, + onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'), + ); + Logger.info('[CALL-BRIDGE] 已连通 杰理 SCO ↔ AST'); + } + + void _detachJieliCallTranslationBridge() { + _jieliCallInUplinkSub?.cancel(); + _jieliCallInUplinkSub = null; + _jieliCallInDownlinkSub?.cancel(); + _jieliCallInDownlinkSub = null; + _jieliCallAstTtsSub?.cancel(); + _jieliCallAstTtsSub = null; + } + /// 配置默认模式 Future _configureDefaultMode() async { _audioSourceType = false; @@ -1951,6 +1997,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { try { isPreparing.value = false; if (currentMode.value == "audioVideo" || currentMode.value == "call") { + _detachJieliCallTranslationBridge(); bleManager.closeCodec(); Logger.info('关闭ble编解码器'); } diff --git a/lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart b/lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart deleted file mode 100644 index 68d4c590f..000000000 --- a/lib/modules/wakeupvoice/bindings/wakeup_voice_binding.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:get/get.dart'; -import '../controllers/wakeup_voice_controller.dart'; - -class WakeupVoiceBinding extends Bindings { - @override - void dependencies() { - Get.lazyPut(() => WakeupVoiceController()); - } -} - diff --git a/lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart b/lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart deleted file mode 100644 index 7ef9b60c4..000000000 --- a/lib/modules/wakeupvoice/controllers/wakeup_voice_controller.dart +++ /dev/null @@ -1,543 +0,0 @@ -import 'package:get/get.dart'; -import 'package:dio/dio.dart'; -import '../../../core/utils/logger.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../data/services/network/api.dart'; - -class WakeupVoiceController extends GetxController { - // 是否启用唤醒词(原有本地词功能,可保留) - final RxBool enabled = true.obs; - - final BleManager bleManager = Get.find(); - // 本地自定义唤醒词(原有) - final RxList wakeWords = ['你好,深声', 'Hey Voitrans'].obs; - final RxString inputText = ''.obs; - - // 服务端唤醒语音包列表 - final RxList voices = [].obs; - // 当前设备的唤醒语音 id 与版本(由外部设备信息设置) - final RxnInt currentDeviceVoiceId = RxnInt(53251); - final RxnString currentDeviceVoiceVersion = RxnString('1.0.0'); - // 当前页面选择的语音包 id - final RxnInt selectedVoiceId = RxnInt(); - - // 加载状态 - final RxBool isLoading = false.obs; - final RxString loadError = ''.obs; - - // 设备状态(用于页面头部展示) - final RxString deviceName = ''.obs; - final RxBool isConnected = false.obs; - final RxBool wakeupVersionMismatch = false.obs; - final RxnInt deviceWakeIdCode = RxnInt(); - final RxnInt deviceWakeVerCode = RxnInt(); - - // 预览/测试模式:跳过 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(); - } - } - - /// 绑定 BLE 相关的观察者 - /// 参数:无 - /// 返回:无 - /// 异常:发生监听绑定异常时捕获并记录日志,不抛出 - void _bindBleObservers() { - // 同步连接状态与设备名 - try { - isConnected.value = bleManager.isConnected; - final info = bleManager.deviceInfo.value; - if (info != null && info.name.isNotEmpty) { - deviceName.value = info.name; - _syncDeviceWakeVersions(info); - } - // 监听连接状态变化 - ever(bleManager.connectionStateRx, (state) { - isConnected.value = bleManager.isConnected; - final info = bleManager.deviceInfo.value; - if (info != null && info.name.isNotEmpty) { - deviceName.value = info.name; - _syncDeviceWakeVersions(info); - } - }); - // 监听设备信息更新 - ever(bleManager.deviceInfo, (info) { - if (info != null && info.name.isNotEmpty) { - deviceName.value = info.name; - _syncDeviceWakeVersions(info); - } - }); - // 监听升级进度 - ever(bleManager.wakeupVoiceUpgradeProgress, (p) { - upgradeProgress.value = p; - if (p >= 100 && upgradingVoiceId.value != null) { - final item = _findById(upgradingVoiceId.value); - if (item != null) { - currentDeviceVoiceId.value = item.id; - currentDeviceVoiceVersion.value = item.version; - selectedVoiceId.value = item.id; - } - upgradingVoiceId.value = null; - } - }); - // 监听升级状态:仅当状态为“成功(2)”或“失败(3)”时弹出提示弹窗;升级中(1)或其他状态不提示 - ever(bleManager.wakeupVoiceUpgradeStatus, (s) { - // 升级状态字段未定义,暂存到日志 - Logger.info('BLE 升级状态码: $s'); - if (s == 2) { - // 升级成功 - Get.snackbar('成功', '唤醒语音升级成功'); - } else if (s == 3) { - // 升级失败 - Get.snackbar('错误', '唤醒语音升级失败'); - } - }); - } catch (e) { - Logger.warning('绑定BLE观察者失败: $e'); - } - } - - // 供外部设置当前设备语音包信息 - /// 设置当前设备的唤醒语音包信息 - /// 参数:`id` 唤醒词ID;`version` 版本字符串(如 `1.2.3.4`) - /// 返回:无 - /// 异常:无 - void setCurrentDeviceWakeVoice({required int id, required String version}) { - currentDeviceVoiceId.value = id; - currentDeviceVoiceVersion.value = version; - // 若已加载列表,默认选中当前设备项 - if (voices.isNotEmpty) { - selectedVoiceId.value = id; - } - } - - /// 拉取服务端唤醒语音包列表 - /// 参数:无 - /// 返回:`Future`,完成后 `voices`、`selectedVoiceId` 等被更新 - /// 异常:网络或数据解析异常会写入 `loadError` 并弹出提示,不抛出 - Future fetchVoices() async { - try { - isLoading.value = true; - loadError.value = ''; - final res = await Api.getwakeupvoices(); - Logger.info('获取唤醒语音列表成功 res: $res'); - // 仅按约定结构解析:{ voices: [...] } - if (res is! Map || res['voices'] is! List) { - loadError.value = '接口返回异常:缺少 voices 列表'; - voices.clear(); - return; - } - final list = List>.from(res['voices'] as List); - - voices.assignAll(list - .map((e) => WakeupVoiceItem.fromJson(e as Map)) - .toList()); - - if (voices.isNotEmpty) { - selectedVoiceId.value = voices.first.id; - } - final devIdCode = deviceWakeIdCode.value; - if (devIdCode != null) { - final sameIdItems = voices.where((v) { - final p = _parseVersion(v.version); - return p != null && - p.length >= 2 && - ((p[0] << 8) | p[1]) == devIdCode; - }).toList(); - if (sameIdItems.isNotEmpty) { - selectedVoiceId.value = sameIdItems.first.id; - currentDeviceVoiceId.value = devIdCode; - } - } - if (wakeupVersionMismatch.value) { - loadError.value = '设备异常:左右唤醒词版本不一致'; - } - } catch (e) { - loadError.value = '加载唤醒语音失败: $e'; - Get.snackbar('错误', loadError.value); - } finally { - isLoading.value = false; - } - } - - /// 解析版本字符串为四段整型数组 - /// 参数:`v` 版本字符串,期望形如 `A.B.C.D`(可包含非数字分隔符) - /// 返回:`List?` 长度为4的数组,若解析失败返回 `null` - /// 异常:无 - List? _parseVersion(String v) { - final parts = - v.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList(); - if (parts.length < 4) return null; - final nums = parts.take(4).map((e) => int.tryParse(e) ?? 0).toList(); - return nums; - } - - /// 同步设备左右耳唤醒词版本信息,并计算设备唤醒词ID与版本编码 - /// 参数:`info` 设备信息对象,需包含 `wakeupLeftVersion` 与 `wakeupRightVersion` - /// 返回:无 - /// 异常:无;若版本缺失或解析失败则静默返回 - void _syncDeviceWakeVersions(DeviceInfo info) { - final left = info.wakeupLeftVersion; - final right = info.wakeupRightVersion; - wakeupVersionMismatch.value = - left.isNotEmpty && right.isNotEmpty && left != right; - final base = left.isNotEmpty ? left : right; - if (base.isEmpty) return; - final p = _parseVersion(base); - if (p == null || p.length < 4) return; - deviceWakeIdCode.value = (p[0] << 8) | p[1]; - deviceWakeVerCode.value = (p[2] << 8) | p[3]; - currentDeviceVoiceId.value = deviceWakeIdCode.value; - currentDeviceVoiceVersion.value = '${p[2]}.${p[3]}'; - } - - /// 判断候选项与设备的唤醒词ID是否一致 - /// 参数:`item` 唤醒词项,若为空返回 `false` - /// 返回:`bool`,一致返回 `true` - /// 异常:无 - bool _isSameWakeId(WakeupVoiceItem? item) { - if (item == null) return false; - final p = _parseVersion(item.version); - if (p == null || p.length < 2) return false; - final idCode = (p[0] << 8) | p[1]; - return deviceWakeIdCode.value != null && deviceWakeIdCode.value == idCode; - } - - /// 判断候选项与设备的唤醒词版本是否一致 - /// 参数:`item` 唤醒词项,若为空返回 `false` - /// 返回:`bool`,一致返回 `true` - /// 异常:无 - bool _isSameWakeVersion(WakeupVoiceItem? item) { - if (item == null) return false; - final p = _parseVersion(item.version); - if (p == null || p.length < 4) return false; - final verCode = (p[2] << 8) | p[3]; - return deviceWakeVerCode.value != null && - deviceWakeVerCode.value == verCode; - } - - // 选择语音包 - /// 选择语音包 - /// 参数:`id` 待选择的语音包ID - /// 返回:无 - /// 异常:无 - void selectVoice(int id) { - selectedVoiceId.value = id; - } - - // 动作按钮文案与状态 - /// 获取当前选择项对应的操作文案(基于设备与候选项版本关系) - /// 参数:无(使用 `selectedVoiceId`) - /// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新`、`处理` - /// 异常:无 - 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 (wakeupVersionMismatch.value) return '设备异常'; - - if (_isSameWakeId(selected)) { - return _isSameWakeVersion(selected) ? '已是最新' : '升级'; - } - return '切换'; - } - - /// 获取当前选择项是否允许触发操作 - /// 参数:无(使用 `selectedVoiceId`) - /// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false` - /// 异常:无 - 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 (!isConnected.value) return false; - if (wakeupVersionMismatch.value) return false; - - if (_isSameWakeId(selected)) { - return !_isSameWakeVersion(selected); - } - return true; - } - - /// 计算指定语音项的操作文案(不影响当前选择项) - /// 参数:`item` 候选语音项 - /// 返回:`String`,可能为 `设备异常`、`升级`、`切换`、`已是最新` - /// 异常:无 - String actionTextForItem(WakeupVoiceItem item) { - if (wakeupVersionMismatch.value) return '设备异常'; - if (_isSameWakeId(item)) { - return _isSameWakeVersion(item) ? '已是最新' : '升级'; - } - return '切换'; - } - - /// 计算指定语音项是否允许操作(不影响当前选择项) - /// 参数:`item` 候选语音项 - /// 返回:`bool`,当 `设备异常` 或 `已是最新` 时为 `false` - /// 异常:无 - bool actionEnabledForItem(WakeupVoiceItem item) { - if (!isConnected.value) return false; - if (wakeupVersionMismatch.value) return false; - if (_isSameWakeId(item)) { - return !_isSameWakeVersion(item); - } - return true; - } - - /// 根据当前选择项触发操作 - /// 参数:无 - /// 返回:`Future` - /// 异常:无;当不允许操作时直接返回 - Future onActionPressed() async { - final label = actionText; - if (!actionEnabled) return; - - switch (label) { - case '升级': - await upgradeWakeVoice(); - break; - case '切换': - await switchWakeVoice(); - break; - default: - break; - } - } - - /// 将当前设备升级到已选择项 - /// 参数:无(使用 `selectedVoiceId`) - /// 返回:`Future` - /// 异常:下载或升级失败会通过提示与日志反馈,不抛出 - Future upgradeWakeVoice() async { - final sel = _findById(selectedVoiceId.value); - if (sel == null) return; - await _startUpgradeByItem(sel); - } - - /// 将当前设备切换到已选择项 - /// 参数:无(使用 `selectedVoiceId`) - /// 返回:`Future` - /// 异常:下载或升级失败会通过提示与日志反馈,不抛出 - Future switchWakeVoice() async { - final sel = _findById(selectedVoiceId.value); - if (sel == null) return; - await _startUpgradeByItem(sel); - } - - // 直接切换到指定语音包(页面卡片按钮使用) - /// 直接切换到指定语音项 - /// 参数:`item` 目标语音项 - /// 返回:`Future` - /// 异常:下载或升级失败会通过提示与日志反馈,不抛出 - Future switchWakeVoiceTo(WakeupVoiceItem item) async { - selectedVoiceId.value = item.id; - await _startUpgradeByItem(item); - } - - // 直接升级到指定语音包(页面卡片按钮使用) - /// 直接升级到指定语音项 - /// 参数:`item` 目标语音项 - /// 返回:`Future` - /// 异常:下载或升级失败会通过提示与日志反馈,不抛出 - Future upgradeWakeVoiceTo(WakeupVoiceItem item) async { - selectedVoiceId.value = item.id; - await _startUpgradeByItem(item); - } - - // 升级相关状态 - final RxnInt upgradingVoiceId = RxnInt(); - final RxInt upgradeProgress = 0.obs; - - /// 按指定语音项发起升级流程(下载并写入设备) - /// 参数:`item` 目标语音项 - /// 返回:`Future` - /// 异常:下载或BLE交互失败会通过提示与日志反馈,不抛出 - Future _startUpgradeByItem(WakeupVoiceItem item) async { - try { - if (item.packagaddress.isEmpty) { - Get.snackbar('升级失败', '语音包地址为空'); - return; - } - upgradingVoiceId.value = item.id; - upgradeProgress.value = 0; - final url = item.packagaddress; - Logger.info('开始下载唤醒语音包: $url'); - final bytes = await _downloadVoiceBytes(url); - if (bytes.isEmpty) { - Get.snackbar('升级失败', '语音包下载为空'); - upgradingVoiceId.value = null; - return; - } - Logger.info('下载完成,字节数: ${bytes.length},开始升级'); - // 注意:MethodChannel 在 Android 端对 Uint8List 会编码为 byte[], - // 而当前原生插件期望的是 List。为避免 ClassCastException,传递 List。 - final ok = await bleManager.startWakeupVoicesUpgrade(bytes.toList()); - if (!ok) { - Get.snackbar('升级失败', 'BLE升级启动失败'); - upgradingVoiceId.value = null; - return; - } - Get.snackbar('升级已启动', '正在写入设备,请稍候'); - } catch (e) { - upgradingVoiceId.value = null; - Logger.info('升级失败: $e'); - Get.snackbar('升级失败', e.toString()); - } - } - - /// 下载语音包二进制数据 - /// 参数:`url` 语音包地址 - /// 返回:`Future>` 字节列表;失败返回空列表 - /// 异常:网络异常会记录日志并返回空列表 - Future> _downloadVoiceBytes(String url) async { - try { - final dio = Dio(); - final response = await dio.get>( - url, - options: Options( - responseType: ResponseType.bytes, - receiveTimeout: const Duration(seconds: 60), - ), - ); - return response.data ?? []; - } catch (e) { - Logger.error('下载语音包失败: $url', e); - return []; - } - } - - /// 根据ID查找语音项 - /// 参数:`id` 语音包ID - /// 返回:`WakeupVoiceItem?` 找到返回对象,否则 `null` - /// 异常:无 - WakeupVoiceItem? _findById(int? id) { - if (id == null) return null; - for (final v in voices) { - if (v.id == id) return v; - } - return null; - } - - // 以下保留原有本地词功能(开关、增删) - void toggleEnabled(bool value) { - enabled.value = value; - } - - void setInput(String value) { - inputText.value = value; - } - - String? validateWakeWord(String text) { - final t = text.trim(); - if (t.isEmpty) return '唤醒词不能为空'; - if (t.length > 20) return '唤醒词过长(≤20个字符)'; - if (wakeWords.contains(t)) return '重复的唤醒词'; - return null; - } - - bool addWakeWord(String text) { - final err = validateWakeWord(text); - if (err != null) { - Get.snackbar('提示', err); - return false; - } - if (wakeWords.length >= 5) { - Get.snackbar('提示', '最多添加5个唤醒词'); - return false; - } - wakeWords.add(text.trim()); - inputText.value = ''; - return true; - } - - void removeWakeWord(String text) { - wakeWords.remove(text); - } - - Future saveChanges() async { - Get.snackbar('保存成功', '唤醒词设置已更新'); - } -} - -class WakeupVoiceItem { - final int id; - final bool enable; - final String name; - final String desc; - final String version; - final String packagaddress; - - WakeupVoiceItem({ - required this.id, - required this.enable, - required this.name, - required this.desc, - required this.version, - required this.packagaddress, - }); - - factory WakeupVoiceItem.fromJson(Map json) { - return WakeupVoiceItem( - id: (json['id'] ?? json['Id'] ?? 0) is String - ? int.tryParse(json['id'] ?? json['Id']) ?? 0 - : (json['id'] ?? json['Id'] ?? 0) as int, - enable: (json['enable'] ?? json['Enable'] ?? false) == true || - (json['enable'] ?? 'false').toString() == 'true', - name: (json['name'] ?? '').toString(), - desc: (json['desc'] ?? '').toString(), - version: (json['version'] ?? '').toString(), - packagaddress: (json['packagaddress'] ?? '').toString(), - ); - } - - // 从描述字段提取识别率与更新说明(供UI展示) -} - -extension WakeupVoiceItemExt on WakeupVoiceController { - String? extractAccuracy(WakeupVoiceItem item) { - final d = item.desc; - final reg = RegExp(r'(识别率|准确率)\s*([0-9]{1,3}(\.[0-9])?)%'); - final m = reg.firstMatch(d); - if (m != null) { - return '识别率 ${m.group(2)}%'; - } - return null; - } - - List extractUpdateNotes(WakeupVoiceItem item) { - final d = item.desc.trim(); - if (d.isEmpty) { - return const ['提升识别准确率至99.2%', '优化唤醒提示音的响应', '修复复杂性延迟问题']; - } - // 按常见分隔符切分 - final parts = d - .split(RegExp(r'[;;\n\r]')) - .map((e) => e.trim()) - .where((e) => e.isNotEmpty) - .toList(); - return parts; - } -} diff --git a/lib/modules/wakeupvoice/views/wakeup_voice_view.dart b/lib/modules/wakeupvoice/views/wakeup_voice_view.dart deleted file mode 100644 index 9545a041f..000000000 --- a/lib/modules/wakeupvoice/views/wakeup_voice_view.dart +++ /dev/null @@ -1,457 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import '../controllers/wakeup_voice_controller.dart'; - -class WakeupVoiceView extends GetView { - const WakeupVoiceView({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Scaffold( - backgroundColor: isDark ? Colors.black : Colors.grey[50], - body: Obx(() { - // 顶部渐变头部 + 内容 - return CustomScrollView( - slivers: [ - SliverAppBar( - pinned: true, - elevation: 0, - centerTitle: true, - backgroundColor: Colors.transparent, - flexibleSpace: _buildHeader(isDark), - expandedHeight: 180, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: () => Get.back(), - ), - title: const Text('唤醒词管理', - style: TextStyle(color: Colors.white, fontSize: 18)), - actions: const [ - Padding( - padding: EdgeInsets.only(right: 12), - child: Icon(Icons.more_vert, color: Colors.white), - ) - ], - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCurrentWakeWordCard(isDark), - const SizedBox(height: 16), - _buildAvailableWakeWords(isDark), - ], - ), - ), - ), - ], - ); - }), - ); - } - - // 顶部渐变头部 - /// 构建顶部设备状态头部 - /// 参数:`isDark` 是否深色模式 - /// 返回:`Widget` 构建好的头部组件 - /// 异常:无 - Widget _buildHeader(bool isDark) { - final connected = controller.isConnected.value; - final deviceName = controller.deviceName.value.isEmpty - ? '未连接设备' - : controller.deviceName.value; - final mismatch = controller.wakeupVersionMismatch.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)), - if (mismatch) ...[ - const SizedBox(width: 12), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: Colors.red.withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.redAccent), - ), - child: const Text('设备异常', - style: TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500)), - ), - ] - ], - ) - ], - ), - ), - ], - ), - ), - ), - ); - } - - // 当前唤醒词卡片 - /// 构建当前设备唤醒词卡片 - /// 参数:`isDark` 是否深色模式 - /// 返回:`Widget` 构建好的卡片 - /// 异常:无 - 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), - if (curItem != null) - _buildVoiceCard( - isDark: isDark, - item: curItem, - isCurrent: true, - hasUpdate: curItem != null && - controller.actionTextForItem(curItem) == '升级', - ), - // 版本一致的唤醒词放在“当前唤醒词”下面 - ...controller.voices - .where((v) => controller.actionTextForItem(v) == '已是最新') - .where((v) => !identical(v, curItem)) - .map((v) => _buildVoiceCard( - isDark: isDark, - item: v, - isCurrent: true, - hasUpdate: false, - )), - ], - ); - } - - // 可用唤醒词列表 - /// 构建可用唤醒词列表 - /// 参数:`isDark` 是否深色模式 - /// 返回:`Widget` 构建好的列表 - /// 异常:无 - 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) => controller.actionTextForItem(v) != '已是最新') - .map((v) => _buildVoiceCard( - isDark: isDark, - item: v, - isCurrent: false, - hasUpdate: controller.actionTextForItem(v) == '升级', - )), - ], - ); - } - - /// 比较版本是否更高(三段比较) - /// 参数:`a` 候选版本;`b` 当前版本(可空) - /// 返回:`bool` 若 `a` 高于 `b` 返回 `true` - /// 异常:无 - bool _versionHigherThan(String a, String? b) { - if (b == null) return true; - // 简易版本比较:按点位分割逐段比较 - List pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - List pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList(); - for (int i = 0; i < 3; i++) { - final va = i < pa.length ? pa[i] : 0; - final vb = i < pb.length ? pb[i] : 0; - if (va != vb) return va > vb; - } - return false; - } - - /// 判断当前卡片是否存在可升级版本 - /// 参数:`curItem` 当前项(可空) - /// 返回:`bool` 存在升级返回 `true` - /// 异常:无 - bool _hasUpdateForCurrent(WakeupVoiceItem? curItem) { - if (curItem == null) return false; - final curVer = controller.currentDeviceVoiceVersion.value; - return _versionHigherThan(curItem.version, curVer); - } - - /// 构建通用语音卡片 - /// 参数:`isDark` 是否深色模式;`item` 语音项;`isCurrent` 是否当前项;`hasUpdate` 是否升级卡片 - /// 返回:`Widget` 卡片组件 - /// 异常:无 - Widget _buildVoiceCard({ - required bool isDark, - required WakeupVoiceItem? item, - required bool isCurrent, - bool hasUpdate = false, - }) { - if (item == null) { - if (isCurrent) { - 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('未设置'), - ); - } else { - return const SizedBox.shrink(); - } - } - - final accuracyText = controller.extractAccuracy(item) ?? '识别率 -'; - final showCheck = isCurrent && !hasUpdate; - final actionColor = - hasUpdate ? const Color(0xFFFF7A00) : const Color(0xFF6A5AE0); - final actionText = controller.actionTextForItem(item); - final actionEnabled = controller.actionEnabledForItem(item); - 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 || !actionEnabled - ? null - : () { - if (actionText == '升级') { - controller.upgradeWakeVoiceTo(item); - } else if (actionText == '切换') { - controller.switchWakeVoiceTo(item); - } - }, - child: Text(actionText), - ), - ), - if (isUpgrading) ...[ - const SizedBox(height: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.sync, size: 16, color: Color(0xFF6A5AE0)), - const SizedBox(width: 6), - Text('升级中... $progress%', - style: const TextStyle( - fontSize: 12, color: Colors.black54)), - ], - ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: (progress.clamp(0, 100)) / 100.0, - minHeight: 8, - backgroundColor: - isDark ? Colors.grey[800] : Colors.grey[300], - color: const Color(0xFF6A5AE0), - ), - ), - ], - ), - ], - ], - ), - ); - } - // 底部按钮已移除,改为每个卡片独立的操作按钮以贴合设计 -} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index d24555e14..0684bec66 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -47,13 +47,9 @@ import '../modules/login/views/login_view.dart'; import '../modules/login/bindings/login_binding.dart'; import '../modules/splash/views/splash_view.dart'; import '../modules/splash/bindings/splash_binding.dart'; -import '../modules/pairing/views/pairing_view.dart'; -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'; @@ -101,13 +97,6 @@ abstract class AppPages { transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 250), ), - GetPage( - name: Routes.pairing, - page: () => const PairingView(), - binding: PairingBinding(), - transition: Transition.rightToLeft, - transitionDuration: Duration(milliseconds: 250), - ), GetPage( name: Routes.initial, page: () => const HomeView(), @@ -373,13 +362,6 @@ 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), - ), GetPage( name: Routes.mobileElf, page: () => const MobileElfView(), diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index e9bcfdc48..3e66edb56 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -2,7 +2,6 @@ abstract class Routes { // 认证相关 static const splash = '/splash'; static const login = '/login'; - static const pairing = '/pairing'; // 主功能模块 static const initial = '/home'; @@ -80,5 +79,4 @@ abstract class Routes { static const goodstrans = '/goodstrans'; //翻译商品页面 static const goodsmeet = '/goodsmeet'; //会议商品页面 static const goodsai = '/goodsai'; //ai商品页面 - static const wakeupvoice = '/wakeupvoice'; //唤醒词管理页面 } diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index b13da804d..505b256dc 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -71,11 +71,35 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, private lateinit var astEventChannel: EventChannel private var astEventSink: EventChannel.EventSink? = null + // AST TTS 输出事件通道(A/B 两路 TTS PCM → Dart → Jielihome.feedTranslatedAudio) + private lateinit var astTtsEventChannel: EventChannel + private var astTtsEventSink: EventChannel.EventSink? = null + // 通话翻译:对方(B)路 E2E 翻译音频(16kHz PCM16 mono)本地播放器 private val callBPcmPlayer = CallBPcmPlayer() - // 设备产品配置 broadcastpeertranslate:true=播报并启用 gate;false/未下发=不播报且 gate 永不触发。默认 false。 + // 设备产品配置 broadcastpeertranslate:true=B 路同时也走外放(耳机一路总会走,外放是叠加)。 @Volatile private var broadcastPeerTranslate: Boolean = false + + private fun emitAstTtsFrame(leg: String, data: ByteArray) { + val sink = astTtsEventSink ?: return + // EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略 + mainHandler.post { + try { + sink.success(mapOf("leg" to leg, "pcm" to data)) + } catch (e: Exception) { + FileLogger.e(tag, "发送 AST TTS 事件失败 leg=$leg: ${e.message}") + } + } + } + + /** A 路 TTS(己方→对端语种)→ 让对端听 = SOURCE_E_SCO_UP_LINK */ + private val aPcmWriter: AudioWriter = AudioWriter { data -> + emitAstTtsFrame("A", data) + } + /** B 路 TTS(对端→己方语种)→ 让本机听 = SOURCE_E_SCO_DOWN_LINK; + * 若 broadcastPeerTranslate=true 还额外通过手机外放叠加播放。 */ private val bPcmWriter: AudioWriter = AudioWriter { data -> + emitAstTtsFrame("B", data) if (broadcastPeerTranslate) callBPcmPlayer.feed(data) } @@ -351,6 +375,20 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, astEventSink = null } }) + // 初始化 AST TTS 输出事件通道(杰理通话翻译用:把 A/B 路 TTS PCM 抛回 Dart, + // 由 Dart 转发给 Jielihome.feedTranslatedAudio 灌回耳机) + astTtsEventChannel = + EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/ast_tts_events") + astTtsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + astTtsEventSink = events + } + + override fun onCancel(arguments: Any?) { + astTtsEventSink = null + } + }) + // 初始化翻译事件通道 translationEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "azure_speech/translation_events") @@ -1216,6 +1254,28 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, result.success(true) } + /** Dart 把杰理 runtime 解码后的 SCO PCM 推进来: + * leg = 'A' / 'uplink' = 本机麦克风 / 己方 + * leg = 'B' / 'downlink' = 对端通话音 + * pcm 16k mono PCM16 little-endian + */ + "pushExternalAudio" -> { + val leg = call.argument("leg")?.uppercase() ?: "" + val pcm = call.argument("pcm") + if (pcm == null || pcm.isEmpty()) { + result.success(false); return + } + when (leg) { + "A", "UPLINK" -> pushAstAudioToA(pcm) + "B", "DOWNLINK" -> pushAstAudioToB(pcm) + else -> { + result.error("BAD_LEG", "leg must be A/uplink or B/downlink, got=$leg", null) + return + } + } + result.success(true) + } + "initialize" -> { val initGeneration = nextAstInitGeneration() val provider = call.argument("provider") ?: "" @@ -1297,13 +1357,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackA = IflytekAstCallback( "A", "$lang0->$lang1", { sendAstEvent(it) }, - { data -> - bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) - } - } - } + aPcmWriter ) val callbackB = IflytekAstCallback( "B", "$lang1->$lang0", @@ -1366,13 +1420,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackA = AzureAstCallback( "A", "$lang0->$lang1", { sendAstEvent(it) }, - { data -> - bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) - } - } - } + aPcmWriter ) val callbackB = AzureAstCallback( "B", "$lang1->$lang0", @@ -1417,13 +1465,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackA = DoubaoAstCallback( "A", "$translationLang0->$translationLang1", translationLang1, { sendAstEvent(it) }, - { data -> - bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) - } - } - } + aPcmWriter ) val callbackB = DoubaoAstCallback( "B", "$translationLang1->$translationLang0", translationLang0, @@ -1476,13 +1518,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, val callbackA = AliyunAstCallback( "A", "$translationLang0->$translationLang1", translationLang1, { sendAstEvent(it) }, - { data -> - bleWriteScope.launch { - bleRightMutex.withLock { - BleService.writeExternalAudioData(data) - } - } - } + aPcmWriter ) val callbackB = AliyunAstCallback( "B", "$translationLang1->$translationLang0", translationLang0, @@ -1792,6 +1828,7 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, asrEventChannel.setStreamHandler(null) ttsEventChannel.setStreamHandler(null) astEventChannel.setStreamHandler(null) + if (::astTtsEventChannel.isInitialized) astTtsEventChannel.setStreamHandler(null) translationEventChannel.setStreamHandler(null) try { diff --git a/local_plugins/azure_speech/pubspec.yaml b/local_plugins/azure_speech/pubspec.yaml index b8a313fbe..da70a7a3f 100644 --- a/local_plugins/azure_speech/pubspec.yaml +++ b/local_plugins/azure_speech/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: sdk: flutter ble_service: path: ../ble_service - + dev_dependencies: flutter_test: sdk: flutter diff --git a/local_plugins/device_jieli/.gitignore b/local_plugins/device_jieli/.gitignore new file mode 100644 index 000000000..e7d347d9d --- /dev/null +++ b/local_plugins/device_jieli/.gitignore @@ -0,0 +1,33 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/local_plugins/device_jieli/.metadata b/local_plugins/device_jieli/.metadata new file mode 100644 index 000000000..e53436dc8 --- /dev/null +++ b/local_plugins/device_jieli/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "30ade5e937e6afbd2d2c38ec53175b89b556447e" + channel: "stable" + +project_type: plugin + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e + base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e + - platform: android + create_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e + base_revision: 30ade5e937e6afbd2d2c38ec53175b89b556447e + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/local_plugins/device_jieli/CHANGELOG.md b/local_plugins/device_jieli/CHANGELOG.md new file mode 100644 index 000000000..41cc7d819 --- /dev/null +++ b/local_plugins/device_jieli/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/local_plugins/device_jieli/LICENSE b/local_plugins/device_jieli/LICENSE new file mode 100644 index 000000000..ba75c69f7 --- /dev/null +++ b/local_plugins/device_jieli/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/local_plugins/device_jieli/README.md b/local_plugins/device_jieli/README.md new file mode 100644 index 000000000..d5eb0fde6 --- /dev/null +++ b/local_plugins/device_jieli/README.md @@ -0,0 +1,15 @@ +# jielihome + +JieLi Home SDK Flutter plugin + +## Getting Started + +This project is a starting point for a Flutter +[plug-in package](https://flutter.dev/to/develop-plugins), +a specialized package that includes platform-specific implementation code for +Android and/or iOS. + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + diff --git a/local_plugins/device_jieli/analysis_options.yaml b/local_plugins/device_jieli/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/local_plugins/device_jieli/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/device_jieli/android/.gitignore b/local_plugins/device_jieli/android/.gitignore new file mode 100644 index 000000000..161bdcdaf --- /dev/null +++ b/local_plugins/device_jieli/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.cxx diff --git a/local_plugins/device_jieli/android/build.gradle b/local_plugins/device_jieli/android/build.gradle new file mode 100644 index 000000000..24cfe9a8b --- /dev/null +++ b/local_plugins/device_jieli/android/build.gradle @@ -0,0 +1,80 @@ +group = "com.jielihome.jielihome" +version = "1.0-SNAPSHOT" + +buildscript { + ext.kotlin_version = "2.1.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.7.3") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + } +} + +allprojects { + repositories { + google() + mavenCentral() + flatDir { + dirs "libs" + } + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace = "com.jielihome.jielihome" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + test.java.srcDirs += "src/test/kotlin" + } + + defaultConfig { + minSdk = 21 + } + + dependencies { + // 杰理原生 SDK:library 模块只能 compileOnly(AGP 限制:library 不能直接 bundle 本地 aar)。 + // host app 需在 android/app/build.gradle.kts 里把同名 aar 用 implementation 引入作运行时依赖。 + compileOnly fileTree(dir: 'libs', include: ['*.aar', '*.jar']) + // device_plugin_interface 提供 DeviceCallTranslationPort 等通用接口, + // 让 JieliCallTranslationPort 可以无 vendor 耦合地被 translate_server 消费。 + implementation project(':device_plugin_interface') + implementation "com.squareup.okhttp3:okhttp:4.12.0" + implementation "com.google.code.gson:gson:2.10.1" + implementation "androidx.core:core-ktx:1.13.1" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" + + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.0.0") + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} diff --git a/local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar b/local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar new file mode 100644 index 000000000..1a609561d Binary files /dev/null and b/local_plugins/device_jieli/android/libs/jl_audio_decode_V2.1.0_20012-release.aar differ diff --git a/local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar b/local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar new file mode 100644 index 000000000..9bfae54af Binary files /dev/null and b/local_plugins/device_jieli/android/libs/jl_audio_v2_V1.0.0_9-release.aar differ diff --git a/local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar b/local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar new file mode 100644 index 000000000..15eb8cf5e Binary files /dev/null and b/local_plugins/device_jieli/android/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar differ diff --git a/local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar b/local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar new file mode 100644 index 000000000..fd933770e Binary files /dev/null and b/local_plugins/device_jieli/android/libs/jldecryption_v0.4-release.aar differ diff --git a/local_plugins/device_jieli/android/settings.gradle b/local_plugins/device_jieli/android/settings.gradle new file mode 100644 index 000000000..8d18be720 --- /dev/null +++ b/local_plugins/device_jieli/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'jielihome' diff --git a/local_plugins/device_jieli/android/src/main/AndroidManifest.xml b/local_plugins/device_jieli/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..368a6627f --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt new file mode 100644 index 000000000..4828c7db3 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/JielihomePlugin.kt @@ -0,0 +1,41 @@ +package com.jielihome.jielihome + +import com.jielihome.jielihome.bridge.MethodRouter +import com.jielihome.jielihome.core.JieliHomeServer +import com.jielihome.jielihome.integration.JieliNativeDevicePlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel + +/** + * Flutter 入口,仅负责注册 Channel 并把请求转交给 MethodRouter。 + * 全部业务逻辑在 [JieliHomeServer] 与 feature/event 子模块中。 + */ +class JielihomePlugin : FlutterPlugin { + + private lateinit var methodChannel: MethodChannel + private lateinit var eventChannel: EventChannel + private lateinit var router: MethodRouter + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + val server = JieliHomeServer.get() + router = MethodRouter(binding.applicationContext, server) + + methodChannel = MethodChannel(binding.binaryMessenger, "device_jieli/method") + methodChannel.setMethodCallHandler(router) + + eventChannel = EventChannel(binding.binaryMessenger, "device_jieli/event") + eventChannel.setStreamHandler(server.dispatcher) + + // 把杰理 vendor 注册到 device_manager 的 NativeDevicePluginRegistry: + // 这样 app 走 device_manager 路径时能 listVendors / useVendor("jieli")。 + // SDK 直连路径(device_jieli/method 上面那条)保留不变;两条路径并存。 + JieliNativeDevicePlugin.register(binding.applicationContext) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + // 进程级资源不在这里 shutdown,保持后续 attach 仍可继续使用 + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt new file mode 100644 index 000000000..ddec2cbbf --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/api/JieliEventListener.kt @@ -0,0 +1,153 @@ +package com.jielihome.jielihome.api + +/** + * 原生层事件监听器。宿主 App 在 Activity/Service 里实现并注册到 + * [com.jielihome.jielihome.core.JieliHomeServer.addEventListener], + * 即可订阅插件所有事件而不依赖 Flutter EventChannel。 + * + * 所有回调里的 payload 都是 [Map],字段与 Dart 侧 EventChannel 推上去的完全一致, + * 因此原生订阅与 Dart 订阅可以并存(fan-out)。 + * + * 默认提供「按 type 路由」的便捷扩展,子类只覆盖关心的方法即可。 + */ +interface JieliEventListener { + /** 任意事件,原始 payload。可以自己按 payload["type"] 路由。 */ + fun onEvent(payload: Map) {} +} + +/** + * 按事件类型分发的便捷适配器。子类只覆盖关心的回调,未覆盖的事件会落到 [onUnknown]。 + */ +abstract class JieliEventAdapter : JieliEventListener { + + override fun onEvent(payload: Map) { + when (payload["type"] as? String) { + "adapterStatus" -> onAdapterStatus( + enabled = payload["enabled"] as? Boolean ?: false, + hasBle = payload["hasBle"] as? Boolean ?: false, + ) + + "scanStatus" -> onScanStatus( + ble = payload["ble"] as? Boolean ?: false, + started = payload["started"] as? Boolean ?: false, + ) + + "deviceFound" -> onDeviceFound(payload) + "bondStatus" -> onBondStatus( + address = payload["address"] as? String ?: "", + status = payload["status"] as? Int ?: 0, + ) + "rcspInit" -> onRcspInit( + address = payload["address"] as? String ?: "", + code = payload["code"] as? Int ?: -1, + ) + "connectionState" -> onConnectionState( + address = payload["address"] as? String ?: "", + state = payload["state"] as? Int ?: -1, + ) + + "battery" -> onBattery( + address = payload["address"] as? String, + level = payload["level"] as? Int, + ) + "phoneCallStatus" -> onPhoneCallStatus( + address = payload["address"] as? String, + status = payload["status"] as? Int ?: 0, + ) + "voiceMode" -> onVoiceMode( + address = payload["address"] as? String, + modeId = payload["modeId"] as? Int, + ) + + "volume" -> onVolume(payload) + "musicName" -> onMusicName( + address = payload["address"] as? String, + name = payload["name"] as? String, + ) + "musicStatus" -> onMusicStatus(payload) + "playMode" -> onPlayMode( + address = payload["address"] as? String, + mode = payload["mode"] as? Int, + ) + + "expandFunction" -> onExpandFunction(payload) + + "translationAudio" -> { + onTranslationAudio(payload) + onTranslationEvent(payload) + } + "translationResult" -> { + onTranslationResult(payload) + onTranslationEvent(payload) + } + "translationError" -> { + onTranslationError(payload) + onTranslationEvent(payload) + } + "translationLog" -> { + onTranslationLog(payload) + onTranslationEvent(payload) + } + + "speechStart" -> onSpeechStart(payload) + "speechAudio" -> onSpeechAudio(payload) + "speechEnd" -> onSpeechEnd(payload) + "speechError" -> onSpeechError(payload) + + "otaState" -> onOtaState(payload) + "otaError" -> onOtaError(payload) + + else -> onUnknown(payload) + } + } + + open fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) {} + open fun onScanStatus(ble: Boolean, started: Boolean) {} + open fun onDeviceFound(payload: Map) {} + open fun onBondStatus(address: String, status: Int) {} + open fun onRcspInit(address: String, code: Int) {} + open fun onConnectionState(address: String, state: Int) {} + + open fun onBattery(address: String?, level: Int?) {} + open fun onPhoneCallStatus(address: String?, status: Int) {} + open fun onVoiceMode(address: String?, modeId: Int?) {} + + open fun onVolume(payload: Map) {} + open fun onMusicName(address: String?, name: String?) {} + open fun onMusicStatus(payload: Map) {} + open fun onPlayMode(address: String?, mode: Int?) {} + + open fun onExpandFunction(payload: Map) {} + + /** 翻译事件统一入口(兜底,默认细分回调之后还会触发一次) */ + open fun onTranslationEvent(payload: Map) {} + + /** payload["pcm"] 为 ByteArray;附带 streamId/sampleRate/seq/tsMs/final 字段 */ + open fun onTranslationAudio(payload: Map) {} + + /** payload 含 srcLang/srcText/destLang/destText/requestId */ + open fun onTranslationResult(payload: Map) {} + + /** payload 含 code/message */ + open fun onTranslationError(payload: Map) {} + + open fun onTranslationLog(payload: Map) {} + + /** 耳机检测到唤醒/语音助手触发;payload: voiceType, sampleRate, vadWay, address, tsMs */ + open fun onSpeechStart(payload: Map) {} + + /** 语音助手音频帧;payload: pcm(ByteArray), encoding, sampleRate, channels, address */ + open fun onSpeechAudio(payload: Map) {} + + /** 语音助手会话结束;payload: reason, message, address */ + open fun onSpeechEnd(payload: Map) {} + + open fun onSpeechError(payload: Map) {} + + /** OTA 进度/状态:state(String), sent(Long), total(Long), percent(Int) */ + open fun onOtaState(payload: Map) {} + + open fun onOtaError(payload: Map) {} + + open fun onUnknown(payload: Map) {} +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt new file mode 100644 index 000000000..01e9cfd32 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/LocalPlayer.kt @@ -0,0 +1,63 @@ +package com.jielihome.jielihome.audio + +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioManager +import android.media.AudioTrack +import java.util.concurrent.atomic.AtomicBoolean + +/** + * 本地 PCM 播放(A2DP 路径)。用于音视频翻译模式:TTS 不回耳机,直接走 A2DP。 + */ +class LocalPlayer( + private val sampleRate: Int = 16000, + private val channels: Int = 1, +) { + private var track: AudioTrack? = null + private val playing = AtomicBoolean(false) + + fun start() { + if (playing.get()) return + val cfg = if (channels == 2) AudioFormat.CHANNEL_OUT_STEREO else AudioFormat.CHANNEL_OUT_MONO + val minBuf = AudioTrack.getMinBufferSize(sampleRate, cfg, AudioFormat.ENCODING_PCM_16BIT) + + track = AudioTrack.Builder() + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_MEDIA) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + ) + .setAudioFormat( + AudioFormat.Builder() + .setSampleRate(sampleRate) + .setChannelMask(cfg) + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .build() + ) + .setBufferSizeInBytes(maxOf(minBuf, sampleRate * 2 * channels)) // 1s + .setTransferMode(AudioTrack.MODE_STREAM) + .build() + track?.play() + playing.set(true) + } + + fun feed(pcm: ByteArray) { + if (!playing.get()) return + runCatching { track?.write(pcm, 0, pcm.size) } + } + + fun stop() { + if (!playing.compareAndSet(true, false)) return + runCatching { track?.stop() } + runCatching { track?.release() } + track = null + } + + @Suppress("unused") + fun setVolumeFollowingSystemMedia() { + // hint: 用 AudioManager.STREAM_MUSIC 已由 USAGE_MEDIA 隐式选择 + @Suppress("DEPRECATION") + AudioManager.STREAM_MUSIC // no-op + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt new file mode 100644 index 000000000..e2aa9ea7d --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/OpusStreamCodec.kt @@ -0,0 +1,104 @@ +package com.jielihome.jielihome.audio + +import com.jieli.jl_audio_decode.callback.OnDecodeStreamCallback +import com.jieli.jl_audio_decode.callback.OnEncodeStreamCallback +import com.jieli.jl_audio_decode.opus.OpusManager +import com.jieli.jl_audio_decode.opus.model.OpusOption + +/** + * OPUS 流式解码器。 + * 输入:从耳机收到的 OPUS 编码帧 + * 输出:16kHz / mono PCM 流 + * + * 立体声场景把 [channel] 设 2 + [packetSize] 设 80。 + */ +class OpusStreamDecoder( + private val channel: Int = 1, + private val packetSize: Int = 200, + private val sampleRate: Int = 16000, + private val hasHead: Boolean = false, + private val onPcm: (pcm: ByteArray) -> Unit, + private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, +) { + private val manager = OpusManager() + + @Volatile + private var started = false + + fun start() { + if (started) return + val option = OpusOption() + .setHasHead(hasHead) + .setChannel(channel) + .setPacketSize(packetSize) + .setSampleRate(sampleRate) + manager.startDecodeStream(option, object : OnDecodeStreamCallback { + override fun onDecodeStream(pcm: ByteArray) { runCatching { onPcm(pcm) } } + override fun onStart() {} + override fun onComplete(p0: String?) {} + override fun onError(code: Int, msg: String?) { + runCatching { onError(code, msg) } + } + }) + started = true + } + + fun feedEncoded(opus: ByteArray) { + if (!started) return + runCatching { manager.writeAudioStream(opus) } + } + + fun stop() { + if (!started) return + runCatching { manager.stopDecodeStream() } + runCatching { manager.release() } + started = false + } +} + +/** + * OPUS 流式编码器。把 PCM 编码成 OPUS 帧后回调 [onOpus],由调用方包成 AudioData 回送给设备。 + */ +class OpusStreamEncoder( + private val channel: Int = 1, + private val packetSize: Int = 200, + private val sampleRate: Int = 16000, + private val hasHead: Boolean = false, + private val onOpus: (opus: ByteArray) -> Unit, + private val onError: (code: Int, msg: String?) -> Unit = { _, _ -> }, +) { + private val manager = OpusManager() + + @Volatile + private var started = false + + fun start() { + if (started) return + val option = OpusOption() + .setHasHead(hasHead) + .setChannel(channel) + .setPacketSize(packetSize) + .setSampleRate(sampleRate) + manager.startEncodeStream(option, object : OnEncodeStreamCallback { + override fun onEncodeStream(opus: ByteArray) { runCatching { onOpus(opus) } } + override fun onStart() {} + override fun onComplete(p0: String?) {} + override fun onError(code: Int, msg: String?) { + runCatching { onError(code, msg) } + } + }) + started = true + } + + fun feedPcm(pcm: ByteArray) { + if (!started) return + runCatching { manager.writeEncodeStream(pcm) } + } + + fun stop() { + if (!started) return + runCatching { manager.stopEncodeStream() } + runCatching { manager.release() } + started = false + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt new file mode 100644 index 000000000..acf037f0b --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PcmKit.kt @@ -0,0 +1,25 @@ +package com.jielihome.jielihome.audio + +object PcmKit { + /** + * 把 16bit 立体声 PCM 拆为左/右两路单声道。 + * 入参字节流:L0_lo L0_hi R0_lo R0_hi L1_lo L1_hi R1_lo R1_hi ... + * 返回:Pair(leftMono, rightMono) + */ + fun splitStereo16(pcm: ByteArray): Pair { + val frames = pcm.size / 4 + val left = ByteArray(frames * 2) + val right = ByteArray(frames * 2) + var li = 0 + var ri = 0 + var i = 0 + while (i + 3 < pcm.size) { + left[li++] = pcm[i] + left[li++] = pcm[i + 1] + right[ri++] = pcm[i + 2] + right[ri++] = pcm[i + 3] + i += 4 + } + return left to right + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt new file mode 100644 index 000000000..c602a8665 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/audio/PhoneMicCapture.kt @@ -0,0 +1,102 @@ +package com.jielihome.jielihome.audio + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageManager +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import androidx.core.content.ContextCompat +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +/** + * 手机麦克风采集,输出 16kHz / 16bit / mono PCM。 + * 每 20ms 切一帧(320 samples = 640 bytes)。 + * + * 用法: + * val mic = PhoneMicCapture(context) { pcm -> /* push to bridge */ } + * mic.start() + * ... + * mic.stop() + */ +class PhoneMicCapture( + private val context: Context, + private val sampleRate: Int = 16000, + private val frameDurationMs: Int = 20, + private val onFrame: (pcm: ByteArray) -> Unit, + private val onError: (code: Int, msg: String) -> Unit = { _, _ -> }, +) { + private val running = AtomicBoolean(false) + private var recorder: AudioRecord? = null + private var worker: Thread? = null + + val frameSizeBytes: Int = sampleRate * frameDurationMs / 1000 * 2 /* 16bit */ + + @SuppressLint("MissingPermission") + fun start(): Boolean { + if (running.get()) return true + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + Log.w(TAG, "RECORD_AUDIO permission missing") + onError(-101, "RECORD_AUDIO permission missing") + return false + } + + val minBuf = AudioRecord.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + val bufSize = maxOf(minBuf, frameSizeBytes * 4) + + val ar = AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + sampleRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufSize + ) + if (ar.state != AudioRecord.STATE_INITIALIZED) { + ar.release() + Log.w(TAG, "AudioRecord init failed") + onError(-102, "AudioRecord init failed (state=${ar.state})") + return false + } + recorder = ar + running.set(true) + ar.startRecording() + + worker = thread(name = "device-jieli-mic", isDaemon = true) { + val frame = ByteArray(frameSizeBytes) + while (running.get()) { + val n = try { ar.read(frame, 0, frameSizeBytes) } catch (t: Throwable) { + onError(-103, "AudioRecord.read crashed: ${t.message}"); -1 + } + when { + n < 0 -> { onError(-104, "AudioRecord.read err code=$n"); break } + n == 0 -> continue + n == frameSizeBytes -> onFrame(frame.copyOf()) + else -> onFrame(frame.copyOf(n)) + } + } + } + return true + } + + fun stop() { + if (!running.compareAndSet(true, false)) return + runCatching { recorder?.stop() } + runCatching { recorder?.release() } + recorder = null + worker?.join(200) + worker = null + } + + companion object { + private const val TAG = "PhoneMicCapture" + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt new file mode 100644 index 000000000..779c2e534 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/EventDispatcher.kt @@ -0,0 +1,50 @@ +package com.jielihome.jielihome.bridge + +import android.os.Handler +import android.os.Looper +import com.jielihome.jielihome.api.JieliEventListener +import io.flutter.plugin.common.EventChannel +import java.util.concurrent.CopyOnWriteArrayList + +/** + * 事件总线。所有 forwarder / feature 调用 [send],由本类做 fan-out: + * - Flutter 端:通过 EventChannel.EventSink 推到 Dart + * - 原生端:通过 [JieliEventListener] 推给宿主直接订阅 + * + * Flutter / 原生 / 多个原生监听器 可以同时存在;payload 形态完全一致。 + */ +class EventDispatcher : EventChannel.StreamHandler { + + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile + private var sink: EventChannel.EventSink? = null + + private val nativeListeners = CopyOnWriteArrayList() + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + sink = events + } + + override fun onCancel(arguments: Any?) { + sink = null + } + + fun addNativeListener(listener: JieliEventListener) { + if (!nativeListeners.contains(listener)) nativeListeners.add(listener) + } + + fun removeNativeListener(listener: JieliEventListener) { + nativeListeners.remove(listener) + } + + fun send(payload: Map) { + // 原生监听同步派发(在事件原始线程,不经主线程切换,方便高频音频帧) + for (l in nativeListeners) { + try { l.onEvent(payload) } catch (_: Throwable) { /* swallow listener errors */ } + } + // Flutter 端必须在主线程 + val s = sink ?: return + mainHandler.post { runCatching { s.success(payload) } } + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt new file mode 100644 index 000000000..ffaf69c3c --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/bridge/MethodRouter.kt @@ -0,0 +1,227 @@ +package com.jielihome.jielihome.bridge + +import android.content.Context +import com.jielihome.jielihome.core.JieliHomeServer +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * MethodChannel 路由层:把 Dart 调用映射到 server 内的 feature 模块。 + * Plugin 类不直接处理任何业务逻辑,只装配 router。 + */ +class MethodRouter( + private val context: Context, + private val server: JieliHomeServer, +) : MethodChannel.MethodCallHandler { + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + try { + when (call.method) { + "getPlatformVersion" -> + result.success("Android ${android.os.Build.VERSION.RELEASE}") + + "initialize" -> { + server.initialize( + context = context, + multiDevice = call.argument("multiDevice") ?: true, + skipNoNameDev = call.argument("skipNoNameDev") ?: false, + enableLog = call.argument("enableLog") ?: false, + ) + result.success(true) + } + + "startScan" -> { + val timeout = call.argument("timeoutMs") ?: 30000 + server.scanFeature.startScan(timeout) + .fold({ result.success(true) }, + { result.error("SCAN_FAILED", it.message, null) }) + } + + "stopScan" -> { server.scanFeature.stopScan(); result.success(true) } + + "isScanning" -> result.success(server.scanFeature.isScanning()) + + "connect" -> { + val mac = call.argument("address") + ?: return result.error("BAD_ARG", "address required", null) + server.connectFeature.connect( + bleAddress = mac, + edrAddress = call.argument("edrAddr"), + deviceType = call.argument("deviceType") ?: -1, + connectWay = call.argument("connectWay") ?: 0, + ).fold({ result.success(true) }, + { result.error("CONNECT_FAILED", it.message, null) }) + } + + "disconnect" -> { + val mac = call.argument("address") + ?: return result.error("BAD_ARG", "address required", null) + server.connectFeature.disconnect(mac) + .fold({ result.success(true) }, + { result.error("DISCONNECT_FAILED", it.message, null) }) + } + + "isConnected" -> { + val mac = call.argument("address") + ?: return result.error("BAD_ARG", "address required", null) + result.success(server.connectFeature.isConnected(mac)) + } + + "connectedDevice" -> { + val d = server.connectFeature.connectedDevice() + if (d == null) result.success(null) + else result.success( + mapOf( + "address" to d.address, + "name" to runCatching { d.name }.getOrNull() + ) + ) + } + + "deviceSnapshot" -> { + val mac = call.argument("address") + ?: return result.error("BAD_ARG", "address required", null) + result.success(server.deviceInfoFeature.snapshot(mac)) + } + + "queryTargetInfo" -> { + val mac = call.argument("address") + ?: return result.error("BAD_ARG", "address required", null) + val mask = call.argument("mask") ?: 0x0F + server.deviceInfoFeature.queryTargetInfo( + mac, mask, + onSuccess = { result.success(it) }, + onError = { c, m -> result.error("TARGET_INFO_ERR", m, c) } + ) + } + + "sendCustomCmd" -> { + val mac = call.argument("address") + ?: return result.error("BAD_ARG", "address required", null) + val opCode = call.argument("opCode") + ?: return result.error("BAD_ARG", "opCode required", null) + val payload = call.argument("payload") ?: ByteArray(0) + server.customCmdFeature.send( + mac, opCode, payload, + onSuccess = { result.success(it) }, + onError = { c, m -> result.error("CUSTOM_CMD_ERR", m, c) } + ) + } + + "startTranslation" -> { + val modeId = call.argument("modeId") + ?: return result.error("BAD_ARG", "modeId required", null) + @Suppress("UNCHECKED_CAST") + val args = (call.argument>("args") ?: emptyMap()) + server.translationFeature.start(modeId, args) + .fold({ result.success(true) }, + { result.error("TRANSLATION_ERR", it.message, null) }) + } + + "stopTranslation" -> { + server.translationFeature.stop(); result.success(true) + } + + "translationStatus" -> result.success( + mapOf( + "working" to server.translationFeature.isWorking(), + "modeId" to server.translationFeature.currentModeId(), + "inputStreams" to server.translationFeature.currentInputStreams(), + "outputStreams" to server.translationFeature.currentOutputStreams() + ) + ) + + "feedTranslatedAudio" -> { + val streamId = call.argument("streamId") + ?: return result.error("BAD_ARG", "streamId required", null) + val pcm = call.argument("pcm") + ?: return result.error("BAD_ARG", "pcm required", null) + val sampleRate = call.argument("sampleRate") ?: 16000 + val channels = call.argument("channels") ?: 1 + val bits = call.argument("bitsPerSample") ?: 16 + val isFinal = call.argument("final") ?: false + val ok = server.translationFeature.feedTranslatedAudio( + streamId, pcm, + com.jielihome.jielihome.feature.translation.AudioFormat(sampleRate, channels, bits), + isFinal + ) + result.success(ok) + } + + "feedTranslationResult" -> { + server.translationFeature.feedTranslationResult( + srcLang = call.argument("srcLang"), + srcText = call.argument("srcText"), + destLang = call.argument("destLang"), + destText = call.argument("destText"), + requestId = call.argument("requestId"), + ) + result.success(true) + } + + "isSupportCallTranslationWithStereo" -> { + val mac = call.argument("address") + result.success(server.translationFeature.isSupportCallTranslationWithStereo(mac)) + } + + "feedAudioFilePcm" -> { + val pcm = call.argument("pcm") + ?: return result.error("BAD_ARG", "pcm required", null) + val sampleRate = call.argument("sampleRate") ?: 16000 + result.success(server.translationFeature.feedAudioFilePcm(pcm, sampleRate)) + } + + "speechIsRecording" -> + result.success(server.speechFeature.isRecording(call.argument("address"))) + + "speechStart" -> { + server.speechFeature.start( + address = call.argument("address"), + voiceType = call.argument("voiceType") + ?: com.jieli.bluetooth.bean.record.RecordParam.VOICE_TYPE_OPUS, + sampleRate = call.argument("sampleRate") + ?: com.jieli.bluetooth.bean.record.RecordParam.SAMPLE_RATE_16K, + vadWay = call.argument("vadWay") + ?: com.jieli.bluetooth.bean.record.RecordParam.VAD_WAY_DEVICE, + onResult = { ok, msg -> + if (ok) result.success(true) + else result.error("SPEECH_START_ERR", msg, null) + } + ) + } + + "speechStop" -> { + server.speechFeature.stop( + address = call.argument("address"), + reason = call.argument("reason") + ?: com.jieli.bluetooth.bean.record.RecordState.REASON_NORMAL, + onResult = { ok, msg -> + if (ok) result.success(true) + else result.error("SPEECH_STOP_ERR", msg, null) + } + ) + } + + "otaStart" -> { + val path = call.argument("firmwareFilePath") + ?: return result.error("BAD_ARG", "firmwareFilePath required", null) + server.otaFeature.start( + address = call.argument("address"), + firmwareFilePath = path, + blockSize = call.argument("blockSize") ?: 512, + fileFlagBytes = call.argument("fileFlag") ?: ByteArray(0), + ) + result.success(true) + } + + "otaCancel" -> { server.otaFeature.cancel(); result.success(true) } + + "otaIsRunning" -> result.success(server.otaFeature.isRunning()) + + else -> result.notImplemented() + } + } catch (t: Throwable) { + result.error("PLUGIN_ERR", t.message, t.stackTraceToString()) + } + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt new file mode 100644 index 000000000..8536020de --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/core/JieliHomeServer.kt @@ -0,0 +1,195 @@ +package com.jielihome.jielihome.core + +import android.content.Context +import com.jieli.bluetooth.bean.BluetoothOption +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.impl.rcsp.RCSPController +import com.jieli.bluetooth.utils.JL_Log +import com.jielihome.jielihome.api.JieliEventListener +import com.jielihome.jielihome.bridge.EventDispatcher +import com.jielihome.jielihome.event.BluetoothEventForwarder +import com.jielihome.jielihome.event.CustomEventForwarder +import com.jielihome.jielihome.event.DeviceInfoEventForwarder +import com.jielihome.jielihome.event.MediaEventForwarder +import com.jielihome.jielihome.feature.ConnectFeature +import com.jielihome.jielihome.feature.CustomCmdFeature +import com.jielihome.jielihome.feature.DeviceInfoFeature +import com.jielihome.jielihome.feature.ScanFeature +import com.jielihome.jielihome.feature.ota.OtaFeature +import com.jielihome.jielihome.feature.translation.EventChannelAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationFeature +import com.jielihome.jielihome.feature.voice.SpeechFeature + +/** + * 全局单例。原生层和 Flutter 层共用同一个 server。 + * + * 原生用法: + * ``` + * val server = JieliHomeServer.get() + * server.initialize(context, multiDevice = true, skipNoNameDev = false, enableLog = true) + * server.addEventListener(myListener) // 订阅事件 + * server.scanFeature.startScan(30_000) + * server.translationFeature.start(MODE_CALL_TRANSLATION, mapOf("address" to mac)) + * server.translationFeature.feedTranslatedAudio("uplink", pcm, AudioFormat(), false) + * ``` + * + * Flutter 侧由 [com.jielihome.jielihome.JielihomePlugin] 自动绑定 EventChannel + MethodChannel。 + * 即使没有 Flutter,原生也能完整使用 server。 + */ +class JieliHomeServer private constructor() { + + companion object { + @Volatile + private var instance: JieliHomeServer? = null + + @JvmStatic + fun get(): JieliHomeServer = instance ?: synchronized(this) { + instance ?: JieliHomeServer().also { instance = it } + } + } + + @Volatile + var initialized: Boolean = false + private set + + /** 事件总线,原生层通过 [addEventListener] 订阅;Flutter 自动注入 EventChannel sink */ + val dispatcher: EventDispatcher = EventDispatcher() + + private lateinit var btManager: JL_BluetoothManager + + private lateinit var bluetoothForwarder: BluetoothEventForwarder + private lateinit var deviceInfoForwarder: DeviceInfoEventForwarder + private lateinit var mediaForwarder: MediaEventForwarder + private lateinit var customForwarder: CustomEventForwarder + + lateinit var scanFeature: ScanFeature + private set + lateinit var connectFeature: ConnectFeature + private set + lateinit var deviceInfoFeature: DeviceInfoFeature + private set + lateinit var customCmdFeature: CustomCmdFeature + private set + lateinit var translationFeature: TranslationFeature + private set + lateinit var speechFeature: SpeechFeature + private set + lateinit var otaFeature: OtaFeature + private set + + /** 默认翻译音频桥(EventChannel 透传到 Dart);宿主 native 想完全自管时可调 + * [TranslationFeature.setBridge] 替换。 + * + * 暴露 getter 供 native 编排器(如 [JieliCallTranslationPort])在 enter/exit + * 时拿到原始引用做"装上自己的 capture bridge / 退出后还原"切换。 + */ + lateinit var defaultBridge: EventChannelAudioBridge + private set + + fun initialize( + context: Context, + multiDevice: Boolean = true, + skipNoNameDev: Boolean = false, + enableLog: Boolean = false, + ) { + if (initialized) return + + JL_Log.configureLog(context, enableLog, enableLog) + + val option = BluetoothOption.createDefaultOption().also { + it.isUseMultiDevice = multiDevice + it.isSkipNoNameDev = skipNoNameDev + // 清空杰理协议广播过滤头:默认值会让 SDK 只回调匹配杰理私有广播头的设备, + // 导致 onDiscovery 在普通 BLE 设备 / 测试场景下完全不触发。 + it.scanFilterData = "" + it.otaScanFilterData = "" + } + RCSPController.init(context, option) + + btManager = RCSPController.getInstance().bluetoothManager + ?: error("RCSPController.bluetoothManager is null after init") + + // 事件转发层 + bluetoothForwarder = BluetoothEventForwarder(btManager, dispatcher).also { it.attach() } + deviceInfoForwarder = DeviceInfoEventForwarder(btManager, dispatcher).also { it.attach() } + mediaForwarder = MediaEventForwarder(btManager, dispatcher).also { it.attach() } + customForwarder = CustomEventForwarder(btManager, dispatcher).also { it.attach() } + + // 业务功能层 + scanFeature = ScanFeature(btManager) + connectFeature = ConnectFeature(btManager) + deviceInfoFeature = DeviceInfoFeature(btManager) + customCmdFeature = CustomCmdFeature(btManager) + translationFeature = TranslationFeature(context.applicationContext, btManager, connectFeature) + speechFeature = SpeechFeature(btManager, connectFeature, dispatcher).also { it.attach() } + otaFeature = OtaFeature(btManager, connectFeature, dispatcher) + + // 默认音频桥:EventChannel;injector 把 Dart 推过来的 PCM 路由回当前 ModeHandler + defaultBridge = EventChannelAudioBridge(dispatcher) { _, streamId, pcm, fmt, isFinal -> + translationFeature.feedTranslatedAudio(streamId, pcm, fmt, isFinal) + } + translationFeature.setBridge(defaultBridge) + + // 内部级联收尾:连接断开 / 来电状态变化时,自动停止当前翻译,避免脏状态 + dispatcher.addNativeListener(internalCleanupListener) + + initialized = true + } + + /** 监听若干生命周期事件并触发自动收尾 */ + private val internalCleanupListener = object : com.jielihome.jielihome.api.JieliEventListener { + override fun onEvent(payload: Map) { + when (payload["type"] as? String) { + "connectionState" -> { + val state = payload["state"] as? Int ?: return + // StateCode.CONNECTION_DISCONNECT == 0 + if (state == 0 && translationFeature.isWorking()) { + translationFeature.stop() + } + } + "phoneCallStatus" -> { + // 来电/通话进行中 (status != 0) 且当前不是通话翻译,自动 exit + val status = payload["status"] as? Int ?: return + if (status == 0) return + val mid = translationFeature.currentModeId() ?: return + val isCallMode = mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION || + mid == com.jieli.bluetooth.bean.translation.TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO + if (!isCallMode && translationFeature.isWorking()) { + translationFeature.stop() + } + } + } + } + } + + /** 原生事件订阅 */ + fun addEventListener(listener: JieliEventListener) = dispatcher.addNativeListener(listener) + fun removeEventListener(listener: JieliEventListener) = dispatcher.removeNativeListener(listener) + + /** 替换翻译音频桥(完全 native 直连翻译服务时使用) */ + fun setTranslationBridge(bridge: TranslationAudioBridge) { + translationFeature.setBridge(bridge) + } + + /** + * 通话翻译能力端口([com.aiagent.device_plugin_interface.DeviceCallTranslationPort] + * 的杰理实现)。延迟创建;翻译编排器(translate_server native)通过 + * [DeviceSession.invokeFeature("jieli.callTranslationPort")] 或类似入口拿到引用。 + */ + val callTranslationPort: com.jielihome.jielihome.feature.translation.JieliCallTranslationPort by lazy { + com.jielihome.jielihome.feature.translation.JieliCallTranslationPort(this) + } + + fun shutdown() { + if (!initialized) return + runCatching { otaFeature.cancel() } + runCatching { translationFeature.stop() } + runCatching { speechFeature.detach() } + runCatching { bluetoothForwarder.detach() } + runCatching { deviceInfoForwarder.detach() } + runCatching { mediaForwarder.detach() } + runCatching { customForwarder.detach() } + initialized = false + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt new file mode 100644 index 000000000..1c78c4b79 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/BluetoothEventForwarder.kt @@ -0,0 +1,114 @@ +package com.jielihome.jielihome.event + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothDevice +import android.util.Log +import com.jieli.bluetooth.bean.BleScanMessage +import com.jieli.bluetooth.constant.BluetoothConstant +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.bluetooth.BluetoothCallbackImpl +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspCallback +import com.jielihome.jielihome.bridge.EventDispatcher + +private const val TAG = "JLBtEvtFwd" + +/** + * 蓝牙基础事件:适配器开关、扫描状态、设备发现、配对、RCSP init、连接状态。 + * 与设备「业务数据」(电量、音乐等)解耦。 + */ +class BluetoothEventForwarder( + private val btManager: JL_BluetoothManager, + private val dispatcher: EventDispatcher, +) { + private val btCallback = object : BluetoothCallbackImpl() { + override fun onAdapterStatus(bEnabled: Boolean, bHasBle: Boolean) { + Log.i(TAG, "onAdapterStatus enabled=$bEnabled hasBle=$bHasBle") + dispatcher.send( + mapOf("type" to "adapterStatus", "enabled" to bEnabled, "hasBle" to bHasBle) + ) + } + + override fun onDiscoveryStatus(bBle: Boolean, bStart: Boolean) { + Log.i(TAG, "onDiscoveryStatus ble=$bBle started=$bStart") + dispatcher.send( + mapOf("type" to "scanStatus", "ble" to bBle, "started" to bStart) + ) + } + + @SuppressLint("MissingPermission") + override fun onDiscovery(device: BluetoothDevice?, msg: BleScanMessage?) { + Log.i(TAG, "onDiscovery raw: device=$device msg=$msg") + if (device == null) { + Log.w(TAG, "onDiscovery 跳过 device==null") + return + } + // 只放行杰理协议设备(vid == JL_VID=1494)。 + // 其它厂商广播(Apple vid=76、Microsoft vid=6 等)跳过,避免列表里出来一堆不能连的设备。 + if (msg == null || msg.vid != BluetoothConstant.JL_VID) { + Log.d(TAG, "onDiscovery 跳过非杰理: addr=${device.address} vid=${msg?.vid}") + return + } + val name = try { device.name } catch (_: SecurityException) { null } ?: "" + Log.i( + TAG, + "onDiscovery deliver name='$name' addr=${device.address} " + + "edr=${msg?.edrAddr} type=${msg?.deviceType} way=${msg?.connectWay} rssi=${msg?.rssi}" + ) + dispatcher.send( + mapOf( + "type" to "deviceFound", + "name" to name, + "address" to device.address, + "edrAddr" to (msg?.edrAddr ?: ""), + "deviceType" to (msg?.deviceType ?: 0), + "connectWay" to (msg?.connectWay ?: 0), + "rssi" to (msg?.rssi ?: 0), + // BleScanMessage 已经包含双耳/盒子电量及充电状态——直接透传, + // 上层就不需要等 RCSP 拉快照了。0 视为「该位置无数据」(如单耳设备的 rightDeviceQuantity) + "leftBattery" to (msg?.leftDeviceQuantity ?: 0), + "rightBattery" to (msg?.rightDeviceQuantity ?: 0), + "caseBattery" to (msg?.chargingBinQuantity ?: 0), + "leftCharging" to (msg?.isLeftCharging ?: false), + "rightCharging" to (msg?.isRightCharging ?: false), + "caseCharging" to (msg?.isDeviceCharging ?: false) + ) + ) + } + + override fun onBondStatus(device: BluetoothDevice?, status: Int) { + if (device == null) return + dispatcher.send( + mapOf("type" to "bondStatus", "address" to device.address, "status" to status) + ) + } + } + + private val rcspCallback = object : OnRcspCallback() { + override fun onRcspInit(device: BluetoothDevice?, code: Int) { + if (device == null) return + dispatcher.send( + mapOf("type" to "rcspInit", "address" to device.address, "code" to code) + ) + if (code != 0) btManager.disconnect(device) + } + + override fun onConnectStateChange(device: BluetoothDevice?, status: Int) { + if (device == null) return + dispatcher.send( + mapOf("type" to "connectionState", "address" to device.address, "state" to status) + ) + } + } + + fun attach() { + Log.i(TAG, "attach: 注册 btCallback / rcspCallback") + btManager.addEventListener(btCallback) + btManager.registerOnRcspCallback(rcspCallback) + } + + fun detach() { + Log.i(TAG, "detach: 注销 btCallback / rcspCallback") + btManager.removeEventListener(btCallback) + btManager.unregisterOnRcspCallback(rcspCallback) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt new file mode 100644 index 000000000..51c2917b5 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/CustomEventForwarder.kt @@ -0,0 +1,40 @@ +package com.jielihome.jielihome.event + +import android.bluetooth.BluetoothDevice +import android.util.Base64 +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener +import com.jielihome.jielihome.bridge.EventDispatcher + +/** + * 厂商私有协议入口。 + * SDK 将所有未在内置协议表中匹配的「设备主动推」事件统一从 onExpandFunction 抛出, + * 这里把它原样透传到 Dart(payload 走 base64),由业务层自行解析。 + * + * 适用:自定义指令的设备->APP 反向通知,例如「检测到唤醒词」「自定义按键事件」。 + */ +class CustomEventForwarder( + private val btManager: JL_BluetoothManager, + private val dispatcher: EventDispatcher, +) { + private val listener = object : OnRcspEventListener() { + override fun onExpandFunction(device: BluetoothDevice?, opCode: Int, payload: ByteArray?) { + dispatcher.send( + mapOf( + "type" to "expandFunction", + "address" to device?.address, + "opCode" to opCode, + "payloadBase64" to payload?.let { Base64.encodeToString(it, Base64.NO_WRAP) } + ) + ) + } + } + + fun attach() { + btManager.registerOnRcspEventListener(listener) + } + + fun detach() { + btManager.unregisterOnRcspEventListener(listener) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt new file mode 100644 index 000000000..5fe0b90db --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/DeviceInfoEventForwarder.kt @@ -0,0 +1,73 @@ +package com.jielihome.jielihome.event + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.base.VoiceMode +import com.jieli.bluetooth.bean.device.status.BatteryInfo +import com.jieli.bluetooth.bean.device.voice.VoiceFunc +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener +import com.jielihome.jielihome.bridge.EventDispatcher + +/** + * 设备信息类事件(设备主动推送):电量、来电状态、语音模式/功能、双连状态。 + * + * 实现思路:注册一个 OnRcspEventListener 实例,按主题分配到不同 forwarder。 + * 这里只负责「设备级状态」,多媒体放 MediaEventForwarder,私有协议放 CustomEventForwarder。 + * + * 多个 forwarder 各注册一个独立 listener,SDK 会全部派发,互不干扰。 + */ +class DeviceInfoEventForwarder( + private val btManager: JL_BluetoothManager, + private val dispatcher: EventDispatcher, +) { + private val listener = object : OnRcspEventListener() { + + override fun onBatteryChange(device: BluetoothDevice?, info: BatteryInfo?) { + dispatcher.send( + mapOf( + "type" to "battery", + "address" to device?.address, + "level" to info?.battery + ) + ) + } + + override fun onPhoneCallStatusChange(device: BluetoothDevice?, status: Int) { + dispatcher.send( + mapOf( + "type" to "phoneCallStatus", + "address" to device?.address, + "status" to status + ) + ) + } + + override fun onCurrentVoiceMode(device: BluetoothDevice?, mode: VoiceMode?) { + dispatcher.send( + mapOf( + "type" to "voiceMode", + "address" to device?.address, + "modeId" to mode?.mode + ) + ) + } + + override fun onVoiceFunctionChange(device: BluetoothDevice?, func: VoiceFunc?) { + dispatcher.send( + mapOf( + "type" to "voiceFunction", + "address" to device?.address, + "function" to func?.toString() + ) + ) + } + } + + fun attach() { + btManager.registerOnRcspEventListener(listener) + } + + fun detach() { + btManager.unregisterOnRcspEventListener(listener) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt new file mode 100644 index 000000000..655e1afaf --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/event/MediaEventForwarder.kt @@ -0,0 +1,79 @@ +package com.jielihome.jielihome.event + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.device.eq.EqInfo +import com.jieli.bluetooth.bean.device.music.MusicNameInfo +import com.jieli.bluetooth.bean.device.music.MusicStatusInfo +import com.jieli.bluetooth.bean.device.music.PlayModeInfo +import com.jieli.bluetooth.bean.device.voice.VolumeInfo +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspEventListener +import com.jielihome.jielihome.bridge.EventDispatcher + +/** + * 多媒体事件:音乐名/状态/播放模式、EQ、音量。 + */ +class MediaEventForwarder( + private val btManager: JL_BluetoothManager, + private val dispatcher: EventDispatcher, +) { + private val listener = object : OnRcspEventListener() { + + override fun onVolumeChange(device: BluetoothDevice?, info: VolumeInfo?) { + dispatcher.send( + mapOf( + "type" to "volume", + "address" to device?.address, + "current" to info?.volume, + "max" to info?.maxVol + ) + ) + } + + override fun onEqChange(device: BluetoothDevice?, info: EqInfo?) { + dispatcher.send( + mapOf("type" to "eq", "address" to device?.address, "info" to info?.toString()) + ) + } + + override fun onMusicNameChange(device: BluetoothDevice?, info: MusicNameInfo?) { + dispatcher.send( + mapOf( + "type" to "musicName", + "address" to device?.address, + "name" to info?.name + ) + ) + } + + override fun onMusicStatusChange(device: BluetoothDevice?, info: MusicStatusInfo?) { + dispatcher.send( + mapOf( + "type" to "musicStatus", + "address" to device?.address, + "playing" to info?.isPlay, + "currentTime" to info?.currentTime, + "totalTime" to info?.totalTime + ) + ) + } + + override fun onPlayModeChange(device: BluetoothDevice?, info: PlayModeInfo?) { + dispatcher.send( + mapOf( + "type" to "playMode", + "address" to device?.address, + "mode" to info?.playMode + ) + ) + } + } + + fun attach() { + btManager.registerOnRcspEventListener(listener) + } + + fun detach() { + btManager.unregisterOnRcspEventListener(listener) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt new file mode 100644 index 000000000..f6f807eda --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ConnectFeature.kt @@ -0,0 +1,56 @@ +package com.jielihome.jielihome.feature + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.constant.BluetoothConstant +import com.jieli.bluetooth.constant.JL_DeviceType +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.utils.BluetoothUtil + +class ConnectFeature(private val btManager: JL_BluetoothManager) { + + fun connect( + bleAddress: String, + edrAddress: String?, + deviceType: Int, + connectWay: Int, + ): Result { + val target = resolveTarget(bleAddress, edrAddress, deviceType, connectWay) + ?: return Result.failure(IllegalStateException("remote device not found")) + btManager.connect(target.first, target.second) + return Result.success(Unit) + } + + fun disconnect(address: String): Result { + val device = BluetoothUtil.getRemoteDevice(address) + ?: return Result.failure(IllegalStateException("remote device not found")) + btManager.disconnect(device) + return Result.success(Unit) + } + + fun isConnected(address: String): Boolean { + val device = BluetoothUtil.getRemoteDevice(address) ?: return false + return btManager.isConnectedBtDevice(device) + } + + fun connectedDevice(): BluetoothDevice? = btManager.connectedDevice + + fun deviceByAddress(address: String): BluetoothDevice? = + BluetoothUtil.getRemoteDevice(address) + + private fun resolveTarget( + bleAddress: String, + edrAddress: String?, + deviceType: Int, + connectWay: Int, + ): Pair? { + val ble = BluetoothUtil.getRemoteDevice(bleAddress) ?: return null + if (deviceType != JL_DeviceType.JL_DEVICE_TYPE_WATCH && + !edrAddress.isNullOrEmpty() && + (connectWay == BluetoothConstant.PROTOCOL_TYPE_SPP || + connectWay == BluetoothConstant.PROTOCOL_TYPE_GATT_OVER_BR_EDR) + ) { + BluetoothUtil.getRemoteDevice(edrAddress)?.let { return it to connectWay } + } + return ble to BluetoothConstant.PROTOCOL_TYPE_BLE + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt new file mode 100644 index 000000000..a71e7fbe1 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/CustomCmdFeature.kt @@ -0,0 +1,42 @@ +package com.jielihome.jielihome.feature + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.base.BaseError +import com.jieli.bluetooth.bean.base.CommandBase +import com.jieli.bluetooth.bean.command.custom.CustomCmd +import com.jieli.bluetooth.bean.parameter.CustomParam +import com.jieli.bluetooth.bean.response.CustomResponse +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback +import com.jieli.bluetooth.utils.BluetoothUtil + +/** + * 厂商扩展指令通道。 + * 用法:业务方约定一个 opCode + payload 字节流,从这里下发;设备应答的 payload 透传回 Dart。 + * 设备「主动推」的扩展事件由 CustomEventForwarder.onExpandFunction 接收。 + */ +class CustomCmdFeature(private val btManager: JL_BluetoothManager) { + + fun send( + address: String, + opCode: Int, + payload: ByteArray, + onSuccess: (ByteArray?) -> Unit, + onError: (Int, String?) -> Unit, + ) { + val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) + ?: return onError(-1, "remote device not found") + + val cmd = CustomCmd(opCode, CustomParam(payload)) + btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { + override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { + val r = resp?.response as? CustomResponse + onSuccess(r?.data) + } + + override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { + onError(err?.code ?: -2, err?.message) + } + }) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt new file mode 100644 index 000000000..e0228e3f3 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/DeviceInfoFeature.kt @@ -0,0 +1,73 @@ +package com.jielihome.jielihome.feature + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.base.BaseError +import com.jieli.bluetooth.bean.base.CommandBase +import com.jieli.bluetooth.bean.command.GetTargetInfoCmd +import com.jieli.bluetooth.bean.parameter.GetTargetInfoParam +import com.jieli.bluetooth.bean.response.TargetInfoResponse +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback +import com.jieli.bluetooth.utils.BluetoothUtil + +/** + * 查询电量、版本号等设备静态信息(拉取式)。 + * 设备主动推的电量变化由 DeviceInfoEventForwarder 处理。 + */ +class DeviceInfoFeature(private val btManager: JL_BluetoothManager) { + + /** 直接读 SDK 缓存(连接成功后已自动拉取过一次) */ + fun snapshot(address: String): Map? { + val device = BluetoothUtil.getRemoteDevice(address) ?: return null + val info = btManager.getDeviceInfo(device) ?: return null + return mapOf( + "address" to device.address, + "battery" to info.quantity, + "volume" to info.volume, + "maxVolume" to info.maxVol, + "versionCode" to info.versionCode, + "versionName" to info.versionName, + "name" to info.name, + "vid" to info.vid, + "pid" to info.pid, + "uid" to info.uid, + "edrAddr" to info.edrAddr + ) + } + + /** + * 主动向设备发指令拉取最新 TargetInfo。 + * @param mask 设备字段位掩码,常见 0x0F 拉常用字段;具体 mask 见杰理协议文档 + */ + fun queryTargetInfo( + address: String, + mask: Int, + onSuccess: (Map) -> Unit, + onError: (Int, String?) -> Unit, + ) { + val device: BluetoothDevice = BluetoothUtil.getRemoteDevice(address) + ?: return onError(-1, "remote device not found") + + val cmd = GetTargetInfoCmd(GetTargetInfoParam(mask)) + btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { + override fun onCommandResponse(dev: BluetoothDevice?, resp: CommandBase<*, *>?) { + val r = resp?.response as? TargetInfoResponse + ?: return onError(-2, "no response") + onSuccess( + mapOf( + "address" to (dev?.address ?: address), + "battery" to r.quantity, + "volume" to r.volume, + "versionCode" to r.versionCode, + "versionName" to r.versionName, + "name" to r.name + ) + ) + } + + override fun onErrCode(dev: BluetoothDevice?, err: BaseError?) { + onError(err?.code ?: -3, err?.message) + } + }) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt new file mode 100644 index 000000000..ce57c80cf --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ScanFeature.kt @@ -0,0 +1,19 @@ +package com.jielihome.jielihome.feature + +import com.jieli.bluetooth.constant.ErrorCode +import com.jieli.bluetooth.impl.JL_BluetoothManager + +class ScanFeature(private val btManager: JL_BluetoothManager) { + + fun startScan(timeoutMs: Int): Result { + val code = btManager.scan(timeoutMs) + return if (code == ErrorCode.ERR_NONE) Result.success(Unit) + else Result.failure(IllegalStateException("scan returned $code")) + } + + fun stopScan() { + btManager.stopScan() + } + + fun isScanning(): Boolean = btManager.bluetoothOperation.isScanning +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt new file mode 100644 index 000000000..2f115bb85 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/ota/OtaFeature.kt @@ -0,0 +1,264 @@ +package com.jielihome.jielihome.feature.ota + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.base.BaseError +import com.jieli.bluetooth.bean.base.CommandBase +import com.jieli.bluetooth.bean.command.ota.EnterUpdateModeCmd +import com.jieli.bluetooth.bean.command.ota.ExitUpdateModeCmd +import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateBlockCmd +import com.jieli.bluetooth.bean.command.ota.FirmwareUpdateStatusCmd +import com.jieli.bluetooth.bean.command.ota.GetUpdateFileOffsetCmd +import com.jieli.bluetooth.bean.command.ota.InquireUpdateCmd +import com.jieli.bluetooth.bean.command.ota.NotifyUpdateContentSizeCmd +import com.jieli.bluetooth.bean.command.ota.RebootDeviceCmd +import com.jieli.bluetooth.bean.parameter.FirmwareUpdateBlockParam +import com.jieli.bluetooth.bean.parameter.InquireUpdateParam +import com.jieli.bluetooth.bean.parameter.NotifyUpdateContentSizeParam +import com.jieli.bluetooth.bean.parameter.RebootDeviceParam +import com.jieli.bluetooth.bean.response.EnterUpdateModeResponse +import com.jieli.bluetooth.bean.response.FirmwareUpdateBlockResponse +import com.jieli.bluetooth.bean.response.FirmwareUpdateStatusResponse +import com.jieli.bluetooth.bean.response.InquireUpdateResponse +import com.jieli.bluetooth.bean.response.UpdateFileOffsetResponse +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.interfaces.bluetooth.RcspCommandCallback +import com.jielihome.jielihome.bridge.EventDispatcher +import com.jielihome.jielihome.feature.ConnectFeature +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import java.io.RandomAccessFile +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * OTA 升级。 + * + * # 重要说明 + * **当前 SDK 包(jl_bluetooth_rcsp)只提供 OTA 协议的原始 Cmd,没有高层 OTA 管理器。** + * 完整 OTA 流(含 CRC 校验、断点续传、双备份切换)通常在杰理另发的 `jl_otalib_*.aar` + * 里实现。本实现是基于公开 cmd 的「最小可工作版」,**生产前需在真机验证**。 + * + * # 状态机 + * IDLE → INQUIRING → NOTIFYING_SIZE → ENTERING → TRANSFERRING(progress) → VERIFYING + * → REBOOTING → DONE / FAILED / CANCELLED + * + * # 数据流(推测的协议,需真机验证) + * 1. InquireUpdate(file flag) → device 决定能否升级 + * 2. NotifyUpdateContentSize(totalSize) → 告知固件包大小 + * 3. EnterUpdateMode → device 进入升级态 + * 4. Loop: + * GetUpdateFileOffset → device 告知它需要从哪个 offset 开始 + * FirmwareUpdateBlock(offset, len) → APP 发块(数据塞在响应里) + * until offset >= totalSize + * 5. FirmwareUpdateStatus → device 验证结果 + * 6. ExitUpdateMode + RebootDevice + * + * # 调用方式 + * server.otaFeature.start(address, "/sdcard/firmware.ufw") + * server.otaFeature.cancel() + */ +class OtaFeature( + private val btManager: JL_BluetoothManager, + private val connectFeature: ConnectFeature, + private val dispatcher: EventDispatcher, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val running = AtomicBoolean(false) + private var job: Job? = null + + fun isRunning(): Boolean = running.get() + + fun start( + address: String? = null, + firmwareFilePath: String, + /** 块大小(设备可能在响应里给出建议值;这里只是默认) */ + blockSize: Int = 512, + /** 升级文件标记(杰理协议要求;常见空数组或自定义校验) */ + fileFlagBytes: ByteArray = ByteArray(0), + ) { + if (!running.compareAndSet(false, true)) { + emitError(-1, "ota already running"); return + } + val device = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() + if (device == null) { + emitError(-2, "no connected device") + running.set(false); return + } + val file = File(firmwareFilePath) + if (!file.isFile || file.length() <= 0) { + emitError(-3, "firmware file invalid: $firmwareFilePath") + running.set(false); return + } + emitState(State.INQUIRING, 0, file.length()) + job = scope.launch { runFlow(device, file, blockSize, fileFlagBytes) } + } + + fun cancel() { + job?.cancel() + job = null + running.set(false) + emitState(State.CANCELLED, -1, 0) + } + + // ───── 内部主流程 ───── + + private suspend fun runFlow( + device: BluetoothDevice, + file: File, + blockSize: Int, + fileFlagBytes: ByteArray, + ) { + try { + val totalSize = file.length() + + // 1. Inquire + val inquire: InquireUpdateResponse = sendCmd( + device, InquireUpdateCmd(InquireUpdateParam(fileFlagBytes)) + ) + if (inquire.canUpdateFlag != 0) { + throw IllegalStateException("device refused update, flag=${inquire.canUpdateFlag}") + } + + // 2. NotifyContentSize + emitState(State.NOTIFYING_SIZE, 0, totalSize) + sendCmd( + device, + NotifyUpdateContentSizeCmd(NotifyUpdateContentSizeParam(totalSize.toInt())) + ) + + // 3. Enter update mode + emitState(State.ENTERING, 0, totalSize) + val enter: EnterUpdateModeResponse = sendCmd(device, EnterUpdateModeCmd()) + if (enter.canUpdateFlag != 0) { + throw IllegalStateException("enter update mode failed, flag=${enter.canUpdateFlag}") + } + + // 4. Block transfer loop + emitState(State.TRANSFERRING, 0, totalSize) + RandomAccessFile(file, "r").use { raf -> + var safety = 0 + while (running.get()) { + val ofs: UpdateFileOffsetResponse = sendCmd(device, GetUpdateFileOffsetCmd()) + val nextOffset = ofs.updateFileFlagOffset.toLong() + val askLen = ofs.updateFileFlagLen.takeIf { it > 0 } ?: blockSize + if (nextOffset >= totalSize) break + + val len = minOf(askLen.toLong(), totalSize - nextOffset).toInt() + val data = ByteArray(len) + raf.seek(nextOffset) + raf.readFully(data) + + val blockCmd = FirmwareUpdateBlockCmd( + FirmwareUpdateBlockParam(nextOffset.toInt(), len) + ) + // 在响应里塞数据:杰理协议要求 APP 把块数据放在 response 里, + // 而不是 param 里(FirmwareUpdateBlockParam 没有 data 字段)。 + // 这里我们走 sendRcspResponse 模式:先把 cmd 当作"已收到的 device 请求" + // 处理,然后回响应。需要 SDK 内部支持;如果直接 sendRcspCommand 不行, + // 真机调试时切到 device-initiated 流(见类注释)。 + val resp = FirmwareUpdateBlockResponse().setFirmwareUpdateBlockData(data) + blockCmd.response = resp + sendBlock(device, blockCmd) + + val written = nextOffset + len + emitState(State.TRANSFERRING, written, totalSize) + safety++ + if (safety > 200_000) throw IllegalStateException("too many block iterations") + delay(0L) // yield + } + if (!running.get()) throw CancellationException() + } + + // 5. Verify + emitState(State.VERIFYING, totalSize, totalSize) + val status: FirmwareUpdateStatusResponse = sendCmd(device, FirmwareUpdateStatusCmd()) + if (status.result != 0) throw IllegalStateException("verify failed result=${status.result}") + + // 6. Exit + Reboot + emitState(State.REBOOTING, totalSize, totalSize) + runCatching { sendCmd(device, ExitUpdateModeCmd()) } + runCatching { sendCmd(device, RebootDeviceCmd(RebootDeviceParam(0))) } + + emitState(State.DONE, totalSize, totalSize) + } catch (ce: CancellationException) { + emitState(State.CANCELLED, -1, file.length()) + } catch (t: Throwable) { + emitError(-100, "ota failed: ${t.message}") + emitState(State.FAILED, -1, file.length()) + } finally { + running.set(false) + } + } + + /** 普通 OTA 控制命令:APP 发,等设备响应 */ + private suspend inline fun sendCmd( + device: BluetoothDevice, + cmd: CommandBase<*, *>, + ): R = suspendCancellableCoroutine { cont -> + btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { + override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { + @Suppress("UNCHECKED_CAST") + val r = resp?.response as? R + if (r != null) cont.resume(r) + else cont.resumeWithException(IllegalStateException("unexpected response")) + } + + override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { + cont.resumeWithException(IllegalStateException("err ${err?.code}: ${err?.message}")) + } + }) + } + + /** 块响应:把已经填好 response 的 cmd 通过 sendRcspResponse 回设备 */ + private suspend fun sendBlock( + device: BluetoothDevice, + cmd: FirmwareUpdateBlockCmd, + ) = suspendCancellableCoroutine { cont -> + try { + // 先尝试 sendRcspResponse(device-initiated 模式) + btManager.sendRcspResponse(device, cmd) + cont.resume(Unit) + } catch (t: Throwable) { + // 兜底:sendRcspCommand + btManager.sendRcspCommand(device, cmd, object : RcspCommandCallback { + override fun onCommandResponse(d: BluetoothDevice?, resp: CommandBase<*, *>?) { cont.resume(Unit) } + override fun onErrCode(d: BluetoothDevice?, err: BaseError?) { + cont.resumeWithException(IllegalStateException("block err ${err?.code}: ${err?.message}")) + } + }) + } + } + + // ───── 事件 ───── + + enum class State { IDLE, INQUIRING, NOTIFYING_SIZE, ENTERING, TRANSFERRING, VERIFYING, REBOOTING, DONE, FAILED, CANCELLED } + + private fun emitState(state: State, sent: Long, total: Long) { + val percent = if (total > 0 && sent in 0..total) (sent * 100.0 / total).toInt() else -1 + dispatcher.send( + mapOf( + "type" to "otaState", + "state" to state.name, + "sent" to sent, + "total" to total, + "percent" to percent, + "tsMs" to System.currentTimeMillis(), + ) + ) + } + + private fun emitError(code: Int, msg: String?) { + dispatcher.send( + mapOf("type" to "otaError", "code" to code, "message" to msg) + ) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt new file mode 100644 index 000000000..ee856b790 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/EventChannelAudioBridge.kt @@ -0,0 +1,105 @@ +package com.jielihome.jielihome.feature.translation + +import com.jielihome.jielihome.bridge.EventDispatcher + +/** + * 默认桥实现:采集帧通过 EventChannel 推到 Dart(base64 PCM), + * Dart 把 PCM 喂给外部翻译服务,再通过 MethodChannel 回调 [TranslationFeature.feedTranslatedAudio]。 + * + * 16kHz / 16bit / 单声道 PCM 流量约 32KB/s,base64 后 ~43KB/s,跨通道开销可控。 + * 若宿主想完全 native 直连翻译服务,可实现自己的 [TranslationAudioBridge] 并通过 + * [TranslationFeature.setBridge] 替换。 + */ +class EventChannelAudioBridge( + private val dispatcher: EventDispatcher, + /** 由 TranslationFeature 注入:把 PCM 真正塞回当前 ModeHandler */ + var injector: TranslationAudioInjector? = null, +) : TranslationAudioBridge { + + override fun emitAudioFrame( + modeId: Int, + streamId: String, + pcm: ByteArray, + format: AudioFormat, + seq: Long, + tsMs: Long, + isFinal: Boolean, + ) { + // EventChannel 原生支持 byte[] → Uint8List,直接传字节流,避免 base64 33% 体积膨胀。 + dispatcher.send( + mapOf( + "type" to "translationAudio", + "modeId" to modeId, + "streamId" to streamId, + "sampleRate" to format.sampleRate, + "channels" to format.channels, + "bitsPerSample" to format.bitsPerSample, + "seq" to seq, + "tsMs" to tsMs, + "final" to isFinal, + "pcm" to pcm, + ) + ) + } + + override fun emitTranslationResult( + modeId: Int, + srcLang: String?, + srcText: String?, + destLang: String?, + destText: String?, + requestId: String?, + ) { + dispatcher.send( + mapOf( + "type" to "translationResult", + "modeId" to modeId, + "srcLang" to srcLang, + "srcText" to srcText, + "destLang" to destLang, + "destText" to destText, + "requestId" to requestId, + ) + ) + } + + override fun emitLog(modeId: Int, content: String) { + dispatcher.send( + mapOf("type" to "translationLog", "modeId" to modeId, "content" to content) + ) + } + + override fun emitError(modeId: Int, code: Int, message: String?) { + dispatcher.send( + mapOf( + "type" to "translationError", + "modeId" to modeId, + "code" to code, + "message" to message + ) + ) + } + + override fun feedTranslatedAudio( + modeId: Int, + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + return injector?.inject(modeId, outputStreamId, pcm, format, isFinal) ?: false + } +} + +/** + * 内部接口:TranslationFeature 决定当前 ModeHandler 是谁,由它消费 PCM。 + */ +fun interface TranslationAudioInjector { + fun inject( + modeId: Int, + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt new file mode 100644 index 000000000..444324e92 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/JieliCallTranslationPort.kt @@ -0,0 +1,210 @@ +package com.jielihome.jielihome.feature.translation + +import android.util.Log +import com.aiagent.device_plugin_interface.CallAudioCodec +import com.aiagent.device_plugin_interface.CallAudioFormat +import com.aiagent.device_plugin_interface.CallAudioFrame +import com.aiagent.device_plugin_interface.CallTranslationError +import com.aiagent.device_plugin_interface.CallTranslationLeg +import com.aiagent.device_plugin_interface.DeviceCallTranslationPort +import com.aiagent.device_plugin_interface.TranslatedAudioFrame +import com.jielihome.jielihome.core.JieliHomeServer +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * `DeviceCallTranslationPort` 的杰理实现。 + * + * 当前阶段策略:**只支持 PCM_S16LE / 16kHz / mono / 20ms**。 + * 新版 SDK 已经在 native 内部把耳机端 OPUS 解码成 PCM 上推(见 [TranslationFeature] + * 的 `mode` handlers),上层零编解码即可贯通。OPUS 直通模式后续按需补。 + * + * # enter / exit 与 SDK bridge 的关系 + * - [enter] 时把 [JieliHomeServer.defaultBridge](EventChannel → Dart 的桥)替换为 + * 本实现内的 capture bridge:拦截 `IN_UPLINK` / `IN_DOWNLINK` 上推的 PCM 帧, + * 翻译成 [CallAudioFrame] 写入 [audioFrames] 流;同时把 `emitError` 接到 + * [errors] 流。 + * - 期间 Dart 的 `device_jieli/event` 不会再收到翻译音频事件——这正是"完全 native + * 直连翻译服务"的预期路径,与 `translate_server` native 编排器配套。 + * - [exit] 时还原默认桥,Dart 端的 EventChannel 恢复正常使用。 + * + * # TTS 回灌 + * 编排器把翻译完成的 PCM 通过 [reportTranslated] 回写: + * - `CallTranslationLeg.UPLINK` → SDK 的 `OUT_UPLINK`(对方耳机听) + * - `CallTranslationLeg.DOWNLINK` → SDK 的 `OUT_DOWNLINK`(本机用户耳机听) + * + * # 线程安全 + * `enter`/`exit`/`reportTranslated` 通过 `synchronized(this)` 互斥; + * SharedFlow `tryEmit` 自身线程安全,capture bridge 可被任意线程回调。 + */ +class JieliCallTranslationPort( + private val server: JieliHomeServer, +) : DeviceCallTranslationPort { + + companion object { + private const val TAG = "JieliCallTransPort" + private val PCM_16K_MONO_20MS = CallAudioFormat.PCM_S16LE_16K_MONO_20MS + } + + private val _audioFrames = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 128, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val _errors = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + @Volatile private var entered = false + @Volatile private var savedBridge: TranslationAudioBridge? = null + + override fun supportedSourceFormats(): Set = setOf(PCM_16K_MONO_20MS) + override fun supportedSinkFormats(): Set = setOf(PCM_16K_MONO_20MS) + + override val audioFrames: Flow = _audioFrames.asSharedFlow() + override val errors: Flow = _errors.asSharedFlow() + + @Synchronized + override fun enter(sourceFormat: CallAudioFormat) { + require(sourceFormat == PCM_16K_MONO_20MS) { + "JieliCallTranslationPort only supports PCM_S16LE/16k/mono/20ms (got $sourceFormat)" + } + check(!entered) { "device.call_translation.busy" } + + val translationFeature = server.translationFeature + + // 调试:统计 SDK 给我们推了多少帧、按 leg 分桶;每秒打一次。 + val countByStream = java.util.concurrent.ConcurrentHashMap() + var lastReportMs = System.currentTimeMillis() + + val captureBridge = object : TranslationAudioBridge { + override fun emitAudioFrame( + modeId: Int, + streamId: String, + pcm: ByteArray, + format: AudioFormat, + seq: Long, + tsMs: Long, + isFinal: Boolean, + ) { + // 调试用:周期性打印 SDK 上推帧统计,确认设备端有没有真出 PCM。 + countByStream.merge(streamId, 1L) { old, _ -> old + 1L } + val now = System.currentTimeMillis() + if (now - lastReportMs >= 1000L) { + Log.d(TAG, "SDK frame stats (last 1s): $countByStream fmt=${format.sampleRate}Hz/${format.channels}ch") + countByStream.clear() + lastReportMs = now + } + val leg = when (streamId) { + TranslationStreams.IN_UPLINK -> CallTranslationLeg.UPLINK + TranslationStreams.IN_DOWNLINK -> CallTranslationLeg.DOWNLINK + else -> return // ignore non-call streams in this mode + } + val frame = CallAudioFrame( + leg = leg, + codec = CallAudioCodec.PCM_S16LE, + sampleRate = format.sampleRate, + channels = format.channels, + bytes = pcm, + sequence = seq, + timestampUs = tsMs * 1000L, + ) + if (!_audioFrames.tryEmit(frame)) { + Log.w(TAG, "audioFrames buffer overflow; dropped seq=$seq leg=$leg") + } + } + + override fun emitTranslationResult( + modeId: Int, + srcLang: String?, srcText: String?, + destLang: String?, destText: String?, + requestId: String?, + ) { + // 字幕在 native 编排器里由 agent 直接出,不依赖这个回调;忽略。 + } + + override fun emitLog(modeId: Int, content: String) { + Log.d(TAG, "translation log: modeId=$modeId $content") + } + + override fun emitError(modeId: Int, code: Int, message: String?) { + _errors.tryEmit( + CallTranslationError( + code = "device.feature_failed", + message = "code=$code msg=${message ?: ""}", + ) + ) + } + + override fun feedTranslatedAudio( + modeId: Int, + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + // capture bridge 不负责回灌——编排器走 [reportTranslated] 直接调 + // server.translationFeature.feedTranslatedAudio。 + return false + } + } + + savedBridge = server.defaultBridge + server.setTranslationBridge(captureBridge) + + val result = translationFeature.start( + TranslationModeIds.MODE_CALL_TRANSLATION, + emptyMap(), + ) + if (result.isFailure) { + // 启动失败:还原桥并抛出 + savedBridge?.let { runCatching { server.setTranslationBridge(it) } } + savedBridge = null + throw IllegalStateException( + "failed to enter MODE_CALL_TRANSLATION: ${result.exceptionOrNull()?.message}", + result.exceptionOrNull(), + ) + } + entered = true + Log.d(TAG, "entered MODE_CALL_TRANSLATION") + } + + @Synchronized + override fun reportTranslated(frame: TranslatedAudioFrame) { + check(entered) { "device.call_translation.not_active" } + require(frame.codec == CallAudioCodec.PCM_S16LE) { + "JieliCallTranslationPort only accepts PCM_S16LE for sink (got ${frame.codec})" + } + val streamId = when (frame.leg) { + CallTranslationLeg.UPLINK -> TranslationStreams.OUT_UPLINK + CallTranslationLeg.DOWNLINK -> TranslationStreams.OUT_DOWNLINK + } + val ok = server.translationFeature.feedTranslatedAudio( + outputStreamId = streamId, + pcm = frame.bytes, + format = AudioFormat( + sampleRate = frame.sampleRate, + channels = frame.channels, + bitsPerSample = 16, + ), + isFinal = frame.isFinal, + ) + if (!ok) { + Log.w(TAG, "feedTranslatedAudio rejected: streamId=$streamId leg=${frame.leg}") + } + } + + @Synchronized + override fun exit() { + if (!entered) return + entered = false + runCatching { server.translationFeature.stop() } + savedBridge?.let { runCatching { server.setTranslationBridge(it) } } + savedBridge = null + Log.d(TAG, "exited call translation") + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt new file mode 100644 index 000000000..8c5665165 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationAudioBridge.kt @@ -0,0 +1,52 @@ +package com.jielihome.jielihome.feature.translation + +/** + * 翻译音频桥:插件 ↔ 外部翻译服务 的双向接口。 + * + * - 「采集面」:ModeHandler 调用 [emitAudioFrame] 把解码后的 PCM 帧推出去。 + * 默认实现走 EventChannel 推到 Dart,由 Dart 转发到外部翻译服务。 + * 也可以由宿主原生代码替换成自定义实现(比如直接走 gRPC)。 + * + * - 「回放面」:[feedTranslatedAudio] 是入口,外部翻译服务调用,把 TTS PCM 注入进来。 + * 交给当前 ModeHandler 编码并回送到耳机/A2DP。 + * + * - 文本结果(字幕)走 [emitTranslationResult],纯展示用,不影响音频路径。 + */ +interface TranslationAudioBridge { + + /** 插件→外部 :推一帧采集到的 PCM 音频 */ + fun emitAudioFrame( + modeId: Int, + streamId: String, + pcm: ByteArray, + format: AudioFormat, + seq: Long, + tsMs: Long, + isFinal: Boolean = false, + ) + + /** 插件→外部 :推翻译文本(可选)。requestId 由调用方贯穿同一段翻译,便于字幕拼接 */ + fun emitTranslationResult( + modeId: Int, + srcLang: String?, + srcText: String?, + destLang: String?, + destText: String?, + requestId: String? = null, + ) + + /** 插件→外部 :日志 */ + fun emitLog(modeId: Int, content: String) + + /** 插件→外部 :错误 */ + fun emitError(modeId: Int, code: Int, message: String?) + + /** 外部→插件 :注入翻译完成的 TTS PCM;插件负责回送到 outputStreamId 对应的物理通道 */ + fun feedTranslatedAudio( + modeId: Int, + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt new file mode 100644 index 000000000..84388868e --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationFeature.kt @@ -0,0 +1,151 @@ +package com.jielihome.jielihome.feature.translation + +import android.content.Context +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.feature.ConnectFeature +import com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler +import com.jielihome.jielihome.feature.translation.mode.CallTranslationModeHandler +import com.jielihome.jielihome.feature.translation.mode.FaceToFaceTranslationModeHandler +import com.jielihome.jielihome.feature.translation.mode.RecordModeHandler +import com.jielihome.jielihome.feature.translation.mode.RecordingTranslationModeHandler +import com.jielihome.jielihome.feature.translation.mode.StereoCallTranslationModeHandler + +/** + * 翻译特性入口。 + * + * # TTS PCM 回灌的两条路径 + * 宿主把外部翻译服务回送的 PCM 注入回插件时,**两条路径同时存在、宿主只需选一条**: + * + * 1) Dart / 远端进程 → MethodChannel `feedTranslatedAudio` → MethodRouter → + * `TranslationFeature.feedTranslatedAudio` → 当前 [TranslationModeHandler.onTranslatedAudio] + * 场景:Flutter UI 调用,或宿主原生代码使用通用入口(不区分桥实现时)。 + * + * 2) 自定义 native bridge → `TranslationAudioBridge.feedTranslatedAudio` → + * (桥内部决定如何下发;默认实现 [EventChannelAudioBridge] 通过 injector 转回 1)。 + * 场景:宿主用 [setBridge] 替换桥,希望把音频生命周期完全留在 native 层管理。 + * + * 推荐:除非有性能/隔离强需求,**统一走路径 1**(更简单、字幕和音频走同一条命令通道)。 + * + * # 立体声通话翻译 + * `MODE_CALL_TRANSLATION` 永远是双流单声道;`MODE_CALL_TRANSLATION_WITH_STEREO` + * 永远走立体声软件分声道方案。是否切到后者由调用方自行判断,可先调 + * [isSupportCallTranslationWithStereo] 探测设备能力。 + */ +class TranslationFeature( + private val context: Context, + private val btManager: JL_BluetoothManager, + private val connectFeature: ConnectFeature, +) { + @Volatile + private var bridge: TranslationAudioBridge? = null + + private var handlers: Map = emptyMap() + + @Volatile + private var current: TranslationModeHandler? = null + + /** 由 JieliHomeServer 在 init 时调一次;后续宿主可调 [setBridge] 替换 */ + fun setBridge(bridge: TranslationAudioBridge) { + this.bridge = bridge + // 重建 handlers,把新桥 + 上下文注入下去 + handlers = mapOf( + TranslationModeIds.MODE_RECORD to RecordModeHandler(context, btManager, bridge), + TranslationModeIds.MODE_RECORDING_TRANSLATION to RecordingTranslationModeHandler(context, btManager, bridge), + TranslationModeIds.MODE_CALL_TRANSLATION to CallTranslationModeHandler(context, btManager, connectFeature, bridge), + TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO to StereoCallTranslationModeHandler(context, btManager, connectFeature, bridge), + TranslationModeIds.MODE_AUDIO_TRANSLATION to AudioTranslationModeHandler(btManager, bridge), + TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION to FaceToFaceTranslationModeHandler(context, btManager, bridge), + ) + } + + fun start(modeId: Int, args: Map): Result { + // 与官方 demo (TranslationViewModel.enterMode) 对齐:MODE_CALL_TRANSLATION + // + OPUS + 设备支持立体声方案时,自动升级到 MODE_CALL_TRANSLATION_WITH_STEREO + // (channel=2)。很多固件版本下 mono CALL_TRANSLATION 通路实际上不出帧, + // 真正出数据的是立体声方案——上下行 mix 成一路立体声 PCM 上推。 + val effectiveModeId = if ( + modeId == TranslationModeIds.MODE_CALL_TRANSLATION && + (args["audioType"] == null || + args["audioType"] == com.jieli.bluetooth.constant.Constants.AUDIO_TYPE_OPUS || + (args["audioType"] as? String)?.lowercase() == "opus") && + isSupportCallTranslationWithStereo(args["address"] as? String) + ) { + android.util.Log.d( + "TranslationFeature", + "MODE_CALL_TRANSLATION + OPUS + stereo-supported → upgrade to MODE_CALL_TRANSLATION_WITH_STEREO", + ) + TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO + } else modeId + + val handler = handlers[effectiveModeId] + ?: return Result.failure(IllegalArgumentException("unknown modeId=$effectiveModeId")) + current?.takeIf { it.isWorking }?.stop() + handler.start(args) + current = handler + return Result.success(Unit) + } + + fun stop() { + current?.takeIf { it.isWorking }?.stop() + current = null + } + + fun isWorking(): Boolean = current?.isWorking == true + fun currentModeId(): Int? = current?.modeId + fun currentInputStreams(): List = current?.inputStreams ?: emptyList() + fun currentOutputStreams(): List = current?.outputStreams ?: emptyList() + + /** + * 翻译服务回送 TTS PCM 的统一入口。 + * 路由到当前活动 ModeHandler 的 onTranslatedAudio。 + */ + fun feedTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + val h = current ?: return false + if (!h.isWorking) return false + if (outputStreamId !in h.outputStreams) return false + return h.onTranslatedAudio(outputStreamId, pcm, format, isFinal) + } + + /** 透传文本结果(字幕);requestId 由调用方贯穿同一段翻译,便于聚合 */ + fun feedTranslationResult( + srcLang: String?, srcText: String?, + destLang: String?, destText: String?, + requestId: String? = null, + ) { + val mid = current?.modeId ?: return + bridge?.emitTranslationResult(mid, srcLang, srcText, destLang, destText, requestId) + } + + /** + * 探测当前已连耳机是否支持立体声通话翻译方案。 + * 内部根据 SDK [com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl.isSupportCallTranslationWithStereo] + * 判断;若设备未连或未握手 RCSP 则返回 false。 + */ + fun isSupportCallTranslationWithStereo(connectedDeviceAddress: String?): Boolean { + val device = connectedDeviceAddress?.let { + com.jieli.bluetooth.utils.BluetoothUtil.getRemoteDevice(it) + } ?: btManager.connectedDevice ?: return false + val tmp = com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl( + btManager, + com.jielihome.jielihome.feature.translation.runtime.NoOpAITranslationApi(), + device, + ) + return runCatching { tmp.isSupportCallTranslationWithStereo }.getOrDefault(false) + .also { runCatching { tmp.destroy() } } + } + + /** + * 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅在该模式生效)。 + */ + fun feedAudioFilePcm(pcm: ByteArray, sampleRate: Int = 16000): Boolean { + val h = current as? com.jielihome.jielihome.feature.translation.mode.AudioTranslationModeHandler + ?: return false + h.feedFilePcm(pcm, sampleRate) + return true + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt new file mode 100644 index 000000000..f8cae9803 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationModeHandler.kt @@ -0,0 +1,67 @@ +package com.jielihome.jielihome.feature.translation + +import com.jieli.bluetooth.impl.JL_BluetoothManager + +/** + * 翻译模式处理器统一接口。每种模式独立实现: + * - 声明自己消费哪些「输入流」(采集面)和产出哪些「输出流」(回放面) + * - 实现 start:启动 SDK 端音频流(进入翻译模式 / 起手机麦),把解码后的 PCM 通过 bridge 推出 + * - 实现 stop:关闭音频流,释放编解码器 + * - 实现 onTranslatedAudio:接到外部翻译服务回送的 PCM,按 outputStreamId 路由 + */ +interface TranslationModeHandler { + val modeId: Int + val inputStreams: List + val outputStreams: List + + fun start(args: Map) + fun stop() + val isWorking: Boolean + + /** 外部翻译服务回送的 PCM 注入点,由 TranslationFeature 路由到当前活动 handler */ + fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean +} + +abstract class BaseTranslationModeHandler( + protected val btManager: JL_BluetoothManager, + protected val bridge: TranslationAudioBridge, +) : TranslationModeHandler { + + @Volatile + protected var working: Boolean = false + + override val isWorking: Boolean get() = working + + /** 把采集到的一帧 PCM 推给外部翻译服务 */ + protected fun pushFrame( + streamId: String, + pcm: ByteArray, + format: AudioFormat = AudioFormat(), + seq: Long = System.nanoTime(), + isFinal: Boolean = false, + ) { + bridge.emitAudioFrame(modeId, streamId, pcm, format, seq, System.currentTimeMillis(), isFinal) + } + + protected fun emitLog(content: String) = bridge.emitLog(modeId, content) + protected fun emitError(code: Int, msg: String?) = bridge.emitError(modeId, code, msg) +} + +/** + * 与 SDK [com.jieli.bluetooth.bean.translation.TranslationMode] 完全一致的取值, + * 方便直接拿插件 modeId 去构造 SDK 的 TranslationMode。 + */ +object TranslationModeIds { + const val MODE_IDLE = 0 + const val MODE_RECORD = 1 + const val MODE_RECORDING_TRANSLATION = 2 + const val MODE_CALL_TRANSLATION = 3 + const val MODE_AUDIO_TRANSLATION = 4 + const val MODE_FACE_TO_FACE_TRANSLATION = 5 + const val MODE_CALL_TRANSLATION_WITH_STEREO = 6 +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt new file mode 100644 index 000000000..96d6a07c4 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/TranslationStreams.kt @@ -0,0 +1,27 @@ +package com.jielihome.jielihome.feature.translation + +/** + * 翻译流逻辑 ID 常量。 + * 「输入流」= 插件解码后推给翻译服务(设备麦克风、eSCO 上行/下行、本地音频文件等)。 + * 「输出流」= 翻译服务返回 TTS PCM 后,插件回送的目标(耳机扬声器、A2DP 本地、对端通话上行等)。 + */ +object TranslationStreams { + // 输入流:插件解码后推给翻译服务的音频 + const val IN_MIC = "in.mic" + const val IN_UPLINK = "in.uplink" // 通话上行:本机用户 + const val IN_DOWNLINK = "in.downlink" // 通话下行:对端 + const val IN_AUDIO_FILE = "in.audioFile" // 本地音频文件 + + // 输出流:翻译服务回送 TTS 后插件应送达的目的地 + const val OUT_SPEAKER = "out.speaker" + const val OUT_UPLINK = "out.uplink" // 译文给对端听 + const val OUT_DOWNLINK = "out.downlink" // 译文给本机用户听 + const val OUT_LOCAL_PLAYBACK = "out.localPlayback" +} + +/** 一帧 PCM 音频规格 */ +data class AudioFormat( + val sampleRate: Int = 16000, + val channels: Int = 1, + val bitsPerSample: Int = 16, +) diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt new file mode 100644 index 000000000..8eace3eca --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/AudioTranslationModeHandler.kt @@ -0,0 +1,61 @@ +package com.jielihome.jielihome.feature.translation.mode + +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.audio.LocalPlayer +import com.jielihome.jielihome.feature.translation.AudioFormat +import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationModeIds +import com.jielihome.jielihome.feature.translation.TranslationStreams + +/** + * MODE_AUDIO_TRANSLATION —— 音视频翻译。 + * 输入:本地音频文件(由调用方自行解码并通过 [feedFilePcm] 灌进来,避免插件依赖各种封装格式解析) + * 输出:本机扬声器(A2DP),不回耳机 + */ +class AudioTranslationModeHandler( + btManager: JL_BluetoothManager, + bridge: TranslationAudioBridge, +) : BaseTranslationModeHandler(btManager, bridge) { + + override val modeId = TranslationModeIds.MODE_AUDIO_TRANSLATION + override val inputStreams = listOf(TranslationStreams.IN_AUDIO_FILE) + override val outputStreams = listOf(TranslationStreams.OUT_LOCAL_PLAYBACK) + + private var player: LocalPlayer? = null + + override fun start(args: Map) { + if (working) return + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + val channels = (args["channels"] as? Int) ?: 1 + player = LocalPlayer(sampleRate, channels).also { it.start() } + working = true + emitLog("AudioTranslation start sampleRate=$sampleRate channels=$channels") + } + + /** 调用方把解码出的 PCM 灌进来推给翻译服务 */ + @Suppress("unused") + fun feedFilePcm(pcm: ByteArray, sampleRate: Int = 16000) { + if (!working) return + pushFrame(TranslationStreams.IN_AUDIO_FILE, pcm, AudioFormat(sampleRate, 1, 16)) + } + + override fun stop() { + if (!working) return + player?.stop() + player = null + working = false + emitLog("AudioTranslation stop") + } + + override fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + if (outputStreamId != TranslationStreams.OUT_LOCAL_PLAYBACK) return false + player?.feed(pcm) + return true + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt new file mode 100644 index 000000000..087663f3c --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/CallTranslationModeHandler.kt @@ -0,0 +1,138 @@ +package com.jielihome.jielihome.feature.translation.mode + +import android.content.Context +import com.jieli.bluetooth.bean.translation.AudioData +import com.jieli.bluetooth.bean.translation.TranslationMode +import com.jieli.bluetooth.constant.Constants +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.feature.ConnectFeature +import com.jielihome.jielihome.feature.translation.AudioFormat +import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationModeIds +import com.jielihome.jielihome.feature.translation.TranslationStreams +import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime +import java.io.File + +/** + * MODE_CALL_TRANSLATION —— 通话翻译,eSCO 上下行各一路单声道。 + * 输入:uplink(本机用户)+ downlink(对端) + * 输出:uplink(TTS 给对端听)+ downlink(TTS 给本机用户听) + * + * 实现:通过 [RcspTranslationRuntime] 让耳机进入 SDK 翻译模式, + * OPUS 流式解码后按 source 分流推到 bridge; + * 回送 TTS PCM 时编码 OPUS 后通过同一 runtime 写回耳机。 + */ +class CallTranslationModeHandler( + private val context: Context, + btManager: JL_BluetoothManager, + private val connectFeature: ConnectFeature, + bridge: TranslationAudioBridge, +) : BaseTranslationModeHandler(btManager, bridge) { + + override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION + override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) + override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) + + private var runtime: RcspTranslationRuntime? = null + + override fun start(args: Map) { + if (working) return + val address = args["address"] as? String + ?: connectFeature.connectedDevice()?.address + val device = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() + if (device == null) { + emitError(-200, "no connected device; pass args.address or connect first") + return + } + + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS + // Recording strategy 选择: + // STRATEGY_DEVICE_AUTO_RECORDING(2) —— 耳机自动检测真实 SCO 通话才开始录, + // 手机端没在打电话时耳机不会上推 PCM,本场景(双向 AI 通话翻译)行不通。 + // STRATEGY_DEVICE_ALWAYS_RECORDING(1) —— 一进入翻译模式就持续上推双声道 + // PCM,正是 AI 通话翻译需要的"始终开麦"语义。 + // 允许通过 args["strategy"] 覆盖(数值或字符串 "auto"/"always"/"custom")。 + val strategy = parseStrategy(args["strategy"]) + ?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING + val sdkMode = TranslationMode( + TranslationMode.MODE_CALL_TRANSLATION, + audioType, + 1, // 单声道 + sampleRate, + ).setRecordingStrategy(strategy) + + val rt = RcspTranslationRuntime( + btManager = btManager, + device = device, + mode = sdkMode, + tempDir = File(context.cacheDir, "jieli_translation_tts"), + onPcm = { source, pcm -> + val streamId = when (source) { + AudioData.SOURCE_E_SCO_UP_LINK -> TranslationStreams.IN_UPLINK + AudioData.SOURCE_E_SCO_DOWN_LINK -> TranslationStreams.IN_DOWNLINK + else -> return@RcspTranslationRuntime + } + pushFrame(streamId, pcm, AudioFormat(sampleRate, 1, 16)) + }, + onError = { code, msg -> emitError(code, msg) } + ) + rt.start().fold( + { + runtime = rt + working = true + emitLog("CallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") + }, + { emitError(-201, it.message); rt.stop() }, + ) + } + + private fun parseStrategy(raw: Any?): Int? = when (raw) { + null -> null + is Int -> raw + is Number -> raw.toInt() + is String -> when (raw.lowercase()) { + "custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING + "always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING + "auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING + else -> raw.toIntOrNull() + } + else -> null + } + + private fun parseAudioType(raw: Any?): Int? = when (raw) { + null -> null + is Int -> raw + is Number -> raw.toInt() + is String -> when (raw.lowercase()) { + "opus" -> Constants.AUDIO_TYPE_OPUS + "pcm" -> Constants.AUDIO_TYPE_PCM + "speex" -> Constants.AUDIO_TYPE_SPEEX + "msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC + "jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 + else -> raw.toIntOrNull() + } + else -> null + } + + override fun stop() { + if (!working) return + runtime?.stop() + runtime = null + working = false + emitLog("CallTranslation stop") + } + + override fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + if (outputStreamId != TranslationStreams.OUT_UPLINK && + outputStreamId != TranslationStreams.OUT_DOWNLINK) return false + return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt new file mode 100644 index 000000000..04624dc7a --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/FaceToFaceTranslationModeHandler.kt @@ -0,0 +1,63 @@ +package com.jielihome.jielihome.feature.translation.mode + +import android.content.Context +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.audio.LocalPlayer +import com.jielihome.jielihome.audio.PhoneMicCapture +import com.jielihome.jielihome.feature.translation.AudioFormat +import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationModeIds +import com.jielihome.jielihome.feature.translation.TranslationStreams + +/** + * MODE_FACE_TO_FACE_TRANSLATION —— 面对面翻译。 + * 输入:手机麦 + * 输出:本机扬声器(自带的 LocalPlayer,A2DP/外放都行) + */ +class FaceToFaceTranslationModeHandler( + private val context: Context, + btManager: JL_BluetoothManager, + bridge: TranslationAudioBridge, +) : BaseTranslationModeHandler(btManager, bridge) { + + override val modeId = TranslationModeIds.MODE_FACE_TO_FACE_TRANSLATION + override val inputStreams = listOf(TranslationStreams.IN_MIC) + override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) + + private var mic: PhoneMicCapture? = null + private var player: LocalPlayer? = null + + override fun start(args: Map) { + if (working) return + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + mic = PhoneMicCapture( + context, sampleRate, + onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, + onError = { code, msg -> emitError(code, msg) }, + ) + if (mic?.start() != true) return + player = LocalPlayer(sampleRate, 1).also { it.start() } + working = true + emitLog("FaceToFaceTranslation start") + } + + override fun stop() { + if (!working) return + mic?.stop(); mic = null + player?.stop(); player = null + working = false + emitLog("FaceToFaceTranslation stop") + } + + override fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false + player?.feed(pcm) + return true + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt new file mode 100644 index 000000000..8bbdedec1 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordModeHandler.kt @@ -0,0 +1,64 @@ +package com.jielihome.jielihome.feature.translation.mode + +import android.content.Context +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.audio.PhoneMicCapture +import com.jielihome.jielihome.feature.translation.AudioFormat +import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationModeIds +import com.jielihome.jielihome.feature.translation.TranslationStreams + +/** + * MODE_RECORD —— 单向录音翻译。 + * 输入:手机麦 → 推到外部 + * 输出:speaker ← 外部回送 TTS PCM;当前不依赖耳机回送,直接交给宿主自行播放(可选)。 + * + * 注:本模式完全不动 RCSP,只用手机麦。耳机端不需要进入翻译模式。 + */ +class RecordModeHandler( + private val context: Context, + btManager: JL_BluetoothManager, + bridge: TranslationAudioBridge, +) : BaseTranslationModeHandler(btManager, bridge) { + + override val modeId = TranslationModeIds.MODE_RECORD + override val inputStreams = listOf(TranslationStreams.IN_MIC) + override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) + + private var mic: PhoneMicCapture? = null + + override fun start(args: Map) { + if (working) return + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + val frameMs = (args["frameDurationMs"] as? Int) ?: 20 + mic = PhoneMicCapture( + context, sampleRate, frameMs, + onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, + onError = { code, msg -> emitError(code, msg) }, + ) + if (mic?.start() != true) return + working = true + emitLog("RecordMode start sampleRate=$sampleRate") + } + + override fun stop() { + if (!working) return + mic?.stop() + mic = null + working = false + emitLog("RecordMode stop") + } + + override fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false + // RECORD 模式下耳机不参与回送;如果宿主想播给用户听,自己用 AudioTrack 播即可。 + emitLog("recv tts pcm=${pcm.size}B final=$isFinal (host-side playback)") + return true + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt new file mode 100644 index 000000000..e5c44f11a --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/RecordingTranslationModeHandler.kt @@ -0,0 +1,59 @@ +package com.jielihome.jielihome.feature.translation.mode + +import android.content.Context +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.audio.PhoneMicCapture +import com.jielihome.jielihome.feature.translation.AudioFormat +import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationModeIds +import com.jielihome.jielihome.feature.translation.TranslationStreams + +/** + * MODE_RECORDING_TRANSLATION —— 流式录音翻译。等同于 RecordMode 的实时版,差别只在语义: + * 调用方实现实时 STT/翻译流水线、不缓存整段录音再发。 + */ +class RecordingTranslationModeHandler( + private val context: Context, + btManager: JL_BluetoothManager, + bridge: TranslationAudioBridge, +) : BaseTranslationModeHandler(btManager, bridge) { + + override val modeId = TranslationModeIds.MODE_RECORDING_TRANSLATION + override val inputStreams = listOf(TranslationStreams.IN_MIC) + override val outputStreams = listOf(TranslationStreams.OUT_SPEAKER) + + private var mic: PhoneMicCapture? = null + + override fun start(args: Map) { + if (working) return + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + mic = PhoneMicCapture( + context, sampleRate, + onFrame = { pcm -> pushFrame(TranslationStreams.IN_MIC, pcm, AudioFormat(sampleRate, 1, 16)) }, + onError = { code, msg -> emitError(code, msg) }, + ) + if (mic?.start() != true) return + working = true + emitLog("RecordingTranslation start sampleRate=$sampleRate") + } + + override fun stop() { + if (!working) return + mic?.stop() + mic = null + working = false + emitLog("RecordingTranslation stop") + } + + override fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + if (outputStreamId != TranslationStreams.OUT_SPEAKER) return false + emitLog("recv tts ${pcm.size}B final=$isFinal") + return true + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt new file mode 100644 index 000000000..2bafa2e3b --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/mode/StereoCallTranslationModeHandler.kt @@ -0,0 +1,128 @@ +package com.jielihome.jielihome.feature.translation.mode + +import android.content.Context +import com.jieli.bluetooth.bean.translation.AudioData +import com.jieli.bluetooth.bean.translation.TranslationMode +import com.jieli.bluetooth.constant.Constants +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jielihome.jielihome.audio.PcmKit +import com.jielihome.jielihome.feature.ConnectFeature +import com.jielihome.jielihome.feature.translation.AudioFormat +import com.jielihome.jielihome.feature.translation.BaseTranslationModeHandler +import com.jielihome.jielihome.feature.translation.TranslationAudioBridge +import com.jielihome.jielihome.feature.translation.TranslationModeIds +import com.jielihome.jielihome.feature.translation.TranslationStreams +import com.jielihome.jielihome.feature.translation.runtime.RcspTranslationRuntime +import java.io.File + +/** + * MODE_CALL_TRANSLATION_WITH_STEREO —— 立体声通话翻译。 + * SDK 上来一路立体声 PCM(左=上行,右=下行),软件分离后等价于普通通话翻译两路。 + */ +class StereoCallTranslationModeHandler( + private val context: Context, + btManager: JL_BluetoothManager, + private val connectFeature: ConnectFeature, + bridge: TranslationAudioBridge, +) : BaseTranslationModeHandler(btManager, bridge) { + + override val modeId = TranslationModeIds.MODE_CALL_TRANSLATION_WITH_STEREO + override val inputStreams = listOf(TranslationStreams.IN_UPLINK, TranslationStreams.IN_DOWNLINK) + override val outputStreams = listOf(TranslationStreams.OUT_UPLINK, TranslationStreams.OUT_DOWNLINK) + + private var runtime: RcspTranslationRuntime? = null + + override fun start(args: Map) { + if (working) return + val address = args["address"] as? String + ?: connectFeature.connectedDevice()?.address + val device = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() + if (device == null) { + emitError(-200, "no connected device") + return + } + val sampleRate = (args["sampleRate"] as? Int) ?: 16000 + val audioType = parseAudioType(args["audioType"]) ?: Constants.AUDIO_TYPE_OPUS + // 与 demo 对齐:默认 ALWAYS_RECORDING,进 mode 即持续上推 PCM; + // AUTO 会在没有真实通话事件时静默不出帧。 + val strategy = parseStrategy(args["strategy"]) + ?: TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING + val sdkMode = TranslationMode( + TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO, + audioType, + 2, // 立体声 + sampleRate, + ).setRecordingStrategy(strategy) + + val rt = RcspTranslationRuntime( + btManager = btManager, + device = device, + mode = sdkMode, + tempDir = File(context.cacheDir, "jieli_translation_tts"), + onPcm = { source, stereoPcm -> + if (source != AudioData.SOURCE_E_SCO_MIX) return@RcspTranslationRuntime + val (left, right) = PcmKit.splitStereo16(stereoPcm) + val fmt = AudioFormat(sampleRate, 1, 16) + pushFrame(TranslationStreams.IN_UPLINK, left, fmt) + pushFrame(TranslationStreams.IN_DOWNLINK, right, fmt) + }, + onError = { code, msg -> emitError(code, msg) } + ) + rt.start().fold( + { + runtime = rt + working = true + emitLog("StereoCallTranslation start device=${device.address} audioType=$audioType sampleRate=$sampleRate") + }, + { emitError(-201, it.message); rt.stop() }, + ) + } + + private fun parseStrategy(raw: Any?): Int? = when (raw) { + null -> null + is Int -> raw + is Number -> raw.toInt() + is String -> when (raw.lowercase()) { + "custom" -> TranslationMode.STRATEGY_CUSTOM_RECORDING + "always" -> TranslationMode.STRATEGY_DEVICE_ALWAYS_RECORDING + "auto" -> TranslationMode.STRATEGY_DEVICE_AUTO_RECORDING + else -> raw.toIntOrNull() + } + else -> null + } + + private fun parseAudioType(raw: Any?): Int? = when (raw) { + null -> null + is Int -> raw + is Number -> raw.toInt() + is String -> when (raw.lowercase()) { + "opus" -> Constants.AUDIO_TYPE_OPUS + "pcm" -> Constants.AUDIO_TYPE_PCM + "speex" -> Constants.AUDIO_TYPE_SPEEX + "msbc", "m_sbc" -> Constants.AUDIO_TYPE_M_SBC + "jla_v2", "jlav2" -> Constants.AUDIO_TYPE_JLA_V2 + else -> raw.toIntOrNull() + } + else -> null + } + + override fun stop() { + if (!working) return + runtime?.stop() + runtime = null + working = false + emitLog("StereoCallTranslation stop") + } + + override fun onTranslatedAudio( + outputStreamId: String, + pcm: ByteArray, + format: AudioFormat, + isFinal: Boolean, + ): Boolean { + if (outputStreamId != TranslationStreams.OUT_UPLINK && + outputStreamId != TranslationStreams.OUT_DOWNLINK) return false + return runtime?.feedTtsPcm(outputStreamId, pcm, isFinal) ?: false + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt new file mode 100644 index 000000000..448c4deed --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/NoOpAITranslationApi.kt @@ -0,0 +1,17 @@ +package com.jielihome.jielihome.feature.translation.runtime + +import com.jieli.bluetooth.bean.translation.AudioData +import com.jieli.bluetooth.bean.translation.TranslationMode +import com.jieli.bluetooth.interfaces.rcsp.translation.AITranslationCallback +import com.jieli.bluetooth.interfaces.rcsp.translation.IAITranslationApi + +/** + * SDK 的 [TranslationImpl] 构造函数要求一个 [IAITranslationApi]。 + * 我们走的是「外部翻译服务」路线,SDK 自带的 AI 流程不启用,所以这里给个空实现。 + */ +internal class NoOpAITranslationApi : IAITranslationApi { + override fun isWorking(): Boolean = false + override fun startTranslating(mode: TranslationMode, cb: AITranslationCallback) { /* no-op */ } + override fun stopTranslating() { /* no-op */ } + override fun writeAudio(data: AudioData) { /* no-op */ } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt new file mode 100644 index 000000000..fca4c751a --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/translation/runtime/RcspTranslationRuntime.kt @@ -0,0 +1,470 @@ +package com.jielihome.jielihome.feature.translation.runtime + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.translation.AudioData +import com.jieli.bluetooth.bean.translation.TranslationMode +import com.jieli.bluetooth.constant.Constants +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.impl.rcsp.translation.TranslationImpl +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback +import com.jieli.bluetooth.interfaces.rcsp.translation.TranslationCallback +import android.util.Log +import com.jielihome.jielihome.audio.OpusStreamDecoder +import com.jielihome.jielihome.feature.translation.TranslationStreams +import com.jieli.jl_audio_decode.callback.OnStateCallback +import com.jieli.jl_audio_decode.opus.OpusManager +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.ArrayDeque +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * RCSP 翻译模式运行时。 + * + * audioType: + * - [Constants.AUDIO_TYPE_OPUS](默认):上行 OPUS → 解码 PCM;下行 PCM → **整段** 编码 OPUS 写回 + * - [Constants.AUDIO_TYPE_PCM]:上下行直接走 PCM,不经编解码 + * + * # TTS 回灌策略(与官方 demo `MachineTranslation.tryToTTS` / `OpusHelper.encodeFile` 对齐) + * + * 之前是流式 [com.jielihome.jielihome.audio.OpusStreamEncoder]:每出一帧 OPUS 立即包成 + * AudioData 调 [TranslationImpl.writeAudioData],导致耳机端 RCSP 把每帧当成"独立的一段 + * TTS 起点",解码器频繁 reset → 杂音 / 断续。 + * + * 现在按 demo:[feedTtsPcm] 把 PCM 累积到 per-leg 缓冲;上层在每段 utterance 末尾把 + * `isFinal=true` 透传过来;runtime 这一刻: + * 1. 把累积 PCM 写到临时 .pcm 文件; + * 2. `OpusManager.encodeFile(pcmFile, opusFile, callback)` 离线整段编码(带 head 的默认 OpusOption); + * 3. 整段读出 → **一个** [AudioData] → [WriteScheduler] 单次入队下发。 + * + * # 文件命名 + * 临时文件位于 [tempDir],按 `tts__.` 命名;编码完成异步删除。 + * + * # source 字段写回方向 + * - 通话翻译给「对端听」 → AudioData.source = SOURCE_E_SCO_UP_LINK + * - 通话翻译给「本机听」 → AudioData.source = SOURCE_E_SCO_DOWN_LINK + * - 录音/音视频/面对面 → AudioData.source = SOURCE_PHONE_MIC(SDK 按 mode 自分发) + */ +class RcspTranslationRuntime( + private val btManager: JL_BluetoothManager, + private val device: BluetoothDevice, + private val mode: TranslationMode, + private val tempDir: File, + /** 解码(或直传 PCM)后的音频上行;source 为 SDK 原值 */ + private val onPcm: (source: Int, pcm: ByteArray) -> Unit, + private val onError: (code: Int, msg: String?) -> Unit, + private val opusPacketSize: Int = if (mode.channel == 2) 80 else 200, +) { + + private val translationImpl = TranslationImpl(btManager, NoOpAITranslationApi(), device) + private val isPcmMode = mode.audioType == Constants.AUDIO_TYPE_PCM + private val sampleRateHz = mode.sampleRate.takeIf { it > 0 } ?: 16000 + + /** 入栈解码器:仅 OPUS 模式下创建 */ + private val upDecoder: OpusStreamDecoder? = if (isPcmMode) null else OpusStreamDecoder( + channel = 1, + packetSize = opusPacketSize, + sampleRate = sampleRateHz, + onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_UP_LINK, pcm) }, + onError = { c, m -> onError(c, "upDecoder: $m") }, + ) + + private val downDecoder: OpusStreamDecoder? = + if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION) OpusStreamDecoder( + channel = 1, + packetSize = opusPacketSize, + sampleRate = sampleRateHz, + onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_DOWN_LINK, pcm) }, + onError = { c, m -> onError(c, "downDecoder: $m") }, + ) else null + + private val stereoDecoder: OpusStreamDecoder? = + if (!isPcmMode && mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO) OpusStreamDecoder( + channel = 2, packetSize = 80, + sampleRate = sampleRateHz, + onPcm = { pcm -> onPcm(AudioData.SOURCE_E_SCO_MIX, pcm) }, + onError = { c, m -> onError(c, "stereoDecoder: $m") }, + ) else null + + /** 调试统计:SDK onReceiveAudioData 被回调多少次、按 (source,type) 分桶 */ + @Volatile private var rxCount = 0L + @Volatile private var rxTypeMismatch = 0L + @Volatile private var rxNullPayload = 0L + @Volatile private var rxLastReportMs = 0L + + private val translationCallback = object : TranslationCallback { + override fun onModeChange(d: BluetoothDevice, m: TranslationMode) { + Log.d(TAG, "TranslationCallback.onModeChange: mode=${m.mode} type=${m.audioType} sr=${m.sampleRate} ch=${m.channel}") + } + + override fun onReceiveAudioData(d: BluetoothDevice, data: AudioData) { + rxCount++ + val now = System.currentTimeMillis() + if (now - rxLastReportMs >= 1000L) { + Log.d(TAG, "RX stats (last 1s): total=$rxCount typeMismatch=$rxTypeMismatch nullPayload=$rxNullPayload expect.type=${mode.audioType} last.source=${data.source} last.type=${data.type} payloadSize=${data.audioData?.size ?: 0}") + rxCount = 0; rxTypeMismatch = 0; rxNullPayload = 0; rxLastReportMs = now + } + if (data.type != mode.audioType) { + rxTypeMismatch++ + return + } + val payload = data.audioData + if (payload == null) { + rxNullPayload++ + return + } + if (isPcmMode) { + onPcm(data.source, payload) + return + } + when (data.source) { + AudioData.SOURCE_E_SCO_UP_LINK -> upDecoder?.feedEncoded(payload) + AudioData.SOURCE_E_SCO_DOWN_LINK -> downDecoder?.feedEncoded(payload) + AudioData.SOURCE_E_SCO_MIX -> stereoDecoder?.feedEncoded(payload) + } + } + + override fun onError(d: BluetoothDevice, code: Int, msg: String) { + Log.e(TAG, "TranslationCallback.onError code=$code msg=$msg") + this@RcspTranslationRuntime.onError(code, msg) + } + } + + companion object { + private const val TAG = "RcspTranslationRuntime" + /** 每腿 writeAudioData 缓冲队列上限。整段下发后队列里几乎不会堆积,留 8 个槽足够。 */ + private const val WRITE_QUEUE_LIMIT = 8 + /** 单段 PCM 上限:≈ 60s 16k mono 16bit = 1.92MB,正常 utterance 远低于此值。 */ + private const val PCM_BUFFER_HARD_LIMIT_BYTES = 2 * 1024 * 1024 + /** + * 周期性 flush 间隔:每隔此毫秒数巡检一次 buffer,**只要非空就 flush**。 + * + * 实测:低于 1s(试过 200ms)耳机端 RCSP 解码器会被频繁 reset 直至死机, + * 因此周期上限只能保持 1s。降低段间延迟改走"首段优先 flush"路线(B)。 + */ + private const val PERIODIC_FLUSH_MS = 1_000L + /** + * 首段优先 flush 阈值(路线 B):每个 utterance 的**首段**达到此毫秒数对应字节量 + * 就立即 flush,不等周期。让用户开口到对方听到的延迟尽可能小(< 200ms)。 + * + * 触发后转入周期模式直到 isFinal=true 重置。火山不发 isFinal 的会话里, + * 只有第一句享受首段优先;后续句统一走 [PERIODIC_FLUSH_MS] 周期。这是为了 + * 避免连续讲话被反复识别成"新 utterance"导致每 100ms 切一段、reset 频率失控。 + */ + private const val FIRST_SEGMENT_FLUSH_MS = 100L + } + + /** + * 串行化的 writeAudioData 调度器(每个 source 一个)。 + * + * RCSP/SPP 写入要求:上一帧 [writeAudioData] 的回调返回(成功或失败)之前不要再发, + * 否则 SDK 会拒收新请求并报 "Operation in progress"。这里维护一个 in-flight ≤ 1 + * 的 FIFO 队列,溢出时丢最老的帧(保实时性,不堆积延迟)。 + */ + private class WriteScheduler( + private val tag: String, + private val send: (AudioData, OnRcspActionCallback) -> Unit, + ) { + private val queue = ArrayDeque(WRITE_QUEUE_LIMIT) + private val inFlight = AtomicBoolean(false) + private val lock = Any() + @Volatile private var droppedSinceReport = 0L + @Volatile private var lastReportMs = 0L + + fun enqueue(data: AudioData) { + val toSend: AudioData? = synchronized(lock) { + if (inFlight.compareAndSet(false, true)) { + data + } else { + if (queue.size >= WRITE_QUEUE_LIMIT) { + queue.pollFirst() // 丢最老 + droppedSinceReport++ + val now = System.currentTimeMillis() + if (now - lastReportMs >= 1000L) { + Log.w(TAG, "writeAudioData[$tag] queue full, dropped=$droppedSinceReport (last 1s)") + droppedSinceReport = 0 + lastReportMs = now + } + } + queue.offerLast(data) + null + } + } + if (toSend != null) dispatch(toSend) + } + + private fun dispatch(data: AudioData) { + send(data, object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, ok: Boolean?) = onComplete() + override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) { + val msg = err?.message.orEmpty() + if (!msg.contains("Operation in progress", ignoreCase = true)) { + Log.w(TAG, "writeAudioData[$tag] error: code=${err?.code} msg=$msg") + } + onComplete() + } + }) + } + + private fun onComplete() { + val next: AudioData? = synchronized(lock) { + val n = queue.pollFirst() + if (n == null) inFlight.set(false) + n + } + if (next != null) dispatch(next) + } + + fun clear() { + synchronized(lock) { + queue.clear() + inFlight.set(false) + } + } + } + + private val uplinkWriter = WriteScheduler("uplink") { data, cb -> + translationImpl.writeAudioData(data, cb) + } + private val downlinkWriter = WriteScheduler("downlink") { data, cb -> + translationImpl.writeAudioData(data, cb) + } + private val phoneMicWriter = WriteScheduler("phoneMic") { data, cb -> + translationImpl.writeAudioData(data, cb) + } + /** 兜底:PCM 直传 / 未识别 source 走这条;和 OPUS 编码器互不抢 in-flight 槽位。 */ + private val rawWriter = WriteScheduler("raw") { data, cb -> + translationImpl.writeAudioData(data, cb) + } + + /** + * Per-leg PCM 缓冲。`feedTtsPcm` 累积写入,`isFinal=true` 时整段编码下发后清空。 + * + * key = outputStreamId(OUT_UPLINK / OUT_DOWNLINK / OUT_SPEAKER 等) + */ + private val pcmBuffers = mutableMapOf() + /** 每条 leg 是否仍处于"等待首段触发"状态(路线 B 首段优先 flush 用)。 */ + private val firstSegmentPending = mutableMapOf() + private val bufferLock = Any() + private var encodeSeq = 0L + + /** 周期性 flush 巡检线程;start() 启动,stop() 关闭。 */ + private var flushWatcher: ScheduledExecutorService? = null + + /** 启动前置校验 + 进入翻译模式 */ + fun start(): Result { + if (!translationImpl.isInit) { + return Result.failure(IllegalStateException("RCSP not init for ${device.address}")) + } + if (!translationImpl.isSupportTranslation) { + return Result.failure(IllegalStateException("device does not support translation")) + } + if (mode.mode == TranslationMode.MODE_CALL_TRANSLATION_WITH_STEREO && + !translationImpl.isSupportCallTranslationWithStereo + ) { + return Result.failure(IllegalStateException("device does not support stereo call translation")) + } + if (!tempDir.exists()) tempDir.mkdirs() + + upDecoder?.start() + downDecoder?.start() + stereoDecoder?.start() + + translationImpl.addTranslationCallback(translationCallback) + Log.d(TAG, "enterMode: mode=${mode.mode} type=${mode.audioType} sr=${mode.sampleRate} ch=${mode.channel} strategy=${mode.recordingStrategy} (waiting for onModeChange/onReceiveAudioData)") + translationImpl.enterMode(mode, translationCallback) + + // OPUS 模式启动周期性 flush 巡检:每 [PERIODIC_FLUSH_MS] 切一次缓冲。 + if (!isPcmMode) startPeriodicFlushWatcher() + return Result.success(Unit) + } + + private fun startPeriodicFlushWatcher() { + flushWatcher?.shutdownNow() + val exec = Executors.newSingleThreadScheduledExecutor { r -> + Thread(r, "rcsp-tts-flush").apply { isDaemon = true } + } + flushWatcher = exec + exec.scheduleAtFixedRate( + { runCatching { periodicFlush() } }, + PERIODIC_FLUSH_MS, PERIODIC_FLUSH_MS, TimeUnit.MILLISECONDS, + ) + } + + /** 每秒巡检:每条 leg 的 buffer 只要非空就立即切走 + 编码 + 下发(不依赖任何外部信号)。 */ + private fun periodicFlush() { + val toEncode = mutableListOf>() + synchronized(bufferLock) { + for ((leg, buf) in pcmBuffers) { + if (buf.size() == 0) continue + val source = when (leg) { + TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK + TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK + else -> AudioData.SOURCE_PHONE_MIC + } + val out = buf.toByteArray() + buf.reset() + Log.d(TAG, "periodicFlush: leg=$leg bytes=${out.size}") + toEncode.add(Triple(source, leg, out)) + } + } + // encode 走 async,必须释放锁后做。 + for ((source, leg, pcm) in toEncode) { + encodeAndDispatchAsync(source, leg, pcm) + } + } + + /** + * 把外部翻译服务回送的 PCM 注入回耳机。 + * + * 策略: + * - PCM 模式:每次调用直接当作一段 AudioData 下发(SDK 内部按 blockMtu 分片)。 + * - OPUS 模式:纯时间驱动 flush —— 火山等端到端服务的段尾事件不可靠, + * 不能依赖 isFinal 或字节量阈值。改为: + * a) feedTtsPcm 仅追加 buffer,不主动 flush(除非 isFinal=true); + * b) [PERIODIC_FLUSH_MS] (1s) 巡检线程每秒切走 buffer 并整段编码下发; + * c) `isFinal=true` 仍短路立即 flush(兼容主动信号,不再依赖)。 + * 最大听感延迟 ≤ 1s,且短句 / 没有段尾事件的服务都不会卡死。 + * + * @param outputStreamId 决定 source: + * [TranslationStreams.OUT_UPLINK] / [TranslationStreams.OUT_DOWNLINK] 用于通话翻译; + * 其它(speaker/localPlayback)由 ModeHandler 自己处理,不会落到这里。 + * @param isFinal 本帧是否为当前 utterance 的最后一帧;触发 buffer 残余立即 flush。 + */ + fun feedTtsPcm(outputStreamId: String, pcm: ByteArray, isFinal: Boolean): Boolean { + val source = when (outputStreamId) { + TranslationStreams.OUT_UPLINK -> AudioData.SOURCE_E_SCO_UP_LINK + TranslationStreams.OUT_DOWNLINK -> AudioData.SOURCE_E_SCO_DOWN_LINK + else -> AudioData.SOURCE_PHONE_MIC + } + if (isPcmMode) { + // PCM 直传:每次调用一段 AudioData。`isFinal` 在该模式下不影响下发节奏。 + writeBack(AudioData(source, Constants.AUDIO_TYPE_PCM, pcm)) + return true + } + // OPUS 模式:累积到 buffer,三条 flush 触发链(按优先级): + // 1. isFinal=true → 立即 flush,并把首段优先重置回 true(下一句新算) + // 2. 首段优先(路线 B) → 当前 leg 处于 firstSegmentPending=true 且 buffer + // 累积到 FIRST_SEGMENT_FLUSH_MS 字节量时立即 flush, + // 让"开口到对方听见"的延迟最小化(< 200ms) + // 3. 周期 flush(路线 A) → 由 [PERIODIC_FLUSH_MS] 巡检线程接管 + val pendingFlush: ByteArray? = synchronized(bufferLock) { + val buf = pcmBuffers.getOrPut(outputStreamId) { ByteArrayOutputStream() } + if (pcm.isNotEmpty()) { + if (buf.size() + pcm.size > PCM_BUFFER_HARD_LIMIT_BYTES) { + Log.w(TAG, "feedTtsPcm: leg=$outputStreamId pcm buffer hit hard limit ${PCM_BUFFER_HARD_LIMIT_BYTES}, dropping segment") + buf.reset() + return false + } + buf.write(pcm) + } + // 1) 段尾信号:立即下发尾段并把首段标志重置回 true。 + if (isFinal) { + firstSegmentPending[outputStreamId] = true + val out = buf.toByteArray() + buf.reset() + return@synchronized if (out.isEmpty()) null else out + } + // 2) 首段优先:仅当 leg 处于 pending 状态(首次 utterance 起始或 isFinal 重置后)。 + val pending = firstSegmentPending.getOrDefault(outputStreamId, true) + if (pending) { + val firstFlushBytes = (sampleRateHz * 2 * FIRST_SEGMENT_FLUSH_MS / 1000L).toInt() + if (buf.size() >= firstFlushBytes) { + firstSegmentPending[outputStreamId] = false + val out = buf.toByteArray() + buf.reset() + Log.d(TAG, "firstSegmentFlush: leg=$outputStreamId bytes=${out.size}") + return@synchronized if (out.isEmpty()) null else out + } + } + null + } + if (pendingFlush != null) { + encodeAndDispatchAsync(source, outputStreamId, pendingFlush) + } + return true + } + + private fun encodeAndDispatchAsync(source: Int, leg: String, pcmBytes: ByteArray) { + val seq = synchronized(bufferLock) { ++encodeSeq } + val pcmFile = File(tempDir, "tts_${source}_$seq.pcm") + val opusFile = File(tempDir, "tts_${source}_$seq.opus") + try { + pcmFile.writeBytes(pcmBytes) + } catch (e: Throwable) { + Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq write pcm file failed: ${e.message}") + return + } + // OpusManager.encodeFile(in, out, callback) 默认 OpusOption(带 head),与 demo 一致。 + val encoder = OpusManager() + encoder.encodeFile(pcmFile.absolutePath, opusFile.absolutePath, object : OnStateCallback { + override fun onStart() {} + override fun onComplete(path: String?) { + try { + val opusBytes = if (opusFile.exists()) opusFile.readBytes() else null + if (opusBytes == null || opusBytes.isEmpty()) { + Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq: empty opus output") + } else { + Log.d(TAG, "encodeAndDispatch leg=$leg seq=$seq pcm=${pcmBytes.size}B opus=${opusBytes.size}B") + writeBack(AudioData(source, Constants.AUDIO_TYPE_OPUS, opusBytes)) + } + } finally { + runCatching { encoder.release() } + runCatching { pcmFile.delete() } + runCatching { opusFile.delete() } + } + } + override fun onError(code: Int, message: String?) { + Log.w(TAG, "encodeAndDispatch leg=$leg seq=$seq encodeFile error code=$code msg=$message") + runCatching { encoder.release() } + runCatching { pcmFile.delete() } + runCatching { opusFile.delete() } + onError(code, "encodeFile $leg: $message") + } + }) + } + + fun stop() { + runCatching { flushWatcher?.shutdownNow() } + flushWatcher = null + runCatching { + translationImpl.exitMode(object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, t: Int?) {} + override fun onError(d: BluetoothDevice?, err: com.jieli.bluetooth.bean.base.BaseError?) {} + }) + } + runCatching { translationImpl.removeTranslationCallback(translationCallback) } + runCatching { translationImpl.destroy() } + runCatching { upDecoder?.stop() } + runCatching { downDecoder?.stop() } + runCatching { stereoDecoder?.stop() } + runCatching { + uplinkWriter.clear() + downlinkWriter.clear() + phoneMicWriter.clear() + rawWriter.clear() + } + synchronized(bufferLock) { + pcmBuffers.values.forEach { it.reset() } + pcmBuffers.clear() + firstSegmentPending.clear() + } + } + + /** + * 把 OPUS / PCM 帧入队到对应 source 的 [WriteScheduler],由调度器串行发起 RCSP + * writeAudioData,避免并发写入触发 "Operation in progress"。 + */ + private fun writeBack(data: AudioData) { + val scheduler = when (data.source) { + AudioData.SOURCE_E_SCO_UP_LINK -> uplinkWriter + AudioData.SOURCE_E_SCO_DOWN_LINK -> downlinkWriter + AudioData.SOURCE_PHONE_MIC -> phoneMicWriter + else -> rawWriter + } + scheduler.enqueue(data) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt new file mode 100644 index 000000000..95ff7e4ad --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/feature/voice/SpeechFeature.kt @@ -0,0 +1,196 @@ +package com.jielihome.jielihome.feature.voice + +import android.bluetooth.BluetoothDevice +import com.jieli.bluetooth.bean.base.BaseError +import com.jieli.bluetooth.bean.record.RecordParam +import com.jieli.bluetooth.bean.record.RecordState +import com.jieli.bluetooth.impl.JL_BluetoothManager +import com.jieli.bluetooth.impl.rcsp.record.RecordOpImpl +import com.jieli.bluetooth.interfaces.rcsp.callback.OnRcspActionCallback +import com.jieli.bluetooth.interfaces.rcsp.record.OnRecordStateCallback +import com.jielihome.jielihome.audio.OpusStreamDecoder +import com.jielihome.jielihome.bridge.EventDispatcher +import com.jielihome.jielihome.feature.ConnectFeature + +/** + * 语音助手 / 唤醒功能。 + * + * # 工作模型(重要) + * 唤醒是「**耳机端主动检测 + 主动推**」模式。零等待: + * + * 1. 插件初始化时立即注册一个常驻 [OnRecordStateCallback]([attach]) + * 2. 耳机本地检测到唤醒词 / 用户按下语音键 + * → SDK 直接回调 onStateChange(state=START),**不需要 APP 先发任何指令** + * → 紧接着持续回调 state=WORKING + voiceData(PCM 或 OPUS 帧) + * → 通话结束/取消时回调 state=IDLE + * 3. 插件解码后通过 EventDispatcher 推三类事件给宿主: + * `speechStart` / `speechAudio` / `speechEnd` + * + * # 主动控制(可选) + * - [start]:APP 主动触发语音助手(用户点 UI 的"按住说话"按钮场景) + * - [stop]:APP 主动结束(用户松手 / 超时取消) + * - 这两个调用内部封装了 SDK 的 StartSpeechCmd/StopSpeechCmd。 + * 大部分场景下你不需要调它们——耳机会自动管理。 + */ +class SpeechFeature( + private val btManager: JL_BluetoothManager, + private val connectFeature: ConnectFeature, + private val dispatcher: EventDispatcher, +) { + private val recordOp: RecordOpImpl = RecordOpImpl.getInstance(btManager) + + @Volatile + private var decoder: OpusStreamDecoder? = null + + @Volatile + private var currentParam: RecordParam? = null + + private val recordCallback = OnRecordStateCallback { device, state -> + if (device == null || state == null) return@OnRecordStateCallback + when (state.state) { + RecordState.RECORD_STATE_START -> onStart(device, state) + RecordState.RECORD_STATE_WORKING -> onWorking(device, state) + RecordState.RECORD_STATE_IDLE -> onEnd(device, state) + } + } + + fun attach() { + recordOp.addOnRecordStateCallback(recordCallback) + } + + fun detach() { + recordOp.removeOnRecordStateCallback(recordCallback) + releaseDecoder() + } + + fun isRecording(address: String?): Boolean { + val d = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() ?: return false + return recordOp.isRecording(d) + } + + /** + * APP 主动启动语音助手;通常无需调用(耳机会自动推)。 + * @param voiceType RecordParam.VOICE_TYPE_PCM / SPEEX / OPUS + * @param sampleRate RecordParam.SAMPLE_RATE_8K / 16K(注意取值是 8/16,不是 8000/16000) + * @param vadWay RecordParam.VAD_WAY_DEVICE / SDK + */ + fun start( + address: String?, + voiceType: Int = RecordParam.VOICE_TYPE_OPUS, + sampleRate: Int = RecordParam.SAMPLE_RATE_16K, + vadWay: Int = RecordParam.VAD_WAY_DEVICE, + onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, + ) { + val device = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() + ?: return onResult(false, "no connected device") + val param = RecordParam(voiceType, sampleRate, vadWay) + recordOp.startRecord(device, param, object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } + override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } + }) + } + + fun stop( + address: String?, + reason: Int = RecordState.REASON_NORMAL, + onResult: (ok: Boolean, msg: String?) -> Unit = { _, _ -> }, + ) { + val device = address?.let { connectFeature.deviceByAddress(it) } + ?: connectFeature.connectedDevice() + ?: return onResult(false, "no connected device") + recordOp.stopRecord(device, reason, object : OnRcspActionCallback { + override fun onSuccess(d: BluetoothDevice?, r: Boolean?) { onResult(r == true, null) } + override fun onError(d: BluetoothDevice?, e: BaseError?) { onResult(false, e?.message) } + }) + } + + // ───── 内部回调处理 ───── + + private fun onStart(device: BluetoothDevice, state: RecordState) { + currentParam = state.recordParam + ensureDecoder(state.recordParam) + dispatcher.send( + mapOf( + "type" to "speechStart", + "address" to device.address, + "voiceType" to state.recordParam?.voiceType, + "sampleRate" to (state.recordParam?.sampleRate ?: 16) * 1000, // 8/16 → 8000/16000 + "vadWay" to state.recordParam?.vadWay, + "tsMs" to System.currentTimeMillis(), + ) + ) + } + + private fun onWorking(device: BluetoothDevice, state: RecordState) { + val payload = state.voiceDataBlock ?: state.voiceData ?: return + val type = (state.recordParam ?: currentParam)?.voiceType ?: RecordParam.VOICE_TYPE_OPUS + when (type) { + RecordParam.VOICE_TYPE_OPUS -> decoder?.feedEncoded(payload) ?: emitFrame(device, payload) + RecordParam.VOICE_TYPE_PCM -> emitFrame(device, payload) + RecordParam.VOICE_TYPE_SPEEX -> { + // 当前未挂 speex 解码器;原样透出,由宿主自己解(极少使用) + emitFrame(device, payload, encoding = "speex") + } + } + } + + private fun onEnd(device: BluetoothDevice, state: RecordState) { + releaseDecoder() + dispatcher.send( + mapOf( + "type" to "speechEnd", + "address" to device.address, + "reason" to state.reason, + "message" to state.message, + "tsMs" to System.currentTimeMillis(), + ) + ) + currentParam = null + } + + private fun ensureDecoder(param: RecordParam?) { + if (decoder != null) return + if (param?.voiceType != RecordParam.VOICE_TYPE_OPUS) return + val srKhz = param.sampleRate.takeIf { it > 0 } ?: RecordParam.SAMPLE_RATE_16K + val sampleRateHz = srKhz * 1000 + val packetSize = if (srKhz == RecordParam.SAMPLE_RATE_8K) 50 else 200 + decoder = OpusStreamDecoder( + channel = 1, + packetSize = packetSize, + sampleRate = sampleRateHz, + onPcm = { pcm -> emitFrame(connectFeature.connectedDevice(), pcm) }, + onError = { c, m -> + dispatcher.send( + mapOf("type" to "speechError", "code" to c, "message" to "decoder: $m") + ) + } + ).also { it.start() } + } + + private fun releaseDecoder() { + runCatching { decoder?.stop() } + decoder = null + } + + private fun emitFrame( + device: BluetoothDevice?, + pcm: ByteArray, + encoding: String = "pcm16", + ) { + val sr = (currentParam?.sampleRate ?: RecordParam.SAMPLE_RATE_16K) * 1000 + dispatcher.send( + mapOf( + "type" to "speechAudio", + "address" to device?.address, + "encoding" to encoding, + "sampleRate" to sr, + "channels" to 1, + "bitsPerSample" to 16, + "tsMs" to System.currentTimeMillis(), + "pcm" to pcm, + ) + ) + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt new file mode 100644 index 000000000..fc1291632 --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDevicePlugin.kt @@ -0,0 +1,314 @@ +package com.jielihome.jielihome.integration + +import android.content.Context +import android.util.Log +import com.aiagent.device_plugin_interface.DeviceCapability +import com.aiagent.device_plugin_interface.DeviceConnectOptions +import com.aiagent.device_plugin_interface.DeviceConnectionState +import com.aiagent.device_plugin_interface.DeviceErrorCode +import com.aiagent.device_plugin_interface.DeviceException +import com.aiagent.device_plugin_interface.DevicePluginConfig +import com.aiagent.device_plugin_interface.DevicePluginEvent +import com.aiagent.device_plugin_interface.DevicePluginEventType +import com.aiagent.device_plugin_interface.DeviceScanFilter +import com.aiagent.device_plugin_interface.DiscoveredDevice +import com.aiagent.device_plugin_interface.NativeDevicePlugin +import com.aiagent.device_plugin_interface.NativeDevicePluginRegistry +import com.aiagent.device_plugin_interface.NativeDeviceSession +import com.jielihome.jielihome.api.JieliEventAdapter +import com.jielihome.jielihome.api.JieliEventListener +import com.jielihome.jielihome.core.JieliHomeServer +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * 杰理 [NativeDevicePlugin] 实现 —— 把 [JieliHomeServer] 包成 vendor-agnostic 接口, + * 由 device_manager native ([com.aiagent.device_manager.DefaultNativeDeviceManager]) + * 通过 [NativeDevicePluginRegistry] 创建并管理。 + * + * 注册入口:[register],由 `JielihomePlugin.onAttachedToEngine` 调用一次。 + * + * 注意:本类**不**关闭 [JieliHomeServer] —— server 是进程级单例,可能被 Dart 直连 + * 路径 / translate_server / OTA 等并发使用,本类的 [dispose] 只解订阅事件 + 切断 + * 当前 session,server 自身保留。 + */ +class JieliNativeDevicePlugin( + private val context: Context, +) : NativeDevicePlugin { + + companion object { + private const val TAG = "JieliNativeDevicePlugin" + const val VENDOR_KEY = "jieli" + const val DISPLAY_NAME = "JieLi (杰理)" + + val CAPABILITIES: Set = setOf( + DeviceCapability.SCAN, + DeviceCapability.CONNECT, + DeviceCapability.BOND, + DeviceCapability.BATTERY, + DeviceCapability.CUSTOM_COMMAND, + DeviceCapability.WAKE_WORD, + DeviceCapability.OTA, + DeviceCapability.ON_DEVICE_CALL_TRANSLATION, + DeviceCapability.ON_DEVICE_FACE_TO_FACE_TRANSLATION, + DeviceCapability.ON_DEVICE_RECORDING_TRANSLATION, + ) + + /** + * 把杰理工厂注册到 [NativeDevicePluginRegistry],由 [JielihomePlugin] + * 在 onAttachedToEngine 调一次(FlutterPlugin 生命周期保证它早于 device_manager + * 的 listVendors / useVendor 调用)。 + */ + fun register(appContext: Context) { + NativeDevicePluginRegistry.register( + vendorKey = VENDOR_KEY, + displayName = DISPLAY_NAME, + capabilities = CAPABILITIES, + ) { JieliNativeDevicePlugin(appContext) } + } + } + + override val vendorKey: String = VENDOR_KEY + override val displayName: String = DISPLAY_NAME + override val capabilities: Set = CAPABILITIES + + private val server: JieliHomeServer = JieliHomeServer.get() + + @Volatile private var _initialized = false + @Volatile private var _disposed = false + + @Volatile private var _activeSession: JieliNativeDeviceSession? = null + @Volatile private var _pendingConnect: CompletableFuture? = null + @Volatile private var _pendingConnectAddress: String? = null + + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val eventStream: Flow = _events.asSharedFlow() + + private val listener: JieliEventListener = object : JieliEventAdapter() { + override fun onAdapterStatus(enabled: Boolean, hasBle: Boolean) { + emit(DevicePluginEvent( + type = DevicePluginEventType.BLUETOOTH_STATE_CHANGED, + bluetoothEnabled = enabled, + )) + } + + override fun onScanStatus(ble: Boolean, started: Boolean) { + emit(DevicePluginEvent( + type = if (started) DevicePluginEventType.SCAN_STARTED + else DevicePluginEventType.SCAN_STOPPED, + )) + } + + override fun onDeviceFound(payload: Map) { + emit(DevicePluginEvent( + type = DevicePluginEventType.DEVICE_DISCOVERED, + discovered = DiscoveredDevice( + id = (payload["address"] as? String) ?: "", + name = (payload["name"] as? String) ?: "", + rssi = (payload["rssi"] as? Number)?.toInt(), + vendor = VENDOR_KEY, + metadata = mapOf( + "edrAddr" to payload["edrAddr"], + "deviceType" to payload["deviceType"], + "connectWay" to payload["connectWay"], + ), + ), + )) + } + + override fun onConnectionState(address: String, state: Int) { + val session = _activeSession ?: return + if (session.deviceId != address) return + when (state) { + 2 -> session.setState(DeviceConnectionState.CONNECTING) + 1 -> session.setState(DeviceConnectionState.LINK_CONNECTED) + 0 -> { + session.setState(DeviceConnectionState.DISCONNECTED) + _activeSession = null + val pending = _pendingConnect + if (pending != null && !pending.isDone) { + pending.completeExceptionally( + DeviceException( + DeviceErrorCode.DISCONNECTED_REMOTE, + "disconnected before ready", + ) + ) + _pendingConnect = null + _pendingConnectAddress = null + } + } + } + } + + override fun onRcspInit(address: String, code: Int) { + val session = _activeSession ?: return + if (session.deviceId != address) return + if (code == 0) { + session.setState(DeviceConnectionState.READY) + val pending = _pendingConnect + if (pending != null && _pendingConnectAddress == address && !pending.isDone) { + pending.complete(session) + _pendingConnect = null + _pendingConnectAddress = null + } + } else { + val pending = _pendingConnect + if (pending != null && _pendingConnectAddress == address && !pending.isDone) { + pending.completeExceptionally( + DeviceException( + DeviceErrorCode.HANDSHAKE_FAILED, + "RCSP init failed code=$code", + ) + ) + _pendingConnect = null + _pendingConnectAddress = null + } + } + } + + override fun onBattery(address: String?, level: Int?) { + val session = _activeSession ?: return + if (address != null && session.deviceId != address) return + // 由 session 自己 refresh info 拉一次最新快照 + runCatching { session.refreshInfo() } + } + + override fun onTranslationAudio(payload: Map) { + _activeSession?.emitFeature("jieli.translation.audio", payload) + } + + override fun onTranslationResult(payload: Map) { + _activeSession?.emitFeature("jieli.translation.result", payload) + } + + override fun onTranslationLog(payload: Map) { + _activeSession?.emitFeature("jieli.translation.log", payload) + } + + override fun onTranslationError(payload: Map) { + _activeSession?.emitError( + "device.feature_failed", + "translation: ${payload["code"]} ${payload["message"] ?: ""}", + ) + } + } + + // ─── NativeDevicePlugin 接口 ────────────────────────────────────────── + + override fun initialize(config: DevicePluginConfig) { + checkAlive() + if (_initialized) return + if (!server.initialized) { + server.initialize( + context = context, + multiDevice = (config.extra["multiDevice"] as? Boolean) ?: false, + skipNoNameDev = (config.extra["skipNoNameDev"] as? Boolean) ?: false, + enableLog = (config.extra["enableLog"] as? Boolean) ?: false, + ) + } + server.addEventListener(listener) + _initialized = true + emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_READY)) + } + + override fun startScan(filter: DeviceScanFilter?, timeoutMs: Long?) { + requireInit() + val ms = (timeoutMs ?: 30_000L).toInt() + server.scanFeature.startScan(ms).onFailure { + throw DeviceException("device.scan_failed", it.message, it) + } + } + + override fun stopScan() { + if (!_initialized) return + server.scanFeature.stopScan() + } + + override fun isScanning(): Boolean = + _initialized && server.scanFeature.isScanning() + + override fun bondedDevices(): List { + // 杰理 SDK 没暴露公开的"已配对"接口;不混入系统经典蓝牙列表。 + return emptyList() + } + + override fun connect(deviceId: String, options: DeviceConnectOptions?): NativeDeviceSession { + requireInit() + // 单设备:有旧 session 时先断 + _activeSession?.let { if (it.deviceId != deviceId) runCatching { it.disconnect() } } + + val extra = options?.extra ?: emptyMap() + val session = JieliNativeDeviceSession( + server = server, + deviceId = deviceId, + initialName = (extra["name"] as? String) ?: "", + capabilities = capabilities, + ) + _activeSession = session + + val future = CompletableFuture() + _pendingConnect = future + _pendingConnectAddress = deviceId + + server.connectFeature.connect( + bleAddress = deviceId, + edrAddress = extra["edrAddr"] as? String, + deviceType = (extra["deviceType"] as? Number)?.toInt() ?: 0, + connectWay = (extra["connectWay"] as? Number)?.toInt() ?: 0, + ).onFailure { + future.completeExceptionally( + DeviceException(DeviceErrorCode.CONNECT_FAILED, it.message, it) + ) + _pendingConnect = null + _pendingConnectAddress = null + } + + val timeout = (options?.timeoutMs ?: 15_000L) + return try { + future.get(timeout, TimeUnit.MILLISECONDS) + } catch (e: java.util.concurrent.TimeoutException) { + _pendingConnect = null + _pendingConnectAddress = null + throw DeviceException(DeviceErrorCode.CONNECT_TIMEOUT, + "connect timeout after ${timeout}ms") + } catch (e: java.util.concurrent.ExecutionException) { + throw e.cause ?: e + } + } + + override val activeSession: NativeDeviceSession? + get() = _activeSession?.takeIf { it.state != DeviceConnectionState.DISCONNECTED } + + override fun dispose() { + if (_disposed) return + _disposed = true + runCatching { _activeSession?.disconnect() } + runCatching { server.removeEventListener(listener) } + _activeSession = null + emit(DevicePluginEvent(type = DevicePluginEventType.PLUGIN_DISPOSED)) + } + + // ─── 内部 ────────────────────────────────────────────────────────────── + + private fun checkAlive() { + if (_disposed) throw IllegalStateException("JieliNativeDevicePlugin disposed") + } + + private fun requireInit() { + checkAlive() + if (!_initialized) throw DeviceException(DeviceErrorCode.PLUGIN_NOT_INITIALIZED) + } + + private fun emit(e: DevicePluginEvent) { + if (!_events.tryEmit(e)) Log.w(TAG, "event buffer full; dropped ${e.type}") + } +} diff --git a/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt new file mode 100644 index 000000000..438dfc42f --- /dev/null +++ b/local_plugins/device_jieli/android/src/main/kotlin/com/jielihome/jielihome/integration/JieliNativeDeviceSession.kt @@ -0,0 +1,266 @@ +package com.jielihome.jielihome.integration + +import android.util.Log +import com.aiagent.device_plugin_interface.DeviceCallTranslationPort +import com.aiagent.device_plugin_interface.DeviceCapability +import com.aiagent.device_plugin_interface.DeviceConnectionState +import com.aiagent.device_plugin_interface.DeviceErrorCode +import com.aiagent.device_plugin_interface.DeviceException +import com.aiagent.device_plugin_interface.DeviceFeatureEvent +import com.aiagent.device_plugin_interface.DeviceInfo +import com.aiagent.device_plugin_interface.DeviceSessionEvent +import com.aiagent.device_plugin_interface.DeviceSessionEventType +import com.aiagent.device_plugin_interface.NativeDeviceSession +import com.jielihome.jielihome.core.JieliHomeServer +import com.jielihome.jielihome.feature.translation.TranslationStreams +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * 杰理 [NativeDeviceSession] 实现 —— 包装 [JieliHomeServer] 的连接快照与事件。 + * + * 状态机由 [JieliNativeDevicePlugin] 在 RCSP 事件回调里推动: + * - ConnectionStateEvent.state == CONNECTING(2) → connecting + * - state == OK(1) + RcspInit 未到 → linkConnected + * - RcspInit(success=true) → ready + * - state == DISCONNECT(0) → disconnected + */ +class JieliNativeDeviceSession internal constructor( + private val server: JieliHomeServer, + override val deviceId: String, + initialName: String, + override val capabilities: Set, +) : NativeDeviceSession { + + companion object { + private const val TAG = "JieliNativeSession" + } + + override val vendor: String = "jieli" + + @Volatile private var _state = DeviceConnectionState.CONNECTING + @Volatile private var _info = DeviceInfo( + id = deviceId, name = initialName, vendor = vendor, + ) + @Volatile private var _disposed = false + + override val state: DeviceConnectionState get() = _state + override val info: DeviceInfo get() = _info + + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + override val eventStream: Flow = _events.asSharedFlow() + + // ─── 由 plugin 在事件回调中调用 ───────────────────────────────────────── + + internal fun setState(s: DeviceConnectionState) { + if (_state == s) return + _state = s + emit(DeviceSessionEvent( + type = DeviceSessionEventType.CONNECTION_STATE_CHANGED, + deviceId = deviceId, + connectionState = s, + )) + if (s == DeviceConnectionState.DISCONNECTED) { + _disposed = true + } + } + + internal fun emitFeature(key: String, data: Map) { + emit(DeviceSessionEvent( + type = DeviceSessionEventType.FEATURE, + deviceId = deviceId, + feature = DeviceFeatureEvent(key = key, data = data), + )) + } + + internal fun emitError(code: String, message: String?) { + emit(DeviceSessionEvent( + type = DeviceSessionEventType.ERROR, + deviceId = deviceId, + errorCode = code, + errorMessage = message, + )) + } + + private fun emit(e: DeviceSessionEvent) { + if (!_events.tryEmit(e)) { + Log.w(TAG, "session event buffer full; dropped ${e.type}") + } + } + + private fun applySnapshot(snapshot: Map) { + _info = _info.copyWith( + name = (snapshot["name"] as? String).takeIf { !it.isNullOrEmpty() } + ?: _info.name, + firmwareVersion = (snapshot["versionName"] as? String) + ?: _info.firmwareVersion, + batteryPercent = (snapshot["battery"] as? Number)?.toInt(), + metadata = snapshot, + ) + emit(DeviceSessionEvent( + type = DeviceSessionEventType.DEVICE_INFO_UPDATED, + deviceId = deviceId, + deviceInfo = _info, + )) + } + + // ─── NativeDeviceSession 接口 ────────────────────────────────────────── + + override fun readRssi(): Int { + requireReady() + throw DeviceException( + DeviceErrorCode.NOT_SUPPORTED, + "jieli native bridge does not expose RSSI yet", + ) + } + + override fun readBattery(): Int? { + requireReady() + return server.deviceInfoFeature.snapshot(deviceId) + ?.get("battery") as? Int + } + + override fun refreshInfo(): DeviceInfo { + requireReady() + val snap = server.deviceInfoFeature.snapshot(deviceId) ?: return _info + applySnapshot(snap) + return _info + } + + override fun invokeFeature( + featureKey: String, + args: Map, + ): Map { + requireReady() + return when (featureKey) { + "jieli.translation.support" -> mapOf( + "supportCallStereo" to server.translationFeature + .isSupportCallTranslationWithStereo(deviceId), + ) + + "jieli.translation.start" -> { + val modeId = (args["modeId"] as? Number)?.toInt() + ?: throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "modeId(int) required", + ) + @Suppress("UNCHECKED_CAST") + val extra = (args["args"] as? Map) ?: emptyMap() + server.translationFeature.start(modeId, extra).getOrElse { + throw DeviceException( + "device.feature_failed", + "translation.start failed: ${it.message}", + it, + ) + } + mapOf("ok" to true) + } + + "jieli.translation.stop" -> { + server.translationFeature.stop() + mapOf("ok" to true) + } + + "jieli.translation.feedTranslatedAudio" -> { + val pcm = args["pcm"] as? ByteArray + ?: (args["pcm"] as? List<*>) + ?.let { it.map { v -> (v as Number).toByte() }.toByteArray() } + ?: throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "pcm(ByteArray|List) required", + ) + val streamId = args["streamId"] as? String + ?: throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "streamId required (e.g. ${TranslationStreams.OUT_UPLINK})", + ) + val ok = server.translationFeature.feedTranslatedAudio( + outputStreamId = streamId, + pcm = pcm, + format = com.jielihome.jielihome.feature.translation.AudioFormat( + sampleRate = (args["sampleRate"] as? Number)?.toInt() ?: 16000, + channels = (args["channels"] as? Number)?.toInt() ?: 1, + bitsPerSample = (args["bitsPerSample"] as? Number)?.toInt() ?: 16, + ), + isFinal = (args["final"] as? Boolean) == true, + ) + mapOf("ok" to ok) + } + + "jieli.translation.feedTranslationResult" -> { + server.translationFeature.feedTranslationResult( + srcLang = args["srcLang"] as? String, + srcText = args["srcText"] as? String, + destLang = args["destLang"] as? String, + destText = args["destText"] as? String, + requestId = args["requestId"] as? String, + ) + mapOf("ok" to true) + } + + "jieli.cmd.send" -> { + val opCode = (args["opCode"] as? Number)?.toInt() + ?: throw DeviceException( + DeviceErrorCode.INVALID_ARGUMENT, + "opCode required", + ) + @Suppress("UNCHECKED_CAST") + val payload = (args["payload"] as? List) + ?.map { it.toByte() }?.toByteArray() + ?: byteArrayOf() + // 同步等待(5s 超时)—— RCSP 自定义命令通常 < 500ms 返回。 + val deferred = java.util.concurrent.CompletableFuture() + server.customCmdFeature.send( + address = deviceId, + opCode = opCode, + payload = payload, + onSuccess = { data -> deferred.complete(data) }, + onError = { code, msg -> + deferred.completeExceptionally( + DeviceException("device.feature_failed", + "cmd $opCode failed: code=$code msg=${msg ?: ""}") + ) + }, + ) + val resp = try { + deferred.get(5, java.util.concurrent.TimeUnit.SECONDS) + } catch (e: java.util.concurrent.TimeoutException) { + throw DeviceException( + "device.feature_failed", + "cmd $opCode timeout", + ) + } catch (e: java.util.concurrent.ExecutionException) { + throw e.cause ?: e + } + mapOf("response" to resp) + } + + else -> throw DeviceException( + DeviceErrorCode.NOT_SUPPORTED, + "unknown feature \"$featureKey\"", + ) + } + } + + override fun callTranslationPort(): DeviceCallTranslationPort = server.callTranslationPort + + override fun disconnect() { + if (_disposed) return + setState(DeviceConnectionState.DISCONNECTING) + runCatching { server.connectFeature.disconnect(deviceId) } + // 真正的 disconnected 状态由 ConnectionStateEvent 推过来 + } + + private fun requireReady() { + if (_disposed || _state != DeviceConnectionState.READY) { + throw DeviceException(DeviceErrorCode.NO_ACTIVE_SESSION) + } + } +} diff --git a/local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt b/local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt new file mode 100644 index 000000000..ed61d7ccd --- /dev/null +++ b/local_plugins/device_jieli/android/src/test/kotlin/com/jielihome/jielihome/JielihomePluginTest.kt @@ -0,0 +1,27 @@ +package com.jielihome.jielihome + +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import kotlin.test.Test +import org.mockito.Mockito + +/* + * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. + * + * Once you have built the plugin's example app, you can run these tests from the command + * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or + * you can run them directly from IDEs that support JUnit such as Android Studio. + */ + +internal class JielihomePluginTest { + @Test + fun onMethodCall_getPlatformVersion_returnsExpectedValue() { + val plugin = JielihomePlugin() + + val call = MethodCall("getPlatformVersion", null) + val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) + plugin.onMethodCall(call, mockResult) + + Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) + } +} diff --git a/local_plugins/device_jieli/example/.gitignore b/local_plugins/device_jieli/example/.gitignore new file mode 100644 index 000000000..79c113f9b --- /dev/null +++ b/local_plugins/device_jieli/example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/local_plugins/device_jieli/example/README.md b/local_plugins/device_jieli/example/README.md new file mode 100644 index 000000000..3b9df497b --- /dev/null +++ b/local_plugins/device_jieli/example/README.md @@ -0,0 +1,16 @@ +# jielihome_example + +Demonstrates how to use the jielihome plugin. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/local_plugins/device_jieli/example/analysis_options.yaml b/local_plugins/device_jieli/example/analysis_options.yaml new file mode 100644 index 000000000..0d2902135 --- /dev/null +++ b/local_plugins/device_jieli/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/local_plugins/device_jieli/example/android/.gitignore b/local_plugins/device_jieli/example/android/.gitignore new file mode 100644 index 000000000..be3943c96 --- /dev/null +++ b/local_plugins/device_jieli/example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/local_plugins/device_jieli/example/android/app/build.gradle.kts b/local_plugins/device_jieli/example/android/app/build.gradle.kts new file mode 100644 index 000000000..167f53a62 --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.jielihome.jielihome_example" + compileSdk = 36 + ndkVersion = "27.0.12077973" + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "com.jielihome.jielihome_example" + minSdk = 21 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} + +dependencies { + implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) +} diff --git a/local_plugins/device_jieli/example/android/app/libs/jl_audio_decode_V2.1.0_20012-release.aar b/local_plugins/device_jieli/example/android/app/libs/jl_audio_decode_V2.1.0_20012-release.aar new file mode 100644 index 000000000..1a609561d Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/libs/jl_audio_decode_V2.1.0_20012-release.aar differ diff --git a/local_plugins/device_jieli/example/android/app/libs/jl_audio_v2_V1.0.0_9-release.aar b/local_plugins/device_jieli/example/android/app/libs/jl_audio_v2_V1.0.0_9-release.aar new file mode 100644 index 000000000..9bfae54af Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/libs/jl_audio_v2_V1.0.0_9-release.aar differ diff --git a/local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar b/local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar new file mode 100644 index 000000000..15eb8cf5e Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/libs/jl_bluetooth_rcsp_V4.2.0_beta2_40214_20251224.aar differ diff --git a/local_plugins/device_jieli/example/android/app/libs/jldecryption_v0.4-release.aar b/local_plugins/device_jieli/example/android/app/libs/jldecryption_v0.4-release.aar new file mode 100644 index 000000000..fd933770e Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/libs/jldecryption_v0.4-release.aar differ diff --git a/local_plugins/device_jieli/example/android/app/src/debug/AndroidManifest.xml b/local_plugins/device_jieli/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/local_plugins/device_jieli/example/android/app/src/main/AndroidManifest.xml b/local_plugins/device_jieli/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cf6dc3ae6 --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/local_plugins/device_jieli/example/android/app/src/main/kotlin/com/jielihome/jielihome_example/MainActivity.kt b/local_plugins/device_jieli/example/android/app/src/main/kotlin/com/jielihome/jielihome_example/MainActivity.kt new file mode 100644 index 000000000..2d704e51f --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/main/kotlin/com/jielihome/jielihome_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.jielihome.jielihome_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/drawable-v21/launch_background.xml b/local_plugins/device_jieli/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000..f74085f3f --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/drawable/launch_background.xml b/local_plugins/device_jieli/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000..304732f88 --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..db77bb4b7 Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..17987b79b Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..09d439148 Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d5f1c8d34 Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4d6372eeb Binary files /dev/null and b/local_plugins/device_jieli/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/values-night/styles.xml b/local_plugins/device_jieli/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000..06952be74 --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/local_plugins/device_jieli/example/android/app/src/main/res/values/styles.xml b/local_plugins/device_jieli/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000..cb1ef8805 --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/local_plugins/device_jieli/example/android/app/src/profile/AndroidManifest.xml b/local_plugins/device_jieli/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000..399f6981d --- /dev/null +++ b/local_plugins/device_jieli/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/local_plugins/device_jieli/example/android/build.gradle.kts b/local_plugins/device_jieli/example/android/build.gradle.kts new file mode 100644 index 000000000..b22fbbb14 --- /dev/null +++ b/local_plugins/device_jieli/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + flatDir { + dirs("${rootProject.projectDir}/app/libs") + } + } +} + +val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/local_plugins/device_jieli/example/android/gradle.properties b/local_plugins/device_jieli/example/android/gradle.properties new file mode 100644 index 000000000..f018a6181 --- /dev/null +++ b/local_plugins/device_jieli/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/local_plugins/device_jieli/example/android/gradle/wrapper/gradle-wrapper.properties b/local_plugins/device_jieli/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ac3b47926 --- /dev/null +++ b/local_plugins/device_jieli/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/local_plugins/device_jieli/example/android/settings.gradle.kts b/local_plugins/device_jieli/example/android/settings.gradle.kts new file mode 100644 index 000000000..ab39a10a2 --- /dev/null +++ b/local_plugins/device_jieli/example/android/settings.gradle.kts @@ -0,0 +1,25 @@ +pluginManagement { + val flutterSdkPath = run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/local_plugins/device_jieli/example/integration_test/plugin_integration_test.dart b/local_plugins/device_jieli/example/integration_test/plugin_integration_test.dart new file mode 100644 index 000000000..a3cb43a08 --- /dev/null +++ b/local_plugins/device_jieli/example/integration_test/plugin_integration_test.dart @@ -0,0 +1,25 @@ +// This is a basic Flutter integration test. +// +// Since integration tests run in a full Flutter application, they can interact +// with the host side of a plugin implementation, unlike Dart unit tests. +// +// For more information about Flutter integration tests, please see +// https://flutter.dev/to/integration-testing + + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:jielihome/jielihome.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('getPlatformVersion test', (WidgetTester tester) async { + final Jielihome plugin = Jielihome(); + final String? version = await plugin.getPlatformVersion(); + // The version string depends on the host platform running the test, so + // just assert that some non-empty string is returned. + expect(version?.isNotEmpty, true); + }); +} diff --git a/local_plugins/device_jieli/example/lib/main.dart b/local_plugins/device_jieli/example/lib/main.dart new file mode 100644 index 000000000..b62573ee4 --- /dev/null +++ b/local_plugins/device_jieli/example/lib/main.dart @@ -0,0 +1,210 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:device_jieli/device_jieli.dart'; +import 'package:permission_handler/permission_handler.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatefulWidget { + const MyApp({super.key}); + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + final _plugin = Jielihome.instance; + final _devices = {}; + StreamSubscription? _sub; + + bool _initialized = false; + bool _scanning = false; + String? _connectedAddress; + String _status = 'idle'; + final _logs = []; + + @override + void initState() { + super.initState(); + _bootstrap(); + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + Future _bootstrap() async { + await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + + await _plugin.initialize(enableLog: true); + _sub = _plugin.events.listen(_onEvent); + setState(() => _initialized = true); + _log('initialized'); + } + + void _onEvent(JieliEvent event) { + if (event is DeviceFoundEvent) { + setState(() => _devices[event.device.address] = event.device); + } else if (event is ScanStatusEvent) { + setState(() { + _scanning = event.started; + _status = event.started ? 'scanning' : 'scan stopped'; + }); + _log('scanStatus started=${event.started} ble=${event.ble}'); + } else if (event is ConnectionStateEvent) { + _log('connectionState ${event.address} state=${event.state}'); + setState(() { + if (event.state == ConnectionStateEvent.connectionOk) { + _connectedAddress = event.address; + _status = 'connected'; + } else if (event.state == ConnectionStateEvent.connectionDisconnect && + event.address == _connectedAddress) { + _connectedAddress = null; + _status = 'disconnected'; + } else if (event.state == ConnectionStateEvent.connectionConnecting) { + _status = 'connecting…'; + } + }); + } else if (event is RcspInitEvent) { + _log('rcspInit ${event.address} code=${event.code}'); + } else if (event is AdapterStatusEvent) { + _log('adapter enabled=${event.enabled} hasBle=${event.hasBle}'); + } else if (event is BondStatusEvent) { + _log('bond ${event.address} status=${event.status}'); + } else if (event is UnknownJieliEvent) { + _log('unknown ${event.raw}'); + } + } + + void _log(String msg) { + setState(() { + _logs.insert(0, msg); + if (_logs.length > 200) _logs.removeLast(); + }); + } + + Future _toggleScan() async { + try { + if (_scanning) { + await _plugin.stopScan(); + } else { + _devices.clear(); + await _plugin.startScan(); + } + } catch (e) { + _log('scan error: $e'); + } + } + + Future _connect(JieliDevice d) async { + try { + await _plugin.stopScan(); + setState(() => _status = 'connecting ${d.address}'); + await _plugin.connect(d); + } catch (e) { + _log('connect error: $e'); + } + } + + Future _disconnect() async { + final addr = _connectedAddress; + if (addr == null) return; + try { + await _plugin.disconnect(addr); + } catch (e) { + _log('disconnect error: $e'); + } + } + + @override + Widget build(BuildContext context) { + final list = _devices.values.toList() + ..sort((a, b) => (b.rssi ?? -127).compareTo(a.rssi ?? -127)); + + return MaterialApp( + home: Scaffold( + appBar: AppBar( + title: const Text('JieLi Home Demo'), + actions: [ + if (_connectedAddress != null) + IconButton( + icon: const Icon(Icons.bluetooth_disabled), + onPressed: _disconnect, + ), + ], + ), + body: !_initialized + ? const Center(child: CircularProgressIndicator()) + : Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: Text('Status: $_status' + '${_connectedAddress != null ? '\nConnected: $_connectedAddress' : ''}'), + ), + ElevatedButton.icon( + onPressed: _toggleScan, + icon: Icon(_scanning ? Icons.stop : Icons.search), + label: Text(_scanning ? 'Stop' : 'Scan'), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + flex: 3, + child: ListView.separated( + itemCount: list.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) { + final d = list[i]; + return ListTile( + dense: true, + title: Text(d.name), + subtitle: Text( + '${d.address} rssi=${d.rssi} type=${d.deviceType} way=${d.connectWay}'), + trailing: ElevatedButton( + onPressed: () => _connect(d), + child: const Text('Connect'), + ), + ); + }, + ), + ), + const Divider(height: 1), + Container( + color: Colors.black87, + height: 200, + child: ListView.builder( + itemCount: _logs.length, + itemBuilder: (_, i) => Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + child: Text( + _logs[i], + style: const TextStyle( + color: Colors.greenAccent, + fontSize: 11, + fontFamily: 'monospace'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/local_plugins/device_jieli/example/pubspec.yaml b/local_plugins/device_jieli/example/pubspec.yaml new file mode 100644 index 000000000..57683bc7c --- /dev/null +++ b/local_plugins/device_jieli/example/pubspec.yaml @@ -0,0 +1,81 @@ +name: jielihome_example +description: "Demonstrates how to use the jielihome plugin." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +environment: + sdk: ^3.8.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + device_jieli: + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + permission_handler: ^11.3.1 + +dev_dependencies: + integration_test: + sdk: flutter + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/local_plugins/device_jieli/example/test/widget_test.dart b/local_plugins/device_jieli/example/test/widget_test.dart new file mode 100644 index 000000000..83de3a133 --- /dev/null +++ b/local_plugins/device_jieli/example/test/widget_test.dart @@ -0,0 +1,27 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:jielihome_example/main.dart'; + +void main() { + testWidgets('Verify Platform version', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that platform version is retrieved. + expect( + find.byWidgetPredicate( + (Widget widget) => widget is Text && + widget.data!.startsWith('Running on:'), + ), + findsOneWidget, + ); + }); +} diff --git a/local_plugins/device_jieli/lib/device_jieli.dart b/local_plugins/device_jieli/lib/device_jieli.dart new file mode 100644 index 000000000..e095ac27f --- /dev/null +++ b/local_plugins/device_jieli/lib/device_jieli.dart @@ -0,0 +1,657 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +// device_plugin_interface 适配器(让 device_manager 以厂商无关方式驱动 jieli)。 +export 'src/jieli_device_plugin.dart'; +export 'src/jieli_device_session.dart'; + +const _methodChannel = MethodChannel('device_jieli/method'); +const _eventChannel = EventChannel('device_jieli/event'); + +class JieliDevice { + JieliDevice({ + required this.name, + required this.address, + this.edrAddr, + this.deviceType, + this.connectWay, + this.rssi, + this.leftBattery, + this.rightBattery, + this.caseBattery, + this.leftCharging, + this.rightCharging, + this.caseCharging, + }); + + final String name; + final String address; + final String? edrAddr; + final int? deviceType; + final int? connectWay; + final int? rssi; + // 来自 BleScanMessage,扫到时就有;0 表示该位置未上报(如单耳设备右耳为 0) + final int? leftBattery; + final int? rightBattery; + final int? caseBattery; + final bool? leftCharging; + final bool? rightCharging; + final bool? caseCharging; + + factory JieliDevice.fromMap(Map m) => JieliDevice( + name: (m['name'] as String?) ?? '', + address: m['address'] as String, + edrAddr: m['edrAddr'] as String?, + deviceType: m['deviceType'] as int?, + connectWay: m['connectWay'] as int?, + rssi: m['rssi'] as int?, + leftBattery: (m['leftBattery'] as num?)?.toInt(), + rightBattery: (m['rightBattery'] as num?)?.toInt(), + caseBattery: (m['caseBattery'] as num?)?.toInt(), + leftCharging: m['leftCharging'] as bool?, + rightCharging: m['rightCharging'] as bool?, + caseCharging: m['caseCharging'] as bool?, + ); + + @override + String toString() => 'JieliDevice($name, $address, rssi=$rssi, ' + 'L=$leftBattery${leftCharging == true ? "+" : ""} ' + 'R=$rightBattery${rightCharging == true ? "+" : ""} ' + 'Case=$caseBattery${caseCharging == true ? "+" : ""})'; +} + +abstract class JieliEvent { + const JieliEvent(); +} + +class AdapterStatusEvent extends JieliEvent { + final bool enabled; + final bool hasBle; + const AdapterStatusEvent(this.enabled, this.hasBle); +} + +class ScanStatusEvent extends JieliEvent { + final bool ble; + final bool started; + const ScanStatusEvent(this.ble, this.started); +} + +class DeviceFoundEvent extends JieliEvent { + final JieliDevice device; + const DeviceFoundEvent(this.device); +} + +class BondStatusEvent extends JieliEvent { + final String address; + final int status; + const BondStatusEvent(this.address, this.status); +} + +class RcspInitEvent extends JieliEvent { + final String address; + final int code; + const RcspInitEvent(this.address, this.code); + bool get success => code == 0; +} + +class ConnectionStateEvent extends JieliEvent { + final String address; + final int state; + const ConnectionStateEvent(this.address, this.state); + + static const int connectionOk = 1; + static const int connectionConnecting = 2; + static const int connectionDisconnect = 0; +} + +class UnknownJieliEvent extends JieliEvent { + final Map raw; + const UnknownJieliEvent(this.raw); +} + +// ───── 设备级事件 ───── + +class BatteryEvent extends JieliEvent { + final String? address; + final int? level; + const BatteryEvent(this.address, this.level); +} + +class PhoneCallStatusEvent extends JieliEvent { + final String? address; + final int status; + const PhoneCallStatusEvent(this.address, this.status); +} + +class ExpandFunctionEvent extends JieliEvent { + final String? address; + final int opCode; + final String? payloadBase64; + const ExpandFunctionEvent(this.address, this.opCode, this.payloadBase64); +} + +// ───── 翻译事件 ───── + +class TranslationModeIds { + static const int idle = 0; + static const int record = 1; + static const int recordingTranslation = 2; + static const int callTranslation = 3; + static const int audioTranslation = 4; + static const int faceToFaceTranslation = 5; + static const int callTranslationWithStereo = 6; +} + +class TranslationStreams { + static const String inMic = 'in.mic'; + static const String inUplink = 'in.uplink'; + static const String inDownlink = 'in.downlink'; + static const String inAudioFile = 'in.audioFile'; + static const String outSpeaker = 'out.speaker'; + static const String outUplink = 'out.uplink'; + static const String outDownlink = 'out.downlink'; + static const String outLocalPlayback = 'out.localPlayback'; +} + +class TranslationAudioEvent extends JieliEvent { + final int modeId; + final String streamId; + final int sampleRate; + final int channels; + final int bitsPerSample; + final int seq; + final int tsMs; + final bool isFinal; + + /// 16bit signed PCM 字节流(小端,interleaved 当 channels>1) + final Uint8List pcm; + + const TranslationAudioEvent({ + required this.modeId, + required this.streamId, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.seq, + required this.tsMs, + required this.isFinal, + required this.pcm, + }); +} + +class TranslationLogEvent extends JieliEvent { + final int modeId; + final String content; + const TranslationLogEvent(this.modeId, this.content); +} + +class TranslationResultEvent extends JieliEvent { + final int modeId; + final String? srcLang; + final String? srcText; + final String? destLang; + final String? destText; + /// 由调用方贯穿同一段翻译,便于字幕聚合到具体段落 + final String? requestId; + const TranslationResultEvent({ + required this.modeId, + this.srcLang, this.srcText, this.destLang, this.destText, + this.requestId, + }); +} + +class TranslationErrorEvent extends JieliEvent { + final int modeId; + final int code; + final String? message; + const TranslationErrorEvent(this.modeId, this.code, this.message); +} + +// ───── 语音助手 / 唤醒事件 ───── + +class SpeechStartEvent extends JieliEvent { + final String? address; + /// RecordParam.VOICE_TYPE_PCM=0 / SPEEX=1 / OPUS=2 + final int? voiceType; + /// 已转换为 Hz(如 16000) + final int? sampleRate; + /// VAD_WAY_DEVICE=0 / SDK=1 + final int? vadWay; + final int tsMs; + const SpeechStartEvent({ + this.address, + this.voiceType, this.sampleRate, this.vadWay, + required this.tsMs, + }); +} + +class SpeechAudioEvent extends JieliEvent { + final String? address; + /// 'pcm16' / 'speex'(speex 时未解码) + final String encoding; + final int sampleRate; + final int channels; + final int bitsPerSample; + final int tsMs; + /// 16bit signed PCM(encoding=='pcm16');speex 时为原始帧 + final Uint8List pcm; + const SpeechAudioEvent({ + this.address, + required this.encoding, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.tsMs, + required this.pcm, + }); +} + +class SpeechEndEvent extends JieliEvent { + final String? address; + final int? reason; + final String? message; + final int tsMs; + const SpeechEndEvent({this.address, this.reason, this.message, required this.tsMs}); +} + +class SpeechErrorEvent extends JieliEvent { + final int code; + final String? message; + const SpeechErrorEvent(this.code, this.message); +} + +class SpeechVoiceType { + static const int pcm = 0; + static const int speex = 1; + static const int opus = 2; +} + +class SpeechSampleRate { + static const int khz8 = 8; + static const int khz16 = 16; +} + +class SpeechVadWay { + static const int device = 0; + static const int sdk = 1; +} + +class SpeechStopReason { + static const int normal = 0; + static const int stop = 1; +} + +// ───── OTA 事件 ───── + +enum OtaState { + idle, inquiring, notifyingSize, entering, transferring, verifying, rebooting, done, failed, cancelled, +} + +class OtaStateEvent extends JieliEvent { + final OtaState state; + final int sent; + final int total; + /// -1 表示未知(idle/cancelled 等) + final int percent; + final int tsMs; + const OtaStateEvent({ + required this.state, required this.sent, required this.total, + required this.percent, required this.tsMs, + }); +} + +class OtaErrorEvent extends JieliEvent { + final int code; + final String? message; + const OtaErrorEvent(this.code, this.message); +} + +class Jielihome { + Jielihome._(); + static final Jielihome instance = Jielihome._(); + + Stream? _events; + + Stream get events { + return _events ??= _eventChannel + .receiveBroadcastStream() + .map((raw) => _parseEvent(raw as Map)); + } + + JieliEvent _parseEvent(Map raw) { + switch (raw['type']) { + case 'adapterStatus': + return AdapterStatusEvent(raw['enabled'] == true, raw['hasBle'] == true); + case 'scanStatus': + return ScanStatusEvent(raw['ble'] == true, raw['started'] == true); + case 'deviceFound': + return DeviceFoundEvent(JieliDevice.fromMap(raw)); + case 'bondStatus': + return BondStatusEvent(raw['address'] as String, raw['status'] as int); + case 'rcspInit': + return RcspInitEvent(raw['address'] as String, raw['code'] as int); + case 'connectionState': + return ConnectionStateEvent(raw['address'] as String, raw['state'] as int); + case 'battery': + return BatteryEvent(raw['address'] as String?, raw['level'] as int?); + case 'phoneCallStatus': + return PhoneCallStatusEvent(raw['address'] as String?, (raw['status'] as int?) ?? 0); + case 'expandFunction': + return ExpandFunctionEvent( + raw['address'] as String?, + (raw['opCode'] as int?) ?? 0, + raw['payloadBase64'] as String?, + ); + case 'translationAudio': + return TranslationAudioEvent( + modeId: (raw['modeId'] as int?) ?? 0, + streamId: raw['streamId'] as String, + sampleRate: (raw['sampleRate'] as int?) ?? 16000, + channels: (raw['channels'] as int?) ?? 1, + bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16, + seq: (raw['seq'] as num?)?.toInt() ?? 0, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + isFinal: raw['final'] == true, + pcm: raw['pcm'] as Uint8List, + ); + case 'translationLog': + return TranslationLogEvent( + (raw['modeId'] as int?) ?? 0, + raw['content'] as String? ?? '', + ); + case 'translationResult': + return TranslationResultEvent( + modeId: (raw['modeId'] as int?) ?? 0, + srcLang: raw['srcLang'] as String?, + srcText: raw['srcText'] as String?, + destLang: raw['destLang'] as String?, + destText: raw['destText'] as String?, + requestId: raw['requestId'] as String?, + ); + case 'translationError': + return TranslationErrorEvent( + (raw['modeId'] as int?) ?? 0, + (raw['code'] as int?) ?? 0, + raw['message'] as String?, + ); + case 'speechStart': + return SpeechStartEvent( + address: raw['address'] as String?, + voiceType: raw['voiceType'] as int?, + sampleRate: raw['sampleRate'] as int?, + vadWay: raw['vadWay'] as int?, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'speechAudio': + return SpeechAudioEvent( + address: raw['address'] as String?, + encoding: (raw['encoding'] as String?) ?? 'pcm16', + sampleRate: (raw['sampleRate'] as int?) ?? 16000, + channels: (raw['channels'] as int?) ?? 1, + bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + pcm: raw['pcm'] as Uint8List, + ); + case 'speechEnd': + return SpeechEndEvent( + address: raw['address'] as String?, + reason: raw['reason'] as int?, + message: raw['message'] as String?, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'speechError': + return SpeechErrorEvent( + (raw['code'] as int?) ?? 0, + raw['message'] as String?, + ); + case 'otaState': + return OtaStateEvent( + state: _parseOtaState(raw['state'] as String?), + sent: (raw['sent'] as num?)?.toInt() ?? -1, + total: (raw['total'] as num?)?.toInt() ?? 0, + percent: (raw['percent'] as int?) ?? -1, + tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0, + ); + case 'otaError': + return OtaErrorEvent( + (raw['code'] as int?) ?? 0, + raw['message'] as String?, + ); + default: + return UnknownJieliEvent(raw); + } + } + + OtaState _parseOtaState(String? s) { + switch (s) { + case 'INQUIRING': return OtaState.inquiring; + case 'NOTIFYING_SIZE': return OtaState.notifyingSize; + case 'ENTERING': return OtaState.entering; + case 'TRANSFERRING': return OtaState.transferring; + case 'VERIFYING': return OtaState.verifying; + case 'REBOOTING': return OtaState.rebooting; + case 'DONE': return OtaState.done; + case 'FAILED': return OtaState.failed; + case 'CANCELLED': return OtaState.cancelled; + default: return OtaState.idle; + } + } + + Future getPlatformVersion() async { + return await _methodChannel.invokeMethod('getPlatformVersion'); + } + + Future initialize({ + bool multiDevice = true, + bool skipNoNameDev = false, + bool enableLog = false, + }) async { + await _methodChannel.invokeMethod('initialize', { + 'multiDevice': multiDevice, + 'skipNoNameDev': skipNoNameDev, + 'enableLog': enableLog, + }); + } + + Future startScan({Duration timeout = const Duration(seconds: 30)}) async { + await _methodChannel.invokeMethod('startScan', { + 'timeoutMs': timeout.inMilliseconds, + }); + } + + Future stopScan() => _methodChannel.invokeMethod('stopScan'); + + Future isScanning() async => + (await _methodChannel.invokeMethod('isScanning')) ?? false; + + Future connect(JieliDevice device) async { + await _methodChannel.invokeMethod('connect', { + 'address': device.address, + 'edrAddr': device.edrAddr, + 'connectWay': device.connectWay, + 'deviceType': device.deviceType, + }); + } + + Future disconnect(String address) async { + await _methodChannel.invokeMethod('disconnect', {'address': address}); + } + + Future isConnected(String address) async => + (await _methodChannel.invokeMethod('isConnected', {'address': address})) ?? + false; + + Future connectedDevice() async { + final raw = await _methodChannel.invokeMethod('connectedDevice'); + if (raw == null) return null; + return JieliDevice( + name: (raw['name'] as String?) ?? '', + address: raw['address'] as String, + ); + } + + // ───── 设备信息查询 ───── + + Future?> deviceSnapshot(String address) async { + final raw = await _methodChannel.invokeMethod( + 'deviceSnapshot', {'address': address}); + return raw?.cast(); + } + + Future?> queryTargetInfo(String address, {int mask = 0x0F}) async { + final raw = await _methodChannel.invokeMethod( + 'queryTargetInfo', {'address': address, 'mask': mask}); + return raw?.cast(); + } + + // ───── 自定义指令 ───── + + Future?> sendCustomCmd(String address, int opCode, List payload) async { + final raw = await _methodChannel.invokeMethod?>( + 'sendCustomCmd', + {'address': address, 'opCode': opCode, 'payload': payload}, + ); + return raw?.cast(); + } + + // ───── 翻译 ───── + + Future startTranslation({ + required int modeId, + Map args = const {}, + }) async { + await _methodChannel.invokeMethod('startTranslation', { + 'modeId': modeId, + 'args': args, + }); + } + + Future stopTranslation() => + _methodChannel.invokeMethod('stopTranslation'); + + Future?> translationStatus() async { + final raw = await _methodChannel.invokeMethod('translationStatus'); + return raw?.cast(); + } + + /// 把外部翻译服务返回的 TTS PCM 喂回插件,由插件根据当前模式回送给耳机或本地播放。 + /// [pcm] 必须是 16bit signed PCM(小端、interleaved when channels>1)。 + /// 推荐传 [Uint8List];也接受 List<int> 但会触发一次拷贝。 + Future feedTranslatedAudio({ + required String streamId, + required List pcm, + int sampleRate = 16000, + int channels = 1, + int bitsPerSample = 16, + bool isFinal = false, + }) async { + final bytes = pcm is Uint8List ? pcm : Uint8List.fromList(pcm); + return (await _methodChannel.invokeMethod('feedTranslatedAudio', { + 'streamId': streamId, + 'pcm': bytes, + 'sampleRate': sampleRate, + 'channels': channels, + 'bitsPerSample': bitsPerSample, + 'final': isFinal, + })) ?? + false; + } + + /// 透传翻译文本(字幕)到 EventChannel,供 UI 订阅 [TranslationResultEvent]。 + /// [requestId] 用于把同一段翻译的多次回调串起来。 + Future feedTranslationResult({ + String? srcLang, String? srcText, + String? destLang, String? destText, + String? requestId, + }) async { + await _methodChannel.invokeMethod('feedTranslationResult', { + 'srcLang': srcLang, + 'srcText': srcText, + 'destLang': destLang, + 'destText': destText, + 'requestId': requestId, + }); + } + + /// 探测当前已连耳机是否支持立体声通话翻译方案。 + /// [address] 不传则用当前已连设备。 + Future isSupportCallTranslationWithStereo({String? address}) async { + return (await _methodChannel.invokeMethod( + 'isSupportCallTranslationWithStereo', + {'address': address}, + )) ?? + false; + } + + /// 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅 MODE_AUDIO_TRANSLATION 生效)。 + Future feedAudioFilePcm({ + required List pcm, + int sampleRate = 16000, + }) async { + final bytes = pcm is Uint8List ? pcm : Uint8List.fromList(pcm); + return (await _methodChannel.invokeMethod('feedAudioFilePcm', { + 'pcm': bytes, + 'sampleRate': sampleRate, + })) ?? + false; + } + + // ───── 语音助手 / 唤醒 ───── + // 提示:插件初始化时已经常驻订阅了耳机 RECORD 状态回调; + // - 耳机本地检测到唤醒词或按键 → 自动收到 SpeechStart/SpeechAudio/SpeechEnd 事件 + // - 业务层不需要先调任何方法 + // 下面的 speechStart/Stop 仅用于「APP 主动触发/取消」语音助手的场景。 + + Future speechIsRecording({String? address}) async => + (await _methodChannel.invokeMethod('speechIsRecording', {'address': address})) ?? + false; + + Future speechStart({ + String? address, + int voiceType = SpeechVoiceType.opus, + int sampleRate = SpeechSampleRate.khz16, + int vadWay = SpeechVadWay.device, + }) async { + await _methodChannel.invokeMethod('speechStart', { + 'address': address, + 'voiceType': voiceType, + 'sampleRate': sampleRate, + 'vadWay': vadWay, + }); + } + + Future speechStop({ + String? address, + int reason = SpeechStopReason.normal, + }) async { + await _methodChannel.invokeMethod('speechStop', { + 'address': address, + 'reason': reason, + }); + } + + // ───── OTA ───── + + /// 启动 OTA 升级。订阅 [OtaStateEvent] / [OtaErrorEvent] 看进度。 + /// [firmwareFilePath] 必须是手机本地完整路径(你提前下好的固件文件)。 + Future otaStart({ + String? address, + required String firmwareFilePath, + int blockSize = 512, + List? fileFlag, + }) async { + await _methodChannel.invokeMethod('otaStart', { + 'address': address, + 'firmwareFilePath': firmwareFilePath, + 'blockSize': blockSize, + if (fileFlag != null) + 'fileFlag': fileFlag is Uint8List ? fileFlag : Uint8List.fromList(fileFlag), + }); + } + + Future otaCancel() => _methodChannel.invokeMethod('otaCancel'); + + Future otaIsRunning() async => + (await _methodChannel.invokeMethod('otaIsRunning')) ?? false; +} diff --git a/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart b/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart new file mode 100644 index 000000000..1392bfa33 --- /dev/null +++ b/local_plugins/device_jieli/lib/src/jieli_device_plugin.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; + +import '../device_jieli.dart'; + +/// Jieli (杰里) `DevicePlugin` 适配。 +/// +/// - 复用底层 [Jielihome] 单例(保留原 dart API 给调试 UI 用); +/// - 把 [JieliEvent] 翻译为 [DevicePluginEvent]; +/// - 当前不暴露麦克风上行 / 扬声器下行 (kotlin 侧尚未接 PCM/Opus 通道), +/// 能力集合先不声明 `micUplink` / `speakerDownlink`; +/// - 通话翻译 / 面对面翻译走 `invokeFeature('jieli.translation.start', ...)`, +/// 设备端自行处理音频。 +class JieliDevicePlugin implements DevicePlugin { + JieliDevicePlugin(); + + static const String kVendor = 'jieli'; + + final Jielihome _home = Jielihome.instance; + + StreamSubscription? _sub; + StreamController? _ctrl; + JieliDeviceSession? _activeSession; + + bool _initialized = false; + bool _disposed = false; + bool _scanning = false; + + @override + String get vendorKey => kVendor; + + @override + String get displayName => 'JieLi (杰理)'; + + @override + Set get capabilities => const { + DeviceCapability.scan, + DeviceCapability.connect, + DeviceCapability.bond, + DeviceCapability.customCommand, + DeviceCapability.onDeviceCallTranslation, + DeviceCapability.onDeviceFaceToFaceTranslation, + DeviceCapability.onDeviceRecordingTranslation, + }; + + @override + DevicePluginConfigSchema get configSchema => const DevicePluginConfigSchema( + fields: [ + DevicePluginConfigField( + key: 'multiDevice', + label: '多设备模式', + defaultValue: false, + helpText: '关闭以遵循 device_manager 单设备约束', + ), + DevicePluginConfigField( + key: 'skipNoNameDev', + label: '跳过无名设备', + defaultValue: false, + ), + DevicePluginConfigField( + key: 'enableLog', + label: '启用 SDK 日志', + defaultValue: false, + ), + ], + ); + + @override + DeviceSession? get activeSession => _activeSession; + + @override + Stream get eventStream => + _ctrl?.stream ?? const Stream.empty(); + + @override + Future initialize(DevicePluginConfig config) async { + _checkAlive(); + if (_initialized) return; + _ctrl ??= StreamController.broadcast(); + await _home.initialize( + multiDevice: (config.extra['multiDevice'] as bool?) ?? false, + skipNoNameDev: (config.extra['skipNoNameDev'] as bool?) ?? false, + enableLog: (config.extra['enableLog'] as bool?) ?? false, + ); + _sub = _home.events.listen(_onJieliEvent); + _initialized = true; + _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); + } + + @override + Future startScan({DeviceScanFilter? filter, Duration? timeout}) async { + _requireInit(); + await _home.startScan(timeout: timeout ?? const Duration(seconds: 30)); + _scanning = true; + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted)); + } + + @override + Future stopScan() async { + if (!_initialized) return; + await _home.stopScan(); + _scanning = false; + _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); + } + + @override + Future isScanning() async { + if (!_initialized) return false; + return _scanning || await _home.isScanning(); + } + + @override + Future> bondedDevices() async { + // Jielihome 当前未暴露已配对列表,留给后续 native 扩展。 + return const []; + } + + @override + Future connect( + String deviceId, { + DeviceConnectOptions? options, + }) async { + _requireInit(); + final extra = options?.extra ?? const {}; + final jl = JieliDevice( + name: (extra['name'] as String?) ?? '', + address: deviceId, + edrAddr: extra['edrAddr'] as String?, + deviceType: extra['deviceType'] as int?, + connectWay: extra['connectWay'] as int?, + ); + await _home.connect(jl); + + final session = JieliDeviceSession( + home: _home, + deviceId: deviceId, + vendor: kVendor, + capabilities: capabilities, + initialName: jl.name, + onClosed: () { + if (identical(_activeSession, _activeSessionRef())) { + _activeSession = null; + } + }, + ); + _activeSession = session; + return session.waitReady(timeout: options?.timeout ?? const Duration(seconds: 15)); + } + + JieliDeviceSession? _activeSessionRef() => _activeSession; + + @override + Future dispose() async { + if (_disposed) return; + _disposed = true; + await _sub?.cancel(); + _sub = null; + final s = _activeSession; + _activeSession = null; + if (s != null) { + try { + await s.disconnect(); + } catch (_) {} + } + _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginDisposed)); + await _ctrl?.close(); + _ctrl = null; + _initialized = false; + } + + // -------- 内部 -------- + + void _checkAlive() { + if (_disposed) throw StateError('JieliDevicePlugin already disposed'); + } + + void _requireInit() { + _checkAlive(); + if (!_initialized) { + throw DeviceException(DeviceErrorCode.pluginNotInitialized); + } + } + + void _emit(DevicePluginEvent e) { + final c = _ctrl; + if (c == null || c.isClosed) return; + c.add(e); + } + + void _onJieliEvent(JieliEvent raw) { + if (raw is AdapterStatusEvent) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.bluetoothStateChanged, + bluetoothEnabled: raw.enabled, + )); + return; + } + if (raw is DeviceFoundEvent) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.deviceDiscovered, + discovered: DiscoveredDevice( + id: raw.device.address, + name: raw.device.name, + rssi: raw.device.rssi, + vendor: kVendor, + metadata: { + if (raw.device.edrAddr != null) 'edrAddr': raw.device.edrAddr, + if (raw.device.deviceType != null) 'deviceType': raw.device.deviceType, + if (raw.device.connectWay != null) 'connectWay': raw.device.connectWay, + }, + ), + )); + return; + } + if (raw is BondStatusEvent) { + _emit(DevicePluginEvent( + type: DevicePluginEventType.bondStateChanged, + deviceId: raw.address, + bondState: _mapBondStatus(raw.status), + )); + return; + } + if (raw is ConnectionStateEvent) { + _activeSession?.updateConnectionFromRaw(raw.state); + return; + } + if (raw is RcspInitEvent) { + _activeSession?.updateRcspInit(raw.success); + return; + } + // 翻译相关事件 → 通过 session.eventStream 派发给 agent 层 + // V4.2 新 SDK:原 ModeChange / SessionStart / SessionStop / RawAudio 已合并; + // 当前对外只暴露 TranslationAudio(PCM)/ Log / Result / Error。 + if (raw is TranslationAudioEvent || + raw is TranslationLogEvent || + raw is TranslationResultEvent || + raw is TranslationErrorEvent) { + _activeSession?.dispatchTranslationEvent(raw); + return; + } + if (raw is ScanStatusEvent) { + _scanning = raw.started; + _emit(DevicePluginEvent( + type: raw.started + ? DevicePluginEventType.scanStarted + : DevicePluginEventType.scanStopped, + )); + return; + } + // 其它事件(UnknownJieliEvent 等)作为 customEvent 透传,业务层一般忽略。 + _emit(DevicePluginEvent( + type: DevicePluginEventType.customEvent, + customKey: 'jieli.raw', + customPayload: const {}, + )); + } + + String _mapBondStatus(int s) { + // android.bluetooth.BluetoothDevice 常量:10=NONE 11=BONDING 12=BONDED + return switch (s) { + 11 => 'bonding', + 12 => 'bonded', + _ => 'none', + }; + } +} + +/// 注册到 [DeviceManager] 的描述符(业务层只需 `manager.registerVendor(jieliDescriptor)`)。 +final DevicePluginDescriptor jieliDevicePluginDescriptor = DevicePluginDescriptor( + vendorKey: JieliDevicePlugin.kVendor, + displayName: 'JieLi (杰理)', + factory: JieliDevicePlugin.new, + declaredCapabilities: const { + DeviceCapability.scan, + DeviceCapability.connect, + DeviceCapability.customCommand, + DeviceCapability.onDeviceCallTranslation, + DeviceCapability.onDeviceFaceToFaceTranslation, + DeviceCapability.onDeviceRecordingTranslation, + }, +); diff --git a/local_plugins/device_jieli/lib/src/jieli_device_session.dart b/local_plugins/device_jieli/lib/src/jieli_device_session.dart new file mode 100644 index 000000000..6663b2f36 --- /dev/null +++ b/local_plugins/device_jieli/lib/src/jieli_device_session.dart @@ -0,0 +1,378 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/services.dart'; + +import '../device_jieli.dart'; + +/// Jieli 会话适配。 +/// +/// 连接生命周期映射: +/// - `ConnectionStateEvent.state == 2` (CONNECTING) → [DeviceConnectionState.connecting] +/// - `ConnectionStateEvent.state == 1` (OK) → [DeviceConnectionState.linkConnected] +/// - `RcspInitEvent(success=true)` → [DeviceConnectionState.ready] +/// - `ConnectionStateEvent.state == 0` (DISCONNECT) → [DeviceConnectionState.disconnected] +/// +/// 当前 native 侧未暴露 mic uplink / spk downlink 通道, +/// `openMicUplink` / `openSpeakerDownlink` 暂抛 `device.not_supported`, +/// 等 kotlin 层接 PCM/Opus 后再实装。 +class JieliDeviceSession implements DeviceSession { + JieliDeviceSession({ + required Jielihome home, + required this.deviceId, + required this.vendor, + required Set capabilities, + required this.onClosed, + String initialName = '', + }) : _home = home, + _capabilities = capabilities, + _info = DeviceInfo(id: deviceId, name: initialName, vendor: vendor); + + static const MethodChannel _method = MethodChannel('device_jieli/method'); + + final Jielihome _home; + final void Function() onClosed; + + @override + final String deviceId; + @override + final String vendor; + + final Set _capabilities; + DeviceInfo _info; + + DeviceConnectionState _state = DeviceConnectionState.connecting; + final StreamController _evt = + StreamController.broadcast(); + Completer? _readyCompleter; + Timer? _readyTimer; + bool _disposed = false; + + @override + DeviceConnectionState get state => _state; + + @override + DeviceInfo get info => _info; + + @override + Set get capabilities => _capabilities; + + @override + Stream get eventStream => _evt.stream; + + /// `JieliDevicePlugin.connect` 中等待握手完成。 + Future waitReady({required Duration timeout}) { + if (_state == DeviceConnectionState.ready) return Future.value(this); + final c = _readyCompleter ??= Completer(); + _readyTimer ??= Timer(timeout, () { + if (!c.isCompleted) { + c.completeError( + DeviceException(DeviceErrorCode.connectTimeout, + 'jieli connect/handshake timeout'), + ); + _close(silent: true); + } + }); + return c.future; + } + + /// 由 plugin 转发 ConnectionStateEvent。 + void updateConnectionFromRaw(int raw) { + switch (raw) { + case ConnectionStateEvent.connectionConnecting: + _setState(DeviceConnectionState.connecting); + case ConnectionStateEvent.connectionOk: + _setState(DeviceConnectionState.linkConnected); + case ConnectionStateEvent.connectionDisconnect: + _setState(DeviceConnectionState.disconnected); + _completeReadyError( + DeviceException(DeviceErrorCode.disconnectedRemote), + ); + _close(); + } + } + + /// 由 [JieliDevicePlugin._onJieliEvent] 转发翻译相关事件(V4.2 新 API)。 + /// + /// 新 SDK 把 OPUS 解码内化,对外只发 PCM16;通道用字符串 [TranslationStreams] + /// 标识(`in.uplink` / `out.downlink` 等)。这里把它们翻译为通用的 + /// `feature.`,业务层订阅 [eventStream] 并按 `feature.key` 路由。 + void dispatchTranslationEvent(JieliEvent raw) { + if (_evt.isClosed) return; + if (raw is TranslationAudioEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent( + key: 'jieli.translation.audio', + data: { + 'modeId': raw.modeId, + 'streamId': raw.streamId, + 'sampleRate': raw.sampleRate, + 'channels': raw.channels, + 'bitsPerSample': raw.bitsPerSample, + 'seq': raw.seq, + 'tsMs': raw.tsMs, + 'final': raw.isFinal, + 'pcm': raw.pcm, + }, + ), + )); + } else if (raw is TranslationLogEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent( + key: 'jieli.translation.log', + data: { + 'modeId': raw.modeId, + 'content': raw.content, + }, + ), + )); + } else if (raw is TranslationResultEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.feature, + deviceId: deviceId, + feature: DeviceFeatureEvent( + key: 'jieli.translation.result', + data: { + 'modeId': raw.modeId, + if (raw.srcLang != null) 'srcLang': raw.srcLang, + if (raw.srcText != null) 'srcText': raw.srcText, + if (raw.destLang != null) 'destLang': raw.destLang, + if (raw.destText != null) 'destText': raw.destText, + if (raw.requestId != null) 'requestId': raw.requestId, + }, + ), + )); + } else if (raw is TranslationErrorEvent) { + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.error, + deviceId: deviceId, + errorCode: 'device.feature_failed', + errorMessage: 'jieli.translation: ${raw.code} ${raw.message ?? ''}', + )); + } + } + + /// 由 plugin 转发 RcspInitEvent。 + void updateRcspInit(bool success) { + if (success) { + _setState(DeviceConnectionState.ready); + _completeReady(); + } else { + _completeReadyError(DeviceException(DeviceErrorCode.handshakeFailed)); + _close(); + } + } + + // ---------- DeviceSession 接口实现 ---------- + + @override + Future readRssi() async { + _requireReady(); + throw DeviceException(DeviceErrorCode.notSupported, + 'jieli native bridge does not expose RSSI yet'); + } + + @override + Future readBattery() async { + _requireReady(); + final snapshot = await _method.invokeMethod( + 'deviceSnapshot', + {'address': deviceId}, + ); + final pct = snapshot?['battery']; + return pct is int ? pct : null; + } + + @override + Future refreshInfo() async { + _requireReady(); + final snapshot = await _method.invokeMethod( + 'deviceSnapshot', + {'address': deviceId}, + ); + if (snapshot == null) return _info; + _info = _info.copyWith( + name: snapshot['name'] as String? ?? _info.name, + firmwareVersion: snapshot['firmwareVersion'] as String?, + hardwareVersion: snapshot['hardwareVersion'] as String?, + serialNumber: snapshot['serialNumber'] as String?, + manufacturer: snapshot['manufacturer'] as String?, + model: snapshot['model'] as String?, + batteryPercent: snapshot['battery'] as int?, + metadata: Map.from(snapshot), + ); + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.deviceInfoUpdated, + deviceId: deviceId, + deviceInfo: _info, + )); + return _info; + } + + @override + Future> invokeFeature( + String featureKey, [ + Map args = const {}, + ]) async { + _requireReady(); + switch (featureKey) { + // ── 翻译能力探测(V4.2 新 SDK 只暴露 stereo 支持探测) ── + case 'jieli.translation.support': + final stereo = await Jielihome.instance + .isSupportCallTranslationWithStereo(address: deviceId); + return {'supportCallStereo': stereo}; + + // ── 翻译模式:start / stop / status ───────────────── + case 'jieli.translation.start': + final modeId = args['modeId']; + if (modeId is! int) { + throw DeviceException( + DeviceErrorCode.invalidArgument, + 'modeId(int) required', + ); + } + final extra = (args['args'] as Map?)?.cast() ?? + const {}; + await Jielihome.instance.startTranslation(modeId: modeId, args: extra); + return {'ok': true}; + + case 'jieli.translation.stop': + await Jielihome.instance.stopTranslation(); + return {'ok': true}; + + case 'jieli.translation.status': + final s = await Jielihome.instance.translationStatus(); + return s ?? const {}; + + // ── PCM 翻译音频回灌 / 字幕透传(V4.2 新 API) ────── + case 'jieli.translation.feedTranslatedAudio': + final pcm = args['pcm']; + if (pcm is! List) { + throw DeviceException( + DeviceErrorCode.invalidArgument, + 'pcm(List) required', + ); + } + final streamId = args['streamId'] as String?; + if (streamId == null) { + throw DeviceException( + DeviceErrorCode.invalidArgument, + 'streamId(String) required (e.g. ${TranslationStreams.outUplink})', + ); + } + final ok = await Jielihome.instance.feedTranslatedAudio( + streamId: streamId, + pcm: pcm, + sampleRate: (args['sampleRate'] as int?) ?? 16000, + channels: (args['channels'] as int?) ?? 1, + bitsPerSample: (args['bitsPerSample'] as int?) ?? 16, + isFinal: (args['final'] as bool?) ?? false, + ); + return {'ok': ok}; + + case 'jieli.translation.feedTranslationResult': + await Jielihome.instance.feedTranslationResult( + srcLang: args['srcLang'] as String?, + srcText: args['srcText'] as String?, + destLang: args['destLang'] as String?, + destText: args['destText'] as String?, + requestId: args['requestId'] as String?, + ); + return {'ok': true}; + + case 'jieli.translation.feedAudioFilePcm': + final pcm = args['pcm']; + if (pcm is! List) { + throw DeviceException( + DeviceErrorCode.invalidArgument, + 'pcm(List) required', + ); + } + final ok = await Jielihome.instance.feedAudioFilePcm( + pcm: pcm, + sampleRate: (args['sampleRate'] as int?) ?? 16000, + ); + return {'ok': ok}; + + // ── 自定义 RCSP 命令 ──────────────────────────────── + case 'jieli.cmd.send': + final opCode = args['opCode']; + final payload = args['payload']; + if (opCode is! int) { + throw DeviceException( + DeviceErrorCode.invalidArgument, 'opCode(int) required', + ); + } + final resp = await Jielihome.instance.sendCustomCmd( + deviceId, + opCode, + (payload as List?)?.cast() ?? const [], + ); + return {'response': resp}; + + default: + throw DeviceException( + DeviceErrorCode.notSupported, + 'unknown feature "$featureKey"', + ); + } + } + + @override + Future disconnect() async { + if (_state == DeviceConnectionState.disconnected) return; + _setState(DeviceConnectionState.disconnecting); + try { + await _home.disconnect(deviceId); + } finally { + _setState(DeviceConnectionState.disconnected); + _close(); + } + } + + // ---------- 内部 ---------- + + void _requireReady() { + if (_disposed || _state != DeviceConnectionState.ready) { + throw DeviceException(DeviceErrorCode.noActiveSession); + } + } + + void _setState(DeviceConnectionState s) { + if (_state == s) return; + _state = s; + if (_evt.isClosed) return; + _evt.add(DeviceSessionEvent( + type: DeviceSessionEventType.connectionStateChanged, + deviceId: deviceId, + connectionState: s, + )); + } + + void _completeReady() { + _readyTimer?.cancel(); + _readyTimer = null; + final c = _readyCompleter; + _readyCompleter = null; + if (c != null && !c.isCompleted) c.complete(this); + } + + void _completeReadyError(Object err) { + _readyTimer?.cancel(); + _readyTimer = null; + final c = _readyCompleter; + _readyCompleter = null; + if (c != null && !c.isCompleted) c.completeError(err); + } + + Future _close({bool silent = false}) async { + if (_disposed) return; + _disposed = true; + if (!silent) onClosed(); + if (!_evt.isClosed) await _evt.close(); + } +} diff --git a/local_plugins/device_jieli/pubspec.yaml b/local_plugins/device_jieli/pubspec.yaml new file mode 100644 index 000000000..2b92c6a1c --- /dev/null +++ b/local_plugins/device_jieli/pubspec.yaml @@ -0,0 +1,73 @@ +name: device_jieli +description: "JieLi Home SDK Flutter plugin" +publish_to: none +version: 0.0.1 +homepage: + +environment: + sdk: ^3.8.1 + flutter: '>=3.3.0' + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: ^2.0.2 + device_plugin_interface: + path: ../device_plugin_interface + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + # The 'pluginClass' specifies the class (in Java, Kotlin, Swift, Objective-C, etc.) + # which should be registered in the plugin registry. This is required for + # using method channels. + # The Android 'package' specifies package in which the registered class is. + # This is required for using method channels on Android. + # The 'ffiPlugin' specifies that native code should be built and bundled. + # This is required for using `dart:ffi`. + # All these are used by the tooling to maintain consistency when + # adding or updating assets for this project. + plugin: + platforms: + android: + package: com.jielihome.jielihome + pluginClass: JielihomePlugin + + # To add assets to your plugin package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/to/asset-from-package + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/to/font-from-package diff --git a/local_plugins/device_plugin_interface/android/build.gradle b/local_plugins/device_plugin_interface/android/build.gradle new file mode 100644 index 000000000..821b1198b --- /dev/null +++ b/local_plugins/device_plugin_interface/android/build.gradle @@ -0,0 +1,37 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:8.7.3" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0" + } +} + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +android { + namespace "com.aiagent.device_plugin_interface" + compileSdk 35 + + defaultConfig { + minSdk 24 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" + // 注意:本包不依赖任何 native 厂商实现;它只定义接口与数据类, + // 厂商插件(device_jieli 等)依赖它来 implement,不能反向依赖。 +} diff --git a/local_plugins/device_plugin_interface/android/src/main/AndroidManifest.xml b/local_plugins/device_plugin_interface/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..31aa42826 --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallTranslation.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallTranslation.kt new file mode 100644 index 000000000..1b64ef328 --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/CallTranslation.kt @@ -0,0 +1,139 @@ +package com.aiagent.device_plugin_interface + +import kotlinx.coroutines.flow.Flow + +/** + * 通话翻译两条腿(leg)的方向: + * - [UPLINK]: 用户说出去(手机麦/耳机麦) → 翻译给对方 + * - [DOWNLINK]: 对方说过来(远端通话音) → 翻译给用户 + */ +enum class CallTranslationLeg { UPLINK, DOWNLINK } + +/** 通话翻译音频载荷的编码。 */ +enum class CallAudioCodec { OPUS, PCM_S16LE } + +/** 通话翻译协商时使用的音频格式描述。 */ +data class CallAudioFormat( + val codec: CallAudioCodec, + val sampleRate: Int, + val channels: Int, + val frameMs: Int, +) { + companion object { + val OPUS_16K_MONO_20MS = CallAudioFormat(CallAudioCodec.OPUS, 16000, 1, 20) + val PCM_S16LE_16K_MONO_20MS = CallAudioFormat(CallAudioCodec.PCM_S16LE, 16000, 1, 20) + } +} + +/** device → 编排器:一帧通话翻译音频。 */ +data class CallAudioFrame( + val leg: CallTranslationLeg, + val codec: CallAudioCodec, + val sampleRate: Int, + val channels: Int, + val bytes: ByteArray, + /** 单调递增帧序号;丢帧后**不得**回填。 */ + val sequence: Long, + /** 单调递增的捕获时间戳(微秒)。 */ + val timestampUs: Long, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CallAudioFrame) return false + return leg == other.leg && sequence == other.sequence && bytes.contentEquals(other.bytes) + } + override fun hashCode(): Int { + var r = leg.hashCode() + r = 31 * r + sequence.hashCode() + r = 31 * r + bytes.contentHashCode() + return r + } +} + +/** 编排器 → device:把已翻译的 TTS 音频回灌到指定 leg。 + * + * @param isFinal 本帧是否为当前 utterance 的最后一帧。device 端可能据此决定是否触发整段编码 / 一次性下发 + * (例如 jieli 通话翻译走 RCSP 时,需要按"一段 = 一个 AudioData"下发)。 + */ +data class TranslatedAudioFrame( + val leg: CallTranslationLeg, + val codec: CallAudioCodec, + val sampleRate: Int, + val channels: Int, + val bytes: ByteArray, + val isFinal: Boolean = false, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is TranslatedAudioFrame) return false + return leg == other.leg && codec == other.codec && bytes.contentEquals(other.bytes) + } + override fun hashCode(): Int { + var r = leg.hashCode() + r = 31 * r + codec.hashCode() + r = 31 * r + bytes.contentHashCode() + return r + } +} + +/** 通话翻译过程中由 device 抛出的错误(不致命:编排器决定是否 stop)。 */ +data class CallTranslationError( + val code: String, + val message: String, +) + +/** + * 通话翻译能力端口(device 侧)。 + * + * 由设备厂商插件按需实现,例如杰理基于 RCSP `MODE_CALL_TRANSLATION` 落地一份 + * `JieliCallTranslationPort`。`translate_server` 编排器统一通过本接口拿音频帧、 + * 回灌 TTS,不依赖具体 vendor。 + * + * 生命周期: + * ``` + * idle → enter(format) → active → exit() → idle + * ``` + * + * 铁律: + * - 同一设备同一时刻**至多一个** active 端口;重复 `enter` 抛 + * `IllegalStateException("device.call_translation.busy")`; + * - 协商格式([enter] 入参)必须出自 [supportedSourceFormats] 列出的范围; + * - [reportTranslated] 的 codec/sampleRate/channels 必须落在 [supportedSinkFormats] 内; + * - [exit] 后 [audioFrames]/[errors] 仍可订阅,但不再产新事件。 + */ +interface DeviceCallTranslationPort { + /** 该端口能向上派发的音频源格式集合。 */ + fun supportedSourceFormats(): Set + + /** 该端口能接受的 TTS 回灌格式集合。 */ + fun supportedSinkFormats(): Set + + /** + * 进入通话翻译模式。 + * + * @param sourceFormat 协商好的源音频格式(必须 ∈ [supportedSourceFormats])。 + * device 侧据此决定是否做 OPUS↔PCM 转换:例如耳机原生发 OPUS,agent 只吃 + * PCM 时编排器协商出 PCM,device 内部解码后再派发到 [audioFrames]。 + */ + fun enter(sourceFormat: CallAudioFormat) + + /** + * 上行/下行音频帧。`enter` 之后开始派发,`exit` 后停止。 + * 必须是 broadcast / replayable 不变的实现,编排器多次订阅安全。 + */ + val audioFrames: Flow + + /** + * 把已翻译的 TTS 音频回灌到指定 leg。 + * + * @throws IllegalArgumentException codec 不在 [supportedSinkFormats] 内 + * @throws IllegalStateException 当前未在 active 状态 + */ + fun reportTranslated(frame: TranslatedAudioFrame) + + /** 退出通话翻译模式,归还设备 RCSP / 音频通道。 */ + fun exit() + + /** 错误流。**不致命**——编排器自行决定是否 [exit]。 */ + val errors: Flow +} diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt new file mode 100644 index 000000000..667755066 --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/DevicePluginInterfacePlugin.kt @@ -0,0 +1,9 @@ +package com.aiagent.device_plugin_interface + +import io.flutter.embedding.engine.plugins.FlutterPlugin + +/** No-op plugin class — device_plugin_interface only provides native interfaces and data classes. */ +class DevicePluginInterfacePlugin : FlutterPlugin { + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {} + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {} +} diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceManager.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceManager.kt new file mode 100644 index 000000000..a37ceb4fa --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceManager.kt @@ -0,0 +1,93 @@ +package com.aiagent.device_plugin_interface + +import kotlinx.coroutines.flow.Flow + +/** 容器 → app 的聚合事件类型(与 Dart [DeviceManagerEventType] 对齐)。 */ +enum class DeviceManagerEventType { + MANAGER_READY, + VENDOR_CHANGED, + BLUETOOTH_STATE_CHANGED, + SCAN_STARTED, + SCAN_STOPPED, + DEVICE_DISCOVERED, + ACTIVE_SESSION_CHANGED, + SESSION_EVENT, + /** + * 设备完整快照变化。**任何**改动(连接态、电量、固件、活跃 session 切换) + * 都会产生一份新的快照供 Dart 直接 mirror,Dart 端不再做字段 diff。 + */ + SNAPSHOT_UPDATED, + ERROR, +} + +data class DeviceManagerEvent( + val type: DeviceManagerEventType, + val vendorKey: String? = null, + val bluetoothEnabled: Boolean? = null, + val discovered: DiscoveredDevice? = null, + val activeDeviceId: String? = null, + val sessionEvent: DeviceSessionEvent? = null, + /** + * SNAPSHOT_UPDATED / ACTIVE_SESSION_CHANGED / SESSION_EVENT 一律附带;null + * 表示当前没有 active session(vendor 未选 / 已断开)。 + * 由 [com.aiagent.device_manager.DefaultNativeDeviceManager] 在每次状态变化 + * 时统一序列化,供 Dart MethodChannel facade 整体替换缓存。 + */ + val sessionSnapshot: Map? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) + +/** 设备触发 agent 的事件(PTT / 翻译键 / 挂断键)。 */ +data class DeviceAgentTrigger( + val deviceId: String, + val kind: Kind, + val payload: Map = emptyMap(), +) { + enum class Kind { CHAT, TRANSLATE, STOP } +} + +/** + * 设备路由器 native 抽象(与 Dart [DeviceManager] 等价)。 + * + * 进程内单例(典型实现:[com.aiagent.device_manager.DefaultNativeDeviceManager])。 + * + * 单设备 / 单 vendor 模型: + * - [activeSession] 至多一个;新 [connect] 自动断开旧 session + * - [useVendor] 是原子操作:stopScan → disconnect → 旧 plugin.dispose → + * 新 plugin.initialize → emit [DeviceManagerEventType.VENDOR_CHANGED] + * - 切换中所有 scan/connect 抛 [DeviceException]([DeviceErrorCode.VENDOR_SWITCHING]) + */ +interface NativeDeviceManager { + /** 当前 active vendor key;未选为 null。 */ + val activeVendor: String? + + val activeCapabilities: Set + + val activeSession: NativeDeviceSession? + + /** 列出全部已注册的 vendor(来自 [NativeDevicePluginRegistry])。 */ + fun listVendors(): List + + /** 切换到指定 vendor;vendor=null 等价于 [clearVendor]。 */ + fun useVendor(vendorKey: String, config: DevicePluginConfig) + + /** 清掉当前 vendor:disconnect → dispose 当前 plugin。 */ + fun clearVendor() + + fun startScan(filter: DeviceScanFilter? = null, timeoutMs: Long? = null) + fun stopScan() + fun bondedDevices(): List + + fun connect(deviceId: String, options: DeviceConnectOptions? = null): NativeDeviceSession + fun disconnect() + + /** 容器级聚合事件流(多订阅安全)。 */ + val eventStream: Flow + + /** 设备主动触发 agent 的事件流。 */ + val agentTriggers: Flow + + fun initialize() + fun dispose() +} diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDevicePlugin.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDevicePlugin.kt new file mode 100644 index 000000000..548cde6fa --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDevicePlugin.kt @@ -0,0 +1,149 @@ +package com.aiagent.device_plugin_interface + +import kotlinx.coroutines.flow.Flow + +/** + * 厂商插件 → 容器的事件类型。 + */ +enum class DevicePluginEventType { + PLUGIN_READY, + PLUGIN_DISPOSED, + BLUETOOTH_STATE_CHANGED, + SCAN_STARTED, + SCAN_STOPPED, + DEVICE_DISCOVERED, + BOND_STATE_CHANGED, + CONNECTION_STATE_CHANGED, + DEVICE_INFO_UPDATED, + WAKE_TRIGGERED, + CUSTOM_EVENT, + ERROR, +} + +data class DevicePluginEvent( + val type: DevicePluginEventType, + val deviceId: String? = null, + val discovered: DiscoveredDevice? = null, + val connectionState: DeviceConnectionState? = null, + val deviceInfo: DeviceInfo? = null, + val bluetoothEnabled: Boolean? = null, + val bondState: String? = null, // 'none' | 'bonding' | 'bonded' + val wake: DeviceWakeEvent? = null, + val customKey: String? = null, + val customPayload: Map? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) + +/** + * 厂商插件 native 抽象(与 Dart [DevicePlugin] 等价)。 + * + * 每家芯片/SDK 一份实现: + * - `JieliNativeDevicePlugin` (设备厂商:杰理) + * - 后续:恒玄 / 高通 / 中科蓝讯 等 + * + * 实现方在自己 FlutterPlugin.onAttachedToEngine 时把工厂注册到 + * [NativeDevicePluginRegistry],例如: + * ```kotlin + * NativeDevicePluginRegistry.register("jieli") { JieliNativeDevicePlugin(context) } + * ``` + * + * 容器 [NativeDeviceManager] 通过 vendorKey 查工厂,按需创建实例并管理生命周期。 + * + * 铁律(与 Dart 接口完全对齐): + * 1. `initialize` 幂等;重复调用先释放旧资源。 + * 2. `dispose` 必须释放**所有** native 资源 + close 事件流。 + * 3. `dispose` 后任何方法抛 [IllegalStateException]。 + * 4. 通过 [eventStream]([Flow])派发事件,多订阅安全(用 `MutableSharedFlow.replay=0`)。 + */ +interface NativeDevicePlugin { + val vendorKey: String + val displayName: String + val capabilities: Set + + /** 初始化 SDK;失败抛 [DeviceException]。 */ + fun initialize(config: DevicePluginConfig) + + fun startScan(filter: DeviceScanFilter? = null, timeoutMs: Long? = null) + fun stopScan() + fun isScanning(): Boolean + + /** 已配对设备快照(含历史/系统配对)。 */ + fun bondedDevices(): List + + /** + * 建立会话;必须在握手完成(state=READY)之后才 return; + * 失败抛 [DeviceException]。 + */ + fun connect(deviceId: String, options: DeviceConnectOptions? = null): NativeDeviceSession + + /** 当前 active session(最多一个)。 */ + val activeSession: NativeDeviceSession? + + /** 全局事件流:扫描/连接/蓝牙开关/错误等。 */ + val eventStream: Flow + + /** 释放 SDK + 断开所有 session + close stream。 */ + fun dispose() +} + +/** 厂商插件工厂。 */ +typealias NativeDevicePluginFactory = () -> NativeDevicePlugin + +/** + * 厂商插件全局注册表(参照 NativeAgentRegistry 模式)。 + * + * 每个 device_ 包在自己的 FlutterPlugin.onAttachedToEngine 时注册一次工厂, + * 容器 [NativeDeviceManager] 通过 vendorKey 创建实例。 + */ +object NativeDevicePluginRegistry { + + private val factories = mutableMapOf() + private val descriptors = mutableMapOf() + + /** 注册厂商(重复 register 用新工厂覆盖旧的)。 */ + fun register( + vendorKey: String, + displayName: String, + capabilities: Set, + factory: NativeDevicePluginFactory, + ) { + factories[vendorKey] = factory + descriptors[vendorKey] = VendorDescriptor( + vendorKey = vendorKey, + displayName = displayName, + capabilities = capabilities, + ) + } + + fun unregister(vendorKey: String) { + factories.remove(vendorKey) + descriptors.remove(vendorKey) + } + + fun create(vendorKey: String): NativeDevicePlugin { + val f = factories[vendorKey] + ?: throw DeviceException( + DeviceErrorCode.NOT_SUPPORTED, + "vendor \"$vendorKey\" not registered. Available: ${factories.keys}", + ) + return f() + } + + fun listVendors(): List = descriptors.values.toList() + + fun isRegistered(vendorKey: String): Boolean = factories.containsKey(vendorKey) +} + +/** 用于 listVendors() 给上层 UI 渲染下拉选项。 */ +data class VendorDescriptor( + val vendorKey: String, + val displayName: String, + val capabilities: Set, +) { + fun toMap(): Map = mapOf( + "vendorKey" to vendorKey, + "displayName" to displayName, + "capabilities" to capabilities.map { it.name }, + ) +} diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt new file mode 100644 index 000000000..bf3a6b408 --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceSession.kt @@ -0,0 +1,72 @@ +package com.aiagent.device_plugin_interface + +import kotlinx.coroutines.flow.Flow + +/** 单设备会话事件类型(与 Dart [DeviceSessionEventType] 对齐)。 */ +enum class DeviceSessionEventType { + CONNECTION_STATE_CHANGED, + DEVICE_INFO_UPDATED, + FEATURE, + RSSI_UPDATED, + RAW, + ERROR, +} + +data class DeviceSessionEvent( + val type: DeviceSessionEventType, + val deviceId: String, + val connectionState: DeviceConnectionState? = null, + val deviceInfo: DeviceInfo? = null, + val feature: DeviceFeatureEvent? = null, + val rssi: Int? = null, + val raw: Map? = null, + val errorCode: String? = null, + val errorMessage: String? = null, +) + +/** + * 已连接设备的 native 会话句柄(与 Dart [DeviceSession] 等价)。 + * + * 生命周期:connecting → linkConnected → ready → (业务调用) → disconnecting → disconnected + * + * 铁律: + * 1. `disconnected` 之后任何方法抛 [IllegalStateException] + * 2. [eventStream] 是 broadcast / multi-subscriber-safe + * 3. [invokeFeature] / [readBattery] / [refreshInfo] 在状态非 READY 时抛 + * [DeviceException]([DeviceErrorCode.NO_ACTIVE_SESSION]),禁止静默排队 + */ +interface NativeDeviceSession { + val deviceId: String + val vendor: String + val state: DeviceConnectionState + val info: DeviceInfo + val capabilities: Set + + val eventStream: Flow + + fun readRssi(): Int + + /** 读电量;不支持时返回 null(不抛)。 */ + fun readBattery(): Int? + + /** 强制刷新 [DeviceInfo](电量/固件版本/序列号)。 */ + fun refreshInfo(): DeviceInfo + + /** + * 调用厂商私有 feature。`featureKey` 形如: + * - `common.battery.subscribe` + * - `jieli.translation.start` + * - `bes.eq.set` + */ + fun invokeFeature(featureKey: String, args: Map = emptyMap()): Map + + /** + * 该 session 暴露的通话翻译端口;vendor 无该能力时返回 null。 + * + * 给 native 编排器(translate_server)vendor-agnostic 拿端口的入口—— + * 编排器只调本接口,不需要 import 任何具体厂商类型。 + */ + fun callTranslationPort(): DeviceCallTranslationPort? = null + + fun disconnect() +} diff --git a/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt new file mode 100644 index 000000000..6f7dc2ff7 --- /dev/null +++ b/local_plugins/device_plugin_interface/android/src/main/kotlin/com/aiagent/device_plugin_interface/NativeDeviceTypes.kt @@ -0,0 +1,197 @@ +package com.aiagent.device_plugin_interface + +/** 设备能力(与 Dart [DeviceCapability] 一一对应)。 */ +enum class DeviceCapability { + SCAN, + CONNECT, + BOND, + BATTERY, + RSSI, + OTA, + EQ, + ANC, + KEY_MAPPING, + WEAR_DETECTION, + MIC_UPLINK, + SPEAKER_DOWNLINK, + WAKE_WORD, + ON_DEVICE_CALL_TRANSLATION, + ON_DEVICE_FACE_TO_FACE_TRANSLATION, + ON_DEVICE_RECORDING_TRANSLATION, + CUSTOM_COMMAND, +} + +/** 连接状态机(与 Dart [DeviceConnectionState] 一一对应)。 */ +enum class DeviceConnectionState { + DISCONNECTED, + CONNECTING, + LINK_CONNECTED, + READY, + DISCONNECTING, +} + +/** 厂商插件初始化配置。 */ +data class DevicePluginConfig( + val appKey: String? = null, + val appSecret: String? = null, + val extra: Map = emptyMap(), +) { + companion object { + fun fromMap(map: Map<*, *>?): DevicePluginConfig { + if (map == null) return DevicePluginConfig() + return DevicePluginConfig( + appKey = map["appKey"] as? String, + appSecret = map["appSecret"] as? String, + extra = (map["extra"] as? Map<*, *>) + ?.entries + ?.associate { it.key.toString() to it.value } + ?: emptyMap(), + ) + } + } +} + +/** 扫描过滤条件。 */ +data class DeviceScanFilter( + val namePrefix: String? = null, + val serviceUuids: List? = null, + val vendor: String? = null, + val minRssi: Int? = null, +) { + companion object { + fun fromMap(map: Map<*, *>?): DeviceScanFilter? { + if (map == null) return null + @Suppress("UNCHECKED_CAST") + return DeviceScanFilter( + namePrefix = map["namePrefix"] as? String, + serviceUuids = (map["serviceUuids"] as? List<*>)?.map { it.toString() }, + vendor = map["vendor"] as? String, + minRssi = (map["minRssi"] as? Number)?.toInt(), + ) + } + } +} + +/** 连接选项。 */ +data class DeviceConnectOptions( + val timeoutMs: Long = 15_000, + val extra: Map = emptyMap(), +) { + companion object { + fun fromMap(map: Map<*, *>?): DeviceConnectOptions { + if (map == null) return DeviceConnectOptions() + return DeviceConnectOptions( + timeoutMs = (map["timeoutMs"] as? Number)?.toLong() ?: 15_000, + extra = (map["extra"] as? Map<*, *>) + ?.entries + ?.associate { it.key.toString() to it.value } + ?: emptyMap(), + ) + } + } +} + +/** 扫描发现的设备。 */ +data class DiscoveredDevice( + val id: String, + val name: String, + val rssi: Int? = null, + val vendor: String? = null, + val metadata: Map = emptyMap(), +) { + fun toMap(): Map = mapOf( + "id" to id, + "name" to name, + "rssi" to rssi, + "vendor" to vendor, + "metadata" to metadata, + ) +} + +/** 已连接设备的信息快照。 */ +data class DeviceInfo( + val id: String, + val name: String, + val vendor: String, + val firmwareVersion: String? = null, + val hardwareVersion: String? = null, + val serialNumber: String? = null, + val manufacturer: String? = null, + val model: String? = null, + val batteryPercent: Int? = null, + val metadata: Map = emptyMap(), +) { + fun toMap(): Map = mapOf( + "id" to id, + "name" to name, + "vendor" to vendor, + "firmwareVersion" to firmwareVersion, + "hardwareVersion" to hardwareVersion, + "serialNumber" to serialNumber, + "manufacturer" to manufacturer, + "model" to model, + "batteryPercent" to batteryPercent, + "metadata" to metadata, + ) + + fun copyWith( + name: String? = null, + firmwareVersion: String? = null, + hardwareVersion: String? = null, + serialNumber: String? = null, + manufacturer: String? = null, + model: String? = null, + batteryPercent: Int? = null, + metadata: Map? = null, + ): DeviceInfo = DeviceInfo( + id = id, + vendor = vendor, + name = name ?: this.name, + firmwareVersion = firmwareVersion ?: this.firmwareVersion, + hardwareVersion = hardwareVersion ?: this.hardwareVersion, + serialNumber = serialNumber ?: this.serialNumber, + manufacturer = manufacturer ?: this.manufacturer, + model = model ?: this.model, + batteryPercent = batteryPercent ?: this.batteryPercent, + metadata = metadata ?: this.metadata, + ) +} + +/** 唤醒原因(设备 → app)。 */ +enum class WakeReason { PTT, VOICE_WAKE, TRANSLATE_KEY, HANGUP } + +data class DeviceWakeEvent( + val deviceId: String, + val reason: WakeReason, + val payload: Map = emptyMap(), +) + +/** 通用语义事件。`key` 命名空间:`common.` / `.`。 */ +data class DeviceFeatureEvent( + val key: String, + val data: Map = emptyMap(), +) + +/** 设备异常(plugin 抛出,路由器据此 emit error / 中断)。 */ +class DeviceException( + val code: String, + message: String? = null, + cause: Throwable? = null, +) : RuntimeException(message ?: code, cause) + +/** 错误码命名空间。 */ +object DeviceErrorCode { + const val PERMISSION_DENIED = "device.permission_denied" + const val BLUETOOTH_OFF = "device.bluetooth_off" + const val CONNECT_TIMEOUT = "device.connect_timeout" + const val CONNECT_FAILED = "device.connect_failed" + const val HANDSHAKE_FAILED = "device.handshake_failed" + const val DISCONNECTED_REMOTE = "device.disconnected_remote" + const val AUDIO_BUSY = "device.audio_busy" + const val NOT_SUPPORTED = "device.not_supported" + const val FORMAT_UNSUPPORTED = "device.format_unsupported" + const val VENDOR_SWITCHING = "device.vendor_switching" + const val NO_ACTIVE_SESSION = "device.no_active_session" + const val PLUGIN_NOT_INITIALIZED = "device.plugin_not_initialized" + const val INVALID_ARGUMENT = "device.invalid_argument" +} diff --git a/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart b/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart new file mode 100644 index 000000000..3db4b1dba --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/device_plugin_interface.dart @@ -0,0 +1,8 @@ +export 'src/audio_format.dart'; +export 'src/device_capability.dart'; +export 'src/device_event.dart'; +export 'src/device_exception.dart'; +export 'src/device_info.dart'; +export 'src/device_manager.dart'; +export 'src/device_plugin.dart'; +export 'src/device_session.dart'; diff --git a/local_plugins/device_plugin_interface/lib/src/audio_format.dart b/local_plugins/device_plugin_interface/lib/src/audio_format.dart new file mode 100644 index 000000000..6707a8cb0 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/audio_format.dart @@ -0,0 +1,92 @@ +import 'package:flutter/foundation.dart'; + +/// 音频编码。 +enum AudioCodec { + /// Opus 编码(推荐,设备端→app 上行默认) + opus, + + /// 16-bit 小端 PCM + pcm16le, +} + +/// 设备插件与 agent 容器之间统一的音频格式约束。 +/// +/// 接口层规定: +/// - 所有 [DeviceAudioSource] 派发的 [AudioFrame] 必须满足 [AudioFormat.standard]; +/// - 所有 [DeviceAudioSink] 接受的 [AudioFrame] 必须满足 [AudioFormat.standard]。 +/// +/// 厂商插件内部如果拿到的格式不一致(如 8 kHz Opus、PCM、非 20ms 帧长), +/// **必须**在 vendor SDK 出口做重采样 / 转码 / 重新切帧后再送入 Stream。 +/// 转换不可行时,在 [DevicePlugin.initialize] 阶段抛出 +/// `DeviceException('device.format_unsupported')`,让上层禁用该厂商。 +@immutable +class AudioFormat { + const AudioFormat({ + required this.codec, + required this.sampleRate, + required this.channels, + required this.frameMs, + }); + + /// 接口层规定的统一格式:Opus / 16 kHz / mono / 20 ms 一帧。 + /// + /// 适用于"app 直接转发设备端 OPUS 帧"的零转码路径(旧版设计)。 + static const AudioFormat standard = AudioFormat( + codec: AudioCodec.opus, + sampleRate: 16000, + channels: 1, + frameMs: 20, + ); + + /// PCM 16-bit LE / 16 kHz / mono / 20 ms 一帧 = 640 字节。 + /// + /// 当前 jieli 通话翻译路径已经在 native 端把 OPUS 解码成 PCM 再上推, + /// translate_server / agent 直接吃这个格式即可(无需自己解码)。 + static const AudioFormat pcm16kMono20ms = AudioFormat( + codec: AudioCodec.pcm16le, + sampleRate: 16000, + channels: 1, + frameMs: 20, + ); + + final AudioCodec codec; + final int sampleRate; + final int channels; + + /// 单帧时长(毫秒)。 + final int frameMs; + + bool isCompatibleWith(AudioFormat other) => + codec == other.codec && + sampleRate == other.sampleRate && + channels == other.channels && + frameMs == other.frameMs; + + @override + String toString() => + 'AudioFormat(${codec.name}, ${sampleRate}Hz, ${channels}ch, ${frameMs}ms)'; +} + +/// 单帧音频。 +@immutable +class AudioFrame { + const AudioFrame({ + required this.bytes, + required this.format, + required this.sequence, + required this.timestampUs, + }); + + /// 帧负载。Opus 时为压缩字节;PCM 时为 16-bit 小端样本。 + final Uint8List bytes; + + /// 实际帧格式。理论上等于 [AudioFormat.standard], + /// 实现方仍需逐帧附带,便于 agent 容器层防御性校验。 + final AudioFormat format; + + /// 严格单调递增的帧序号;丢帧后**不得**回填。 + final int sequence; + + /// 单调递增的捕获时间戳(微秒)。 + final int timestampUs; +} diff --git a/local_plugins/device_plugin_interface/lib/src/device_capability.dart b/local_plugins/device_plugin_interface/lib/src/device_capability.dart new file mode 100644 index 000000000..192b6c35e --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_capability.dart @@ -0,0 +1,56 @@ +/// 厂商插件声明自身支持哪些能力。 +/// +/// 业务层据此显示/隐藏 UI(OTA 按钮、翻译模式选择等), +/// **禁止**写厂商分支判断。 +enum DeviceCapability { + /// 扫描发现设备(必备) + scan, + + /// 建立连接(必备) + connect, + + /// 设备配对/绑定状态查询 + bond, + + /// 读电量 + battery, + + /// 读 RSSI + rssi, + + /// OTA 固件升级 + ota, + + /// 均衡器(EQ)调参 + eq, + + /// 主动降噪 / 通透模式(ANC/Transparency) + anc, + + /// 按键自定义映射 + keyMapping, + + /// 佩戴检测事件 + wearDetection, + + /// 设备 → app 麦克风上行(音频通道在 native 内部维护,不直接暴露给 Flutter) + micUplink, + + /// app → 设备扬声器下行(音频通道在 native 内部维护) + speakerDownlink, + + /// 设备本地唤醒(PTT 键 / 语音唤醒),派发 [DeviceWakeEvent] + wakeWord, + + /// 设备端通话翻译(设备自己处理混音、回放,app 侧只下发开关与目标语言) + onDeviceCallTranslation, + + /// 设备端面对面翻译(同上) + onDeviceFaceToFaceTranslation, + + /// 设备端录音翻译 + onDeviceRecordingTranslation, + + /// 自定义 RCSP / 私有协议命令([DeviceSession.invokeFeature] 可用) + customCommand, +} diff --git a/local_plugins/device_plugin_interface/lib/src/device_event.dart b/local_plugins/device_plugin_interface/lib/src/device_event.dart new file mode 100644 index 000000000..b0e1ab099 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_event.dart @@ -0,0 +1,243 @@ +import 'package:flutter/foundation.dart'; + +import 'device_info.dart'; + +// --------------------------------------------------------------------------- +// DevicePlugin 侧(厂商插件 → 容器) +// --------------------------------------------------------------------------- + +/// 厂商插件向容器派发的事件类型。 +/// +/// 命名约定与 §2.1 / §5.x 一致,状态变更以"已发生"语义命名。 +enum DevicePluginEventType { + pluginReady, + pluginDisposed, + bluetoothStateChanged, + scanStarted, + scanStopped, + deviceDiscovered, + bondStateChanged, + connectionStateChanged, + deviceInfoUpdated, + wakeTriggered, + customEvent, + error, +} + +@immutable +class DevicePluginEvent { + const DevicePluginEvent({ + required this.type, + this.deviceId, + this.discovered, + this.connectionState, + this.deviceInfo, + this.bluetoothEnabled, + this.bondState, + this.wake, + this.customKey, + this.customPayload, + this.errorCode, + this.errorMessage, + }); + + final DevicePluginEventType type; + final String? deviceId; + + // deviceDiscovered + final DiscoveredDevice? discovered; + + // connectionStateChanged + final DeviceConnectionState? connectionState; + + // deviceInfoUpdated + final DeviceInfo? deviceInfo; + + // bluetoothStateChanged + final bool? bluetoothEnabled; + + // bondStateChanged:'none' | 'bonding' | 'bonded' + final String? bondState; + + // wakeTriggered + final DeviceWakeEvent? wake; + + // customEvent:厂商私有事件,业务层一般忽略 + final String? customKey; + final Map? customPayload; + + // error + final String? errorCode; + final String? errorMessage; +} + +// --------------------------------------------------------------------------- +// DeviceSession 侧(连接后,按 deviceId 维度的事件) +// --------------------------------------------------------------------------- + +/// 单设备会话事件。 +enum DeviceSessionEventType { + /// 连接状态变化(connecting → linkConnected → ready / disconnecting / disconnected) + connectionStateChanged, + + /// 设备信息更新(电量、固件版本…) + deviceInfoUpdated, + + /// 通用语义事件——按键、佩戴、ANC 切换等。 + /// 由厂商插件把 RCSP / GATT 通知翻译为统一的 [DeviceFeatureEvent.key]。 + feature, + + /// RSSI 周期上报 + rssiUpdated, + + /// 厂商私有原始通知(不要在业务层直接消费) + raw, + + error, +} + +@immutable +class DeviceSessionEvent { + const DeviceSessionEvent({ + required this.type, + required this.deviceId, + this.connectionState, + this.deviceInfo, + this.feature, + this.rssi, + this.raw, + this.errorCode, + this.errorMessage, + }); + + final DeviceSessionEventType type; + final String deviceId; + + final DeviceConnectionState? connectionState; + final DeviceInfo? deviceInfo; + final DeviceFeatureEvent? feature; + final int? rssi; + final Map? raw; + + final String? errorCode; + final String? errorMessage; +} + +/// 通用语义事件。`key` 命名空间: +/// - `common.`:跨厂商通用(`battery`, `wear.on`, `wear.off`, +/// `key.click`, `key.double_click`, `key.long_press`, `anc.changed`...) +/// - `.`:厂商私有(业务层若要消费需注明强耦合) +@immutable +class DeviceFeatureEvent { + const DeviceFeatureEvent({required this.key, this.data = const {}}); + final String key; + final Map data; +} + +// --------------------------------------------------------------------------- +// 设备主动唤醒(→ DeviceManager.agentTriggers) +// --------------------------------------------------------------------------- + +/// 触发原因:决定路由器启动哪个 agent。 +enum WakeReason { + /// 长按 / 单击 PTT 键 + ptt, + + /// 设备端语音唤醒("小艺小艺"等) + voiceWake, + + /// 翻译键 + translateKey, + + /// 挂断键 / 取消 + hangup, +} + +@immutable +class DeviceWakeEvent { + const DeviceWakeEvent({ + required this.deviceId, + required this.reason, + this.payload = const {}, + }); + + final String deviceId; + final WakeReason reason; + + /// 厂商扩展字段(按下时长、是否长按等) + final Map payload; +} + +// --------------------------------------------------------------------------- +// DeviceManager 侧(容器对外的聚合事件) +// --------------------------------------------------------------------------- + +enum DeviceManagerEventType { + managerReady, + vendorChanged, + bluetoothStateChanged, + scanStarted, + scanStopped, + deviceDiscovered, + activeSessionChanged, + sessionEvent, + + /// 整个 active session 的最新完整快照(任何字段变化都重发)。Flutter 侧 + /// 直接 mirror,没有自己的 state diff 逻辑——一切以最近一次快照为准。 + snapshotUpdated, + error, +} + +@immutable +class DeviceManagerEvent { + const DeviceManagerEvent({ + required this.type, + this.vendorKey, + this.bluetoothEnabled, + this.discovered, + this.activeDeviceId, + this.sessionEvent, + this.sessionSnapshot, + this.errorCode, + this.errorMessage, + }); + + final DeviceManagerEventType type; + + final String? vendorKey; + final bool? bluetoothEnabled; + final DiscoveredDevice? discovered; + + /// activeSessionChanged 时为新 active deviceId;断开时为 null + final String? activeDeviceId; + + /// sessionEvent 时携带,包含具体语义事件 + final DeviceSessionEvent? sessionEvent; + + /// 整个 active session 的最新完整快照——native 在 snapshotUpdated / + /// activeSessionChanged / sessionEvent / vendorChanged 时都会附带,null 表示 + /// 当前没有 active session。Dart 直接以此覆盖缓存。 + final Map? sessionSnapshot; + + final String? errorCode; + final String? errorMessage; +} + +/// 设备主动触发 agent 的事件(由 [DeviceManager.agentTriggers] 派发)。 +@immutable +class DeviceAgentTrigger { + const DeviceAgentTrigger({ + required this.deviceId, + required this.kind, + this.payload = const {}, + }); + + final String deviceId; + + /// 'chat' | 'translate' | 'stop' + final DeviceAgentTriggerKind kind; + + final Map payload; +} + +enum DeviceAgentTriggerKind { chat, translate, stop } diff --git a/local_plugins/device_plugin_interface/lib/src/device_exception.dart b/local_plugins/device_plugin_interface/lib/src/device_exception.dart new file mode 100644 index 000000000..4928cd5da --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_exception.dart @@ -0,0 +1,46 @@ +/// 统一错误码命名空间:`device.`。 +/// +/// 通用约定: +/// - `permission_denied` / `bluetooth_off` 是 UI 引导用户的固定码; +/// - `audio_busy` 表示同一 session 已有 active 上行/下行; +/// - `not_supported` 表示厂商插件未声明对应能力; +/// - `format_unsupported` 出现在 `DevicePlugin.initialize` 阶段,用以禁用厂商。 +class DeviceErrorCode { + DeviceErrorCode._(); + + static const String permissionDenied = 'device.permission_denied'; + static const String bluetoothOff = 'device.bluetooth_off'; + static const String scanFailed = 'device.scan_failed'; + static const String connectTimeout = 'device.connect_timeout'; + static const String connectFailed = 'device.connect_failed'; + static const String handshakeFailed = 'device.handshake_failed'; + static const String disconnectedRemote = 'device.disconnected_remote'; + static const String disconnectedLocal = 'device.disconnected_local'; + static const String writeFailed = 'device.write_failed'; + static const String readFailed = 'device.read_failed'; + static const String featureFailed = 'device.feature_failed'; + static const String audioBusy = 'device.audio_busy'; + static const String notSupported = 'device.not_supported'; + static const String formatUnsupported = 'device.format_unsupported'; + static const String vendorSwitching = 'device.vendor_switching'; + static const String noActiveSession = 'device.no_active_session'; + static const String invalidArgument = 'device.invalid_argument'; + static const String pluginNotInitialized = 'device.plugin_not_initialized'; +} + +/// 设备域统一异常。 +/// +/// - 所有插件方法**禁止**让 SDK 原始异常逃逸;要么 catch 后转换为 +/// [DeviceException],要么以 `error` 事件派发到事件流。 +/// - [code] 必须使用 [DeviceErrorCode] 中预定义的字符串;新增码必须同步更新。 +class DeviceException implements Exception { + DeviceException(this.code, [this.message, this.cause]); + + final String code; + final String? message; + final Object? cause; + + @override + String toString() => + 'DeviceException($code${message == null ? '' : ': $message'})'; +} diff --git a/local_plugins/device_plugin_interface/lib/src/device_info.dart b/local_plugins/device_plugin_interface/lib/src/device_info.dart new file mode 100644 index 000000000..482585ad0 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_info.dart @@ -0,0 +1,139 @@ +import 'package:flutter/foundation.dart'; + +/// 扫描时发现的设备(尚未连接)。 +@immutable +class DiscoveredDevice { + const DiscoveredDevice({ + required this.id, + required this.name, + this.rssi, + this.vendor, + this.metadata = const {}, + }); + + /// 厂商插件不透明 ID。 + /// - 杰里:BLE MAC(与 [metadata]['edrAddr'] 一同标识真实设备) + /// - BLE 通用:MAC(Android)/ CBPeripheral.identifier(iOS) + /// + /// **业务层只把它当字符串用**,不要解析或假设格式。 + final String id; + + /// 广播名/友好名。 + final String name; + + /// 信号强度(dBm),未知为 null。 + final int? rssi; + + /// 来源厂商插件 [DevicePlugin.vendorKey]。 + final String? vendor; + + /// 厂商相关的扩展字段(edrAddr / deviceType / advertisementData / serviceUuids 等)。 + /// key 由具体厂商插件文档约定。 + final Map metadata; + + @override + String toString() => 'DiscoveredDevice($name, $id, rssi=$rssi)'; +} + +/// 已连接设备的信息快照。 +@immutable +class DeviceInfo { + const DeviceInfo({ + required this.id, + required this.name, + required this.vendor, + this.firmwareVersion, + this.hardwareVersion, + this.serialNumber, + this.manufacturer, + this.model, + this.batteryPercent, + this.metadata = const {}, + }); + + final String id; + final String name; + final String vendor; + + final String? firmwareVersion; + final String? hardwareVersion; + final String? serialNumber; + final String? manufacturer; + final String? model; + + /// 0..100;未知为 null。 + final int? batteryPercent; + + final Map metadata; + + DeviceInfo copyWith({ + String? name, + String? firmwareVersion, + String? hardwareVersion, + String? serialNumber, + String? manufacturer, + String? model, + int? batteryPercent, + Map? metadata, + }) { + return DeviceInfo( + id: id, + vendor: vendor, + name: name ?? this.name, + firmwareVersion: firmwareVersion ?? this.firmwareVersion, + hardwareVersion: hardwareVersion ?? this.hardwareVersion, + serialNumber: serialNumber ?? this.serialNumber, + manufacturer: manufacturer ?? this.manufacturer, + model: model ?? this.model, + batteryPercent: batteryPercent ?? this.batteryPercent, + metadata: metadata ?? this.metadata, + ); + } +} + +/// 连接状态机(细化以容纳"链路通了但 RCSP 协议尚未握手"的中间态)。 +enum DeviceConnectionState { + disconnected, + + /// 已发起连接,链路(ACL/GATT/SPP)尚未建立 + connecting, + + /// 蓝牙链路已建立,等待厂商私有协议握手(如杰里 RCSP init、GATT 服务发现等) + linkConnected, + + /// 协议握手完成,业务可调用 read/write/feature + ready, + + /// 已发起断开,等待对端确认 + disconnecting, +} + +/// 扫描过滤条件。所有字段均可选;`null` 表示不过滤。 +@immutable +class DeviceScanFilter { + const DeviceScanFilter({ + this.namePrefix, + this.serviceUuids, + this.vendor, + this.minRssi, + }); + + final String? namePrefix; + final List? serviceUuids; + final String? vendor; + final int? minRssi; +} + +/// 连接选项。 +@immutable +class DeviceConnectOptions { + const DeviceConnectOptions({ + this.timeout = const Duration(seconds: 15), + this.extra = const {}, + }); + + final Duration timeout; + + /// 厂商私有连接参数(如杰里的 connectWay/deviceType)。 + final Map extra; +} diff --git a/local_plugins/device_plugin_interface/lib/src/device_manager.dart b/local_plugins/device_plugin_interface/lib/src/device_manager.dart new file mode 100644 index 000000000..c7f14e0e6 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_manager.dart @@ -0,0 +1,107 @@ +import 'dart:async'; + +import 'device_capability.dart'; +import 'device_event.dart'; +import 'device_info.dart'; +import 'device_plugin.dart'; +import 'device_session.dart'; + +/// 容器层入口(业务层只见此一个)。 +/// +/// 单设备模型: +/// - `activeSession` 至多一个;切换厂商或连接新设备会先断开旧 session; +/// - 唤醒/翻译键事件统一从 [agentTriggers] 派发; +/// - 音频通道(耳机麦/扬声器)一律由 native 内部管理,Flutter 不直接持有。 +/// +/// 所有方法在 `dispose` 后必须抛 `StateError`。 +abstract class DeviceManager { + // ---------- 厂商管理 ---------- + + /// 已注册的厂商(在 app 启动时由各 plugin 主动 register)。 + Map get registeredVendors; + + /// 注册厂商工厂。`replace=false` 时同 key 重复注册抛 [DeviceException]。 + void registerVendor( + DevicePluginDescriptor descriptor, { + bool replace = false, + }); + + /// 当前激活的厂商 key;未选择为 null。 + String? get activeVendor; + + /// 当前激活厂商的能力集合(便于 UI 一行判断);无 active 时空集合。 + Set get activeCapabilities; + + /// 切换厂商。流程(原子操作): + /// + /// 1. `stopScan()` + /// 2. 断开 [activeSession](若有) + /// 3. dispose 旧 plugin + /// 4. 创建并 initialize 新 plugin + /// 5. 派发 `vendorChanged` + /// + /// 切换中所有 scan/connect 调用抛 `DeviceException('device.vendor_switching')`。 + Future useVendor(String vendorKey, DevicePluginConfig config); + + /// 清空当前 vendor —— 断开 active session、dispose 旧 plugin, + /// 派发 `vendorChanged(vendorKey=null)`。无 active vendor 时 no-op。 + Future clearVendor(); + + // ---------- 扫描 / 连接 ---------- + + Future startScan({DeviceScanFilter? filter, Duration? timeout}); + Future stopScan(); + + /// 当前蓝牙开关状态。仅由 native 实现真实查询,pure-Dart 默认返回 true。 + Future isBluetoothEnabled(); + + /// 已配对设备(来自 active plugin)。 + Future> bondedDevices(); + + /// 连接设备。如果当前已有 active session,先断开旧的再连新的, + /// 并派发 `activeSessionChanged`。 + Future connect( + String deviceId, { + DeviceConnectOptions? options, + }); + + /// 断开当前 session(无 session 时 no-op)。 + Future disconnect(); + + /// 当前 active session;未连接为 null。 + DeviceSession? get activeSession; + + // ---------- agent 协调 ---------- + + /// 设备主动触发 agent 的事件(PTT/语音唤醒/翻译键)。 + /// 由 `agents_server` 中的 `DeviceAgentRouter` 监听。 + Stream get agentTriggers; + + /// 容器全局事件流:扫描结果、连接状态、active session 变更等。 + Stream get eventStream; + + // ---------- 生命周期 ---------- + + Future initialize(); + Future dispose(); +} + +/// 厂商描述符(用于注册表 / 设置 UI 列表)。 +class DevicePluginDescriptor { + DevicePluginDescriptor({ + required this.vendorKey, + required this.displayName, + required this.factory, + this.configSchema = DevicePluginConfigSchema.empty, + this.declaredCapabilities = const {}, + }); + + final String vendorKey; + final String displayName; + final DevicePluginFactory factory; + final DevicePluginConfigSchema configSchema; + + /// 能力声明的"广告版"——容器在 plugin 未 initialize 前用它做 UI 引导。 + /// 真实可用能力以 [DevicePlugin.capabilities] 为准。 + final Set declaredCapabilities; +} diff --git a/local_plugins/device_plugin_interface/lib/src/device_plugin.dart b/local_plugins/device_plugin_interface/lib/src/device_plugin.dart new file mode 100644 index 000000000..bfd486e14 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_plugin.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'device_capability.dart'; +import 'device_event.dart'; +import 'device_info.dart'; +import 'device_session.dart'; + +/// 厂商插件传入的初始化配置。`vendorKey` / `extra` 字段由具体厂商插件文档约定。 +@immutable +class DevicePluginConfig { + const DevicePluginConfig({this.appKey, this.appSecret, this.extra = const {}}); + + final String? appKey; + final String? appSecret; + final Map extra; +} + +/// 配置 schema(用于 [DeviceManager] 在设置界面动态生成厂商配置表单)。 +@immutable +class DevicePluginConfigField { + const DevicePluginConfigField({ + required this.key, + required this.label, + this.required = false, + this.secret = false, + this.defaultValue, + this.helpText, + }); + + final String key; + final String label; + final bool required; + + /// 是否敏感字段(密码框 + 不在 UI 明文展示)。 + final bool secret; + final Object? defaultValue; + final String? helpText; +} + +@immutable +class DevicePluginConfigSchema { + const DevicePluginConfigSchema({required this.fields}); + final List fields; + static const empty = DevicePluginConfigSchema(fields: []); +} + +/// 厂商插件抽象——每家芯片/SDK 一份实现。 +/// +/// 容器层 (`DeviceManager`) 通过工厂注册 [DevicePlugin];同一时刻 +/// **最多一个** plugin 处于 `initialized` 状态(多 SDK 并存大概率冲突蓝牙栈)。 +/// +/// 生命周期与铁律见 §2.1: +/// - `initialize` 幂等:重复调用先释放旧资源; +/// - `dispose` 必须释放**所有**原生资源 + close 事件流; +/// - `dispose` 后任何方法抛 `StateError`。 +abstract class DevicePlugin { + /// 厂商唯一键:`jieli` / `bes` / `qcc` / `bluetrum` / `mock` ... + String get vendorKey; + + /// 展示用名称。 + String get displayName; + + /// 该厂商插件支持的能力集合。`micUplink` / `connect` / `scan` 是事实必备项。 + Set get capabilities; + + /// 配置 schema:UI 据此渲染设置表单。 + DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; + + /// 初始化 SDK。失败抛 [DeviceException],容器据此禁用该厂商。 + Future initialize(DevicePluginConfig config); + + /// 扫描相关。 + Future startScan({DeviceScanFilter? filter, Duration? timeout}); + Future stopScan(); + Future isScanning(); + + /// 已配对设备快照(含历史/系统配对,但**不一定**当前可连)。 + Future> bondedDevices(); + + /// 建立会话。 + /// + /// 实现要求: + /// 1. 完成"链路 + 协议握手"两段后再 resolve; + /// 握手未完成不要返回 [DeviceSession],否则上层 ready 的语义被破坏。 + /// 2. 失败抛 [DeviceException],code 取自 [DeviceErrorCode]: + /// `connect_timeout` / `connect_failed` / `handshake_failed`。 + Future connect( + String deviceId, { + DeviceConnectOptions? options, + }); + + /// 当前 active session(最多一个);未连接为 null。 + DeviceSession? get activeSession; + + /// 全局事件流:扫描/连接/蓝牙开关/错误等。 + Stream get eventStream; + + /// 释放 SDK + 断开所有 session + close stream。 + Future dispose(); +} + +/// 厂商插件工厂。容器层通过 `registerVendor(factory)` 注册; +/// `useVendor(key)` 时调用 [createPlugin] 创建实例。 +typedef DevicePluginFactory = DevicePlugin Function(); diff --git a/local_plugins/device_plugin_interface/lib/src/device_session.dart b/local_plugins/device_plugin_interface/lib/src/device_session.dart new file mode 100644 index 000000000..7136c5ca9 --- /dev/null +++ b/local_plugins/device_plugin_interface/lib/src/device_session.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'device_capability.dart'; +import 'device_event.dart'; +import 'device_info.dart'; + +/// 已连接设备的会话句柄。生命周期: +/// +/// ``` +/// connecting → linkConnected → ready → (业务调用) → disconnecting → disconnected +/// ``` +/// +/// 铁律: +/// 1. `disconnected` 之后任何方法必须抛 `StateError`; +/// 2. `eventStream` 必须用 `StreamController.broadcast()`,`disconnect` 时关闭; +/// 3. 同一时刻最多一个 active [DeviceAudioSource] + 一个 active [DeviceAudioSink], +/// 重复 open 抛 `DeviceException('device.audio_busy')`; +/// 4. `invokeFeature` / `read*` / `write*` 在状态非 `ready` 时抛 +/// `DeviceException('device.no_active_session')`,禁止静默排队。 +abstract class DeviceSession { + String get deviceId; + String get vendor; + + DeviceConnectionState get state; + DeviceInfo get info; + Set get capabilities; + + /// 会话事件流(连接态、电量、按键、佩戴...)。 + Stream get eventStream; + + // -------- 通用查询 -------- + + Future readRssi(); + + Future readBattery(); + + /// 强制刷新 [DeviceInfo](电量/固件版本/序列号)。 + Future refreshInfo(); + + // -------- 厂商私有特性 -------- + + /// 调用厂商私有 feature。`featureKey` 形如: + /// - `common.battery.subscribe` + /// - `jieli.translation.start` + /// - `bes.eq.set` + /// + /// 返回 `Map` 或 throw [DeviceException]。 + Future> invokeFeature( + String featureKey, [ + Map args = const {}, + ]); + + // -------- 关闭 -------- + + Future disconnect(); +} diff --git a/local_plugins/device_plugin_interface/pubspec.yaml b/local_plugins/device_plugin_interface/pubspec.yaml new file mode 100644 index 000000000..03a4b62bb --- /dev/null +++ b/local_plugins/device_plugin_interface/pubspec.yaml @@ -0,0 +1,23 @@ +name: device_plugin_interface +description: Abstract contracts for hardware device plugins (BLE / RCSP / vendor SDK). +publish_to: none +version: 0.1.0 + +environment: + sdk: ^3.8.1 + flutter: ">=3.3.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: com.aiagent.device_plugin_interface + pluginClass: DevicePluginInterfacePlugin diff --git a/pubspec.yaml b/pubspec.yaml index f4de6fa47..e48b79e7e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,8 +101,8 @@ dependencies: path: local_plugins/spotify_sdk ota: path: local_plugins/ota - ble_service: - path: local_plugins/ble_service + device_jieli: + path: local_plugins/device_jieli classic_bluetooth: path: local_plugins/classic_bluetooth agent_service: