diff --git a/apps/client/lib/data/models/device_category.dart b/apps/client/lib/data/models/device_category.dart index f578151e..a45219be 100644 --- a/apps/client/lib/data/models/device_category.dart +++ b/apps/client/lib/data/models/device_category.dart @@ -162,6 +162,22 @@ class DeviceCategories { return null; } + /// 按设备名(服务端下发的 `devicename` 或蓝牙广播名)猜品类 id;猜不到返回 null。 + /// + /// 口径与 [productsOf] 的 `nameKeywords` 匹配完全一致,给「设备管理」这类 + /// 只有一行绑定记录、没有在线会话的地方用。**只做展示/入口判断**, + /// 不要拿它做协议分支——协议分支按能力(`DeviceCapability`)走。 + static String? categoryIdForName(String? deviceName) { + final name = (deviceName ?? '').trim().toLowerCase(); + if (name.isEmpty) return null; + for (final c in [...all, ...hidden]) { + if (c.nameKeywords.any((k) => name.contains(k.toLowerCase()))) { + return c.id; + } + } + return null; + } + /// 该品类下的产品(服务端下发的产品表里筛) static List productsOf(DeviceCategory category) { if (!AppConfig.isInitialized()) return const []; diff --git a/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart b/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart index 4431da2c..6dd61ac2 100644 --- a/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart +++ b/apps/client/lib/devices/eaicar/eaicar_device_plugin.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math' as math; import 'package:classic_bluetooth/classic_bluetooth.dart'; import 'package:device_plugin_interface/device_plugin_interface.dart'; @@ -381,6 +382,9 @@ class EaiCarSession implements DeviceSession { // 设备按键/语音唤醒 AI:设备主动上报 0xCC 0x11。声明它就能白嫖 // DeviceAiSessionService 那整套会话编排 DeviceCapability.aiWake, + // 设备设置面板:音量/亮度/启停/行车感应/摇摆/随机表情,状态来自 0x15, + // 写命令见 [settingsCommand]。业务层按这个能力位在设备管理页开面板。 + DeviceCapability.settingsPanel, // ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP // (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇, // 见 [_EaiCarPhoneSpeaker]。 @@ -403,6 +407,13 @@ class EaiCarSession implements DeviceSession { DeviceConnectionState _state = DeviceConnectionState.linkConnected; DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: ''); + /// 最近一次 0x15 汇总解出来的全部设置(写成功后本地回填)。 + /// 握手成功后必有值;为 null 说明 0x15 从没应答过。 + EaiCarAllSettings? _settings; + + /// 只读快照,给面板/排查用;写要走 [invokeFeature] 的 `settings.*`。 + EaiCarAllSettings? get settings => _settings; + @override String get vendor => DeviceVendors.eaicar; @@ -532,6 +543,138 @@ class EaiCarSession implements DeviceSession { ); _emitInfo(); _emitFeature('common.battery', {'percent': s.battery}); + // 2026-09-22 之前这里只留固件和电量,音量/亮度/各开关解完就丢—— + // 设备设置面板要的"当前值"正是这些,整份留下并派事件。 + _settings = s; + _emitFeature(DeviceFeatureKeys.settingsChanged, s.toSettingsMap()); + } + + /// `settings.*` 写命令的**纯映射**:feature key + 参数 → (命令号, 载荷, 是否等应答)。 + /// + /// 抽成静态纯函数是为了让 `test/devices/eaicar_settings_features_test.dart` + /// 不连设备就能逐字节钉死;[invokeFeature] 只是"映射 → 发 → 回填"。 + /// 参数越界抛 `device.invalid_argument`,不认识的 key 返回 null。 + /// + /// ⚠️ `play_random_expression`:设备**没有**"随机播一个"的原生命令, + /// 这里由 app 在 0x00~0x09 里挑([random] 可注入,测试用固定值)。 + static ({int cmd, List payload, bool expectResponse})? settingsCommand( + String key, + Map args, { + int Function(int max)? random, + }) { + int intArg(String name) { + final v = args[name]; + if (v is num) return v.toInt(); + throw DeviceException( + DeviceErrorCode.invalidArgument, '$key 缺少整数参数 $name'); + } + + bool onArg() { + final v = args['on']; + if (v is bool) return v; + throw DeviceException( + DeviceErrorCode.invalidArgument, '$key 缺少布尔参数 on'); + } + + int ranged(String name, int max) { + final v = intArg(name); + if (v < 0 || v > max) { + throw DeviceException( + DeviceErrorCode.invalidArgument, '$key $name=$v 超出 0~$max'); + } + return v; + } + + switch (key) { + case DeviceFeatures.settingsVolume: + return ( + cmd: EaiCarProtocol.cmdSetVolume, + payload: [ranged('value', EaiCarProtocol.volumeMax)], + expectResponse: true, + ); + case DeviceFeatures.settingsBrightness: + return ( + cmd: EaiCarProtocol.cmdControlBrightness, + payload: [ranged('value', EaiCarProtocol.brightnessMax)], + expectResponse: true, + ); + case DeviceFeatures.settingsPower: + return ( + cmd: EaiCarProtocol.cmdAutoPowerSwitch, + payload: [onArg() ? 0x01 : 0x00], + expectResponse: true, + ); + case DeviceFeatures.settingsDrivingSense: + return ( + cmd: EaiCarProtocol.cmdGyroscopeSwitch, + payload: [onArg() ? 0x01 : 0x00], + expectResponse: true, + ); + case DeviceFeatures.settingsSwing: + return ( + cmd: EaiCarProtocol.cmdMotorSwitch, + payload: [onArg() ? 0x01 : 0x00], + expectResponse: true, + ); + case DeviceFeatures.settingsRandomExpressionInterval: + final s = intArg('seconds'); + if (!EaiCarProtocol.randomExpressionIntervalOptions.contains(s)) { + throw DeviceException(DeviceErrorCode.invalidArgument, + '$key seconds=$s 不在 ${EaiCarProtocol.randomExpressionIntervalOptions}'); + } + return ( + cmd: EaiCarProtocol.cmdRandomExpressionInterval, + payload: [s], + expectResponse: true, + ); + case DeviceFeatures.settingsPlayRandomExpression: + final pick = (random ?? _defaultRandom)(EaiCarProtocol.emotionCount); + return ( + cmd: EaiCarProtocol.cmdControlEmotion, + payload: [pick.clamp(0, EaiCarProtocol.emotionCount - 1)], + expectResponse: false, + ); + default: + return null; + } + } + + static final math.Random _rng = math.Random(); + static int _defaultRandom(int max) => _rng.nextInt(max); + + /// 发一条 `settings.*` 写命令:设备拒绝(应答 len==0)抛 `feature_failed`; + /// 成功后**本地回填** [_settings] 并派 `settings.changed`。 + /// + /// 为什么要本地回填:六条写命令的应答只有 `0x64` 成功码,不回显新值, + /// 也没有主动上报;不回填的话面板要等下一次 0x15 才知道自己改成了没有。 + Future> _applySettingsCommand( + String key, Map args) async { + final c = settingsCommand(key, args); + if (c == null) { + throw DeviceException(DeviceErrorCode.notSupported, 'feature $key'); + } + final f = await _link.send(c.cmd, + payload: c.payload, expectResponse: c.expectResponse); + if (c.expectResponse && (f == null || !f.success)) { + throw DeviceException( + DeviceErrorCode.featureFailed, '$key 设备拒绝 (0x${c.cmd.toRadixString(16)})'); + } + final cur = _settings; + if (cur != null && key != DeviceFeatures.settingsPlayRandomExpression) { + final v = c.payload[0]; + _settings = switch (key) { + DeviceFeatures.settingsVolume => cur.copyWith(volume: v), + DeviceFeatures.settingsBrightness => cur.copyWith(brightness: v), + DeviceFeatures.settingsPower => cur.copyWith(powerSwitch: v != 0), + DeviceFeatures.settingsDrivingSense => cur.copyWith(gyroscope: v != 0), + DeviceFeatures.settingsSwing => cur.copyWith(motor: v != 0), + DeviceFeatures.settingsRandomExpressionInterval => + cur.copyWith(randomExprFreq: v), + _ => cur, + }; + _emitFeature(DeviceFeatureKeys.settingsChanged, _settings!.toSettingsMap()); + } + return {'ok': true}; } void _applyVersion(List p) { @@ -772,6 +915,33 @@ class EaiCarSession implements DeviceSession { case 'state.refresh': await refreshInfo(); return {'ok': true}; + + // ---------- 设备设置面板(DeviceCapability.settingsPanel) ---------- + case DeviceFeatures.settingsQuery: + // 每次都真的问一次 0x15,不拿缓存糊弄面板:面板打开就是想看"现在"的值。 + // 字节与握手 / refreshInfo 完全一样(不带载荷,真机验证过;lumi 带 0xFF, + // 设备两种都认,别在这里另起一种写法)。 + final f = await _link.send(EaiCarProtocol.cmdSyncSummary); + if (f == null || !f.success) { + throw DeviceException( + DeviceErrorCode.readFailed, '0x15 无应答或设备拒绝'); + } + _applySummary(f.payload); + final s = _settings; + if (s == null) { + throw DeviceException( + DeviceErrorCode.readFailed, '0x15 应答不足 14 字节'); + } + return s.toSettingsMap(); + case DeviceFeatures.settingsVolume: + case DeviceFeatures.settingsBrightness: + case DeviceFeatures.settingsPower: + case DeviceFeatures.settingsDrivingSense: + case DeviceFeatures.settingsSwing: + case DeviceFeatures.settingsRandomExpressionInterval: + case DeviceFeatures.settingsPlayRandomExpression: + return _applySettingsCommand(key, args); + case 'eaicar.volume': final v = (args['value'] as num?)?.toInt() ?? 0; await _link.send(EaiCarProtocol.cmdSetVolume, payload: [v & 0xFF]); diff --git a/apps/client/lib/devices/eaicar/eaicar_protocol.dart b/apps/client/lib/devices/eaicar/eaicar_protocol.dart index 19023522..35753e72 100644 --- a/apps/client/lib/devices/eaicar/eaicar_protocol.dart +++ b/apps/client/lib/devices/eaicar/eaicar_protocol.dart @@ -150,9 +150,30 @@ class EaiCarProtocol { static const int cmdGyroscopeSwitch = 0x11; static const int cmdMusicHeadStyle = 0x12; + + /// 待机随机表情频次,载荷 1 字节 = **秒数原值**(不是档位索引), + /// 允许 [randomExpressionIntervalOptions] 里的 9 档(lumi `settings_view.dart:1517`)。 + /// 0x15 回读 `[2]` 也是秒数原值。 static const int cmdRandomExpressionInterval = 0x13; + + /// 电机(摇摆)开关,载荷 1 字节 0/1。lumi 常量名 MOTOR,UI 文案「摇摆开关」。 static const int cmdMotorSwitch = 0x14; + /// [cmdRandomExpressionInterval] 的合法取值(秒) + static const List randomExpressionIntervalOptions = [ + 20, 30, 40, 50, 60, 90, 120, 150, 180, + ]; + + /// [cmdControlEmotion] 的表情 id 范围:0x00 开心 / 01 生气 / 02 伤心 / 03 害怕 / + /// 04 疲惫 / 05 得意 / 06 无聊 / 07 疑惑 / 08 跳舞 / 09 唱歌 + /// (lumi `chat_controller.dart:35-59`,与协议文档一致) + static const int emotionCount = 10; + + /// 音量 / 亮度的上限。lumi 原生层把音量校验写成 0~16,但 UI 只发 0~10, + /// 协议文档也写 0~10,按 10 收。 + static const int volumeMax = 10; + static const int brightnessMax = 10; + /// 同步参数汇总。应答 data = 14 字节,见 [EaiCarAllSettings] static const int cmdSyncSummary = 0x15; @@ -399,4 +420,52 @@ class EaiCarAllSettings { motor: u(13) != 0, ); } + + EaiCarAllSettings copyWith({ + int? volume, + int? randomExprFreq, + int? brightness, + bool? powerSwitch, + bool? gyroscope, + bool? motor, + }) => + EaiCarAllSettings( + otaAbnormal: otaAbnormal, + volume: volume ?? this.volume, + randomExprFreq: randomExprFreq ?? this.randomExprFreq, + brightness: brightness ?? this.brightness, + battery: battery, + firmwareVersion: firmwareVersion, + powerSwitch: powerSwitch ?? this.powerSwitch, + musicHeadStyle: musicHeadStyle, + musicExpression: musicExpression, + gyroscope: gyroscope ?? this.gyroscope, + motor: motor ?? this.motor, + ); + + /// `DeviceFeatures.settingsQuery` 返回值 / `settings.changed` 事件的 data。 + /// + /// 键名是契约层定的通用语义(见 `DeviceFeatures.settings*`),不是 C2 字段名: + /// `gyroscope` 对外叫 `driving_sense`,`motor` 对外叫 `swing`。 + Map toSettingsMap() => { + 'items': { + 'power': {'on': powerSwitch}, + 'volume': {'value': volume, 'min': 0, 'max': EaiCarProtocol.volumeMax}, + 'brightness': { + 'value': brightness, + 'min': 0, + 'max': EaiCarProtocol.brightnessMax, + }, + 'driving_sense': {'on': gyroscope}, + 'swing': {'on': motor}, + 'random_expression_interval': { + 'value': randomExprFreq, + 'options': EaiCarProtocol.randomExpressionIntervalOptions, + }, + 'play_random_expression': {}, + }, + 'battery': battery, + 'firmware': firmwareVersion, + 'otaAbnormal': otaAbnormal, + }; } diff --git a/apps/client/lib/modules/eaicar/module.dart b/apps/client/lib/modules/eaicar/module.dart index b016aa71..de5292d5 100644 --- a/apps/client/lib/modules/eaicar/module.dart +++ b/apps/client/lib/modules/eaicar/module.dart @@ -55,6 +55,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': '欢迎回来', 'eaicarA2dpTitle': '需要连接蓝牙音频', 'eaicarA2dpHint': 'AI 回复和音乐要经典蓝牙才能从设备里放出来。请到「设置 → 蓝牙」里配对 @name。', + 'deviceSettingsTitle': '设备设置', + 'deviceNotConnected': '设备未连接', + 'goConnect': '去连接', + 'refresh': '刷新', + 'settingReadFailed': '读取设备状态失败', + 'settingApplyFailed': '设置失败', + 'settingNoItems': '该设备没有可调项', + 'settingPower': '启停开关', + 'settingVolume': '音量', + 'settingBrightness': '亮度', + 'settingDrivingSense': '行车感应', + 'settingSwing': '摇摆', + 'settingRandomExpressionInterval': '随机表情频次', + 'settingSeconds': '@n 秒', + 'settingPlayRandomExpression': '随机来一个表情', }, 'zh_TW': { 'agentEaiCar': 'EaiCar', @@ -62,6 +77,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': '歡迎回來', 'eaicarA2dpTitle': '需要連接藍牙音訊', 'eaicarA2dpHint': 'AI 回覆和音樂要經典藍牙才能從裝置裡播出來。請到「設定 → 藍牙」配對 @name。', + 'deviceSettingsTitle': '裝置設定', + 'deviceNotConnected': '裝置未連接', + 'goConnect': '去連接', + 'refresh': '重新整理', + 'settingReadFailed': '讀取裝置狀態失敗', + 'settingApplyFailed': '設定失敗', + 'settingNoItems': '此裝置沒有可調項目', + 'settingPower': '啟停開關', + 'settingVolume': '音量', + 'settingBrightness': '亮度', + 'settingDrivingSense': '行車感應', + 'settingSwing': '搖擺', + 'settingRandomExpressionInterval': '隨機表情頻率', + 'settingSeconds': '@n 秒', + 'settingPlayRandomExpression': '隨機來一個表情', }, 'zh_HK': { 'agentEaiCar': 'EaiCar', @@ -69,6 +99,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': '歡迎回來', 'eaicarA2dpTitle': '需要連接藍牙音訊', 'eaicarA2dpHint': 'AI 回覆和音樂要經典藍牙先可以喺裝置度播出嚟。請去「設定 → 藍牙」配對 @name。', + 'deviceSettingsTitle': '裝置設定', + 'deviceNotConnected': '裝置未連接', + 'goConnect': '去連接', + 'refresh': '重新整理', + 'settingReadFailed': '讀取裝置狀態失敗', + 'settingApplyFailed': '設定失敗', + 'settingNoItems': '呢部裝置冇可調項目', + 'settingPower': '啟停開關', + 'settingVolume': '音量', + 'settingBrightness': '亮度', + 'settingDrivingSense': '行車感應', + 'settingSwing': '搖擺', + 'settingRandomExpressionInterval': '隨機表情頻率', + 'settingSeconds': '@n 秒', + 'settingPlayRandomExpression': '隨機嚟一個表情', }, 'en_US': { 'agentEaiCar': 'EaiCar', @@ -76,6 +121,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'Welcome back', 'eaicarA2dpTitle': 'Bluetooth audio not connected', 'eaicarA2dpHint': 'Pair @name under Settings → Bluetooth so replies and music play from the device.', + 'deviceSettingsTitle': 'Device settings', + 'deviceNotConnected': 'Device not connected', + 'goConnect': 'Connect', + 'refresh': 'Refresh', + 'settingReadFailed': 'Failed to read device status', + 'settingApplyFailed': 'Failed to apply', + 'settingNoItems': 'No adjustable settings on this device', + 'settingPower': 'Auto power', + 'settingVolume': 'Volume', + 'settingBrightness': 'Brightness', + 'settingDrivingSense': 'Driving sensor', + 'settingSwing': 'Swing', + 'settingRandomExpressionInterval': 'Random expression interval', + 'settingSeconds': '@n s', + 'settingPlayRandomExpression': 'Play a random expression', }, 'ja_JP': { 'agentEaiCar': 'EaiCar', @@ -83,6 +143,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'おかえりなさい', 'eaicarA2dpTitle': 'Bluetoothオーディオ未接続', 'eaicarA2dpHint': '「設定 → Bluetooth」で @name をペアリングすると、応答と音楽が本体から再生されます。', + 'deviceSettingsTitle': 'デバイス設定', + 'deviceNotConnected': 'デバイス未接続', + 'goConnect': '接続する', + 'refresh': '更新', + 'settingReadFailed': 'デバイス状態の取得に失敗', + 'settingApplyFailed': '設定に失敗', + 'settingNoItems': 'このデバイスに調整項目はありません', + 'settingPower': '自動オン/オフ', + 'settingVolume': '音量', + 'settingBrightness': '明るさ', + 'settingDrivingSense': '走行センサー', + 'settingSwing': 'スイング', + 'settingRandomExpressionInterval': 'ランダム表情の間隔', + 'settingSeconds': '@n 秒', + 'settingPlayRandomExpression': 'ランダムに表情を再生', }, 'ko_KR': { 'agentEaiCar': 'EaiCar', @@ -90,6 +165,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': '다시 오신 것을 환영합니다', 'eaicarA2dpTitle': '블루투스 오디오 미연결', 'eaicarA2dpHint': '설정 → 블루투스에서 @name 을(를) 페어링하면 응답과 음악이 기기에서 재생됩니다。', + 'deviceSettingsTitle': '기기 설정', + 'deviceNotConnected': '기기 미연결', + 'goConnect': '연결하기', + 'refresh': '새로고침', + 'settingReadFailed': '기기 상태를 읽지 못했습니다', + 'settingApplyFailed': '설정 실패', + 'settingNoItems': '이 기기에는 조절 항목이 없습니다', + 'settingPower': '자동 전원', + 'settingVolume': '음량', + 'settingBrightness': '밝기', + 'settingDrivingSense': '주행 감지', + 'settingSwing': '스윙', + 'settingRandomExpressionInterval': '랜덤 표정 주기', + 'settingSeconds': '@n 초', + 'settingPlayRandomExpression': '랜덤 표정 재생', }, 'de_DE': { 'agentEaiCar': 'EaiCar', @@ -97,6 +187,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'Willkommen zurück', 'eaicarA2dpTitle': 'Bluetooth-Audio nicht verbunden', 'eaicarA2dpHint': 'Koppeln Sie @name unter Einstellungen → Bluetooth, damit Antworten und Musik über das Gerät laufen.', + 'deviceSettingsTitle': 'Geräteeinstellungen', + 'deviceNotConnected': 'Gerät nicht verbunden', + 'goConnect': 'Verbinden', + 'refresh': 'Aktualisieren', + 'settingReadFailed': 'Gerätestatus konnte nicht gelesen werden', + 'settingApplyFailed': 'Einstellung fehlgeschlagen', + 'settingNoItems': 'Keine einstellbaren Optionen', + 'settingPower': 'Automatisches Ein/Aus', + 'settingVolume': 'Lautstärke', + 'settingBrightness': 'Helligkeit', + 'settingDrivingSense': 'Fahrsensor', + 'settingSwing': 'Schwingen', + 'settingRandomExpressionInterval': 'Intervall zufälliger Ausdrücke', + 'settingSeconds': '@n s', + 'settingPlayRandomExpression': 'Zufälligen Ausdruck abspielen', }, 'fr_FR': { 'agentEaiCar': 'EaiCar', @@ -104,6 +209,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'Bon retour', 'eaicarA2dpTitle': 'Audio Bluetooth non connecté', 'eaicarA2dpHint': 'Associez @name dans Réglages → Bluetooth pour que les réponses et la musique sortent de l\'appareil.', + 'deviceSettingsTitle': 'Réglages de l\'appareil', + 'deviceNotConnected': 'Appareil non connecté', + 'goConnect': 'Connecter', + 'refresh': 'Actualiser', + 'settingReadFailed': 'Impossible de lire l\'état de l\'appareil', + 'settingApplyFailed': 'Échec du réglage', + 'settingNoItems': 'Aucun réglage disponible', + 'settingPower': 'Marche/arrêt auto', + 'settingVolume': 'Volume', + 'settingBrightness': 'Luminosité', + 'settingDrivingSense': 'Capteur de conduite', + 'settingSwing': 'Balancement', + 'settingRandomExpressionInterval': 'Intervalle des expressions aléatoires', + 'settingSeconds': '@n s', + 'settingPlayRandomExpression': 'Jouer une expression aléatoire', }, 'es_ES': { 'agentEaiCar': 'EaiCar', @@ -111,6 +231,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'Bienvenido de nuevo', 'eaicarA2dpTitle': 'Audio Bluetooth no conectado', 'eaicarA2dpHint': 'Empareja @name en Ajustes → Bluetooth para que las respuestas y la música suenen en el dispositivo.', + 'deviceSettingsTitle': 'Ajustes del dispositivo', + 'deviceNotConnected': 'Dispositivo no conectado', + 'goConnect': 'Conectar', + 'refresh': 'Actualizar', + 'settingReadFailed': 'No se pudo leer el estado del dispositivo', + 'settingApplyFailed': 'Error al aplicar', + 'settingNoItems': 'No hay ajustes disponibles', + 'settingPower': 'Encendido automático', + 'settingVolume': 'Volumen', + 'settingBrightness': 'Brillo', + 'settingDrivingSense': 'Sensor de conducción', + 'settingSwing': 'Balanceo', + 'settingRandomExpressionInterval': 'Intervalo de expresiones aleatorias', + 'settingSeconds': '@n s', + 'settingPlayRandomExpression': 'Reproducir una expresión aleatoria', }, 'pt_PT': { 'agentEaiCar': 'EaiCar', @@ -118,6 +253,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'Bem-vindo de volta', 'eaicarA2dpTitle': 'Áudio Bluetooth não ligado', 'eaicarA2dpHint': 'Emparelhe @name em Definições → Bluetooth para que as respostas e a música saiam do dispositivo.', + 'deviceSettingsTitle': 'Definições do dispositivo', + 'deviceNotConnected': 'Dispositivo não ligado', + 'goConnect': 'Ligar', + 'refresh': 'Atualizar', + 'settingReadFailed': 'Falha ao ler o estado do dispositivo', + 'settingApplyFailed': 'Falha ao aplicar', + 'settingNoItems': 'Sem definições ajustáveis', + 'settingPower': 'Ligar/desligar automático', + 'settingVolume': 'Volume', + 'settingBrightness': 'Brilho', + 'settingDrivingSense': 'Sensor de condução', + 'settingSwing': 'Balanço', + 'settingRandomExpressionInterval': 'Intervalo de expressões aleatórias', + 'settingSeconds': '@n s', + 'settingPlayRandomExpression': 'Reproduzir uma expressão aleatória', }, 'ru_RU': { 'agentEaiCar': 'EaiCar', @@ -125,6 +275,21 @@ final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor( 'eaicarWelcome': 'С возвращением', 'eaicarA2dpTitle': 'Bluetooth-аудио не подключено', 'eaicarA2dpHint': 'Подключите @name в «Настройки → Bluetooth», чтобы ответы и музыка звучали из устройства.', + 'deviceSettingsTitle': 'Настройки устройства', + 'deviceNotConnected': 'Устройство не подключено', + 'goConnect': 'Подключить', + 'refresh': 'Обновить', + 'settingReadFailed': 'Не удалось прочитать состояние устройства', + 'settingApplyFailed': 'Не удалось применить', + 'settingNoItems': 'Нет настраиваемых параметров', + 'settingPower': 'Автовключение', + 'settingVolume': 'Громкость', + 'settingBrightness': 'Яркость', + 'settingDrivingSense': 'Датчик движения', + 'settingSwing': 'Покачивание', + 'settingRandomExpressionInterval': 'Интервал случайных эмоций', + 'settingSeconds': '@n с', + 'settingPlayRandomExpression': 'Показать случайную эмоцию', }, }, ); diff --git a/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart b/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart index a9eed596..c5bbc3cc 100644 --- a/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart +++ b/apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart @@ -4,6 +4,7 @@ import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; import '/core/utils/logger.dart'; +import '/data/models/device_category.dart'; import '/data/models/user_Info.dart'; import '/data/services/network/api.dart'; import '/devices/device_auth.dart'; @@ -128,17 +129,33 @@ class MyDevicesController extends GetxController { } /// 当前是不是正连着这一台:比服务端记的 MAC 与在线会话的真实 MAC / 本地 id。 - bool isConnected(UserDevice device) { - if (device.devicemac.isEmpty) return false; + bool isConnected(UserDevice device) => sessionFor(device) != null; + + /// 这一行对应的在线会话;没连着为 null。 + /// 匹配口径:服务端记的 MAC == 会话的真实 MAC / 本地 id / metadata.localId。 + DeviceSession? sessionFor(UserDevice device) { + if (device.devicemac.isEmpty) return null; final mac = device.devicemac.toUpperCase(); for (final s in DeviceHub.to.sessions) { final info = s.info; - if ((info.macAddress ?? '').toUpperCase() == mac) return true; - if (info.id.toUpperCase() == mac) return true; + if ((info.macAddress ?? '').toUpperCase() == mac) return s; + if (info.id.toUpperCase() == mac) return s; final localId = (info.metadata['localId'] as String?) ?? ''; - if (localId.isNotEmpty && localId.toUpperCase() == mac) return true; + if (localId.isNotEmpty && localId.toUpperCase() == mac) return s; } - return false; + return null; + } + + /// 这一行能不能点开「设备设置面板」。 + /// + /// 连着:看会话有没有声明 [DeviceCapability.settingsPanel](按能力,不认厂商)。 + /// 没连着:退化为按设备名猜品类——只有车载香薰这一档目前有面板, + /// 点进去看到的是「未连接 / 去连接」。别的品类不可点,行为与以前完全一样。 + bool canOpenSettings(UserDevice device) { + final s = sessionFor(device); + if (s != null) return s.supports(DeviceCapability.settingsPanel); + return DeviceCategories.categoryIdForName(device.devicename) == + DeviceCategories.carAroma; } /// 本页已去掉「添加设备」入口,跳品类选择页 diff --git a/apps/client/lib/modules/my_devices/views/my_devices_view.dart b/apps/client/lib/modules/my_devices/views/my_devices_view.dart index 7716b409..739f4699 100644 --- a/apps/client/lib/modules/my_devices/views/my_devices_view.dart +++ b/apps/client/lib/modules/my_devices/views/my_devices_view.dart @@ -6,6 +6,7 @@ import 'package:get/get.dart'; import '/data/models/user_Info.dart'; import '../../../routes/app_routes.dart'; import '../controllers/my_devices_controller.dart'; +import 'widgets/device_settings_sheet.dart'; enum _MyDeviceCategory { unknown(0), @@ -332,8 +333,15 @@ class MyDevicesView extends GetView { UserDevice device, _MyDeviceCategory category, bool isDark) { final connected = controller.isConnected(device); const connectedColor = Color(0xFF22C55E); + // 2026-09-22:有设置面板的设备(目前只有 EaiCar)整行可点,点开底部面板。 + // 解绑按钮自己的 GestureDetector 在子树里先吃掉事件,不会冒泡到这里; + // 其它设备 onTap 为 null,行为与以前一字不差。 + final canOpen = controller.canOpenSettings(device); - return Container( + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: canOpen ? () => DeviceSettingsSheet.show(device) : null, + child: Container( margin: EdgeInsets.only(bottom: 10.h), padding: EdgeInsets.all(18.w), decoration: BoxDecoration( @@ -457,6 +465,18 @@ class MyDevicesView extends GetView { ], ), ), + if (canOpen) ...[ + Icon( + Icons.tune, + size: 18.sp, + color: connected + ? connectedColor + : (isDark + ? Colors.white.withValues(alpha: 0.3) + : const Color(0xFFBBBBBB)), + ), + SizedBox(width: 10.w), + ], GestureDetector( onTap: () => _showUnbindDialog(device, isDark), child: Container( @@ -512,6 +532,7 @@ class MyDevicesView extends GetView { ], ], ), + ), ); } diff --git a/apps/client/lib/modules/my_devices/views/widgets/device_settings_sheet.dart b/apps/client/lib/modules/my_devices/views/widgets/device_settings_sheet.dart new file mode 100644 index 00000000..eed99781 --- /dev/null +++ b/apps/client/lib/modules/my_devices/views/widgets/device_settings_sheet.dart @@ -0,0 +1,584 @@ +import 'dart:async'; + +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import '/core/utils/logger.dart'; +import '/data/models/user_Info.dart'; +import '/devices/device_hub.dart'; +import '/routes/app_routes.dart'; +import '../../controllers/my_devices_controller.dart'; + +/// 设备设置面板(底部弹出)。**厂商无关**:只认 [DeviceCapability.settingsPanel] +/// 与 `settings.*` 特性键,按 `settings.query` 返回的 `items` 渲染—— +/// 设备说自己有哪几项,面板就画哪几项。 +/// +/// 交互口径(与 lumi 设置页一致): +/// - 打开先发一次 `settings.query`(3 s 超时),成功前是骨架屏; +/// - 开关点了立刻翻转(乐观),写失败翻回来并提示; +/// - 滑块拖动只改本地值,300 ms 防抖后发,松手立即发最后一个值; +/// - 频次滑块吸附到设备给的 `options` 档位; +/// - 订阅会话事件:`settings.changed` 整体刷新(覆盖乐观值),断连切到「未连接」态。 +/// +/// ⚠️ 六条写命令设备只回成功码不回显新值,"当前值"的可信来源只有查询与插件回填, +/// 所以面板从不自己推断状态,只信 `items`。 +class DeviceSettingsSheet extends StatefulWidget { + const DeviceSettingsSheet({super.key, required this.device}); + + final UserDevice device; + + static Future show(UserDevice device) { + final isDark = Get.isDarkMode; + return Get.bottomSheet( + DeviceSettingsSheet(device: device), + isScrollControlled: true, + backgroundColor: isDark ? const Color(0xFF1C1C1E) : Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(18.r)), + ), + ); + } + + @override + State createState() => _DeviceSettingsSheetState(); +} + +class _DeviceSettingsSheetState extends State { + static const String _tag = 'DeviceSettingsSheet'; + static const Duration _queryTimeout = Duration(seconds: 3); + static const Duration _sliderDebounce = Duration(milliseconds: 300); + + DeviceSession? _session; + StreamSubscription? _eventSub; + Worker? _revisionWorker; + + /// `settings.query` 返回的 items;null = 还没拿到 + Map? _items; + int? _battery; + String? _firmware; + + bool _loading = false; + String? _error; + + /// 正在写的键,用来禁掉对应控件防连点 + final Set _busy = {}; + + /// 滑块的本地值(拖动中先改这个,防抖后再发) + final Map _sliderLocal = {}; + final Map _debounce = {}; + DateTime _lastExpressionTap = DateTime.fromMillisecondsSinceEpoch(0); + + @override + void initState() { + super.initState(); + _attach(); + // 设备在面板开着时连上/断开:DeviceHub.revision 会变,重新找会话 + _revisionWorker = ever(DeviceHub.to.revision, (_) => _attach()); + } + + @override + void dispose() { + _revisionWorker?.dispose(); + _eventSub?.cancel(); + for (final t in _debounce.values) { + t.cancel(); + } + super.dispose(); + } + + MyDevicesController get _controller => Get.find(); + + void _attach() { + final s = _controller.sessionFor(widget.device); + final usable = + s != null && s.supports(DeviceCapability.settingsPanel) ? s : null; + if (identical(usable, _session)) return; + _eventSub?.cancel(); + _session = usable; + if (usable == null) { + if (mounted) setState(() => _items = null); + return; + } + _eventSub = usable.eventStream.listen(_onEvent); + unawaited(_query()); + } + + void _onEvent(DeviceSessionEvent e) { + if (!mounted) return; + switch (e.type) { + case DeviceSessionEventType.feature: + final f = e.feature; + if (f != null && f.key == DeviceFeatureKeys.settingsChanged) { + _applySnapshot(f.data); + } + break; + case DeviceSessionEventType.connectionStateChanged: + if (e.connectionState == DeviceConnectionState.disconnected) { + setState(() { + _session = null; + _items = null; + }); + } + break; + default: + break; + } + } + + void _applySnapshot(Map data) { + final items = data['items']; + if (items is! Map) return; + setState(() { + _items = items.map((k, v) => MapEntry(k.toString(), v)); + _battery = (data['battery'] as num?)?.toInt(); + _firmware = data['firmware'] as String?; + _error = null; + // 设备给的值覆盖本地乐观值,拖动中的滑块除外(防抖定时器还没触发) + for (final k in _sliderLocal.keys.toList()) { + if (!(_debounce[k]?.isActive ?? false)) _sliderLocal.remove(k); + } + }); + } + + Future _query() async { + final s = _session; + if (s == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final r = await s + .invokeFeature(DeviceFeatures.settingsQuery) + .timeout(_queryTimeout); + if (!mounted) return; + _applySnapshot(r); + } catch (e) { + Logger.w(_tag, 'settings.query 失败: $e'); + if (mounted) setState(() => _error = 'settingReadFailed'.tr); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// 发一条写命令。失败时由调用方决定怎么回滚(开关翻回 / 滑块由下一次快照覆盖)。 + Future _write(String key, Map args) async { + final s = _session; + if (s == null) return false; + setState(() => _busy.add(key)); + try { + await s.invokeFeature(key, args).timeout(_queryTimeout); + return true; + } catch (e) { + Logger.w(_tag, '$key 写入失败: $e'); + if (mounted) { + Get.snackbar( + 'settingApplyFailed'.tr, + e is DeviceException ? (e.message ?? e.code) : e.toString(), + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 2), + ); + } + return false; + } finally { + if (mounted) setState(() => _busy.remove(key)); + } + } + + // ---------------- 控件动作 ---------------- + + Future _toggle(String itemKey, String featureKey, bool on) async { + // 乐观翻转 + setState(() => _items = { + ..._items!, + itemKey: {'on': on}, + }); + final ok = await _write(featureKey, {'on': on}); + if (!ok && mounted) { + setState(() => _items = { + ..._items!, + itemKey: {'on': !on}, + }); + } + } + + void _slide(String itemKey, String featureKey, int value, String argName, + {bool flush = false}) { + setState(() => _sliderLocal[itemKey] = value); + _debounce[itemKey]?.cancel(); + void send() { + _debounce.remove(itemKey); + unawaited(_write(featureKey, {argName: value})); + } + + if (flush) { + send(); + } else { + _debounce[itemKey] = Timer(_sliderDebounce, send); + } + } + + Future _playRandomExpression() async { + final now = DateTime.now(); + if (now.difference(_lastExpressionTap) < const Duration(seconds: 1)) return; + _lastExpressionTap = now; + await _write(DeviceFeatures.settingsPlayRandomExpression, const {}); + } + + // ---------------- 渲染 ---------------- + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final textColor = isDark ? Colors.white : const Color(0xFF1A1A2E); + final subColor = + isDark ? Colors.white.withValues(alpha: 0.5) : const Color(0xFF888888); + + return SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 16.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36.w, + height: 4.h, + decoration: BoxDecoration( + color: subColor.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2.r), + ), + ), + ), + SizedBox(height: 14.h), + _header(textColor, subColor), + SizedBox(height: 12.h), + if (_session == null) + _notConnected(textColor, subColor) + else if (_items == null) + _loading ? _skeleton(subColor) : _errorBox(textColor, subColor) + else + _controls(textColor, subColor), + ], + ), + ), + ); + } + + Widget _header(Color textColor, Color subColor) { + final parts = []; + if (_battery != null) parts.add('$_battery%'); + if (_firmware != null && _firmware!.isNotEmpty) parts.add('v$_firmware'); + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.device.devicename.isNotEmpty + ? widget.device.devicename + : 'deviceSettingsTitle'.tr, + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + color: textColor), + ), + if (parts.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 2.h), + child: Text(parts.join(' · '), + style: TextStyle(fontSize: 12.sp, color: subColor)), + ), + ], + ), + ), + if (_session != null) + IconButton( + tooltip: 'refresh'.tr, + onPressed: _loading ? null : _query, + icon: Icon(Icons.refresh, color: subColor, size: 20.sp), + ), + ], + ); + } + + Widget _notConnected(Color textColor, Color subColor) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 24.h), + child: Column( + children: [ + Icon(Icons.bluetooth_disabled, size: 40.sp, color: subColor), + SizedBox(height: 10.h), + Text('deviceNotConnected'.tr, + style: TextStyle(fontSize: 14.sp, color: textColor)), + SizedBox(height: 14.h), + SizedBox( + height: 42.h, + child: ElevatedButton( + onPressed: () { + Get.back(); + Get.toNamed(Routes.eaicarScan); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r)), + ), + child: Text('goConnect'.tr, + style: TextStyle( + fontSize: 14.sp, fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ); + } + + Widget _skeleton(Color subColor) { + Widget bar(double w) => Container( + width: w, + height: 14.h, + margin: EdgeInsets.symmetric(vertical: 12.h), + decoration: BoxDecoration( + color: subColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6.r), + ), + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [bar(140.w), bar(220.w), bar(180.w), bar(200.w)], + ); + } + + Widget _errorBox(Color textColor, Color subColor) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 20.h), + child: Column( + children: [ + Text(_error ?? 'settingReadFailed'.tr, + style: TextStyle(fontSize: 14.sp, color: textColor)), + SizedBox(height: 10.h), + TextButton(onPressed: _query, child: Text('retry'.tr)), + ], + ), + ); + } + + /// 固定顺序:启停 → 音量 → 亮度 → 行车感应 → 摇摆 → 随机表情频次 → 随机来一个。 + /// items 里没有的项不画。 + Widget _controls(Color textColor, Color subColor) { + final items = _items!; + final rows = []; + + void toggleRow(String itemKey, String featureKey, String titleKey, + IconData icon) { + final it = items[itemKey]; + if (it is! Map) return; + final on = it['on'] == true; + rows.add(_toggleTile( + icon: icon, + title: titleKey.tr, + value: on, + busy: _busy.contains(featureKey), + onChanged: (v) => _toggle(itemKey, featureKey, v), + textColor: textColor, + subColor: subColor, + )); + } + + void sliderRow(String itemKey, String featureKey, String titleKey, + IconData icon) { + final it = items[itemKey]; + if (it is! Map) return; + final min = (it['min'] as num?)?.toInt() ?? 0; + final max = (it['max'] as num?)?.toInt() ?? 10; + final cur = _sliderLocal[itemKey] ?? (it['value'] as num?)?.toInt() ?? 0; + rows.add(_sliderTile( + icon: icon, + title: titleKey.tr, + valueLabel: '$cur', + value: cur.clamp(min, max).toDouble(), + min: min.toDouble(), + max: max.toDouble(), + divisions: (max - min).clamp(1, 100), + onChanged: (v) => _slide(itemKey, featureKey, v.round(), 'value'), + onChangeEnd: (v) => + _slide(itemKey, featureKey, v.round(), 'value', flush: true), + textColor: textColor, + subColor: subColor, + )); + } + + toggleRow('power', DeviceFeatures.settingsPower, 'settingPower', + Icons.power_settings_new); + sliderRow('volume', DeviceFeatures.settingsVolume, 'settingVolume', + Icons.volume_up); + sliderRow('brightness', DeviceFeatures.settingsBrightness, + 'settingBrightness', Icons.brightness_6); + toggleRow('driving_sense', DeviceFeatures.settingsDrivingSense, + 'settingDrivingSense', Icons.screen_rotation); + toggleRow('swing', DeviceFeatures.settingsSwing, 'settingSwing', + Icons.settings_remote); + + final rei = items['random_expression_interval']; + if (rei is Map) { + final options = ((rei['options'] as List?) ?? const [20, 180]) + .map((e) => (e as num).toInt()) + .toList() + ..sort(); + final cur = _sliderLocal['random_expression_interval'] ?? + (rei['value'] as num?)?.toInt() ?? + options.first; + // 用档位索引当滑块值,吸附天然成立;显示秒数 + var idx = options.indexOf(cur); + if (idx < 0) { + idx = 0; + for (var i = 0; i < options.length; i++) { + if ((options[i] - cur).abs() < (options[idx] - cur).abs()) idx = i; + } + } + rows.add(_sliderTile( + icon: Icons.emoji_emotions_outlined, + title: 'settingRandomExpressionInterval'.tr, + valueLabel: 'settingSeconds'.trParams({'n': '${options[idx]}'}), + value: idx.toDouble(), + min: 0, + max: (options.length - 1).toDouble(), + divisions: (options.length - 1).clamp(1, 100), + onChanged: (v) => _slide('random_expression_interval', + DeviceFeatures.settingsRandomExpressionInterval, + options[v.round()], 'seconds'), + onChangeEnd: (v) => _slide('random_expression_interval', + DeviceFeatures.settingsRandomExpressionInterval, + options[v.round()], 'seconds', + flush: true), + textColor: textColor, + subColor: subColor, + )); + } + + if (items['play_random_expression'] is Map) { + final busy = _busy.contains(DeviceFeatures.settingsPlayRandomExpression); + rows.add(Padding( + padding: EdgeInsets.only(top: 6.h), + child: SizedBox( + width: double.infinity, + height: 44.h, + child: OutlinedButton.icon( + onPressed: busy ? null : _playRandomExpression, + icon: Icon(Icons.casino_outlined, size: 18.sp), + label: Text('settingPlayRandomExpression'.tr, + style: + TextStyle(fontSize: 14.sp, fontWeight: FontWeight.w600)), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF6366F1), + side: const BorderSide(color: Color(0xFF6366F1)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12.r)), + ), + ), + ), + )); + } + + if (rows.isEmpty) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 20.h), + child: Center( + child: Text('settingNoItems'.tr, + style: TextStyle(fontSize: 14.sp, color: subColor)), + ), + ); + } + return Column(children: rows); + } + + Widget _toggleTile({ + required IconData icon, + required String title, + required bool value, + required bool busy, + required ValueChanged onChanged, + required Color textColor, + required Color subColor, + }) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 6.h), + child: Row( + children: [ + Icon(icon, size: 20.sp, color: subColor), + SizedBox(width: 12.w), + Expanded( + child: Text(title, + style: TextStyle(fontSize: 15.sp, color: textColor)), + ), + Switch.adaptive( + value: value, + onChanged: busy ? null : onChanged, + activeThumbColor: const Color(0xFF6366F1), + ), + ], + ), + ); + } + + Widget _sliderTile({ + required IconData icon, + required String title, + required String valueLabel, + required double value, + required double min, + required double max, + required int divisions, + required ValueChanged onChanged, + required ValueChanged onChangeEnd, + required Color textColor, + required Color subColor, + }) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4.h), + child: Column( + children: [ + Row( + children: [ + Icon(icon, size: 20.sp, color: subColor), + SizedBox(width: 12.w), + Expanded( + child: Text(title, + style: TextStyle(fontSize: 15.sp, color: textColor)), + ), + Text(valueLabel, + style: TextStyle( + fontSize: 13.sp, + color: subColor, + fontFeatures: const [FontFeature.tabularFigures()])), + ], + ), + SliderTheme( + data: SliderTheme.of(context).copyWith( + activeTrackColor: const Color(0xFF6366F1), + thumbColor: const Color(0xFF6366F1), + overlayColor: const Color(0xFF6366F1).withValues(alpha: 0.12), + inactiveTrackColor: subColor.withValues(alpha: 0.2), + ), + child: Slider( + value: value.clamp(min, max), + min: min, + max: max, + divisions: divisions, + onChanged: onChanged, + onChangeEnd: onChangeEnd, + ), + ), + ], + ), + ); + } +} diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart index 55abb3c8..e92ff4b0 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart @@ -87,4 +87,12 @@ enum DeviceCapability { /// ⚠️ 与 [micUplink](边录边传)**互不蕴含**:EaiRec 支架只有前者, /// 恒玄耳机只有后者,以后的录音笔可能两个都有。 fileStore, + + /// 设备有一组**可读可写的设置项**(音量 / 亮度 / 开关…),通过 + /// [DeviceFeatures.settingsQuery] 读、`settings.*` 各键写,变化时派 + /// [DeviceFeatureKeys.settingsChanged] 事件。 + /// + /// 业务层的「设备设置面板」按它出现,**面板按设备返回的 `items` 渲染**, + /// 不写厂商分支:哪家插件声明了它、返回了哪些项,面板就画哪些。 + settingsPanel, } diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart index 1d2330ca..fa4b83d2 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart @@ -132,6 +132,10 @@ class DeviceFeatureKeys { static const String batteryUpdated = 'battery.updated'; static const String firmwareUpdated = 'firmware.updated'; static const String macUpdated = 'mac.updated'; + + /// 设备设置项变了(设备回了 `0x15` 这类汇总、或 app 写成功后本地回填)。 + /// `data` 与 [DeviceFeatures.settingsQuery] 的返回值同形。 + static const String settingsChanged = 'settings.changed'; } /// 通用 feature 调用 key(app → 设备)。 @@ -172,6 +176,51 @@ class DeviceFeatures { /// 设备,**不再经过 `openMic(ai)` 的 Dart 流**。返回 `{'enabled': bool}`; /// 没有原生桥的厂商抛 `device.not_supported`,业务层据此退回 Dart 链路。 static const String aiPcmNativeBridge = 'ai.pcm.nativebridge'; + + // ---------- 设备设置项(DeviceCapability.settingsPanel)---------- + // + // 通用语义键,厂商插件各自映射成字节。返回值统一 `{'ok': true}`, + // 设备明确拒绝抛 `device.feature_failed`,参数越界抛 `device.invalid_argument`。 + // 写成功后插件**本地回填**并派 [DeviceFeatureKeys.settingsChanged]—— + // 多数设备的应答只有成功码、不回显新值,面板别指望等设备来告诉它。 + + /// 拉一次全部设置项。返回: + /// ``` + /// { + /// 'items': { // 只含设备支持的项;面板按 key 渲染 + /// 'volume': {'value': 6, 'min': 0, 'max': 10}, + /// 'brightness': {'value': 8, 'min': 0, 'max': 10}, + /// 'random_expression_interval': {'value': 30, 'options': [20, 30, ...]}, + /// 'power': {'on': true}, 'driving_sense': {'on': false}, 'swing': {'on': true}, + /// 'play_random_expression': {}, // 只表示支持这个动作 + /// }, + /// 'battery': 80, 'firmware': '1.2.3', + /// } + /// ``` + static const String settingsQuery = 'settings.query'; + + /// `{'value': int}` + static const String settingsVolume = 'settings.volume'; + + /// `{'value': int}` + static const String settingsBrightness = 'settings.brightness'; + + /// 启停开关 `{'on': bool}` + static const String settingsPower = 'settings.power'; + + /// 行车感应开关 `{'on': bool}` + static const String settingsDrivingSense = 'settings.driving_sense'; + + /// 摇摆开关 `{'on': bool}` + static const String settingsSwing = 'settings.swing'; + + /// 待机随机表情的频次 `{'seconds': int}`,取值须在 `items` 给的 `options` 里 + static const String settingsRandomExpressionInterval = + 'settings.random_expression_interval'; + + /// 立刻随机播一个表情,无参。设备没有原生"随机"命令时由插件自己挑一个。 + static const String settingsPlayRandomExpression = + 'settings.play_random_expression'; } /// [DeviceFeatures.aiState] 的取值。 diff --git a/apps/client/test/devices/eaicar_settings_features_test.dart b/apps/client/test/devices/eaicar_settings_features_test.dart new file mode 100644 index 00000000..5d7a9455 --- /dev/null +++ b/apps/client/test/devices/eaicar_settings_features_test.dart @@ -0,0 +1,137 @@ +import 'package:device_plugin_interface/device_plugin_interface.dart'; +import 'package:eaimar/devices/eaicar/eaicar_device_plugin.dart'; +import 'package:eaimar/devices/eaicar/eaicar_protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 设备设置面板的 `settings.*` feature key → 发给 EaiCar 的字节,**冻结**。 +/// +/// 字节来自 lumi 真正发出的命令(`ble_service/.../BleService.kt`),不是协议文档: +/// 文档的 CRC 写法是错的、0x15 应答字段也不全。改一个字节等于改协议,先上真机验。 +void main() { + List frame(String key, Map args) { + final c = EaiCarSession.settingsCommand(key, args, + random: (max) => 0x08 /* 固定挑"跳舞",只为字节可复现 */)!; + return EaiCarProtocol.buildPacket(c.cmd, c.payload); + } + + List withCrc(List body) => + [...body, EaiCarProtocol.crc8Maxim(body)]; + + group('settings.* → 字节', () { + test('六条写命令逐条固定', () { + expect(frame(DeviceFeatures.settingsVolume, {'value': 5}), + withCrc([0xAA, 0x0B, 0x01, 0x05])); + expect(frame(DeviceFeatures.settingsBrightness, {'value': 10}), + withCrc([0xAA, 0x0A, 0x01, 0x0A])); + expect(frame(DeviceFeatures.settingsPower, {'on': true}), + withCrc([0xAA, 0x0D, 0x01, 0x01])); + expect(frame(DeviceFeatures.settingsPower, {'on': false}), + withCrc([0xAA, 0x0D, 0x01, 0x00])); + expect(frame(DeviceFeatures.settingsDrivingSense, {'on': true}), + withCrc([0xAA, 0x11, 0x01, 0x01])); + expect(frame(DeviceFeatures.settingsSwing, {'on': false}), + withCrc([0xAA, 0x14, 0x01, 0x00])); + // 频次是秒数原值,不是档位索引(lumi settings_view.dart:1517) + expect( + frame(DeviceFeatures.settingsRandomExpressionInterval, + {'seconds': 60}), + withCrc([0xAA, 0x13, 0x01, 0x3C])); + }); + + test('随机表情走 0x06、单向不等应答,id 落在 0x00~0x09', () { + final c = EaiCarSession.settingsCommand( + DeviceFeatures.settingsPlayRandomExpression, const {}, + random: (max) => max - 1)!; + expect(c.cmd, EaiCarProtocol.cmdControlEmotion); + expect(c.expectResponse, isFalse); + expect(c.payload, [0x09]); + for (var i = 0; i < 50; i++) { + final r = EaiCarSession.settingsCommand( + DeviceFeatures.settingsPlayRandomExpression, const {})!; + expect(r.payload.single, inInclusiveRange(0, 9)); + } + }); + + test('其余写命令都等应答(设备会回 0x64 成功码)', () { + for (final k in [ + DeviceFeatures.settingsVolume, + DeviceFeatures.settingsBrightness, + ]) { + expect(EaiCarSession.settingsCommand(k, {'value': 1})!.expectResponse, + isTrue); + } + for (final k in [ + DeviceFeatures.settingsPower, + DeviceFeatures.settingsDrivingSense, + DeviceFeatures.settingsSwing, + ]) { + expect(EaiCarSession.settingsCommand(k, {'on': true})!.expectResponse, + isTrue); + } + }); + + test('参数越界 / 缺参抛 invalid_argument,不会发出去', () { + void bad(String key, Map args) { + expect( + () => EaiCarSession.settingsCommand(key, args), + throwsA(isA() + .having((e) => e.code, 'code', DeviceErrorCode.invalidArgument)), + reason: '$key $args', + ); + } + + bad(DeviceFeatures.settingsVolume, {'value': 11}); + bad(DeviceFeatures.settingsVolume, {'value': -1}); + bad(DeviceFeatures.settingsVolume, {}); + bad(DeviceFeatures.settingsBrightness, {'value': 16}); + bad(DeviceFeatures.settingsPower, {'on': 1}); + bad(DeviceFeatures.settingsSwing, {}); + // 45 秒不在 9 档里 + bad(DeviceFeatures.settingsRandomExpressionInterval, {'seconds': 45}); + }); + + test('不认识的键返回 null(由 invokeFeature 抛 not_supported)', () { + expect(EaiCarSession.settingsCommand('settings.nope', const {}), isNull); + }); + }); + + group('0x15 → settings.query 返回值', () { + test('14 字节逐项映射到通用键名', () { + // 与 eaicar_protocol_test 里同一条样本:音量6/频次30/亮度8/电量80/1.2.3/ + // 启停开/摇头风格3/音乐表情开/陀螺仪关/电机开 + final s = EaiCarAllSettings.parse( + [0x00, 0x06, 0x1E, 0x08, 0x50, 0x01, 0x02, 0x03, 0x01, 0x03, 0x01, 0x00, 0x00, 0x01])!; + final m = s.toSettingsMap(); + final items = m['items'] as Map; + expect(items['volume'], {'value': 6, 'min': 0, 'max': 10}); + expect(items['brightness'], {'value': 8, 'min': 0, 'max': 10}); + expect((items['random_expression_interval'] as Map)['value'], 30); + expect((items['random_expression_interval'] as Map)['options'], + EaiCarProtocol.randomExpressionIntervalOptions); + expect(items['power'], {'on': true}); + expect(items['driving_sense'], {'on': false}); + expect(items['swing'], {'on': true}); + expect(items.containsKey('play_random_expression'), isTrue); + expect(m['battery'], 80); + expect(m['firmware'], '1.2.3'); + expect(m['otaAbnormal'], false); + }); + + test('copyWith 只改指定项(写成功后的本地回填靠它)', () { + final s = EaiCarAllSettings.parse( + [0x01, 0x00, 0x14, 0x0A, 0x3C, 0x02, 0x0C, 0x0C, 0x00, 0x02, 0x01, 0x01, 0x04, 0x01])!; + final t = s.copyWith(volume: 7, gyroscope: false); + expect(t.volume, 7); + expect(t.gyroscope, isFalse); + expect(t.brightness, s.brightness); + expect(t.battery, s.battery); + expect(t.firmwareVersion, s.firmwareVersion); + expect(t.motor, s.motor); + expect(t.otaAbnormal, s.otaAbnormal); + }); + }); + + test('EaiCar 会话声明了 settingsPanel(面板按它出现)', () { + expect(EaiCarSession.caps.contains(DeviceCapability.settingsPanel), isTrue); + }); +} diff --git a/docs/EaiCar设备控制面板-落地文档.md b/docs/EaiCar设备控制面板-落地文档.md new file mode 100644 index 00000000..5068ee5f --- /dev/null +++ b/docs/EaiCar设备控制面板-落地文档.md @@ -0,0 +1,219 @@ +# EaiCar 设备控制面板 — 落地文档 + +> 状态:**已按 §5 落地**(2026-09-22,`along`),待真机验证(§5.7)。§7 的 5 个待定项按文中默认值实现: +> 随机表情"频次 + 随机来一个"都做;启停文案照 lumi;未连接的行可点;未露出 0x12/0x0E;面板在 `my_devices`。 +> 参考源工程:`/Users/yunyanzhineng/work/liangchuang/lumi`(插件目录 `local_plugins/voibots_device`、`local_plugins/ble_service`)。 +> 本工程侧:`apps/client/lib/devices/eaicar/`、`lib/modules/my_devices/`。 +> 接入设计与协议总览见 [Smartcar车载香薰-接入设计与落地文档.md](Smartcar车载香薰-接入设计与落地文档.md),本文只讲"控制面板"这一件事。 + +## 1. 要做什么 + +「设备 → 设备管理」列表里,EaiCar 那一行**除解绑按钮外的整块区域**可点。点开一个底部面板: + +1. 先向设备拉一次当前状态(`0x15` 汇总帧),显示出来; +2. 面板上可以直接改:**音量、亮度、随机表情、启停开关、行车感应开关、摇摆开关**; +3. 改完立刻反映在面板上,失败回滚并提示。 + +其它设备(Echo-one 耳机、EaiRec 支架)那一行行为不变。 + +## 2. 六项指令(以 lumi 真正发出的字节为准) + +帧格式两边一致:`AA | cmd | len | payload | crc`,CRC-8/MAXIM(poly 0x31,反射 0x8C,init 0)。 +本工程 `eaicar_protocol.dart` 的 `buildPacket` / `crc8Maxim` 与 lumi 逐字节相同,**协议层不用改**。 +所有 payload 都是单字节标量,没有字节序问题。 + +| 功能 | 请求帧 | 取值 | 应答 | 当前值来源 | lumi 代码 | +|---|---|---|---|---|---| +| 音量 | `AA 0B 01 crc` | **0~10**(原生校验写的 0~16 是宽松校验,UI 只发 0~10) | `BB 0B 01 64` 成功 / `BB 0B 00` 失败 | `0x15` byte[1] | `BleService.kt:1105`,UI `settings_view.dart:383` Slider(divisions 10) | +| 亮度 | `AA 0A 01 crc` | 0~10 | `BB 0A 01 64` / `BB 0A 00` | `0x15` byte[3] | `BleService.kt:1092`,UI `settings_view.dart:493` | +| 随机表情频次 | `AA 13 01 <秒> crc` | 秒数**原值**:20/30/40/50/60/90/120/150/180 | `BB 13 01 64` / `BB 13 00` | `0x15` byte[2] | `BleService.kt:1259`,UI `settings_view.dart:1423` Slider 吸附到档位 | +| 立即播一个表情 | `AA 06 01 crc` | 0x00 开心 / 01 生气 / 02 伤心 / 03 害怕 / 04 疲惫 / 05 得意 / 06 无聊 / 07 疑惑 / 08 跳舞 / 09 唱歌 | **无应答**(单向) | 无状态 | `ble_manager.dart:1471`,枚举 `chat_controller.dart:35-59` | +| 启停开关 | `AA 0D 01 01/00 crc` | 1 开 / 0 关 | `BB 0D 01 64` / `BB 0D 00` | `0x15` byte[8] | `BleService.kt:1207`,UI `settings_view.dart:697`(文案「启停开关」,常量名 AUTO_POWER_SWITCH) | +| 行车感应开关 | `AA 11 01 01/00 crc` | 1 开 / 0 关 | `BB 11 01 64` / `BB 11 00` | `0x15` byte[11] | `BleService.kt:1216`,UI `settings_view.dart:756`(代码叫「陀螺仪」,文案「行车感应」) | +| 摇摆开关 | `AA 14 01 01/00 crc` | 1 开 / 0 关 | `BB 14 01 64` / `BB 14 00` | `0x15` byte[13] | `BleService.kt:1250`,UI `settings_view.dart:815`(代码叫「电机」,文案「摇摆」) | +| 状态查询 | `AA 15 01 FF crc` | 固定 `0xFF` | `BB 15 0E <14B>` / `BB 15 00` | 自身 | `BleService.kt:1085`;lumi 连上后 1.2 s 自动发一次 | + +### 2.1 `0x15` 汇总帧 14 字节偏移(本工程 `EaiCarAllSettings.parse` 已按此实现,金测试已钉死) + +``` +[0] OTA 异常 [1] 音量 0-10 [2] 随机表情频次(秒) [3] 亮度 0-10 +[4] 电量 0-100 [5..7] 固件 major.minor.patch +[8] 启停开关 [9] 音乐摇头风格 [10] 音乐表情开关 [11] 行车感应(陀螺仪) +[12] 保留 [13] 摇摆(电机) +``` + +### 2.2 四个必须知道的坑 + +1. **六项都没有独立的"读"命令,也没有主动上报**。当前值只能靠 `0x15`;设置成功的应答只回 `0x64` 成功码,**不回显新值**。所以面板的即时反馈只能是"乐观回填",要确认就再发一次 `0x15`。 +2. **"随机表情"在 lumi 里是频次设置,不是触发**。设备没有"现在随机播一个"的命令;要立刻播只能 App 自己随机挑一个 0x00~0x09 走 `0x06`(单向、无应答、无法知道设备是否真播了)。见 §7 待确认第 1 条。 +3. **`0x11` 双向语义不同**:`AA 11` 是行车感应开关;`CC 11 01 AA` 是设备上报「AI 唤醒」。本工程 `eaicar_device_plugin.dart:544` 已按帧头分发,新增发送方向不影响它,但**别把 `0xCC 0x11` 当成开关状态回报**。 +4. **命令必须在 RCSP 认证通过后发**(lumi Android 硬拦 `isAuthPassed`;iOS 只查连接,两端不一致)。本工程 `EaiCarLink.send` 以 `ready`(= 认证通过)为门槛,两端一致,比 lumi 更严,保持。 +5. 文档 `c2_ble_protocol.md` 有两处**错的**:CRC 写成"异或"(实为 CRC-8/MAXIM);`0x15` 应答只列了 5 个字段(实为 14 字节)。以代码为准。 + +## 3. 本工程现状:已有 / 缺失 + +| 功能 | 命令常量 | `invokeFeature` 键 | `0x15` 解析 | 状态透出到业务层 | UI | +|---|---|---|---|---|---| +| 音量 | ✅ `cmdSetVolume 0x0B` | ✅ `eaicar.volume`(`args['value']`) | ✅ | ❌ | ❌ | +| 亮度 | ✅ `cmdControlBrightness 0x0A` | ✅ `eaicar.brightness` | ✅ | ❌ | ❌ | +| 随机表情频次 | ✅ `cmdRandomExpressionInterval 0x13`(仅常量) | ❌ | ✅ | ❌ | ❌ | +| 播一个表情 | ✅ `cmdControlEmotion 0x06` | ✅ `eaicar.emotion` | — | — | ❌ | +| 启停开关 | ✅ `cmdAutoPowerSwitch 0x0D`(仅常量) | ❌ | ✅ | ❌ | ❌ | +| 行车感应 | ✅ `cmdGyroscopeSwitch 0x11`(仅常量) | ❌ | ✅ | ❌ | ❌ | +| 摇摆开关 | ✅ `cmdMotorSwitch 0x14`(仅常量) | ❌ | ✅ | ❌ | ❌ | + +**断点只有一处:`eaicar_device_plugin.dart:525 _applySummary`**。`0x15` 解出的 `EaiCarAllSettings` 里, +它只把固件版本和电量写进 `DeviceInfo`,其余全部丢弃。所以"状态回读"这条链 90% 是现成的, +补一条透出的路即可。 + +设备管理页现状(`my_devices_view.dart:331-516 _buildDeviceCard`):整行没有 `onTap`,只有解绑按钮可点; +行的品类枚举 `_MyDeviceCategory` 没有车载香薰这一档(`devicetype` 服务端已恒为 0,不能用来判断)。 + +## 4. 分层约束(不能违反的两条) + +1. **业务层不认厂商**:`lib/modules|core|data` 不能 import `lib/devices/eaicar/`,不能出现厂商类名 + (`test/devices/device_hub_boundary_test.dart` 守着)。所以面板只能通过 `DeviceHub` 拿 `DeviceSession`, + 用 `invokeFeature` + `eventStream` 说话。 +2. **同一 featureKey 永远发同样字节**,由字节级金测试守着(`device_plugin_interface` 铁律)。 + +由此得出的设计:**面板是厂商无关的"设备设置面板",靠能力位出现,靠设备声明的设置项渲染;EaiCar 插件负责把通用设置键映射成字节。** +将来支架/耳机要露设置项,只需在各自插件里声明,面板零改动。 + +## 5. 方案 + +### 5.1 契约层 `device_plugin_interface`(纯 Dart,加不改) + +```dart +// device_capability.dart +enum DeviceCapability { ..., settingsPanel } // 新增:设备有可读可写的设置项 + +// device_session.dart · DeviceFeatures 新增通用键(app → 设备) +static const settingsQuery = 'settings.query'; // 拉一次全部设置,返回 Map +static const settingsVolume = 'settings.volume'; // {value:int} +static const settingsBrightness = 'settings.brightness'; // {value:int} +static const settingsPower = 'settings.power'; // {on:bool} +static const settingsDrivingSense = 'settings.driving_sense'; // {on:bool} +static const settingsSwing = 'settings.swing'; // {on:bool} +static const settingsRandomExpressionInterval = 'settings.random_expression_interval'; // {seconds:int} +static const settingsPlayRandomExpression = 'settings.play_random_expression'; // 无参,见 §7-1 + +// DeviceFeatureKeys 新增(设备 → app 事件) +static const settingsChanged = 'settings.changed'; // data = 同 settingsQuery 的返回 Map +``` + +`settings.query` 返回值形状(也是 `settings.changed` 事件的 data): + +```dart +{ + 'items': { // 只列设备支持的项,面板按 key 渲染,缺的不画 + 'volume': {'value': 6, 'min': 0, 'max': 10}, + 'brightness': {'value': 8, 'min': 0, 'max': 10}, + 'random_expression_interval': {'value': 30, 'options': [20,30,40,50,60,90,120,150,180]}, + 'power': {'on': true}, + 'driving_sense':{'on': false}, + 'swing': {'on': true}, + 'play_random_expression': {}, // 只表示"支持这个动作" + }, + 'battery': 80, 'firmware': '1.2.3', 'otaAbnormal': false, +} +``` + +现有的 `eaicar.volume` / `eaicar.brightness` / `eaicar.emotion` 三个键**保留不动**(冻结),新键与它们发同样的字节。 + +### 5.2 EaiCar 插件 `lib/devices/eaicar/` + +| 改动 | 位置 | 内容 | +|---|---|---| +| 声明能力 | `eaicar_device_plugin.dart:369` capabilities | 加 `DeviceCapability.settingsPanel` | +| 透出状态 | `:525 _applySummary` | 把整个 `EaiCarAllSettings` 存进 `_settings`,`_emitFeature(DeviceFeatureKeys.settingsChanged, _settingsMap())`。固件/电量逻辑不动 | +| 新特性键 | `:762 invokeFeature` switch | `settings.query` → `send(0x15,[0xFF])` → 解析 → 返回 Map;
`settings.volume` → `send(0x0B,[v])`,v 夹到 0~10;
`settings.brightness` → `send(0x0A,[v])`;
`settings.power` → `send(0x0D,[on?1:0])`;
`settings.driving_sense` → `send(0x11,[on?1:0])`;
`settings.swing` → `send(0x14,[on?1:0])`;
`settings.random_expression_interval` → `send(0x13,[s])`,s 必须在 9 档里,否则 `invalidArgument`;
`settings.play_random_expression` → `send(0x06,[random 0..9], expectResponse:false)` | +| 应答判定 | 同上 | `0x0A/0B/0D/11/13/14` 的应答 `len==0` 视为设备拒绝 → 抛 `DeviceException(operationFailed)`;成功后**本地乐观更新 `_settings` 并发一次 `settings.changed`**(设备不回显值,见 §2.2-1) | +| 协议常量 | `eaicar_protocol.dart` | 不加新命令号(都有了);加 `kRandomExpressionIntervalOptions` 常量与表情 id 枚举注释 | + +`EaiCarLink.send` 按 cmd 号配对应答、同 cmd 并发时复用同一个 Completer(`eaicar_link.dart:389`)—— +滑块连续拖动时靠 UI 侧 300 ms 防抖即可,插件不需要排队。 + +### 5.3 面板 `lib/modules/my_devices/views/widgets/device_settings_sheet.dart`(新,厂商无关) + +- `showModalBottomSheet`,写法照 `lib/modules/meeting/views/bottomSheet/more_bottom_sheet.dart`;开关行样式照 `settings_view.dart:890 _buildToggleSetting`。 +- 打开时: + 1. 用行的 MAC 找会话:遍历 `DeviceHub.to.sessions`,`info.macAddress`/`info.id`/`metadata['localId']` 与 `device.devicemac` 相等(与 `MyDevicesController.isConnected` 同一套判定,抽成公共函数)且 `capabilities.contains(settingsPanel)`。 + 2. 找不到 → 面板显示「设备未连接」+「去连接」(跳 `Routes.eaicarScan`,即品类扫描页),控件全部禁用。 + 3. 找到 → 显示骨架屏,`invokeFeature(DeviceFeatures.settingsQuery)`,3 s 超时;成功按 `items` 渲染,失败显示「读取失败,重试」。 +- 渲染规则:`items` 里有哪个 key 画哪个控件;顺序固定:启停 → 音量 → 亮度 → 行车感应 → 摇摆 → 随机表情频次 → 「随机来一个表情」按钮。 +- 交互: + - 开关:点击立刻翻转(乐观),`invokeFeature` 失败翻回并 `Get.snackbar`。 + - 滑块:`onChanged` 只改本地值,300 ms 防抖后发;`onChangeEnd` 立即发最后一个值。 + - 频次滑块:`min 20 max 180 divisions 8`,吸附到 9 档之一(照 lumi)。 + - 表情按钮:点一次发一次,按钮 1 s 内防连点;不做状态。 +- 订阅 `session.eventStream`,`feature` 事件 key == `settings.changed` 时整体刷新(覆盖乐观值);`connectionStateChanged` 变为断开时切到「设备未连接」态。 +- 顶部显示设备名、电量、固件版本(都在 `settings.query` 返回里,顺手)。 + +### 5.4 设备管理页 `my_devices_view.dart` + +- `_buildDeviceCard` 外包一层 `InkWell`/`GestureDetector`:`onTap` → 若该行"有设置面板"则打开面板,否则无动作。 + 解绑按钮自身的 `GestureDetector` 在子树里先消费事件,不会冒泡到整行(现有结构就是这样,不用改按钮)。 +- "该行有设置面板"的判定:**按能力不按厂商**——找到 MAC 匹配的会话且含 `settingsPanel` 即可点; + 未连接时也允许点(进面板看到「未连接 / 去连接」),判定退化为 `DeviceCategories.matchName(device.devicename) == carAroma` + (`device_category.dart:109` 的 `nameKeywords`,含 `eaicar`/`smartcar`/`车载`/`香薰`)。 + 这一条是唯一按品类判断的地方,写在 data 层 `DeviceCategory` 上,不违反分层。 +- 行右侧加一个 chevron 图标提示可点(仅对可点的行)。 +- 顺手:`_MyDeviceCategory` 加 `carAroma` 一档用于图标兜底(现在落到 `unknown`)。 + +### 5.5 文案 + +走模块自带 translations(`lib/modules/eaicar/module.dart:48` 那张表,11 个语种),不动 41 个语言文件。 +新增键:`deviceSettingsTitle / settingPower / settingVolume / settingBrightness / settingDrivingSense / +settingSwing / settingRandomExpressionInterval / settingPlayRandomExpression / settingReadFailed / +settingApplyFailed / deviceNotConnected / goConnect`,单位「秒」用 `@n` 占位。 +⚠️ 面板本身在 `my_devices` 模块,但文案挂在 `eaicar` 模块下会被 `mergeTranslations` 合进全局表,可用; +若将来第二个厂商也露面板,把这批键挪到全局表。 + +### 5.6 测试 + +| 测试 | 内容 | +|---|---| +| `test/devices/eaicar_protocol_test.dart` | 新增金样本:`AA 0D 01 01`、`AA 0D 01 00`、`AA 11 01 01`、`AA 14 01 00`、`AA 13 01 3C`(60 秒)、`AA 15 01 FF`;随机表情 `AA 06 01 xx` 的 xx 必在 0x00~0x09 | +| `test/devices/eaicar_session_features_test.dart`(新,照 `bes_session_commands_test.dart`) | 每个 `settings.*` 键 → 字节逐条断言;`settings.random_expression_interval` 传 45 抛 `invalidArgument`;`settings.query` 返回 Map 的形状 | +| `test/devices/eaicar_protocol_test.dart` 0x15 段 | 已有,补一条"`_applySummary` 后 `settings.changed` 事件 data 与 14 字节一一对应" | +| `test/devices/device_hub_boundary_test.dart` | 现有,面板与 my_devices 改动不能让它红 | +| `test/module_registry_test.dart` | 新文案键至少有 zh_CN / en_US | + +### 5.7 真机验证清单(按风险排) + +1. `0x0D` 启停:常量注释标着「会真改设备状态、别试探」——**第一次发之前确认 0 是"关闭自动开关机"不是"关机"**。先在 `0x15` 里看当前值,再发同值,确认应答 `0x64`,再翻转。 +2. `0x11` 开关翻转后 `0x15` byte[11] 是否跟着变;同时验证翻转期间 `CC 11` 唤醒上报不受影响。 +3. `0x14` 摇摆开关翻转后设备有无实际动作;与 `0x0E` 遥控电机、`0x12` 摇头风格无冲突。 +4. `0x13` 发非 9 档值(如 45)设备回什么,决定要不要在插件层强校验(设计上已校验)。 +5. 音量发 11~16 设备回什么,确认 0~10 上限。 +6. `0x06` 随机表情:确认播放时设备状态(是否打断当前动作、AI 会话中是否忽略)。 +7. 面板打开时机:连上后 `_handshake` 已发过一次 `0x15`,面板再发一次,确认同 cmd 并发下 Completer 复用不会把第一次的结果错配给第二次。 + +## 6. 改动清单(预估) + +| 层 | 文件 | 改动 | +|---|---|---| +| 契约 | `local_plugins/device_plugin_interface/lib/src/device_capability.dart`、`device_session.dart` | +1 能力位,+8 特性键,+1 事件键 | +| 插件 | `lib/devices/eaicar/eaicar_device_plugin.dart` | `_applySummary` 透出、`invokeFeature` +8 分支、乐观更新 | +| 插件 | `lib/devices/eaicar/eaicar_protocol.dart` | +档位常量、表情枚举注释 | +| 业务 | `lib/modules/my_devices/views/widgets/device_settings_sheet.dart` | 新文件,约 300 行 | +| 业务 | `lib/modules/my_devices/views/my_devices_view.dart` | 行 onTap、chevron、品类档 | +| 业务 | `lib/modules/my_devices/controllers/my_devices_controller.dart` | `sessionFor(device)` 公共函数(从 `isConnected` 抽出) | +| 数据 | `lib/data/models/device_category.dart` | `matchName(String)` 小函数(复用 `nameKeywords`) | +| 文案 | `lib/modules/eaicar/module.dart` | +12 键 × 11 语种 | +| 测试 | `test/devices/*` | 见 §5.6 | +| 文档 | `CLAUDE.md` | 加一小节:设置面板按能力出现、`0x11` 双向语义、`0x0D` 验证口径 | + +不动的:原生插件 `device_smartcar`(字节由 Dart 组,原生只透传 `writeCustom`)、`EaiCarLink`、协议帧格式、iOS/Android 原生代码。 + +## 7. 待你拍板的问题 + +1. **「随机表情」到底要哪个**: + - A. 频次滑块(设备原生能力,20~180 秒,与 lumi 一致); + - B. 「随机来一个」按钮(App 随机挑一个表情发 `0x06`,设备无此原生命令,发出去也没应答); + - C. 两个都要。 + 本文按 **C** 写,默认按钮放在频次滑块下方。 +2. **「启停开关」的语义**:lumi 常量叫"自动开关机",UI 文案叫"启停开关"。面板文案照 lumi 用「启停开关」,副标题写「自动开关机」,还是要另一种说法? +3. **未连接时的行为**:按本文是"能点进去、看到未连接、给去连接按钮"。另一种是未连接的行干脆不可点。 +4. **要不要顺带露出 `0x12` 音乐摇头风格、`0x0E` 遥控电机**:lumi 设置页有,本文没放(你没列)。放的话多两行,插件多两个键。 +5. **面板归属**:本文放在 `my_devices` 模块(厂商无关)。如果你更希望它是 EaiCar 模块自己的页面(走路由、全屏),改成 `lib/modules/eaicar/views/eaicar_settings_view.dart`,设备管理页只负责跳转。 diff --git a/docs/Smartcar车载香薰-接入设计与落地文档.md b/docs/Smartcar车载香薰-接入设计与落地文档.md index b3b7f82a..c4a927de 100644 --- a/docs/Smartcar车载香薰-接入设计与落地文档.md +++ b/docs/Smartcar车载香薰-接入设计与落地文档.md @@ -3,7 +3,10 @@ > 2026-09-09 起草 → 2026-09-10 按客户端架构重构大幅修订 → 2026-09-10 **决策定稿**。 > 除命令表需真机复核外,实现方案已全部确定,可以开工。 > 对应产品:后台已登记的 `Smartcar`,客户端品类 `DeviceCategories.carAroma`,厂商键 `DeviceVendors.smartcar`。 -> 参考源工程:`/Users/yunyanzhineng/work/src/lumi`(Lumi「智能车载AI精灵」App,同一颗方案)。 +> 参考源工程:`/Users/yunyanzhineng/work/liangchuang/lumi`(Lumi「智能车载AI精灵」App,同一颗方案; +> 2026-09-22 起改用这个路径,原 `~/work/src/lumi` 已不存在)。 +> ⚠️ 那边的插件已改名:本文写的 `local_plugins/lumi_device` 现在是 `local_plugins/voibots_device` +> (工程 pubspec name 也变成 `voibots`),`BleProtocol.kt/.swift`、`BleConst.kt` 仍在,按新目录找。 > **本文只做设计,未改任何代码。** ---