import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'package:classic_bluetooth/classic_bluetooth.dart'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:flutter/services.dart' show Uint8List; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/utils/logger.dart'; import '../../data/utils/pcm_stream_player.dart'; import '../../core/utils/permission_util.dart'; import '../device_auth.dart'; import '../device_vendors.dart'; import 'eaicar_device_auth.dart'; import 'eaicar_link.dart'; import 'eaicar_ota_port.dart'; import 'eaicar_protocol.dart'; /// EaiCar(车载香薰,旧名 Smartcar)厂商插件。 /// /// 把 [EaiCarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约, /// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。 /// /// ## 本期(阶段 1~3)范围 /// /// 只做**连接 + 控制 + 绑定确权**。声明的能力集也只有这些—— /// [DeviceCapability] 是「只增不删」的,先声明再实现会让业务层把不存在的功能画出来。 /// /// 还没做、要等后续阶段的: /// - `micUplink` / `aiWake`:Opus 上行(阶段 6),要在同一条 GATT 上订阅 /// [EaiCarProtocol.audioNotifyCharUuid],**不能另起连接** /// - `ota`:阶段 7,搬杰理 OTA SDK /// - 经典蓝牙 A2DP 音乐(阶段 5),要先给 `classic_bluetooth` 补 `connectDevice` class EaiCarDevicePlugin implements DevicePlugin { static const String _tag = 'EaiCarDevicePlugin'; /// 记住的设备(自动回连用)。存 `{id, name, mac}`。 static const String _rememberedKey = 'smartcar_remembered_devices'; final EaiCarLink link = EaiCarLink(); final GetStorage _storage = GetStorage(); final _events = StreamController.broadcast(); /// 扫描诊断事件的 key(业务层按它取 [DevicePluginEvent.customPayload])。 /// /// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着), /// 所以诊断信息只能顺着插件事件流出去。 static const String kScanDiagnosticKey = 'eaicar.scan_diagnostic'; EaiCarSession? _session; StreamSubscription? _connSub; StreamSubscription>? _diagSub; bool _disposed = false; bool _scanning = false; @override String get vendorKey => DeviceVendors.eaicar; @override String get displayName => 'EaiCar (车载香薰)'; @override Set get capabilities => EaiCarSession.caps; @override DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; @override DeviceSession? get activeSession => _session; @override Stream get eventStream => _events.stream; @override Future initialize(DevicePluginConfig config) async { _checkAlive(); // 这里**不申请权限、不起扫描**:initialize 在冷启动就跑, // 弹权限框会在启动页糊用户一脸(与 bes / holder 同口径)。 _diagSub?.cancel(); _diagSub = link.diagnostics.listen((d) { _emit(DevicePluginEvent( type: DevicePluginEventType.customEvent, customKey: kScanDiagnosticKey, customPayload: d, )); }); _connSub?.cancel(); _connSub = link.connected.listen((up) { if (!up) { _session?._markDisconnected(); _session = null; } _emit(DevicePluginEvent( type: DevicePluginEventType.connectionStateChanged, deviceId: link.deviceId, connectionState: up ? DeviceConnectionState.ready : DeviceConnectionState.disconnected, )); }); _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady)); } /// 自持 GATT 要的运行时权限(Android 12+)。iOS 由系统在首次用 CoreBluetooth 时弹框。 static const List _btPermissions = [ Permission.bluetoothScan, Permission.bluetoothConnect, ]; /// 用户拒绝过且仍在冷却期——UI 据此置灰入口(与支架同一套闸) bool get isPermissionCoolingDown => Platform.isAndroid && PermissionUtil.instance.isAnyDenialCoolingDown(_btPermissions); @override Future ensureReady() async { if (!Platform.isAndroid) return true; // ⚠️ Android 12+ 起 BLUETOOTH_SCAN / BLUETOOTH_CONNECT 是运行时权限, // 没授权时原生 startScan 直接抛 SecurityException。我们不走 flutter_blue_plus // (它自己会申请),所以这一步必须自己做,否则安卓上永远「扫不到设备」且不报错。 if (isPermissionCoolingDown) { Logger.i(_tag, '蓝牙权限已被拒绝且在冷却期,本次不再申请'); return false; } final granted = await PermissionUtil.instance.requestPermissions( permissions: _btPermissions, permissionName: 'bluetoothPermissions'.tr, explanationText: 'bluetoothPermissionDetailDesc'.tr, userInitiated: true, ); if (!granted) await PermissionUtil.instance.markDenied(_btPermissions); return granted; } // ───────────────────────────── 扫描 ───────────────────────────── StreamSubscription? _scanSub; @override Future startScan({DeviceScanFilter? filter, Duration? timeout}) async { _checkAlive(); await stopScan(); _scanning = true; _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStarted)); _scanSub = link .scan(timeout: timeout ?? const Duration(seconds: 15)) .listen((r) { _emit(DevicePluginEvent( type: DevicePluginEventType.deviceDiscovered, deviceId: r.id, discovered: DiscoveredDevice( id: r.id, name: r.name.isEmpty ? 'EaiCar' : r.name, rssi: r.rssi, vendor: vendorKey, // ⚠️ MAC 与 License 只在广播里有,连上之后就拿不到了。 // 绑定确权要用,所以在这里就带出去,别等到 connect 之后再找。 metadata: { 'mac': r.advertisement?.macAddress, 'license': r.advertisement?.license, 'pid': r.advertisement?.pid, 'classicBtConnected': r.advertisement?.classicBtConnected, }, ), )); }, onError: (Object e) { _emit(DevicePluginEvent( type: DevicePluginEventType.error, errorCode: DeviceErrorCode.scanFailed, errorMessage: '$e', )); }, onDone: () { _scanning = false; _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); }); } @override Future stopScan() async { await _scanSub?.cancel(); _scanSub = null; await link.stopScan(); if (_scanning) { _scanning = false; _emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped)); } } @override Future isScanning() async => _scanning; @override Future> bondedDevices() async => rememberedDevices; // ───────────────────────────── 连接 ───────────────────────────── @override Future connect(String deviceId, {DeviceConnectOptions? options}) async { _checkAlive(); await stopScan(); final adv = options?.extra['advertisement']; final macHint = options?.extra['mac'] as String?; try { // 45s 不是随手写的:GATT 最多等 35s(设备被别的手机占着时不接受新连接), // 之后杰理认证再给 15s。给 15s 会在设备刚被抢走时误报"超时"。 await link.connect( deviceId, timeout: options?.timeout ?? const Duration(seconds: 45), adv: adv is EaiCarAdvertisement ? adv : null, ); } on TimeoutException catch (e) { throw DeviceException(DeviceErrorCode.connectTimeout, '$e'); } catch (e) { throw DeviceException(DeviceErrorCode.connectFailed, '$e'); } final s = EaiCarSession( deviceId: deviceId, link: link, plugin: this, macAddress: link.advertisement?.macAddress ?? macHint, ); // 契约要求:握手完成后才 resolve。这里的「握手」= 取到固件版本, // 取不到说明服务/特征对不上,宁可判失败也别把半条链路交给业务层。 try { await s._handshake(); } catch (e) { await link.disconnect(); // 握手失败 = BLE 连上了但设备不认我们的命令(命令号错、CRC 错、 // 或这一版固件根本没有 0x15/0x01)。**必须把原因带出去**, // 只说「连接失败」分不清是链路问题还是协议问题。 throw DeviceException( DeviceErrorCode.handshakeFailed, 'BLE 已连上,但握手失败:$e'); } // 确权闸门。放在握手之后:要拿设备名去登记,而设备名是握手时才确定的。 // // ⚠️ 只有 denied 才断开。unknown(未登录/断网/没解出 MAC)一律放行—— // 判严的代价是合法用户断网就用不了自己的设备,见 [EaiCarDeviceAuth] 类注释。 final auth = await EaiCarDeviceAuth.verify( mac: s.info.macAddress, name: s.info.name, ); if (auth == DeviceAuthResult.denied) { await link.disconnect(); throw DeviceException( DeviceErrorCode.connectFailed, '设备未登记:这台 MAC 不在后台设备表里', ); } _session = s; // 顺手把经典蓝牙(A2DP)接起来:AI 回复与音乐都靠它出声,BLE 只走控制与上行。 // Android 上是 createBond(配上了系统自己接 A2DP);iOS 上做不到,只会返回 // unsupported_ios,由扫描页提示用户去系统设置配对。失败一律不影响 BLE 会话。 unawaited(s.connectClassicAudio().then((r) { Logger.i(_tag, '经典蓝牙: $r'); }).catchError((Object e) { Logger.w(_tag, '经典蓝牙自动连接失败(不影响 BLE): $e'); })); _emit(DevicePluginEvent( type: DevicePluginEventType.connectionStateChanged, deviceId: deviceId, connectionState: DeviceConnectionState.ready, )); return s; } // ────────────────────── 记住 / 回连 / 解绑 ────────────────────── List> get _remembered => (_storage.read(_rememberedKey) ?? const []) .whereType() .map((e) => e.cast()) .toList(); @override List get rememberedDevices => _remembered .map((m) => DiscoveredDevice( id: '${m['id']}', name: '${m['name'] ?? 'EaiCar'}', vendor: vendorKey, metadata: {'mac': m['mac']}, )) .toList(); @override DiscoveredDevice? get lastKnownDevice { final list = rememberedDevices; return list.isEmpty ? null : list.first; } @override Future rememberDevice(DiscoveredDevice device) async { final list = _remembered..removeWhere((m) => m['id'] == device.id); // 最近的排前面,[lastKnownDevice] 靠这个顺序 list.insert(0, { 'id': device.id, 'name': device.name, 'mac': device.metadata['mac'], }); await _storage.write(_rememberedKey, list); } @override Future forgetDevice(String deviceId) async { final list = _remembered..removeWhere((m) => m['id'] == deviceId); await _storage.write(_rememberedKey, list); if (link.deviceId == deviceId) await link.disconnect(); } @override Future tryAutoConnect() async { if (_disposed || _session != null || link.isConnected) return; final first = rememberedDevices.isEmpty ? null : rememberedDevices.first; if (first == null) return; try { await connect(first.id); } catch (e) { // 回连失败很正常(设备不在身边 / 蓝牙没开),不该打扰用户 Logger.i(_tag, '自动回连未成功: $e'); } } @override Future dispose() async { if (_disposed) return; _disposed = true; await _scanSub?.cancel(); await _connSub?.cancel(); await _diagSub?.cancel(); await link.dispose(); _emit(const DevicePluginEvent(type: DevicePluginEventType.pluginDisposed)); await _events.close(); } void _emit(DevicePluginEvent e) { if (!_events.isClosed) _events.add(e); } void _checkAlive() { if (_disposed) throw StateError('EaiCarDevicePlugin 已 dispose'); } } /// EaiCar 的会话句柄 class EaiCarSession implements DeviceSession { EaiCarSession({ required this.deviceId, required EaiCarLink link, required EaiCarDevicePlugin plugin, String? macAddress, }) : _link = link, _plugin = plugin, _mac = macAddress { _frameSub = _link.frames.listen(_onFrame); } /// 本期实现到的能力。 /// /// ⚠️ **只增不删**,而且**别提前声明没做的**:业务层按能力画 UI, /// 声明了 `micUplink` 却没实现,AI 页就会出现一个点了没反应的入口。 static const Set caps = { DeviceCapability.scan, DeviceCapability.connect, DeviceCapability.battery, // EaiCar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强, // 恒玄在 iOS 上只有 peripheral UUID,确权等于没有。 DeviceCapability.macAddress, DeviceCapability.customCommand, // 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [EaiCarOtaPort] DeviceCapability.ota, // 设备麦克风上行:opus → 16k PCM(原生 SmartcarAudio 解码) DeviceCapability.micUplink, // 设备按键/语音唤醒 AI:设备主动上报 0xCC 0x11。声明它就能白嫖 // DeviceAiSessionService 那整套会话编排 DeviceCapability.aiWake, // 设备设置面板:音量/亮度/启停/行车感应/摇摆/随机表情,状态来自 0x15, // 写命令见 [settingsCommand]。业务层按这个能力位在设备管理页开面板。 DeviceCapability.settingsPanel, // ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP // (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇, // 见 [_EaiCarPhoneSpeaker]。 }; @override final String deviceId; final EaiCarLink _link; /// OTA 结束后回连要用([EaiCarOtaPort.close]) final EaiCarDevicePlugin _plugin; final String? _mac; StreamSubscription? _frameSub; final _events = StreamController.broadcast(); final _wake = StreamController.broadcast(); _EaiCarMic? _mic; _EaiCarPhoneSpeaker? _spk; DeviceConnectionState _state = DeviceConnectionState.linkConnected; DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: ''); /// 最近一次 0x15 汇总解出来的全部设置(写成功后本地回填)。 /// 握手成功后必有值;为 null 说明 0x15 从没应答过。 EaiCarAllSettings? _settings; /// 只读快照,给面板/排查用;写要走 [invokeFeature] 的 `settings.*`。 EaiCarAllSettings? get settings => _settings; @override String get vendor => DeviceVendors.eaicar; @override DeviceConnectionState get state => _state; @override DeviceInfo get info => _info; @override Set get capabilities => caps; @override bool supports(DeviceCapability c) => caps.contains(c); @override Stream get eventStream => _events.stream; /// 握手:先宣告「APP 已连接」(0x10),再取一次固件版本 + 参数汇总。 /// /// ⚠️ 0x10 不能省:杰理认证通过、0x15 也能应答的情况下,设备屏幕照样显示 /// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [EaiCarProtocol.cmdAppConnected])。 /// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。 /// /// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。 /// 拿不到 0x15 时退回 0x01(只要版本号),两条都失败才判握手失败。 Future _handshake() async { _info = DeviceInfo( id: deviceId, name: 'EaiCar', vendor: vendor, macAddress: _mac, ); await _announceAppConnected(); await _jlPostAuth(); await _syncTime(); Object? e15; try { final f = await _link.send(EaiCarProtocol.cmdSyncSummary); if (f != null && f.success) { _applySummary(f.payload); _state = DeviceConnectionState.ready; return; } e15 = '设备回了 0x15 但 len=0(命令执行失败)'; } catch (e) { e15 = e; Logger.w('EaiCarSession', '0x15 参数汇总失败,退回取版本号: $e'); } Object? e01; EaiCarFrame? v; try { v = await _link.send(EaiCarProtocol.cmdGetVersion); } catch (e) { e01 = e; } if (v == null || !v.success) { // 两条查询命令都没回应答。最可能的原因按概率排: // ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 EaiCarProtocol 的 // TODO(eaicar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。 throw StateError('0x15 与 0x01 都没有有效应答 ' '(0x15: $e15; 0x01: ${e01 ?? '无应答'})。' '多半是命令号或帧格式不对——见 EaiCarProtocol 里的 TODO(eaicar-cmd)'); } _applyVersion(v.payload); _state = DeviceConnectionState.ready; } /// 0x10「蓝牙连接成功」:`[设备类型, 车企编号]`,设备回 `BB 10 01 64` 才算数。 Future _announceAppConnected() async { final appType = Platform.isIOS ? EaiCarProtocol.appTypeIos : EaiCarProtocol.appTypeAndroid; EaiCarFrame? f; try { f = await _link.send( EaiCarProtocol.cmdAppConnected, payload: [appType, EaiCarProtocol.carVendorDefault], ); } catch (e) { throw StateError('0x10「蓝牙连接成功」无应答:$e。' '认证已过但设备不会把手机当成已连接,屏幕会一直显示 BLE 未连接'); } if (f == null || !f.success) { throw StateError('设备拒绝了 0x10「蓝牙连接成功」(len=0)'); } Logger.i('EaiCarSession', '已向设备宣告 APP 连接 (type=$appType),设备应答 ${f.payload}'); } /// 杰理 RCSP 认证后的收尾:设时间 → GET_TARGET_INFO → GET_SYS_INFO。 /// /// lumi 两端的 JL SDK 都会在认证后自动发这一串(iOS `initDialSystem`、Android `WatchOpImpl`), /// 我们自己持有 GATT 就得自己发。2026-09-10 真机:0x10 + 0x15 都成功时屏幕仍显示未连接, /// 把这串和 0x17 补上之后才亮。TODO(eaicar-handshake):还没二分出到底是哪一步点亮的, /// 全部保留;失败只记日志不断链——这几条是"锦上添花",链路本身已经能用。 Future _jlPostAuth() async { try { final r = await _link.jlPostAuth(); Logger.i('EaiCarSession', 'RCSP 认证后收尾: $r'); } catch (e) { Logger.w('EaiCarSession', 'RCSP 认证后收尾失败(继续): $e'); } } /// 0x17 同步手机时间:`[年%100, 月, 日, 时, 分, 秒, 0x01]`(lumi 连上 800ms 后发,设备回 `BB 17 01 64`)。 /// 协议文档里没有这条,以 lumi 代码与真机应答为准。 Future _syncTime() async { final n = DateTime.now(); try { await _link.send( EaiCarProtocol.cmdSyncTime, payload: [n.year % 100, n.month, n.day, n.hour, n.minute, n.second, 0x01], timeout: const Duration(seconds: 2), ); } catch (e) { Logger.w('EaiCarSession', '0x17 时间同步无应答(继续): $e'); } } void _applySummary(List p) { final s = EaiCarAllSettings.parse(p); if (s == null) return; _info = _info.copyWith( firmwareVersion: s.firmwareVersion, batteryPercent: s.battery, battery: DeviceBattery(single: s.battery), ); _emitInfo(); _emitFeature('common.battery', {'percent': s.battery}); // 2026-09-22 之前这里只留固件和电量,音量/亮度/各开关解完就丢—— // 设备设置面板要的"当前值"正是这些,整份留下并派事件。 _settings = s; _emitFeature(DeviceFeatureKeys.settingsChanged, s.toSettingsMap()); } /// `settings.*` 写命令的**纯映射**:feature key + 参数 → (命令号, 载荷, 是否等应答)。 /// /// 抽成静态纯函数是为了让 `test/devices/eaicar_settings_features_test.dart` /// 不连设备就能逐字节钉死;[invokeFeature] 只是"映射 → 发 → 回填"。 /// 参数越界抛 `device.invalid_argument`,不认识的 key 返回 null。 /// /// ⚠️ `play_random_expression`:设备**没有**"随机播一个"的原生命令, /// 这里由 app 在 0x00~0x09 里挑([random] 可注入,测试用固定值)。 static ({int cmd, List payload, bool expectResponse})? settingsCommand( String key, Map args, { int Function(int max)? random, }) { int intArg(String name) { final v = args[name]; if (v is num) return v.toInt(); throw DeviceException( DeviceErrorCode.invalidArgument, '$key 缺少整数参数 $name'); } bool onArg() { final v = args['on']; if (v is bool) return v; throw DeviceException( DeviceErrorCode.invalidArgument, '$key 缺少布尔参数 on'); } int ranged(String name, int max) { final v = intArg(name); if (v < 0 || v > max) { throw DeviceException( DeviceErrorCode.invalidArgument, '$key $name=$v 超出 0~$max'); } return v; } switch (key) { case DeviceFeatures.settingsVolume: return ( cmd: EaiCarProtocol.cmdSetVolume, payload: [ranged('value', EaiCarProtocol.volumeMax)], expectResponse: true, ); case DeviceFeatures.settingsBrightness: return ( cmd: EaiCarProtocol.cmdControlBrightness, payload: [ranged('value', EaiCarProtocol.brightnessMax)], expectResponse: true, ); case DeviceFeatures.settingsPower: return ( cmd: EaiCarProtocol.cmdAutoPowerSwitch, payload: [onArg() ? 0x01 : 0x00], expectResponse: true, ); case DeviceFeatures.settingsDrivingSense: return ( cmd: EaiCarProtocol.cmdGyroscopeSwitch, payload: [onArg() ? 0x01 : 0x00], expectResponse: true, ); case DeviceFeatures.settingsSwing: return ( cmd: EaiCarProtocol.cmdMotorSwitch, payload: [onArg() ? 0x01 : 0x00], expectResponse: true, ); case DeviceFeatures.settingsRandomExpressionInterval: final s = intArg('seconds'); if (!EaiCarProtocol.randomExpressionIntervalOptions.contains(s)) { throw DeviceException(DeviceErrorCode.invalidArgument, '$key seconds=$s 不在 ${EaiCarProtocol.randomExpressionIntervalOptions}'); } return ( cmd: EaiCarProtocol.cmdRandomExpressionInterval, payload: [s], expectResponse: true, ); case DeviceFeatures.settingsPlayRandomExpression: final pick = (random ?? _defaultRandom)(EaiCarProtocol.emotionCount); return ( cmd: EaiCarProtocol.cmdControlEmotion, payload: [pick.clamp(0, EaiCarProtocol.emotionCount - 1)], expectResponse: false, ); default: return null; } } static final math.Random _rng = math.Random(); static int _defaultRandom(int max) => _rng.nextInt(max); /// 发一条 `settings.*` 写命令:设备拒绝(应答 len==0)抛 `feature_failed`; /// 成功后**本地回填** [_settings] 并派 `settings.changed`。 /// /// 为什么要本地回填:六条写命令的应答只有 `0x64` 成功码,不回显新值, /// 也没有主动上报;不回填的话面板要等下一次 0x15 才知道自己改成了没有。 Future> _applySettingsCommand( String key, Map args) async { final c = settingsCommand(key, args); if (c == null) { throw DeviceException(DeviceErrorCode.notSupported, 'feature $key'); } final f = await _link.send(c.cmd, payload: c.payload, expectResponse: c.expectResponse); if (c.expectResponse && (f == null || !f.success)) { throw DeviceException( DeviceErrorCode.featureFailed, '$key 设备拒绝 (0x${c.cmd.toRadixString(16)})'); } final cur = _settings; if (cur != null && key != DeviceFeatures.settingsPlayRandomExpression) { final v = c.payload[0]; _settings = switch (key) { DeviceFeatures.settingsVolume => cur.copyWith(volume: v), DeviceFeatures.settingsBrightness => cur.copyWith(brightness: v), DeviceFeatures.settingsPower => cur.copyWith(powerSwitch: v != 0), DeviceFeatures.settingsDrivingSense => cur.copyWith(gyroscope: v != 0), DeviceFeatures.settingsSwing => cur.copyWith(motor: v != 0), DeviceFeatures.settingsRandomExpressionInterval => cur.copyWith(randomExprFreq: v), _ => cur, }; _emitFeature(DeviceFeatureKeys.settingsChanged, _settings!.toSettingsMap()); } return {'ok': true}; } void _applyVersion(List p) { if (p.length < 3) return; _info = _info.copyWith( firmwareVersion: '${p[0] & 0xFF}.${p[1] & 0xFF}.${p[2] & 0xFF}'); _emitInfo(); } void _onFrame(EaiCarFrame f) { if (f.isNotify) { // 设备主动上报。0xCC 0x11 = AI 唤醒:设备已经自己开麦了, // DeviceAiSessionService 收到 wakeEvents 后会 aiAccept + openMic。 if (f.cmd == EaiCarProtocol.notifyAiWake) { Logger.i('EaiCarSession', '设备唤醒 AI 对话 (0xCC 0x11)'); _emitFeature('eaicar.ai_wake', const {}); if (!_wake.isClosed) { _wake.add(DeviceWakeEvent( deviceId: deviceId, // 设备侧是按键或语音触发的,协议里区分不出来,统一按 ptt 上报 reason: WakeReason.ptt, )); } return; } if (f.cmd == EaiCarProtocol.cmdGetBattery && f.payload.isNotEmpty) { final b = f.payload[0] & 0xFF; _info = _info.copyWith( batteryPercent: b, battery: DeviceBattery(single: b)); _emitInfo(); _emitFeature('common.battery', {'percent': b}); } return; } if (f.isResponse && f.success) { if (f.cmd == EaiCarProtocol.cmdSyncSummary) _applySummary(f.payload); if (f.cmd == EaiCarProtocol.cmdGetVersion) _applyVersion(f.payload); if (f.cmd == EaiCarProtocol.cmdGetBattery && f.payload.isNotEmpty) { final b = f.payload[0] & 0xFF; _info = _info.copyWith( batteryPercent: b, battery: DeviceBattery(single: b)); _emitInfo(); } } } void _emitInfo() { if (_events.isClosed) return; _events.add(DeviceSessionEvent( type: DeviceSessionEventType.deviceInfoUpdated, deviceId: deviceId, deviceInfo: _info, )); } void _emitFeature(String key, Map data) { if (_events.isClosed) return; _events.add(DeviceSessionEvent( type: DeviceSessionEventType.feature, deviceId: deviceId, feature: DeviceFeatureEvent(key: key, data: data), )); } void _closeAudio() { _mic?.close(); _mic = null; _spk?.close(); _spk = null; } void _markDisconnected() { if (_state == DeviceConnectionState.disconnected) return; _state = DeviceConnectionState.disconnected; if (!_events.isClosed) { _events.add(DeviceSessionEvent( type: DeviceSessionEventType.connectionStateChanged, deviceId: deviceId, connectionState: DeviceConnectionState.disconnected, )); _events.close(); } _frameSub?.cancel(); _frameSub = null; // 断链时把音频通路一起收掉:麦克风流没人再喂,扬声器要停掉正在播的 AI 回复 _closeAudio(); if (!_wake.isClosed) _wake.close(); } void _requireReady() { if (_state == DeviceConnectionState.disconnected) { throw StateError('会话已断开'); } if (_state != DeviceConnectionState.ready) { throw DeviceException( DeviceErrorCode.noActiveSession, 'EaiCar 会话未就绪'); } } @override Future readRssi() async { _requireReady(); // flutter_blue_plus 的 readRssi 挂在 BluetoothDevice 上,本期不接; // 契约允许抛 not_supported,业务层已按能力判断(没声明 rssi 能力)。 throw DeviceException(DeviceErrorCode.notSupported, 'readRssi'); } @override Future readBattery() async { _requireReady(); final f = await _link.send(EaiCarProtocol.cmdGetBattery); if (f == null || !f.success || f.payload.isEmpty) return null; return f.payload[0] & 0xFF; } @override Future refreshInfo() async { _requireReady(); final f = await _link.send(EaiCarProtocol.cmdSyncSummary); if (f != null && f.success) _applySummary(f.payload); return _info; } @override Stream get wakeEvents => _wake.stream; @override Future openFileRecorder({ required String path, required List routes, }) async => // 香薰没有录音场景(上行只在唤醒后送给助手),也没有原生落盘器。 // 返回 null 让调用方退回 openMic + Dart 写盘,行为与改动前一致。 null; @override // 香薰这条链路没有通话翻译下行,没有可诊断的计数。 // ⚠️ 这个类是 `implements DeviceSession` 而不是 extends,接口上的默认实现 // 对它不生效,契约加成员时必须同步补一份(否则编译期直接报 // 「missing implementations」)。 Future> diagnostics() async => const {}; @override Future openMic({AudioRoute route = AudioRoute.mic}) async { _requireReady(); // ⚠️ **先关旧的、再开新的**,顺序反了会踩这个坑(2026-09-11 真机): // 旧 mic 的 close() 会 audioStop(),写在 audioStart() 后面就等于把刚打开的 // 解码器又关掉。第一次唤醒 _mic 是 null 看不出来,**第二次起每次都上行 0 包**, // 表现成"第一句正常、后面全卡住"。[_closeMic] 的身份校验是第二道保险。 final old = _mic; _mic = null; await old?.close(); // 设备在唤醒后自己开麦,这里只是打开原生的 opus 解码转发 final ok = await _link.audioStart(); if (!ok) { throw DeviceException( DeviceErrorCode.notSupported, 'opus 解码器起不来,拿不到设备麦克风'); } final src = _EaiCarMic(route, _link, _closeMic); _mic = src; return src; } /// 关掉某个麦克风源。**只有它还是当前那个才真去停解码器**—— /// 已经被新会话换掉的旧 mic 再 close 是空操作,否则会误伤新会话。 Future _closeMic(_EaiCarMic mic) async { if (!identical(_mic, mic)) return; _mic = null; await _link.audioStop(); } @override Future openSpeaker({AudioRoute route = AudioRoute.ai}) async { _requireReady(); if (route != AudioRoute.ai) { throw DeviceException( DeviceErrorCode.notSupported, 'openSpeaker($route)'); } // ⚠️ 与恒玄不同:EaiCar 的下行**不经 BLE**。AI 回复由手机正常播放, // 经典蓝牙 A2DP 把它送到设备喇叭。所以这个"汇"就是手机扬声器。 // 没配对 A2DP 时声音会从手机外放出来——功能仍然成立,只是不在车里响, // 所以这里只提示、不拦截。 if (!await isClassicAudioConnected()) { Logger.w('EaiCarSession', 'A2DP 未连接,AI 回复会从手机外放。设备 MAC=$_mac'); } // 同 openMic:先关旧的再建新的。这里每个 sink 自带播放器、互不共享, // 顺序反了不会互相误伤,但保持一致的写法,免得下次照着抄出问题。 final old = _spk; _spk = null; await old?.close(); final sink = _EaiCarPhoneSpeaker(); _spk = sink; return sink; } /// 经典蓝牙音频通路(A2DP)通没通——决定 AI 回复"在不在车里响"。 /// /// 判据有两个,取其一即可: /// 1. 系统层:`ClassicBluetooth.isA2dpConnected(MAC)`(Android 查 A2DP profile, /// iOS 查 AVAudioSession 当前输出) /// 2. 设备层:广播里的经典蓝牙连接位(`0x0C`)——设备自己说的,最权威, /// 但只有扫描时才能拿到,连上之后就不再更新 Future isClassicAudioConnected() async { final mac = classicMacCandidate; if (mac == null || mac.isEmpty) return false; return ClassicBluetooth.isA2dpConnected(mac); } EaiCarOtaPort? _otaPort; /// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。 @override DeviceOtaPort? otaPort() => _otaPort ??= EaiCarOtaPort(_link, _plugin); /// 车载香薰**没有**设备端文件仓库(没有 TF 卡,录音不落设备),所以恒为 null, /// 能力集里也没有 `DeviceCapability.fileStore`。 /// 支架那条「录到 TF 卡、事后拉」的链路见 `HolderFileStore`。 @override DeviceFileStore? fileStore() => null; /// 通用 feature key → C2 命令。 /// /// ⚠️ 这张表就是**冻结的协议**:同一个 key 永远发同样的字节。 /// 改它之前先看 [EaiCarProtocol] 里那些 TODO(eaicar-cmd) 标记—— /// 有几条命令号本身还没经真机验证。 @override Future> invokeFeature( String key, [ Map args = const {}, ]) async { _requireReady(); switch (key) { case 'battery.query': final b = await readBattery(); return {'percent': b}; case 'state.refresh': await refreshInfo(); return {'ok': true}; // ---------- 设备设置面板(DeviceCapability.settingsPanel) ---------- case DeviceFeatures.settingsQuery: // 每次都真的问一次 0x15,不拿缓存糊弄面板:面板打开就是想看"现在"的值。 // 字节与握手 / refreshInfo 完全一样(不带载荷,真机验证过;lumi 带 0xFF, // 设备两种都认,别在这里另起一种写法)。 final f = await _link.send(EaiCarProtocol.cmdSyncSummary); if (f == null || !f.success) { throw DeviceException( DeviceErrorCode.readFailed, '0x15 无应答或设备拒绝'); } _applySummary(f.payload); final s = _settings; if (s == null) { throw DeviceException( DeviceErrorCode.readFailed, '0x15 应答不足 14 字节'); } return s.toSettingsMap(); case DeviceFeatures.settingsVolume: case DeviceFeatures.settingsBrightness: case DeviceFeatures.settingsPower: case DeviceFeatures.settingsDrivingSense: case DeviceFeatures.settingsSwing: case DeviceFeatures.settingsRandomExpressionInterval: case DeviceFeatures.settingsPlayRandomExpression: return _applySettingsCommand(key, args); case 'eaicar.volume': final v = (args['value'] as num?)?.toInt() ?? 0; await _link.send(EaiCarProtocol.cmdSetVolume, payload: [v & 0xFF]); return {'ok': true}; case 'eaicar.brightness': final v = (args['value'] as num?)?.toInt() ?? 0; await _link .send(EaiCarProtocol.cmdControlBrightness, payload: [v & 0xFF]); return {'ok': true}; case 'eaicar.app_connected': // 重发一次「APP 已连接」宣告(握手里已经发过;这里给排查/固件回落时用) await _announceAppConnected(); return {'ok': true}; // ---------- AI 会话(DeviceAiSessionService 驱动) ---------- case DeviceFeatures.aiAccept: // 设备发完 0xCC 0x11 就自己开麦了,App 这边只要把状态回报过去, // 设备据此切"聆听中"的表情。**不需要**再下开麦命令。 await _setAiState(EaiCarProtocol.aiStateListening); return {'ok': true}; case DeviceFeatures.aiExit: await _setAiState(EaiCarProtocol.aiStateClose); await _link.audioStop(); return {'ok': true}; case DeviceFeatures.aiState: // 通用语义 → C2 的 0x07。**设备的表情动画全靠这条**:不发的话 // 整轮对话里设备只在开头收到一次"聆听中",之后一直是那个表情 // (2026-09-11 真机反馈"表情没反应"就是这个原因)。 final st = args['state']; final v = switch (st) { DeviceAiState.listening => EaiCarProtocol.aiStateListening, DeviceAiState.thinking => EaiCarProtocol.aiStateThinking, DeviceAiState.speaking => EaiCarProtocol.aiStateSpeakStart, DeviceAiState.idle => EaiCarProtocol.aiStateSpeakEnd, _ => null, }; if (v == null) { throw DeviceException( DeviceErrorCode.invalidArgument, 'ai.state 参数非法: $st'); } await _setAiState(v); return {'ok': true}; case 'eaicar.ai_state': final v = (args['value'] as num?)?.toInt(); if (v == null) { throw DeviceException(DeviceErrorCode.invalidArgument, '缺少 value'); } await _setAiState(v); return {'ok': true}; // ---------- 经典蓝牙 / 音乐 ---------- case 'eaicar.music_playing': // 0x05:告诉设备手机在不在放音乐,设备据此显示"听音乐"表情 final on = args['value'] == true; await _link.send(EaiCarProtocol.cmdMusicPlayState, payload: [on ? 0x01 : 0x00]); return {'ok': true}; case 'eaicar.a2dp_status': return { 'mac': _mac, 'connected': await isClassicAudioConnected(), 'devices': (await ClassicBluetooth.getConnectedDevices()) .map((d) => {'name': d.name, 'address': d.address}) .toList(), }; case 'eaicar.a2dp_connect': return await connectClassicAudio(); case 'eaicar.emotion': final v = (args['value'] as num?)?.toInt() ?? 0; await _link .send(EaiCarProtocol.cmdControlEmotion, payload: [v & 0xFF]); return {'ok': true}; default: throw DeviceException(DeviceErrorCode.notSupported, 'feature $key'); } } /// 0x07 AI 状态。设备靠它切表情动画,**不回应答**,发完就完事。 Future _setAiState(int state) async { try { await _link .send(EaiCarProtocol.cmdAiState, payload: [state & 0xFF]); } catch (e) { // 状态同步失败不该中断会话——它只影响设备表情,不影响对话本身 Logger.w('EaiCarSession', '0x07 AI 状态($state) 发送失败: $e'); } } /// 发起经典蓝牙配对,让 A2DP 通起来(AI 回复与音乐都靠它出声)。 /// /// - Android:`createBond()`,配对成功后系统自己接 A2DP /// - iOS:**做不到**——Apple 没有发起经典蓝牙配对的公开 API,只能引导用户 /// 去「设置 → 蓝牙」手动配一次。返回 `{'ok': false, 'status': 'unsupported_ios'}` Future> connectClassicAudio() async { final mac = classicMacCandidate; if (mac == null || mac.isEmpty) { return {'ok': false, 'status': 'no_mac', 'hint': '广播里没解出 MAC'}; } if (await ClassicBluetooth.isA2dpConnected(mac)) { return {'ok': true, 'status': 'already_connected', 'mac': mac}; } try { final status = await ClassicBluetooth.connectDevice(mac); Logger.i('EaiCarSession', '经典蓝牙 connectDevice($mac) → $status(广播MAC=$_mac, BLE id=$deviceId)'); return { 'ok': status != null && status != 'unsupported_ios', 'status': status, 'mac': mac, }; } catch (e) { Logger.w('EaiCarSession', '经典蓝牙配对失败: $e'); return {'ok': false, 'status': 'error', 'error': '$e', 'mac': mac}; } } /// 拿去配经典蓝牙的 MAC。 /// /// 协议文档说广播里那 6 字节就是**经典蓝牙 MAC**,iOS 真机上服务端也按它确权通过了, /// 所以默认原样用。但 lumi 的 Android 实现是另一套:`经典 MAC = BLE MAC 首字节 − 1` /// (`deriveClassicMacFromBleMac`,固件约定),前提是广播里给的就是 BLE MAC。 /// /// 两种说法只能在安卓上分辨——安卓的 `deviceId` 就是 BLE MAC,iOS 只有 UUID: /// - 广播 MAC ≠ BLE MAC → 广播给的是另一个地址,按文档当经典 MAC 用 /// - 广播 MAC == BLE MAC → 广播给的其实是 BLE 地址,按 lumi 的规则减一 /// /// ⚠️ **未经安卓真机验证**(TODO(eaicar-a2dp))。首次真机看 /// `connectDevice(...)` 那行日志里三个地址的关系就能定论。 String? get classicMacCandidate { final mac = _mac; if (mac == null || mac.isEmpty) return null; if (!Platform.isAndroid) return mac; if (mac.toUpperCase() != deviceId.toUpperCase()) return mac; final parts = mac.split(':'); if (parts.length != 6) return mac; final first = int.tryParse(parts[0], radix: 16); if (first == null) return mac; parts[0] = ((first - 1) & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase(); return parts.join(':'); } @override Future disconnect() async { _state = DeviceConnectionState.disconnecting; await _link.audioStop(); await _link.disconnect(); _markDisconnected(); } } /// 设备麦克风 → 业务层。原生已经把 opus 解成 16k/16bit/mono PCM。 class _EaiCarMic implements DeviceAudioSource { _EaiCarMic(this.route, this._link, this._onClose); @override final AudioRoute route; final EaiCarLink _link; /// 交给会话去决定要不要真停:这个源可能早就被新会话换掉了, /// 直接 `audioStop()` 会把新会话的解码器一起关了。 final Future Function(_EaiCarMic) _onClose; @override AudioFormat get format => AudioFormat.pcm16kMono; @override Stream get pcm => _link.audioPcm; @override Future close() => _onClose(this); } /// AI 回复的去处:**手机扬声器**(经典蓝牙 A2DP 连着时就从设备喇叭出来)。 /// /// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——EaiCar 的下行不经 BLE, /// 所以这里不写任何字节到链路上,只是把 PCM 交给手机播放。 class _EaiCarPhoneSpeaker implements DeviceAudioSink { _EaiCarPhoneSpeaker(); /// ⚠️ `playbackOnly: true` 是这条链路能从**香薰喇叭**出声的关键: /// 采音在设备上(BLE opus),手机一个字节都不录,会话就该用 playback 类别。 /// 用 playAndRecord + defaultToSpeaker 会把声音钉死在手机扬声器上, /// 表现就是「连着香薰、AI 却从手机外放说话」。见 EmaiAudioSession.ensure。 final PcmStreamPlayer _player = PcmStreamPlayer(sampleRate: 16000, channels: 1, playbackOnly: true); @override AudioRoute get route => AudioRoute.ai; @override AudioFormat get format => AudioFormat.pcm16kMono; @override Future write(Uint8List pcm) async { // feed 内部会自己 begin,不用外面掐时序 _player.feed(pcm); } @override Future discardPending() => _player.stopNow(); @override Future close() => _player.dispose(); }