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.
438 lines
14 KiB
438 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();
|
|
}
|
|
}
|
|
|