Browse Source

client: EaiCar 设备设置面板(按能力出现、按设备回的 items 渲染)

「设备 → 设备管理」里 EaiCar 那一行整块可点(解绑按钮除外),弹底部面板拉 0x15
显示并可改:启停 0x0D / 音量 0x0B / 亮度 0x0A / 行车感应 0x11 / 摇摆 0x14 /
随机表情频次 0x13 / 随机播一个表情 0x06。

- 面板是厂商无关的:只认 DeviceCapability.settingsPanel 与 DeviceFeatures.settings*
  通用键,按 settings.query 返回的 items 画控件,缺哪项不画哪项。EaiCar 插件把
  通用键映射成字节(EaiCarSession.settingsCommand,纯函数,金测试逐字节钉死)。
  以后耳机/支架要露设置,各自插件声明即可。
- 状态只有 0x15 一个来源:六条写命令只回 0x64 成功码,不回显新值也没有主动上报。
  插件写成功后本地回填并派 settings.changed;面板从不自己推断状态。原先
  _applySummary 解完 14 字节只留固件和电量,这次整份留下。
- 0x11 双向语义不同:AA 11 是行车感应开关,CC 11 是设备上报 AI 唤醒,按帧头分发。
- 「随机表情」设备只有频次(9 档秒数);「现在随机播一个」没有原生命令,
  由 app 在 0x00~0x09 里挑一个发 0x06。
- 未连接的 EaiCar 行也可点(按设备名猜品类),进去是「未连接 / 去连接」;
  其它品类的行 onTap 仍为 null,行为一字不差。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
6f1686a8fd
  1. 16
      apps/client/lib/data/models/device_category.dart
  2. 170
      apps/client/lib/devices/eaicar/eaicar_device_plugin.dart
  3. 69
      apps/client/lib/devices/eaicar/eaicar_protocol.dart
  4. 165
      apps/client/lib/modules/eaicar/module.dart
  5. 29
      apps/client/lib/modules/my_devices/controllers/my_devices_controller.dart
  6. 23
      apps/client/lib/modules/my_devices/views/my_devices_view.dart
  7. 584
      apps/client/lib/modules/my_devices/views/widgets/device_settings_sheet.dart
  8. 8
      apps/client/local_plugins/device_plugin_interface/lib/src/device_capability.dart
  9. 49
      apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart
  10. 137
      apps/client/test/devices/eaicar_settings_features_test.dart
  11. 219
      docs/EaiCar设备控制面板-落地文档.md
  12. 5
      docs/Smartcar车载香薰-接入设计与落地文档.md

16
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<DBProduct> productsOf(DeviceCategory category) {
if (!AppConfig.isInitialized()) return const [];

170
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<int> payload, bool expectResponse})? settingsCommand(
String key,
Map<String, Object?> 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<Map<String, Object?>> _applySettingsCommand(
String key, Map<String, Object?> 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<int> 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]);

69
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<int> 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<String, Object?> 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': <String, Object?>{},
},
'battery': battery,
'firmware': firmwareVersion,
'otaAbnormal': otaAbnormal,
};
}

165
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': 'Показать случайную эмоцию',
},
},
);

29
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;
}
/// 本页已去掉「添加设备」入口,跳品类选择页

23
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<MyDevicesController> {
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<MyDevicesController> {
],
),
),
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<MyDevicesController> {
],
],
),
),
);
}

584
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<void> 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<DeviceSettingsSheet> createState() => _DeviceSettingsSheetState();
}
class _DeviceSettingsSheetState extends State<DeviceSettingsSheet> {
static const String _tag = 'DeviceSettingsSheet';
static const Duration _queryTimeout = Duration(seconds: 3);
static const Duration _sliderDebounce = Duration(milliseconds: 300);
DeviceSession? _session;
StreamSubscription<DeviceSessionEvent>? _eventSub;
Worker? _revisionWorker;
/// `settings.query` 返回的 items;null = 还没拿到
Map<String, Object?>? _items;
int? _battery;
String? _firmware;
bool _loading = false;
String? _error;
/// 正在写的键,用来禁掉对应控件防连点
final Set<String> _busy = {};
/// 滑块的本地值(拖动中先改这个,防抖后再发)
final Map<String, int> _sliderLocal = {};
final Map<String, Timer> _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<MyDevicesController>();
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<String, Object?> 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<void> _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<bool> _write(String key, Map<String, Object?> 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<void> _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<void> _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 = <String>[];
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 = <Widget>[];
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<bool> 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<double> onChanged,
required ValueChanged<double> 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,
),
),
],
),
);
}
}

8
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,
}

49
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] 的取值。

137
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<int> frame(String key, Map<String, Object?> args) {
final c = EaiCarSession.settingsCommand(key, args,
random: (max) => 0x08 /* 固定挑"跳舞",只为字节可复现 */)!;
return EaiCarProtocol.buildPacket(c.cmd, c.payload);
}
List<int> withCrc(List<int> 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<String, Object?> args) {
expect(
() => EaiCarSession.settingsCommand(key, args),
throwsA(isA<DeviceException>()
.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);
});
}

219
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 <v> 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 <v> 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 <id> 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;<br>`settings.volume` → `send(0x0B,[v])`,v 夹到 0~10;<br>`settings.brightness` → `send(0x0A,[v])`;<br>`settings.power` → `send(0x0D,[on?1:0])`;<br>`settings.driving_sense` → `send(0x11,[on?1:0])`;<br>`settings.swing` → `send(0x14,[on?1:0])`;<br>`settings.random_expression_interval` → `send(0x13,[s])`,s 必须在 9 档里,否则 `invalidArgument`;<br>`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`,设备管理页只负责跳转。

5
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` 仍在,按新目录找。
> **本文只做设计,未改任何代码。**
---

Loading…
Cancel
Save