Browse Source
「设备 → 设备管理」里 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
12 changed files with 1466 additions and 8 deletions
@ -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, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -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); |
|||
}); |
|||
} |
|||
@ -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`,设备管理页只负责跳转。 |
|||
Loading…
Reference in new issue