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.
 
 
 
 
 
 

437 lines
14 KiB

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<DevicePluginEvent> _ctrl =
StreamController<DevicePluginEvent>.broadcast();
HolderDeviceSession? _session;
final List<Worker> _workers = [];
bool _initialized = false;
bool _disposed = false;
@override
String get vendorKey => DeviceVendors.holder;
@override
String get displayName => 'EaiRec (录音支架)';
@override
Set<DeviceCapability> get capabilities => HolderDeviceSession.caps;
@override
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty;
@override
DeviceSession? get activeSession => _session;
@override
Stream<DevicePluginEvent> get eventStream => _ctrl.stream;
@override
Future<void> initialize(DevicePluginConfig config) async {
_checkAlive();
if (_initialized) return;
_initialized = true;
if (!Get.isRegistered<HolderDeviceService>()) {
Get.put<HolderDeviceService>(link, permanent: true);
}
_workers.add(ever<EchoMeetConnectionState>(link.connection, _onConn));
_workers.add(ever<List<EchoMeetScannedDevice>>(link.scanned, (list) {
for (final d in list) {
_emit(DevicePluginEvent(
type: DevicePluginEventType.deviceDiscovered,
deviceId: d.address,
discovered: _discovered(d),
));
}
}));
_workers.add(ever<bool>(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<bool> ensureReady() async {
if (link.isPermissionCoolingDown) return false;
// 用户主动进连接页:允许再申请一次系统蓝牙权限
link.resetPermissionDenied();
return link.requestPermission();
}
/// 用户拒绝过蓝牙权限且仍在冷却期——UI 据此置灰入口
bool get isPermissionCoolingDown => link.isPermissionCoolingDown;
@override
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) =>
link.startScan();
@override
Future<void> stopScan() => link.stopScan();
@override
Future<bool> isScanning() async => link.isScanning.value;
@override
Future<List<DiscoveredDevice>> bondedDevices() async =>
link.scanned.map(_discovered).toList(growable: false);
@override
Future<DeviceSession> 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<DiscoveredDevice> 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<void> rememberDevice(DiscoveredDevice device) async {
// 支架在 connect 成功时自己会记;这里保持幂等
}
@override
Future<void> tryAutoConnect() => link.tryAutoConnect();
@override
Future<void> forgetDevice(String deviceId) async {
link.removePaired(deviceId);
if (link.isConnected &&
link.connectedAddress.toUpperCase() == deviceId.toUpperCase()) {
await link.disconnect();
}
}
@override
Future<void> 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<DeviceCapability> 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<DeviceSessionEvent> _events =
StreamController<DeviceSessionEvent>.broadcast();
final List<Worker> _workers = [];
DeviceConnectionState _state = DeviceConnectionState.ready;
bool _lastRecording = false;
void _bind() {
_lastRecording = _link.recording.value.isRecording;
_workers.add(ever<EchoMeetRecordingState>(_link.recording, (st) {
if (st.isRecording == _lastRecording) return;
_lastRecording = st.isRecording;
_feature(st.isRecording
? DeviceFeatureKeys.recordStarted
: DeviceFeatureKeys.recordStopped);
}));
_workers.add(ever<EchoMeetDeviceInfo?>(_link.deviceInfo, (_) {
_feature(DeviceFeatureKeys.batteryUpdated);
_infoUpdated();
}));
_workers.add(ever<String>(_link.connectedNameRx, (_) => _infoUpdated()));
}
void _feature(String key, [Map<String, Object?> 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<DeviceCapability> get capabilities => caps;
@override
Stream<DeviceSessionEvent> 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<int> readRssi() =>
throw DeviceException(DeviceErrorCode.notSupported, 'rssi');
@override
Future<int?> readBattery() async {
_requireReady();
await _link.refreshBattery();
return info.battery.single;
}
@override
Future<DeviceInfo> refreshInfo() async {
_requireReady();
await _link.refreshBattery();
return info;
}
/// 主麦上行。**必须**打开设备编码器,否则设备不往手机推 Opus 流
/// (设备按键发起的录音只切本地录音态、不开编码器,所以无条件补一条)。
@override
Future<DeviceAudioSource> 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<Map<String, Object?>> invokeFeature(String featureKey,
[Map<String, Object?> 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<void> disconnect() => _link.disconnect();
}
class _HolderSource implements DeviceAudioSource {
_HolderSource(Stream<Uint8List> src) {
_sub = src.listen(_ctrl.add, onError: _ctrl.addError);
}
final StreamController<Uint8List> _ctrl = StreamController.broadcast();
StreamSubscription? _sub;
@override
AudioRoute get route => AudioRoute.mic;
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Stream<Uint8List> get pcm => _ctrl.stream;
@override
Future<void> close() async {
await _sub?.cancel();
_sub = null;
await _ctrl.close();
}
}