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.
 
 
 
 
 
 

963 lines
36 KiB

import 'dart:async';
import 'dart:io';
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 'smartcar_device_auth.dart';
import 'smartcar_link.dart';
import 'smartcar_ota_port.dart';
import 'smartcar_protocol.dart';
/// Smartcar(车载香薰)厂商插件。
///
/// 把 [SmartcarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约,
/// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。
///
/// ## 本期(阶段 1~3)范围
///
/// 只做**连接 + 控制 + 绑定确权**。声明的能力集也只有这些——
/// [DeviceCapability] 是「只增不删」的,先声明再实现会让业务层把不存在的功能画出来。
///
/// 还没做、要等后续阶段的:
/// - `micUplink` / `aiWake`:Opus 上行(阶段 6),要在同一条 GATT 上订阅
/// [SmartcarProtocol.audioNotifyCharUuid],**不能另起连接**
/// - `ota`:阶段 7,搬杰理 OTA SDK
/// - 经典蓝牙 A2DP 音乐(阶段 5),要先给 `classic_bluetooth` 补 `connectDevice`
class SmartcarDevicePlugin implements DevicePlugin {
static const String _tag = 'SmartcarDevicePlugin';
/// 记住的设备(自动回连用)。存 `{id, name, mac}`。
static const String _rememberedKey = 'smartcar_remembered_devices';
final SmartcarLink link = SmartcarLink();
final GetStorage _storage = GetStorage();
final _events = StreamController<DevicePluginEvent>.broadcast();
/// 扫描诊断事件的 key(业务层按它取 [DevicePluginEvent.customPayload])。
///
/// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着),
/// 所以诊断信息只能顺着插件事件流出去。
static const String kScanDiagnosticKey = 'smartcar.scan_diagnostic';
SmartcarSession? _session;
StreamSubscription<bool>? _connSub;
StreamSubscription<Map<String, Object?>>? _diagSub;
bool _disposed = false;
bool _scanning = false;
@override
String get vendorKey => DeviceVendors.smartcar;
@override
String get displayName => 'Smartcar (车载香薰)';
@override
Set<DeviceCapability> get capabilities => SmartcarSession.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) {
_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<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<SmartcarScanResult>? _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 ? 'Smartcar' : 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();
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 SmartcarAdvertisement ? adv : null,
);
} on TimeoutException catch (e) {
throw DeviceException(DeviceErrorCode.connectTimeout, '$e');
} catch (e) {
throw DeviceException(DeviceErrorCode.connectFailed, '$e');
}
final s = SmartcarSession(
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)一律放行——
// 判严的代价是合法用户断网就用不了自己的设备,见 [SmartcarDeviceAuth] 类注释。
final auth = await SmartcarDeviceAuth.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'] ?? 'Smartcar'}',
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 {
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;
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('SmartcarDevicePlugin 已 dispose');
}
}
/// Smartcar 的会话句柄
class SmartcarSession implements DeviceSession {
SmartcarSession({
required this.deviceId,
required SmartcarLink link,
required SmartcarDevicePlugin 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,
// Smartcar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强,
// 恒玄在 iOS 上只有 peripheral UUID,确权等于没有。
DeviceCapability.macAddress,
DeviceCapability.customCommand,
// 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [SmartcarOtaPort]
DeviceCapability.ota,
// 设备麦克风上行:opus → 16k PCM(原生 SmartcarAudio 解码)
DeviceCapability.micUplink,
// 设备按键/语音唤醒 AI:设备主动上报 0xCC 0x11。声明它就能白嫖
// DeviceAiSessionService 那整套会话编排
DeviceCapability.aiWake,
// ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP
// (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇,
// 见 [_SmartcarPhoneSpeaker]。
};
@override
final String deviceId;
final SmartcarLink _link;
/// OTA 结束后回连要用([SmartcarOtaPort.close])
final SmartcarDevicePlugin _plugin;
final String? _mac;
StreamSubscription<SmartcarFrame>? _frameSub;
final _events = StreamController<DeviceSessionEvent>.broadcast();
final _wake = StreamController<DeviceWakeEvent>.broadcast();
_SmartcarMic? _mic;
_SmartcarPhoneSpeaker? _spk;
DeviceConnectionState _state = DeviceConnectionState.linkConnected;
DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: '');
@override
String get vendor => DeviceVendors.smartcar;
@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 验证,见 [SmartcarProtocol.cmdAppConnected])。
/// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。
///
/// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。
/// 拿不到 0x15 时退回 0x01(只要版本号),两条都失败才判握手失败。
Future<void> _handshake() async {
_info = DeviceInfo(
id: deviceId,
name: 'Smartcar',
vendor: vendor,
macAddress: _mac,
);
await _announceAppConnected();
await _jlPostAuth();
await _syncTime();
Object? e15;
try {
final f = await _link.send(SmartcarProtocol.cmdSyncSummary);
if (f != null && f.success) {
_applySummary(f.payload);
_state = DeviceConnectionState.ready;
return;
}
e15 = '设备回了 0x15 但 len=0(命令执行失败)';
} catch (e) {
e15 = e;
Logger.w('SmartcarSession', '0x15 参数汇总失败,退回取版本号: $e');
}
Object? e01;
SmartcarFrame? v;
try {
v = await _link.send(SmartcarProtocol.cmdGetVersion);
} catch (e) {
e01 = e;
}
if (v == null || !v.success) {
// 两条查询命令都没回应答。最可能的原因按概率排:
// ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 SmartcarProtocol 的
// TODO(smartcar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。
throw StateError('0x15 与 0x01 都没有有效应答 '
'(0x15: $e15; 0x01: ${e01 ?? '无应答'})。'
'多半是命令号或帧格式不对——见 SmartcarProtocol 里的 TODO(smartcar-cmd)');
}
_applyVersion(v.payload);
_state = DeviceConnectionState.ready;
}
/// 0x10「蓝牙连接成功」:`[设备类型, 车企编号]`,设备回 `BB 10 01 64` 才算数。
Future<void> _announceAppConnected() async {
final appType = Platform.isIOS
? SmartcarProtocol.appTypeIos
: SmartcarProtocol.appTypeAndroid;
SmartcarFrame? f;
try {
f = await _link.send(
SmartcarProtocol.cmdAppConnected,
payload: [appType, SmartcarProtocol.carVendorDefault],
);
} catch (e) {
throw StateError('0x10「蓝牙连接成功」无应答:$e。'
'认证已过但设备不会把手机当成已连接,屏幕会一直显示 BLE 未连接');
}
if (f == null || !f.success) {
throw StateError('设备拒绝了 0x10「蓝牙连接成功」(len=0)');
}
Logger.i('SmartcarSession', '已向设备宣告 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(smartcar-handshake):还没二分出到底是哪一步点亮的,
/// 全部保留;失败只记日志不断链——这几条是"锦上添花",链路本身已经能用。
Future<void> _jlPostAuth() async {
try {
final r = await _link.jlPostAuth();
Logger.i('SmartcarSession', 'RCSP 认证后收尾: $r');
} catch (e) {
Logger.w('SmartcarSession', 'RCSP 认证后收尾失败(继续): $e');
}
}
/// 0x17 同步手机时间:`[年%100, 月, 日, 时, 分, 秒, 0x01]`(lumi 连上 800ms 后发,设备回 `BB 17 01 64`)。
/// 协议文档里没有这条,以 lumi 代码与真机应答为准。
Future<void> _syncTime() async {
final n = DateTime.now();
try {
await _link.send(
SmartcarProtocol.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('SmartcarSession', '0x17 时间同步无应答(继续): $e');
}
}
void _applySummary(List<int> p) {
final s = SmartcarAllSettings.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});
}
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(SmartcarFrame f) {
if (f.isNotify) {
// 设备主动上报。0xCC 0x11 = AI 唤醒:设备已经自己开麦了,
// DeviceAiSessionService 收到 wakeEvents 后会 aiAccept + openMic。
if (f.cmd == SmartcarProtocol.notifyAiWake) {
Logger.i('SmartcarSession', '设备唤醒 AI 对话 (0xCC 0x11)');
_emitFeature('smartcar.ai_wake', const {});
if (!_wake.isClosed) {
_wake.add(DeviceWakeEvent(
deviceId: deviceId,
// 设备侧是按键或语音触发的,协议里区分不出来,统一按 ptt 上报
reason: WakeReason.ptt,
));
}
return;
}
if (f.cmd == SmartcarProtocol.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 == SmartcarProtocol.cmdSyncSummary) _applySummary(f.payload);
if (f.cmd == SmartcarProtocol.cmdGetVersion) _applyVersion(f.payload);
if (f.cmd == SmartcarProtocol.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, 'Smartcar 会话未就绪');
}
}
@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(SmartcarProtocol.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(SmartcarProtocol.cmdSyncSummary);
if (f != null && f.success) _applySummary(f.payload);
return _info;
}
@override
Stream<DeviceWakeEvent> get wakeEvents => _wake.stream;
@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 = _SmartcarMic(route, _link, _closeMic);
_mic = src;
return src;
}
/// 关掉某个麦克风源。**只有它还是当前那个才真去停解码器**——
/// 已经被新会话换掉的旧 mic 再 close 是空操作,否则会误伤新会话。
Future<void> _closeMic(_SmartcarMic 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)');
}
// ⚠️ 与恒玄不同:Smartcar 的下行**不经 BLE**。AI 回复由手机正常播放,
// 经典蓝牙 A2DP 把它送到设备喇叭。所以这个"汇"就是手机扬声器。
// 没配对 A2DP 时声音会从手机外放出来——功能仍然成立,只是不在车里响,
// 所以这里只提示、不拦截。
if (!await isClassicAudioConnected()) {
Logger.w('SmartcarSession',
'A2DP 未连接,AI 回复会从手机外放。设备 MAC=$_mac');
}
// 同 openMic:先关旧的再建新的。这里每个 sink 自带播放器、互不共享,
// 顺序反了不会互相误伤,但保持一致的写法,免得下次照着抄出问题。
final old = _spk;
_spk = null;
await old?.close();
final sink = _SmartcarPhoneSpeaker();
_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 (mac == null || mac.isEmpty) return false;
return ClassicBluetooth.isA2dpConnected(mac);
}
SmartcarOtaPort? _otaPort;
/// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。
@override
DeviceOtaPort? otaPort() => _otaPort ??= SmartcarOtaPort(_link, _plugin);
/// 车载香薰**没有**设备端文件仓库(没有 TF 卡,录音不落设备),所以恒为 null,
/// 能力集里也没有 `DeviceCapability.fileStore`。
/// 支架那条「录到 TF 卡、事后拉」的链路见 `HolderFileStore`。
@override
DeviceFileStore? fileStore() => null;
/// 通用 feature key → C2 命令。
///
/// ⚠️ 这张表就是**冻结的协议**:同一个 key 永远发同样的字节。
/// 改它之前先看 [SmartcarProtocol] 里那些 TODO(smartcar-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};
case 'smartcar.volume':
final v = (args['value'] as num?)?.toInt() ?? 0;
await _link.send(SmartcarProtocol.cmdSetVolume, payload: [v & 0xFF]);
return {'ok': true};
case 'smartcar.brightness':
final v = (args['value'] as num?)?.toInt() ?? 0;
await _link
.send(SmartcarProtocol.cmdControlBrightness, payload: [v & 0xFF]);
return {'ok': true};
case 'smartcar.app_connected':
// 重发一次「APP 已连接」宣告(握手里已经发过;这里给排查/固件回落时用)
await _announceAppConnected();
return {'ok': true};
// ---------- AI 会话(DeviceAiSessionService 驱动) ----------
case DeviceFeatures.aiAccept:
// 设备发完 0xCC 0x11 就自己开麦了,App 这边只要把状态回报过去,
// 设备据此切"聆听中"的表情。**不需要**再下开麦命令。
await _setAiState(SmartcarProtocol.aiStateListening);
return {'ok': true};
case DeviceFeatures.aiExit:
await _setAiState(SmartcarProtocol.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 => SmartcarProtocol.aiStateListening,
DeviceAiState.thinking => SmartcarProtocol.aiStateThinking,
DeviceAiState.speaking => SmartcarProtocol.aiStateSpeakStart,
DeviceAiState.idle => SmartcarProtocol.aiStateSpeakEnd,
_ => null,
};
if (v == null) {
throw DeviceException(
DeviceErrorCode.invalidArgument, 'ai.state 参数非法: $st');
}
await _setAiState(v);
return {'ok': true};
case 'smartcar.ai_state':
final v = (args['value'] as num?)?.toInt();
if (v == null) {
throw DeviceException(DeviceErrorCode.invalidArgument, '缺少 value');
}
await _setAiState(v);
return {'ok': true};
// ---------- 经典蓝牙 / 音乐 ----------
case 'smartcar.music_playing':
// 0x05:告诉设备手机在不在放音乐,设备据此显示"听音乐"表情
final on = args['value'] == true;
await _link.send(SmartcarProtocol.cmdMusicPlayState,
payload: [on ? 0x01 : 0x00]);
return {'ok': true};
case 'smartcar.a2dp_status':
return {
'mac': _mac,
'connected': await isClassicAudioConnected(),
'devices': (await ClassicBluetooth.getConnectedDevices())
.map((d) => {'name': d.name, 'address': d.address})
.toList(),
};
case 'smartcar.a2dp_connect':
return await connectClassicAudio();
case 'smartcar.emotion':
final v = (args['value'] as num?)?.toInt() ?? 0;
await _link
.send(SmartcarProtocol.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(SmartcarProtocol.cmdAiState, payload: [state & 0xFF]);
} catch (e) {
// 状态同步失败不该中断会话——它只影响设备表情,不影响对话本身
Logger.w('SmartcarSession', '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 (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('SmartcarSession',
'经典蓝牙 connectDevice($mac) → $status(广播MAC=$_mac, BLE id=$deviceId)');
return {
'ok': status != null && status != 'unsupported_ios',
'status': status,
'mac': mac,
};
} catch (e) {
Logger.w('SmartcarSession', '经典蓝牙配对失败: $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(smartcar-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 {
_state = DeviceConnectionState.disconnecting;
await _link.audioStop();
await _link.disconnect();
_markDisconnected();
}
}
/// 设备麦克风 → 业务层。原生已经把 opus 解成 16k/16bit/mono PCM。
class _SmartcarMic implements DeviceAudioSource {
_SmartcarMic(this.route, this._link, this._onClose);
@override
final AudioRoute route;
final SmartcarLink _link;
/// 交给会话去决定要不要真停:这个源可能早就被新会话换掉了,
/// 直接 `audioStop()` 会把新会话的解码器一起关了。
final Future<void> Function(_SmartcarMic) _onClose;
@override
AudioFormat get format => AudioFormat.pcm16kMono;
@override
Stream<Uint8List> get pcm => _link.audioPcm;
@override
Future<void> close() => _onClose(this);
}
/// AI 回复的去处:**手机扬声器**(经典蓝牙 A2DP 连着时就从设备喇叭出来)。
///
/// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——Smartcar 的下行不经 BLE,
/// 所以这里不写任何字节到链路上,只是把 PCM 交给手机播放。
class _SmartcarPhoneSpeaker implements DeviceAudioSink {
_SmartcarPhoneSpeaker();
/// ⚠️ `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();
}