Browse Source

client: 车载香薰 Dart 侧整体改名 Smartcar → EaiCar

目录、类名、文案键、路由、测试全部改成 EaiCar。刻意保留:原生插件 device_smartcar、
持久化厂商键 'smartcar'、模块 id 'smartcarChat'、GetStorage 两个键(改了会丢老装机的编排/白名单)。
广播名准入白名单本来就只认 EaiCar,旧名 Smartcar 在测试里仍作为被拒绝的样本保留。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
c1dcfcfc60
  1. 6
      CLAUDE.md
  2. 12
      apps/client/lib/core/translations/language/ar_sa.dart
  3. 12
      apps/client/lib/core/translations/language/bg_bg.dart
  4. 12
      apps/client/lib/core/translations/language/cs_cz.dart
  5. 12
      apps/client/lib/core/translations/language/da_dk.dart
  6. 12
      apps/client/lib/core/translations/language/de_de.dart
  7. 12
      apps/client/lib/core/translations/language/el_gr.dart
  8. 12
      apps/client/lib/core/translations/language/en_us.dart
  9. 12
      apps/client/lib/core/translations/language/es_es.dart
  10. 12
      apps/client/lib/core/translations/language/et_ee.dart
  11. 12
      apps/client/lib/core/translations/language/fa_ir.dart
  12. 12
      apps/client/lib/core/translations/language/fi_fi.dart
  13. 12
      apps/client/lib/core/translations/language/fr_fr.dart
  14. 12
      apps/client/lib/core/translations/language/hi_in.dart
  15. 12
      apps/client/lib/core/translations/language/hr_hr.dart
  16. 12
      apps/client/lib/core/translations/language/hu_hu.dart
  17. 12
      apps/client/lib/core/translations/language/id_id.dart
  18. 12
      apps/client/lib/core/translations/language/is_is.dart
  19. 12
      apps/client/lib/core/translations/language/it_it.dart
  20. 12
      apps/client/lib/core/translations/language/ja_jp.dart
  21. 12
      apps/client/lib/core/translations/language/ko_kr.dart
  22. 12
      apps/client/lib/core/translations/language/lt_lt.dart
  23. 12
      apps/client/lib/core/translations/language/lv_lv.dart
  24. 12
      apps/client/lib/core/translations/language/ms_my.dart
  25. 12
      apps/client/lib/core/translations/language/nb_no.dart
  26. 12
      apps/client/lib/core/translations/language/nl_nl.dart
  27. 12
      apps/client/lib/core/translations/language/pl_pl.dart
  28. 12
      apps/client/lib/core/translations/language/pt_pt.dart
  29. 12
      apps/client/lib/core/translations/language/ro_ro.dart
  30. 12
      apps/client/lib/core/translations/language/ru_ru.dart
  31. 12
      apps/client/lib/core/translations/language/sk_sk.dart
  32. 12
      apps/client/lib/core/translations/language/sl_si.dart
  33. 12
      apps/client/lib/core/translations/language/sr_rs.dart
  34. 12
      apps/client/lib/core/translations/language/sv_se.dart
  35. 12
      apps/client/lib/core/translations/language/th_th.dart
  36. 12
      apps/client/lib/core/translations/language/tl_ph.dart
  37. 12
      apps/client/lib/core/translations/language/tr_tr.dart
  38. 12
      apps/client/lib/core/translations/language/uk_ua.dart
  39. 12
      apps/client/lib/core/translations/language/vi_vn.dart
  40. 12
      apps/client/lib/core/translations/language/zh_cn.dart
  41. 12
      apps/client/lib/core/translations/language/zh_hk.dart
  42. 12
      apps/client/lib/core/translations/language/zh_tw.dart
  43. 12
      apps/client/lib/data/models/agent_module.dart
  44. 2
      apps/client/lib/data/models/assistant_directive.dart
  45. 2
      apps/client/lib/data/models/device_category.dart
  46. 2
      apps/client/lib/data/services/agent_module_defaults.dart
  47. 4
      apps/client/lib/data/services/agent_module_service.dart
  48. 2
      apps/client/lib/data/services/bailian_multimodal_service.dart
  49. 4
      apps/client/lib/data/services/device_ai_session_service.dart
  50. 4
      apps/client/lib/data/utils/emai_audio_session.dart
  51. 16
      apps/client/lib/devices/device_auth.dart
  52. 6
      apps/client/lib/devices/device_bootstrap.dart
  53. 4
      apps/client/lib/devices/device_vendors.dart
  54. 12
      apps/client/lib/devices/eaicar/eaicar_device_auth.dart
  55. 206
      apps/client/lib/devices/eaicar/eaicar_device_plugin.dart
  56. 72
      apps/client/lib/devices/eaicar/eaicar_link.dart
  57. 18
      apps/client/lib/devices/eaicar/eaicar_ota_port.dart
  58. 54
      apps/client/lib/devices/eaicar/eaicar_protocol.dart
  59. 6
      apps/client/lib/modules/devices/views/device_category_view.dart
  60. 36
      apps/client/lib/modules/devices/views/eaicar_scan_view.dart
  61. 24
      apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart
  62. 130
      apps/client/lib/modules/eaicar/module.dart
  63. 2
      apps/client/lib/modules/emai/controllers/emai_controller.dart
  64. 2
      apps/client/lib/modules/emai/views/emai_view.dart
  65. 4
      apps/client/lib/modules/module_registry.dart
  66. 130
      apps/client/lib/modules/smartcar/module.dart
  67. 4
      apps/client/lib/routes/app_pages.dart
  68. 4
      apps/client/lib/routes/app_routes.dart
  69. 4
      apps/client/test/agent_module_defaults_test.dart
  70. 2
      apps/client/test/assistant_directive_test.dart
  71. 2
      apps/client/test/bailian_native_parity_test.dart
  72. 2
      apps/client/test/devices/device_hub_boundary_test.dart
  73. 30
      apps/client/test/devices/eaicar_name_test.dart
  74. 110
      apps/client/test/devices/eaicar_protocol_test.dart
  75. 30
      apps/client/test/devices/smartcar_name_test.dart
  76. 4
      apps/client/test/module_registry_test.dart

6
CLAUDE.md

