import 'dart:async'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; import '../core/utils/logger.dart'; /// 设备中枢——**业务层唯一的设备入口**。 /// /// 业务层(`lib/modules`、`lib/core`)只能 import 这个文件和 /// `device_plugin_interface`,不能 import 任何 `lib/devices//` 下的东西, /// `test/devices/device_hub_boundary_test.dart` 守着这条线。 /// /// 职责: /// - 持有各厂商插件(恒玄 / EaiRec 支架 / 以后接的每一种芯片各一个); /// - 汇总所有 `ready` 会话成一份可观测状态(连没连、能力集、电量、通话中…); /// - 合流唤醒事件与会话事件,供常驻服务订阅; /// - 「最近连过的设备」这类展示用信息也从这里拿。 /// /// 多设备模型:耳机和支架可以同时在线,所以是 `sessions` 列表而不是单个 /// active。要「一台能录音的设备」就问 [sessionWith]。 class DeviceHub extends GetxService { static const String _tag = 'DeviceHub'; static DeviceHub get to => Get.find(); final Map _plugins = {}; final Map _pluginSubs = {}; final Map _sessionSubs = {}; final Map _wakeSubs = {}; /// 当前 `ready` 的会话,按连上的先后排。 final RxList sessions = [].obs; /// 任一设备在线。 final RxBool anyConnected = false.obs; /// 所有在线设备能力的并集。UI 显隐一律问它,不问厂商。 final RxSet capabilities = {}.obs; /// 正在通话中(由声明了 [DeviceCapability.callState] 的设备上报)。 final RxBool inCall = false.obs; /// 主设备(第一台在线的)的信息快照;没有设备时为 null。 final Rxn primaryInfo = Rxn(); /// 主设备名;没有设备时为空串。 final RxString primaryName = ''.obs; /// 每当任何会话的 info / 状态变了就 +1,给只想「重画一下」的 UI 用。 final RxInt revision = 0.obs; final StreamController _wake = StreamController.broadcast(); final StreamController _events = StreamController.broadcast(); /// 所有在线设备的唤醒事件合流。 Stream get wakeEvents => _wake.stream; /// 所有在线设备的会话事件合流。 Stream get sessionEvents => _events.stream; // ---------------- 插件管理 ---------------- Iterable get plugins => _plugins.values; DevicePlugin? plugin(String vendorKey) => _plugins[vendorKey]; /// 注册一个厂商插件。重复 key 直接覆盖并报警(正常不该发生)。 void registerPlugin(DevicePlugin p) { if (_plugins.containsKey(p.vendorKey)) { Logger.w(_tag, '重复注册厂商插件 ${p.vendorKey},覆盖旧实例'); _pluginSubs.remove(p.vendorKey)?.cancel(); } _plugins[p.vendorKey] = p; _pluginSubs[p.vendorKey] = p.eventStream.listen( (e) => _onPluginEvent(p, e), onError: (e) => Logger.e(_tag, '${p.vendorKey} 事件流错误: $e'), ); // 插件可能在注册前就已经连上了(比如原生被动回连),补一次快照 final s = p.activeSession; if (s != null && s.state == DeviceConnectionState.ready) _attach(s); Logger.i(_tag, '已注册厂商插件 ${p.vendorKey}(${p.displayName})'); } // ---------------- 查询 ---------------- bool has(DeviceCapability c) => capabilities.contains(c); /// 第一台具备该能力的在线设备;没有为 null。 DeviceSession? sessionWith(DeviceCapability c) { for (final s in sessions) { if (s.supports(c)) return s; } return null; } /// 主音频设备:第一台能上行麦克风的。 DeviceSession? get primaryAudio => sessionWith(DeviceCapability.micUplink); /// 主设备 = **最近上线**的那台([_attach] 把新会话插在最前)。 DeviceSession? get primary => sessions.isEmpty ? null : sessions.first; DeviceSession? sessionOf(String vendorKey) { for (final s in sessions) { if (s.vendor == vendorKey) return s; } return null; } /// 最近连过(或正连着)的那台设备:名字 + 出自哪家插件。 /// /// ⚠️ 名字和厂商**必须同源**,不能各算各的:设备页的产品图按名字取、 /// AI 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、 /// 功能却是香薰的」这种自相矛盾的界面。 /// /// ⚠️ 已知口径局限:没有活跃会话时按**插件注册顺序**取第一个连过的, /// 不是按「最近连接时间」。同时连过耳机和香薰、然后都断开时, /// 显示的是注册在前的那家(当前是恒玄),而不是真正最近用的那台。 ({String name, String vendor}) get _lastKnown { final p = primary; if (p != null && p.info.name.isNotEmpty) { return (name: p.info.name, vendor: p.vendor); } for (final plugin in _plugins.values) { final d = plugin.lastKnownDevice; if (d != null && d.name.isNotEmpty) { return (name: d.name, vendor: plugin.vendorKey); } } return (name: '', vendor: ''); } /// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。 /// 从没连过任何设备返回空串。 String get lastKnownName => _lastKnown.name; /// 同一台设备出自哪家厂商插件。AI 页按它铺该品类的默认功能编排。 String get lastKnownVendor => _lastKnown.vendor; bool get hasKnownDevice => lastKnownName.isNotEmpty; // ---------------- 动作 ---------------- /// 冷启动/前台恢复时让每个插件各自回连记住的设备。 Future tryAutoConnectAll() async { for (final p in _plugins.values) { try { await p.tryAutoConnect(); } catch (e) { Logger.e(_tag, '${p.vendorKey} 自动回连失败: $e'); } } } Future disconnectAll() async { for (final s in sessions.toList()) { try { await s.disconnect(); } catch (e) { Logger.e(_tag, '${s.vendor} 断开失败: $e'); } } } /// 进功能页时刷一遍所有在线设备的状态(电量会掉、配置会变)。 Future refreshAll() async { for (final s in sessions.toList()) { try { await s.refreshInfo(); } catch (e) { Logger.w(_tag, '${s.vendor} 刷新状态失败: $e'); } } } // ---------------- 内部 ---------------- void _onPluginEvent(DevicePlugin p, DevicePluginEvent e) { switch (e.type) { case DevicePluginEventType.connectionStateChanged: final s = p.activeSession; if (e.connectionState == DeviceConnectionState.ready && s != null) { _attach(s); } else if (e.connectionState == DeviceConnectionState.disconnected) { _detachVendor(p.vendorKey); } break; case DevicePluginEventType.error: Logger.w(_tag, '${p.vendorKey} 错误: ${e.errorCode} ${e.errorMessage}'); break; default: break; } } void _attach(DeviceSession s) { if (sessions.any((x) => identical(x, s))) return; // 同一厂商只保留最新的会话 _detachVendor(s.vendor, silent: true); // ★ 单活动设备:用户从车载香薰切到耳机(反之亦然),旧设备的链路要断掉, // 不能两台同时在线——设备页只有一张图、一个电量,两台并存时展示和音频路由都会打架。 // 新上线的设备**排在最前**成为主设备,设备页的图/名字/电量跟着它走。 // (2026-09-10 用户反馈:切到耳机后香薰 BLE 没断、设备页图片也没换。) // ⚠️ 被动回连(系统 ACL 把耳机连回来)同样会走到这里并顶掉当前设备,这是刻意的: // "最近连上的那台就是用户此刻想用的那台"。 for (final other in sessions.where((x) => x.vendor != s.vendor).toList()) { Logger.i(_tag, '切换设备:${s.vendor} 上线,断开 ${other.vendor} ${other.info.name}'); _detachVendor(other.vendor, silent: true); other.disconnect().catchError((Object e) { Logger.w(_tag, '${other.vendor} 断开失败: $e'); }); } sessions.insert(0, s); _sessionSubs[s.vendor] = s.eventStream.listen((e) { _events.add(e); _onSessionEvent(s, e); }); _wakeSubs[s.vendor] = s.wakeEvents.listen(_wake.add); Logger.i(_tag, '设备上线: ${s.vendor} ${s.info.name} caps=${s.capabilities.map((c) => c.name).join(',')}'); _recompute(); } void _detachVendor(String vendor, {bool silent = false}) { _sessionSubs.remove(vendor)?.cancel(); _wakeSubs.remove(vendor)?.cancel(); final before = sessions.length; sessions.removeWhere((x) => x.vendor == vendor); if (sessions.length != before && !silent) { Logger.i(_tag, '设备下线: $vendor'); } _recompute(); } void _onSessionEvent(DeviceSession s, DeviceSessionEvent e) { switch (e.type) { case DeviceSessionEventType.connectionStateChanged: if (e.connectionState == DeviceConnectionState.disconnected) { _detachVendor(s.vendor); } break; case DeviceSessionEventType.deviceInfoUpdated: _recompute(); break; case DeviceSessionEventType.feature: final key = e.feature?.key; if (key == DeviceFeatureKeys.callStarted) inCall.value = true; if (key == DeviceFeatureKeys.callEnded) inCall.value = false; break; default: break; } } void _recompute() { anyConnected.value = sessions.isNotEmpty; final caps = {}; for (final s in sessions) { caps.addAll(s.capabilities); } capabilities.assignAll(caps); final p = primary; primaryInfo.value = p?.info; primaryName.value = p?.info.name ?? ''; if (p == null) inCall.value = false; revision.value++; } @override void onClose() { for (final s in _pluginSubs.values) { s.cancel(); } for (final s in _sessionSubs.values) { s.cancel(); } for (final s in _wakeSubs.values) { s.cancel(); } _wake.close(); _events.close(); super.onClose(); } }