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.
 
 
 
 
 
 

515 lines
21 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 '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<BesBluetoothService>();
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<Map>(_lastDeviceKey)?['name'] as String?) ?? '';
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();
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<void> 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<void> 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<void> 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<void> 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<int>();
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<int> 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<int> 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<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([0xAA, 0x51]);
/// 退出通话模式(`AA 52`)
Future<void> stopCallMode() => sendData([0xAA, 0x52]);
/// 通知耳机「ASR 已开启」(`AA 57`),收到 0xD1 之后发
Future<void> notifyAsrStarted() => sendData([0xAA, 0x57]);
/// 通知耳机「ASR 已关闭」(`AA 58`)
Future<void> notifyAsrStopped() => sendData([0xAA, 0x58]);
/// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`;
/// 耳机按键触发时也会主动上报 `0xE6`。
Future<void> startFaceToFace() => sendData([0xAA, 0x66]);
/// 结束面对面翻译(`AA 67`),设备回 `0xE7`
Future<void> stopFaceToFace() => sendData([0xAA, 0x67]);
/// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`,
/// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。
Future<void> startMicRecording() => sendData([0xAA, 0x59]);
/// 停止设备端主麦录音(`AA 5A`),设备回 `0xDA`
Future<void> stopMicRecording() => sendData([0xAA, 0x5A]);
/// 系统已配对的经典蓝牙设备列表(不做 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);
}
Future<void> 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<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);
}
}