@ -1661,8 +1661,12 @@ google 的 aab 需要单独出)。原来靠打包时写进 `.env` 的 `Channel
纯 Dart 只订阅 `ABC0`,真机上"扫得到、连得上、设备说没连"。设计与踩坑记录见 纯 Dart 只订阅 `ABC0`,真机上"扫得到、连得上、设备说没连"。设计与踩坑记录见
[docs/Smartcar车载香薰-接入设计与落地文档.md](docs/Smartcar车载香薰-接入设计与落地文档.md) 5.5。 [docs/Smartcar车载香薰-接入设计与落地文档.md](docs/Smartcar车载香薰-接入设计与落地文档.md) 5.5。
- **2026-09-22 Dart 侧整体改名 Smartcar → EaiCar**(目录 `lib/devices/eaicar/`、`lib/modules/eaicar/`、类名/文案键/路由)。
**刻意不改的**:原生插件名 `device_smartcar`(Kotlin 包 `com.yimai.device_smartcar`、Swift 包)、持久化的厂商键
`DeviceVendors.eaicar = 'smartcar'`、已铺进装机编排的模块 id `AgentModules.eaicarChat = 'smartcarChat'`、
GetStorage 键 `smartcar_authorized_macs` / `smartcar_remembered_devices`。改这些值会让老装机的编排/白名单/记住的设备全部丢失。
- 链路在原生插件 `apps/client/local_plugins/device_smartcar`(自持 GATT,同一条连接复用 AE00 + ABC0), - 链路在原生插件 `apps/client/local_plugins/device_smartcar`(自持 GATT,同一条连接复用 AE00 + ABC0),
Dart 侧 `lib/devices/smartcar/` 只做组帧/广播解析/DevicePlugin 适配。 Dart 侧 `lib/devices/eaicar/` 只做组帧/广播解析/DevicePlugin 适配。
- ⚠️ iOS 的 `JL_BLEKit` 是**动态库**,只能有一份:由 `device_jieli` 的 `Package.swift` 以 `jl-blekit` - ⚠️ iOS 的 `JL_BLEKit` 是**动态库**,只能有一份:由 `device_jieli` 的 `Package.swift` 以 `jl-blekit`
产品暴露,`device_smartcar` 按路径依赖。再 vendor 一份会「Class implemented in both」。 产品暴露,`device_smartcar` 按路径依赖。再 vendor 一份会「Class implemented in both」。
- ⚠️ Android 若搬 lumi 的认证(`jl_rcsp`/`jl_watch`/`jl_bluetooth_connect` aar),**不要再放 - ⚠️ Android 若搬 lumi 的认证(`jl_rcsp`/`jl_watch`/`jl_bluetooth_connect` aar),**不要再放

12
apps/client/lib/core/translations/language/ar_sa.dart

@ -2059,12 +2059,12 @@ const Map<String, String> arSA = {
'inCallBusyDesc': 'الميكروفون ومسار الصوت مشغولان بالمكالمة. حاول مرة أخرى بعد إنهاء المكالمة.', 'inCallBusyDesc': 'الميكروفون ومسار الصوت مشغولان بالمكالمة. حاول مرة أخرى بعد إنهاء المكالمة.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'مجاني', 'freeSectionTitle': 'مجاني',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/bg_bg.dart

@ -2122,12 +2122,12 @@ const Map<String, String> bgBG = {
'inCallBusyDesc': 'Микрофонът и аудио каналът се използват от разговора. Опитайте отново след затваряне на разговора.', 'inCallBusyDesc': 'Микрофонът и аудио каналът се използват от разговора. Опитайте отново след затваряне на разговора.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Безплатно', 'freeSectionTitle': 'Безплатно',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/cs_cz.dart

@ -2090,12 +2090,12 @@ const Map<String, String> csCZ = {
'inCallBusyDesc': 'Mikrofon a zvukovou cestu používá hovor. Zkuste to znovu po zavěšení.', 'inCallBusyDesc': 'Mikrofon a zvukovou cestu používá hovor. Zkuste to znovu po zavěšení.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Zdarma', 'freeSectionTitle': 'Zdarma',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/da_dk.dart

@ -2092,12 +2092,12 @@ const Map<String, String> daDK = {
'inCallBusyDesc': 'Mikrofonen og lydvejen bruges af opkaldet. Prøv igen, når du har lagt på.', 'inCallBusyDesc': 'Mikrofonen og lydvejen bruges af opkaldet. Prøv igen, når du har lagt på.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis', 'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/de_de.dart

@ -2159,12 +2159,12 @@ const Map<String, String> deDE = {
'notNow': 'Nicht jetzt', 'notNow': 'Nicht jetzt',
'permanentlyDisallow': 'Nicht erlauben', 'permanentlyDisallow': 'Nicht erlauben',
'otaBesNotSupported': 'Dieses Headset unterstützt das Firmware-Upgrade in der App noch nicht. Bitte wenden Sie sich an den Support.', 'otaBesNotSupported': 'Dieses Headset unterstützt das Firmware-Upgrade in der App noch nicht. Bitte wenden Sie sich an den Support.',
'smartcarConnected': 'Verbunden', 'eaicarConnected': 'Verbunden',
'smartcarConnectFailed': 'Verbindung fehlgeschlagen', 'eaicarConnectFailed': 'Verbindung fehlgeschlagen',
'smartcarConnecting': 'Verbinde…', 'eaicarConnecting': 'Verbinde…',
'smartcarSearching': 'Suche nach Auto-Diffusoren in der Nähe…', 'eaicarSearching': 'Suche nach Auto-Diffusoren in der Nähe…',
'smartcarNotFound': 'Kein Gerät gefunden', 'eaicarNotFound': 'Kein Gerät gefunden',
'smartcarScanHint': 'Stellen Sie sicher, dass das Gerät eingeschaltet und in der Nähe ist', 'eaicarScanHint': 'Stellen Sie sicher, dass das Gerät eingeschaltet und in der Nähe ist',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/el_gr.dart

@ -2126,12 +2126,12 @@ const Map<String, String> elGR = {
'inCallBusyDesc': 'Το μικρόφωνο και η διαδρομή ήχου χρησιμοποιούνται από την κλήση. Δοκιμάστε ξανά αφού τερματίσετε την κλήση.', 'inCallBusyDesc': 'Το μικρόφωνο και η διαδρομή ήχου χρησιμοποιούνται από την κλήση. Δοκιμάστε ξανά αφού τερματίσετε την κλήση.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Δωρεάν', 'freeSectionTitle': 'Δωρεάν',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/en_us.dart

@ -2115,12 +2115,12 @@ const Map<String, String> enUS = {
'unmarkFavorite': 'Remove from Favorites', 'unmarkFavorite': 'Remove from Favorites',
'unnamedDevice': 'Unnamed Device', 'unnamedDevice': 'Unnamed Device',
'userDataParseFailed': 'Failed to parse user data', 'userDataParseFailed': 'Failed to parse user data',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/es_es.dart

@ -2153,12 +2153,12 @@ const Map<String, String> esES = {
'notNow': 'Ahora no', 'notNow': 'Ahora no',
'permanentlyDisallow': 'No permitir', 'permanentlyDisallow': 'No permitir',
'otaBesNotSupported': 'Estos auriculares aún no admiten la actualización de firmware desde la app. Ponte en contacto con el soporte.', 'otaBesNotSupported': 'Estos auriculares aún no admiten la actualización de firmware desde la app. Ponte en contacto con el soporte.',
'smartcarConnected': 'Conectado', 'eaicarConnected': 'Conectado',
'smartcarConnectFailed': 'Error de conexión', 'eaicarConnectFailed': 'Error de conexión',
'smartcarConnecting': 'Conectando…', 'eaicarConnecting': 'Conectando…',
'smartcarSearching': 'Buscando difusores de coche cercanos…', 'eaicarSearching': 'Buscando difusores de coche cercanos…',
'smartcarNotFound': 'No se encontró ningún dispositivo', 'eaicarNotFound': 'No se encontró ningún dispositivo',
'smartcarScanHint': 'Asegúrate de que el dispositivo esté encendido y cerca del teléfono', 'eaicarScanHint': 'Asegúrate de que el dispositivo esté encendido y cerca del teléfono',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/et_ee.dart

@ -2086,12 +2086,12 @@ const Map<String, String> etEE = {
'inCallBusyDesc': 'Mikrofon ja heliliin on kõne kasutuses. Proovi uuesti pärast kõne lõpetamist.', 'inCallBusyDesc': 'Mikrofon ja heliliin on kõne kasutuses. Proovi uuesti pärast kõne lõpetamist.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Tasuta', 'freeSectionTitle': 'Tasuta',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/fa_ir.dart

@ -2077,12 +2077,12 @@ const Map<String, String> faIR = {
'inCallBusyDesc': 'میکروفون و مسیر صدا در اختیار تماس است. پس از پایان تماس دوباره تلاش کنید.', 'inCallBusyDesc': 'میکروفون و مسیر صدا در اختیار تماس است. پس از پایان تماس دوباره تلاش کنید.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'رایگان', 'freeSectionTitle': 'رایگان',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/fi_fi.dart

@ -2096,12 +2096,12 @@ const Map<String, String> fiFI = {
'inCallBusyDesc': 'Mikrofoni ja äänireitti ovat puhelun käytössä. Yritä uudelleen puhelun päätyttyä.', 'inCallBusyDesc': 'Mikrofoni ja äänireitti ovat puhelun käytössä. Yritä uudelleen puhelun päätyttyä.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ilmainen', 'freeSectionTitle': 'Ilmainen',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/fr_fr.dart

@ -2172,12 +2172,12 @@ const Map<String, String> frFR = {
'notNow': 'Pas maintenant', 'notNow': 'Pas maintenant',
'permanentlyDisallow': 'Ne pas autoriser', 'permanentlyDisallow': 'Ne pas autoriser',
'otaBesNotSupported': 'Ce casque ne prend pas encore en charge la mise à jour du firmware depuis l\'application. Veuillez contacter le support.', 'otaBesNotSupported': 'Ce casque ne prend pas encore en charge la mise à jour du firmware depuis l\'application. Veuillez contacter le support.',
'smartcarConnected': 'Connecté', 'eaicarConnected': 'Connecté',
'smartcarConnectFailed': 'Échec de la connexion', 'eaicarConnectFailed': 'Échec de la connexion',
'smartcarConnecting': 'Connexion…', 'eaicarConnecting': 'Connexion…',
'smartcarSearching': 'Recherche de diffuseurs auto à proximité…', 'eaicarSearching': 'Recherche de diffuseurs auto à proximité…',
'smartcarNotFound': 'Aucun appareil trouvé', 'eaicarNotFound': 'Aucun appareil trouvé',
'smartcarScanHint': 'Vérifiez que l’appareil est allumé et proche du téléphone', 'eaicarScanHint': 'Vérifiez que l’appareil est allumé et proche du téléphone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/hi_in.dart

@ -2095,12 +2095,12 @@ const Map<String, String> hiIN = {
'inCallBusyDesc': 'माइक और ऑडियो पाथ कॉल द्वारा उपयोग में हैं। कॉल समाप्त करने के बाद पुनः प्रयास करें।', 'inCallBusyDesc': 'माइक और ऑडियो पाथ कॉल द्वारा उपयोग में हैं। कॉल समाप्त करने के बाद पुनः प्रयास करें।',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'मुफ़्त', 'freeSectionTitle': 'मुफ़्त',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/hr_hr.dart

@ -2111,12 +2111,12 @@ const Map<String, String> hrHR = {
'inCallBusyDesc': 'Mikrofon i audio put koristi poziv. Pokušajte ponovno nakon prekida poziva.', 'inCallBusyDesc': 'Mikrofon i audio put koristi poziv. Pokušajte ponovno nakon prekida poziva.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Besplatno', 'freeSectionTitle': 'Besplatno',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/hu_hu.dart

@ -2114,12 +2114,12 @@ const Map<String, String> huHU = {
'inCallBusyDesc': 'A mikrofont és a hangútvonalat a hívás használja. Próbálja újra a hívás befejezése után.', 'inCallBusyDesc': 'A mikrofont és a hangútvonalat a hívás használja. Próbálja újra a hívás befejezése után.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ingyenes', 'freeSectionTitle': 'Ingyenes',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/id_id.dart

@ -2095,12 +2095,12 @@ const Map<String, String> idID = {
'inCallBusyDesc': 'Mikrofon dan jalur audio sedang digunakan oleh panggilan. Coba lagi setelah menutup panggilan.', 'inCallBusyDesc': 'Mikrofon dan jalur audio sedang digunakan oleh panggilan. Coba lagi setelah menutup panggilan.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis', 'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/is_is.dart

@ -2092,12 +2092,12 @@ const Map<String, String> isIS = {
'inCallBusyDesc': 'Hljóðneminn og hljóðleiðin eru í notkun í símtalinu. Reyndu aftur eftir að þú leggur á.', 'inCallBusyDesc': 'Hljóðneminn og hljóðleiðin eru í notkun í símtalinu. Reyndu aftur eftir að þú leggur á.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ókeypis', 'freeSectionTitle': 'Ókeypis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/it_it.dart

@ -2133,12 +2133,12 @@ const Map<String, String> itIT = {
'inCallBusyDesc': 'Il microfono e il percorso audio sono in uso dalla chiamata. Riprova dopo aver riagganciato.', 'inCallBusyDesc': 'Il microfono e il percorso audio sono in uso dalla chiamata. Riprova dopo aver riagganciato.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis', 'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/ja_jp.dart

@ -1954,12 +1954,12 @@ const Map<String, String> jaJP = {
'notNow': '今はしない', 'notNow': '今はしない',
'permanentlyDisallow': '許可しない', 'permanentlyDisallow': '許可しない',
'otaBesNotSupported': 'このイヤホンはアプリ内でのファームウェアアップグレードにまだ対応していません。サポートにお問い合わせください。', 'otaBesNotSupported': 'このイヤホンはアプリ内でのファームウェアアップグレードにまだ対応していません。サポートにお問い合わせください。',
'smartcarConnected': '接続しました', 'eaicarConnected': '接続しました',
'smartcarConnectFailed': '接続に失敗しました', 'eaicarConnectFailed': '接続に失敗しました',
'smartcarConnecting': '接続中…', 'eaicarConnecting': '接続中…',
'smartcarSearching': '近くの車載アロマを検索中…', 'eaicarSearching': '近くの車載アロマを検索中…',
'smartcarNotFound': 'デバイスが見つかりません', 'eaicarNotFound': 'デバイスが見つかりません',
'smartcarScanHint': '電源が入っていること、スマホの近くにあることを確認してください', 'eaicarScanHint': '電源が入っていること、スマホの近くにあることを確認してください',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/ko_kr.dart

@ -1947,12 +1947,12 @@ const Map<String, String> koKR = {
'notNow': '나중에', 'notNow': '나중에',
'permanentlyDisallow': '허용 안 함', 'permanentlyDisallow': '허용 안 함',
'otaBesNotSupported': '이 이어폰은 아직 앱 내 펌웨어 업그레이드를 지원하지 않습니다. 고객 지원에 문의해 주세요.', 'otaBesNotSupported': '이 이어폰은 아직 앱 내 펌웨어 업그레이드를 지원하지 않습니다. 고객 지원에 문의해 주세요.',
'smartcarConnected': '연결됨', 'eaicarConnected': '연결됨',
'smartcarConnectFailed': '연결 실패', 'eaicarConnectFailed': '연결 실패',
'smartcarConnecting': '연결 중…', 'eaicarConnecting': '연결 중…',
'smartcarSearching': '주변 차량용 방향제 검색 중…', 'eaicarSearching': '주변 차량용 방향제 검색 중…',
'smartcarNotFound': '기기를 찾을 수 없습니다', 'eaicarNotFound': '기기를 찾을 수 없습니다',
'smartcarScanHint': '기기 전원이 켜져 있고 휴대폰 근처에 있는지 확인하세요', 'eaicarScanHint': '기기 전원이 켜져 있고 휴대폰 근처에 있는지 확인하세요',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/lt_lt.dart

@ -2110,12 +2110,12 @@ const Map<String, String> ltLT = {
'inCallBusyDesc': 'Mikrofoną ir garso kelią naudoja skambutis. Bandykite dar kartą padėję ragelį.', 'inCallBusyDesc': 'Mikrofoną ir garso kelią naudoja skambutis. Bandykite dar kartą padėję ragelį.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Nemokama', 'freeSectionTitle': 'Nemokama',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/lv_lv.dart

@ -2115,12 +2115,12 @@ const Map<String, String> lvLV = {
'inCallBusyDesc': 'Mikrofonu un audio ceļu izmanto zvans. Mēģiniet vēlreiz pēc zvana beigšanas.', 'inCallBusyDesc': 'Mikrofonu un audio ceļu izmanto zvans. Mēģiniet vēlreiz pēc zvana beigšanas.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Bezmaksas', 'freeSectionTitle': 'Bezmaksas',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/ms_my.dart

@ -2099,12 +2099,12 @@ const Map<String, String> msMY = {
'inCallBusyDesc': 'Mikrofon dan laluan audio sedang digunakan oleh panggilan. Cuba lagi selepas menamatkan panggilan.', 'inCallBusyDesc': 'Mikrofon dan laluan audio sedang digunakan oleh panggilan. Cuba lagi selepas menamatkan panggilan.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Percuma', 'freeSectionTitle': 'Percuma',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/nb_no.dart

@ -2093,12 +2093,12 @@ const Map<String, String> nbNO = {
'inCallBusyDesc': 'Mikrofonen og lydbanen brukes av samtalen. Prøv igjen etter at du har lagt på.', 'inCallBusyDesc': 'Mikrofonen og lydbanen brukes av samtalen. Prøv igjen etter at du har lagt på.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis', 'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/nl_nl.dart

@ -2147,12 +2147,12 @@ const Map<String, String> nlNL = {
'inCallBusyDesc': 'De microfoon en het audiopad worden gebruikt door het gesprek. Probeer het opnieuw na het ophangen.', 'inCallBusyDesc': 'De microfoon en het audiopad worden gebruikt door het gesprek. Probeer het opnieuw na het ophangen.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis', 'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/pl_pl.dart

@ -2121,12 +2121,12 @@ const Map<String, String> plPL = {
'inCallBusyDesc': 'Mikrofon i tor audio są używane przez rozmowę. Spróbuj ponownie po rozłączeniu.', 'inCallBusyDesc': 'Mikrofon i tor audio są używane przez rozmowę. Spróbuj ponownie po rozłączeniu.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Bezpłatne', 'freeSectionTitle': 'Bezpłatne',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/pt_pt.dart

@ -2159,12 +2159,12 @@ const Map<String, String> ptPT = {
'notNow': 'Agora não', 'notNow': 'Agora não',
'permanentlyDisallow': 'Não permitir', 'permanentlyDisallow': 'Não permitir',
'otaBesNotSupported': 'Estes auscultadores ainda não suportam a atualização de firmware na app. Contacte o suporte.', 'otaBesNotSupported': 'Estes auscultadores ainda não suportam a atualização de firmware na app. Contacte o suporte.',
'smartcarConnected': 'Ligado', 'eaicarConnected': 'Ligado',
'smartcarConnectFailed': 'Falha na ligação', 'eaicarConnectFailed': 'Falha na ligação',
'smartcarConnecting': 'A ligar…', 'eaicarConnecting': 'A ligar…',
'smartcarSearching': 'A procurar difusores de carro próximos…', 'eaicarSearching': 'A procurar difusores de carro próximos…',
'smartcarNotFound': 'Nenhum dispositivo encontrado', 'eaicarNotFound': 'Nenhum dispositivo encontrado',
'smartcarScanHint': 'Certifique-se de que o dispositivo está ligado e perto do telemóvel', 'eaicarScanHint': 'Certifique-se de que o dispositivo está ligado e perto do telemóvel',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/ro_ro.dart

@ -2123,12 +2123,12 @@ const Map<String, String> roRO = {
'inCallBusyDesc': 'Microfonul și calea audio sunt folosite de apel. Reîncearcă după ce închizi.', 'inCallBusyDesc': 'Microfonul și calea audio sunt folosite de apel. Reîncearcă după ce închizi.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratuit', 'freeSectionTitle': 'Gratuit',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/ru_ru.dart

@ -2134,12 +2134,12 @@ const Map<String, String> ruRU = {
'notNow': 'Не сейчас', 'notNow': 'Не сейчас',
'permanentlyDisallow': 'Не разрешать', 'permanentlyDisallow': 'Не разрешать',
'otaBesNotSupported': 'Эта гарнитура пока не поддерживает обновление прошивки из приложения. Обратитесь в службу поддержки.', 'otaBesNotSupported': 'Эта гарнитура пока не поддерживает обновление прошивки из приложения. Обратитесь в службу поддержки.',
'smartcarConnected': 'Подключено', 'eaicarConnected': 'Подключено',
'smartcarConnectFailed': 'Ошибка подключения', 'eaicarConnectFailed': 'Ошибка подключения',
'smartcarConnecting': 'Подключение…', 'eaicarConnecting': 'Подключение…',
'smartcarSearching': 'Поиск автомобильных ароматизаторов поблизости…', 'eaicarSearching': 'Поиск автомобильных ароматизаторов поблизости…',
'smartcarNotFound': 'Устройство не найдено', 'eaicarNotFound': 'Устройство не найдено',
'smartcarScanHint': 'Убедитесь, что устройство включено и находится рядом с телефоном', 'eaicarScanHint': 'Убедитесь, что устройство включено и находится рядом с телефоном',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/sk_sk.dart

@ -2104,12 +2104,12 @@ const Map<String, String> skSK = {
'inCallBusyDesc': 'Mikrofón a zvukovú cestu používa hovor. Skúste to znova po zavesení.', 'inCallBusyDesc': 'Mikrofón a zvukovú cestu používa hovor. Skúste to znova po zavesení.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Zadarmo', 'freeSectionTitle': 'Zadarmo',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/sl_si.dart

@ -2107,12 +2107,12 @@ const Map<String, String> slSI = {
'inCallBusyDesc': 'Mikrofon in zvočno pot uporablja klic. Poskusite znova, ko odložite.', 'inCallBusyDesc': 'Mikrofon in zvočno pot uporablja klic. Poskusite znova, ko odložite.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Brezplačno', 'freeSectionTitle': 'Brezplačno',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/sr_rs.dart

@ -2103,12 +2103,12 @@ const Map<String, String> srRS = {
'inCallBusyDesc': 'Mikrofon i audio putanja su zauzeti pozivom. Pokušajte ponovo nakon završetka poziva.', 'inCallBusyDesc': 'Mikrofon i audio putanja su zauzeti pozivom. Pokušajte ponovo nakon završetka poziva.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Besplatno', 'freeSectionTitle': 'Besplatno',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/sv_se.dart

@ -2099,12 +2099,12 @@ const Map<String, String> svSE = {
'inCallBusyDesc': 'Mikrofonen och ljudvägen används av samtalet. Försök igen när du har lagt på.', 'inCallBusyDesc': 'Mikrofonen och ljudvägen används av samtalet. Försök igen när du har lagt på.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Gratis', 'freeSectionTitle': 'Gratis',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/th_th.dart

@ -2052,12 +2052,12 @@ const Map<String, String> thTH = {
'inCallBusyDesc': 'ไมโครโฟนและเส้นทางเสียงกำลังถูกใช้โดยการโทร กรุณาลองใหม่หลังวางสาย', 'inCallBusyDesc': 'ไมโครโฟนและเส้นทางเสียงกำลังถูกใช้โดยการโทร กรุณาลองใหม่หลังวางสาย',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'ฟรี', 'freeSectionTitle': 'ฟรี',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/tl_ph.dart

@ -2137,12 +2137,12 @@ const Map<String, String> tlPH = {
'inCallBusyDesc': 'Ginagamit ng tawag ang mikropono at audio path. Subukang muli pagkatapos ibaba ang tawag.', 'inCallBusyDesc': 'Ginagamit ng tawag ang mikropono at audio path. Subukang muli pagkatapos ibaba ang tawag.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Libre', 'freeSectionTitle': 'Libre',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/tr_tr.dart

@ -2093,12 +2093,12 @@ const Map<String, String> trTR = {
'inCallBusyDesc': 'Mikrofon ve ses yolu arama tarafından kullanılıyor. Aramayı kapattıktan sonra tekrar deneyin.', 'inCallBusyDesc': 'Mikrofon ve ses yolu arama tarafından kullanılıyor. Aramayı kapattıktan sonra tekrar deneyin.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Ücretsiz', 'freeSectionTitle': 'Ücretsiz',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/uk_ua.dart

@ -2111,12 +2111,12 @@ const Map<String, String> ukUA = {
'inCallBusyDesc': 'Мікрофон і аудіотракт зайняті дзвінком. Спробуйте ще раз після завершення дзвінка.', 'inCallBusyDesc': 'Мікрофон і аудіотракт зайняті дзвінком. Спробуйте ще раз після завершення дзвінка.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Безкоштовно', 'freeSectionTitle': 'Безкоштовно',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/vi_vn.dart

@ -2071,12 +2071,12 @@ const Map<String, String> viVN = {
'inCallBusyDesc': 'Micro và đường âm thanh đang được cuộc gọi sử dụng. Hãy thử lại sau khi cúp máy.', 'inCallBusyDesc': 'Micro và đường âm thanh đang được cuộc gọi sử dụng. Hãy thử lại sau khi cúp máy.',
'vipSectionTitle': 'VIP', 'vipSectionTitle': 'VIP',
'freeSectionTitle': 'Miễn phí', 'freeSectionTitle': 'Miễn phí',
'smartcarConnected': 'Connected', 'eaicarConnected': 'Connected',
'smartcarConnectFailed': 'Connection failed', 'eaicarConnectFailed': 'Connection failed',
'smartcarConnecting': 'Connecting…', 'eaicarConnecting': 'Connecting…',
'smartcarSearching': 'Searching for nearby car diffusers…', 'eaicarSearching': 'Searching for nearby car diffusers…',
'smartcarNotFound': 'No device found', 'eaicarNotFound': 'No device found',
'smartcarScanHint': 'Make sure the device is powered on and close to your phone', 'eaicarScanHint': 'Make sure the device is powered on and close to your phone',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/zh_cn.dart

@ -1927,12 +1927,12 @@ const Map<String, String> zhCN = {
'unmarkFavorite': '取消收藏', 'unmarkFavorite': '取消收藏',
'unnamedDevice': '未命名设备', 'unnamedDevice': '未命名设备',
'userDataParseFailed': '用户数据解析失败', 'userDataParseFailed': '用户数据解析失败',
'smartcarConnected': '已连接', 'eaicarConnected': '已连接',
'smartcarConnectFailed': '连接失败', 'eaicarConnectFailed': '连接失败',
'smartcarConnecting': '正在连接…', 'eaicarConnecting': '正在连接…',
'smartcarSearching': '正在搜索附近的车载香薰…', 'eaicarSearching': '正在搜索附近的车载香薰…',
'smartcarNotFound': '没有找到设备', 'eaicarNotFound': '没有找到设备',
'smartcarScanHint': '请确认设备已通电,并靠近手机', 'eaicarScanHint': '请确认设备已通电,并靠近手机',
'vipRequiredTitle': '需要会员', 'vipRequiredTitle': '需要会员',
'vipRequiredDesc': '翻译与会议纪要需要会员。续费会员,或绑定带会员的设备即可使用。', 'vipRequiredDesc': '翻译与会议纪要需要会员。续费会员,或绑定带会员的设备即可使用。',
'vipRequiredGo': '去开通', 'vipRequiredGo': '去开通',

12
apps/client/lib/core/translations/language/zh_hk.dart

@ -1924,12 +1924,12 @@ const Map<String, String> zhHK = {
'notNow': '暫不', 'notNow': '暫不',
'permanentlyDisallow': '永久不允許', 'permanentlyDisallow': '永久不允許',
'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服獲取升級方式', 'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服獲取升級方式',
'smartcarConnected': '已連接', 'eaicarConnected': '已連接',
'smartcarConnectFailed': '連接失敗', 'eaicarConnectFailed': '連接失敗',
'smartcarConnecting': '正在連接…', 'eaicarConnecting': '正在連接…',
'smartcarSearching': '正在搜尋附近的車載香薰…', 'eaicarSearching': '正在搜尋附近的車載香薰…',
'smartcarNotFound': '沒有找到裝置', 'eaicarNotFound': '沒有找到裝置',
'smartcarScanHint': '請確認裝置已通電,並靠近手機', 'eaicarScanHint': '請確認裝置已通電,並靠近手機',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/core/translations/language/zh_tw.dart

@ -1924,12 +1924,12 @@ const Map<String, String> zhTW = {
'notNow': '暫不', 'notNow': '暫不',
'permanentlyDisallow': '永久不允許', 'permanentlyDisallow': '永久不允許',
'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服取得升級方式', 'otaBesNotSupported': '此耳機暫不支援在 App 內升級韌體,請聯絡客服取得升級方式',
'smartcarConnected': '已連接', 'eaicarConnected': '已連接',
'smartcarConnectFailed': '連接失敗', 'eaicarConnectFailed': '連接失敗',
'smartcarConnecting': '正在連接…', 'eaicarConnecting': '正在連接…',
'smartcarSearching': '正在搜尋附近的車載香氛…', 'eaicarSearching': '正在搜尋附近的車載香氛…',
'smartcarNotFound': '沒有找到裝置', 'eaicarNotFound': '沒有找到裝置',
'smartcarScanHint': '請確認裝置已通電,並靠近手機', 'eaicarScanHint': '請確認裝置已通電,並靠近手機',
'vipRequiredTitle': 'Membership required', 'vipRequiredTitle': 'Membership required',
'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.', 'vipRequiredDesc': 'Translation and meeting notes require a membership. Renew, or pair a device that includes one.',
'vipRequiredGo': 'Get membership', 'vipRequiredGo': 'Get membership',

12
apps/client/lib/data/models/agent_module.dart

@ -41,7 +41,7 @@ class AgentModule {
/// ///
/// **设备按键唤醒时用它决定接哪个助手**:`DeviceAiSessionService` 按 /// **设备按键唤醒时用它决定接哪个助手**:`DeviceAiSessionService` 按
/// 厂商 → 品类 → 默认编排第一个模块 → 这个字段,找到该说话的那个 agent。 /// 厂商 → 品类 → 默认编排第一个模块 → 这个字段,找到该说话的那个 agent。
/// 车载香薰唤醒的是「Smartcar 车载助手」而不是 EMAI,就是靠这一条。 /// 车载香薰唤醒的是「EaiCar 车载助手」而不是 EMAI,就是靠这一条。
/// ///
/// ⚠️ 放在 data 层的基类而不是 `AgentModuleDescriptor`(modules 层),是为了让 /// ⚠️ 放在 data 层的基类而不是 `AgentModuleDescriptor`(modules 层),是为了让
/// `DeviceAiSessionService`(data 层)读得到 —— **data 层不反向依赖 modules 层**。 /// `DeviceAiSessionService`(data 层)读得到 —— **data 层不反向依赖 modules 层**。
@ -73,8 +73,8 @@ class AgentModules {
/// EMAI 助手:原来 AI 页底部那颗悬浮球,改成广场里的一个正式 agent /// EMAI 助手:原来 AI 页底部那颗悬浮球,改成广场里的一个正式 agent
static const String emaiAssistant = 'emaiAssistant'; static const String emaiAssistant = 'emaiAssistant';
/// Smartcar 车载助手(车载香薰的对话 agent,另一个百炼应用) /// EaiCar 车载助手(车载香薰的对话 agent,另一个百炼应用)
static const String smartcarChat = 'smartcarChat'; static const String eaicarChat = 'smartcarChat'; // 已铺进装机编排的模块 id,改名后值不动
static const String textTranslation = 'textTranslation'; static const String textTranslation = 'textTranslation';
static const String pictureTranslation = 'pictureTranslation'; static const String pictureTranslation = 'pictureTranslation';
@ -121,14 +121,14 @@ class AgentModules {
DeviceCategories.noteCard: [meeting, fieldRecording, callRecording], DeviceCategories.noteCard: [meeting, fieldRecording, callRecording],
// AI耳机(恒玄):通话 + 翻译整套都能跑,铺开全量默认编排 // AI耳机(恒玄):通话 + 翻译整套都能跑,铺开全量默认编排
DeviceCategories.earphone: defaultEnabled, DeviceCategories.earphone: defaultEnabled,
// AI车载香薰(Smartcar):随时开聊 + 现场录音 + 语音纪要。 // AI车载香薰(EaiCar):随时开聊 + 现场录音 + 语音纪要。
// 这三个都不依赖设备能力(只有通话录音/翻译才声明 requiredCapabilities), // 这三个都不依赖设备能力(只有通话录音/翻译才声明 requiredCapabilities),
// 所以连上香薰就能直接用,不会灰。 // 所以连上香薰就能直接用,不会灰。
// ⚠️ 这是**默认编排不是权限限制**——用户仍可去广场把别的功能加回来, // ⚠️ 这是**默认编排不是权限限制**——用户仍可去广场把别的功能加回来,
// 这是产品有意接受的(落地文档 6.5)。 // 这是产品有意接受的(落地文档 6.5)。
// ⚠️ 2026-09-11 从「只有 smartcarChat」改成这三个;已经铺过旧编排的装机 // ⚠️ 2026-09-11 从「只有 eaicarChat」改成这三个;已经铺过旧编排的装机
// 由 AgentModuleService 的一次性补铺处理,见那边的 _migrateCarAromaDefaults。 // 由 AgentModuleService 的一次性补铺处理,见那边的 _migrateCarAromaDefaults。
DeviceCategories.carAroma: [smartcarChat, fieldRecording, meeting], DeviceCategories.carAroma: [eaicarChat, fieldRecording, meeting],
}; };
static List<String> defaultsForCategory(String? categoryId) { static List<String> defaultsForCategory(String? categoryId) {

2
apps/client/lib/data/models/assistant_directive.dart

@ -161,7 +161,7 @@ class AssistantDirective {
/// 解析 `extra_info.commands` —— **另一种指令形状**。 /// 解析 `extra_info.commands` —— **另一种指令形状**。
/// ///
/// ⚠️ 2026-09-11 真机发现:Smartcar 车载助手下发的指令不在 `tool_calls` 里, /// ⚠️ 2026-09-11 真机发现:EaiCar 车载助手下发的指令不在 `tool_calls` 里,
/// 而是 `extra_info.commands`,形如 /// 而是 `extra_info.commands`,形如
/// ```json /// ```json
/// "commands":[{"intent_info":{"domain":"general_command","intent":"quit"}, /// "commands":[{"intent_info":{"domain":"general_command","intent":"quit"},

2
apps/client/lib/data/models/device_category.dart

@ -106,7 +106,7 @@ class DeviceCategories {
icon: Icons.air, icon: Icons.air,
gradient: [Color(0xFFFFB74D), Color(0xFFE65100)], gradient: [Color(0xFFFFB74D), Color(0xFFE65100)],
imageAsset: '${DeviceImages.dir}EaiCar.png', imageAsset: '${DeviceImages.dir}EaiCar.png',
nameKeywords: ['smartcar', '车载', '香薰', 'aroma', 'diffuser'], nameKeywords: ['eaicar', 'smartcar', '车载', '香薰', 'aroma', 'diffuser'],
), ),
]; ];

2
apps/client/lib/data/services/agent_module_defaults.dart

