import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:bluetooth_manager/bluetooth_manager.dart' as bes; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/utils/logger.dart'; import 'agent_module_defaults.dart'; import 'background_launch_consent.dart'; /// 恒玄(BES/Bestechnic)芯片经典蓝牙连接服务。 /// /// 项目里有两条完全独立的耳机连接链路: /// - 杰理(Jieli):BLE,见 [BleManager] + `device_jieli` 插件,走 GATT 扫描/绑定。 /// - 恒玄(BES):经典蓝牙 SPP(Android 走 RFCOMM,iOS 走固定 UUID 的 GATT 桥接), /// 本服务封装 `bluetooth_manager` 插件,从系统已配对设备里选择并连接, /// 不做 BLE 广播扫描。两条链路互不干扰,具体某个产品用哪条链路由后台 /// 下发的 [DBProduct.devicetype](classicBluetoothHeadset=恒玄经典蓝牙, /// bleBluetoothDoubleHeadset/bleBluetoothSingleHeadset=杰理 BLE)决定。 class BesBluetoothService extends GetxService { static const String _tag = 'BesBluetoothService'; static BesBluetoothService get to => Get.find(); final _manager = bes.BluetoothManager(); final _storage = GetStorage(); StreamSubscription? _statusSubscription; StreamSubscription? _cmdEventSubscription; /// 0=未连接 1=连接中 2=已连接(与原生侧约定一致) final RxInt deviceStatus = 0.obs; final RxString connectedDeviceName = ''.obs; final RxString connectedDeviceId = ''.obs; /// 固件版本(连接后发 0xAA 0x06 查询,回包 0x07/0x86 解析出来) final RxString deviceVersion = ''.obs; /// 耳机端通话中(0x02 / 0x03 按键事件) final RxBool isCallOngoing = false.obs; /// 电量百分比,-1 表示还没拿到(固件没上报或还没查到)。 /// 左右耳分开上报时取较低的一侧——用户关心的是「还能用多久」。 final RxInt batteryLevel = (-1).obs; final RxInt batteryLeft = (-1).obs; final RxInt batteryRight = (-1).obs; final RxInt batteryCase = (-1).obs; static const String _pairedDevicesKey = 'bes_paired_devices'; static const String _lastDeviceKey = 'bes_last_device'; /// 设备端按键事件(type: startMicRecording / stopMicRecording / startAI …)。 /// 只做转发,具体业务由订阅方决定;与支架的 [HolderRecordingService] 完全分开。 final RxString lastCmdType = ''.obs; /// 最近一次连接过的耳机名/地址——断连后设备页仍要显示这台设备的主图 String get lastDeviceName => (_storage.read(_lastDeviceKey)?['name'] as String?) ?? ''; String get lastDeviceAddress => (_storage.read(_lastDeviceKey)?['address'] as String?) ?? ''; bool get hasPairedDevice => getPairedDevices().isNotEmpty; @override void onInit() { super.onInit(); _listen(); Future.delayed(const Duration(seconds: 2), _bootstrap); } /// 冷启动引导。两端的前提完全不同: /// /// - **Android**:原生 `BluetoothManager.setup()` 在引擎 attach 时就跑了,ACL 广播 /// 接收器已经挂上,`initialize()` 实际是申请蓝牙权限,所以只在绑过设备时才调, /// 免得没绑设备的用户一进 App 就被弹权限框。 /// - **iOS**:CBCentralManager 是在 `initialize()` 里才创建的,不调就什么都没有—— /// 既收不到状态流,`registerForConnectionEvents` 也没注册, /// 耳机连上时的 `.peerConnected` 被动接管自然不会发生。所以 iOS 必须无条件初始化。 Future _bootstrap() async { if (Platform.isIOS) { try { await initialize(); } catch (e) { Logger.e(_tag, '恒玄蓝牙初始化失败: $e'); } } await tryAutoConnect(); } /// 冷启动后回连最近一台已配对耳机。没绑过设备则什么都不做。 /// /// iOS 侧 `connect(deviceId:)` 走的是 `retrievePeripherals` + `attachAndConnect`, /// 与 `.peerConnected` 被动接管同一条路径:耳机若已经在系统层连着,会立刻回调 /// didConnect(不会弹任何系统框);不在身边则请求一直挂着,下次 connect 时被 /// resetConnectionState 清掉。所以两端都可以主动发起,只是含义不同。 Future tryAutoConnect() async { if (deviceStatus.value != 0) return; // 冷启动自动回连属于「无用户操作时的后台连接」,与支架一样要先取得授权 if (!BackgroundLaunchConsent.granted) { Logger.i(_tag, '用户未开启设备自动连接,跳过恒玄自动回连'); return; } final saved = getPairedDevices(); if (saved.isEmpty) return; final d = saved.first; final id = d['address'] as String? ?? ''; if (id.isEmpty) return; try { if (Platform.isAndroid) { // Android 的自动回连靠原生 ACL 广播 + lastDeviceId,冷启动后 // lastDeviceId 是空的,必须由 App 主动发起一次把它记下来 await initialize(); } await connectToDevice(id, d['name'] as String? ?? ''); } catch (e) { Logger.e(_tag, '恒玄自动回连失败: $e'); deviceStatus.value = 0; } } @override void onClose() { _statusSubscription?.cancel(); _cmdEventSubscription?.cancel(); super.onClose(); } void _listen() { _statusSubscription = _manager.onDeviceStatusChanged().listen((event) async { deviceStatus.value = event; Logger.i(_tag, '恒玄设备状态变化: $event'); if (event == 2) { // 首次连上耳机 → AI 页铺开耳机的默认功能编排 AgentDefaults.onEarphoneConnected(); try { if (connectedDeviceId.value.isEmpty) { // 被动连接场景(原生 ACL 广播触发的自动回连)没人调过 connectToDevice, // 向原生查询当前已连接外设补齐 id/name。 final info = await _manager.getConnectedDevice(); // await 期间可能已经掉线,避免把陈旧信息写回 if (info != null && deviceStatus.value == 2) { connectedDeviceId.value = info['address'] ?? ''; connectedDeviceName.value = info['name'] ?? ''; } } // 数据通道就绪后再发 0x06 获取固件版本 await Future.delayed(const Duration(milliseconds: 500)); if (deviceStatus.value == 2) { await sendData([0xAA, 0x06]); await queryBattery(); // App 可能是在耳机已经通话中时才连上/重启回连的——0xD1/0xD2/0x02/0x03 // 都是推送事件,错过了就再也等不到,必须连上后主动查一次。 await queryCallState(); // 命令探测改成手动触发(BesBluetoothService.to.probeCommands()), // 不再每次连上都扫——扫一轮要几十秒,日志也吵 } } catch (e) { Logger.e(_tag, '连接后握手失败(不致命): $e'); } } else if (event == 0) { connectedDeviceId.value = ''; connectedDeviceName.value = ''; deviceVersion.value = ''; isCallOngoing.value = false; batteryLevel.value = -1; batteryLeft.value = -1; batteryRight.value = -1; batteryCase.value = -1; } }); _cmdEventSubscription = _manager.onDeviceCmdEvent().listen((event) { Logger.d(_tag, '恒玄设备命令事件: $event'); final type = event['type']; if (type is String && type.isNotEmpty) lastCmdType.value = type; switch (type) { // 0x02/0x03 是去电起止,0xD1/0xD2 是通话接通/挂断, // 两类都算「通话中」——通话录音和通话翻译都以此为准 case 'startMakeCall': case 'startCall': isCallOngoing.value = true; break; case 'stopMakeCall': case 'stopCall': isCallOngoing.value = false; break; case 'unknown': // 原始帧回包都以 type=unknown 上报,按 CMD 二级分发 _handleRawFrame(event['data']); break; } }); } /// 【调试用】命令探测:逐个下发候选 CMD,看固件回什么。 /// /// 恒玄这套私有协议的电量命令没有文档,deepvoice 也没实现过,只能实测。 /// 跳过已知会切换工作模式的命令(0x5x/0x6x 那批:进通话/录音/AI 模式), /// 只扫低位查询类命令。仅在 debug 包里调用。 Future probeCommands() async { const skip = {0x06, 0x0E, 0x51, 0x52, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A}; // 第一轮 0x01~0x20 已扫过(拿到按键配置 0x8C、EQ 0x90 等), // 这轮扫 0x21~0x50 和 0x6B~0x7F,跳过会切工作模式的 0x5x/0x6x for (var cmd = 0x21; cmd <= 0x7F; cmd++) { if (cmd >= 0x51 && cmd <= 0x6A) continue; if (skip.contains(cmd)) continue; if (deviceStatus.value != 2) return; Logger.i(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}'); try { await sendData([0xAA, cmd]); } catch (_) {} await Future.delayed(const Duration(milliseconds: 400)); } Logger.i(_tag, '[探测] 结束'); } /// 刷新耳机当前状态。 /// /// deepvoice 的做法:每次进入一个功能都重新拉一遍设备状态,而不是只在连接 /// 时查一次——耳机可能在 App 后台时被摘下、换过配置、或重连过。 /// 这里一次性把已知的查询命令都发一遍: /// - `AA 06` 固件版本(回 0x86) /// - `AA 0C` 按键配置(回 0x8C) /// - `AA 10` EQ 配置(回 0x90) /// 回包各自在 [_handleRawFrame] 里解析并刷新对应的 Rx。 Future refreshDeviceState() async { if (!isConnected) return; try { await sendData([0xAA, 0x06]); await Future.delayed(const Duration(milliseconds: 120)); await sendData([0xAA, 0x0C]); await Future.delayed(const Duration(milliseconds: 120)); await sendData([0xAA, 0x10]); await Future.delayed(const Duration(milliseconds: 120)); await queryCallState(); Logger.i(_tag, '已刷新耳机状态'); } catch (e) { Logger.e(_tag, '刷新耳机状态失败: $e'); } } /// 查询电量。 /// /// deepvoice 那边没实现过电量查询,恒玄这套私有协议里电量用哪个 CMD /// 目前没有文档。这里按同族固件常见的 0x0A 试查一次;固件不认的话会静默 /// 忽略,不影响其它功能。真实 CMD 一旦确认,改这一处即可。 Future queryBattery() async { try { await sendData([0xAA, 0x0A]); } catch (e) { Logger.e(_tag, '查询电量失败: $e'); } } /// 请求上报通话状态(`AA 0E`),耳机回 `0x8E`: /// data[6] 0x01=来电 / 0x02=通话中 / 0x03=通话结束。 /// 原生侧已把 0x02/0x03 解析成 startMakeCall/stopMakeCall 事件,与 /// 0xD1/0xD2/0x02/0x03 的实时上报走同一路,回来后自动刷新 [isCallOngoing]。 /// 通话状态只靠耳机推送同步不过来(App 没跑起来时的事件永远错过), /// 这条查询命令是唯一能补的办法,见连接握手和 [refreshDeviceState]。 Future queryCallState() async { try { await sendData([0xAA, 0x0E]); } catch (e) { Logger.e(_tag, '查询通话状态失败: $e'); } } /// type=unknown 的原始帧回包(SOF 0xBB)按 CMD 分发。 void _handleRawFrame(dynamic raw) { if (raw is! List) return; final data = raw.cast(); if (data.length < 2 || data[0] != 0xBB) return; switch (data[1] & 0xFF) { case 0x07: // 主动上报固件版本 case 0x86: // 响应 0x06 获取固件版本 _parseFirmwareVersion(data); break; case 0x0A: // 实测这帧是连接后周期性上报的遥测: // BB 0A 28 | 02 01 63 | 02 02 60 | 02 03 46 | 02 04 89 | 02 05 6E // | 0D 06 .. <本机MAC> .. <对端MAC> // 值每秒都在变(99→97、70→73),还有 >100 的(137/110), // 不像电量,更像链路质量/信号强度。先只记日志,不当电量显示, // 免得界面上跳一个会乱跳的假电量。固件确认后再决定怎么用。 Logger.d(_tag, '设备遥测(0x0A) 原始=$data'); break; case 0x8A: // 响应 0xAA 0x0A 查询电量(当前固件不响应) _parseBattery(data); break; case 0x18: // 连接后周期性上报,结构是 [len,attr,value] × 2(attr 0x01左 / 0x02右)。 // 实测值恒为 1,不像百分比;可能是等级或入耳状态。 // 固件确认它是电量后,把下面这行换成 _parseBattery(data) 即可。 Logger.d(_tag, '设备状态上报(0x18) 原始=$data'); break; default: // 没识别的回包打出来,方便按真实固件补协议(电量 CMD 就靠这个定位) Logger.d(_tag, '未识别回包 CMD=0x${data[1].toRadixString(16)} 原始=$data'); } } /// 电量回包:与固件版本同构 —— BB CMD len + 若干块 [len, 属性, 数据…], /// 属性 0x01左 / 0x02右 / 0x04盒,数据首字节为百分比。 /// 收到超出 0~100 的值一律丢弃,宁可不显示也不显示错的。 void _parseBattery(List data) { var p = 4; var hit = false; while (p + 1 < data.length) { final blockLen = data[p]; if (blockLen < 2 || p + blockLen >= data.length) break; final attr = data[p + 1]; final value = data[p + 2]; if (value >= 0 && value <= 100) { hit = true; switch (attr) { case 0x01: batteryLeft.value = value; break; case 0x02: batteryRight.value = value; break; case 0x04: batteryCase.value = value; break; } } p += blockLen + 1; } if (!hit) return; final levels = [batteryLeft.value, batteryRight.value] .where((v) => v >= 0) .toList(); if (levels.isNotEmpty) { batteryLevel.value = levels.reduce((a, b) => a < b ? a : b); Logger.i(_tag, '恒玄耳机电量: ${batteryLevel.value}%' ' (L=${batteryLeft.value} R=${batteryRight.value} 仓=${batteryCase.value})'); } } /// 固件版本回包:BB CMD len(2) + 若干版本块 [len, 属性, 8字节版本], /// 属性 0x01左 / 0x02右 / 0x04盒;版本取版本块后 3 字节,优先左耳。 void _parseFirmwareVersion(List data) { String? left, right, box; var p = 4; // 跳过 SOF + CMD + 2 字节长度 while (p + 1 < data.length) { final blockLen = data[p]; if (blockLen < 2 || p + blockLen >= data.length) break; final attr = data[p + 1]; final version = data.sublist(p + 2, p + 1 + blockLen); if (version.length >= 8) { final v = '${version[5]}.${version[6]}.${version[7]}'; switch (attr) { case 0x01: left = v; break; case 0x02: right = v; break; case 0x04: box = v; break; } } p += blockLen + 1; } final parsed = left ?? right ?? box; if (parsed != null) { deviceVersion.value = parsed; Logger.i(_tag, '恒玄耳机固件版本: $parsed'); } } /// 初始化原生蓝牙管理器,并把本地已保存的配对设备同步为授权白名单。 Future initialize() async { final ok = await _manager.initialize(); Logger.i(_tag, '恒玄蓝牙初始化: $ok'); await _syncAuthorizedDevices(); return ok; } /// 原生侧当前实际连着的经典蓝牙设备(地址/名字)。 /// /// 耳机是 TWS 双地址:系统配对列表里那条记录的地址,和真正能跑 SPP 的地址 /// 不一定是同一个(实测 DEEPVOICE 配对列表是 …C1:C4,SPP 走的是 …BE:F8), /// 所以连接前后都要以原生上报的地址为准。 Future?> currentConnectedDevice() => _manager.getConnectedDevice(); /// 设备上行的音频 PCM 流(原生已把 mSBC / G.722 解码成 16kHz/16bit 单声道)。 /// /// 注意:现场录音场景下耳机是**主麦和前馈麦交替**上行的, /// 取音时要隔帧取主麦,不能全收(全收会变成两路混在一起、语速翻倍)。 Stream get audioStream => _manager.onDeviceDataReceived(); /// 分类音频事件流:`{'type': 'spkData'|'micData'|..., 'data': Uint8List}`。 /// /// 与 [audioStream] 的区别很关键:那条是不带类型的裸流,下游只能靠帧交替 /// 猜方向——一旦中途开始订阅就会永久错相。这条由原生直接标好了 /// 「对端(spkData) / 本端(micData)」,通话翻译必须用它,否则两个语种会喂反。 Stream get audioEventStream => _manager.onDeviceAudioEvent(); /// 本端麦克风 PCM(通话模式 mode=0x03 的 mic 路),16k/16bit/mono Stream get micPcmStream => _typedPcm('micData'); /// 对端通话音 PCM(通话模式 mode=0x03 的 spk 路),16k/16bit/mono Stream get spkPcmStream => _typedPcm('spkData'); Stream _typedPcm(String type) => audioEventStream .where((e) => e['type'] == type) .map((e) => e['data'] as Uint8List) .where((d) => d.isNotEmpty); /// 启动/停止通话翻译的下行 TTS 通道(`AA 56` 84 字节包, /// G.722 编码与 20ms 节流都在原生做,Dart 只丢 PCM) Future startTranslationDownlink() => _manager.startCallDownlink(); Future stopTranslationDownlink() => _manager.stopCallDownlink(); /// 下发一段译文 TTS 的 PCM。 /// [leg] 'A' = 己方译文给对端听;'B' = 对端译文给己方听。 Future pushTranslationTtsPcm(String leg, Uint8List pcm) => _manager.pushTtsPcm(leg, pcm); /// 进入通话模式(`AA 51`):耳机开始双路上行(对端 + 本端), /// mode=0x03 交织,原生解码后走 [audioStream]。通话录音/通话翻译都用它。 Future startCallMode() => sendData([0xAA, 0x51]); /// 退出通话模式(`AA 52`) Future stopCallMode() => sendData([0xAA, 0x52]); /// 通知耳机「ASR 已开启」(`AA 57`),收到 0xD1 之后发 Future notifyAsrStarted() => sendData([0xAA, 0x57]); /// 通知耳机「ASR 已关闭」(`AA 58`) Future notifyAsrStopped() => sendData([0xAA, 0x58]); /// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`; /// 耳机按键触发时也会主动上报 `0xE6`。 Future startFaceToFace() => sendData([0xAA, 0x66]); /// 结束面对面翻译(`AA 67`),设备回 `0xE7` Future stopFaceToFace() => sendData([0xAA, 0x67]); /// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`, /// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。 Future startMicRecording() => sendData([0xAA, 0x59]); /// 停止设备端主麦录音(`AA 5A`),设备回 `0xDA` Future stopMicRecording() => sendData([0xAA, 0x5A]); /// 系统已配对的经典蓝牙设备列表(不做 BLE 扫描)。 Future>> getBondedDevices() { return _manager.getBondedDevices(); } Future connectToDevice(String deviceId, String deviceName) async { Logger.i(_tag, '连接恒玄设备: $deviceName ($deviceId)'); deviceStatus.value = 1; connectedDeviceId.value = deviceId; connectedDeviceName.value = deviceName; await _manager.connectDevice(deviceId); } Future disconnectDevice() async { await _manager.disconnect(); connectedDeviceId.value = ''; connectedDeviceName.value = ''; } Future sendData(List bytes) { return _manager.sendData(Uint8List.fromList(bytes)); } bool get isConnected => deviceStatus.value == 2; // 已配对设备持久化(与杰理的绑定设备列表分开存储) List> getPairedDevices() { final list = _storage.read(_pairedDevicesKey); if (list == null) return []; return list.map((e) => Map.from(e as Map)).toList(); } Future savePairedDevice(Map device) async { final devices = getPairedDevices(); devices.removeWhere((d) => d['address'] == device['address']); devices.insert(0, device); await _storage.write(_pairedDevicesKey, devices); await _storage.write(_lastDeviceKey, device); await _syncAuthorizedDevices(devices); } Future removePairedDevice(String address) async { final devices = getPairedDevices(); devices.removeWhere((d) => d['address'] == address); await _storage.write(_pairedDevicesKey, devices); await _syncAuthorizedDevices(devices); if (connectedDeviceId.value == address) { await disconnectDevice(); } } Future _syncAuthorizedDevices([List>? devices]) async { final ids = (devices ?? getPairedDevices()) .map((d) => d['address'] as String? ?? '') .where((id) => id.isNotEmpty) .toList(); await _manager.setAuthorizedDevices(ids); } }