You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

855 lines
41 KiB

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 '../../data/services/background_launch_consent.dart';
import 'bes_device_auth.dart';
import 'bes_protocol.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<BesBluetoothService>();
final _manager = bes.BluetoothManager();
final _storage = GetStorage();
/// 原生插件句柄。只给同目录的 [BesDevicePlugin] 用(AI 通道、下行编码)。
bes.BluetoothManager get manager => _manager;
/// 确权闸门(按 MAC 向服务端登记),由插件注入;null = 不确权。
BesDeviceAuth? auth;
StreamSubscription? _statusSubscription;
StreamSubscription? _cmdEventSubscription;
/// 连接后补查电量的重试定时器,断开时取消(见 [_scheduleBatteryRetry])
Timer? _batteryRetryTimer;
/// 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<Map>(_lastDeviceKey)?['name'] as String?) ?? '';
/// ⚠️ **别拿它(或 [currentConnectedDevice])去当连接候选地址。**
/// 这两个值都只是把 App 自己拨过的地址回吐——插件里没有扫描/发现路径,
/// 地址的唯一来源是系统配对列表;`lastDeviceId` 只在原生 `connect()` 里赋值,
/// 这个 key 又来自 `savePairedDevice` 存的 `connectedDeviceId`。
/// 它们永远产生不了「新地址」,塞进候选只会把**上一台耳机**排到用户的选择前面。
/// 换耳机连不上就是这么来的,详见 bes_scan_view 的 `_connect`。
String get lastDeviceAddress =>
(_storage.read<Map>(_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<void> _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<void> 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();
_batteryRetryTimer?.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 (this.auth?.verifyConnected() ??
Future.value(BesAuthResult.unknown));
if (auth == BesAuthResult.denied) {
// 断开与提示已由 BesDeviceAuth 就地做完(此时 connectedDeviceId
// 已被清空,别再拿它打日志),这里只是不再往下走
Logger.w(_tag, '设备未通过确权,已断开连接');
return;
}
if (deviceStatus.value != 2) return; // 确权期间掉线了
// 数据通道就绪后再发 0x06 获取固件版本
await Future.delayed(const Duration(milliseconds: 500));
if (deviceStatus.value == 2) {
await sendData(BesCmd.frame(BesCmd.firmwareVersion));
await queryBattery();
_scheduleBatteryRetry();
// App 可能是在耳机已经通话中时才连上/重启回连的——0xD1/0xD2/0x02/0x03
// 都是推送事件,错过了就再也等不到,必须连上后主动查一次。
await queryCallState();
// ⚠️ **不要在这里自动跑 probeCommands**。
//
// 它每条命令间隔 400ms,扫 0x01~0x20 一轮要十几秒,期间 SPP 通道被
// 探测命令占满,用户点录音/开始翻译的指令只能排队——表现就是
// 「第一次进去点了半天没响应,过一会儿就好了」(2026-09-04 真机复现,
// 当时正是为了查别的问题打的 debug 包,结果被这段探测干扰)。
//
// 需要重扫时手动调 `BesBluetoothService.to.probeCommands()`。
// 2026-09-04 在固件 0.0.3 上扫 0x01~0x20 的结果(供定位电量命令参考):
// AA 0B → BB 8B 04 00
// AA 0C → BB 8C 1C 00 02 01 01 02 02 01 02 03 00 02 04 05
// 02 05 00 02 06 00 02 07 00 02 08 00
// AA 0D → BB 8D 07 00 02 01 02
// AA 0F → BB 8F 04 00
// AA 10 → BB 90 07 00 02 01 00
// AA 19 → BB 99 04 00
// 都是 [len, attr, value] 的 TLV,但取值(0/1/2/5)都不像电量百分比,
// 电量命令在 0.0.3 上仍未定位到。
}
} catch (e) {
Logger.e(_tag, '连接后握手失败(不致命): $e');
}
} else if (event == 0) {
_batteryRetryTimer?.cancel();
_batteryRetryTimer = null;
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) {
final raw = event['data'];
final hex = raw is List
? raw
.map((b) => (b as int).toRadixString(16).padLeft(2, '0'))
.join(' ')
: '$raw';
// ⚠️ 用 warn 级:release 包的级别门槛就是 warning,写 debug/info 等于
// 在真机上什么都记不下来。这是定位「新固件上电量命令是哪个」的唯一
// 第一手材料,频率也低(只在耳机推帧时触发),留在正式包里是划算的。
Logger.w(_tag, '恒玄设备命令事件: type=${event['type']} raw=[$hex]');
final type = event['type'];
if (type is String && type.isNotEmpty) lastCmdType.value = type;
switch (type) {
// 0x02/0x03 是去电起止 —— **只有它们**代表真实通话状态,
// 通话录音和通话翻译的准入都以此为准。
//
// ⚠️ **0xD1/0xD2 不在此列**(2026-09-19 真机定性):它们是
// `callModeStart(0x51)` / `callModeStop(0x52)` 的应答(`| 0x80`),
// 也就是**我们自己那条「进/退通话模式」指令的 ACK**,跟电话接没接通无关。
// 原来把它们一起当通话状态,后果是:通话翻译一结束,收尾发的 call.stop
// 换回一个 0xD2 → isCallOngoing 被打成 false → DeviceHub.inCall 跟着 false
// → 主界面那道「不在通话中」的闸门(home_controller.openTranslationFeature)
// 把用户挡在门外,而电话其实一直在通着;要等耳机下一次推真实状态帧
// (0x02/0x8E)才恢复,表现就是「关掉再点提示未在通话中,再点一次才进得去」。
case 'startMakeCall':
// ⚠️ 用 info 而不是 debug:这是排查「刚连上耳机就被判成通话中、
// 同传点不动」的关键线索——原生侧 `case 0x02` 对任何第二字节为 0x02
// 的帧都会上报 startMakeCall,且不做任何长度/上下文校验,
// 握手期间的推送帧有可能被误判。要看清是哪一帧干的就得有原始字节。
Logger.i(_tag, 'isCallOngoing → true (type=$type, raw=[$hex])');
isCallOngoing.value = true;
break;
case 'stopMakeCall':
Logger.i(_tag, 'isCallOngoing → false (type=$type, raw=[$hex])');
isCallOngoing.value = false;
break;
case 'startCall':
case 'stopCall':
// 我们自己发的进/退通话模式指令的 ACK,只记一笔,**不动通话状态**(见上)
Logger.i(_tag, '通话模式指令已应答 (type=$type, raw=[$hex]),不改通话状态');
break;
case 'unknown':
// 原始帧回包都以 type=unknown 上报,按 CMD 二级分发
_handleRawFrame(event['data']);
break;
}
});
}
/// 【调试用】命令探测:逐个下发候选 CMD,看固件回什么。
///
/// 恒玄这套私有协议的电量命令没有文档,deepvoice 也没实现过,只能实测。
/// 跳过已知会切换工作模式的命令(0x5x/0x6x 那批:进通话/录音/AI 模式),
/// 只扫低位查询类命令。仅在 debug 包里调用。
Future<void> 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.w(_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.w(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}');
try {
await sendData([0xAA, cmd]);
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 400));
}
Logger.w(_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<void> refreshDeviceState() async {
if (!isConnected) return;
try {
await sendData(BesCmd.frame(BesCmd.firmwareVersion));
await Future.delayed(const Duration(milliseconds: 120));
await sendData(BesCmd.frame(BesCmd.keyConfig));
await Future.delayed(const Duration(milliseconds: 120));
await sendData(BesCmd.frame(BesCmd.eqConfig));
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<String?> 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<Map>(_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 = <String, dynamic>{
...?_storage.read<Map>(_macCacheKey)?.cast<String, dynamic>(),
};
final key = id.toUpperCase();
if (cache[key] == mac) return;
cache[key] = mac;
_storage.write(_macCacheKey, cache);
}
Future<void> queryBattery() async {
try {
await sendData(BesCmd.frame(BesCmd.status));
} catch (e) {
Logger.e(_tag, '查询电量失败: $e');
}
}
/// 连上之后电量迟迟没到就再补问几次。
///
/// 握手里只发一次 `AA 09` 是不够的,两个原因:
/// 1. 那一刻 SPP 通道刚建好(还排在 `AA 06` 后面),命令可能被固件丢掉;
/// 2. 协议 3.1.10 说这帧「响应查询**或者**状态变化时主动上报」——如果这版
/// 固件只走后者,连接那一瞬本来就不会有推送,等一等反而可能等到。
///
/// 间隔逐次拉长(3s / 8s / 20s),三次拿不到就彻底放弃:探测命令占着 SPP
/// 会让用户点录音/翻译排队(2026-09-04 真机踩过),不能无限期轮询下去。
/// 中途任何一路(查询回包或耳机主动推)把电量填上了就立即停。
void _scheduleBatteryRetry() {
_batteryRetryTimer?.cancel();
const delays = <Duration>[
Duration(seconds: 3),
Duration(seconds: 8),
Duration(seconds: 20),
];
var attempt = 0;
void next() {
if (attempt >= delays.length) return;
final d = delays[attempt++];
_batteryRetryTimer = Timer(d, () async {
if (deviceStatus.value != 2) return; // 已断开
if (batteryLevel.value >= 0) return; // 已经拿到了,不再打扰通道
Logger.w(_tag, '电量仍未上报,第 $attempt 次补查 (AA 09)');
await queryBattery();
next();
});
}
next();
}
/// 请求上报通话状态(`AA 0E`),耳机回 `0x8E`:
/// data[6] 0x01=来电 / 0x02=通话中 / 0x03=通话结束。
/// 原生侧已把 0x02/0x03 解析成 startMakeCall/stopMakeCall 事件,与
/// 0xD1/0xD2/0x02/0x03 的实时上报走同一路,回来后自动刷新 [isCallOngoing]。
/// 通话状态只靠耳机推送同步不过来(App 没跑起来时的事件永远错过),
/// 这条查询命令是唯一能补的办法,见连接握手和 [refreshDeviceState]。
Future<void> queryCallState() async {
try {
await sendData(BesCmd.frame(BesCmd.callState));
} catch (e) {
Logger.e(_tag, '查询通话状态失败: $e');
}
}
/// type=unknown 的原始帧回包(SOF 0xBB)按 CMD 分发。
void _handleRawFrame(dynamic raw) {
if (raw is! List) return;
final data = raw.cast<int>();
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.w(_tag, '设备状态上报(0x18) 原始=$data');
break;
default:
// 没识别的回包打出来,方便按真实固件补协议(电量 CMD 就靠这个定位)。
// 用 INFO 级 + 十六进制:DEBUG 在某些构建里被过滤掉,而十进制的
// `[187, 134, 34, ...]` 对着协议文档看要一个个换算,很容易看错。
Logger.w(
_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<int> data) {
final st = BesProtocol.parseStatus(data);
if (st.batteryLeft != null) {
batteryLeft.value = st.batteryLeft!;
chargingLeft.value = st.chargingLeft;
}
if (st.batteryRight != null) {
batteryRight.value = st.batteryRight!;
chargingRight.value = st.chargingRight;
}
if (st.batteryCase != null) {
batteryCase.value = st.batteryCase!;
chargingCase.value = st.chargingCase;
}
// 属性 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,含义至今未知。
final mac = st.mac;
if (mac != null) {
if (mac != deviceMac.value) {
deviceMac.value = mac;
Logger.i(_tag, '耳机上报蓝牙地址: $mac');
}
// 按本地设备标识记下来,下次连同一台时同步命中,不用再等一帧 BB 0A
_rememberDeviceMac(mac);
}
if (!st.hasBattery) return;
final summary = st.summaryBattery;
if (summary != null) batteryLevel.value = summary;
Logger.w(
_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<int> data) {
String hex(Iterable<int> b) =>
b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
String mac(Iterable<int> b) =>
b.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(':');
Logger.w(_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.w(
_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<int> data) {
final parsed = BesProtocol.parseFirmware(data).preferred;
if (parsed != null) {
deviceVersion.value = parsed;
// 固件版本决定这台机器该用哪套命令号(0.0.2 与 0.0.3 的电量命令不同),
// 排查电量问题第一件事就是看它,所以要在 release 日志里留得住。
Logger.w(_tag, '恒玄耳机固件版本: $parsed');
}
}
/// 初始化原生蓝牙管理器,并把本地已保存的配对设备同步为授权白名单。
Future<bool> initialize() async {
final ok = await _manager.initialize();
Logger.i(_tag, '恒玄蓝牙初始化: $ok');
await _syncAuthorizedDevices();
return ok;
}
/// 原生侧当前实际连着的经典蓝牙设备(地址/名字)。
///
/// 耳机是 TWS 双地址:系统配对列表里那条记录的地址,和真正能跑 SPP 的地址
/// 不一定是同一个(实测 DEEPVOICE 配对列表是 …C1:C4,SPP 走的是 …BE:F8),
/// 所以连接前后都要以原生上报的地址为准。
Future<Map<String, String>?> currentConnectedDevice() =>
_manager.getConnectedDevice();
/// 设备上行的音频 PCM 流(原生已把 mSBC / G.722 解码成 16kHz/16bit 单声道)。
///
/// 注意:现场录音场景下耳机是**主麦和前馈麦交替**上行的,
/// 取音时要隔帧取主麦,不能全收(全收会变成两路混在一起、语速翻倍)。
Stream<Uint8List> get audioStream => _manager.onDeviceDataReceived();
/// 分类音频事件流:`{'type': 'spkData'|'micData'|..., 'data': Uint8List}`。
///
/// 与 [audioStream] 的区别很关键:那条是不带类型的裸流,下游只能靠帧交替
/// 猜方向——一旦中途开始订阅就会永久错相。这条由原生直接标好了
/// 「对端(spkData) / 本端(micData)」,通话翻译必须用它,否则两个语种会喂反。
Stream<Map> get audioEventStream => _manager.onDeviceAudioEvent();
/// 本端麦克风 PCM(通话模式 mode=0x03 的 mic 路),16k/16bit/mono
Stream<Uint8List> get micPcmStream => _typedPcm('micData');
/// 对端通话音 PCM(通话模式 mode=0x03 的 spk 路),16k/16bit/mono
Stream<Uint8List> get spkPcmStream => _typedPcm('spkData');
Stream<Uint8List> _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<void> startTranslationDownlink() => _manager.startCallDownlink();
Future<void> stopTranslationDownlink() => _manager.stopCallDownlink();
/// 下发一段译文 TTS 的 PCM。
/// [leg] 'A' = 己方译文给对端听;'B' = 对端译文给己方听。
Future<void> pushTranslationTtsPcm(String leg, Uint8List pcm) =>
_manager.pushTtsPcm(leg, pcm);
/// 进入通话模式(`AA 51`):耳机开始双路上行(对端 + 本端),
/// mode=0x03 交织,原生解码后走 [audioStream]。通话录音/通话翻译都用它。
Future<void> startCallMode() => sendData(BesCmd.frame(BesCmd.callModeStart));
/// 退出通话模式(`AA 52`)
Future<void> stopCallMode() => sendData(BesCmd.frame(BesCmd.callModeStop));
/// 通知耳机「ASR 已开启」(`AA 57`),收到 0xD1 之后发
Future<void> notifyAsrStarted() => sendData(BesCmd.frame(BesCmd.asrStarted));
/// 通知耳机「ASR 已关闭」(`AA 58`)
Future<void> notifyAsrStopped() => sendData(BesCmd.frame(BesCmd.asrStopped));
/// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`;
/// 耳机按键触发时也会主动上报 `0xE6`。
Future<void> startFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStart));
/// 结束面对面翻译(`AA 67`),设备回 `0xE7`
Future<void> stopFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStop));
/// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`,
/// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。
Future<void> startMicRecording() => sendData(BesCmd.frame(BesCmd.micRecordStart));
/// 停止设备端主麦录音(`AA 5A`),设备回 `0xDA`
Future<void> stopMicRecording() => sendData(BesCmd.frame(BesCmd.micRecordStop));
/// 系统已配对的经典蓝牙设备列表(不做 BLE 扫描)。
Future<List<Map<String, dynamic>>> getBondedDevices() {
return _manager.getBondedDevices();
}
Future<void> 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<void> disconnectDevice() async {
await _manager.disconnect();
connectedDeviceId.value = '';
connectedDeviceName.value = '';
}
Future<void> sendData(List<int> bytes) {
return _manager.sendData(Uint8List.fromList(bytes));
}
bool get isConnected => deviceStatus.value == 2;
// 已配对设备持久化(与杰理的绑定设备列表分开存储)
List<Map<String, dynamic>> getPairedDevices() {
final list = _storage.read<List>(_pairedDevicesKey);
if (list == null) return [];
return list.map((e) => Map<String, dynamic>.from(e as Map)).toList();
}
Future<void> savePairedDevice(Map<String, dynamic> 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<void> 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 = <String>{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<Map>(_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<String> upperIds) {
final cache = _storage.read<Map>(_macCacheKey);
if (cache == null || cache.isEmpty) return;
final next = <String, dynamic>{...cache.cast<String, dynamic>()}
..removeWhere((k, _) => upperIds.contains(k.toUpperCase()));
if (next.length != cache.length) _storage.write(_macCacheKey, next);
}
Future<void> _syncAuthorizedDevices([List<Map<String, dynamic>>? devices]) async {
final ids = (devices ?? getPairedDevices())
.map((d) => d['address'] as String? ?? '')
.where((id) => id.isNotEmpty)
.toList();
await _manager.setAuthorizedDevices(ids);
}
}