import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:echomeet_device_sdk/echomeet_device_sdk.dart'; import 'package:get/get.dart'; import '../../core/utils/logger.dart'; import '../device_vendors.dart'; import 'holder_device_service.dart'; import 'holder_file_store.dart'; import 'holder_ota_port.dart'; /// EaiRec 录音支架的厂商插件——把 [HolderDeviceService](协议在 `recorder_holder` /// 插件里,冻结)适配成 [DevicePlugin] / [DeviceSession]。 /// /// 与恒玄的差异全在这一层吸收:BLE 扫描而不是系统配对列表、Opus 而不是 G.722、 /// 录音是「切状态 + 开编码器」两步而不是一条 `AA 59`。业务层看到的是同一套 /// `openMic(mic)` / `record.start`。 class HolderDevicePlugin implements DevicePlugin { final HolderDeviceService link; HolderDevicePlugin({HolderDeviceService? link}) : link = link ?? HolderDeviceService(); final StreamController _ctrl = StreamController.broadcast(); HolderDeviceSession? _session; final List _workers = []; bool _initialized = false; bool _disposed = false; @override String get vendorKey => DeviceVendors.holder; @override String get displayName => 'EaiRec (录音支架)'; @override Set get capabilities => HolderDeviceSession.caps; @override DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; @override DeviceSession? get activeSession => _session; @override Stream get eventStream => _ctrl.stream; @override Future initialize(DevicePluginConfig config) async { _checkAlive(); if (_initialized) return; _initialized = true; if (!Get.isRegistered()) { Get.put(link, permanent: true); } _workers.add(ever(link.connection, _onConn)); _workers.add(ever>(link.scanned, (list) { for (final d in list) { _emit(DevicePluginEvent( type: DevicePluginEventType.deviceDiscovered, deviceId: d.address, discovered: _discovered(d), )); } })); _workers.add(ever(link.isScanning, (on) { _emit(DevicePluginEvent( type: on ? DevicePluginEventType.scanStarted : DevicePluginEventType.scanStopped)); })); if (link.isConnected) _onConn(EchoMeetConnectionState.connected); _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); } DiscoveredDevice _discovered(EchoMeetScannedDevice d) => DiscoveredDevice( id: d.address.isNotEmpty ? d.address : d.id, name: d.name, vendor: vendorKey, metadata: {'advertisementHex': d.advertisementHex, 'sdkId': d.id}, ); void _onConn(EchoMeetConnectionState s) { switch (s) { case EchoMeetConnectionState.connected: if (_session != null) return; final sess = HolderDeviceSession._(link); _session = sess; _emit(DevicePluginEvent( type: DevicePluginEventType.connectionStateChanged, deviceId: sess.deviceId, connectionState: DeviceConnectionState.ready, )); break; case EchoMeetConnectionState.connecting: _emit(DevicePluginEvent( type: DevicePluginEventType.connectionStateChanged, deviceId: link.connectedAddress, connectionState: DeviceConnectionState.connecting, )); break; default: final sess = _session; _session = null; sess?._markDisconnected(); _emit(DevicePluginEvent( type: DevicePluginEventType.connectionStateChanged, deviceId: sess?.deviceId, connectionState: DeviceConnectionState.disconnected, )); } } void _emit(DevicePluginEvent e) { if (!_ctrl.isClosed) _ctrl.add(e); } // ---------- 扫描 / 连接 ---------- @override Future ensureReady() async { if (link.isPermissionCoolingDown) return false; // 用户主动进连接页:允许再申请一次系统蓝牙权限 link.resetPermissionDenied(); return link.requestPermission(); } /// 用户拒绝过蓝牙权限且仍在冷却期——UI 据此置灰入口 bool get isPermissionCoolingDown => link.isPermissionCoolingDown; @override Future startScan({DeviceScanFilter? filter, Duration? timeout}) => link.startScan(); @override Future stopScan() => link.stopScan(); @override Future isScanning() async => link.isScanning.value; @override Future> bondedDevices() async => link.scanned.map(_discovered).toList(growable: false); @override Future connect(String deviceId, {DeviceConnectOptions? options}) async { _checkAlive(); final d = link.scanned.firstWhereOrNull( (x) => x.address.toUpperCase() == deviceId.toUpperCase() || x.id == deviceId); if (d == null) { throw DeviceException(DeviceErrorCode.connectFailed, '未扫描到 $deviceId'); } final registered = await link.connect(d); if (!registered) { throw DeviceException(DeviceErrorCode.connectFailed, deviceId); } final ok = await link.waitConnected( timeout: options?.timeout ?? const Duration(seconds: 25)); if (!ok) throw DeviceException(DeviceErrorCode.connectTimeout, deviceId); _onConn(EchoMeetConnectionState.connected); return _session!; } @override List get rememberedDevices => link.pairedDevices .map((e) => DiscoveredDevice( id: (e['address'] ?? '').toString(), name: (e['name'] ?? '').toString(), vendor: vendorKey, )) .where((d) => d.id.isNotEmpty) .toList(growable: false); @override DiscoveredDevice? get lastKnownDevice { final n = link.lastDeviceName; if (n.isEmpty) return null; return DiscoveredDevice( id: link.connectedAddress, name: n, vendor: vendorKey); } @override Future rememberDevice(DiscoveredDevice device) async { // 支架在 connect 成功时自己会记;这里保持幂等 } @override Future tryAutoConnect() => link.tryAutoConnect(); @override Future forgetDevice(String deviceId) async { link.removePaired(deviceId); if (link.isConnected && link.connectedAddress.toUpperCase() == deviceId.toUpperCase()) { await link.disconnect(); } } @override Future dispose() async { _disposed = true; for (final w in _workers) { w.dispose(); } _session?._markDisconnected(); _session = null; await _ctrl.close(); } void _checkAlive() { if (_disposed) throw StateError('HolderDevicePlugin disposed'); } } class HolderDeviceSession extends DeviceSession { HolderDeviceSession._(this._link) { _bind(); } static const String _tag = 'HolderSession'; static const Set caps = { DeviceCapability.scan, DeviceCapability.connect, DeviceCapability.battery, DeviceCapability.macAddress, DeviceCapability.micUplink, DeviceCapability.micRecording, // AB FOT 固件升级(协议实现在 recorder_holder/lib/src/ota/),见 [HolderOtaPort] DeviceCapability.ota, // 通道 B:TF 卡录音文件,见 [HolderFileStore]。 // ⚠️ 这台硬件**只有**这条录音路径可用(没有 ae00 实时音频服务)。 DeviceCapability.fileStore, DeviceCapability.customCommand, }; final HolderDeviceService _link; final StreamController _events = StreamController.broadcast(); final List _workers = []; DeviceConnectionState _state = DeviceConnectionState.ready; bool _lastRecording = false; void _bind() { _lastRecording = _link.recording.value.isRecording; _workers.add(ever(_link.recording, (st) { if (st.isRecording == _lastRecording) return; _lastRecording = st.isRecording; _feature(st.isRecording ? DeviceFeatureKeys.recordStarted : DeviceFeatureKeys.recordStopped); })); _workers.add(ever(_link.deviceInfo, (_) { _feature(DeviceFeatureKeys.batteryUpdated); _infoUpdated(); })); _workers.add(ever(_link.connectedNameRx, (_) => _infoUpdated())); } void _feature(String key, [Map data = const {}]) { if (_events.isClosed) return; _events.add(DeviceSessionEvent( type: DeviceSessionEventType.feature, deviceId: deviceId, feature: DeviceFeatureEvent(key: key, data: data), )); } void _infoUpdated() { if (_events.isClosed) return; _events.add(DeviceSessionEvent( type: DeviceSessionEventType.deviceInfoUpdated, deviceId: deviceId, deviceInfo: info, )); } void _markDisconnected() { if (_state == DeviceConnectionState.disconnected) return; _state = DeviceConnectionState.disconnected; for (final w in _workers) { w.dispose(); } _workers.clear(); _ota?.onSessionGone(); if (!_events.isClosed) { _events.add(DeviceSessionEvent( type: DeviceSessionEventType.connectionStateChanged, deviceId: deviceId, connectionState: DeviceConnectionState.disconnected, )); _events.close(); } } void _requireReady() { if (_state != DeviceConnectionState.ready) { throw DeviceException(DeviceErrorCode.noActiveSession, 'holder'); } } @override String get deviceId => _link.connectedAddress; @override String get vendor => DeviceVendors.holder; @override DeviceConnectionState get state => _state; @override Set get capabilities => caps; @override Stream get eventStream => _events.stream; static final RegExp _macRe = RegExp(r'^([0-9A-F]{2}:){5}[0-9A-F]{2}$'); @override DeviceInfo get info { final i = _link.deviceInfo.value; final lvl = i?.batteryLevel; final addr = _link.connectedAddress.toUpperCase(); return DeviceInfo( id: deviceId, name: _link.connectedName, vendor: vendor, firmwareVersion: i?.firmwareVersion, batteryPercent: lvl, battery: DeviceBattery(single: lvl, chargingSingle: i?.isCharging ?? false), // iOS 上拿到的是 peripheral UUID,只有长得像 MAC 才当 MAC macAddress: Platform.isAndroid || _macRe.hasMatch(addr) ? addr : null, metadata: {'productId': i?.productId, 'deviceIsRecording': _link.deviceIsRecording}, ); } @override Future readRssi() => throw DeviceException(DeviceErrorCode.notSupported, 'rssi'); @override Future readBattery() async { _requireReady(); await _link.refreshBattery(); return info.battery.single; } @override Future refreshInfo() async { _requireReady(); await _link.refreshBattery(); return info; } /// 主麦上行。**必须**打开设备编码器,否则设备不往手机推 Opus 流 /// (设备按键发起的录音只切本地录音态、不开编码器,所以无条件补一条)。 @override Future openMic({AudioRoute route = AudioRoute.mic}) async { _requireReady(); if (route != AudioRoute.mic) { throw DeviceException(DeviceErrorCode.notSupported, 'openMic($route)'); } final ok = await _link.openEncoder(); if (!ok) Logger.w(_tag, 'openEncoder 返回 false,可能收不到音频流'); return _HolderSource(_link.audioFrames.map((f) => f.pcm)); } @override Future> invokeFeature(String featureKey, [Map args = const {}]) async { _requireReady(); switch (featureKey) { case DeviceFeatures.recordStart: // toggle 语义:设备已在录时再切一次会把它关掉,先看设备状态 if (_link.deviceIsRecording) return const {'ok': true, 'already': true}; final ok = await _link.startRecording(); return {'ok': ok}; case DeviceFeatures.recordStop: if (!_link.deviceIsRecording) return const {'ok': true, 'already': true}; final ok = await _link.stopRecording( fileName: args['fileName'] as String?); return {'ok': ok}; case 'holder.toggle': case 'record.toggle': return {'ok': await _link.toggleRecording()}; case DeviceFeatures.batteryQuery: return {'ok': await _link.refreshBattery()}; case DeviceFeatures.stateRefresh: await _link.refreshBattery(); return const {}; default: throw DeviceException(DeviceErrorCode.notSupported, featureKey); } } /// 主动断开 = 「不要它了」:支架 SDK 的 disconnect 会一并清掉自动回连记忆。 HolderOtaPort? _ota; HolderFileStore? _files; @override DeviceFileStore? fileStore() => _files ??= HolderFileStore(_link); @override DeviceOtaPort? otaPort() => _ota ??= HolderOtaPort(_link); @override Future disconnect() => _link.disconnect(); } class _HolderSource implements DeviceAudioSource { _HolderSource(Stream src) { _sub = src.listen(_ctrl.add, onError: _ctrl.addError); } final StreamController _ctrl = StreamController.broadcast(); StreamSubscription? _sub; @override AudioRoute get route => AudioRoute.mic; @override AudioFormat get format => AudioFormat.pcm16kMono; @override Stream get pcm => _ctrl.stream; @override Future close() async { await _sub?.cancel(); _sub = null; await _ctrl.close(); } }