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.

1040 lines
39 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import 'package:device_jieli/device_jieli.dart';
import 'package:agent_service/agent_service.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 'language_manager.dart';
import 'network/dio_manager.dart';
import '../../routes/app_routes.dart';
/// 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;
DeviceInfo({
required this.address,
required this.name,
this.batteryLevel = 0,
this.leftEarBattery = 0,
this.rightEarBattery = 0,
this.caseBattery = 0,
this.leftEarCharging = false,
this.rightEarCharging = false,
this.caseCharging = false,
this.firmwareVersion = '',
this.firmwareProductId = '',
this.firmwareProductColor = '',
DateTime? lastUpdated,
}) : lastUpdated = lastUpdated ?? DateTime.now();
DeviceInfo copyWith({
String? address,
String? name,
int? batteryLevel,
int? leftEarBattery,
int? rightEarBattery,
int? caseBattery,
bool? leftEarCharging,
bool? rightEarCharging,
bool? caseCharging,
String? firmwareVersion,
String? firmwareProductId,
String? firmwareProductColor,
}) {
return DeviceInfo(
address: address ?? this.address,
name: name ?? this.name,
batteryLevel: batteryLevel ?? this.batteryLevel,
leftEarBattery: leftEarBattery ?? this.leftEarBattery,
rightEarBattery: rightEarBattery ?? this.rightEarBattery,
caseBattery: caseBattery ?? this.caseBattery,
leftEarCharging: leftEarCharging ?? this.leftEarCharging,
rightEarCharging: rightEarCharging ?? this.rightEarCharging,
caseCharging: caseCharging ?? this.caseCharging,
firmwareVersion: firmwareVersion ?? this.firmwareVersion,
firmwareProductId: firmwareProductId ?? this.firmwareProductId,
firmwareProductColor: firmwareProductColor ?? this.firmwareProductColor,
lastUpdated: DateTime.now(),
);
}
@override
String toString() =>
'DeviceInfo(addr: $address, name: $name, battery: $batteryLevel%, fw: $firmwareVersion)';
}
/// 杰理蓝牙管理器(替换旧的 ble_service 实现,对外保留 BleManager 名字)
class BleManager extends GetxService {
static BleManager get to => Get.find<BleManager>();
// ─── 连接信息 ────────────────────────────────────────
String? currentDeviceAddress;
String? _currentDeviceName;
JieliDevice? _connectedDevice;
JieliDevice? get connectedDevice => _connectedDevice;
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<DeviceInfo?>(null);
// ─── 通话翻译/编解码器(杰理 startTranslation 模式状态)─────
final _isCodecActive = false.obs;
bool get isCodecActive => _isCodecActive.value;
RxBool get isCodecActiveRx => _isCodecActive;
// 设备主动上报通话翻译开关信号(耳机端按键)
final StreamController<bool> _callTranslationRequestController =
StreamController<bool>.broadcast();
Stream<bool> get onCallTranslationRequest =>
_callTranslationRequestController.stream;
// 通话翻译模式下,杰理 runtime 解码后推上来的 PCM:
// - in.uplink = 本机用户麦克风(己方/A 路)
// - in.downlink = 对端通话音(对方/B 路)
// 由 translation_controller 转发到 azure_speech 的 AST 流水线。
final StreamController<Uint8List> _inUplinkPcmController =
StreamController<Uint8List>.broadcast();
final StreamController<Uint8List> _inDownlinkPcmController =
StreamController<Uint8List>.broadcast();
Stream<Uint8List> get inUplinkPcmStream => _inUplinkPcmController.stream;
Stream<Uint8List> get inDownlinkPcmStream => _inDownlinkPcmController.stream;
// ─── 扫描结果(供设备页扫描列表使用)─────────────────────
final RxList<JieliDevice> scannedDevices = <JieliDevice>[].obs;
final RxBool isScanning = false.obs;
/// 每个地址最近一次扫到的 advertisement(电量等)。
/// scannedDevices 只追加不更新,但电量在每次 advertisement 都可能变;
/// 这里保留最新一次,供连接成功时填到 DeviceInfo。
final Map<String, JieliDevice> _latestAdvByAddress = {};
StreamSubscription<JieliEvent>? _eventSub;
final GetStorage _storage = GetStorage();
Future<void>? _initFuture;
bool _initSucceeded = false;
String? _initError;
String? get initError => _initError;
bool get isInitialized => _initSucceeded;
/// 确保杰理 SDK native 端已 initialize。失败则重试一次。
/// 失败时抛出 [StateError] —— 调用方负责 try-catch / UI 反馈。
Future<void> 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"}');
}
}
/// 兼容旧 API(部分调用点不关心初始化结果)
Future<void> get ready async {
try {
await ensureInitialized();
} catch (_) {
// 静默:让后续 native 调用自身报错
}
}
BleManager() {
print('[BLE] BleManager 构造,立即启动杰理 SDK 初始化');
_initFuture = _initializeJieli();
_setupEventListener();
_startConnectionWatchdog();
}
@override
void onInit() {
super.onInit();
print('[BLE] BleManager.onInit (init 已在构造时启动)');
}
@override
void onClose() {
_eventSub?.cancel();
_stopBatteryWatchdog();
_stopConnectionWatchdog();
_callTranslationRequestController.close();
_inUplinkPcmController.close();
_inDownlinkPcmController.close();
super.onClose();
}
/// 把外部 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<bool> feedAstTtsPcm({
required String leg,
required Uint8List pcm,
int sampleRate = 16000,
int channels = 1,
bool isFinal = false,
}) async {
// 段尾帧(pcm 为空 + isFinal=true)必须放行,让 native 触发立即整段下发;
// 仅对"普通空帧(非段尾)"短路。
if (pcm.isEmpty && !isFinal) 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.w('BleManager', 'feedAstTtsPcm leg=$leg failed: $e');
return false;
}
}
Future<void> _initializeJieli() async {
try {
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 通道是否畅通。
// 通话翻译 PCM 上推 (TranslationAudioEvent) 是高频事件(20ms 一帧),
// 日志量大且无诊断价值,单独跳过;其它事件保留 runtimeType 打印。
if (event is! TranslationAudioEvent) {
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);
// 每次广播都用最新数据覆盖(后续若 SDK 扩展了广播字段,仍可从这里拿到)
_latestAdvByAddress[found.address] = found;
print('[BLE] [EVT] deviceFound name="${found.name}" addr=${found.address} '
'rssi=${found.rssi} '
'(已存在=$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();
// 同时启动 AgentService(azureAsrHelper / TTS / chatApi 在这里建出来)。
// native initialize 自带幂等保护,重连重复触发是 no-op;
// 老的 PairingController 删除时这一调用丢了,不补回来 AI 助理 / PTT
// 等所有 ASR 路径都会因为 azureAsrHelper=null 静默失败。
startAgentService().then((ok) {
Logger.i('BleManager', 'startAgentService on RCSP init: ok=$ok');
}).catchError((e) {
Logger.e('BleManager', 'startAgentService on RCSP init 失败: $e');
});
}
} 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;
}
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;
}
// 连接状态 watchdog:杰理 SDK 在某些时序下不会回调 ConnectionStateEvent
// (冷启时设备已经连着、事件订阅 race、原生侧自动重连等),导致本地 Rx
// 状态停在 disconnected 但 SDK 内部已经有连接。这里定期直接问 SDK。
//
// 重要:只做"单向"同步(disconnected → connected),**不**把已连状态翻回
// disconnected。原因:multi-device 模式下 connectedDevice() 偶发会返回 null,
// 上一版把它当真翻成 disconnected 反而把已连接 UI 抹掉了。断开仍走
// ConnectionStateEvent.connectionDisconnect 那条事件路径。
Timer? _connectionWatchdog;
static const Duration _connectionWatchdogInterval = Duration(seconds: 5);
void _startConnectionWatchdog() {
_connectionWatchdog?.cancel();
_connectionWatchdog = Timer.periodic(_connectionWatchdogInterval, (_) {
_reconcileConnectionFromSdk();
});
}
void _stopConnectionWatchdog() {
_connectionWatchdog?.cancel();
_connectionWatchdog = null;
}
Future<void> _reconcileConnectionFromSdk() async {
if (!_initSucceeded) return;
try {
// 步骤 1:尽量找到一个真实在连的 address。
// 优先:本地已有 currentDeviceAddress → 直接 isConnected(addr) 校验。
// 兜底:connectedDevice() 看 SDK 是否能给出当前已连设备。
String? sdkConnectedAddr;
String? sdkConnectedName;
final localAddr = currentDeviceAddress;
if (localAddr != null && localAddr.isNotEmpty) {
if (await Jielihome.instance.isConnected(localAddr)) {
sdkConnectedAddr = localAddr;
sdkConnectedName = _currentDeviceName;
}
}
if (sdkConnectedAddr == null) {
final dev = await Jielihome.instance.connectedDevice();
if (dev != null && dev.address.isNotEmpty) {
sdkConnectedAddr = dev.address;
sdkConnectedName = dev.name;
}
}
if (sdkConnectedAddr == null) return; // 没拿到,本轮放弃,**不**翻 _isConnected
// 步骤 2:本地 Rx 与 SDK 不一致 → 强制同步到「已连」。
final wasConnected = _isConnected.value;
final addressChanged = currentDeviceAddress != sdkConnectedAddr;
if (!wasConnected || addressChanged) {
Logger.w('BleManager',
'[WATCHDOG] 本地 isConnected=$wasConnected addr=$currentDeviceAddress'
' / SDK 在连 $sdkConnectedAddr → 同步为 connected');
currentDeviceAddress = sdkConnectedAddr;
if (sdkConnectedName != null && sdkConnectedName.isNotEmpty) {
_currentDeviceName = sdkConnectedName;
}
_isConnected.value = true;
_connectionState.value = BleConnectionState.connected;
_ensureDeviceInfoStub();
}
// 步骤 3:兜底电量。冷启场景下 RcspInitEvent 不会再来,
// _startBatteryWatchdog 永远不被触发,电量 UI 一直 0。
// 这里发现 SDK 在连且本地电量为 0 时,主动启动一次电量轮询。
final lvl = deviceInfo.value?.batteryLevel ?? 0;
if (lvl == 0 && _batteryWatchdog == null) {
Logger.i('BleManager', '[WATCHDOG] 已连但电量=0,启动电量轮询补抓');
_startBatteryWatchdog();
}
} catch (e) {
Logger.w('BleManager', '[WATCHDOG] 查询 SDK 连接状态失败: $e');
}
}
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,
);
}
/// 杰理同步后的 `JieliDevice` 仅暴露 name / address / rssi 等基础字段,
/// 不再提供左右耳/仓的电量与充电状态。电量改由以下两路拿:
/// 1. 连接成功后 RCSP 初始化完成 → `_startBatteryWatchdog` 轮询
/// `fetchDeviceInfo` 拉 `deviceSnapshot` / `queryTargetInfo`;
/// 2. SDK 主动推送的 [BatteryEvent](见 `_handleBattery`)。
/// 所以这里不再从扫描广播里预填电量,保留空实现以兼容既有调用点。
void _fillBatteryFromAdvertisement(String address) {
final adv = _latestAdvByAddress[address];
if (adv == null) {
Logger.w('BleManager', '[BAT] 没有 $address 的 advertisement 数据可用');
return;
}
Logger.i('BleManager',
'[BAT] 广播不再携带电量字段,连接后将通过 RCSP/BatteryEvent 补齐');
}
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);
}
break;
case 'in.downlink':
if (!_inDownlinkPcmController.isClosed) {
_inDownlinkPcmController.add(e.pcm);
}
break;
}
}
void _handleExpandFunction(ExpandFunctionEvent e) {
if (e.opCode == 0x16) {
_emitCallTranslationRequest(true);
} else if (e.opCode == 0x17) {
_emitCallTranslationRequest(false);
}
}
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) {
Get.toNamed(
Routes.translation,
arguments: {'translationId': 'call', 'autoStart': true},
);
}
}
}
void _ensureDeviceInfoStub() {
if (deviceInfo.value == null && currentDeviceAddress != null) {
deviceInfo.value = DeviceInfo(
address: currentDeviceAddress!,
name: _currentDeviceName ?? '',
);
}
}
// ─── 扫描 / 连接 ────────────────────────────────────
/// 启动扫描。
///
/// 扫描的 service UUID 白名单来源:
/// 1) 调用方显式传入 [uuids](通常用于调试或定向扫描);
/// 2) 否则根据 [productIds] 从 [AppConfig.config.products] 映射出每个产品
/// 的 `scanuuid`(服务端下发的产品配置),在 Flutter 侧拼接成 uuids 列表
/// 再通过平台通道下发给底层,由 Android `ScanFilter.setServiceUuid`
/// 进行 OS 级过滤;
/// 3) 如果上面两项都没给,就默认使用 [AppConfig.allproductUuids()],等价
/// 于「下发所有已配置产品」的 uuid 白名单。
///
/// 空列表 / AppConfig 未初始化时,底层不做 uuid 过滤,全部放行。
Future<bool> startScan({
Duration timeout = const Duration(seconds: 30),
List<String>? uuids,
List<int>? productIds,
}) async {
print('[BLE] [SCAN] BleManager.startScan 入口 '
'productIds=$productIds uuids=$uuids');
_lastScanError = null;
try {
await ensureInitialized();
} catch (e) {
print('[BLE] [SCAN] 杰理 SDK 未初始化: $e');
_lastScanError = e.toString();
isScanning.value = false;
return false;
}
final List<String> resolvedUuids;
if (uuids != null && uuids.isNotEmpty) {
resolvedUuids =
uuids.where((u) => u.isNotEmpty).toList(growable: false);
} else if (productIds != null &&
productIds.isNotEmpty &&
AppConfig.isInitialized()) {
final ids = productIds.toSet();
resolvedUuids = AppConfig.config.products
.where((p) => ids.contains(p.id))
.map((p) => p.scanuuid)
.where((u) => u.isNotEmpty)
.toList(growable: false);
} else if (AppConfig.isInitialized()) {
resolvedUuids = AppConfig.allproductUuids()
.where((u) => u.isNotEmpty)
.toList(growable: false);
} else {
resolvedUuids = const <String>[];
}
print('[BLE] [SCAN] init 已通过,UUID 白名单=$resolvedUuids');
scannedDevices.clear();
isScanning.value = true;
try {
await Jielihome.instance.startScan(
timeout: timeout,
uuidList: resolvedUuids,
skipUnnamed: true,
);
print('[BLE] [SCAN] native startScan 返回成功,等待 deviceFound 事件');
return true;
} catch (e, s) {
print('[BLE] [SCAN] 启动扫描失败: $e\n$s');
isScanning.value = false;
_lastScanError = e.toString();
return false;
}
}
String? _lastScanError;
String? get lastScanError => _lastScanError;
Future<void> stopScan() async {
await ready;
try {
await Jielihome.instance.stopScan();
} catch (e) {
Logger.e('BleManager', '停止扫描失败: $e');
} finally {
isScanning.value = false;
}
}
Future<void> connectDevice(JieliDevice device) async {
await ready;
_currentDeviceName = device.name;
currentDeviceAddress = device.address;
_connectedDevice = device;
_connectionState.value = BleConnectionState.connecting;
try {
await Jielihome.instance.connect(device);
} catch (e) {
Logger.e('BleManager', '连接设备失败: $e');
_connectionState.value = BleConnectionState.error;
}
}
Future<void> disconnect() async {
await ready;
try {
final addr = currentDeviceAddress;
if (addr != null) {
await Jielihome.instance.disconnect(addr);
}
} catch (e) {
Logger.e('BleManager', '断开连接失败: $e');
}
}
Future<int> getConnectionState() async {
final addr = currentDeviceAddress;
if (addr == null) return BleConnectionState.disconnected;
try {
final connected = await Jielihome.instance.isConnected(addr);
_connectionState.value =
connected ? BleConnectionState.connected : BleConnectionState.disconnected;
_isConnected.value = connected;
return _connectionState.value;
} catch (e) {
Logger.e('BleManager', '查询连接状态失败: $e');
return BleConnectionState.error;
}
}
Future<bool> isBleConnected() async {
final addr = currentDeviceAddress;
if (addr == null) return false;
try {
return await Jielihome.instance.isConnected(addr);
} catch (_) {
return false;
}
}
/// 探查当前是否已经有杰理设备连着(用于登录/启动后判断是否需要跳转扫描页)。
/// 通过 [Jielihome.connectedDevice] 同步 SDK 已知的连接状态。
Future<bool> 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 false;
}
// ─── 设备信息 ───────────────────────────────────────
String getDeviceName() {
return deviceInfo.value?.name ?? _currentDeviceName ?? '';
}
Future<void> 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,
);
Logger.i('BleManager',
'设备快照: name=$name battery=$battery fw=$fw pid=$pid');
} catch (e) {
Logger.w('BleManager', '获取设备快照失败: $e');
}
}
Future<bool> fetchBatteryInfo() async => true;
Future<bool> fetchDeviceVersion() async => true;
Future<bool> getProductId() async => true;
Future<int> getBatteryLevel() async =>
deviceInfo.value?.batteryLevel ?? 0;
void clearDeviceInfo() {
deviceInfo.value = null;
currentDeviceAddress = null;
_currentDeviceName = null;
_connectedDevice = null;
}
// ─── 编解码器 / 通话翻译模式 ───────────────────────────
// 杰理:openA2DPDecoder/closeCodec 映射到 startTranslation/stopTranslation。
// 其余 codec 入口按现有调用语义保留乐观成功,以保持上层兼容。
Future<bool> openEncoder() async {
_isCodecActive.value = true;
return true;
}
Future<bool> openDecoder() async {
_isCodecActive.value = true;
return true;
}
Future<bool> openA2DPDecoder() async {
Logger.i('CALL_TRANS_CMD', '启动通话翻译模式 (杰理 startTranslation)');
try {
await Jielihome.instance.startTranslation(
modeId: TranslationModeIds.callTranslation,
);
_isCodecActive.value = true;
return true;
} catch (e) {
Logger.e('CALL_TRANS_CMD', '启动通话翻译模式失败: $e');
_isCodecActive.value = false;
return false;
}
}
Future<bool> openCallRecordDecoder() async {
_isCodecActive.value = true;
return true;
}
Future<bool> closeCodec() async {
_isCodecActive.value = false;
try {
await Jielihome.instance.stopTranslation();
} catch (_) {
// 静默失败:未启动时调 stop 是常态
}
return true;
}
Future<bool> playA2DP() async => true;
// ─── OTA ───────────────────────────────────────────
/// 杰理 OTA 通过 Jielihome.otaStart 触发,此处保留空实现以兼容旧调用点。
Future<void> setEnterOta(bool enterOta) async {}
// ─── 兼容存根 ───────────────────────────────────────
Future<bool> requestBluetoothPermission() async => true;
/// 旧 Companion 关联 / 后台扫描接口已废弃 — 保留空实现以兼容
/// 即将被移除的调用点(pairing 模块已删除)。
Future<bool> clearPairedDevices() async {
await disconnect();
return true;
}
// ─── AgentService 启动 / 停止(保持原逻辑) ───────────
Future<bool> startAgentService() async {
try {
var serverurl = DioManager().baseUrl;
var jsonString = _storage.read<String>("logintoken");
final azureSpeechKey = AppConfig.env('AZURE_SPEECH_KEY') ?? '';
final azureSpeechRegion =
AppConfig.env('AZURE_SPEECH_REGION') ?? 'eastasia';
final openaiApiKey = AppConfig.env('OPENAI_API_KEY') ?? '';
final openaiBaseUrl =
AppConfig.env('OPENAI_BASE_URL') ?? 'https://api.openai.com/v1';
final openaiModel = AppConfig.env('OPENAI_MODEL') ?? 'gpt-3.5-turbo';
final openaiModelMedia = AppConfig.env('OPENAI_API_VISION_MODEL') ??
'doubao-1-5-vision-pro-32k-250115';
final openaiagentid =
AppConfig.env('OPENAI_AGENT_ID') ?? 'personal_assistant';
final systemPrompt = AppConfig.agentsystemPrompt(openaiagentid);
var musicServiceType =
AppConfig.env('APP_ANDROID_MUSICSERVICE_TYPE') ?? 'AUTO';
if (Platform.isIOS) {
musicServiceType = AppConfig.env('APP_IOS_MUSICSERVICE_TYPE') ?? 'AUTO';
}
final navigationMode =
AppConfig.env('APP_NAVIGATION_MODE') ?? 'external_auto';
final xunfeiAppId = AppConfig.env('XUNFEI_ASR_APP_ID') ?? '';
final xunfeiAccessKeyId = AppConfig.env('XUNFEI_ASR_ACCESS_KEY_ID') ?? '';
final xunfeiAccessKeySecret =
AppConfig.env('XUNFEI_ASR_ACCESS_KEY_SECRET') ?? '';
final jsonData = AppConfig.mcpConfig();
Logger.info('启动 AgentService,musicServiceType: $musicServiceType');
final isTtsInterruptEnabled = await _loadTtsInterrupe();
final vipExpiryTime = userInfo.User.instance.vipexptime;
List<String> supportedLanguages = [];
try {
final storage = GetStorage();
final languageManager = Get.find<LanguageManager>();
String? savedLanguage = storage.read('language');
if (savedLanguage != null && savedLanguage != 'system') {
final parts = savedLanguage.split('_');
if (parts.length == 2) {
final lang = parts[0].toLowerCase();
final country = parts[1].toUpperCase();
if (lang == 'zh' &&
(country == 'HK' || country == 'TW' || country == 'CN')) {
supportedLanguages.add('zh-$country');
} else if (lang == 'tl' && country == 'PH') {
supportedLanguages.add('fil-PH');
} else {
supportedLanguages.add('$lang-$country');
}
}
} else {
final appLocale = Get.locale ?? AppTranslations.deviceLocale();
if (appLocale != null) {
final lang = appLocale.languageCode.toLowerCase();
final country = appLocale.countryCode;
if (lang.toLowerCase() == 'zh' &&
country != null &&
country.isNotEmpty &&
(country.toUpperCase() == 'HK' ||
country.toUpperCase() == 'TW' ||
country.toUpperCase() == 'CN')) {
supportedLanguages.add('zh-${country.toUpperCase()}');
} else {
if (country != null && country.isNotEmpty) {
final normalizedCountry = country.toUpperCase();
if (lang == 'tl' && normalizedCountry == 'PH') {
supportedLanguages.add('fil-PH');
} else {
supportedLanguages.add('$lang-$normalizedCountry');
}
} else {
final asrCode = languageManager.getAsrCodeByShortCode(lang);
if (asrCode != null) {
supportedLanguages.add(asrCode);
} else {
supportedLanguages.add('en-US');
}
}
}
}
}
if (supportedLanguages.isEmpty) {
supportedLanguages.add('en-US');
}
} catch (e) {
Logger.error('获取APP语言配置失败,使用默认语言: $e');
supportedLanguages = ['zh-CN', 'en-US'];
}
final agentType = _storage.read<String>('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') ?? '';
final stsDeviceNo = _storage.read<String>('device_sts_code') ?? '';
final stsDeviceId = _storage.read<String>('sts_device_id') ?? '';
final stsDeviceSecret = _storage.read<String>('sts_device_secret') ?? '';
final mac = currentDeviceAddress?.toUpperCase() ?? '';
String stsCmei = '';
try {
final matchedDevice = userInfo.User.instance.devices.firstWhere(
(d) => d.devicemac.toUpperCase() == mac,
);
stsCmei = matchedDevice.cmei;
} catch (_) {}
final config = <String, dynamic>{
'agentMode': agentMode,
'agentId': agentId,
'stsProductId': stsProductId,
'stsProductKey': stsProductKey,
'stsDeviceNo': stsDeviceNo,
'stsDeviceId': stsDeviceId,
'stsDeviceSecret': stsDeviceSecret,
'stsCmei': stsCmei,
'azureSpeechKey': azureSpeechKey,
'azureSpeechRegion': azureSpeechRegion,
'xunfeiAppId': xunfeiAppId,
'xunfeiAccessKeyId': xunfeiAccessKeyId,
'xunfeiAccessKeySecret': xunfeiAccessKeySecret,
'openaiApiKey': openaiApiKey,
'openaiBaseUrl': openaiBaseUrl,
'openaiModel': openaiModel,
'openaivisionModel': openaiModelMedia,
'systemPrompt': systemPrompt,
'mcpServer': jsonEncode(jsonData),
'volcanoAppId': '2316081933',
'volcanoToken': 'mcJTh6OelBqU_L78lgGd77Nyrc_-5-En',
'serverurl': serverurl,
'usertoken': jsonString,
'supportedLanguages': supportedLanguages,
'musicServiceType': musicServiceType,
'navigationMode': navigationMode,
'isttsinterrupt': isTtsInterruptEnabled ? 'true' : 'false',
'vipexptime': vipExpiryTime,
'callingToolText': 'callingTool'.tr,
'insufficientIntegralText': 'insufficientIntegral'.tr,
};
AgentService.nativeLogCallback ??= (level, message) {
switch (level) {
case 'ERROR':
Logger.e('NativeAgent', message);
break;
case 'WARNING':
Logger.w('NativeAgent', message);
break;
case 'DEBUG':
Logger.d('NativeAgent', message);
break;
default:
Logger.i('NativeAgent', message);
}
};
if (agentMode == 'sts') {
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 (deviceNo.isNotEmpty) _storage.write('sts_device_no', deviceNo);
});
}
final result = await AgentService.startAgentService(config);
Logger.i('BleManager', 'AgentService.startAgentService 结果: $result');
return result;
} catch (e) {
Logger.error('启动 AgentService 失败: $e');
return false;
}
}
Future<bool> stopAgentService() async {
try {
final result = await AgentService.stopAgentService();
if (result) {
Logger.info('AgentService 停止成功');
clearDeviceInfo();
}
return result;
} catch (e) {
Logger.error('停止 AgentService 失败: $e');
return false;
}
}
Future<bool> _loadTtsInterrupe() async {
try {
if (!_storage.hasData('agent_tts_interrupt')) {
String deviceName = getDeviceName();
DBProduct? product = AppConfig.getproduct(deviceName);
if (deviceName != '' && product != null) {
return !product.iscontinuouschat!;
}
return false;
}
return _storage.read('agent_tts_interrupt') ?? false;
} catch (_) {
return false;
}
}
static Future<BleManager> init() async {
if (!Get.isRegistered<BleManager>()) {
Get.put(BleManager(), permanent: true);
}
return Future.value(Get.find<BleManager>());
}
}