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.
 
 
 
 
 
 

1323 lines
51 KiB

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/services/background_launch_consent.dart';
import '../../data/utils/pcm_stream_player.dart';
import '../../core/utils/permission_util.dart';
import '../device_auth.dart';
import '../device_hub.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<DevicePluginEvent>.broadcast();
/// 扫描诊断事件的 key(业务层按它取 [DevicePluginEvent.customPayload])。
///
/// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着),
/// 所以诊断信息只能顺着插件事件流出去。
static const String kScanDiagnosticKey = 'eaicar.scan_diagnostic';
EaiCarSession? _session;
StreamSubscription<bool>? _connSub;
StreamSubscription<Map<String, Object?>>? _diagSub;
bool _disposed = false;
bool _scanning = false;
@override
String get vendorKey => DeviceVendors.eaicar;
@override
String get displayName => 'EaiCar (车载香薰)';
@override
Set<DeviceCapability> get capabilities => EaiCarSession.caps;
@override
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty;
@override
DeviceSession? get activeSession => _session;
@override
Stream<DevicePluginEvent> get eventStream => _events.stream;
@override
Future<void> 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) {
final wasOnline = _session != null;
_session?._markDisconnected();
_session = null;
// 不是用户/切设备主动断的(设备走远、关机、信号抖)→ 自动回连
if (wasOnline && !_userDisconnected) {
_reconnectAttempt = 0;
_scheduleReconnect('设备意外断开');
}
}
_emit(DevicePluginEvent(
type: DevicePluginEventType.connectionStateChanged,
deviceId: link.deviceId,
connectionState: up
? DeviceConnectionState.ready
: DeviceConnectionState.disconnected,
));
});
_emit(const DevicePluginEvent(type: DevicePluginEventType.pluginReady));
// 冷启动回连:等 CoreBluetooth / 蓝牙适配器就绪、DeviceHub 注册完其它插件再动
Timer(const Duration(seconds: 3), _startupReconnect);
}
// ─────────────────────────── 自动回连 ───────────────────────────
//
// 2026-09-25 以前 [tryAutoConnect] 写好了但**全项目没有调用方**(DeviceHub 的
// tryAutoConnectAll 也没人调),于是香薰断了就断了,冷启动也不连。现在两处触发:
// 1. 冷启动:最后一次用的设备是香薰([DeviceHub.lastUsedVendor]),且用户同意过
// 后台自动连接(与恒玄同一个开关);
// 2. 意外断开:不是用户点断开 / 切到别的设备 / 解绑造成的,就按退避重试。
// 两个平台都能按 id 直连已知外设,不需要先扫描(iOS retrievePeripherals、
// Android getRemoteDevice),设备回到范围内就能接上。
// 有别的设备在线时不回连:DeviceHub 是单活动设备,连上香薰会把用户正在用的那台顶掉。
/// 用户(或 DeviceHub 切设备)主动断开 / 解绑后置位,挡住自动回连;下次 connect 清掉。
bool _userDisconnected = false;
/// OTA 进行中([EaiCarOtaPort] 置位、close 时清掉):设备会断开重启,别去回连。
bool _otaActive = false;
set otaActive(bool v) {
_otaActive = v;
if (v) _cancelReconnect();
}
Timer? _reconnectTimer;
int _reconnectAttempt = 0;
static const List<int> _reconnectDelaysSec = [2, 5, 10, 20, 30];
/// 意外断开后最多试这么多次(约 10 分钟),之后等下次冷启动或手动连接。
static const int _reconnectMaxAttempts = 22;
void _startupReconnect() {
if (_disposed || _session != null || link.isConnected) return;
if (rememberedDevices.isEmpty) return;
String last = '';
try {
last = DeviceHub.to.lastUsedVendor;
} catch (_) {}
if (last.isNotEmpty && last != vendorKey) return;
if (!BackgroundLaunchConsent.granted) {
Logger.i(_tag, '用户未开启设备自动连接,跳过香薰冷启动回连');
return;
}
_reconnectAttempt = 0;
_scheduleReconnect('冷启动', immediate: true);
}
bool _otherDeviceOnline() {
try {
return DeviceHub.to.sessions.any((x) => x.vendor != vendorKey);
} catch (_) {
return false;
}
}
void _cancelReconnect() {
_reconnectTimer?.cancel();
_reconnectTimer = null;
}
void _scheduleReconnect(String why, {bool immediate = false}) {
_cancelReconnect();
if (_disposed || _userDisconnected || _otaActive || rememberedDevices.isEmpty) {
return;
}
if (_reconnectAttempt >= _reconnectMaxAttempts) {
Logger.i(_tag, '香薰自动回连已试 $_reconnectAttempt 次,停止(等手动连接或下次启动)');
return;
}
final sec = immediate
? 0
: _reconnectDelaysSec[
math.min(_reconnectAttempt, _reconnectDelaysSec.length - 1)];
_reconnectAttempt++;
Logger.i(_tag, '$why,${sec}s 后第 $_reconnectAttempt 次自动回连');
_reconnectTimer = Timer(Duration(seconds: sec), () async {
_reconnectTimer = null;
if (_disposed ||
_userDisconnected ||
_otaActive ||
_session != null ||
link.isConnected) {
return;
}
if (_otherDeviceOnline()) {
Logger.i(_tag, '已有其它设备在线,停止香薰自动回连');
return;
}
final first = rememberedDevices.isEmpty ? null : rememberedDevices.first;
if (first == null) return;
try {
// 回连不经过扫描,拿不到广播里的 MAC——带上记住的那份,
// 否则确权跳过、经典蓝牙状态也查不了(日志里的「广播里没解出 MAC」)
final mac = first.metadata['mac'];
await connect(first.id,
options: DeviceConnectOptions(
timeout: const Duration(seconds: 45),
extra: {if (mac is String && mac.isNotEmpty) 'mac': mac},
));
Logger.i(_tag, '香薰自动回连成功(第 $_reconnectAttempt 次)');
_reconnectAttempt = 0;
} catch (e) {
_scheduleReconnect('自动回连未成功($e)');
}
});
}
/// 自持 GATT 要的运行时权限(Android 12+)。iOS 由系统在首次用 CoreBluetooth 时弹框。
static const List<Permission> _btPermissions = [
Permission.bluetoothScan,
Permission.bluetoothConnect,
];
/// 用户拒绝过且仍在冷却期——UI 据此置灰入口(与支架同一套闸)
bool get isPermissionCoolingDown =>
Platform.isAndroid &&
PermissionUtil.instance.isAnyDenialCoolingDown(_btPermissions);
@override
Future<bool> 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<EaiCarScanResult>? _scanSub;
@override
Future<void> 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<void> stopScan() async {
await _scanSub?.cancel();
_scanSub = null;
await link.stopScan();
if (_scanning) {
_scanning = false;
_emit(const DevicePluginEvent(type: DevicePluginEventType.scanStopped));
}
}
@override
Future<bool> isScanning() async => _scanning;
@override
Future<List<DiscoveredDevice>> bondedDevices() async => rememberedDevices;
// ───────────────────────────── 连接 ─────────────────────────────
@override
Future<DeviceSession> connect(String deviceId,
{DeviceConnectOptions? options}) async {
_checkAlive();
_userDisconnected = false;
_cancelReconnect();
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<Map<String, Object?>> get _remembered =>
(_storage.read<List>(_rememberedKey) ?? const [])
.whereType<Map>()
.map((e) => e.cast<String, Object?>())
.toList();
@override
List<DiscoveredDevice> 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<void> 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<void> forgetDevice(String deviceId) async {
if (link.deviceId == deviceId) {
_userDisconnected = true;
_cancelReconnect();
}
final list = _remembered..removeWhere((m) => m['id'] == deviceId);
await _storage.write(_rememberedKey, list);
if (link.deviceId == deviceId) await link.disconnect();
}
@override
Future<void> 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<void> dispose() async {
if (_disposed) return;
_disposed = true;
_cancelReconnect();
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<DeviceCapability> 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<EaiCarFrame>? _frameSub;
final _events = StreamController<DeviceSessionEvent>.broadcast();
final _wake = StreamController<DeviceWakeEvent>.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<DeviceCapability> get capabilities => caps;
@override
bool supports(DeviceCapability c) => caps.contains(c);
@override
Stream<DeviceSessionEvent> get eventStream => _events.stream;
/// 握手:先宣告「APP 已连接」(0x10),再取一次固件版本 + 参数汇总。
///
/// ⚠️ 0x10 不能省:杰理认证通过、0x15 也能应答的情况下,设备屏幕照样显示
/// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [EaiCarProtocol.cmdAppConnected])。
/// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。
///
/// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。
/// 拿不到 0x15 时退回 0x01(只要版本号),两条都失败才判握手失败。
Future<void> _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<void> _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<void> _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<void> _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<int> 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<int> payload, bool expectResponse})? settingsCommand(
String key,
Map<String, Object?> 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<Map<String, Object?>> _applySettingsCommand(
String key, Map<String, Object?> 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<int> 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<String, Object?> 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<int> readRssi() async {
_requireReady();
// flutter_blue_plus 的 readRssi 挂在 BluetoothDevice 上,本期不接;
// 契约允许抛 not_supported,业务层已按能力判断(没声明 rssi 能力)。
throw DeviceException(DeviceErrorCode.notSupported, 'readRssi');
}
@override
Future<int?> 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<DeviceInfo> refreshInfo() async {
_requireReady();
final f = await _link.send(EaiCarProtocol.cmdSyncSummary);
if (f != null && f.success) _applySummary(f.payload);
return _info;
}
@override
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream;
@override
Future<DeviceFileRecorder?> openFileRecorder({
required String path,
required List<AudioRoute> routes,
}) async =>
// 香薰没有录音场景(上行只在唤醒后送给助手),也没有原生落盘器。
// 返回 null 让调用方退回 openMic + Dart 写盘,行为与改动前一致。
null;
@override
// 香薰这条链路没有通话翻译下行,没有可诊断的计数。
// ⚠️ 这个类是 `implements DeviceSession` 而不是 extends,接口上的默认实现
// 对它不生效,契约加成员时必须同步补一份(否则编译期直接报
// 「missing implementations」)。
Future<Map<String, Object?>> diagnostics() async => const {};
@override
Future<DeviceAudioSource> 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<void> _closeMic(_EaiCarMic mic) async {
if (!identical(_mic, mic)) return;
_mic = null;
await _link.audioStop();
}
@override
Future<DeviceAudioSink> 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<bool> isClassicAudioConnected() async {
final mac = classicMacCandidate;
if (Platform.isIOS) return _iosClassicAudioConnected(mac);
if (mac == null || mac.isEmpty) return false;
return ClassicBluetooth.isA2dpConnected(mac);
}
/// iOS:看当前音频输出里有没有这台设备。
///
/// ⚠️ 2026-09-25 真机:系统设置里经典蓝牙明明连着,这里却恒判「未连接」,
/// 于是每次连上都弹「请到 设置 → 蓝牙 里配对」、AI 会话也打「A2DP 未连接」。
/// 原因是只拿广播 MAC 去比,而经典蓝牙地址可能与它差首字节(杰理 BLE/经典
/// 两个地址的约定,见 [classicMacCandidate]),名字也没参与比较。
/// 现在广播 MAC、首字节 ±1、设备名任一对上即算,并把系统看到的输出打出来,
/// 下次真机一眼就能看出经典地址和广播地址的关系。
Future<bool> _iosClassicAudioConnected(String? mac) async {
try {
final outs = await ClassicBluetooth.getConnectedDevices();
final macs = <String>{
for (final m in [mac, _mac, _shiftFirstByte(_mac, -1), _shiftFirstByte(_mac, 1)])
if (m != null && m.isNotEmpty) m.toUpperCase(),
};
final name = _info.name.trim().toUpperCase();
final hit = outs.any((d) {
final a = d.address.toUpperCase();
final n = d.name.toUpperCase();
return macs.contains(a) ||
(name.isNotEmpty && n.contains(name)) ||
n.contains('EAICAR');
});
Logger.i('EaiCarSession',
'iOS 蓝牙音频输出: ${outs.map((d) => '${d.name}(${d.address})').join(', ')}'
' | 广播MAC=$_mac 设备名=${_info.name} → ${hit ? '已连' : '未连'}');
return hit;
} catch (e) {
Logger.w('EaiCarSession', '查询 iOS 音频输出失败: $e');
return false;
}
}
static String? _shiftFirstByte(String? mac, int delta) {
if (mac == null) return null;
final parts = mac.split(':');
if (parts.length != 6) return null;
final first = int.tryParse(parts[0], radix: 16);
if (first == null) return null;
parts[0] = ((first + delta) & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase();
return parts.join(':');
}
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<Map<String, Object?>> invokeFeature(
String key, [
Map<String, Object?> 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();
// 0x07 是单向命令,设备不回结束应答;告诉会话服务别等了
return {'ok': true, 'noAck': 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 'debug.audioStats':
return await _link.audioStats();
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<void> _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<Map<String, Object?>> connectClassicAudio() async {
final mac = classicMacCandidate;
if (Platform.isIOS && await isClassicAudioConnected()) {
return {'ok': true, 'status': 'already_connected', 'mac': mac};
}
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<void> disconnect() async {
// 主动断开(用户点断开 / DeviceHub 切到别的设备)不触发自动回连
_plugin._userDisconnected = true;
_plugin._cancelReconnect();
_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<void> Function(_EaiCarMic) _onClose;
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Stream<Uint8List> get pcm => _link.audioPcm;
@override
Future<void> 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<void> write(Uint8List pcm) async {
// feed 内部会自己 begin,不用外面掐时序
_player.feed(pcm);
}
@override
Future<void> discardPending() => _player.stopNow();
@override
Future<void> close() => _player.dispose();
}