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.
 
 
 
 
 
 

335 lines
13 KiB

import 'dart:async';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import 'package:get/get.dart';
import 'package:get_storage/get_storage.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 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、
/// 功能却是香薰的」这种自相矛盾的界面。
///
/// 没有活跃会话时,先认**最后一次上线的那家**([lastUsedVendor],落盘,跨冷启动),
/// 它没有记住的设备才退回按插件注册顺序找。
/// ⚠️ 2026-09-25 以前只有后一条:连过耳机又连了香薰、香薰一断,
/// 界面就跳回耳机(恒玄注册在前),与「最后连的是哪台就显示哪台」相反。
({String name, String vendor}) get _lastKnown {
final p = primary;
if (p != null && p.info.name.isNotEmpty) {
return (name: p.info.name, vendor: p.vendor);
}
final last = _plugins[lastUsedVendor]?.lastKnownDevice;
if (last != null && last.name.isNotEmpty) {
return (name: last.name, vendor: lastUsedVendor);
}
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: '');
}
static const String _lastVendorKey = 'device_hub_last_vendor';
/// 最后一次上线的设备出自哪家插件(落盘)。从没连过为空串。
/// 自动回连也按它决定该回连哪一家,避免冷启动时两家互相顶掉。
String get lastUsedVendor {
try {
return GetStorage().read<String>(_lastVendorKey) ?? '';
} catch (_) {
return '';
}
}
void _rememberLastVendor(String vendor) {
try {
if (lastUsedVendor != vendor) GetStorage().write(_lastVendorKey, vendor);
} catch (e) {
Logger.w(_tag, '记录最后连接的设备厂商失败: $e');
}
}
/// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。
/// 从没连过任何设备返回空串。
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);
_rememberLastVendor(s.vendor);
_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();
}
}