|
|
|
@ -13,14 +13,14 @@ 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'; |
|
|
|
import 'eaicar_device_auth.dart'; |
|
|
|
import 'eaicar_link.dart'; |
|
|
|
import 'eaicar_ota_port.dart'; |
|
|
|
import 'eaicar_protocol.dart'; |
|
|
|
|
|
|
|
/// EaiCar(车载香薰,旧名 Smartcar)厂商插件。 |
|
|
|
/// |
|
|
|
/// 把 [SmartcarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约, |
|
|
|
/// 把 [EaiCarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约, |
|
|
|
/// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。 |
|
|
|
/// |
|
|
|
/// ## 本期(阶段 1~3)范围 |
|
|
|
@ -30,16 +30,16 @@ import 'smartcar_protocol.dart'; |
|
|
|
/// |
|
|
|
/// 还没做、要等后续阶段的: |
|
|
|
/// - `micUplink` / `aiWake`:Opus 上行(阶段 6),要在同一条 GATT 上订阅 |
|
|
|
/// [SmartcarProtocol.audioNotifyCharUuid],**不能另起连接** |
|
|
|
/// [EaiCarProtocol.audioNotifyCharUuid],**不能另起连接** |
|
|
|
/// - `ota`:阶段 7,搬杰理 OTA SDK |
|
|
|
/// - 经典蓝牙 A2DP 音乐(阶段 5),要先给 `classic_bluetooth` 补 `connectDevice` |
|
|
|
class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
static const String _tag = 'SmartcarDevicePlugin'; |
|
|
|
class EaiCarDevicePlugin implements DevicePlugin { |
|
|
|
static const String _tag = 'EaiCarDevicePlugin'; |
|
|
|
|
|
|
|
/// 记住的设备(自动回连用)。存 `{id, name, mac}`。 |
|
|
|
static const String _rememberedKey = 'smartcar_remembered_devices'; |
|
|
|
|
|
|
|
final SmartcarLink link = SmartcarLink(); |
|
|
|
final EaiCarLink link = EaiCarLink(); |
|
|
|
final GetStorage _storage = GetStorage(); |
|
|
|
final _events = StreamController<DevicePluginEvent>.broadcast(); |
|
|
|
|
|
|
|
@ -47,22 +47,22 @@ class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
/// |
|
|
|
/// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着), |
|
|
|
/// 所以诊断信息只能顺着插件事件流出去。 |
|
|
|
static const String kScanDiagnosticKey = 'smartcar.scan_diagnostic'; |
|
|
|
static const String kScanDiagnosticKey = 'eaicar.scan_diagnostic'; |
|
|
|
|
|
|
|
SmartcarSession? _session; |
|
|
|
EaiCarSession? _session; |
|
|
|
StreamSubscription<bool>? _connSub; |
|
|
|
StreamSubscription<Map<String, Object?>>? _diagSub; |
|
|
|
bool _disposed = false; |
|
|
|
bool _scanning = false; |
|
|
|
|
|
|
|
@override |
|
|
|
String get vendorKey => DeviceVendors.smartcar; |
|
|
|
String get vendorKey => DeviceVendors.eaicar; |
|
|
|
|
|
|
|
@override |
|
|
|
String get displayName => 'EaiCar (车载香薰)'; |
|
|
|
|
|
|
|
@override |
|
|
|
Set<DeviceCapability> get capabilities => SmartcarSession.caps; |
|
|
|
Set<DeviceCapability> get capabilities => EaiCarSession.caps; |
|
|
|
|
|
|
|
@override |
|
|
|
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; |
|
|
|
@ -137,7 +137,7 @@ class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
|
|
|
|
// ───────────────────────────── 扫描 ───────────────────────────── |
|
|
|
|
|
|
|
StreamSubscription<SmartcarScanResult>? _scanSub; |
|
|
|
StreamSubscription<EaiCarScanResult>? _scanSub; |
|
|
|
|
|
|
|
@override |
|
|
|
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) async { |
|
|
|
@ -213,7 +213,7 @@ class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
await link.connect( |
|
|
|
deviceId, |
|
|
|
timeout: options?.timeout ?? const Duration(seconds: 45), |
|
|
|
adv: adv is SmartcarAdvertisement ? adv : null, |
|
|
|
adv: adv is EaiCarAdvertisement ? adv : null, |
|
|
|
); |
|
|
|
} on TimeoutException catch (e) { |
|
|
|
throw DeviceException(DeviceErrorCode.connectTimeout, '$e'); |
|
|
|
@ -221,7 +221,7 @@ class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
throw DeviceException(DeviceErrorCode.connectFailed, '$e'); |
|
|
|
} |
|
|
|
|
|
|
|
final s = SmartcarSession( |
|
|
|
final s = EaiCarSession( |
|
|
|
deviceId: deviceId, |
|
|
|
link: link, |
|
|
|
plugin: this, |
|
|
|
@ -242,8 +242,8 @@ class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
// 确权闸门。放在握手之后:要拿设备名去登记,而设备名是握手时才确定的。 |
|
|
|
// |
|
|
|
// ⚠️ 只有 denied 才断开。unknown(未登录/断网/没解出 MAC)一律放行—— |
|
|
|
// 判严的代价是合法用户断网就用不了自己的设备,见 [SmartcarDeviceAuth] 类注释。 |
|
|
|
final auth = await SmartcarDeviceAuth.verify( |
|
|
|
// 判严的代价是合法用户断网就用不了自己的设备,见 [EaiCarDeviceAuth] 类注释。 |
|
|
|
final auth = await EaiCarDeviceAuth.verify( |
|
|
|
mac: s.info.macAddress, |
|
|
|
name: s.info.name, |
|
|
|
); |
|
|
|
@ -345,16 +345,16 @@ class SmartcarDevicePlugin implements DevicePlugin { |
|
|
|
} |
|
|
|
|
|
|
|
void _checkAlive() { |
|
|
|
if (_disposed) throw StateError('SmartcarDevicePlugin 已 dispose'); |
|
|
|
if (_disposed) throw StateError('EaiCarDevicePlugin 已 dispose'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/// Smartcar 的会话句柄 |
|
|
|
class SmartcarSession implements DeviceSession { |
|
|
|
SmartcarSession({ |
|
|
|
/// EaiCar 的会话句柄 |
|
|
|
class EaiCarSession implements DeviceSession { |
|
|
|
EaiCarSession({ |
|
|
|
required this.deviceId, |
|
|
|
required SmartcarLink link, |
|
|
|
required SmartcarDevicePlugin plugin, |
|
|
|
required EaiCarLink link, |
|
|
|
required EaiCarDevicePlugin plugin, |
|
|
|
String? macAddress, |
|
|
|
}) : _link = link, |
|
|
|
_plugin = plugin, |
|
|
|
@ -370,11 +370,11 @@ class SmartcarSession implements DeviceSession { |
|
|
|
DeviceCapability.scan, |
|
|
|
DeviceCapability.connect, |
|
|
|
DeviceCapability.battery, |
|
|
|
// Smartcar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强, |
|
|
|
// EaiCar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强, |
|
|
|
// 恒玄在 iOS 上只有 peripheral UUID,确权等于没有。 |
|
|
|
DeviceCapability.macAddress, |
|
|
|
DeviceCapability.customCommand, |
|
|
|
// 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [SmartcarOtaPort] |
|
|
|
// 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [EaiCarOtaPort] |
|
|
|
DeviceCapability.ota, |
|
|
|
// 设备麦克风上行:opus → 16k PCM(原生 SmartcarAudio 解码) |
|
|
|
DeviceCapability.micUplink, |
|
|
|
@ -383,28 +383,28 @@ class SmartcarSession implements DeviceSession { |
|
|
|
DeviceCapability.aiWake, |
|
|
|
// ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP |
|
|
|
// (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇, |
|
|
|
// 见 [_SmartcarPhoneSpeaker]。 |
|
|
|
// 见 [_EaiCarPhoneSpeaker]。 |
|
|
|
}; |
|
|
|
|
|
|
|
@override |
|
|
|
final String deviceId; |
|
|
|
|
|
|
|
final SmartcarLink _link; |
|
|
|
/// OTA 结束后回连要用([SmartcarOtaPort.close]) |
|
|
|
final SmartcarDevicePlugin _plugin; |
|
|
|
final EaiCarLink _link; |
|
|
|
/// OTA 结束后回连要用([EaiCarOtaPort.close]) |
|
|
|
final EaiCarDevicePlugin _plugin; |
|
|
|
final String? _mac; |
|
|
|
|
|
|
|
StreamSubscription<SmartcarFrame>? _frameSub; |
|
|
|
StreamSubscription<EaiCarFrame>? _frameSub; |
|
|
|
final _events = StreamController<DeviceSessionEvent>.broadcast(); |
|
|
|
final _wake = StreamController<DeviceWakeEvent>.broadcast(); |
|
|
|
_SmartcarMic? _mic; |
|
|
|
_SmartcarPhoneSpeaker? _spk; |
|
|
|
_EaiCarMic? _mic; |
|
|
|
_EaiCarPhoneSpeaker? _spk; |
|
|
|
|
|
|
|
DeviceConnectionState _state = DeviceConnectionState.linkConnected; |
|
|
|
DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: ''); |
|
|
|
|
|
|
|
@override |
|
|
|
String get vendor => DeviceVendors.smartcar; |
|
|
|
String get vendor => DeviceVendors.eaicar; |
|
|
|
|
|
|
|
@override |
|
|
|
DeviceConnectionState get state => _state; |
|
|
|
@ -424,7 +424,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
/// 握手:先宣告「APP 已连接」(0x10),再取一次固件版本 + 参数汇总。 |
|
|
|
/// |
|
|
|
/// ⚠️ 0x10 不能省:杰理认证通过、0x15 也能应答的情况下,设备屏幕照样显示 |
|
|
|
/// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [SmartcarProtocol.cmdAppConnected])。 |
|
|
|
/// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [EaiCarProtocol.cmdAppConnected])。 |
|
|
|
/// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。 |
|
|
|
/// |
|
|
|
/// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。 |
|
|
|
@ -441,7 +441,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
await _syncTime(); |
|
|
|
Object? e15; |
|
|
|
try { |
|
|
|
final f = await _link.send(SmartcarProtocol.cmdSyncSummary); |
|
|
|
final f = await _link.send(EaiCarProtocol.cmdSyncSummary); |
|
|
|
if (f != null && f.success) { |
|
|
|
_applySummary(f.payload); |
|
|
|
_state = DeviceConnectionState.ready; |
|
|
|
@ -450,22 +450,22 @@ class SmartcarSession implements DeviceSession { |
|
|
|
e15 = '设备回了 0x15 但 len=0(命令执行失败)'; |
|
|
|
} catch (e) { |
|
|
|
e15 = e; |
|
|
|
Logger.w('SmartcarSession', '0x15 参数汇总失败,退回取版本号: $e'); |
|
|
|
Logger.w('EaiCarSession', '0x15 参数汇总失败,退回取版本号: $e'); |
|
|
|
} |
|
|
|
Object? e01; |
|
|
|
SmartcarFrame? v; |
|
|
|
EaiCarFrame? v; |
|
|
|
try { |
|
|
|
v = await _link.send(SmartcarProtocol.cmdGetVersion); |
|
|
|
v = await _link.send(EaiCarProtocol.cmdGetVersion); |
|
|
|
} catch (e) { |
|
|
|
e01 = e; |
|
|
|
} |
|
|
|
if (v == null || !v.success) { |
|
|
|
// 两条查询命令都没回应答。最可能的原因按概率排: |
|
|
|
// ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 SmartcarProtocol 的 |
|
|
|
// TODO(smartcar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。 |
|
|
|
// ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 EaiCarProtocol 的 |
|
|
|
// TODO(eaicar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。 |
|
|
|
throw StateError('0x15 与 0x01 都没有有效应答 ' |
|
|
|
'(0x15: $e15; 0x01: ${e01 ?? '无应答'})。' |
|
|
|
'多半是命令号或帧格式不对——见 SmartcarProtocol 里的 TODO(smartcar-cmd)'); |
|
|
|
'多半是命令号或帧格式不对——见 EaiCarProtocol 里的 TODO(eaicar-cmd)'); |
|
|
|
} |
|
|
|
_applyVersion(v.payload); |
|
|
|
_state = DeviceConnectionState.ready; |
|
|
|
@ -474,13 +474,13 @@ class SmartcarSession implements DeviceSession { |
|
|
|
/// 0x10「蓝牙连接成功」:`[设备类型, 车企编号]`,设备回 `BB 10 01 64` 才算数。 |
|
|
|
Future<void> _announceAppConnected() async { |
|
|
|
final appType = Platform.isIOS |
|
|
|
? SmartcarProtocol.appTypeIos |
|
|
|
: SmartcarProtocol.appTypeAndroid; |
|
|
|
SmartcarFrame? f; |
|
|
|
? EaiCarProtocol.appTypeIos |
|
|
|
: EaiCarProtocol.appTypeAndroid; |
|
|
|
EaiCarFrame? f; |
|
|
|
try { |
|
|
|
f = await _link.send( |
|
|
|
SmartcarProtocol.cmdAppConnected, |
|
|
|
payload: [appType, SmartcarProtocol.carVendorDefault], |
|
|
|
EaiCarProtocol.cmdAppConnected, |
|
|
|
payload: [appType, EaiCarProtocol.carVendorDefault], |
|
|
|
); |
|
|
|
} catch (e) { |
|
|
|
throw StateError('0x10「蓝牙连接成功」无应答:$e。' |
|
|
|
@ -489,21 +489,21 @@ class SmartcarSession implements DeviceSession { |
|
|
|
if (f == null || !f.success) { |
|
|
|
throw StateError('设备拒绝了 0x10「蓝牙连接成功」(len=0)'); |
|
|
|
} |
|
|
|
Logger.i('SmartcarSession', '已向设备宣告 APP 连接 (type=$appType),设备应答 ${f.payload}'); |
|
|
|
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(smartcar-handshake):还没二分出到底是哪一步点亮的, |
|
|
|
/// 把这串和 0x17 补上之后才亮。TODO(eaicar-handshake):还没二分出到底是哪一步点亮的, |
|
|
|
/// 全部保留;失败只记日志不断链——这几条是"锦上添花",链路本身已经能用。 |
|
|
|
Future<void> _jlPostAuth() async { |
|
|
|
try { |
|
|
|
final r = await _link.jlPostAuth(); |
|
|
|
Logger.i('SmartcarSession', 'RCSP 认证后收尾: $r'); |
|
|
|
Logger.i('EaiCarSession', 'RCSP 认证后收尾: $r'); |
|
|
|
} catch (e) { |
|
|
|
Logger.w('SmartcarSession', 'RCSP 认证后收尾失败(继续): $e'); |
|
|
|
Logger.w('EaiCarSession', 'RCSP 认证后收尾失败(继续): $e'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -513,17 +513,17 @@ class SmartcarSession implements DeviceSession { |
|
|
|
final n = DateTime.now(); |
|
|
|
try { |
|
|
|
await _link.send( |
|
|
|
SmartcarProtocol.cmdSyncTime, |
|
|
|
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('SmartcarSession', '0x17 时间同步无应答(继续): $e'); |
|
|
|
Logger.w('EaiCarSession', '0x17 时间同步无应答(继续): $e'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
void _applySummary(List<int> p) { |
|
|
|
final s = SmartcarAllSettings.parse(p); |
|
|
|
final s = EaiCarAllSettings.parse(p); |
|
|
|
if (s == null) return; |
|
|
|
_info = _info.copyWith( |
|
|
|
firmwareVersion: s.firmwareVersion, |
|
|
|
@ -541,13 +541,13 @@ class SmartcarSession implements DeviceSession { |
|
|
|
_emitInfo(); |
|
|
|
} |
|
|
|
|
|
|
|
void _onFrame(SmartcarFrame f) { |
|
|
|
void _onFrame(EaiCarFrame 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 (f.cmd == EaiCarProtocol.notifyAiWake) { |
|
|
|
Logger.i('EaiCarSession', '设备唤醒 AI 对话 (0xCC 0x11)'); |
|
|
|
_emitFeature('eaicar.ai_wake', const {}); |
|
|
|
if (!_wake.isClosed) { |
|
|
|
_wake.add(DeviceWakeEvent( |
|
|
|
deviceId: deviceId, |
|
|
|
@ -557,7 +557,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
} |
|
|
|
return; |
|
|
|
} |
|
|
|
if (f.cmd == SmartcarProtocol.cmdGetBattery && f.payload.isNotEmpty) { |
|
|
|
if (f.cmd == EaiCarProtocol.cmdGetBattery && f.payload.isNotEmpty) { |
|
|
|
final b = f.payload[0] & 0xFF; |
|
|
|
_info = _info.copyWith( |
|
|
|
batteryPercent: b, battery: DeviceBattery(single: b)); |
|
|
|
@ -567,9 +567,9 @@ class SmartcarSession implements DeviceSession { |
|
|
|
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) { |
|
|
|
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)); |
|
|
|
@ -627,7 +627,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
} |
|
|
|
if (_state != DeviceConnectionState.ready) { |
|
|
|
throw DeviceException( |
|
|
|
DeviceErrorCode.noActiveSession, 'Smartcar 会话未就绪'); |
|
|
|
DeviceErrorCode.noActiveSession, 'EaiCar 会话未就绪'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -642,7 +642,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
@override |
|
|
|
Future<int?> readBattery() async { |
|
|
|
_requireReady(); |
|
|
|
final f = await _link.send(SmartcarProtocol.cmdGetBattery); |
|
|
|
final f = await _link.send(EaiCarProtocol.cmdGetBattery); |
|
|
|
if (f == null || !f.success || f.payload.isEmpty) return null; |
|
|
|
return f.payload[0] & 0xFF; |
|
|
|
} |
|
|
|
@ -650,7 +650,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
@override |
|
|
|
Future<DeviceInfo> refreshInfo() async { |
|
|
|
_requireReady(); |
|
|
|
final f = await _link.send(SmartcarProtocol.cmdSyncSummary); |
|
|
|
final f = await _link.send(EaiCarProtocol.cmdSyncSummary); |
|
|
|
if (f != null && f.success) _applySummary(f.payload); |
|
|
|
return _info; |
|
|
|
} |
|
|
|
@ -691,14 +691,14 @@ class SmartcarSession implements DeviceSession { |
|
|
|
throw DeviceException( |
|
|
|
DeviceErrorCode.notSupported, 'opus 解码器起不来,拿不到设备麦克风'); |
|
|
|
} |
|
|
|
final src = _SmartcarMic(route, _link, _closeMic); |
|
|
|
final src = _EaiCarMic(route, _link, _closeMic); |
|
|
|
_mic = src; |
|
|
|
return src; |
|
|
|
} |
|
|
|
|
|
|
|
/// 关掉某个麦克风源。**只有它还是当前那个才真去停解码器**—— |
|
|
|
/// 已经被新会话换掉的旧 mic 再 close 是空操作,否则会误伤新会话。 |
|
|
|
Future<void> _closeMic(_SmartcarMic mic) async { |
|
|
|
Future<void> _closeMic(_EaiCarMic mic) async { |
|
|
|
if (!identical(_mic, mic)) return; |
|
|
|
_mic = null; |
|
|
|
await _link.audioStop(); |
|
|
|
@ -711,12 +711,12 @@ class SmartcarSession implements DeviceSession { |
|
|
|
throw DeviceException( |
|
|
|
DeviceErrorCode.notSupported, 'openSpeaker($route)'); |
|
|
|
} |
|
|
|
// ⚠️ 与恒玄不同:Smartcar 的下行**不经 BLE**。AI 回复由手机正常播放, |
|
|
|
// ⚠️ 与恒玄不同:EaiCar 的下行**不经 BLE**。AI 回复由手机正常播放, |
|
|
|
// 经典蓝牙 A2DP 把它送到设备喇叭。所以这个"汇"就是手机扬声器。 |
|
|
|
// 没配对 A2DP 时声音会从手机外放出来——功能仍然成立,只是不在车里响, |
|
|
|
// 所以这里只提示、不拦截。 |
|
|
|
if (!await isClassicAudioConnected()) { |
|
|
|
Logger.w('SmartcarSession', |
|
|
|
Logger.w('EaiCarSession', |
|
|
|
'A2DP 未连接,AI 回复会从手机外放。设备 MAC=$_mac'); |
|
|
|
} |
|
|
|
// 同 openMic:先关旧的再建新的。这里每个 sink 自带播放器、互不共享, |
|
|
|
@ -724,7 +724,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
final old = _spk; |
|
|
|
_spk = null; |
|
|
|
await old?.close(); |
|
|
|
final sink = _SmartcarPhoneSpeaker(); |
|
|
|
final sink = _EaiCarPhoneSpeaker(); |
|
|
|
_spk = sink; |
|
|
|
return sink; |
|
|
|
} |
|
|
|
@ -742,11 +742,11 @@ class SmartcarSession implements DeviceSession { |
|
|
|
return ClassicBluetooth.isA2dpConnected(mac); |
|
|
|
} |
|
|
|
|
|
|
|
SmartcarOtaPort? _otaPort; |
|
|
|
EaiCarOtaPort? _otaPort; |
|
|
|
|
|
|
|
/// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。 |
|
|
|
@override |
|
|
|
DeviceOtaPort? otaPort() => _otaPort ??= SmartcarOtaPort(_link, _plugin); |
|
|
|
DeviceOtaPort? otaPort() => _otaPort ??= EaiCarOtaPort(_link, _plugin); |
|
|
|
|
|
|
|
/// 车载香薰**没有**设备端文件仓库(没有 TF 卡,录音不落设备),所以恒为 null, |
|
|
|
/// 能力集里也没有 `DeviceCapability.fileStore`。 |
|
|
|
@ -757,7 +757,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
/// 通用 feature key → C2 命令。 |
|
|
|
/// |
|
|
|
/// ⚠️ 这张表就是**冻结的协议**:同一个 key 永远发同样的字节。 |
|
|
|
/// 改它之前先看 [SmartcarProtocol] 里那些 TODO(smartcar-cmd) 标记—— |
|
|
|
/// 改它之前先看 [EaiCarProtocol] 里那些 TODO(eaicar-cmd) 标记—— |
|
|
|
/// 有几条命令号本身还没经真机验证。 |
|
|
|
@override |
|
|
|
Future<Map<String, Object?>> invokeFeature( |
|
|
|
@ -772,16 +772,16 @@ class SmartcarSession implements DeviceSession { |
|
|
|
case 'state.refresh': |
|
|
|
await refreshInfo(); |
|
|
|
return {'ok': true}; |
|
|
|
case 'smartcar.volume': |
|
|
|
case 'eaicar.volume': |
|
|
|
final v = (args['value'] as num?)?.toInt() ?? 0; |
|
|
|
await _link.send(SmartcarProtocol.cmdSetVolume, payload: [v & 0xFF]); |
|
|
|
await _link.send(EaiCarProtocol.cmdSetVolume, payload: [v & 0xFF]); |
|
|
|
return {'ok': true}; |
|
|
|
case 'smartcar.brightness': |
|
|
|
case 'eaicar.brightness': |
|
|
|
final v = (args['value'] as num?)?.toInt() ?? 0; |
|
|
|
await _link |
|
|
|
.send(SmartcarProtocol.cmdControlBrightness, payload: [v & 0xFF]); |
|
|
|
.send(EaiCarProtocol.cmdControlBrightness, payload: [v & 0xFF]); |
|
|
|
return {'ok': true}; |
|
|
|
case 'smartcar.app_connected': |
|
|
|
case 'eaicar.app_connected': |
|
|
|
// 重发一次「APP 已连接」宣告(握手里已经发过;这里给排查/固件回落时用) |
|
|
|
await _announceAppConnected(); |
|
|
|
return {'ok': true}; |
|
|
|
@ -790,10 +790,10 @@ class SmartcarSession implements DeviceSession { |
|
|
|
case DeviceFeatures.aiAccept: |
|
|
|
// 设备发完 0xCC 0x11 就自己开麦了,App 这边只要把状态回报过去, |
|
|
|
// 设备据此切"聆听中"的表情。**不需要**再下开麦命令。 |
|
|
|
await _setAiState(SmartcarProtocol.aiStateListening); |
|
|
|
await _setAiState(EaiCarProtocol.aiStateListening); |
|
|
|
return {'ok': true}; |
|
|
|
case DeviceFeatures.aiExit: |
|
|
|
await _setAiState(SmartcarProtocol.aiStateClose); |
|
|
|
await _setAiState(EaiCarProtocol.aiStateClose); |
|
|
|
await _link.audioStop(); |
|
|
|
return {'ok': true}; |
|
|
|
case DeviceFeatures.aiState: |
|
|
|
@ -802,10 +802,10 @@ class SmartcarSession implements DeviceSession { |
|
|
|
// (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, |
|
|
|
DeviceAiState.listening => EaiCarProtocol.aiStateListening, |
|
|
|
DeviceAiState.thinking => EaiCarProtocol.aiStateThinking, |
|
|
|
DeviceAiState.speaking => EaiCarProtocol.aiStateSpeakStart, |
|
|
|
DeviceAiState.idle => EaiCarProtocol.aiStateSpeakEnd, |
|
|
|
_ => null, |
|
|
|
}; |
|
|
|
if (v == null) { |
|
|
|
@ -814,7 +814,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
} |
|
|
|
await _setAiState(v); |
|
|
|
return {'ok': true}; |
|
|
|
case 'smartcar.ai_state': |
|
|
|
case 'eaicar.ai_state': |
|
|
|
final v = (args['value'] as num?)?.toInt(); |
|
|
|
if (v == null) { |
|
|
|
throw DeviceException(DeviceErrorCode.invalidArgument, '缺少 value'); |
|
|
|
@ -823,13 +823,13 @@ class SmartcarSession implements DeviceSession { |
|
|
|
return {'ok': true}; |
|
|
|
|
|
|
|
// ---------- 经典蓝牙 / 音乐 ---------- |
|
|
|
case 'smartcar.music_playing': |
|
|
|
case 'eaicar.music_playing': |
|
|
|
// 0x05:告诉设备手机在不在放音乐,设备据此显示"听音乐"表情 |
|
|
|
final on = args['value'] == true; |
|
|
|
await _link.send(SmartcarProtocol.cmdMusicPlayState, |
|
|
|
await _link.send(EaiCarProtocol.cmdMusicPlayState, |
|
|
|
payload: [on ? 0x01 : 0x00]); |
|
|
|
return {'ok': true}; |
|
|
|
case 'smartcar.a2dp_status': |
|
|
|
case 'eaicar.a2dp_status': |
|
|
|
return { |
|
|
|
'mac': _mac, |
|
|
|
'connected': await isClassicAudioConnected(), |
|
|
|
@ -837,13 +837,13 @@ class SmartcarSession implements DeviceSession { |
|
|
|
.map((d) => {'name': d.name, 'address': d.address}) |
|
|
|
.toList(), |
|
|
|
}; |
|
|
|
case 'smartcar.a2dp_connect': |
|
|
|
case 'eaicar.a2dp_connect': |
|
|
|
return await connectClassicAudio(); |
|
|
|
|
|
|
|
case 'smartcar.emotion': |
|
|
|
case 'eaicar.emotion': |
|
|
|
final v = (args['value'] as num?)?.toInt() ?? 0; |
|
|
|
await _link |
|
|
|
.send(SmartcarProtocol.cmdControlEmotion, payload: [v & 0xFF]); |
|
|
|
.send(EaiCarProtocol.cmdControlEmotion, payload: [v & 0xFF]); |
|
|
|
return {'ok': true}; |
|
|
|
default: |
|
|
|
throw DeviceException(DeviceErrorCode.notSupported, 'feature $key'); |
|
|
|
@ -854,10 +854,10 @@ class SmartcarSession implements DeviceSession { |
|
|
|
Future<void> _setAiState(int state) async { |
|
|
|
try { |
|
|
|
await _link |
|
|
|
.send(SmartcarProtocol.cmdAiState, payload: [state & 0xFF]); |
|
|
|
.send(EaiCarProtocol.cmdAiState, payload: [state & 0xFF]); |
|
|
|
} catch (e) { |
|
|
|
// 状态同步失败不该中断会话——它只影响设备表情,不影响对话本身 |
|
|
|
Logger.w('SmartcarSession', '0x07 AI 状态($state) 发送失败: $e'); |
|
|
|
Logger.w('EaiCarSession', '0x07 AI 状态($state) 发送失败: $e'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@ -876,7 +876,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
} |
|
|
|
try { |
|
|
|
final status = await ClassicBluetooth.connectDevice(mac); |
|
|
|
Logger.i('SmartcarSession', |
|
|
|
Logger.i('EaiCarSession', |
|
|
|
'经典蓝牙 connectDevice($mac) → $status(广播MAC=$_mac, BLE id=$deviceId)'); |
|
|
|
return { |
|
|
|
'ok': status != null && status != 'unsupported_ios', |
|
|
|
@ -884,7 +884,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
'mac': mac, |
|
|
|
}; |
|
|
|
} catch (e) { |
|
|
|
Logger.w('SmartcarSession', '经典蓝牙配对失败: $e'); |
|
|
|
Logger.w('EaiCarSession', '经典蓝牙配对失败: $e'); |
|
|
|
return {'ok': false, 'status': 'error', 'error': '$e', 'mac': mac}; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -899,7 +899,7 @@ class SmartcarSession implements DeviceSession { |
|
|
|
/// - 广播 MAC ≠ BLE MAC → 广播给的是另一个地址,按文档当经典 MAC 用 |
|
|
|
/// - 广播 MAC == BLE MAC → 广播给的其实是 BLE 地址,按 lumi 的规则减一 |
|
|
|
/// |
|
|
|
/// ⚠️ **未经安卓真机验证**(TODO(smartcar-a2dp))。首次真机看 |
|
|
|
/// ⚠️ **未经安卓真机验证**(TODO(eaicar-a2dp))。首次真机看 |
|
|
|
/// `connectDevice(...)` 那行日志里三个地址的关系就能定论。 |
|
|
|
String? get classicMacCandidate { |
|
|
|
final mac = _mac; |
|
|
|
@ -924,16 +924,16 @@ class SmartcarSession implements DeviceSession { |
|
|
|
} |
|
|
|
|
|
|
|
/// 设备麦克风 → 业务层。原生已经把 opus 解成 16k/16bit/mono PCM。 |
|
|
|
class _SmartcarMic implements DeviceAudioSource { |
|
|
|
_SmartcarMic(this.route, this._link, this._onClose); |
|
|
|
class _EaiCarMic implements DeviceAudioSource { |
|
|
|
_EaiCarMic(this.route, this._link, this._onClose); |
|
|
|
|
|
|
|
@override |
|
|
|
final AudioRoute route; |
|
|
|
final SmartcarLink _link; |
|
|
|
final EaiCarLink _link; |
|
|
|
|
|
|
|
/// 交给会话去决定要不要真停:这个源可能早就被新会话换掉了, |
|
|
|
/// 直接 `audioStop()` 会把新会话的解码器一起关了。 |
|
|
|
final Future<void> Function(_SmartcarMic) _onClose; |
|
|
|
final Future<void> Function(_EaiCarMic) _onClose; |
|
|
|
|
|
|
|
@override |
|
|
|
AudioFormat get format => AudioFormat.pcm16kMono; |
|
|
|
@ -947,10 +947,10 @@ class _SmartcarMic implements DeviceAudioSource { |
|
|
|
|
|
|
|
/// AI 回复的去处:**手机扬声器**(经典蓝牙 A2DP 连着时就从设备喇叭出来)。 |
|
|
|
/// |
|
|
|
/// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——Smartcar 的下行不经 BLE, |
|
|
|
/// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——EaiCar 的下行不经 BLE, |
|
|
|
/// 所以这里不写任何字节到链路上,只是把 PCM 交给手机播放。 |
|
|
|
class _SmartcarPhoneSpeaker implements DeviceAudioSink { |
|
|
|
_SmartcarPhoneSpeaker(); |
|
|
|
class _EaiCarPhoneSpeaker implements DeviceAudioSink { |
|
|
|
_EaiCarPhoneSpeaker(); |
|
|
|
|
|
|
|
/// ⚠️ `playbackOnly: true` 是这条链路能从**香薰喇叭**出声的关键: |
|
|
|
/// 采音在设备上(BLE opus),手机一个字节都不录,会话就该用 playback 类别。 |