import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:bluetooth_manager/bluetooth_manager.dart' as bes; import 'package:flutter/foundation.dart' show kDebugMode; 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'; import 'bes_device_auth.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; /// 耳机自报的蓝牙地址(`BB 0A` 的属性 7),格式 `11:11:22:33:33:A4`。 /// /// 与 [connectedDeviceId] 的区别:后者在 Android 上是蓝牙 MAC,在 iOS 上是 /// CoreBluetooth 的 peripheral UUID(每台 iPhone 看同一只耳机都不一样)。 /// 这个值来自耳机自己的上报,两端一致,是 iOS 上唯一能拿到真 MAC 的途径。 /// /// 每次连上时由 [_restoreDeviceMac] 按 [connectedDeviceId] 从 [_macCacheKey] /// 恢复,查不到就是空。**它属于「当前这台」,不是全局值**——早先的写法是断开 /// 不清、一直留着,换耳机之后就变成上一台的 MAC,新耳机会拿它命中旧耳机的 /// 确权白名单蒙混过关。 final RxString deviceMac = ''.obs; /// 充电中标记:电量字节的**最高位**就是充电指示位(协议 3.1.10), /// 所以电量必须 `& 0x7F` 取值,直接用原字节会得到 128~228 的假电量。 final RxBool chargingLeft = false.obs; final RxBool chargingRight = false.obs; final RxBool chargingCase = false.obs; static const String _pairedDevicesKey = 'bes_paired_devices'; static const String _lastDeviceKey = 'bes_last_device'; /// `{本地设备标识: 耳机自报的 MAC}`,持久化。 /// /// 为什么不是「断开就清 [deviceMac]」:iOS 上 [resolveDeviceMac] 拿不到缓存时 /// 要发 `AA 09` 再轮询**最多 3 秒**,而它排在确权和握手的最前面——断开就清会让 /// **每次自动回连都多等最多 3 秒**;固件 0.0.3 上 `AA 09` 已经不回 `BB 0A`, /// 那就是每次必然等满 3 秒才拿到 null。 /// /// 但也不能像原先那样「断开不清、全局留着」:换一台耳机之后那个值就是**上一台** /// 的 MAC 了,iOS 的 [resolveDeviceMac] 第一行直接返回它,于是新耳机会命中旧耳机 /// 的确权白名单蒙混过关。 /// /// 按本地标识建映射同时满足两边:同一台回连是同步命中(零等待), /// 换设备时标识不同、查不到,老老实实重查。iOS 的 peripheral UUID 对 /// 「同一台手机 + 同一只耳机」是稳定的,可长期复用。 static const String _macCacheKey = 'bes_device_mac_cache'; /// 设备端按键事件(type: startMicRecording / stopMicRecording / startAI …)。 /// 只做转发,具体业务由订阅方决定;与支架的 [HolderRecordingService] 完全分开。 final RxString lastCmdType = ''.obs; /// 最近一次连接过的耳机名/地址——断连后设备页仍要显示这台设备的主图 String get lastDeviceName => (_storage.read(_lastDeviceKey)?['name'] as String?) ?? ''; /// ⚠️ **别拿它(或 [currentConnectedDevice])去当连接候选地址。** /// 这两个值都只是把 App 自己拨过的地址回吐——插件里没有扫描/发现路径, /// 地址的唯一来源是系统配对列表;`lastDeviceId` 只在原生 `connect()` 里赋值, /// 这个 key 又来自 `savePairedDevice` 存的 `connectedDeviceId`。 /// 它们永远产生不了「新地址」,塞进候选只会把**上一台耳机**排到用户的选择前面。 /// 换耳机连不上就是这么来的,详见 bes_scan_view 的 `_connect`。 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) { try { // **无条件**向原生要当前真正连着的设备,不再以「本地值为空」为条件。 // // connectToDevice 是在调原生**之前**就乐观写了 connectedDeviceId 的, // 那个值只代表「我们想连谁」。原生若把这次 connect 丢弃、或连上的是 // 候选列表里的另一个地址,本地值就是错的;只在为空时才查询,会把这个 // 错值一路带到确权——拿 B 的 MAC 去 binddevice(绑一台没连着的耳机), // 被拒时 _reject 又按它判 isCurrent,把实际连着的那台断掉。 // // 被动连接场景(ACL 广播 / .peerConnected)本来就靠这一步补齐 id/name, // 改成无条件只是多一次 IPC,对那条路径没有影响。 final info = await _manager.getConnectedDevice(); // await 期间可能已经掉线,避免把陈旧信息写回 if (info != null && deviceStatus.value == 2) { final realId = info['address'] ?? ''; if (realId.isNotEmpty) { if (realId.toUpperCase() != connectedDeviceId.value.toUpperCase()) { Logger.w(_tag, '实际连上的设备与请求的不一致: 请求=${connectedDeviceId.value} 实际=$realId'); } connectedDeviceId.value = realId; } final realName = info['name'] ?? ''; if (realName.isNotEmpty) connectedDeviceName.value = realName; } // 身份确定之后再恢复这台设备的 MAC 缓存(见 _restoreDeviceMac) _restoreDeviceMac(); // 设备确权闸门。必须排在握手和 AI 编排之前——未确权的设备不该被 // 当成一台可用耳机对待。 // // 第一次连这台耳机才走服务端(按 MAC 校验),之后读本地白名单, // 命中就是同步返回、不产生任何延迟。所以「首连慢几百毫秒」是 // 唯一的代价,回连和离线都不受影响。 // // 只有 denied 会中止:unknown(断网/未登录/产品表没下发)一律放行, // 理由见 BesDeviceAuth 的类注释。 final auth = await BesDeviceAuth.verifyConnected(); if (auth == BesAuthResult.denied) { // 断开与提示已由 BesDeviceAuth 就地做完(此时 connectedDeviceId // 已被清空,别再拿它打日志),这里只是不再往下走 Logger.w(_tag, '设备未通过确权,已断开连接'); return; } if (deviceStatus.value != 2) return; // 确权期间掉线了 // 首次连上耳机 → AI 页铺开耳机的默认功能编排 AgentDefaults.onEarphoneConnected(); // 数据通道就绪后再发 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()), // 不再每次连上都扫——扫一轮要几十秒,日志也吵 // // TODO(临时): 固件 0.0.3 上 `AA 09` 不再回 `BB 0A`(电量帧), // 之前那张命令表是在 0.0.2 上扫出来的,升级后失效了。 // 重扫 0x01~0x20 这一段——当初就是在这段里扫出 AA 09 的。 // 0x21~0x7F 已确认整段无回包(2026-09-01 扫过 69 个命令),不用再扫。 // **重新定位到电量命令后必须删掉这段**。 if (kDebugMode) { unawaited(probeCommands(from: 0x01, to: 0x20)); } } } 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; chargingLeft.value = false; chargingRight.value = false; chargingCase.value = false; } }); _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({int from = 0x21, int to = 0x7F}) async { // 0x5x/0x6x 那批会切工作模式(进通话/录音/AI),一律不碰。 // 0x06(固件版本)/0x0E(通话状态) **故意不跳过**:重扫是为了确认新固件上 // 哪些命令还活着,跳过它们就失去了对照组。 const skip = {0x51, 0x52, 0x56, 0x57, 0x58, 0x59, 0x5A, 0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A}; Logger.i(_tag, '[探测] 开始 0x${from.toRadixString(16)}~0x${to.toRadixString(16)}'); for (var cmd = from; cmd <= to; 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, '[探测] 结束'); } /// 实测出来的命令号(2026-09-01,Echo-one 固件 0.0.2,`probeCommands` 扫 0x01~0x20 得到): /// /// | 请求 | 回包 | 含义 | /// |---|---|---| /// | `AA 09` | `BB 0A` + `BB 89`(ack) | **耳机状态:左右耳电量 / 位置 / 连接状态 / MAC** | /// | `AA 06` | `BB 86` | 固件版本 | /// | `AA 0E` | `BB 8E` | 通话状态 | /// | `AA 0B` | `BB 8B 04 00` | 未知,只回一个 0 | /// | `AA 0D` | `BB 8D 07 00 02 01 02` | 未知 | /// | `AA 0F` | `BB 8F 04 00` | 未知 | /// | `AA 19` | `BB 99 04 01` | 未知,回 1 | /// /// `BB 18` 是耳机周期主动上报的另一种状态帧(`02 01 01 02 02 00`,值 0/1), /// **不是电量**,`AA 18` 也查不出东西。 /// 刷新耳机当前状态。 /// /// deepvoice 的做法:每次进入一个功能都重新拉一遍设备状态,而不是只在连接 /// 时查一次——耳机可能在 App 后台时被摘下、换过配置、或重连过。 /// 这里一次性把已知的查询命令都发一遍: /// - `AA 06` 固件版本(回 0x86) /// - `AA 0C` 按键配置(回 0x8C) /// - `AA 10` EQ 配置(回 0x90) /// - `AA 20` 耳机状态/电量(回 0x0A) /// 回包各自在 [_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 queryBattery(); await Future.delayed(const Duration(milliseconds: 120)); await queryCallState(); Logger.i(_tag, '已刷新耳机状态'); } catch (e) { Logger.e(_tag, '刷新耳机状态失败: $e'); } } /// 查询耳机状态(电量 / 是否在盒内 / 连接状态 / 蓝牙地址)。 /// /// 协议 3.1.10 的状态帧:**APP 发 `AA 09`,耳机回 `BB 0A`** /// (状态变化时耳机也会主动推同一帧)。 /// /// 请求码 `0x09` 是**真机实测**出来的(Echo-one 固件 0.0.2),不是文档给的: /// 文档正文只写了「响应命令 20」且不带 `0x` 前缀,那个 20 既不是 `0x20` /// 也不是十进制的 `0x14`——两个都发过,**完全无回包**。别再照文档改这里。 /// /// ⚠️ 请求码和响应码差 1(`09` → `0A`),不是本机其它命令那种 /// 「响应 = 请求 | 0x80」的规律(`AA 06`→`BB 86`、`AA 0E`→`BB 8E`)。 /// 按 `|0x80` 推出来的 `AA 0A` 是错的,早期版本就栽在这上面。 /// `AA 09` 另外还会附带一个 `BB 89 04 00` 的 ack,忽略即可。 /// 取这台耳机的真实蓝牙 MAC,拿不到返回 null。 /// /// Android 直接用 [connectedDeviceId](系统给的就是 MAC);iOS 上那是 /// peripheral UUID,只能靠耳机自报的 [deviceMac],而它要等一帧 `BB 0A` /// 回来才有值——所以这里会主动发一次查询并等它到达。 /// /// [timeout] 内没等到就返回 null,调用方按「拿不到 MAC」处理,别死等。 Future resolveDeviceMac( {Duration timeout = const Duration(seconds: 3)}) async { if (Platform.isAndroid) { final id = connectedDeviceId.value; return id.isEmpty ? null : id.toUpperCase(); } if (deviceMac.value.isNotEmpty) return deviceMac.value; if (!isConnected) return null; await queryBattery(); final deadline = DateTime.now().add(timeout); while (DateTime.now().isBefore(deadline)) { if (deviceMac.value.isNotEmpty) return deviceMac.value; await Future.delayed(const Duration(milliseconds: 100)); if (!isConnected) return null; } return null; } /// 连上之后按本地设备标识恢复这台耳机的 MAC(见 [_macCacheKey])。 /// /// 必须在 [connectedDeviceId] 已经用原生真实值回填之后调用——用连接前写入的 /// 乐观值做键,等于换个方式串号。 void _restoreDeviceMac() { final id = connectedDeviceId.value; if (id.isEmpty) { deviceMac.value = ''; return; } final cache = _storage.read(_macCacheKey); final cached = cache?[id.toUpperCase()] as String?; // 查不到就清空:这次连的是另一台设备,绝不能沿用上一台的 MAC deviceMac.value = cached ?? ''; } void _rememberDeviceMac(String mac) { final id = connectedDeviceId.value; if (id.isEmpty || mac.isEmpty) return; final cache = { ...?_storage.read(_macCacheKey)?.cast(), }; final key = id.toUpperCase(); if (cache[key] == mac) return; cache[key] = mac; _storage.write(_macCacheKey, cache); } Future queryBattery() async { try { await sendData([0xAA, 0x09]); } 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: // TODO(临时/抓帧): 找 iOS 能用的真 MAC。iOS 拿不到蓝牙 MAC(CoreBluetooth // 只给 peripheral UUID),设备页显示的「MAC」和设备确权都因此失效; // 这帧的属性 6「预留」里嵌着 MAC,布局没实测过,先原样打出来分析。 // **布局确认后必须删掉这段**。 _dumpStatusFrame(data); // 耳机状态帧(协议 3.1.10):既是 `AA 20` 的响应,也会在状态变化时主动上报。 // BB 0A 28 | 02 01 2B | 02 02 18 | 02 03 64 | 02 04 85 | 02 05 6E // | 0D 06 .. | 07 07 <6字节MAC> // 属性 1=左耳电量 2=右耳电量 3=盒电量 4=耳机位置 5=连接状态 6=预留 7=蓝牙地址。 // ⚠️ 早先把这帧判成「链路质量」只记日志,是因为解析偏移和属性映射都错了 //(见 _parseBattery 的注释),解出来的是错位垃圾值,才显得「每秒乱跳、还超过 100」。 // 真机实测(Echo-one 0.0.2)这帧解出 L=69% R=86%,末尾 MAC 与设备地址逐字节吻合。 _parseBattery(data); break; case 0x89: // `AA 09` 的 ack(实测 `BB 89 04 00`,00=成功)。真正的数据在 BB 0A 里, // 这里只吞掉不处理,免得落到 default 分支刷「未识别回包」日志。 break; case 0x18: // 连接后周期性上报,结构是 [len,attr,value] × 2(attr 0x01左 / 0x02右)。 // 实测值恒为 1,不像百分比;可能是等级或入耳状态。 // 固件确认它是电量后,把下面这行换成 _parseBattery(data) 即可。 Logger.d(_tag, '设备状态上报(0x18) 原始=$data'); break; default: // 没识别的回包打出来,方便按真实固件补协议(电量 CMD 就靠这个定位)。 // 用 INFO 级 + 十六进制:DEBUG 在某些构建里被过滤掉,而十进制的 // `[187, 134, 34, ...]` 对着协议文档看要一个个换算,很容易看错。 Logger.i( _tag, '[探测] 未识别回包 CMD=0x${data[1].toRadixString(16).padLeft(2, '0')} ' '原始=${data.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ')}'); } } /// 耳机状态帧(`BB 0A`,协议 3.1.10)里的电量解析。 /// /// 帧structure:`BB | 0A | CMD Length(1) | 块… | checksum`, /// 块是 `[len, 属性, 数据…]`,**len 计的是「属性+数据」的字节数** /// (`07 07 20 FF 36 6B A4 F3` = len 7 → 属性 1 + 6 字节 MAC)。 /// /// 属性:1=左耳电量 2=右耳电量 3=充电盒电量 4=耳机位置 5=连接状态 6=预留 7=蓝牙地址。 /// /// ⚠️ 这里踩过三个坑,都会让电量永远显示不出来,且**不报任何错**: /// 1. **起点是 3 不是 4**。`_parseFirmwareVersion` 那帧的长度字段占 2 字节, /// 照抄它的 `p = 4` 用在本帧上会整体错位一格——把属性号当成块长度, /// 真实样例里第一个块长度读成 0x01 < 2 直接 break,一个值都解不出来。 /// 2. **充电盒是属性 0x03 不是 0x04**。0x04 是「耳机位置」,值经常 >100 /// (样例里 0x85),当电量读会被 0~100 的范围判丢掉。 /// 3. **电量字节最高位是充电指示位**,必须 `& 0x7F` 取百分比。 /// 直接用原字节,充电时会得到 128~228 的假电量。 void _parseBattery(List data) { var p = 3; // 跳过 SOF + CMD + 1 字节长度 var hit = false; while (p + 2 < data.length) { final blockLen = data[p]; if (blockLen < 2 || p + blockLen >= data.length) break; final attr = data[p + 1]; final raw = data[p + 2]; final level = raw & 0x7F; // 低 7 位是百分比 final charging = (raw & 0x80) != 0; // 最高位是充电指示 if (level <= 100) { switch (attr) { case 0x01: batteryLeft.value = level; chargingLeft.value = charging; hit = true; break; case 0x02: batteryRight.value = level; chargingRight.value = charging; hit = true; break; case 0x03: batteryCase.value = level; chargingCase.value = charging; hit = true; break; } } // 属性 7 = 耳机蓝牙地址(6 字节)。这是**耳机自己报上来的**真 MAC, // 不经过系统蓝牙 API,所以 iOS 上也拿得到——iOS 的 connectedDeviceId // 是 CoreBluetooth 的 peripheral UUID(每台手机看同一只耳机都不一样), // 设备页显示 MAC 和设备确权都指望这个值。 // 实测(Echo-one 0.0.3)解出 11:11:22:33:33:A4,与 Android 上 // connectedDeviceId 逐字节一致,也正是后台 license 表登记的那个。 // ⚠️ 别改用属性 6:那 12 字节里虽然也嵌着一个 MAC //(布局 `00 01 01 | <6字节> | 00 00 00`,实测 70:B3:B1:4D:33:DC), // 但它既不是耳机主地址也不是手机地址,含义至今未知,拿去确权必然对不上。 if (attr == 0x07 && blockLen >= 7 && p + 7 < data.length) { final mac = data .sublist(p + 2, p + 8) .map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()) .join(':'); if (mac != deviceMac.value) { deviceMac.value = mac; Logger.i(_tag, '耳机上报蓝牙地址: $mac'); } // 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A _rememberDeviceMac(mac); } 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); } else if (batteryCase.value >= 0) { // 只有盒子上报时(两只耳机都在盒内)退而显示盒电量 batteryLevel.value = batteryCase.value; } Logger.i( _tag, '恒玄耳机电量: ${batteryLevel.value}%' ' (L=${batteryLeft.value}${chargingLeft.value ? "⚡" : ""}' ' R=${batteryRight.value}${chargingRight.value ? "⚡" : ""}' ' 仓=${batteryCase.value}${chargingCase.value ? "⚡" : ""})'); } /// 【调试用/临时】把 `BB 0A` 状态帧逐块打出来,重点是属性 6 那 12 字节。 /// /// 目的:确认属性 6 里嵌的 MAC 到底是谁的(副耳?手机?),能不能拿来当 /// iOS 上的设备 MAC 用。布局定了就删掉这个方法和它的调用点。 void _dumpStatusFrame(List data) { String hex(Iterable b) => b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' '); String mac(Iterable b) => b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':'); Logger.i(_tag, '[抓帧] BB0A 原始 = ${hex(data)}'); var p = 3; // 与 _parseBattery 同一起点:SOF + CMD + 1 字节长度 while (p + 1 < data.length) { final blockLen = data[p]; if (blockLen < 2 || p + blockLen >= data.length) break; final attr = data[p + 1]; final body = data.sublist(p + 2, p + 1 + blockLen); final note = attr == 6 ? ' ← 预留(找MAC) 可能的MAC切片: ' '[0..5]=${body.length >= 6 ? mac(body.sublist(0, 6)) : "-"} ' '[3..8]=${body.length >= 9 ? mac(body.sublist(3, 9)) : "-"} ' '[6..11]=${body.length >= 12 ? mac(body.sublist(6, 12)) : "-"}' : (attr == 7 ? ' ← 蓝牙地址 = ${mac(body)}' : ''); Logger.i( _tag, '[抓帧] attr=$attr len=$blockLen body(${body.length}B)=' '${hex(body)}$note'); p += blockLen + 1; } } /// 固件版本回包: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); } /// 把一台设备移出「已配对」列表(自动回连列表),正连着就顺带断开。 /// /// [address] 可以是本地设备标识([connectedDeviceId],即 pairedDevices 里存的 /// 那个串)或服务端记录的真 MAC(`UserDevice.devicemac`)——**两者在 iOS 上是 /// 完全不同的值**(前者是 CoreBluetooth peripheral UUID),所以这里两种都认。 /// /// ⚠️ 只按 [connectedDeviceId] 比较会让 iOS 上的解绑**静默失效**: /// 传进来的是真 MAC,跟 UUID 永远不相等 → 既不断开连接、也删不掉 /// pairedDevices,表现为「服务端解绑成功了,App 却还连着、还会自动回连」。 /// 耳机自报的 [deviceMac] 是唯一能把「服务端的 MAC」和「本地的 UUID」 /// 对应起来的桥。 /// /// 地址比较一律大小写不敏感——存进去的大小写取决于当时是谁写的。 Future removePairedDevice(String address) async { final target = address.toUpperCase(); // 要删的是不是「当前连着的这一台」:本地标识对上、或自报 MAC 对上都算 final bool isCurrent = (connectedDeviceId.value.isNotEmpty && connectedDeviceId.value.toUpperCase() == target) || (deviceMac.value.isNotEmpty && deviceMac.value.toUpperCase() == target); // 待清理的本地标识:传进来的那个,外加(若是当前设备)它的本地 id—— // iOS 上 pairedDevices 存的是后者,只按前者删会漏。 final ids = {target}; if (isCurrent && connectedDeviceId.value.isNotEmpty) { ids.add(connectedDeviceId.value.toUpperCase()); } final devices = getPairedDevices(); devices.removeWhere( (d) => ids.contains((d['address'] as String? ?? '').toUpperCase())); await _storage.write(_pairedDevicesKey, devices); // lastDevice 是 tryAutoConnect 的另一个入口,只清 pairedDevices 不够 if (ids.contains( (_storage.read(_lastDeviceKey)?['address'] as String? ?? '') .toUpperCase())) { await _storage.remove(_lastDeviceKey); } await _syncAuthorizedDevices(devices); // 解绑 = 「不要它了」,把这些本地标识对应的 MAC 缓存一并删掉, // 下次再连上时重新查一遍(与 BesDeviceAuth.forget 清白名单是同一个用意: // 不清的话会命中缓存跳过重新确权)。 _forgetDeviceMac(ids); if (isCurrent) { deviceMac.value = ''; await disconnectDevice(); } } void _forgetDeviceMac(Set upperIds) { final cache = _storage.read(_macCacheKey); if (cache == null || cache.isEmpty) return; final next = {...cache.cast()} ..removeWhere((k, _) => upperIds.contains(k.toUpperCase())); if (next.length != cache.length) _storage.write(_macCacheKey, next); } Future _syncAuthorizedDevices([List>? devices]) async { final ids = (devices ?? getPairedDevices()) .map((d) => d['address'] as String? ?? '') .where((id) => id.isNotEmpty) .toList(); await _manager.setAuthorizedDevices(ids); } }