@ -17,7 +17,7 @@ class AgentDefaults {
static const Map<String, String> _vendorCategory = { static const Map<String, String> _vendorCategory = {
DeviceVendors.bes: DeviceCategories.earphone, DeviceVendors.bes: DeviceCategories.earphone,
DeviceVendors.holder: DeviceCategories.phoneHolder, DeviceVendors.holder: DeviceCategories.phoneHolder,
DeviceVendors.smartcar: DeviceCategories.carAroma, DeviceVendors.eaicar: DeviceCategories.carAroma,
}; };
/// 厂商 → 品类。设备按键唤醒时用它找该接哪个 agent(见 /// 厂商 → 品类。设备按键唤醒时用它找该接哪个 agent(见

4
apps/client/lib/data/services/agent_module_service.dart

@ -44,7 +44,7 @@ class AgentModuleService extends GetxService {
// ⚠️ 当前这份编排如果是**设备默认铺下来的**(用户没动过),三个一次性迁移 // ⚠️ 当前这份编排如果是**设备默认铺下来的**(用户没动过),三个一次性迁移
// 一律跳过。它们是给「用户自己的编排」补新功能用的,而设备编排本来就该 // 一律跳过。它们是给「用户自己的编排」补新功能用的,而设备编排本来就该
// 只有那几项 —— 尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部 // 只有那几项 —— 尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部
// 追加回来,等于把「连上香薰只显示 Smartcar/现场录音/语音纪要」直接抹掉。 // 追加回来,等于把「连上香薰只显示 EaiCar/现场录音/语音纪要」直接抹掉。
// //
// 2026-09-14 线上就是这么坏的:香薰编排铺成 3 项 → 重启 App → 迁移追加成 // 2026-09-14 线上就是这么坏的:香薰编排铺成 3 项 → 重启 App → 迁移追加成
// 11 项 → 而基线 _lastAppliedKey 还停在 3 项 → 之后 applyDeviceDefaults // 11 项 → 而基线 _lastAppliedKey 还停在 3 项 → 之后 applyDeviceDefaults
@ -226,7 +226,7 @@ class AgentModuleService extends GetxService {
/// 连上某品类的设备时,把 AI 页铺成这个品类的默认编排。 /// 连上某品类的设备时,把 AI 页铺成这个品类的默认编排。
/// ///
/// 规则只有一条:**只要用户没自己动过编排,AI 页就跟着当前设备走**。 /// 规则只有一条:**只要用户没自己动过编排,AI 页就跟着当前设备走**。
/// 于是「连上车载香薰 → 只剩 Smartcar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」 /// 于是「连上车载香薰 → 只剩 EaiCar / 现场录音 / 语音纪要」「换回耳机 → 恢复全量」
/// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。 /// 都成立,而用户一旦增删或排序过,就再也不会被自动改动。
/// ///
/// 判据是 [_lastAppliedKey]:上一次**自动铺**下去的那份清单。 /// 判据是 [_lastAppliedKey]:上一次**自动铺**下去的那份清单。

2
apps/client/lib/data/services/bailian_multimodal_service.dart

@ -94,7 +94,7 @@ class BailianEvent {
/// 也就是说 EMAI 与设备唤醒会话的行为一个字节都没变。 /// 也就是说 EMAI 与设备唤醒会话的行为一个字节都没变。
/// ///
/// 之所以做成实例级而不是改全局:不同 agent 用的是百炼里不同的应用 /// 之所以做成实例级而不是改全局:不同 agent 用的是百炼里不同的应用
/// (EMAI 一个、Smartcar 车载助手另一个),改全局值会把所有 agent 一起换掉。 /// (EMAI 一个、EaiCar 车载助手另一个),改全局值会把所有 agent 一起换掉。
/// ///
/// ⚠️ 每个应用**必须各自 new 一个实例**,不能共用:`start()` 第一件事就是 `stop()`, /// ⚠️ 每个应用**必须各自 new 一个实例**,不能共用:`start()` 第一件事就是 `stop()`,
/// 共用会让两边互相把对方的会话掐掉,下行音频还会串到对方的播放器上。 /// 共用会让两边互相把对方的会话掐掉,下行音频还会串到对方的播放器上。

4
apps/client/lib/data/services/device_ai_session_service.dart

@ -44,7 +44,7 @@ class DeviceAiSessionService extends GetxService {
/// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。 /// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。
/// ///
/// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接 /// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接
/// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例, /// 「EaiCar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例,
/// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。 /// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。
BailianMultimodalService _svc = BailianMultimodalService(); BailianMultimodalService _svc = BailianMultimodalService();
@ -355,7 +355,7 @@ class DeviceAiSessionService extends GetxService {
Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒'); Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒');
return; return;
} }
// ★ 先按厂商选中该说话的那个 agent(车载香薰 → Smartcar 车载助手), // ★ 先按厂商选中该说话的那个 agent(车载香薰 → EaiCar 车载助手),
// 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用 // 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用
_ensureService(_resolveAgentAppId(session.vendor)); _ensureService(_resolveAgentAppId(session.vendor));
Logger.i(_tag, Logger.i(_tag,

4
apps/client/lib/data/utils/emai_audio_session.dart

@ -11,7 +11,7 @@ enum EmaiAudioProfile {
/// 手机麦克风 + 手机放音(EMAI 页「按住说话」)。必须 playAndRecord。 /// 手机麦克风 + 手机放音(EMAI 页「按住说话」)。必须 playAndRecord。
phoneMic, phoneMic,
/// 只放音,麦克风在**设备**上(Smartcar 车载香薰按键唤醒:采音走 BLE opus, /// 只放音,麦克风在**设备**上(EaiCar 车载香薰按键唤醒:采音走 BLE opus,
/// 手机一个字节都不录)。 /// 手机一个字节都不录)。
/// ///
/// 这一档必须用 playback 而不是 playAndRecord —— 见 [ensure] 里的说明。 /// 这一档必须用 playback 而不是 playAndRecord —— 见 [ensure] 里的说明。
@ -52,7 +52,7 @@ class EmaiAudioSession {
/// ///
/// ⚠️ **playbackOnly 这一档是有来历的**(2026-09-14 修):车载香薰唤醒 AI 后 /// ⚠️ **playbackOnly 这一档是有来历的**(2026-09-14 修):车载香薰唤醒 AI 后
/// 回复声音从**手机扬声器**出来,而不是从香薰喇叭出来。香薰的下行本来就设计成 /// 回复声音从**手机扬声器**出来,而不是从香薰喇叭出来。香薰的下行本来就设计成
/// 走经典蓝牙 A2DP(`_SmartcarPhoneSpeaker` 的注释写的就是「A2DP 连着时从设备 /// 走经典蓝牙 A2DP(`_EaiCarPhoneSpeaker` 的注释写的就是「A2DP 连着时从设备
/// 喇叭出来」),坏在这份会话配置上,两处叠加: /// 喇叭出来」),坏在这份会话配置上,两处叠加:
/// 1. `defaultToSpeaker` 把输出钉在内置扬声器上; /// 1. `defaultToSpeaker` 把输出钉在内置扬声器上;
/// 2. 选项里只有 `allowBluetooth`(那是 HFP 免提),**没有 allowBluetoothA2dp** /// 2. 选项里只有 `allowBluetooth`(那是 HFP 免提),**没有 allowBluetoothA2dp**

16
apps/client/lib/devices/device_auth.dart

@ -9,8 +9,8 @@ import 'bes/bes_device_plugin.dart';
import 'device_bind_registry.dart'; import 'device_bind_registry.dart';
import 'device_hub.dart'; import 'device_hub.dart';
import 'holder/holder_device_plugin.dart'; import 'holder/holder_device_plugin.dart';
import 'smartcar/smartcar_device_auth.dart'; import 'eaicar/eaicar_device_auth.dart';
import 'smartcar/smartcar_device_plugin.dart'; import 'eaicar/eaicar_device_plugin.dart';
/// 确权结论(厂商无关)。 /// 确权结论(厂商无关)。
/// ///
@ -38,7 +38,7 @@ class DeviceAuth {
/// 登出:换账号后同一台设备要重新登记到新账号名下。**白名单不清**。 /// 登出:换账号后同一台设备要重新登记到新账号名下。**白名单不清**。
static void reset() { static void reset() {
BesDeviceAuth.reset(); BesDeviceAuth.reset();
SmartcarDeviceAuth.reset(); EaiCarDeviceAuth.reset();
// 换账号后同一台设备要重新登记到新账号名下(服务端会接管) // 换账号后同一台设备要重新登记到新账号名下(服务端会接管)
DeviceBindRegistry.reset(); DeviceBindRegistry.reset();
} }
@ -49,13 +49,13 @@ class DeviceAuth {
/// 而各家白名单是按 MAC 存的、互不相干,多清一次无副作用。 /// 而各家白名单是按 MAC 存的、互不相干,多清一次无副作用。
static Future<void> forget(String mac) async { static Future<void> forget(String mac) async {
await BesDeviceAuth.forget(mac); await BesDeviceAuth.forget(mac);
await SmartcarDeviceAuth.forget(mac); await EaiCarDeviceAuth.forget(mac);
} }
/// 【调试/售后】清空白名单 /// 【调试/售后】清空白名单
static Future<void> clearWhitelist() async { static Future<void> clearWhitelist() async {
await BesDeviceAuth.clearWhitelist(); await BesDeviceAuth.clearWhitelist();
await SmartcarDeviceAuth.clearWhitelist(); await EaiCarDeviceAuth.clearWhitelist();
} }
/// 补校当前连着的、需要确权的设备(顺带完成绑定登记)。 /// 补校当前连着的、需要确权的设备(顺带完成绑定登记)。
@ -76,9 +76,9 @@ class DeviceAuth {
} else if (p is HolderDevicePlugin) { } else if (p is HolderDevicePlugin) {
// 支架没有单独的确权类,登记就在服务自己身上(本进程内已成功的会去重) // 支架没有单独的确权类,登记就在服务自己身上(本进程内已成功的会去重)
await p.link.bindConnectedToServer(); await p.link.bindConnectedToServer();
} else if (p is SmartcarDevicePlugin) { } else if (p is EaiCarDevicePlugin) {
// Smartcar 的 MAC 来自广播,两端都拿得到(不像恒玄 iOS 只有 UUID) // EaiCar 的 MAC 来自广播,两端都拿得到(不像恒玄 iOS 只有 UUID)
await SmartcarDeviceAuth.verify( await EaiCarDeviceAuth.verify(
mac: s.info.macAddress, mac: s.info.macAddress,
name: s.info.name, name: s.info.name,
); );

6
apps/client/lib/devices/device_bootstrap.dart

@ -6,7 +6,7 @@ import 'bes/bes_device_auth.dart';
import 'bes/bes_device_plugin.dart'; import 'bes/bes_device_plugin.dart';
import 'device_hub.dart'; import 'device_hub.dart';
import 'holder/holder_device_plugin.dart'; import 'holder/holder_device_plugin.dart';
import 'smartcar/smartcar_device_plugin.dart'; import 'eaicar/eaicar_device_plugin.dart';
/// 装配设备中枢:注册所有厂商插件并初始化。由 InitialBinding 调用一次。 /// 装配设备中枢:注册所有厂商插件并初始化。由 InitialBinding 调用一次。
/// ///
@ -26,9 +26,9 @@ void installDeviceHub() {
_register(hub, HolderDevicePlugin()); _register(hub, HolderDevicePlugin());
// Smartcar 车载香薰。BLE 走 flutter_blue_plus(纯 Dart,无原生插件)。 // EaiCar 车载香薰。BLE 走 flutter_blue_plus(纯 Dart,无原生插件)。
// initialize 里不申请权限也不起扫描,冷启动零副作用。 // initialize 里不申请权限也不起扫描,冷启动零副作用。
_register(hub, SmartcarDevicePlugin()); _register(hub, EaiCarDevicePlugin());
} }
void _register(DeviceHub hub, DevicePlugin p) { void _register(DeviceHub hub, DevicePlugin p) {

4
apps/client/lib/devices/device_vendors.dart

@ -9,6 +9,6 @@ class DeviceVendors {
/// EaiRec 录音支架,BLE + Opus /// EaiRec 录音支架,BLE + Opus
static const String holder = 'holder'; static const String holder = 'holder';
/// Smartcar 车载香薰(协议待接) /// EaiCar 车载香薰(协议待接)
static const String smartcar = 'smartcar'; static const String eaicar = 'smartcar'; // 持久化的厂商键,改名后值不动
} }

12
apps/client/lib/devices/smartcar/smartcar_device_auth.dart → apps/client/lib/devices/eaicar/eaicar_device_auth.dart

@ -8,7 +8,7 @@ import '../../data/services/network/api.dart';
import '../device_auth.dart'; import '../device_auth.dart';
import '../device_bind_registry.dart'; import '../device_bind_registry.dart';
/// 「账号 ↔ Smartcar」的确权 + 绑定登记,走服务端 `user_binddevice`。 /// 「账号 ↔ EaiCar」的确权 + 绑定登记,走服务端 `user_binddevice`。
/// ///
/// 与恒玄那套([BesDeviceAuth])同一个套路,判定口径逐条对齐, /// 与恒玄那套([BesDeviceAuth])同一个套路,判定口径逐条对齐,
/// 但有**一个关键差别**—— /// 但有**一个关键差别**——
@ -18,7 +18,7 @@ import '../device_bind_registry.dart';
/// 恒玄链路上 iOS 拿不到真 MAC(CoreBluetooth 只给 peripheral UUID, /// 恒玄链路上 iOS 拿不到真 MAC(CoreBluetooth 只给 peripheral UUID,
/// 每台手机看同一只耳机都不一样),所以那边 iOS 一律放行、闸门等于没有。 /// 每台手机看同一只耳机都不一样),所以那边 iOS 一律放行、闸门等于没有。
/// ///
/// Smartcar 的**经典蓝牙 MAC 就写在 BLE 广播里**(厂商数据 CID 之后的 6 字节,原样、不异或), /// EaiCar 的**经典蓝牙 MAC 就写在 BLE 广播里**(厂商数据 CID 之后的 6 字节,原样、不异或),
/// 两端解出来完全一致。所以这条链路的确权在 iOS 上是真生效的。 /// 两端解出来完全一致。所以这条链路的确权在 iOS 上是真生效的。
/// ⚠️ 别照抄恒玄那句「非 Android 直接返回 null」。 /// ⚠️ 别照抄恒玄那句「非 Android 直接返回 null」。
/// ///
@ -38,10 +38,10 @@ import '../device_bind_registry.dart';
/// [DeviceAuthResult.unknown] 放行是刻意选的失败方向:判严的代价是 /// [DeviceAuthResult.unknown] 放行是刻意选的失败方向:判严的代价是
/// **合法用户一断网就用不了自己的设备**;判宽只是未登记设备离线时能用一会儿, /// **合法用户一断网就用不了自己的设备**;判宽只是未登记设备离线时能用一会儿,
/// 联网第一次确权就会被拦下。前者严重得多。 /// 联网第一次确权就会被拦下。前者严重得多。
class SmartcarDeviceAuth { class EaiCarDeviceAuth {
SmartcarDeviceAuth._(); EaiCarDeviceAuth._();
static const String _tag = 'SmartcarDeviceAuth'; static const String _tag = 'EaiCarDeviceAuth';
/// 确权通过的 MAC 白名单(持久化,存大写) /// 确权通过的 MAC 白名单(持久化,存大写)
static const String _authorizedKey = 'smartcar_authorized_macs'; static const String _authorizedKey = 'smartcar_authorized_macs';
@ -92,7 +92,7 @@ class SmartcarDeviceAuth {
_reported.clear(); _reported.clear();
} }
/// 校验一台刚连上的 Smartcar。 /// 校验一台刚连上的 EaiCar。
/// ///
/// [mac] 来自广播(CID 之后 6 字节原样)。拿不到就 [DeviceAuthResult.unknown]。 /// [mac] 来自广播(CID 之后 6 字节原样)。拿不到就 [DeviceAuthResult.unknown]。
static Future<DeviceAuthResult> verify({ static Future<DeviceAuthResult> verify({

206
apps/client/lib/devices/smartcar/smartcar_device_plugin.dart → apps/client/lib/devices/eaicar/eaicar_device_plugin.dart

@ -13,14 +13,14 @@ import '../../data/utils/pcm_stream_player.dart';
import '../../core/utils/permission_util.dart'; import '../../core/utils/permission_util.dart';
import '../device_auth.dart'; import '../device_auth.dart';
import '../device_vendors.dart'; import '../device_vendors.dart';
import 'smartcar_device_auth.dart'; import 'eaicar_device_auth.dart';
import 'smartcar_link.dart'; import 'eaicar_link.dart';
import 'smartcar_ota_port.dart'; import 'eaicar_ota_port.dart';
import 'smartcar_protocol.dart'; import 'eaicar_protocol.dart';
/// EaiCar(车载香薰,旧名 Smartcar)厂商插件。 /// EaiCar(车载香薰,旧名 Smartcar)厂商插件。
/// ///
/// 把 [SmartcarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约, /// 把 [EaiCarLink] 的 C2 私有协议适配成 [DevicePlugin] / [DeviceSession] 契约,
/// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。 /// 业务层看到的只是「一台能读电量、能报 MAC、能发私有命令的设备」。
/// ///
/// ## 本期(阶段 1~3)范围 /// ## 本期(阶段 1~3)范围
@ -30,16 +30,16 @@ import 'smartcar_protocol.dart';
/// ///
/// 还没做、要等后续阶段的: /// 还没做、要等后续阶段的:
/// - `micUplink` / `aiWake`:Opus 上行(阶段 6),要在同一条 GATT 上订阅 /// - `micUplink` / `aiWake`:Opus 上行(阶段 6),要在同一条 GATT 上订阅
/// [SmartcarProtocol.audioNotifyCharUuid],**不能另起连接** /// [EaiCarProtocol.audioNotifyCharUuid],**不能另起连接**
/// - `ota`:阶段 7,搬杰理 OTA SDK /// - `ota`:阶段 7,搬杰理 OTA SDK
/// - 经典蓝牙 A2DP 音乐(阶段 5),要先给 `classic_bluetooth` 补 `connectDevice` /// - 经典蓝牙 A2DP 音乐(阶段 5),要先给 `classic_bluetooth` 补 `connectDevice`
class SmartcarDevicePlugin implements DevicePlugin { class EaiCarDevicePlugin implements DevicePlugin {
static const String _tag = 'SmartcarDevicePlugin'; static const String _tag = 'EaiCarDevicePlugin';
/// 记住的设备(自动回连用)。存 `{id, name, mac}`。 /// 记住的设备(自动回连用)。存 `{id, name, mac}`。
static const String _rememberedKey = 'smartcar_remembered_devices'; static const String _rememberedKey = 'smartcar_remembered_devices';
final SmartcarLink link = SmartcarLink(); final EaiCarLink link = EaiCarLink();
final GetStorage _storage = GetStorage(); final GetStorage _storage = GetStorage();
final _events = StreamController<DevicePluginEvent>.broadcast(); final _events = StreamController<DevicePluginEvent>.broadcast();
@ -47,22 +47,22 @@ class SmartcarDevicePlugin implements DevicePlugin {
/// ///
/// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着), /// 业务层**不能** import 厂商目录(`device_hub_boundary_test` 守着),
/// 所以诊断信息只能顺着插件事件流出去。 /// 所以诊断信息只能顺着插件事件流出去。
static const String kScanDiagnosticKey = 'smartcar.scan_diagnostic'; static const String kScanDiagnosticKey = 'eaicar.scan_diagnostic';
SmartcarSession? _session; EaiCarSession? _session;
StreamSubscription<bool>? _connSub; StreamSubscription<bool>? _connSub;
StreamSubscription<Map<String, Object?>>? _diagSub; StreamSubscription<Map<String, Object?>>? _diagSub;
bool _disposed = false; bool _disposed = false;
bool _scanning = false; bool _scanning = false;
@override @override
String get vendorKey => DeviceVendors.smartcar; String get vendorKey => DeviceVendors.eaicar;
@override @override
String get displayName => 'EaiCar (车载香薰)'; String get displayName => 'EaiCar (车载香薰)';
@override @override
Set<DeviceCapability> get capabilities => SmartcarSession.caps; Set<DeviceCapability> get capabilities => EaiCarSession.caps;
@override @override
DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty; DevicePluginConfigSchema get configSchema => DevicePluginConfigSchema.empty;
@ -137,7 +137,7 @@ class SmartcarDevicePlugin implements DevicePlugin {
// ───────────────────────────── 扫描 ───────────────────────────── // ───────────────────────────── 扫描 ─────────────────────────────
StreamSubscription<SmartcarScanResult>? _scanSub; StreamSubscription<EaiCarScanResult>? _scanSub;
@override @override
Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) async { Future<void> startScan({DeviceScanFilter? filter, Duration? timeout}) async {
@ -213,7 +213,7 @@ class SmartcarDevicePlugin implements DevicePlugin {
await link.connect( await link.connect(
deviceId, deviceId,
timeout: options?.timeout ?? const Duration(seconds: 45), timeout: options?.timeout ?? const Duration(seconds: 45),
adv: adv is SmartcarAdvertisement ? adv : null, adv: adv is EaiCarAdvertisement ? adv : null,
); );
} on TimeoutException catch (e) { } on TimeoutException catch (e) {
throw DeviceException(DeviceErrorCode.connectTimeout, '$e'); throw DeviceException(DeviceErrorCode.connectTimeout, '$e');
@ -221,7 +221,7 @@ class SmartcarDevicePlugin implements DevicePlugin {
throw DeviceException(DeviceErrorCode.connectFailed, '$e'); throw DeviceException(DeviceErrorCode.connectFailed, '$e');
} }
final s = SmartcarSession( final s = EaiCarSession(
deviceId: deviceId, deviceId: deviceId,
link: link, link: link,
plugin: this, plugin: this,
@ -242,8 +242,8 @@ class SmartcarDevicePlugin implements DevicePlugin {
// 确权闸门。放在握手之后:要拿设备名去登记,而设备名是握手时才确定的。 // 确权闸门。放在握手之后:要拿设备名去登记,而设备名是握手时才确定的。
// //
// ⚠️ 只有 denied 才断开。unknown(未登录/断网/没解出 MAC)一律放行—— // ⚠️ 只有 denied 才断开。unknown(未登录/断网/没解出 MAC)一律放行——
// 判严的代价是合法用户断网就用不了自己的设备,见 [SmartcarDeviceAuth] 类注释。 // 判严的代价是合法用户断网就用不了自己的设备,见 [EaiCarDeviceAuth] 类注释。
final auth = await SmartcarDeviceAuth.verify( final auth = await EaiCarDeviceAuth.verify(
mac: s.info.macAddress, mac: s.info.macAddress,
name: s.info.name, name: s.info.name,
); );
@ -345,16 +345,16 @@ class SmartcarDevicePlugin implements DevicePlugin {
} }
void _checkAlive() { void _checkAlive() {
if (_disposed) throw StateError('SmartcarDevicePlugin 已 dispose'); if (_disposed) throw StateError('EaiCarDevicePlugin 已 dispose');
} }
} }
/// Smartcar 的会话句柄 /// EaiCar 的会话句柄
class SmartcarSession implements DeviceSession { class EaiCarSession implements DeviceSession {
SmartcarSession({ EaiCarSession({
required this.deviceId, required this.deviceId,
required SmartcarLink link, required EaiCarLink link,
required SmartcarDevicePlugin plugin, required EaiCarDevicePlugin plugin,
String? macAddress, String? macAddress,
}) : _link = link, }) : _link = link,
_plugin = plugin, _plugin = plugin,
@ -370,11 +370,11 @@ class SmartcarSession implements DeviceSession {
DeviceCapability.scan, DeviceCapability.scan,
DeviceCapability.connect, DeviceCapability.connect,
DeviceCapability.battery, DeviceCapability.battery,
// Smartcar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强, // EaiCar 的 MAC 在广播里,**iOS 也拿得到**——这点比恒玄强,
// 恒玄在 iOS 上只有 peripheral UUID,确权等于没有。 // 恒玄在 iOS 上只有 peripheral UUID,确权等于没有。
DeviceCapability.macAddress, DeviceCapability.macAddress,
DeviceCapability.customCommand, DeviceCapability.customCommand,
// 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [SmartcarOtaPort] // 杰理 OTA:原生 cmdOTAData + stage2 扫描回连(从 lumi 搬),见 [EaiCarOtaPort]
DeviceCapability.ota, DeviceCapability.ota,
// 设备麦克风上行:opus → 16k PCM(原生 SmartcarAudio 解码) // 设备麦克风上行:opus → 16k PCM(原生 SmartcarAudio 解码)
DeviceCapability.micUplink, DeviceCapability.micUplink,
@ -383,28 +383,28 @@ class SmartcarSession implements DeviceSession {
DeviceCapability.aiWake, DeviceCapability.aiWake,
// ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP // ⚠️ 刻意**不声明** speakerDownlink:AI 回复不走 BLE,走经典蓝牙 A2DP
// (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇, // (手机正常播放,设备当音箱)。openSpeaker(ai) 返回的是"手机扬声器"这个汇,
// 见 [_SmartcarPhoneSpeaker]。 // 见 [_EaiCarPhoneSpeaker]。
}; };
@override @override
final String deviceId; final String deviceId;
final SmartcarLink _link; final EaiCarLink _link;
/// OTA 结束后回连要用([SmartcarOtaPort.close]) /// OTA 结束后回连要用([EaiCarOtaPort.close])
final SmartcarDevicePlugin _plugin; final EaiCarDevicePlugin _plugin;
final String? _mac; final String? _mac;
StreamSubscription<SmartcarFrame>? _frameSub; StreamSubscription<EaiCarFrame>? _frameSub;
final _events = StreamController<DeviceSessionEvent>.broadcast(); final _events = StreamController<DeviceSessionEvent>.broadcast();
final _wake = StreamController<DeviceWakeEvent>.broadcast(); final _wake = StreamController<DeviceWakeEvent>.broadcast();
_SmartcarMic? _mic; _EaiCarMic? _mic;
_SmartcarPhoneSpeaker? _spk; _EaiCarPhoneSpeaker? _spk;
DeviceConnectionState _state = DeviceConnectionState.linkConnected; DeviceConnectionState _state = DeviceConnectionState.linkConnected;
DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: ''); DeviceInfo _info = const DeviceInfo(id: '', name: '', vendor: '');
@override @override
String get vendor => DeviceVendors.smartcar; String get vendor => DeviceVendors.eaicar;
@override @override
DeviceConnectionState get state => _state; DeviceConnectionState get state => _state;
@ -424,7 +424,7 @@ class SmartcarSession implements DeviceSession {
/// 握手:先宣告「APP 已连接」(0x10),再取一次固件版本 + 参数汇总。 /// 握手:先宣告「APP 已连接」(0x10),再取一次固件版本 + 参数汇总。
/// ///
/// ⚠️ 0x10 不能省:杰理认证通过、0x15 也能应答的情况下,设备屏幕照样显示 /// ⚠️ 0x10 不能省:杰理认证通过、0x15 也能应答的情况下,设备屏幕照样显示
/// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [SmartcarProtocol.cmdAppConnected])。 /// BLE 未连接,直到收到这条(真机 2026-09-10 验证,见 [EaiCarProtocol.cmdAppConnected])。
/// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。 /// 它失败就判握手失败——"App 以为连上了、设备不认"正是这次排查的起点。
/// ///
/// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。 /// 0x15 一次就把电量、版本、各种开关全带回来了,比逐条问省好几个往返。
@ -441,7 +441,7 @@ class SmartcarSession implements DeviceSession {
await _syncTime(); await _syncTime();
Object? e15; Object? e15;
try { try {
final f = await _link.send(SmartcarProtocol.cmdSyncSummary); final f = await _link.send(EaiCarProtocol.cmdSyncSummary);
if (f != null && f.success) { if (f != null && f.success) {
_applySummary(f.payload); _applySummary(f.payload);
_state = DeviceConnectionState.ready; _state = DeviceConnectionState.ready;
@ -450,22 +450,22 @@ class SmartcarSession implements DeviceSession {
e15 = '设备回了 0x15 但 len=0(命令执行失败)'; e15 = '设备回了 0x15 但 len=0(命令执行失败)';
} catch (e) { } catch (e) {
e15 = e; e15 = e;
Logger.w('SmartcarSession', '0x15 参数汇总失败,退回取版本号: $e'); Logger.w('EaiCarSession', '0x15 参数汇总失败,退回取版本号: $e');
} }
Object? e01; Object? e01;
SmartcarFrame? v; EaiCarFrame? v;
try { try {
v = await _link.send(SmartcarProtocol.cmdGetVersion); v = await _link.send(EaiCarProtocol.cmdGetVersion);
} catch (e) { } catch (e) {
e01 = e; e01 = e;
} }
if (v == null || !v.success) { if (v == null || !v.success) {
// 两条查询命令都没回应答。最可能的原因按概率排: // 两条查询命令都没回应答。最可能的原因按概率排:
// ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 SmartcarProtocol 的 // ① 命令号不对(协议文档与 lumi 代码本来就冲突,见 EaiCarProtocol 的
// TODO(smartcar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。 // TODO(eaicar-cmd));② CRC 算法或帧格式对不上;③ 这版固件不支持。
throw StateError('0x15 与 0x01 都没有有效应答 ' throw StateError('0x15 与 0x01 都没有有效应答 '
'(0x15: $e15; 0x01: ${e01 ?? '无应答'})。' '(0x15: $e15; 0x01: ${e01 ?? '无应答'})。'
'多半是命令号或帧格式不对——见 SmartcarProtocol 里的 TODO(smartcar-cmd)'); '多半是命令号或帧格式不对——见 EaiCarProtocol 里的 TODO(eaicar-cmd)');
} }
_applyVersion(v.payload); _applyVersion(v.payload);
_state = DeviceConnectionState.ready; _state = DeviceConnectionState.ready;
@ -474,13 +474,13 @@ class SmartcarSession implements DeviceSession {
/// 0x10「蓝牙连接成功」:`[设备类型, 车企编号]`,设备回 `BB 10 01 64` 才算数。 /// 0x10「蓝牙连接成功」:`[设备类型, 车企编号]`,设备回 `BB 10 01 64` 才算数。
Future<void> _announceAppConnected() async { Future<void> _announceAppConnected() async {
final appType = Platform.isIOS final appType = Platform.isIOS
? SmartcarProtocol.appTypeIos ? EaiCarProtocol.appTypeIos
: SmartcarProtocol.appTypeAndroid; : EaiCarProtocol.appTypeAndroid;
SmartcarFrame? f; EaiCarFrame? f;
try { try {
f = await _link.send( f = await _link.send(
SmartcarProtocol.cmdAppConnected, EaiCarProtocol.cmdAppConnected,
payload: [appType, SmartcarProtocol.carVendorDefault], payload: [appType, EaiCarProtocol.carVendorDefault],
); );
} catch (e) { } catch (e) {
throw StateError('0x10「蓝牙连接成功」无应答:$e。' throw StateError('0x10「蓝牙连接成功」无应答:$e。'
@ -489,21 +489,21 @@ class SmartcarSession implements DeviceSession {
if (f == null || !f.success) { if (f == null || !f.success) {
throw StateError('设备拒绝了 0x10「蓝牙连接成功」(len=0)'); throw StateError('设备拒绝了 0x10「蓝牙连接成功」(len=0)');
} }
Logger.i('SmartcarSession', '已向设备宣告 APP 连接 (type=$appType),设备应答 ${f.payload}'); Logger.i('EaiCarSession', '已向设备宣告 APP 连接 (type=$appType),设备应答 ${f.payload}');
} }
/// 杰理 RCSP 认证后的收尾:设时间 → GET_TARGET_INFO → GET_SYS_INFO。 /// 杰理 RCSP 认证后的收尾:设时间 → GET_TARGET_INFO → GET_SYS_INFO。
/// ///
/// lumi 两端的 JL SDK 都会在认证后自动发这一串(iOS `initDialSystem`、Android `WatchOpImpl`), /// lumi 两端的 JL SDK 都会在认证后自动发这一串(iOS `initDialSystem`、Android `WatchOpImpl`),
/// 我们自己持有 GATT 就得自己发。2026-09-10 真机:0x10 + 0x15 都成功时屏幕仍显示未连接, /// 我们自己持有 GATT 就得自己发。2026-09-10 真机:0x10 + 0x15 都成功时屏幕仍显示未连接,
/// 把这串和 0x17 补上之后才亮。TODO(smartcar-handshake):还没二分出到底是哪一步点亮的, /// 把这串和 0x17 补上之后才亮。TODO(eaicar-handshake):还没二分出到底是哪一步点亮的,
/// 全部保留;失败只记日志不断链——这几条是"锦上添花",链路本身已经能用。 /// 全部保留;失败只记日志不断链——这几条是"锦上添花",链路本身已经能用。
Future<void> _jlPostAuth() async { Future<void> _jlPostAuth() async {
try { try {
final r = await _link.jlPostAuth(); final r = await _link.jlPostAuth();
Logger.i('SmartcarSession', 'RCSP 认证后收尾: $r'); Logger.i('EaiCarSession', 'RCSP 认证后收尾: $r');
} catch (e) { } catch (e) {
Logger.w('SmartcarSession', 'RCSP 认证后收尾失败(继续): $e'); Logger.w('EaiCarSession', 'RCSP 认证后收尾失败(继续): $e');
} }
} }
@ -513,17 +513,17 @@ class SmartcarSession implements DeviceSession {
final n = DateTime.now(); final n = DateTime.now();
try { try {
await _link.send( await _link.send(
SmartcarProtocol.cmdSyncTime, EaiCarProtocol.cmdSyncTime,
payload: [n.year % 100, n.month, n.day, n.hour, n.minute, n.second, 0x01], payload: [n.year % 100, n.month, n.day, n.hour, n.minute, n.second, 0x01],
timeout: const Duration(seconds: 2), timeout: const Duration(seconds: 2),
); );
} catch (e) { } catch (e) {
Logger.w('SmartcarSession', '0x17 时间同步无应答(继续): $e'); Logger.w('EaiCarSession', '0x17 时间同步无应答(继续): $e');
} }
} }
void _applySummary(List<int> p) { void _applySummary(List<int> p) {
final s = SmartcarAllSettings.parse(p); final s = EaiCarAllSettings.parse(p);
if (s == null) return; if (s == null) return;
_info = _info.copyWith( _info = _info.copyWith(
firmwareVersion: s.firmwareVersion, firmwareVersion: s.firmwareVersion,
@ -541,13 +541,13 @@ class SmartcarSession implements DeviceSession {
_emitInfo(); _emitInfo();
} }
void _onFrame(SmartcarFrame f) { void _onFrame(EaiCarFrame f) {
if (f.isNotify) { if (f.isNotify) {
// 设备主动上报。0xCC 0x11 = AI 唤醒:设备已经自己开麦了, // 设备主动上报。0xCC 0x11 = AI 唤醒:设备已经自己开麦了,
// DeviceAiSessionService 收到 wakeEvents 后会 aiAccept + openMic。 // DeviceAiSessionService 收到 wakeEvents 后会 aiAccept + openMic。
if (f.cmd == SmartcarProtocol.notifyAiWake) { if (f.cmd == EaiCarProtocol.notifyAiWake) {
Logger.i('SmartcarSession', '设备唤醒 AI 对话 (0xCC 0x11)'); Logger.i('EaiCarSession', '设备唤醒 AI 对话 (0xCC 0x11)');
_emitFeature('smartcar.ai_wake', const {}); _emitFeature('eaicar.ai_wake', const {});
if (!_wake.isClosed) { if (!_wake.isClosed) {
_wake.add(DeviceWakeEvent( _wake.add(DeviceWakeEvent(
deviceId: deviceId, deviceId: deviceId,
@ -557,7 +557,7 @@ class SmartcarSession implements DeviceSession {
} }
return; return;
} }
if (f.cmd == SmartcarProtocol.cmdGetBattery && f.payload.isNotEmpty) { if (f.cmd == EaiCarProtocol.cmdGetBattery && f.payload.isNotEmpty) {
final b = f.payload[0] & 0xFF; final b = f.payload[0] & 0xFF;
_info = _info.copyWith( _info = _info.copyWith(
batteryPercent: b, battery: DeviceBattery(single: b)); batteryPercent: b, battery: DeviceBattery(single: b));
@ -567,9 +567,9 @@ class SmartcarSession implements DeviceSession {
return; return;
} }
if (f.isResponse && f.success) { if (f.isResponse && f.success) {
if (f.cmd == SmartcarProtocol.cmdSyncSummary) _applySummary(f.payload); if (f.cmd == EaiCarProtocol.cmdSyncSummary) _applySummary(f.payload);
if (f.cmd == SmartcarProtocol.cmdGetVersion) _applyVersion(f.payload); if (f.cmd == EaiCarProtocol.cmdGetVersion) _applyVersion(f.payload);
if (f.cmd == SmartcarProtocol.cmdGetBattery && f.payload.isNotEmpty) { if (f.cmd == EaiCarProtocol.cmdGetBattery && f.payload.isNotEmpty) {
final b = f.payload[0] & 0xFF; final b = f.payload[0] & 0xFF;
_info = _info.copyWith( _info = _info.copyWith(
batteryPercent: b, battery: DeviceBattery(single: b)); batteryPercent: b, battery: DeviceBattery(single: b));
@ -627,7 +627,7 @@ class SmartcarSession implements DeviceSession {
} }
if (_state != DeviceConnectionState.ready) { if (_state != DeviceConnectionState.ready) {
throw DeviceException( throw DeviceException(
DeviceErrorCode.noActiveSession, 'Smartcar 会话未就绪'); DeviceErrorCode.noActiveSession, 'EaiCar 会话未就绪');
} }
} }
@ -642,7 +642,7 @@ class SmartcarSession implements DeviceSession {
@override @override
Future<int?> readBattery() async { Future<int?> readBattery() async {
_requireReady(); _requireReady();
final f = await _link.send(SmartcarProtocol.cmdGetBattery); final f = await _link.send(EaiCarProtocol.cmdGetBattery);
if (f == null || !f.success || f.payload.isEmpty) return null; if (f == null || !f.success || f.payload.isEmpty) return null;
return f.payload[0] & 0xFF; return f.payload[0] & 0xFF;
} }
@ -650,7 +650,7 @@ class SmartcarSession implements DeviceSession {
@override @override
Future<DeviceInfo> refreshInfo() async { Future<DeviceInfo> refreshInfo() async {
_requireReady(); _requireReady();
final f = await _link.send(SmartcarProtocol.cmdSyncSummary); final f = await _link.send(EaiCarProtocol.cmdSyncSummary);
if (f != null && f.success) _applySummary(f.payload); if (f != null && f.success) _applySummary(f.payload);
return _info; return _info;
} }
@ -691,14 +691,14 @@ class SmartcarSession implements DeviceSession {
throw DeviceException( throw DeviceException(
DeviceErrorCode.notSupported, 'opus 解码器起不来,拿不到设备麦克风'); DeviceErrorCode.notSupported, 'opus 解码器起不来,拿不到设备麦克风');
} }
final src = _SmartcarMic(route, _link, _closeMic); final src = _EaiCarMic(route, _link, _closeMic);
_mic = src; _mic = src;
return src; return src;
} }
/// 关掉某个麦克风源。**只有它还是当前那个才真去停解码器**—— /// 关掉某个麦克风源。**只有它还是当前那个才真去停解码器**——
/// 已经被新会话换掉的旧 mic 再 close 是空操作,否则会误伤新会话。 /// 已经被新会话换掉的旧 mic 再 close 是空操作,否则会误伤新会话。
Future<void> _closeMic(_SmartcarMic mic) async { Future<void> _closeMic(_EaiCarMic mic) async {
if (!identical(_mic, mic)) return; if (!identical(_mic, mic)) return;
_mic = null; _mic = null;
await _link.audioStop(); await _link.audioStop();
@ -711,12 +711,12 @@ class SmartcarSession implements DeviceSession {
throw DeviceException( throw DeviceException(
DeviceErrorCode.notSupported, 'openSpeaker($route)'); DeviceErrorCode.notSupported, 'openSpeaker($route)');
} }
// ⚠️ 与恒玄不同:Smartcar 的下行**不经 BLE**。AI 回复由手机正常播放, // ⚠️ 与恒玄不同:EaiCar 的下行**不经 BLE**。AI 回复由手机正常播放,
// 经典蓝牙 A2DP 把它送到设备喇叭。所以这个"汇"就是手机扬声器。 // 经典蓝牙 A2DP 把它送到设备喇叭。所以这个"汇"就是手机扬声器。
// 没配对 A2DP 时声音会从手机外放出来——功能仍然成立,只是不在车里响, // 没配对 A2DP 时声音会从手机外放出来——功能仍然成立,只是不在车里响,
// 所以这里只提示、不拦截。 // 所以这里只提示、不拦截。
if (!await isClassicAudioConnected()) { if (!await isClassicAudioConnected()) {
Logger.w('SmartcarSession', Logger.w('EaiCarSession',
'A2DP 未连接,AI 回复会从手机外放。设备 MAC=$_mac'); 'A2DP 未连接,AI 回复会从手机外放。设备 MAC=$_mac');
} }
// 同 openMic:先关旧的再建新的。这里每个 sink 自带播放器、互不共享, // 同 openMic:先关旧的再建新的。这里每个 sink 自带播放器、互不共享,
@ -724,7 +724,7 @@ class SmartcarSession implements DeviceSession {
final old = _spk; final old = _spk;
_spk = null; _spk = null;
await old?.close(); await old?.close();
final sink = _SmartcarPhoneSpeaker(); final sink = _EaiCarPhoneSpeaker();
_spk = sink; _spk = sink;
return sink; return sink;
} }
@ -742,11 +742,11 @@ class SmartcarSession implements DeviceSession {
return ClassicBluetooth.isA2dpConnected(mac); return ClassicBluetooth.isA2dpConnected(mac);
} }
SmartcarOtaPort? _otaPort; EaiCarOtaPort? _otaPort;
/// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。 /// 杰理 OTA 端口。同一会话复用一个实例(端口内部有互斥,重复 start 抛 ota_busy)。
@override @override
DeviceOtaPort? otaPort() => _otaPort ??= SmartcarOtaPort(_link, _plugin); DeviceOtaPort? otaPort() => _otaPort ??= EaiCarOtaPort(_link, _plugin);
/// 车载香薰**没有**设备端文件仓库(没有 TF 卡,录音不落设备),所以恒为 null, /// 车载香薰**没有**设备端文件仓库(没有 TF 卡,录音不落设备),所以恒为 null,
/// 能力集里也没有 `DeviceCapability.fileStore`。 /// 能力集里也没有 `DeviceCapability.fileStore`。
@ -757,7 +757,7 @@ class SmartcarSession implements DeviceSession {
/// 通用 feature key → C2 命令。 /// 通用 feature key → C2 命令。
/// ///
/// ⚠️ 这张表就是**冻结的协议**:同一个 key 永远发同样的字节。 /// ⚠️ 这张表就是**冻结的协议**:同一个 key 永远发同样的字节。
/// 改它之前先看 [SmartcarProtocol] 里那些 TODO(smartcar-cmd) 标记—— /// 改它之前先看 [EaiCarProtocol] 里那些 TODO(eaicar-cmd) 标记——
/// 有几条命令号本身还没经真机验证。 /// 有几条命令号本身还没经真机验证。
@override @override
Future<Map<String, Object?>> invokeFeature( Future<Map<String, Object?>> invokeFeature(
@ -772,16 +772,16 @@ class SmartcarSession implements DeviceSession {
case 'state.refresh': case 'state.refresh':
await refreshInfo(); await refreshInfo();
return {'ok': true}; return {'ok': true};
case 'smartcar.volume': case 'eaicar.volume':
final v = (args['value'] as num?)?.toInt() ?? 0; final v = (args['value'] as num?)?.toInt() ?? 0;
await _link.send(SmartcarProtocol.cmdSetVolume, payload: [v & 0xFF]); await _link.send(EaiCarProtocol.cmdSetVolume, payload: [v & 0xFF]);
return {'ok': true}; return {'ok': true};
case 'smartcar.brightness': case 'eaicar.brightness':
final v = (args['value'] as num?)?.toInt() ?? 0; final v = (args['value'] as num?)?.toInt() ?? 0;
await _link await _link
.send(SmartcarProtocol.cmdControlBrightness, payload: [v & 0xFF]); .send(EaiCarProtocol.cmdControlBrightness, payload: [v & 0xFF]);
return {'ok': true}; return {'ok': true};
case 'smartcar.app_connected': case 'eaicar.app_connected':
// 重发一次「APP 已连接」宣告(握手里已经发过;这里给排查/固件回落时用) // 重发一次「APP 已连接」宣告(握手里已经发过;这里给排查/固件回落时用)
await _announceAppConnected(); await _announceAppConnected();
return {'ok': true}; return {'ok': true};
@ -790,10 +790,10 @@ class SmartcarSession implements DeviceSession {
case DeviceFeatures.aiAccept: case DeviceFeatures.aiAccept:
// 设备发完 0xCC 0x11 就自己开麦了,App 这边只要把状态回报过去, // 设备发完 0xCC 0x11 就自己开麦了,App 这边只要把状态回报过去,
// 设备据此切"聆听中"的表情。**不需要**再下开麦命令。 // 设备据此切"聆听中"的表情。**不需要**再下开麦命令。
await _setAiState(SmartcarProtocol.aiStateListening); await _setAiState(EaiCarProtocol.aiStateListening);
return {'ok': true}; return {'ok': true};
case DeviceFeatures.aiExit: case DeviceFeatures.aiExit:
await _setAiState(SmartcarProtocol.aiStateClose); await _setAiState(EaiCarProtocol.aiStateClose);
await _link.audioStop(); await _link.audioStop();
return {'ok': true}; return {'ok': true};
case DeviceFeatures.aiState: case DeviceFeatures.aiState:
@ -802,10 +802,10 @@ class SmartcarSession implements DeviceSession {
// (2026-09-11 真机反馈"表情没反应"就是这个原因)。 // (2026-09-11 真机反馈"表情没反应"就是这个原因)。
final st = args['state']; final st = args['state'];
final v = switch (st) { final v = switch (st) {
DeviceAiState.listening => SmartcarProtocol.aiStateListening, DeviceAiState.listening => EaiCarProtocol.aiStateListening,
DeviceAiState.thinking => SmartcarProtocol.aiStateThinking, DeviceAiState.thinking => EaiCarProtocol.aiStateThinking,
DeviceAiState.speaking => SmartcarProtocol.aiStateSpeakStart, DeviceAiState.speaking => EaiCarProtocol.aiStateSpeakStart,
DeviceAiState.idle => SmartcarProtocol.aiStateSpeakEnd, DeviceAiState.idle => EaiCarProtocol.aiStateSpeakEnd,
_ => null, _ => null,
}; };
if (v == null) { if (v == null) {
@ -814,7 +814,7 @@ class SmartcarSession implements DeviceSession {
} }
await _setAiState(v); await _setAiState(v);
return {'ok': true}; return {'ok': true};
case 'smartcar.ai_state': case 'eaicar.ai_state':
final v = (args['value'] as num?)?.toInt(); final v = (args['value'] as num?)?.toInt();
if (v == null) { if (v == null) {
throw DeviceException(DeviceErrorCode.invalidArgument, '缺少 value'); throw DeviceException(DeviceErrorCode.invalidArgument, '缺少 value');
@ -823,13 +823,13 @@ class SmartcarSession implements DeviceSession {
return {'ok': true}; return {'ok': true};
// ---------- 经典蓝牙 / 音乐 ---------- // ---------- 经典蓝牙 / 音乐 ----------
case 'smartcar.music_playing': case 'eaicar.music_playing':
// 0x05:告诉设备手机在不在放音乐,设备据此显示"听音乐"表情 // 0x05:告诉设备手机在不在放音乐,设备据此显示"听音乐"表情
final on = args['value'] == true; final on = args['value'] == true;
await _link.send(SmartcarProtocol.cmdMusicPlayState, await _link.send(EaiCarProtocol.cmdMusicPlayState,
payload: [on ? 0x01 : 0x00]); payload: [on ? 0x01 : 0x00]);
return {'ok': true}; return {'ok': true};
case 'smartcar.a2dp_status': case 'eaicar.a2dp_status':
return { return {
'mac': _mac, 'mac': _mac,
'connected': await isClassicAudioConnected(), 'connected': await isClassicAudioConnected(),
@ -837,13 +837,13 @@ class SmartcarSession implements DeviceSession {
.map((d) => {'name': d.name, 'address': d.address}) .map((d) => {'name': d.name, 'address': d.address})
.toList(), .toList(),
}; };
case 'smartcar.a2dp_connect': case 'eaicar.a2dp_connect':
return await connectClassicAudio(); return await connectClassicAudio();
case 'smartcar.emotion': case 'eaicar.emotion':
final v = (args['value'] as num?)?.toInt() ?? 0; final v = (args['value'] as num?)?.toInt() ?? 0;
await _link await _link
.send(SmartcarProtocol.cmdControlEmotion, payload: [v & 0xFF]); .send(EaiCarProtocol.cmdControlEmotion, payload: [v & 0xFF]);
return {'ok': true}; return {'ok': true};
default: default:
throw DeviceException(DeviceErrorCode.notSupported, 'feature $key'); throw DeviceException(DeviceErrorCode.notSupported, 'feature $key');
@ -854,10 +854,10 @@ class SmartcarSession implements DeviceSession {
Future<void> _setAiState(int state) async { Future<void> _setAiState(int state) async {
try { try {
await _link await _link
.send(SmartcarProtocol.cmdAiState, payload: [state & 0xFF]); .send(EaiCarProtocol.cmdAiState, payload: [state & 0xFF]);
} catch (e) { } catch (e) {
// 状态同步失败不该中断会话——它只影响设备表情,不影响对话本身 // 状态同步失败不该中断会话——它只影响设备表情,不影响对话本身
Logger.w('SmartcarSession', '0x07 AI 状态($state) 发送失败: $e'); Logger.w('EaiCarSession', '0x07 AI 状态($state) 发送失败: $e');
} }
} }
@ -876,7 +876,7 @@ class SmartcarSession implements DeviceSession {
} }
try { try {
final status = await ClassicBluetooth.connectDevice(mac); final status = await ClassicBluetooth.connectDevice(mac);
Logger.i('SmartcarSession', Logger.i('EaiCarSession',
'经典蓝牙 connectDevice($mac) → $status(广播MAC=$_mac, BLE id=$deviceId)'); '经典蓝牙 connectDevice($mac) → $status(广播MAC=$_mac, BLE id=$deviceId)');
return { return {
'ok': status != null && status != 'unsupported_ios', 'ok': status != null && status != 'unsupported_ios',
@ -884,7 +884,7 @@ class SmartcarSession implements DeviceSession {
'mac': mac, 'mac': mac,
}; };
} catch (e) { } catch (e) {
Logger.w('SmartcarSession', '经典蓝牙配对失败: $e'); Logger.w('EaiCarSession', '经典蓝牙配对失败: $e');
return {'ok': false, 'status': 'error', 'error': '$e', 'mac': mac}; return {'ok': false, 'status': 'error', 'error': '$e', 'mac': mac};
} }
} }
@ -899,7 +899,7 @@ class SmartcarSession implements DeviceSession {
/// - 广播 MAC ≠ BLE MAC → 广播给的是另一个地址,按文档当经典 MAC 用 /// - 广播 MAC ≠ BLE MAC → 广播给的是另一个地址,按文档当经典 MAC 用
/// - 广播 MAC == BLE MAC → 广播给的其实是 BLE 地址,按 lumi 的规则减一 /// - 广播 MAC == BLE MAC → 广播给的其实是 BLE 地址,按 lumi 的规则减一
/// ///
/// ⚠️ **未经安卓真机验证**(TODO(smartcar-a2dp))。首次真机看 /// ⚠️ **未经安卓真机验证**(TODO(eaicar-a2dp))。首次真机看
/// `connectDevice(...)` 那行日志里三个地址的关系就能定论。 /// `connectDevice(...)` 那行日志里三个地址的关系就能定论。
String? get classicMacCandidate { String? get classicMacCandidate {
final mac = _mac; final mac = _mac;
@ -924,16 +924,16 @@ class SmartcarSession implements DeviceSession {
} }
/// 设备麦克风 → 业务层。原生已经把 opus 解成 16k/16bit/mono PCM。 /// 设备麦克风 → 业务层。原生已经把 opus 解成 16k/16bit/mono PCM。
class _SmartcarMic implements DeviceAudioSource { class _EaiCarMic implements DeviceAudioSource {
_SmartcarMic(this.route, this._link, this._onClose); _EaiCarMic(this.route, this._link, this._onClose);
@override @override
final AudioRoute route; final AudioRoute route;
final SmartcarLink _link; final EaiCarLink _link;
/// 交给会话去决定要不要真停:这个源可能早就被新会话换掉了, /// 交给会话去决定要不要真停:这个源可能早就被新会话换掉了,
/// 直接 `audioStop()` 会把新会话的解码器一起关了。 /// 直接 `audioStop()` 会把新会话的解码器一起关了。
final Future<void> Function(_SmartcarMic) _onClose; final Future<void> Function(_EaiCarMic) _onClose;
@override @override
AudioFormat get format => AudioFormat.pcm16kMono; AudioFormat get format => AudioFormat.pcm16kMono;
@ -947,10 +947,10 @@ class _SmartcarMic implements DeviceAudioSource {
/// AI 回复的去处:**手机扬声器**(经典蓝牙 A2DP 连着时就从设备喇叭出来)。 /// AI 回复的去处:**手机扬声器**(经典蓝牙 A2DP 连着时就从设备喇叭出来)。
/// ///
/// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——Smartcar 的下行不经 BLE, /// 与恒玄那种"PCM 编码后经 BLE 灌给设备"完全不同——EaiCar 的下行不经 BLE,
/// 所以这里不写任何字节到链路上,只是把 PCM 交给手机播放。 /// 所以这里不写任何字节到链路上,只是把 PCM 交给手机播放。
class _SmartcarPhoneSpeaker implements DeviceAudioSink { class _EaiCarPhoneSpeaker implements DeviceAudioSink {
_SmartcarPhoneSpeaker(); _EaiCarPhoneSpeaker();
/// ⚠️ `playbackOnly: true` 是这条链路能从**香薰喇叭**出声的关键: /// ⚠️ `playbackOnly: true` 是这条链路能从**香薰喇叭**出声的关键:
/// 采音在设备上(BLE opus),手机一个字节都不录,会话就该用 playback 类别。 /// 采音在设备上(BLE opus),手机一个字节都不录,会话就该用 playback 类别。

72
apps/client/lib/devices/smartcar/smartcar_link.dart → apps/client/lib/devices/eaicar/eaicar_link.dart

@ -4,13 +4,13 @@ import 'package:device_smartcar/device_smartcar.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'smartcar_protocol.dart'; import 'eaicar_protocol.dart';
/// Smartcar 的 BLE 链路:扫描 / 连接 / 收发 C2 帧。 /// EaiCar 的 BLE 链路:扫描 / 连接 / 收发 C2 帧。
/// ///
/// ## 为什么是原生插件、不是 flutter_blue_plus(2026-09-10 真机踩出来的) /// ## 为什么是原生插件、不是 flutter_blue_plus(2026-09-10 真机踩出来的)
/// ///
/// Smartcar 是杰理芯片,**固件要求先过杰理 RCSP 认证**(在服务 `AE00` 上,由闭源 /// EaiCar 是杰理芯片,**固件要求先过杰理 RCSP 认证**(在服务 `AE00` 上,由闭源
/// `JL_BLEKit` 的 `JL_Assist` 跑挑战应答)才把手机当成已连接:认证前设备屏幕显示 /// `JL_BLEKit` 的 `JL_Assist` 跑挑战应答)才把手机当成已连接:认证前设备屏幕显示
/// BLE 未连接、`ABC0` 私有通道的命令也不理。lumi 两端代码里都写明了这一点 /// BLE 未连接、`ABC0` 私有通道的命令也不理。lumi 两端代码里都写明了这一点
/// (Android:「全新的 BLE 连接,没有走 JLWatchManager 认证,设备拒绝响应任何 RCSP 命令」)。 /// (Android:「全新的 BLE 连接,没有走 JLWatchManager 认证,设备拒绝响应任何 RCSP 命令」)。
@ -20,7 +20,7 @@ import 'smartcar_protocol.dart';
/// CBPeripheralDelegate 的回调转交给 SDK,GATT 必须由自己持有,flutter_blue_plus /// CBPeripheralDelegate 的回调转交给 SDK,GATT 必须由自己持有,flutter_blue_plus
/// 做不到。于是链路收进原生插件 `device_smartcar`,这里只剩通道封装。 /// 做不到。于是链路收进原生插件 `device_smartcar`,这里只剩通道封装。
/// ///
/// 组帧 / CRC / 广播解析仍在 Dart([SmartcarProtocol]),单测继续守着。 /// 组帧 / CRC / 广播解析仍在 Dart([EaiCarProtocol]),单测继续守着。
/// ///
/// ⚠️ 一部手机对同一个 peripheral **只能有一条 GATT 连接**。阶段 6 的 Opus 上行 /// ⚠️ 一部手机对同一个 peripheral **只能有一条 GATT 连接**。阶段 6 的 Opus 上行
/// 要在原生插件里复用这条连接订阅音频特征,**不能另起一条**。 /// 要在原生插件里复用这条连接订阅音频特征,**不能另起一条**。
@ -32,24 +32,24 @@ import 'smartcar_protocol.dart';
/// ///
/// ⚠️ 旧广播名 `Smartcar` 已于 2026-09-15 移除,刷着旧名字的样机从此扫不到。 /// ⚠️ 旧广播名 `Smartcar` 已于 2026-09-15 移除,刷着旧名字的样机从此扫不到。
/// 别"顺手加回来"——加回去准入就整个失效了。 /// 别"顺手加回来"——加回去准入就整个失效了。
const List<String> kSmartcarAllowedNames = <String>['EaiCar']; const List<String> kEaiCarAllowedNames = <String>['EaiCar'];
bool isAllowedSmartcarName(String raw) { bool isAllowedEaiCarName(String raw) {
final n = raw.trim().toUpperCase(); final n = raw.trim().toUpperCase();
if (n.isEmpty) return false; if (n.isEmpty) return false;
for (final allowed in kSmartcarAllowedNames) { for (final allowed in kEaiCarAllowedNames) {
if (n.startsWith(allowed.toUpperCase())) return true; if (n.startsWith(allowed.toUpperCase())) return true;
} }
return false; return false;
} }
class SmartcarLink { class EaiCarLink {
static const String _tag = 'SmartcarLink'; static const String _tag = 'EaiCarLink';
final DeviceSmartcar _native = DeviceSmartcar.instance; final DeviceSmartcar _native = DeviceSmartcar.instance;
StreamSubscription<Map<String, dynamic>>? _eventSub; StreamSubscription<Map<String, dynamic>>? _eventSub;
final _frames = StreamController<SmartcarFrame>.broadcast(); final _frames = StreamController<EaiCarFrame>.broadcast();
final _connState = StreamController<bool>.broadcast(); final _connState = StreamController<bool>.broadcast();
final _diag = StreamController<Map<String, Object?>>.broadcast(); final _diag = StreamController<Map<String, Object?>>.broadcast();
final _ota = StreamController<Map<String, Object?>>.broadcast(); final _ota = StreamController<Map<String, Object?>>.broadcast();
@ -63,7 +63,7 @@ class SmartcarLink {
Stream<Map<String, Object?>> get otaEvents => _ota.stream; Stream<Map<String, Object?>> get otaEvents => _ota.stream;
/// 解好的帧(CRC 已校验,坏帧不会出现在这里) /// 解好的帧(CRC 已校验,坏帧不会出现在这里)
Stream<SmartcarFrame> get frames => _frames.stream; Stream<EaiCarFrame> get frames => _frames.stream;
/// 链路通断。**true = 杰理认证已通过**,不是 GATT 连上。 /// 链路通断。**true = 杰理认证已通过**,不是 GATT 连上。
Stream<bool> get connected => _connState.stream; Stream<bool> get connected => _connState.stream;
@ -80,13 +80,13 @@ class SmartcarLink {
String? get deviceId => _deviceId; String? get deviceId => _deviceId;
/// 连上时记下的广播信息(MAC / License 从这里来) /// 连上时记下的广播信息(MAC / License 从这里来)
SmartcarAdvertisement? advertisement; EaiCarAdvertisement? advertisement;
/// 最近一次原生上报的链路状态与原因,连接失败时拼进错误信息 /// 最近一次原生上报的链路状态与原因,连接失败时拼进错误信息
String _lastState = 'disconnected'; String _lastState = 'disconnected';
String? _lastReason; String? _lastReason;
SmartcarLink() { EaiCarLink() {
_eventSub = _native.events.listen(_onNativeEvent, onError: (Object e) { _eventSub = _native.events.listen(_onNativeEvent, onError: (Object e) {
Logger.w(_tag, '原生事件流错误: $e'); Logger.w(_tag, '原生事件流错误: $e');
}); });
@ -94,7 +94,7 @@ class SmartcarLink {
// ───────────────────────────── 事件路由 ───────────────────────────── // ───────────────────────────── 事件路由 ─────────────────────────────
StreamController<SmartcarScanResult>? _scanCtrl; StreamController<EaiCarScanResult>? _scanCtrl;
void _onNativeEvent(Map<String, dynamic> e) { void _onNativeEvent(Map<String, dynamic> e) {
switch (e['type']) { switch (e['type']) {
@ -152,7 +152,7 @@ class SmartcarLink {
} }
} }
/// 启动杰理 OTA(原生 `SmartcarOta`:cmdOTAData + stage2 扫描回连)。失败抛 [StateError]。 /// 启动杰理 OTA(原生 `EaiCarOta`:cmdOTAData + stage2 扫描回连)。失败抛 [StateError]。
Future<void> otaStart(String filePath) async { Future<void> otaStart(String filePath) async {
try { try {
await _native.otaStart(filePath); await _native.otaStart(filePath);
@ -197,11 +197,11 @@ class SmartcarLink {
/// 扫本品类设备。原生侧全量扫(不加 service 过滤——CoreBluetooth 的过滤只认 /// 扫本品类设备。原生侧全量扫(不加 service 过滤——CoreBluetooth 的过滤只认
/// 「Service UUIDs」AD 字段,C2 的身份在厂商数据里,加了一台都扫不到),这里按两条判定收: /// 「Service UUIDs」AD 字段,C2 的身份在厂商数据里,加了一台都扫不到),这里按两条判定收:
/// 1. 厂商数据能解出合法 C2 广播且 CID == 0x8505(主判据,MAC/License 一并解出) /// 1. 厂商数据能解出合法 C2 广播且 CID == 0x8505(主判据,MAC/License 一并解出)
/// 2. 广播名含 `smartcar`(兜底) /// 2. 广播名含 `eaicar`(兜底)
Stream<SmartcarScanResult> scan({ Stream<EaiCarScanResult> scan({
Duration timeout = const Duration(seconds: 15), Duration timeout = const Duration(seconds: 15),
}) { }) {
final ctrl = StreamController<SmartcarScanResult>(); final ctrl = StreamController<EaiCarScanResult>();
_scanCtrl?.close(); _scanCtrl?.close();
_scanCtrl = ctrl; _scanCtrl = ctrl;
ctrl.onCancel = () async { ctrl.onCancel = () async {
@ -218,7 +218,7 @@ class SmartcarLink {
_diagLine('(扫描启动失败)', msg); _diagLine('(扫描启动失败)', msg);
if (!ctrl.isClosed) ctrl.addError(StateError(msg)); if (!ctrl.isClosed) ctrl.addError(StateError(msg));
} on MissingPluginException { } on MissingPluginException {
const msg = '本平台没有 Smartcar 原生链路(只支持 Android / iOS)'; const msg = '本平台没有 EaiCar 原生链路(只支持 Android / iOS)';
if (!ctrl.isClosed) ctrl.addError(StateError(msg)); if (!ctrl.isClosed) ctrl.addError(StateError(msg));
} }
}(); }();
@ -247,15 +247,15 @@ class SmartcarLink {
payload = mfg.sublist(2); payload = mfg.sublist(2);
} }
SmartcarAdvertisement? adv; EaiCarAdvertisement? adv;
// 云衍厂商 ID 0x8505;0x0585 是字节序反过来的写法,两个都收(lumi 的常量表并列了两者) // 云衍厂商 ID 0x8505;0x0585 是字节序反过来的写法,两个都收(lumi 的常量表并列了两者)
final cidMatched = companyId == 0x8505 || companyId == 0x0585; final cidMatched = companyId == 0x8505 || companyId == 0x0585;
if (cidMatched) { if (cidMatched) {
// 补回 [长度, 0xFF, CID低, CID高]:解析器按协议文档吃完整 AD 结构 // 补回 [长度, 0xFF, CID低, CID高]:解析器按协议文档吃完整 AD 结构
adv = SmartcarProtocol.parseManufacturerData( adv = EaiCarProtocol.parseManufacturerData(
<int>[payload.length + 3, 0xFF, 0x05, 0x85, ...payload]); <int>[payload.length + 3, 0xFF, 0x05, 0x85, ...payload]);
} }
final nameOk = isAllowedSmartcarName(name); final nameOk = isAllowedEaiCarName(name);
if (!_diag.isClosed) { if (!_diag.isClosed) {
_diag.add({ _diag.add({
@ -282,7 +282,7 @@ class SmartcarLink {
} }
final c = _scanCtrl; final c = _scanCtrl;
if (c != null && !c.isClosed) { if (c != null && !c.isClosed) {
c.add(SmartcarScanResult(id: id, name: name, rssi: rssi, advertisement: adv)); c.add(EaiCarScanResult(id: id, name: name, rssi: rssi, advertisement: adv));
} }
} }
@ -296,7 +296,7 @@ class SmartcarLink {
Future<void> connect( Future<void> connect(
String deviceId, { String deviceId, {
Duration timeout = const Duration(seconds: 45), Duration timeout = const Duration(seconds: 45),
SmartcarAdvertisement? adv, EaiCarAdvertisement? adv,
}) async { }) async {
advertisement = adv; advertisement = adv;
_lastReason = null; _lastReason = null;
@ -305,7 +305,7 @@ class SmartcarLink {
} on PlatformException catch (e) { } on PlatformException catch (e) {
throw StateError(e.message ?? e.code); throw StateError(e.message ?? e.code);
} on MissingPluginException { } on MissingPluginException {
throw StateError('本平台没有 Smartcar 原生链路(只支持 Android / iOS)'); throw StateError('本平台没有 EaiCar 原生链路(只支持 Android / iOS)');
} }
// 原生只在 ready 时 resolve;state 事件与 result 谁先到不确定,这里补齐 // 原生只在 ready 时 resolve;state 事件与 result 谁先到不确定,这里补齐
if (!_isConnected) { if (!_isConnected) {
@ -353,10 +353,10 @@ class SmartcarLink {
// ───────────────────────── 收发 ───────────────────────── // ───────────────────────── 收发 ─────────────────────────
/// 等应答的命令:cmd → 等待者。同一条命令不允许并发两次(应答里没有序号)。 /// 等应答的命令:cmd → 等待者。同一条命令不允许并发两次(应答里没有序号)。
final Map<int, Completer<SmartcarFrame>> _pending = {}; final Map<int, Completer<EaiCarFrame>> _pending = {};
void _onValue(List<int> value) { void _onValue(List<int> value) {
final f = SmartcarProtocol.parseFrame(value); final f = EaiCarProtocol.parseFrame(value);
if (f == null) { if (f == null) {
Logger.w(_tag, '丢弃非法帧: ${_hex(value)}'); Logger.w(_tag, '丢弃非法帧: ${_hex(value)}');
return; return;
@ -368,9 +368,9 @@ class SmartcarLink {
if (!_frames.isClosed) _frames.add(f); if (!_frames.isClosed) _frames.add(f);
} }
/// 发一条命令。[expectResponse] 不传就按 [SmartcarProtocol.expectsResponse] 判断—— /// 发一条命令。[expectResponse] 不传就按 [EaiCarProtocol.expectsResponse] 判断——
/// 那四条设备不回包的命令若强行等,只会白等到超时并把成功记成失败。 /// 那四条设备不回包的命令若强行等,只会白等到超时并把成功记成失败。
Future<SmartcarFrame?> send( Future<EaiCarFrame?> send(
int cmd, { int cmd, {
List<int> payload = const [], List<int> payload = const [],
bool? expectResponse, bool? expectResponse,
@ -379,17 +379,17 @@ class SmartcarLink {
if (!_isConnected) { if (!_isConnected) {
throw StateError('链路未就绪($_lastState),不能发 0x${cmd.toRadixString(16)}'); throw StateError('链路未就绪($_lastState),不能发 0x${cmd.toRadixString(16)}');
} }
final wantResp = expectResponse ?? SmartcarProtocol.expectsResponse(cmd); final wantResp = expectResponse ?? EaiCarProtocol.expectsResponse(cmd);
Completer<SmartcarFrame>? waiter; Completer<EaiCarFrame>? waiter;
if (wantResp) { if (wantResp) {
final exist = _pending[cmd]; final exist = _pending[cmd];
if (exist != null) return exist.future; if (exist != null) return exist.future;
waiter = Completer<SmartcarFrame>(); waiter = Completer<EaiCarFrame>();
_pending[cmd] = waiter; _pending[cmd] = waiter;
} }
final packet = SmartcarProtocol.buildPacket(cmd, payload); final packet = EaiCarProtocol.buildPacket(cmd, payload);
final ok = await _native.writeCustom(packet); final ok = await _native.writeCustom(packet);
if (!ok) { if (!ok) {
_pending.remove(cmd); _pending.remove(cmd);
@ -415,8 +415,8 @@ class SmartcarLink {
} }
/// 一条扫描结果 /// 一条扫描结果
class SmartcarScanResult { class EaiCarScanResult {
const SmartcarScanResult({ const EaiCarScanResult({
required this.id, required this.id,
required this.name, required this.name,
required this.rssi, required this.rssi,
@ -429,5 +429,5 @@ class SmartcarScanResult {
/// 厂商广播段。**可能为 null**:仅按名字命中、厂商段解不出来时就是空的, /// 厂商广播段。**可能为 null**:仅按名字命中、厂商段解不出来时就是空的,
/// 此时拿不到 MAC,绑定确权会走 unknown(放行但不登记)。 /// 此时拿不到 MAC,绑定确权会走 unknown(放行但不登记)。
final SmartcarAdvertisement? advertisement; final EaiCarAdvertisement? advertisement;
} }

18
apps/client/lib/devices/smartcar/smartcar_ota_port.dart → apps/client/lib/devices/eaicar/eaicar_ota_port.dart

@ -3,12 +3,12 @@ import 'dart:async';
import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../core/utils/logger.dart'; import '../../core/utils/logger.dart';
import 'smartcar_device_plugin.dart'; import 'eaicar_device_plugin.dart';
import 'smartcar_link.dart'; import 'eaicar_link.dart';
/// Smartcar(杰理方案)的 OTA 端口。 /// EaiCar(杰理方案)的 OTA 端口。
/// ///
/// 推送本身在原生 `SmartcarOta`(从 lumi `PbMangerImp` 搬:`cmdOTAData` + 0xfc 后 /// 推送本身在原生 `EaiCarOta`(从 lumi `PbMangerImp` 搬:`cmdOTAData` + 0xfc 后
/// 扫描回连 + 认证 + 续传),这里只做三件事: /// 扫描回连 + 认证 + 续传),这里只做三件事:
/// 1. 把原生事件翻译成 [DeviceOtaProgress]; /// 1. 把原生事件翻译成 [DeviceOtaProgress];
/// 2. 终态锁定——成功后迟到的 failed/cancelled 不能把它覆盖回去(恒玄踩过); /// 2. 终态锁定——成功后迟到的 failed/cancelled 不能把它覆盖回去(恒玄踩过);
@ -20,13 +20,13 @@ import 'smartcar_link.dart';
/// ///
/// ⚠️ 上层 `DeviceOtaController` 的看门狗 90s 没进度就判失败,而 stage2 等待可达 3 分钟, /// ⚠️ 上层 `DeviceOtaController` 的看门狗 90s 没进度就判失败,而 stage2 等待可达 3 分钟,
/// 原生在这段窗口每 15s 发一次 `stage2` 心跳事件喂它。 /// 原生在这段窗口每 15s 发一次 `stage2` 心跳事件喂它。
class SmartcarOtaPort implements DeviceOtaPort { class EaiCarOtaPort implements DeviceOtaPort {
SmartcarOtaPort(this._link, this._plugin); EaiCarOtaPort(this._link, this._plugin);
static const String _tag = 'SmartcarOtaPort'; static const String _tag = 'EaiCarOtaPort';
final SmartcarLink _link; final EaiCarLink _link;
final SmartcarDevicePlugin _plugin; final EaiCarDevicePlugin _plugin;
final StreamController<DeviceOtaProgress> _progress = final StreamController<DeviceOtaProgress> _progress =
StreamController<DeviceOtaProgress>.broadcast(); StreamController<DeviceOtaProgress>.broadcast();

54
apps/client/lib/devices/smartcar/smartcar_protocol.dart → apps/client/lib/devices/eaicar/eaicar_protocol.dart

@ -1,9 +1,9 @@
import 'dart:typed_data'; import 'dart:typed_data';
/// Smartcar(车载香薰,方案代号 **C2**)的 BLE 私有协议。 /// EaiCar(车载香薰,方案代号 **C2**)的 BLE 私有协议。
/// ///
/// 纯函数 + 纯数据,不碰蓝牙栈,方便单测把每个字节钉死 /// 纯函数 + 纯数据,不碰蓝牙栈,方便单测把每个字节钉死
/// (`test/devices/smartcar_protocol_test.dart`)。 /// (`test/devices/eaicar_protocol_test.dart`)。
/// ///
/// 帧格式: /// 帧格式:
/// ``` /// ```
@ -14,8 +14,8 @@ import 'dart:typed_data';
/// CRC 覆盖**除自己以外的全部前导字节**,算法见 [crc8Maxim]。 /// CRC 覆盖**除自己以外的全部前导字节**,算法见 [crc8Maxim]。
/// ///
/// ⚠️ 多字节字段一律**小端**。 /// ⚠️ 多字节字段一律**小端**。
class SmartcarProtocol { class EaiCarProtocol {
SmartcarProtocol._(); EaiCarProtocol._();
// ─────────────────────────── GATT ─────────────────────────── // ─────────────────────────── GATT ───────────────────────────
@ -50,10 +50,10 @@ class SmartcarProtocol {
// ───────────────────────── 命令号 ───────────────────────── // ───────────────────────── 命令号 ─────────────────────────
// //
// ⚠️⚠️ 下面标了 TODO(smartcar-cmd) 的几条,**协议文档与 lumi 代码是冲突的**。 // ⚠️⚠️ 下面标了 TODO(eaicar-cmd) 的几条,**协议文档与 lumi 代码是冲突的**。
// 按 2026-09-10 的决定:一律以 lumi 的 BleConst.kt 为准(那是真机上跑着的), // 按 2026-09-10 的决定:一律以 lumi 的 BleConst.kt 为准(那是真机上跑着的),
// 协议文档只作参考。样机到手后逐条复核,改这里并同步更新 // 协议文档只作参考。样机到手后逐条复核,改这里并同步更新
// docs/Smartcar车载香薰-接入设计与落地文档.md 的 2.4 节。 // docs/EaiCar车载香薰-接入设计与落地文档.md 的 2.4 节。
// //
// ⚠️ 复核时**只扫查询类命令**。0x0C 恢复出厂 / 0x0D 启停开关 / 0x0E 遥控电机 // ⚠️ 复核时**只扫查询类命令**。0x0C 恢复出厂 / 0x0D 启停开关 / 0x0E 遥控电机
// 会真的改设备状态,别拿来试探。 // 会真的改设备状态,别拿来试探。
@ -105,7 +105,7 @@ class SmartcarProtocol {
static const int aiStateClose = 0x03; static const int aiStateClose = 0x03;
static const int aiStateListening = 0x04; static const int aiStateListening = 0x04;
/// TODO(smartcar-cmd): 未经真机验证 —— 协议文档里没有这条, /// TODO(eaicar-cmd): 未经真机验证 —— 协议文档里没有这条,
/// BleConst.kt 说 CMD_CONTROL_MUSIC_BEAT,取后者。 /// BleConst.kt 说 CMD_CONTROL_MUSIC_BEAT,取后者。
static const int cmdControlMusicBeat = 0x08; static const int cmdControlMusicBeat = 0x08;
@ -128,7 +128,7 @@ class SmartcarProtocol {
/// ⚠️ 2026-09-10 真机定论:GATT 连上 + 杰理认证通过 + 0x15 都能应答之后, /// ⚠️ 2026-09-10 真机定论:GATT 连上 + 杰理认证通过 + 0x15 都能应答之后,
/// 设备屏幕**仍显示 BLE 未连接**;发了这条(`AA 10 02 03 00`)设备回 `BB 10 01 64`, /// 设备屏幕**仍显示 BLE 未连接**;发了这条(`AA 10 02 03 00`)设备回 `BB 10 01 64`,
/// 屏幕才认。也就是说固件把"手机已连接"这个状态交给 APP 来宣告。 /// 屏幕才认。也就是说固件把"手机已连接"这个状态交给 APP 来宣告。
/// 所以它是握手的一部分([SmartcarSession._handshake]),不是可选功能。 /// 所以它是握手的一部分([EaiCarSession._handshake]),不是可选功能。
/// ///
/// 载荷 2 字节:`[设备类型, 车企编号]`,设备类型 0 车机 / 1 小程序 / 2 安卓 / 3 iOS / 4 鸿蒙, /// 载荷 2 字节:`[设备类型, 车企编号]`,设备类型 0 车机 / 1 小程序 / 2 安卓 / 3 iOS / 4 鸿蒙,
/// 车企编号 0 默认 / 1 长城。lumi 没发过这条(它不是车载产品)。 /// 车企编号 0 默认 / 1 长城。lumi 没发过这条(它不是车载产品)。
@ -153,7 +153,7 @@ class SmartcarProtocol {
static const int cmdRandomExpressionInterval = 0x13; static const int cmdRandomExpressionInterval = 0x13;
static const int cmdMotorSwitch = 0x14; static const int cmdMotorSwitch = 0x14;
/// 同步参数汇总。应答 data = 14 字节,见 [SmartcarAllSettings] /// 同步参数汇总。应答 data = 14 字节,见 [EaiCarAllSettings]
static const int cmdSyncSummary = 0x15; static const int cmdSyncSummary = 0x15;
/// 同步手机时间(协议文档没写,lumi 代码有,真机回 `BB 17 01 64`): /// 同步手机时间(协议文档没写,lumi 代码有,真机回 `BB 17 01 64`):
@ -217,7 +217,7 @@ class SmartcarProtocol {
/// 返回 null 表示这段不是合法帧(长度不够 / 帧头不认识 / CRC 不符)。 /// 返回 null 表示这段不是合法帧(长度不够 / 帧头不认识 / CRC 不符)。
/// **CRC 不符一律丢弃**,不要「先用着再说」——错帧解出来的电量、版本号 /// **CRC 不符一律丢弃**,不要「先用着再说」——错帧解出来的电量、版本号
/// 看着都像合法值,查起来极费劲(恒玄那次就是被错位解析出的垃圾值带偏的)。 /// 看着都像合法值,查起来极费劲(恒玄那次就是被错位解析出的垃圾值带偏的)。
static SmartcarFrame? parseFrame(List<int> raw) { static EaiCarFrame? parseFrame(List<int> raw) {
if (raw.length < 4) return null; if (raw.length < 4) return null;
final header = raw[0] & 0xFF; final header = raw[0] & 0xFF;
if (!isFrameHeader(header)) return null; if (!isFrameHeader(header)) return null;
@ -227,7 +227,7 @@ class SmartcarProtocol {
final expected = raw[len + 3] & 0xFF; final expected = raw[len + 3] & 0xFF;
final calc = crc8Maxim(raw.sublist(0, len + 3)); final calc = crc8Maxim(raw.sublist(0, len + 3));
if (expected != calc) return null; if (expected != calc) return null;
return SmartcarFrame( return EaiCarFrame(
header: header, header: header,
cmd: cmd, cmd: cmd,
payload: Uint8List.fromList(raw.sublist(3, 3 + len)), payload: Uint8List.fromList(raw.sublist(3, 3 + len)),
@ -238,13 +238,13 @@ class SmartcarProtocol {
/// 解厂商自定义广播段(传完整 AD 结构:`[长度, 0xFF, CID低, CID高, ...]`)。 /// 解厂商自定义广播段(传完整 AD 结构:`[长度, 0xFF, CID低, CID高, ...]`)。
/// ///
/// **真机实测布局(2026-09-10,Smartcar 样机,与 lumi iOS `parseMacFromManufacturer` /// **真机实测布局(2026-09-10,EaiCar 样机,与 lumi iOS `parseMacFromManufacturer`
/// 的 0x0585 分支一致)**:CID 之后只有 7 字节 /// 的 0x0585 分支一致)**:CID 之后只有 7 字节
/// ``` /// ```
/// [0] 长度 [1] Type=0xFF /// [0] 长度 [1] Type=0xFF
/// [2-3] CID 0x05 0x85 → 小端 0x8505 /// [2-3] CID 0x05 0x85 → 小端 0x8505
/// [4-9] MAC 原样、不异或、不倒序:a9 1a b3 21 e4 dd = A9:1A:B3:21:E4:DD /// [4-9] MAC 原样、不异或、不倒序:a9 1a b3 21 e4 dd = A9:1A:B3:21:E4:DD
/// [10] 1 字节状态 TODO(smartcar-adv):未连经典蓝牙时实测为 0x00, /// [10] 1 字节状态 TODO(eaicar-adv):未连经典蓝牙时实测为 0x00,
/// 按协议文档「0x0C = 经典蓝牙已连」处理,连上 A2DP 后要复核 /// 按协议文档「0x0C = 经典蓝牙已连」处理,连上 A2DP 后要复核
/// ``` /// ```
/// 实测原始厂商数据:`05 85 a9 1a b3 21 e4 dd 00`,解出的 MAC 与设备已知地址逐字节相同, /// 实测原始厂商数据:`05 85 a9 1a b3 21 e4 dd 00`,解出的 MAC 与设备已知地址逐字节相同,
@ -253,7 +253,7 @@ class SmartcarProtocol {
/// ⚠️ 协议文档里那套「26 字节:BID/PID/MAC XOR 0x5A/License/设备类型」**这台固件没有发**, /// ⚠️ 协议文档里那套「26 字节:BID/PID/MAC XOR 0x5A/License/设备类型」**这台固件没有发**,
/// 第一版照文档写的解析器要求 26 字节,于是永远解不出、MAC 恒为空。 /// 第一版照文档写的解析器要求 26 字节,于是永远解不出、MAC 恒为空。
/// BID/PID/License 现在从广播里拿不到,字段保留为 0 / 空串。 /// BID/PID/License 现在从广播里拿不到,字段保留为 0 / 空串。
static SmartcarAdvertisement? parseManufacturerData(List<int>? raw) { static EaiCarAdvertisement? parseManufacturerData(List<int>? raw) {
if (raw == null || raw.length < 10) return null; if (raw == null || raw.length < 10) return null;
if ((raw[1] & 0xFF) != 0xFF) return null; if ((raw[1] & 0xFF) != 0xFF) return null;
int le16(int off) => (raw[off] & 0xFF) | ((raw[off + 1] & 0xFF) << 8); int le16(int off) => (raw[off] & 0xFF) | ((raw[off + 1] & 0xFF) << 8);
@ -262,7 +262,7 @@ class SmartcarProtocol {
.map((b) => (b & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase()) .map((b) => (b & 0xFF).toRadixString(16).padLeft(2, '0').toUpperCase())
.join(':'); .join(':');
final state = raw.length > 10 ? raw[10] & 0xFF : 0; final state = raw.length > 10 ? raw[10] & 0xFF : 0;
return SmartcarAdvertisement( return EaiCarAdvertisement(
cid: le16(2), cid: le16(2),
bid: 0, bid: 0,
pid: 0, pid: 0,
@ -276,8 +276,8 @@ class SmartcarProtocol {
/// 一帧解好的数据 /// 一帧解好的数据
class SmartcarFrame { class EaiCarFrame {
const SmartcarFrame({ const EaiCarFrame({
required this.header, required this.header,
required this.cmd, required this.cmd,
required this.payload, required this.payload,
@ -287,8 +287,8 @@ class SmartcarFrame {
final int cmd; final int cmd;
final Uint8List payload; final Uint8List payload;
bool get isResponse => header == SmartcarProtocol.headerResponse; bool get isResponse => header == EaiCarProtocol.headerResponse;
bool get isNotify => header == SmartcarProtocol.headerNotify; bool get isNotify => header == EaiCarProtocol.headerNotify;
/// 设备回的应答里,`len == 0` 表示这条命令**执行失败** /// 设备回的应答里,`len == 0` 表示这条命令**执行失败**
/// (协议就是这么设计的:成功才带数据)。 /// (协议就是这么设计的:成功才带数据)。
@ -296,13 +296,13 @@ class SmartcarFrame {
@override @override
String toString() => String toString() =>
'SmartcarFrame(0x${header.toRadixString(16)}, cmd=0x${cmd.toRadixString(16)}, ' 'EaiCarFrame(0x${header.toRadixString(16)}, cmd=0x${cmd.toRadixString(16)}, '
'len=${payload.length})'; 'len=${payload.length})';
} }
/// 广播里带的设备身份 /// 广播里带的设备身份
class SmartcarAdvertisement { class EaiCarAdvertisement {
const SmartcarAdvertisement({ const EaiCarAdvertisement({
required this.cid, required this.cid,
required this.bid, required this.bid,
required this.pid, required this.pid,
@ -334,13 +334,13 @@ class SmartcarAdvertisement {
final int deviceType; final int deviceType;
@override @override
String toString() => 'SmartcarAdv(pid=0x${pid.toRadixString(16)}, ' String toString() => 'EaiCarAdv(pid=0x${pid.toRadixString(16)}, '
'mac=$macAddress, btConnected=$classicBtConnected)'; 'mac=$macAddress, btConnected=$classicBtConnected)';
} }
/// `0x15 同步参数汇总` 的 14 字节应答 /// `0x15 同步参数汇总` 的 14 字节应答
class SmartcarAllSettings { class EaiCarAllSettings {
const SmartcarAllSettings({ const EaiCarAllSettings({
required this.otaAbnormal, required this.otaAbnormal,
required this.volume, required this.volume,
required this.randomExprFreq, required this.randomExprFreq,
@ -382,10 +382,10 @@ class SmartcarAllSettings {
/// ///
/// ⚠️ **`[1]` 音量实测是 0**。如果这个偏移没错,说明样机当前是静音的—— /// ⚠️ **`[1]` 音量实测是 0**。如果这个偏移没错,说明样机当前是静音的——
/// A2DP 接上后 AI 回复也可能听不见。真机验声音前先确认这一项。 /// A2DP 接上后 AI 回复也可能听不见。真机验声音前先确认这一项。
static SmartcarAllSettings? parse(List<int> d) { static EaiCarAllSettings? parse(List<int> d) {
if (d.length < 14) return null; if (d.length < 14) return null;
int u(int i) => d[i] & 0xFF; int u(int i) => d[i] & 0xFF;
return SmartcarAllSettings( return EaiCarAllSettings(
otaAbnormal: u(0) != 0, otaAbnormal: u(0) != 0,
volume: u(1), volume: u(1),
randomExprFreq: u(2), randomExprFreq: u(2),

6
apps/client/lib/modules/devices/views/device_category_view.dart

@ -75,8 +75,8 @@ class DeviceCategoryView extends StatelessWidget {
Get.toNamed(Routes.besScan); Get.toNamed(Routes.besScan);
break; break;
case DeviceCategories.carAroma: case DeviceCategories.carAroma:
// Smartcar 车载香薰,走 lib/devices/smartcar 那条 BLE 链路(C2 私有协议)。 // EaiCar 车载香薰,走 lib/devices/eaicar 那条 BLE 链路(C2 私有协议)。
Get.toNamed(Routes.smartcarScan); Get.toNamed(Routes.eaicarScan);
break; break;
case DeviceCategories.phoneHolder: case DeviceCategories.phoneHolder:
// EaiRec 录音支架,走 recorder_holder 那条 BLE 链路。 // EaiRec 录音支架,走 recorder_holder 那条 BLE 链路。
@ -101,7 +101,7 @@ class DeviceCategoryView extends StatelessWidget {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
// 每个品类走各自的连接页(背后是 DeviceHub 里对应的厂商插件)。 // 每个品类走各自的连接页(背后是 DeviceHub 里对应的厂商插件)。
// Smartcar 的链路还没接,见 [_scanNotReady]。 // EaiCar 的链路还没接,见 [_scanNotReady]。
onTap: () => _openCategory(category), onTap: () => _openCategory(category),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(

36
apps/client/lib/modules/devices/views/smartcar_scan_view.dart → apps/client/lib/modules/devices/views/eaicar_scan_view.dart

@ -11,22 +11,22 @@ import '../../../devices/device_vendors.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../main_tab/controllers/main_tab_controller.dart'; import '../../main_tab/controllers/main_tab_controller.dart';
/// Smartcar(AI车载香薰)的扫描 / 连接页——走 [DeviceHub] 里的 Smartcar 插件。 /// EaiCar(AI车载香薰)的扫描 / 连接页——走 [DeviceHub] 里的 EaiCar 插件。
/// ///
/// 与支架那页结构一致,两点不同: /// 与支架那页结构一致,两点不同:
/// 1. **不需要后台自启动授权**:Smartcar 的连接是纯 GATT,没有向系统注册 /// 1. **不需要后台自启动授权**:EaiCar 的连接是纯 GATT,没有向系统注册
/// 「设备靠近就唤醒本应用」那种后台扫连,不构成自启动行为。 /// 「设备靠近就唤醒本应用」那种后台扫连,不构成自启动行为。
/// 2. 卡片上多显示一行 MAC —— 它来自 BLE 广播,是服务端确权的依据, /// 2. 卡片上多显示一行 MAC —— 它来自 BLE 广播,是服务端确权的依据,
/// 连不上时看一眼就知道是不是扫到了别的设备。 /// 连不上时看一眼就知道是不是扫到了别的设备。
class SmartcarScanView extends StatefulWidget { class EaiCarScanView extends StatefulWidget {
const SmartcarScanView({super.key}); const EaiCarScanView({super.key});
@override @override
State<SmartcarScanView> createState() => _SmartcarScanViewState(); State<EaiCarScanView> createState() => _EaiCarScanViewState();
} }
class _SmartcarScanViewState extends State<SmartcarScanView> { class _EaiCarScanViewState extends State<EaiCarScanView> {
DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.smartcar)!; DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.eaicar)!;
final RxList<DiscoveredDevice> _scanned = <DiscoveredDevice>[].obs; final RxList<DiscoveredDevice> _scanned = <DiscoveredDevice>[].obs;
final RxBool _isScanning = false.obs; final RxBool _isScanning = false.obs;
@ -68,7 +68,7 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
break; break;
case DevicePluginEventType.customEvent: case DevicePluginEventType.customEvent:
// 厂商插件转发的扫描诊断(业务层不能直接 import 厂商目录) // 厂商插件转发的扫描诊断(业务层不能直接 import 厂商目录)
if (e.customKey == 'smartcar.scan_diagnostic') { if (e.customKey == 'eaicar.scan_diagnostic') {
final d = e.customPayload; final d = e.customPayload;
if (d is Map<String, Object?>) { if (d is Map<String, Object?>) {
final id = '${d['id']}'; final id = '${d['id']}';
@ -124,7 +124,7 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
setState(() => _connecting = false); setState(() => _connecting = false);
if (failReason == null) { if (failReason == null) {
Get.snackbar('smartcarConnected'.tr, d.name, Get.snackbar('eaicarConnected'.tr, d.name,
snackPosition: SnackPosition.TOP, snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2)); duration: const Duration(seconds: 2));
// BLE 通了不等于听得见:AI 回复和音乐走的是**经典蓝牙 A2DP**(见落地文档 7.0)。 // BLE 通了不等于听得见:AI 回复和音乐走的是**经典蓝牙 A2DP**(见落地文档 7.0)。
@ -141,7 +141,7 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
// 这些信息(设备实际暴露了哪些服务、握手卡在哪条命令)是排查的全部依据, // 这些信息(设备实际暴露了哪些服务、握手卡在哪条命令)是排查的全部依据,
// snackbar 几秒就消失,用户根本来不及看清更别说转述。 // snackbar 几秒就消失,用户根本来不及看清更别说转述。
_scanError.value = failReason; _scanError.value = failReason;
Get.snackbar('smartcarConnectFailed'.tr, failReason, Get.snackbar('eaicarConnectFailed'.tr, failReason,
snackPosition: SnackPosition.TOP, snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 5)); duration: const Duration(seconds: 5));
} }
@ -154,11 +154,11 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
Future<void> _hintClassicAudioIfNeeded(String name) async { Future<void> _hintClassicAudioIfNeeded(String name) async {
try { try {
final r = await _plugin.activeSession final r = await _plugin.activeSession
?.invokeFeature('smartcar.a2dp_status'); ?.invokeFeature('eaicar.a2dp_status');
if (r == null || r['connected'] == true) return; if (r == null || r['connected'] == true) return;
Get.snackbar( Get.snackbar(
'smartcarA2dpTitle'.tr, 'eaicarA2dpTitle'.tr,
'smartcarA2dpHint'.tr.replaceAll('@name', name), 'eaicarA2dpHint'.tr.replaceAll('@name', name),
snackPosition: SnackPosition.TOP, snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 6), duration: const Duration(seconds: 6),
); );
@ -201,7 +201,7 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
children: [ children: [
const CircularProgressIndicator(), const CircularProgressIndicator(),
SizedBox(height: 16.h), SizedBox(height: 16.h),
Text('smartcarConnecting'.tr), Text('eaicarConnecting'.tr),
], ],
), ),
) )
@ -227,15 +227,15 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
SizedBox(height: 12.h), SizedBox(height: 12.h),
Obx(() => Text( Obx(() => Text(
_isScanning.value _isScanning.value
? 'smartcarSearching'.tr ? 'eaicarSearching'.tr
: 'smartcarNotFound'.tr, : 'eaicarNotFound'.tr,
style: TextStyle( style: TextStyle(
fontSize: 15.sp, fontSize: 15.sp,
color: AppPalette.textSecondary(isDark)), color: AppPalette.textSecondary(isDark)),
)), )),
SizedBox(height: 6.h), SizedBox(height: 6.h),
Text( Text(
'smartcarScanHint'.tr, 'eaicarScanHint'.tr,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 12.sp, color: AppPalette.textHint(isDark)), fontSize: 12.sp, color: AppPalette.textHint(isDark)),
@ -248,7 +248,7 @@ class _SmartcarScanViewState extends State<SmartcarScanView> {
/// 诊断面板:把附近扫到的 BLE 设备摊开。 /// 诊断面板:把附近扫到的 BLE 设备摊开。
/// ///
/// 判据(厂商 ID 0x8505 / 名字含 smartcar)对不对,看这里最快: /// 判据(厂商 ID 0x8505 / 名字含 eaicar)对不对,看这里最快:
/// - 列表里根本没有目标设备 → 设备没在播,或不在范围内 /// - 列表里根本没有目标设备 → 设备没在播,或不在范围内
/// - 有、但 mfg 那栏的厂商 ID 不是 0x8505 → 判据要按实际值改 /// - 有、但 mfg 那栏的厂商 ID 不是 0x8505 → 判据要按实际值改
/// - 有、厂商 ID 对但长度不足 22 → 广播段布局和文档对不上 /// - 有、厂商 ID 对但长度不足 22 → 广播段布局和文档对不上

24
apps/client/lib/modules/smartcar/bindings/smartcar_chat_binding.dart → apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart

@ -3,46 +3,46 @@ import 'package:get/get.dart';
import '../../../data/services/bailian_multimodal_service.dart'; import '../../../data/services/bailian_multimodal_service.dart';
import '../../emai/controllers/emai_controller.dart'; import '../../emai/controllers/emai_controller.dart';
/// Smartcar 车载助手的依赖装配。 /// EaiCar 车载助手的依赖装配。
/// ///
/// 复用 EMAI 那套对话 UI 与控制器,只把「连哪个百炼应用」换掉: /// 复用 EMAI 那套对话 UI 与控制器,只把「连哪个百炼应用」换掉:
/// 这里 put 一个带 Smartcar 专属 app_id 的 [BailianMultimodalService], /// 这里 put 一个带 EaiCar 专属 app_id 的 [BailianMultimodalService],
/// 再用它构造一个同 tag 的 [EmaiController]。 /// 再用它构造一个同 tag 的 [EmaiController]。
/// ///
/// ⚠️ **两者都必须带 tag**:不带 tag 会覆盖掉全局那个 EMAI 用的实例, /// ⚠️ **两者都必须带 tag**:不带 tag 会覆盖掉全局那个 EMAI 用的实例,
/// 表现为退出车载助手后 EMAI 也连到了车载的应用上。 /// 表现为退出车载助手后 EMAI 也连到了车载的应用上。
/// `EmaiView(controllerTag: kSmartcarTag)` 靠同一个 tag 取到这里的控制器。 /// `EmaiView(controllerTag: kEaiCarTag)` 靠同一个 tag 取到这里的控制器。
/// ///
/// ⚠️ **service 必须是独立实例**,不能 `Get.find` 全局那个: /// ⚠️ **service 必须是独立实例**,不能 `Get.find` 全局那个:
/// `BailianMultimodalService.start()` 第一件事就是 `stop()`,共用会让两个 agent /// `BailianMultimodalService.start()` 第一件事就是 `stop()`,共用会让两个 agent
/// 互相把对方的会话掐掉,下行音频还会串到对方的播放器上。 /// 互相把对方的会话掐掉,下行音频还会串到对方的播放器上。
class SmartcarChatBinding extends Bindings { class EaiCarChatBinding extends Bindings {
@override @override
void dependencies() { void dependencies() {
if (!Get.isRegistered<BailianMultimodalService>(tag: kSmartcarTag)) { if (!Get.isRegistered<BailianMultimodalService>(tag: kEaiCarTag)) {
Get.put<BailianMultimodalService>( Get.put<BailianMultimodalService>(
BailianMultimodalService(appId: kSmartcarBailianAppId), BailianMultimodalService(appId: kEaiCarBailianAppId),
tag: kSmartcarTag, tag: kEaiCarTag,
permanent: true, permanent: true,
); );
} }
Get.lazyPut<EmaiController>( Get.lazyPut<EmaiController>(
() => EmaiController( () => EmaiController(
service: Get.find<BailianMultimodalService>(tag: kSmartcarTag), service: Get.find<BailianMultimodalService>(tag: kEaiCarTag),
), ),
tag: kSmartcarTag, tag: kEaiCarTag,
); );
} }
} }
/// GetX 实例 tag:service 与 controller 共用这一个 /// GetX 实例 tag:service 与 controller 共用这一个
const String kSmartcarTag = 'smartcar'; const String kEaiCarTag = 'eaicar';
/// Smartcar 车载助手在百炼里的多模态应用 ID(workspace 与 EMAI 同一个, /// EaiCar 车载助手在百炼里的多模态应用 ID(workspace 与 EMAI 同一个,
/// 所以不覆盖 workspaceId,让它走全局配置)。 /// 所以不覆盖 workspaceId,让它走全局配置)。
/// ///
/// ⚠️ 写死在客户端是**有意选的**(落地文档 6.3 方案 A):换 app_id 要发版。 /// ⚠️ 写死在客户端是**有意选的**(落地文档 6.3 方案 A):换 app_id 要发版。
/// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发—— /// 等运营提出「要后台随时改」时,再改成从 `DBAgent.serviceData` 下发——
/// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**, /// 那时要给客户端手写镜像补 `source` / `serviceData` 两个字段,且**必须 nullable**,
/// 否则老后端不下发就整个 `fromJson` 抛 TypeError、应用配置全空白。 /// 否则老后端不下发就整个 `fromJson` 抛 TypeError、应用配置全空白。
const String kSmartcarBailianAppId = 'mm_dd1eb7cb06484aafb94a0b6b2dff'; const String kEaiCarBailianAppId = 'mm_dd1eb7cb06484aafb94a0b6b2dff';

130
apps/client/lib/modules/eaicar/module.dart

@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../routes/app_routes.dart';
import '../emai/views/emai_view.dart';
import '../module_descriptor.dart';
import 'bindings/eaicar_chat_binding.dart';
/// EaiCar 车载助手(EaiCar 车载香薰的随身对话 agent)
///
/// 与 EMAI 共用同一套对话 UI 和控制器,差别只有一个:连的是百炼里**另一个应用**
/// (见 [kEaiCarBailianAppId])。所以这里直接复用 [EmaiView],靠 `controllerTag`
/// 取到 [EaiCarChatBinding] 装配的那份带专属 app_id 的实例。
///
/// ⚠️ **没有声明 `requiredCapabilities`**:这个 agent 手机自己就能聊,不必连着香薰
/// (产品定位是"随时开聊")。设备连上时它会被自动铺到 AI 页首位,那是
/// `AgentModules.defaultsByCategory[carAroma]` 的事,与本字段无关。
///
/// ⚠️ **对话记录与 EMAI 共用 `EmaiChatStore`**:设备按键唤醒的轮次由
/// `DeviceAiSessionService` 写进那个 store,页面不开也能收;这里若另起一个 store,
/// 设备发起的对话就不会出现在本页上。要拆分历史得连 `DeviceAiSessionService`
/// 一起改,本期不做。
final AgentModuleDescriptor eaicarModule = AgentModuleDescriptor(
id: 'smartcarChat',
title: 'agentEaiCar',
subtitle: 'agentEaiCarDesc',
// 卡片图标只支持 IconData(module_card 里是 Icon(module.icon) 套渐变块),
// 用不了产品图 EaiCar_icon.png。要用图得给 AgentModule 加字段并改 module_card,
// 那是耳机/支架共用的卡片组件,本期不动。
icon: Icons.directions_car,
// 与「AI车载香薰」品类卡同色系(device_category.dart 的 carAroma)
gradient: const [Color(0xFFFFB74D), Color(0xFFE65100)],
entryRoute: Routes.eaicarChat,
// 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId
bailianAppId: kEaiCarBailianAppId,
pages: [
GetPage(
name: Routes.eaicarChat,
page: () => const EmaiView(
controllerTag: kEaiCarTag,
titleKey: 'agentEaiCar',
),
binding: EaiCarChatBinding(),
transition: Transition.rightToLeft,
transitionDuration: const Duration(milliseconds: 250),
),
],
// 模块自带文案:不用再去改 41 个语言文件。
// 缺某个语种时 GetX 回退到全局表,全局也没有就显示 key 本身,
// 所以至少要保证 zh_CN / en_US 有(module_registry_test 守着这两个)。
translations: const {
'zh_CN': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': '您的车载助手',
'eaicarWelcome': '欢迎回来',
'eaicarA2dpTitle': '需要连接蓝牙音频',
'eaicarA2dpHint': 'AI 回复和音乐要经典蓝牙才能从设备里放出来。请到「设置 → 蓝牙」里配对 @name。',
},
'zh_TW': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': '您的車載助手',
'eaicarWelcome': '歡迎回來',
'eaicarA2dpTitle': '需要連接藍牙音訊',
'eaicarA2dpHint': 'AI 回覆和音樂要經典藍牙才能從裝置裡播出來。請到「設定 → 藍牙」配對 @name。',
},
'zh_HK': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': '您的車載助手',
'eaicarWelcome': '歡迎回來',
'eaicarA2dpTitle': '需要連接藍牙音訊',
'eaicarA2dpHint': 'AI 回覆和音樂要經典藍牙先可以喺裝置度播出嚟。請去「設定 → 藍牙」配對 @name。',
},
'en_US': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': 'Your in-car assistant',
'eaicarWelcome': 'Welcome back',
'eaicarA2dpTitle': 'Bluetooth audio not connected',
'eaicarA2dpHint': 'Pair @name under Settings → Bluetooth so replies and music play from the device.',
},
'ja_JP': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': '車載アシスタント',
'eaicarWelcome': 'おかえりなさい',
'eaicarA2dpTitle': 'Bluetoothオーディオ未接続',
'eaicarA2dpHint': '「設定 → Bluetooth」で @name をペアリングすると、応答と音楽が本体から再生されます。',
},
'ko_KR': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': '차량용 어시스턴트',
'eaicarWelcome': '다시 오신 것을 환영합니다',
'eaicarA2dpTitle': '블루투스 오디오 미연결',
'eaicarA2dpHint': '설정 → 블루투스에서 @name 을(를) 페어링하면 응답과 음악이 기기에서 재생됩니다。',
},
'de_DE': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': 'Ihr Auto-Assistent',
'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.',
},
'fr_FR': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': 'Votre assistant automobile',
'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.',
},
'es_ES': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': 'Tu asistente para el coche',
'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.',
},
'pt_PT': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': 'O seu assistente para o carro',
'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.',
},
'ru_RU': {
'agentEaiCar': 'EaiCar',
'agentEaiCarDesc': 'Ваш автомобильный помощник',
'eaicarWelcome': 'С возвращением',
'eaicarA2dpTitle': 'Bluetooth-аудио не подключено',
'eaicarA2dpHint': 'Подключите @name в «Настройки → Bluetooth», чтобы ответы и музыка звучали из устройства.',
},
},
);

2
apps/client/lib/modules/emai/controllers/emai_controller.dart

@ -42,7 +42,7 @@ class EmaiController extends GetxController
implements EmaiSessionOwner { implements EmaiSessionOwner {
/// [service] 不传就用全局那个(EMAI 的百炼应用),行为与改造前一致。 /// [service] 不传就用全局那个(EMAI 的百炼应用),行为与改造前一致。
/// ///
/// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 Smartcar /// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar
/// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见 /// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见
/// [BailianMultimodalService] 的类注释。 /// [BailianMultimodalService] 的类注释。
EmaiController({BailianMultimodalService? service}) EmaiController({BailianMultimodalService? service})

2
apps/client/lib/modules/emai/views/emai_view.dart

@ -14,7 +14,7 @@ import 'widgets/emai_level_bar.dart';
/// 所有 AI 能力都在阿里百炼多模态应用里,页面本身只管采音、播音和展示。 /// 所有 AI 能力都在阿里百炼多模态应用里,页面本身只管采音、播音和展示。
class EmaiView extends GetView<EmaiController> { class EmaiView extends GetView<EmaiController> {
/// [controllerTag]:不传 = 取全局那个 [EmaiController](EMAI 自己),行为不变。 /// [controllerTag]:不传 = 取全局那个 [EmaiController](EMAI 自己),行为不变。
/// 传了则取同名 tag 的实例——给复用这套 UI 的其它 agent 用(如 Smartcar)。 /// 传了则取同名 tag 的实例——给复用这套 UI 的其它 agent 用(如 EaiCar)。
/// [titleKey]:标题栏的 i18n key,默认 EMAI 自己的。 /// [titleKey]:标题栏的 i18n key,默认 EMAI 自己的。
const EmaiView({super.key, this.controllerTag, this.titleKey = 'agentEmai'}); const EmaiView({super.key, this.controllerTag, this.titleKey = 'agentEmai'});

4
apps/client/lib/modules/module_registry.dart

@ -8,7 +8,7 @@ import 'emai/module.dart';
import 'meeting/module.dart'; import 'meeting/module.dart';
import 'module_descriptor.dart'; import 'module_descriptor.dart';
import 'picture_translation/module.dart'; import 'picture_translation/module.dart';
import 'smartcar/module.dart'; import 'eaicar/module.dart';
import 'text_translation/module.dart'; import 'text_translation/module.dart';
import 'translation/module.dart'; import 'translation/module.dart';
@ -31,7 +31,7 @@ class ModuleRegistry {
...callRecordingModules, ...callRecordingModules,
textTranslationModule, textTranslationModule,
pictureTranslationModule, pictureTranslationModule,
smartcarModule, eaicarModule,
// ---------- 规划中(占位,方便测试增删) ---------- // ---------- 规划中(占位,方便测试增删) ----------
AgentModuleDescriptor( AgentModuleDescriptor(
id: 'englishChat', id: 'englishChat',

130
apps/client/lib/modules/smartcar/module.dart

@ -1,130 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../routes/app_routes.dart';
import '../emai/views/emai_view.dart';
import '../module_descriptor.dart';
import 'bindings/smartcar_chat_binding.dart';
/// Smartcar 车载助手(Smartcar 车载香薰的随身对话 agent)
///
/// 与 EMAI 共用同一套对话 UI 和控制器,差别只有一个:连的是百炼里**另一个应用**
/// (见 [kSmartcarBailianAppId])。所以这里直接复用 [EmaiView],靠 `controllerTag`
/// 取到 [SmartcarChatBinding] 装配的那份带专属 app_id 的实例。
///
/// ⚠️ **没有声明 `requiredCapabilities`**:这个 agent 手机自己就能聊,不必连着香薰
/// (产品定位是"随时开聊")。设备连上时它会被自动铺到 AI 页首位,那是
/// `AgentModules.defaultsByCategory[carAroma]` 的事,与本字段无关。
///
/// ⚠️ **对话记录与 EMAI 共用 `EmaiChatStore`**:设备按键唤醒的轮次由
/// `DeviceAiSessionService` 写进那个 store,页面不开也能收;这里若另起一个 store,
/// 设备发起的对话就不会出现在本页上。要拆分历史得连 `DeviceAiSessionService`
/// 一起改,本期不做。
final AgentModuleDescriptor smartcarModule = AgentModuleDescriptor(
id: 'smartcarChat',
title: 'agentSmartcar',
subtitle: 'agentSmartcarDesc',
// 卡片图标只支持 IconData(module_card 里是 Icon(module.icon) 套渐变块),
// 用不了产品图 Smartcar_icon.png。要用图得给 AgentModule 加字段并改 module_card,
// 那是耳机/支架共用的卡片组件,本期不动。
icon: Icons.directions_car,
// 与「AI车载香薰」品类卡同色系(device_category.dart 的 carAroma)
gradient: const [Color(0xFFFFB74D), Color(0xFFE65100)],
entryRoute: Routes.smartcarChat,
// 设备按键唤醒时接的就是这个应用(不是 EMAI)——见 AgentModuleDescriptor.bailianAppId
bailianAppId: kSmartcarBailianAppId,
pages: [
GetPage(
name: Routes.smartcarChat,
page: () => const EmaiView(
controllerTag: kSmartcarTag,
titleKey: 'agentSmartcar',
),
binding: SmartcarChatBinding(),
transition: Transition.rightToLeft,
transitionDuration: const Duration(milliseconds: 250),
),
],
// 模块自带文案:不用再去改 41 个语言文件。
// 缺某个语种时 GetX 回退到全局表,全局也没有就显示 key 本身,
// 所以至少要保证 zh_CN / en_US 有(module_registry_test 守着这两个)。
translations: const {
'zh_CN': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': '您的车载助手',
'smartcarWelcome': '欢迎回来',
'smartcarA2dpTitle': '需要连接蓝牙音频',
'smartcarA2dpHint': 'AI 回复和音乐要经典蓝牙才能从设备里放出来。请到「设置 → 蓝牙」里配对 @name。',
},
'zh_TW': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': '您的車載助手',
'smartcarWelcome': '歡迎回來',
'smartcarA2dpTitle': '需要連接藍牙音訊',
'smartcarA2dpHint': 'AI 回覆和音樂要經典藍牙才能從裝置裡播出來。請到「設定 → 藍牙」配對 @name。',
},
'zh_HK': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': '您的車載助手',
'smartcarWelcome': '歡迎回來',
'smartcarA2dpTitle': '需要連接藍牙音訊',
'smartcarA2dpHint': 'AI 回覆和音樂要經典藍牙先可以喺裝置度播出嚟。請去「設定 → 藍牙」配對 @name。',
},
'en_US': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': 'Your in-car assistant',
'smartcarWelcome': 'Welcome back',
'smartcarA2dpTitle': 'Bluetooth audio not connected',
'smartcarA2dpHint': 'Pair @name under Settings → Bluetooth so replies and music play from the device.',
},
'ja_JP': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': '車載アシスタント',
'smartcarWelcome': 'おかえりなさい',
'smartcarA2dpTitle': 'Bluetoothオーディオ未接続',
'smartcarA2dpHint': '「設定 → Bluetooth」で @name をペアリングすると、応答と音楽が本体から再生されます。',
},
'ko_KR': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': '차량용 어시스턴트',
'smartcarWelcome': '다시 오신 것을 환영합니다',
'smartcarA2dpTitle': '블루투스 오디오 미연결',
'smartcarA2dpHint': '설정 → 블루투스에서 @name 을(를) 페어링하면 응답과 음악이 기기에서 재생됩니다。',
},
'de_DE': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': 'Ihr Auto-Assistent',
'smartcarWelcome': 'Willkommen zurück',
'smartcarA2dpTitle': 'Bluetooth-Audio nicht verbunden',
'smartcarA2dpHint': 'Koppeln Sie @name unter Einstellungen → Bluetooth, damit Antworten und Musik über das Gerät laufen.',
},
'fr_FR': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': 'Votre assistant automobile',
'smartcarWelcome': 'Bon retour',
'smartcarA2dpTitle': 'Audio Bluetooth non connecté',
'smartcarA2dpHint': 'Associez @name dans Réglages → Bluetooth pour que les réponses et la musique sortent de l\'appareil.',
},
'es_ES': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': 'Tu asistente para el coche',
'smartcarWelcome': 'Bienvenido de nuevo',
'smartcarA2dpTitle': 'Audio Bluetooth no conectado',
'smartcarA2dpHint': 'Empareja @name en Ajustes → Bluetooth para que las respuestas y la música suenen en el dispositivo.',
},
'pt_PT': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': 'O seu assistente para o carro',
'smartcarWelcome': 'Bem-vindo de volta',
'smartcarA2dpTitle': 'Áudio Bluetooth não ligado',
'smartcarA2dpHint': 'Emparelhe @name em Definições → Bluetooth para que as respostas e a música saiam do dispositivo.',
},
'ru_RU': {
'agentSmartcar': 'EaiCar',
'agentSmartcarDesc': 'Ваш автомобильный помощник',
'smartcarWelcome': 'С возвращением',
'smartcarA2dpTitle': 'Bluetooth-аудио не подключено',
'smartcarA2dpHint': 'Подключите @name в «Настройки → Bluetooth», чтобы ответы и музыка звучали из устройства.',
},
},
);

4
apps/client/lib/routes/app_pages.dart

@ -3,7 +3,7 @@ import 'package:get/get.dart';
import '../modules/devices/views/bes_scan_view.dart'; import '../modules/devices/views/bes_scan_view.dart';
import '../modules/devices/views/device_category_view.dart'; import '../modules/devices/views/device_category_view.dart';
import '../modules/devices/views/holder_scan_view.dart'; import '../modules/devices/views/holder_scan_view.dart';
import '../modules/devices/views/smartcar_scan_view.dart'; import '../modules/devices/views/eaicar_scan_view.dart';
import '../modules/feedback/bindings/feedback_bindings.dart'; import '../modules/feedback/bindings/feedback_bindings.dart';
import '../modules/feedback/views/feedback_view.dart'; import '../modules/feedback/views/feedback_view.dart';
import '../modules/goods/bindings/goodsmeet_binding.dart'; import '../modules/goods/bindings/goodsmeet_binding.dart';
@ -93,7 +93,7 @@ abstract class AppPages {
// 添加设备第一步:选品类(纯展示,无需 binding) // 添加设备第一步:选品类(纯展示,无需 binding)
_page(Routes.deviceCategory, () => const DeviceCategoryView()), _page(Routes.deviceCategory, () => const DeviceCategoryView()),
_page(Routes.holderScan, () => const HolderScanView()), _page(Routes.holderScan, () => const HolderScanView()),
_page(Routes.smartcarScan, () => const SmartcarScanView()), _page(Routes.eaicarScan, () => const EaiCarScanView()),
_page(Routes.besScan, () => const BesScanView()), _page(Routes.besScan, () => const BesScanView()),
_page(Routes.plaza, () => const PlazaTab()), _page(Routes.plaza, () => const PlazaTab()),
_page(Routes.myDevices, () => const MyDevicesView(), _page(Routes.myDevices, () => const MyDevicesView(),

4
apps/client/lib/routes/app_routes.dart

@ -17,7 +17,7 @@ abstract class Routes {
static const otaUpgrade = '/ota_upgrade'; // 固件升级 static const otaUpgrade = '/ota_upgrade'; // 固件升级
static const deviceCategory = '/device_category'; // 添加设备第一步:选择产品品类 static const deviceCategory = '/device_category'; // 添加设备第一步:选择产品品类
static const holderScan = '/holder_scan'; // EaiRec 录音支架扫描/连接 static const holderScan = '/holder_scan'; // EaiRec 录音支架扫描/连接
static const smartcarScan = '/smartcar_scan'; // Smartcar 车载香薰扫描/连接 static const eaicarScan = '/eaicar_scan'; // EaiCar 车载香薰扫描/连接
static const besScan = '/bes_scan'; // 恒玄耳机(经典蓝牙)连接 static const besScan = '/bes_scan'; // 恒玄耳机(经典蓝牙)连接
static const myDevices = '/my_devices'; // 用户已绑定设备列表 static const myDevices = '/my_devices'; // 用户已绑定设备列表
static const besTest = '/bes_test'; // 恒玄协议探测页(开发者) static const besTest = '/bes_test'; // 恒玄协议探测页(开发者)
@ -48,7 +48,7 @@ abstract class Routes {
// AI 助手 // AI 助手
static const emai = '/emai'; // EMAI 助手(阿里百炼多模态对话) static const emai = '/emai'; // EMAI 助手(阿里百炼多模态对话)
static const smartcarChat = '/smartcar_chat'; // Smartcar 车载助手(同一套对话 UI,另一个百炼应用) static const eaicarChat = '/eaicar_chat'; // EaiCar 车载助手(同一套对话 UI,另一个百炼应用)
static const mobileElf = '/mobile_elf'; // 移动精灵 static const mobileElf = '/mobile_elf'; // 移动精灵
// 广场 // 广场

4
apps/client/test/agent_module_defaults_test.dart

@ -68,11 +68,11 @@ void main() {
}); });
// 设备编排的内容本身也守一下:香薰就该是这三个,别被顺手改掉。 // 设备编排的内容本身也守一下:香薰就该是这三个,别被顺手改掉。
test('车载香薰的默认编排是 Smartcar / 现场录音 / 语音纪要', () { test('车载香薰的默认编排是 EaiCar / 现场录音 / 语音纪要', () {
expect( expect(
AgentModules.defaultsForCategory('carAroma'), AgentModules.defaultsForCategory('carAroma'),
[ [
AgentModules.smartcarChat, AgentModules.eaicarChat,
AgentModules.fieldRecording, AgentModules.fieldRecording,
AgentModules.meeting, AgentModules.meeting,
], ],

2
apps/client/test/assistant_directive_test.dart

@ -348,7 +348,7 @@ void main() {
}); });
group('commands 形状(2026-09-11 车载助手真机帧)', () { group('commands 形状(2026-09-11 车载助手真机帧)', () {
// Smartcar 车载助手的指令不在 tool_calls 里,而是 extra_info.commands。 // EaiCar 车载助手的指令不在 tool_calls 里,而是 extra_info.commands。
// 只解析 tool_calls 的话这里恒为 0 条 —— 表现就是"AI 说已经关闭了、实际没反应"。 // 只解析 tool_calls 的话这里恒为 0 条 —— 表现就是"AI 说已经关闭了、实际没反应"。
Map<String, dynamic> frame() => { Map<String, dynamic> frame() => {
'dialog_id': 'd1', 'dialog_id': 'd1',

2
apps/client/test/bailian_native_parity_test.dart

@ -29,7 +29,7 @@ void main() {
}); });
test('显式覆盖优先于全局配置', () { test('显式覆盖优先于全局配置', () {
// 两个 agent(EMAI / Smartcar 车载助手)用的是百炼里不同的应用, // 两个 agent(EMAI / EaiCar 车载助手)用的是百炼里不同的应用,
// 覆盖失效会让车载香薰唤醒到 EMAI 上去 // 覆盖失效会让车载香薰唤醒到 EMAI 上去
expect(BailianMultimodalService.resolveAppId('app-x'), 'app-x'); expect(BailianMultimodalService.resolveAppId('app-x'), 'app-x');
expect(BailianMultimodalService.resolveWorkspaceId('ws-x'), 'ws-x'); expect(BailianMultimodalService.resolveWorkspaceId('ws-x'), 'ws-x');

2
apps/client/test/devices/device_hub_boundary_test.dart

@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart';
/// 与 `device_plugin_interface`,**不能** import `lib/devices/<vendor>/…`, /// 与 `device_plugin_interface`,**不能** import `lib/devices/<vendor>/…`,
/// 也不能出现厂商类名。新接一种芯片只动 `lib/devices/<vendor>/` 与 bootstrap。 /// 也不能出现厂商类名。新接一种芯片只动 `lib/devices/<vendor>/` 与 bootstrap。
void main() { void main() {
final vendorImport = RegExp(r"devices/(bes|holder|smartcar)/"); final vendorImport = RegExp(r"devices/(bes|holder|eaicar)/");
final vendorTypes = RegExp( final vendorTypes = RegExp(
r'\b(BesBluetoothService|BesDevicePlugin|BesDeviceSession|BesDeviceAuth|' r'\b(BesBluetoothService|BesDevicePlugin|BesDeviceSession|BesDeviceAuth|'
r'HolderDeviceService|HolderDevicePlugin|HolderDeviceSession|' r'HolderDeviceService|HolderDevicePlugin|HolderDeviceSession|'

30
apps/client/test/devices/eaicar_name_test.dart

@ -0,0 +1,30 @@
import 'package:eaimar/devices/eaicar/eaicar_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isAllowedEaiCarName —— 车载香薰广播名准入', () {
test('规定的名字放行', () {
expect(isAllowedEaiCarName('EaiCar'), isTrue);
expect(isAllowedEaiCarName(' eaicar '), isTrue);
expect(isAllowedEaiCarName('EAICAR-3F2A'), isTrue);
});
// ⚠️ 旧广播名不再接纳。厂商 ID(0x8505) 也不再单独放行——它只说明
// "这是云衍方案的设备",说明不了"这是我们的正式产品"。
test('旧名 Smartcar 一律拒绝', () {
expect(isAllowedEaiCarName('Smartcar'), isFalse);
expect(isAllowedEaiCarName('smartcar'), isFalse);
expect(isAllowedEaiCarName('SmartcarX'), isFalse);
});
test('空名字拒绝', () {
expect(isAllowedEaiCarName(''), isFalse);
expect(isAllowedEaiCarName(' '), isFalse);
});
test('别家设备拒绝', () {
expect(isAllowedEaiCarName('EaiRec'), isFalse);
expect(isAllowedEaiCarName('Echo-one'), isFalse);
});
});
}

110
apps/client/test/devices/smartcar_protocol_test.dart → apps/client/test/devices/eaicar_protocol_test.dart

@ -1,23 +1,23 @@
import 'package:eaimar/devices/smartcar/smartcar_protocol.dart'; import 'package:eaimar/devices/eaicar/eaicar_protocol.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
/// Smartcar C2 协议的字节级金测试。 /// EaiCar C2 协议的字节级金测试。
/// ///
/// 协议是**冻结的**:同一个命令永远发同样的字节。这些用例就是那份契约—— /// 协议是**冻结的**:同一个命令永远发同样的字节。这些用例就是那份契约——
/// 改动这里等于改协议,必须先在真机上验过。 /// 改动这里等于改协议,必须先在真机上验过。
void main() { void main() {
group('CRC-8/MAXIM', () { group('CRC-8/MAXIM', () {
test('空数据为 0', () { test('空数据为 0', () {
expect(SmartcarProtocol.crc8Maxim(const []), 0x00); expect(EaiCarProtocol.crc8Maxim(const []), 0x00);
}); });
// 与 lumi BleProtocol.calcCrc 同算法(poly 0x31 反射 0x8C / init 0 / xorOut 0)。 // 与 lumi BleProtocol.calcCrc 同算法(poly 0x31 反射 0x8C / init 0 / xorOut 0)。
// 这几个值是标准 CRC-8/MAXIM 的公认结果,可离线核对。 // 这几个值是标准 CRC-8/MAXIM 的公认结果,可离线核对。
test('标准向量', () { test('标准向量', () {
expect(SmartcarProtocol.crc8Maxim([0x00]), 0x00); expect(EaiCarProtocol.crc8Maxim([0x00]), 0x00);
// "123456789" 的 CRC-8/MAXIM 标准 check 值是 0xA1 // "123456789" 的 CRC-8/MAXIM 标准 check 值是 0xA1
expect( expect(
SmartcarProtocol.crc8Maxim( EaiCarProtocol.crc8Maxim(
'123456789'.codeUnits), '123456789'.codeUnits),
0xA1, 0xA1,
); );
@ -30,33 +30,33 @@ void main() {
for (var i = 0; i < 8; i++) { for (var i = 0; i < 8; i++) {
crc = (crc & 1) != 0 ? (crc >> 1) ^ 0x8C : crc >> 1; crc = (crc & 1) != 0 ? (crc >> 1) ^ 0x8C : crc >> 1;
} }
expect(SmartcarProtocol.crc8Maxim([b]), crc & 0xFF); expect(EaiCarProtocol.crc8Maxim([b]), crc & 0xFF);
} }
}); });
}); });
group('组帧', () { group('组帧', () {
test('无参命令:获取版本号', () { test('无参命令:获取版本号', () {
final p = SmartcarProtocol.buildPacket(SmartcarProtocol.cmdGetVersion); final p = EaiCarProtocol.buildPacket(EaiCarProtocol.cmdGetVersion);
expect(p[0], 0xAA); expect(p[0], 0xAA);
expect(p[1], 0x01); expect(p[1], 0x01);
expect(p[2], 0x00); // len expect(p[2], 0x00); // len
expect(p.length, 4); expect(p.length, 4);
expect(p[3], SmartcarProtocol.crc8Maxim([0xAA, 0x01, 0x00])); expect(p[3], EaiCarProtocol.crc8Maxim([0xAA, 0x01, 0x00]));
}); });
test('带参命令:设音量', () { test('带参命令:设音量', () {
final p = SmartcarProtocol.buildPacket( final p = EaiCarProtocol.buildPacket(
SmartcarProtocol.cmdSetVolume, const [0x05]); EaiCarProtocol.cmdSetVolume, const [0x05]);
expect(p.sublist(0, 4), [0xAA, 0x0B, 0x01, 0x05]); expect(p.sublist(0, 4), [0xAA, 0x0B, 0x01, 0x05]);
expect(p.length, 5); expect(p.length, 5);
expect(p[4], SmartcarProtocol.crc8Maxim([0xAA, 0x0B, 0x01, 0x05])); expect(p[4], EaiCarProtocol.crc8Maxim([0xAA, 0x0B, 0x01, 0x05]));
}); });
test('组出来的帧能被自己解回去', () { test('组出来的帧能被自己解回去', () {
final p = SmartcarProtocol.buildPacket(0x42, const [1, 2, 3]); final p = EaiCarProtocol.buildPacket(0x42, const [1, 2, 3]);
// buildPacket 发的是 0xAA,parseFrame 也认这个头 // buildPacket 发的是 0xAA,parseFrame 也认这个头
final f = SmartcarProtocol.parseFrame(p); final f = EaiCarProtocol.parseFrame(p);
expect(f, isNotNull); expect(f, isNotNull);
expect(f!.cmd, 0x42); expect(f!.cmd, 0x42);
expect(f.payload, [1, 2, 3]); expect(f.payload, [1, 2, 3]);
@ -67,28 +67,28 @@ void main() {
/// 造一帧设备应答 /// 造一帧设备应答
List<int> resp(int cmd, List<int> payload) { List<int> resp(int cmd, List<int> payload) {
final head = [0xBB, cmd, payload.length, ...payload]; final head = [0xBB, cmd, payload.length, ...payload];
return [...head, SmartcarProtocol.crc8Maxim(head)]; return [...head, EaiCarProtocol.crc8Maxim(head)];
} }
test('正常应答', () { test('正常应答', () {
final f = SmartcarProtocol.parseFrame(resp(0x04, const [0x64])); final f = EaiCarProtocol.parseFrame(resp(0x04, const [0x64]));
expect(f, isNotNull); expect(f, isNotNull);
expect(f!.isResponse, isTrue); expect(f!.isResponse, isTrue);
expect(f.cmd, SmartcarProtocol.cmdGetBattery); expect(f.cmd, EaiCarProtocol.cmdGetBattery);
expect(f.payload, [100]); expect(f.payload, [100]);
expect(f.success, isTrue); expect(f.success, isTrue);
}); });
test('len=0 表示命令执行失败', () { test('len=0 表示命令执行失败', () {
final f = SmartcarProtocol.parseFrame(resp(0x01, const [])); final f = EaiCarProtocol.parseFrame(resp(0x01, const []));
expect(f, isNotNull); expect(f, isNotNull);
expect(f!.success, isFalse); expect(f!.success, isFalse);
}); });
test('主动上报帧头 0xCC', () { test('主动上报帧头 0xCC', () {
final head = [0xCC, SmartcarProtocol.notifyAiWake, 0x00]; final head = [0xCC, EaiCarProtocol.notifyAiWake, 0x00];
final f = SmartcarProtocol final f = EaiCarProtocol
.parseFrame([...head, SmartcarProtocol.crc8Maxim(head)]); .parseFrame([...head, EaiCarProtocol.crc8Maxim(head)]);
expect(f!.isNotify, isTrue); expect(f!.isNotify, isTrue);
expect(f.cmd, 0x11); expect(f.cmd, 0x11);
}); });
@ -96,15 +96,15 @@ void main() {
test('CRC 不符必须丢弃', () { test('CRC 不符必须丢弃', () {
final bad = resp(0x04, const [0x64]); final bad = resp(0x04, const [0x64]);
bad[bad.length - 1] = (bad.last + 1) & 0xFF; bad[bad.length - 1] = (bad.last + 1) & 0xFF;
expect(SmartcarProtocol.parseFrame(bad), isNull); expect(EaiCarProtocol.parseFrame(bad), isNull);
}); });
test('帧头不认识 / 长度不够 一律 null', () { test('帧头不认识 / 长度不够 一律 null', () {
expect(SmartcarProtocol.parseFrame(const [0x12, 0x01, 0x00, 0x00]), isNull); expect(EaiCarProtocol.parseFrame(const [0x12, 0x01, 0x00, 0x00]), isNull);
expect(SmartcarProtocol.parseFrame(const [0xBB, 0x01]), isNull); expect(EaiCarProtocol.parseFrame(const [0xBB, 0x01]), isNull);
// 声明 len=5 但实际只有 1 字节 payload // 声明 len=5 但实际只有 1 字节 payload
expect( expect(
SmartcarProtocol.parseFrame(const [0xBB, 0x01, 0x05, 0x00, 0x00]), EaiCarProtocol.parseFrame(const [0xBB, 0x01, 0x05, 0x00, 0x00]),
isNull); isNull);
}); });
}); });
@ -122,33 +122,33 @@ void main() {
]; ];
test('真机广播:MAC 原样取 CID 之后 6 字节', () { test('真机广播:MAC 原样取 CID 之后 6 字节', () {
final a = SmartcarProtocol.parseManufacturerData(adv())!; final a = EaiCarProtocol.parseManufacturerData(adv())!;
expect(a.cid, 0x8505); expect(a.cid, 0x8505);
expect(a.macAddress, 'A9:1A:B3:21:E4:DD'); expect(a.macAddress, 'A9:1A:B3:21:E4:DD');
expect(a.classicBtConnected, isFalse); expect(a.classicBtConnected, isFalse);
}); });
test('状态字节 0x0C 视为经典蓝牙已连', () { test('状态字节 0x0C 视为经典蓝牙已连', () {
final a = SmartcarProtocol.parseManufacturerData(adv(btState: 0x0C))!; final a = EaiCarProtocol.parseManufacturerData(adv(btState: 0x0C))!;
expect(a.classicBtConnected, isTrue); expect(a.classicBtConnected, isTrue);
}); });
test('没有状态字节也能解出 MAC', () { test('没有状态字节也能解出 MAC', () {
final a = SmartcarProtocol.parseManufacturerData(adv().sublist(0, 10))!; final a = EaiCarProtocol.parseManufacturerData(adv().sublist(0, 10))!;
expect(a.macAddress, 'A9:1A:B3:21:E4:DD'); expect(a.macAddress, 'A9:1A:B3:21:E4:DD');
}); });
test('长度不足返回 null,不能抛', () { test('长度不足返回 null,不能抛', () {
expect(SmartcarProtocol.parseManufacturerData(null), isNull); expect(EaiCarProtocol.parseManufacturerData(null), isNull);
expect(SmartcarProtocol.parseManufacturerData(List.filled(9, 0)), isNull); expect(EaiCarProtocol.parseManufacturerData(List.filled(9, 0)), isNull);
// Type 不是 0xFF 的段不是厂商数据 // Type 不是 0xFF 的段不是厂商数据
expect(SmartcarProtocol.parseManufacturerData([10, 0x09, ...List.filled(8, 0)]), isNull); expect(EaiCarProtocol.parseManufacturerData([10, 0x09, ...List.filled(8, 0)]), isNull);
}); });
}); });
group('0x15 参数汇总', () { group('0x15 参数汇总', () {
test('14 字节逐项解对', () { test('14 字节逐项解对', () {
final s = SmartcarAllSettings.parse(const [ final s = EaiCarAllSettings.parse(const [
0x00, // OTA 正常 0x00, // OTA 正常
0x06, // 音量 6 0x06, // 音量 6
0x1E, // 随机表情频次 30 0x1E, // 随机表情频次 30
@ -176,7 +176,7 @@ void main() {
}); });
test('短包返回 null', () { test('短包返回 null', () {
expect(SmartcarAllSettings.parse(List.filled(13, 0)), isNull); expect(EaiCarAllSettings.parse(List.filled(13, 0)), isNull);
}); });
}); });
@ -185,28 +185,28 @@ void main() {
// 判据:lumi `BleService.swift` 的 checkMusicPlaybackState() 实际发的是 // 判据:lumi `BleService.swift` 的 checkMusicPlaybackState() 实际发的是
// 0x05 [0x01]/[0x00](手机在放/停止放音乐),与协议文档一致; // 0x05 [0x01]/[0x00](手机在放/停止放音乐),与协议文档一致;
// "遥控电机"另有其号 0x0E,所以 0x07 不是控制动作。 // "遥控电机"另有其号 0x0E,所以 0x07 不是控制动作。
expect(SmartcarProtocol.cmdMusicPlayState, 0x05); expect(EaiCarProtocol.cmdMusicPlayState, 0x05);
expect(SmartcarProtocol.cmdAiState, 0x07); expect(EaiCarProtocol.cmdAiState, 0x07);
expect(SmartcarProtocol.cmdRemoteMotor, 0x0E); expect(EaiCarProtocol.cmdRemoteMotor, 0x0E);
}); });
test('0x11 双向共号:0xAA 是陀螺仪开关,0xCC 是 AI 唤醒', () { test('0x11 双向共号:0xAA 是陀螺仪开关,0xCC 是 AI 唤醒', () {
expect(SmartcarProtocol.cmdGyroscopeSwitch, 0x11); expect(EaiCarProtocol.cmdGyroscopeSwitch, 0x11);
expect(SmartcarProtocol.notifyAiWake, 0x11); expect(EaiCarProtocol.notifyAiWake, 0x11);
final wake = SmartcarProtocol.parseFrame( final wake = EaiCarProtocol.parseFrame(
SmartcarProtocol.buildPacket(SmartcarProtocol.notifyAiWake, const [0xAA]) EaiCarProtocol.buildPacket(EaiCarProtocol.notifyAiWake, const [0xAA])
.toList() .toList()
..[0] = SmartcarProtocol.headerNotify); ..[0] = EaiCarProtocol.headerNotify);
// 帧头被改过,CRC 对不上,解析必须失败——这条顺带守住"CRC 覆盖帧头" // 帧头被改过,CRC 对不上,解析必须失败——这条顺带守住"CRC 覆盖帧头"
expect(wake, isNull); expect(wake, isNull);
}); });
test('AI 状态取值与协议文档一致', () { test('AI 状态取值与协议文档一致', () {
expect(SmartcarProtocol.aiStateSpeakEnd, 0x00); expect(EaiCarProtocol.aiStateSpeakEnd, 0x00);
expect(SmartcarProtocol.aiStateThinking, 0x01); expect(EaiCarProtocol.aiStateThinking, 0x01);
expect(SmartcarProtocol.aiStateSpeakStart, 0x02); expect(EaiCarProtocol.aiStateSpeakStart, 0x02);
expect(SmartcarProtocol.aiStateClose, 0x03); expect(EaiCarProtocol.aiStateClose, 0x03);
expect(SmartcarProtocol.aiStateListening, 0x04); expect(EaiCarProtocol.aiStateListening, 0x04);
}); });
}); });
@ -219,13 +219,13 @@ void main() {
]; ];
test('电量与版本与单发命令对得上', () { test('电量与版本与单发命令对得上', () {
final s = SmartcarAllSettings.parse(real)!; final s = EaiCarAllSettings.parse(real)!;
expect(s.battery, 60, reason: '与 0x04 的应答 3c 一致'); expect(s.battery, 60, reason: '与 0x04 的应答 3c 一致');
expect(s.firmwareVersion, '2.12.12', reason: '与 0x01 的应答 02 0c 0c 一致'); expect(s.firmwareVersion, '2.12.12', reason: '与 0x01 的应答 02 0c 0c 一致');
}); });
test('其余字段按已钉死的偏移解出', () { test('其余字段按已钉死的偏移解出', () {
final s = SmartcarAllSettings.parse(real)!; final s = EaiCarAllSettings.parse(real)!;
expect(s.brightness, 10); expect(s.brightness, 10);
expect(s.randomExprFreq, 20); expect(s.randomExprFreq, 20);
expect(s.volume, 0, reason: '实测就是 0(样机静音),改这条前先在真机上复核'); expect(s.volume, 0, reason: '实测就是 0(样机静音),改这条前先在真机上复核');
@ -236,22 +236,22 @@ void main() {
group('应答期望', () { group('应答期望', () {
test('这四条命令设备不回包,别等超时', () { test('这四条命令设备不回包,别等超时', () {
for (final c in [ for (final c in [
SmartcarProtocol.cmdMusicPlayState, EaiCarProtocol.cmdMusicPlayState,
SmartcarProtocol.cmdControlEmotion, EaiCarProtocol.cmdControlEmotion,
SmartcarProtocol.cmdAiState, EaiCarProtocol.cmdAiState,
SmartcarProtocol.cmdControlMusicBeat, EaiCarProtocol.cmdControlMusicBeat,
]) { ]) {
expect(SmartcarProtocol.expectsResponse(c), isFalse, expect(EaiCarProtocol.expectsResponse(c), isFalse,
reason: '0x${c.toRadixString(16)} 不该等应答'); reason: '0x${c.toRadixString(16)} 不该等应答');
} }
}); });
test('查询类命令要等应答', () { test('查询类命令要等应答', () {
expect(SmartcarProtocol.expectsResponse(SmartcarProtocol.cmdGetVersion), expect(EaiCarProtocol.expectsResponse(EaiCarProtocol.cmdGetVersion),
isTrue); isTrue);
expect(SmartcarProtocol.expectsResponse(SmartcarProtocol.cmdGetBattery), expect(EaiCarProtocol.expectsResponse(EaiCarProtocol.cmdGetBattery),
isTrue); isTrue);
expect(SmartcarProtocol.expectsResponse(SmartcarProtocol.cmdSyncSummary), expect(EaiCarProtocol.expectsResponse(EaiCarProtocol.cmdSyncSummary),
isTrue); isTrue);
}); });
}); });

30
apps/client/test/devices/smartcar_name_test.dart

@ -1,30 +0,0 @@
import 'package:eaimar/devices/smartcar/smartcar_link.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('isAllowedSmartcarName —— 车载香薰广播名准入', () {
test('规定的名字放行', () {
expect(isAllowedSmartcarName('EaiCar'), isTrue);
expect(isAllowedSmartcarName(' eaicar '), isTrue);
expect(isAllowedSmartcarName('EAICAR-3F2A'), isTrue);
});
// ⚠️ 旧广播名不再接纳。厂商 ID(0x8505) 也不再单独放行——它只说明
// "这是云衍方案的设备",说明不了"这是我们的正式产品"。
test('旧名 Smartcar 一律拒绝', () {
expect(isAllowedSmartcarName('Smartcar'), isFalse);
expect(isAllowedSmartcarName('smartcar'), isFalse);
expect(isAllowedSmartcarName('SmartcarX'), isFalse);
});
test('空名字拒绝', () {
expect(isAllowedSmartcarName(''), isFalse);
expect(isAllowedSmartcarName(' '), isFalse);
});
test('别家设备拒绝', () {
expect(isAllowedSmartcarName('EaiRec'), isFalse);
expect(isAllowedSmartcarName('Echo-one'), isFalse);
});
});
}

4
apps/client/test/module_registry_test.dart

@ -70,11 +70,11 @@ void main() {
}); });
group('设备品类默认编排', () { group('设备品类默认编排', () {
test('车载香薰连上后铺 Smartcar / 现场录音 / 语音纪要(按这个顺序)', () { test('车载香薰连上后铺 EaiCar / 现场录音 / 语音纪要(按这个顺序)', () {
expect( expect(
AgentModules.defaultsForCategory(DeviceCategories.carAroma), AgentModules.defaultsForCategory(DeviceCategories.carAroma),
[ [
AgentModules.smartcarChat, AgentModules.eaicarChat,
AgentModules.fieldRecording, AgentModules.fieldRecording,
AgentModules.meeting, AgentModules.meeting,
], ],

Loading…
Cancel
Save