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
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();
|
|
}
|
|
|