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.
308 lines
12 KiB
308 lines
12 KiB
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/<vendor>/` 下的东西,
|
|
/// `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<DeviceHub>();
|
|
|
|
final Map<String, DevicePlugin> _plugins = {};
|
|
final Map<String, StreamSubscription> _pluginSubs = {};
|
|
final Map<String, StreamSubscription> _sessionSubs = {};
|
|
final Map<String, StreamSubscription> _wakeSubs = {};
|
|
|
|
/// 当前 `ready` 的会话,按连上的先后排。
|
|
final RxList<DeviceSession> sessions = <DeviceSession>[].obs;
|
|
|
|
/// 任一设备在线。
|
|
final RxBool anyConnected = false.obs;
|
|
|
|
/// 所有在线设备能力的并集。UI 显隐一律问它,不问厂商。
|
|
final RxSet<DeviceCapability> capabilities = <DeviceCapability>{}.obs;
|
|
|
|
/// 正在通话中(由声明了 [DeviceCapability.callState] 的设备上报)。
|
|
final RxBool inCall = false.obs;
|
|
|
|
/// 主设备(第一台在线的)的信息快照;没有设备时为 null。
|
|
final Rxn<DeviceInfo> primaryInfo = Rxn<DeviceInfo>();
|
|
|
|
/// 主设备名;没有设备时为空串。
|
|
final RxString primaryName = ''.obs;
|
|
|
|
/// 每当任何会话的 info / 状态变了就 +1,给只想「重画一下」的 UI 用。
|
|
final RxInt revision = 0.obs;
|
|
|
|
final StreamController<DeviceWakeEvent> _wake =
|
|
StreamController<DeviceWakeEvent>.broadcast();
|
|
final StreamController<DeviceSessionEvent> _events =
|
|
StreamController<DeviceSessionEvent>.broadcast();
|
|
|
|
/// 所有在线设备的唤醒事件合流。
|
|
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream;
|
|
|
|
/// 所有在线设备的会话事件合流。
|
|
Stream<DeviceSessionEvent> get sessionEvents => _events.stream;
|
|
|
|
// ---------------- 插件管理 ----------------
|
|
|
|
Iterable<DevicePlugin> 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<void> tryAutoConnectAll() async {
|
|
for (final p in _plugins.values) {
|
|
try {
|
|
await p.tryAutoConnect();
|
|
} catch (e) {
|
|
Logger.e(_tag, '${p.vendorKey} 自动回连失败: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> disconnectAll() async {
|
|
for (final s in sessions.toList()) {
|
|
try {
|
|
await s.disconnect();
|
|
} catch (e) {
|
|
Logger.e(_tag, '${s.vendor} 断开失败: $e');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 进功能页时刷一遍所有在线设备的状态(电量会掉、配置会变)。
|
|
Future<void> 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) {
|
|
_setInCall(true, 'callStarted/${s.vendor}');
|
|
}
|
|
if (key == DeviceFeatureKeys.callEnded) {
|
|
_setInCall(false, 'callEnded/${s.vendor}');
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// inCall 的唯一写入口。这个值决定主界面那道「通话中禁用同传/面对面/音视频」
|
|
/// 的闸门(DeviceConnection.isBlockedByCall),卡住了用户就进不去,
|
|
/// 所以每次翻转都留一条 warn —— 之前它一条日志都没有,排查时是个纯盲区。
|
|
void _setInCall(bool v, String why) {
|
|
if (inCall.value == v) return;
|
|
Logger.w(_tag, 'inCall ${inCall.value} → $v ($why)');
|
|
inCall.value = v;
|
|
}
|
|
|
|
void _recompute() {
|
|
anyConnected.value = sessions.isNotEmpty;
|
|
final caps = <DeviceCapability>{};
|
|
for (final s in sessions) {
|
|
caps.addAll(s.capabilities);
|
|
}
|
|
capabilities.assignAll(caps);
|
|
final p = primary;
|
|
primaryInfo.value = p?.info;
|
|
primaryName.value = p?.info.name ?? '';
|
|
// 通话状态以**会话快照**为准,feature 事件只是加速通道。
|
|
//
|
|
// ⚠️ 只靠事件会漏:worker 在 _markDisconnected() 里被整批 dispose,断连那一刻
|
|
// 之后的状态变化发不出来;而重连建的新 session 里 `ever` 注册时又**不会回调当前值**,
|
|
// 于是 inCall 停在上一轮的旧值。偏偏之后耳机每次刷新状态都是重复赋同一个值,
|
|
// RxBool 去重 → ever 再也不触发 → **永远不会自愈**,表现就是
|
|
// 「电话早挂了,同声翻译还一直点不进去」。快照每次 _recompute 都会重新对齐。
|
|
if (p == null) {
|
|
_setInCall(false, 'no-session');
|
|
} else {
|
|
_setInCall(p.info.metadata['inCall'] == true, 'snapshot/${p.vendor}');
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
|