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.
 
 
 
 
 
 

410 lines
15 KiB

import 'dart:async';
import 'package:device_smartcar/device_smartcar.dart';
import 'package:flutter/services.dart';
import '../../core/utils/logger.dart';
import 'smartcar_protocol.dart';
/// Smartcar 的 BLE 链路:扫描 / 连接 / 收发 C2 帧。
///
/// ## 为什么是原生插件、不是 flutter_blue_plus(2026-09-10 真机踩出来的)
///
/// Smartcar 是杰理芯片,**固件要求先过杰理 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([SmartcarProtocol]),单测继续守着。
///
/// ⚠️ 一部手机对同一个 peripheral **只能有一条 GATT 连接**。阶段 6 的 Opus 上行
/// 要在原生插件里复用这条连接订阅音频特征,**不能另起一条**。
class SmartcarLink {
static const String _tag = 'SmartcarLink';
final DeviceSmartcar _native = DeviceSmartcar.instance;
StreamSubscription<Map<String, dynamic>>? _eventSub;
final _frames = StreamController<SmartcarFrame>.broadcast();
final _connState = StreamController<bool>.broadcast();
final _diag = StreamController<Map<String, Object?>>.broadcast();
final _ota = StreamController<Map<String, Object?>>.broadcast();
final _audio = StreamController<Uint8List>.broadcast();
/// 设备麦克风上行 PCM(**16k / 16bit / mono**,原生已把 opus 解好)。
/// 帧格式与解码见原生 `SmartcarAudio`。
Stream<Uint8List> get audioPcm => _audio.stream;
/// 原生 OTA 事件:`{state: preparing|transferring|stage2|done|failed|cancelled, percent, code?, message?}`
Stream<Map<String, Object?>> get otaEvents => _ota.stream;
/// 解好的帧(CRC 已校验,坏帧不会出现在这里)
Stream<SmartcarFrame> get frames => _frames.stream;
/// 链路通断。**true = 杰理认证已通过**,不是 GATT 连上。
Stream<bool> get connected => _connState.stream;
/// 扫描诊断:每一台扫到的 BLE 设备(含没命中判定的),扫描页拿去摊开给人看。
/// 「扫不到」「连不上」两次排查都栽在失败不可见上,所以原生报上来的每个状态/原因
/// 也会以一行的形式塞进这里。
Stream<Map<String, Object?>> get diagnostics => _diag.stream;
bool _isConnected = false;
bool get isConnected => _isConnected;
String? _deviceId;
String? get deviceId => _deviceId;
/// 连上时记下的广播信息(MAC / License 从这里来)
SmartcarAdvertisement? advertisement;
/// 最近一次原生上报的链路状态与原因,连接失败时拼进错误信息
String _lastState = 'disconnected';
String? _lastReason;
SmartcarLink() {
_eventSub = _native.events.listen(_onNativeEvent, onError: (Object e) {
Logger.w(_tag, '原生事件流错误: $e');
});
}
// ───────────────────────────── 事件路由 ─────────────────────────────
StreamController<SmartcarScanResult>? _scanCtrl;
void _onNativeEvent(Map<String, dynamic> 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<String, Object?>.from(e));
break;
case 'audio':
final d = e['data'];
if (d is Uint8List && !_audio.isClosed) _audio.add(d);
break;
}
}
/// 开始接收设备麦克风上行(设备自己开麦,这里只是打开原生的解码转发)
Future<bool> 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<void> audioStop() async {
try {
await _native.audioStop();
} catch (_) {}
}
Future<Map<String, Object?>> audioStats() async {
try {
return await _native.audioStats();
} catch (_) {
return const {};
}
}
/// 启动杰理 OTA(原生 `SmartcarOta`:cmdOTAData + stage2 扫描回连)。失败抛 [StateError]。
Future<void> otaStart(String filePath) async {
try {
await _native.otaStart(filePath);
} on PlatformException catch (e) {
throw StateError(e.message ?? e.code);
}
}
Future<void> otaCancel() async {
try {
await _native.otaCancel();
} catch (_) {}
}
void _onStateEvent(Map<String, dynamic> 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. 广播名含 `smartcar`(兜底)
Stream<SmartcarScanResult> scan({
Duration timeout = const Duration(seconds: 15),
}) {
final ctrl = StreamController<SmartcarScanResult>();
_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 = '本平台没有 Smartcar 原生链路(只支持 Android / iOS)';
if (!ctrl.isClosed) ctrl.addError(StateError(msg));
}
}();
return ctrl.stream;
}
Future<void> stopScan() async {
try {
await _native.stopScan();
} catch (_) {}
}
void _onScanHit(Map<String, dynamic> 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<int> payload = const [];
if (mfg is Uint8List && mfg.length >= 2) {
companyId = mfg[0] | (mfg[1] << 8);
payload = mfg.sublist(2);
}
SmartcarAdvertisement? adv;
// 云衍厂商 ID 0x8505;0x0585 是字节序反过来的写法,两个都收(lumi 的常量表并列了两者)
final cidMatched = companyId == 0x8505 || companyId == 0x0585;
if (cidMatched) {
// 补回 [长度, 0xFF, CID低, CID高]:解析器按协议文档吃完整 AD 结构
adv = SmartcarProtocol.parseManufacturerData(
<int>[payload.length + 3, 0xFF, 0x05, 0x85, ...payload]);
}
final byName = name.toLowerCase().contains('smartcar');
if (!_diag.isClosed) {
_diag.add({
'name': name,
'id': id,
'rssi': rssi,
'matched': adv != null || byName,
'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 ?? '(解不出)',
});
}
if (adv == null && !byName) return;
if (adv == null) {
Logger.w(_tag, '按名字命中 "$name" 但厂商广播段解析失败,MAC 取不到;companyId=$companyId len=${payload.length}');
}
final c = _scanCtrl;
if (c != null && !c.isClosed) {
c.add(SmartcarScanResult(id: id, name: name, rssi: rssi, advertisement: adv));
}
}
// ───────────────────────────── 连接 ─────────────────────────────
/// 连上并**等杰理认证通过**才返回。失败抛 [StateError],message 带原生报上来的原因
/// (没有 AE00 / 认证失败 / 认证超时 / GATT 超时 各不相同,别再合并成"连接失败")。
///
/// [timeout] 是总超时:GATT 连接本身最多 35s(设备被别的手机占着时不接受新连接,
/// 只能等它超时),认证再给 15s,所以默认给到 45s。
Future<void> connect(
String deviceId, {
Duration timeout = const Duration(seconds: 45),
SmartcarAdvertisement? 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('本平台没有 Smartcar 原生链路(只支持 Android / iOS)');
}
// 原生只在 ready 时 resolve;state 事件与 result 谁先到不确定,这里补齐
if (!_isConnected) {
_isConnected = true;
_deviceId = deviceId;
if (!_connState.isClosed) _connState.add(true);
}
// 认证刚过,给固件一拍再发业务命令(lumi 连上后也留了延迟才 syncCurrentTime)
await Future<void>.delayed(const Duration(milliseconds: 300));
Logger.i(_tag, '已连接并通过认证 $deviceId');
}
/// 杰理 RCSP 认证后的收尾(原生 SDK 发:设时间 / GET_TARGET_INFO / GET_SYS_INFO)。
/// 返回各步状态,0 = 成功;失败抛 [StateError]。
Future<Map<String, Object?>> jlPostAuth() async {
try {
return await _native.jlPostAuth();
} on PlatformException catch (e) {
throw StateError(e.message ?? e.code);
}
}
Future<void> disconnect() async {
_failAllPending('主动断开');
try {
await _native.disconnect();
} catch (_) {}
if (_isConnected) {
_isConnected = false;
if (!_connState.isClosed) _connState.add(false);
}
_deviceId = null;
}
Future<void> 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<int, Completer<SmartcarFrame>> _pending = {};
void _onValue(List<int> value) {
final f = SmartcarProtocol.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] 不传就按 [SmartcarProtocol.expectsResponse] 判断——
/// 那四条设备不回包的命令若强行等,只会白等到超时并把成功记成失败。
Future<SmartcarFrame?> send(
int cmd, {
List<int> payload = const [],
bool? expectResponse,
Duration timeout = const Duration(seconds: 3),
}) async {
if (!_isConnected) {
throw StateError('链路未就绪($_lastState),不能发 0x${cmd.toRadixString(16)}');
}
final wantResp = expectResponse ?? SmartcarProtocol.expectsResponse(cmd);
Completer<SmartcarFrame>? waiter;
if (wantResp) {
final exist = _pending[cmd];
if (exist != null) return exist.future;
waiter = Completer<SmartcarFrame>();
_pending[cmd] = waiter;
}
final packet = SmartcarProtocol.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<int> b) =>
b.map((x) => (x & 0xFF).toRadixString(16).padLeft(2, '0')).join(' ');
}
/// 一条扫描结果
class SmartcarScanResult {
const SmartcarScanResult({
required this.id,
required this.name,
required this.rssi,
this.advertisement,
});
final String id;
final String name;
final int rssi;
/// 厂商广播段。**可能为 null**:仅按名字命中、厂商段解不出来时就是空的,
/// 此时拿不到 MAC,绑定确权会走 unknown(放行但不登记)。
final SmartcarAdvertisement? advertisement;
}