import 'dart:async'; import 'package:device_smartcar/device_smartcar.dart'; import 'package:flutter/services.dart'; import '../../core/utils/logger.dart'; import 'eaicar_protocol.dart'; /// EaiCar 的 BLE 链路:扫描 / 连接 / 收发 C2 帧。 /// /// ## 为什么是原生插件、不是 flutter_blue_plus(2026-09-10 真机踩出来的) /// /// EaiCar 是杰理芯片,**固件要求先过杰理 RCSP 认证**(在服务 `AE00` 上,由闭源 /// `JL_BLEKit` 的 `JL_Assist` 跑挑战应答)才把手机当成已连接:认证前设备屏幕显示 /// BLE 未连接、`ABC0` 私有通道的命令也不理。lumi 两端代码里都写明了这一点 /// (Android:「全新的 BLE 连接,没有走 JLWatchManager 认证,设备拒绝响应任何 RCSP 命令」)。 /// /// 第一版用 flutter_blue_plus 纯 Dart 实现,只订阅了 `ABC0`,从没和 `AE00` 说过话—— /// 设备看来就是"没人认证过"。认证算法在闭源 SDK 里,Dart 复刻不了;而要把 /// CBPeripheralDelegate 的回调转交给 SDK,GATT 必须由自己持有,flutter_blue_plus /// 做不到。于是链路收进原生插件 `device_smartcar`,这里只剩通道封装。 /// /// 组帧 / CRC / 广播解析仍在 Dart([EaiCarProtocol]),单测继续守着。 /// /// ⚠️ 一部手机对同一个 peripheral **只能有一条 GATT 连接**。阶段 6 的 Opus 上行 /// 要在原生插件里复用这条连接订阅音频特征,**不能另起一条**。 /// 我们规定的车载香薰广播名(准入白名单)。 /// /// ⚠️ **名字对不上就不放行**,这是准入校验不是容错:那样的固件不是正式产品, /// 服务端的 MAC 校验同样过不了(device_mac 里没有它),连上也没有意义。 /// 口径与支架的 [isAllowedHolderName] 一致。 /// /// ⚠️ 旧广播名 `Smartcar` 已于 2026-09-15 移除,刷着旧名字的样机从此扫不到。 /// 别"顺手加回来"——加回去准入就整个失效了。 const List kEaiCarAllowedNames = ['EaiCar']; bool isAllowedEaiCarName(String raw) { final n = raw.trim().toUpperCase(); if (n.isEmpty) return false; for (final allowed in kEaiCarAllowedNames) { if (n.startsWith(allowed.toUpperCase())) return true; } return false; } class EaiCarLink { static const String _tag = 'EaiCarLink'; final DeviceSmartcar _native = DeviceSmartcar.instance; StreamSubscription>? _eventSub; final _frames = StreamController.broadcast(); final _connState = StreamController.broadcast(); final _diag = StreamController>.broadcast(); final _ota = StreamController>.broadcast(); final _audio = StreamController.broadcast(); /// 设备麦克风上行 PCM(**16k / 16bit / mono**,原生已把 opus 解好)。 /// 帧格式与解码见原生 `SmartcarAudio`。 Stream get audioPcm => _audio.stream; /// 原生 OTA 事件:`{state: preparing|transferring|stage2|done|failed|cancelled, percent, code?, message?}` Stream> get otaEvents => _ota.stream; /// 解好的帧(CRC 已校验,坏帧不会出现在这里) Stream get frames => _frames.stream; /// 链路通断。**true = 杰理认证已通过**,不是 GATT 连上。 Stream get connected => _connState.stream; /// 扫描诊断:每一台扫到的 BLE 设备(含没命中判定的),扫描页拿去摊开给人看。 /// 「扫不到」「连不上」两次排查都栽在失败不可见上,所以原生报上来的每个状态/原因 /// 也会以一行的形式塞进这里。 Stream> get diagnostics => _diag.stream; bool _isConnected = false; bool get isConnected => _isConnected; String? _deviceId; String? get deviceId => _deviceId; /// 连上时记下的广播信息(MAC / License 从这里来) EaiCarAdvertisement? advertisement; /// 最近一次原生上报的链路状态与原因,连接失败时拼进错误信息 String _lastState = 'disconnected'; String? _lastReason; EaiCarLink() { _eventSub = _native.events.listen(_onNativeEvent, onError: (Object e) { Logger.w(_tag, '原生事件流错误: $e'); }); } // ───────────────────────────── 事件路由 ───────────────────────────── StreamController? _scanCtrl; void _onNativeEvent(Map e) { switch (e['type']) { case 'adapter': _diagLine('(蓝牙适配器)', 'adapter=${e['state']}'); break; case 'scan': _onScanHit(e); break; case 'scanStopped': final c = _scanCtrl; _scanCtrl = null; if (c != null && !c.isClosed) c.close(); break; case 'state': _onStateEvent(e); break; case 'custom': final d = e['data']; if (d is Uint8List) _onValue(d); break; case 'ota': if (!_ota.isClosed) _ota.add(Map.from(e)); break; case 'audio': final d = e['data']; if (d is Uint8List && !_audio.isClosed) _audio.add(d); break; } } /// 开始接收设备麦克风上行(设备自己开麦,这里只是打开原生的解码转发) Future audioStart() async { try { return await _native.audioStart(); } on PlatformException catch (e) { Logger.w(_tag, '音频上行起不来: ${e.message ?? e.code}'); return false; } on MissingPluginException { return false; } } Future audioStop() async { try { await _native.audioStop(); } catch (_) {} } Future> audioStats() async { try { return await _native.audioStats(); } catch (_) { return const {}; } } /// 启动杰理 OTA(原生 `EaiCarOta`:cmdOTAData + stage2 扫描回连)。失败抛 [StateError]。 Future otaStart(String filePath) async { try { await _native.otaStart(filePath); } on PlatformException catch (e) { throw StateError(e.message ?? e.code); } } Future otaCancel() async { try { await _native.otaCancel(); } catch (_) {} } void _onStateEvent(Map e) { final s = '${e['state']}'; _lastState = s; _lastReason = e['reason'] as String?; Logger.i(_tag, '链路状态 → $s${_lastReason == null ? '' : '($_lastReason)'}'); _diagLine('(链路 ${e['id'] ?? ''})', '$s${_lastReason == null ? '' : ':$_lastReason'}'); final up = s == 'ready'; if (up == _isConnected) return; _isConnected = up; if (up) { _deviceId = e['id'] as String?; } else { _failAllPending('链路断开: $s ${_lastReason ?? ''}'); } if (!_connState.isClosed) _connState.add(up); } void _diagLine(String name, String msg) { if (_diag.isClosed) return; _diag.add({ 'name': name, 'id': '', 'rssi': 0, 'matched': false, 'mfg': msg, 'services': '', }); } // ───────────────────────────── 扫描 ───────────────────────────── /// 扫本品类设备。原生侧全量扫(不加 service 过滤——CoreBluetooth 的过滤只认 /// 「Service UUIDs」AD 字段,C2 的身份在厂商数据里,加了一台都扫不到),这里按两条判定收: /// 1. 厂商数据能解出合法 C2 广播且 CID == 0x8505(主判据,MAC/License 一并解出) /// 2. 广播名含 `eaicar`(兜底) Stream scan({ Duration timeout = const Duration(seconds: 15), }) { final ctrl = StreamController(); _scanCtrl?.close(); _scanCtrl = ctrl; ctrl.onCancel = () async { if (identical(_scanCtrl, ctrl)) _scanCtrl = null; await stopScan(); }; () async { try { await _native.startScan(timeoutSec: timeout.inSeconds); } on PlatformException catch (e) { final msg = e.message ?? e.code; Logger.e(_tag, '启动扫描失败: $msg'); _diagLine('(扫描启动失败)', msg); if (!ctrl.isClosed) ctrl.addError(StateError(msg)); } on MissingPluginException { const msg = '本平台没有 EaiCar 原生链路(只支持 Android / iOS)'; if (!ctrl.isClosed) ctrl.addError(StateError(msg)); } }(); return ctrl.stream; } Future stopScan() async { try { await _native.stopScan(); } catch (_) {} } void _onScanHit(Map e) { final id = '${e['id'] ?? ''}'; final name = '${e['name'] ?? ''}'; final rssi = (e['rssi'] as num?)?.toInt() ?? 0; final mfg = e['mfg']; final services = (e['services'] as List?)?.map((x) => '$x').join(',') ?? ''; // CBAdvertisementDataManufacturerDataKey 原样过来:前 2 字节是小端公司 ID int? companyId; List payload = const []; if (mfg is Uint8List && mfg.length >= 2) { companyId = mfg[0] | (mfg[1] << 8); payload = mfg.sublist(2); } EaiCarAdvertisement? adv; // 云衍厂商 ID 0x8505;0x0585 是字节序反过来的写法,两个都收(lumi 的常量表并列了两者) final cidMatched = companyId == 0x8505 || companyId == 0x0585; if (cidMatched) { // 补回 [长度, 0xFF, CID低, CID高]:解析器按协议文档吃完整 AD 结构 adv = EaiCarProtocol.parseManufacturerData( [payload.length + 3, 0xFF, 0x05, 0x85, ...payload]); } final nameOk = isAllowedEaiCarName(name); if (!_diag.isClosed) { _diag.add({ 'name': name, 'id': id, 'rssi': rssi, 'matched': nameOk, 'mfg': companyId == null ? '(无厂商数据)' : '0x${companyId.toRadixString(16).toUpperCase()}[${payload.length}]=' '${payload.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}', 'services': services, 'parsedMac': adv?.macAddress ?? '(解不出)', }); } // ⚠️ 准入只认**名字**。厂商 ID(0x8505) 不再单独作为放行条件: // 它只说明"这是云衍方案的设备",说明不了"这是我们的正式产品", // 而名字不对的固件服务端那关同样过不了。名字对不上一律当没扫到。 if (!nameOk) return; if (adv == null) { Logger.w(_tag, '"$name" 名字命中但厂商广播段解析失败,MAC 取不到(绑定会失败);companyId=$companyId len=${payload.length}'); } final c = _scanCtrl; if (c != null && !c.isClosed) { c.add(EaiCarScanResult(id: id, name: name, rssi: rssi, advertisement: adv)); } } // ───────────────────────────── 连接 ───────────────────────────── /// 连上并**等杰理认证通过**才返回。失败抛 [StateError],message 带原生报上来的原因 /// (没有 AE00 / 认证失败 / 认证超时 / GATT 超时 各不相同,别再合并成"连接失败")。 /// /// [timeout] 是总超时:GATT 连接本身最多 35s(设备被别的手机占着时不接受新连接, /// 只能等它超时),认证再给 15s,所以默认给到 45s。 Future connect( String deviceId, { Duration timeout = const Duration(seconds: 45), EaiCarAdvertisement? adv, }) async { advertisement = adv; _lastReason = null; try { await _native.connect(deviceId, timeoutSec: timeout.inSeconds); } on PlatformException catch (e) { throw StateError(e.message ?? e.code); } on MissingPluginException { throw StateError('本平台没有 EaiCar 原生链路(只支持 Android / iOS)'); } // 原生只在 ready 时 resolve;state 事件与 result 谁先到不确定,这里补齐 if (!_isConnected) { _isConnected = true; _deviceId = deviceId; if (!_connState.isClosed) _connState.add(true); } // 认证刚过,给固件一拍再发业务命令(lumi 连上后也留了延迟才 syncCurrentTime) await Future.delayed(const Duration(milliseconds: 300)); Logger.i(_tag, '已连接并通过认证 $deviceId'); } /// 杰理 RCSP 认证后的收尾(原生 SDK 发:设时间 / GET_TARGET_INFO / GET_SYS_INFO)。 /// 返回各步状态,0 = 成功;失败抛 [StateError]。 Future> jlPostAuth() async { try { return await _native.jlPostAuth(); } on PlatformException catch (e) { throw StateError(e.message ?? e.code); } } Future disconnect() async { _failAllPending('主动断开'); try { await _native.disconnect(); } catch (_) {} if (_isConnected) { _isConnected = false; if (!_connState.isClosed) _connState.add(false); } _deviceId = null; } Future dispose() async { await disconnect(); await _eventSub?.cancel(); await _frames.close(); await _connState.close(); await _diag.close(); await _ota.close(); await _audio.close(); } // ───────────────────────── 收发 ───────────────────────── /// 等应答的命令:cmd → 等待者。同一条命令不允许并发两次(应答里没有序号)。 final Map> _pending = {}; void _onValue(List value) { final f = EaiCarProtocol.parseFrame(value); if (f == null) { Logger.w(_tag, '丢弃非法帧: ${_hex(value)}'); return; } if (f.isResponse) { final c = _pending.remove(f.cmd); if (c != null && !c.isCompleted) c.complete(f); } if (!_frames.isClosed) _frames.add(f); } /// 发一条命令。[expectResponse] 不传就按 [EaiCarProtocol.expectsResponse] 判断—— /// 那四条设备不回包的命令若强行等,只会白等到超时并把成功记成失败。 Future send( int cmd, { List payload = const [], bool? expectResponse, Duration timeout = const Duration(seconds: 3), }) async { if (!_isConnected) { throw StateError('链路未就绪($_lastState),不能发 0x${cmd.toRadixString(16)}'); } final wantResp = expectResponse ?? EaiCarProtocol.expectsResponse(cmd); Completer? waiter; if (wantResp) { final exist = _pending[cmd]; if (exist != null) return exist.future; waiter = Completer(); _pending[cmd] = waiter; } final packet = EaiCarProtocol.buildPacket(cmd, payload); final ok = await _native.writeCustom(packet); if (!ok) { _pending.remove(cmd); throw StateError('写 0x${cmd.toRadixString(16)} 失败:原生侧没有可用的写特征'); } if (waiter == null) return null; return waiter.future.timeout(timeout, onTimeout: () { _pending.remove(cmd); throw TimeoutException('0x${cmd.toRadixString(16)} 等应答超时'); }); } void _failAllPending(String why) { for (final c in _pending.values) { if (!c.isCompleted) c.completeError(StateError(why)); } _pending.clear(); } static String _hex(List b) => b.map((x) => (x & 0xFF).toRadixString(16).padLeft(2, '0')).join(' '); } /// 一条扫描结果 class EaiCarScanResult { const EaiCarScanResult({ required this.id, required this.name, required this.rssi, this.advertisement, }); final String id; final String name; final int rssi; /// 厂商广播段。**可能为 null**:仅按名字命中、厂商段解不出来时就是空的, /// 此时拿不到 MAC,绑定确权会走 unknown(放行但不登记)。 final EaiCarAdvertisement? advertisement; }