Browse Source

client: 设备默认图去背;AI 页编排跟随设备;香薰 AI 回复走蓝牙;OTA 状态如实显示

设备默认图
- default.png / default_icon.png 是 assets/images/devices 里仅有的两张**不透明带底图**
  (892×1152 竖版海报 + 浅蓝渐变底),而设备页展示区是 1.14:1 的横宽框,
  BoxFit.contain 后成了中间一条窄的蓝色卡片、左右大片留白,深色模式下尤其突兀。
  改成去背透明(948×828 / 176×154),主体自己铺满框、周围就是页面背景,代码零适配。
- device_images.dart 记下这条约定,免得以后又丢一张竖版带底图进来。

AI 页按设备铺编排(连着香薰却显示全量功能)
- _load() 的一次性迁移会改 enabledIds 却**不更新基线 _lastAppliedKey**,
  尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部 append 回来 ——
  香薰编排铺成 3 项,重启后被追加成 11 项,基线还停在 3 项,
  于是 applyDeviceDefaults 判定「用户动过编排」,从此再也不铺,且怎么重连都恢复不了。
  现在:当前编排若是自动铺的就整个跳过这些迁移;已经坏掉的装机由
  _rebaseDeviceDefaultsBaseline 一次性把基线抬到现状(判据收紧到迁移留下的特征形态)。
- AgentDefaults.bind 补跑一次当前状态:GetX 的 ever 拿不到绑定那一刻已有的值,
  而设备是被动回连的,完全可能赶在 bind 之前就绪。
- 断开设备后回退到 DeviceHub.lastKnownVendor:设备图和「设备管理」在断连后都显示
  最近那台设备,AI 页却停在上一台铺的编排里,于是出现「图是耳机、功能是香薰」。
  为此给 DeviceHub 加 lastKnownVendor,并把它与 lastKnownName 抽成同一个来源 ——
  各算各的就还会自相矛盾。
- test/agent_module_defaults_test.dart 6 个用例守这条规则。

车载香薰唤醒 AI 后回复从手机扬声器出来
- 下行本来就设计成走经典蓝牙 A2DP,坏在共用的音频会话上:defaultToSpeaker 把输出
  钉在内置扬声器,选项里只有 allowBluetooth(HFP)而没有 allowBluetoothA2dp
  (playAndRecord 下 A2DP 输出默认是关的)。而这条链路采音在设备上走 BLE opus,
  手机压根不录音,用 playAndRecord 本身就是错配。
- EmaiAudioSession 分档:香薰走新的 playbackOnly(.playback 类别,输出跟随系统路由),
  EMAI 页「按住说话」仍是 playAndRecord(去掉 defaultToSpeaker 会退回听筒,更糟),
  只补上 allowBluetoothA2dp。原生 PcmSpeakerPlayer 明确不碰 AVAudioSession,改这一处即可。

固件升级页
- 后台没给产品配过固件时,页面显示的是绿色的「已是最新」——这是假话,会让人以为
  手上固件是最新的而不再去查。改成三态:找不到产品配置 / 服务端暂无固件版本 / 有新版本·已是最新。
  新文案 otaNoServerVersion 已补齐 41 个语言文件(i18n_audit total_missing=0)。
- 产品找到但版本为空时打一条点名 warn,不再和「拉取失败」混在一起。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 4 weeks ago
parent
commit
c14580d7db
  1. BIN
      apps/client/assets/images/devices/default.png
  2. BIN
      apps/client/assets/images/devices/default_icon.png
  3. 1
      apps/client/lib/core/translations/language/ar_sa.dart
  4. 1
      apps/client/lib/core/translations/language/bg_bg.dart
  5. 1
      apps/client/lib/core/translations/language/cs_cz.dart
  6. 1
      apps/client/lib/core/translations/language/da_dk.dart
  7. 1
      apps/client/lib/core/translations/language/de_de.dart
  8. 1
      apps/client/lib/core/translations/language/el_gr.dart
  9. 1
      apps/client/lib/core/translations/language/en_us.dart
  10. 1
      apps/client/lib/core/translations/language/es_es.dart
  11. 1
      apps/client/lib/core/translations/language/et_ee.dart
  12. 1
      apps/client/lib/core/translations/language/fa_ir.dart
  13. 1
      apps/client/lib/core/translations/language/fi_fi.dart
  14. 1
      apps/client/lib/core/translations/language/fr_fr.dart
  15. 1
      apps/client/lib/core/translations/language/hi_in.dart
  16. 1
      apps/client/lib/core/translations/language/hr_hr.dart
  17. 1
      apps/client/lib/core/translations/language/hu_hu.dart
  18. 1
      apps/client/lib/core/translations/language/id_id.dart
  19. 1
      apps/client/lib/core/translations/language/is_is.dart
  20. 1
      apps/client/lib/core/translations/language/it_it.dart
  21. 1
      apps/client/lib/core/translations/language/ja_jp.dart
  22. 1
      apps/client/lib/core/translations/language/ko_kr.dart
  23. 1
      apps/client/lib/core/translations/language/lt_lt.dart
  24. 1
      apps/client/lib/core/translations/language/lv_lv.dart
  25. 1
      apps/client/lib/core/translations/language/ms_my.dart
  26. 1
      apps/client/lib/core/translations/language/nb_no.dart
  27. 1
      apps/client/lib/core/translations/language/nl_nl.dart
  28. 1
      apps/client/lib/core/translations/language/pl_pl.dart
  29. 1
      apps/client/lib/core/translations/language/pt_pt.dart
  30. 1
      apps/client/lib/core/translations/language/ro_ro.dart
  31. 1
      apps/client/lib/core/translations/language/ru_ru.dart
  32. 1
      apps/client/lib/core/translations/language/sk_sk.dart
  33. 1
      apps/client/lib/core/translations/language/sl_si.dart
  34. 1
      apps/client/lib/core/translations/language/sr_rs.dart
  35. 1
      apps/client/lib/core/translations/language/sv_se.dart
  36. 1
      apps/client/lib/core/translations/language/th_th.dart
  37. 1
      apps/client/lib/core/translations/language/tl_ph.dart
  38. 1
      apps/client/lib/core/translations/language/tr_tr.dart
  39. 1
      apps/client/lib/core/translations/language/uk_ua.dart
  40. 1
      apps/client/lib/core/translations/language/vi_vn.dart
  41. 1
      apps/client/lib/core/translations/language/zh_cn.dart
  42. 1
      apps/client/lib/core/translations/language/zh_hk.dart
  43. 1
      apps/client/lib/core/translations/language/zh_tw.dart
  44. 7
      apps/client/lib/data/models/device_images.dart
  45. 23
      apps/client/lib/data/services/agent_module_defaults.dart
  46. 79
      apps/client/lib/data/services/agent_module_service.dart
  47. 81
      apps/client/lib/data/utils/emai_audio_session.dart
  48. 14
      apps/client/lib/data/utils/pcm_stream_player.dart
  49. 35
      apps/client/lib/devices/device_hub.dart
  50. 6
      apps/client/lib/devices/smartcar/smartcar_device_plugin.dart
  51. 17
      apps/client/lib/modules/ota_upgrade/controllers/device_ota_controller.dart
  52. 26
      apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart
  53. 81
      apps/client/test/agent_module_defaults_test.dart

BIN
apps/client/assets/images/devices/default.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 KiB

After

Width:  |  Height:  |  Size: 572 KiB

BIN
apps/client/assets/images/devices/default_icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 32 KiB

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

@ -1814,6 +1814,7 @@ const Map<String, String> arSA = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'لديك أحدث إصدار',
'otaNoServerVersion': 'لا توجد نسخة برنامج ثابت لهذا الجهاز على الخادم',
'otaCurrentVersion': 'الإصدار الحالي',
'otaDone': 'تم',
'otaDownloading': 'جارٍ تنزيل البرنامج الثابت...',

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

@ -1877,6 +1877,7 @@ const Map<String, String> bgBG = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Вече е актуален',
'otaNoServerVersion': 'Няма версия на фърмуера за това устройство на сървъра',
'otaCurrentVersion': 'Текуща версия',
'otaDone': 'Готово',
'otaDownloading': 'Изтегляне на фърмуера...',

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

@ -1845,6 +1845,7 @@ const Map<String, String> csCZ = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Již aktuální',
'otaNoServerVersion': 'Na serveru není k dispozici verze firmwaru pro toto zařízení',
'otaCurrentVersion': 'Aktuální verze',
'otaDone': 'Hotovo',
'otaDownloading': 'Stahování firmwaru...',

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

@ -1847,6 +1847,7 @@ const Map<String, String> daDK = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Allerede opdateret',
'otaNoServerVersion': 'Ingen firmwareversion til denne enhed på serveren',
'otaCurrentVersion': 'Nuværende version',
'otaDone': 'Færdig',
'otaDownloading': 'Downloader firmware...',

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

@ -2091,6 +2091,7 @@ const Map<String, String> deDE = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Bereits aktuell',
'otaNoServerVersion': 'Keine Firmware-Version für dieses Gerät auf dem Server',
'otaCurrentVersion': 'Aktuelle Version',
'otaDone': 'Fertig',
'otaDownloading': 'Firmware wird heruntergeladen...',

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

@ -1881,6 +1881,7 @@ const Map<String, String> elGR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Ήδη ενημερωμένο',
'otaNoServerVersion': 'Δεν υπάρχει έκδοση υλικολογισμικού για αυτή τη συσκευή στον διακομιστή',
'otaCurrentVersion': 'Τρέχουσα έκδοση',
'otaDone': 'Τέλος',
'otaDownloading': 'Λήψη υλικολογισμικού...',

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

@ -2059,6 +2059,7 @@ const Map<String, String> enUS = {
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Already up to date',
'otaNoServerVersion': 'No firmware version for this device on the server',
'otaCurrentVersion': 'Current version',
'otaDone': 'Done',
'otaDownloading': 'Downloading firmware...',

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

@ -2085,6 +2085,7 @@ const Map<String, String> esES = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Ya está actualizado',
'otaNoServerVersion': 'No hay versión de firmware para este dispositivo en el servidor',
'otaCurrentVersion': 'Versión actual',
'otaDone': 'Listo',
'otaDownloading': 'Descargando firmware...',

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

@ -1841,6 +1841,7 @@ const Map<String, String> etEE = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Juba ajakohane',
'otaNoServerVersion': 'Serveris pole selle seadme püsivara versiooni',
'otaCurrentVersion': 'Praegune versioon',
'otaDone': 'Valmis',
'otaDownloading': 'Püsivara allalaadimine...',

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

@ -1832,6 +1832,7 @@ const Map<String, String> faIR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'هم‌اکنون به‌روز است',
'otaNoServerVersion': 'هیچ نسخه میان‌افزاری برای این دستگاه روی سرور موجود نیست',
'otaCurrentVersion': 'نسخهٔ فعلی',
'otaDone': 'انجام شد',
'otaDownloading': 'در حال دانلود میان‌افزار...',

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

@ -1851,6 +1851,7 @@ const Map<String, String> fiFI = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jo ajan tasalla',
'otaNoServerVersion': 'Palvelimella ei ole laiteohjelmistoversiota tälle laitteelle',
'otaCurrentVersion': 'Nykyinen versio',
'otaDone': 'Valmis',
'otaDownloading': 'Ladataan laiteohjelmistoa...',

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

@ -2104,6 +2104,7 @@ const Map<String, String> frFR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Déjà à jour',
'otaNoServerVersion': 'Aucune version de micrologiciel pour cet appareil sur le serveur',
'otaCurrentVersion': 'Version actuelle',
'otaDone': 'Terminé',
'otaDownloading': 'Téléchargement du firmware...',

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

@ -1850,6 +1850,7 @@ const Map<String, String> hiIN = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'पहले से नवीनतम है',
'otaNoServerVersion': 'सर्वर पर इस डिवाइस के लिए कोई फ़र्मवेयर संस्करण नहीं है',
'otaCurrentVersion': 'वर्तमान संस्करण',
'otaDone': 'पूर्ण',
'otaDownloading': 'फ़र्मवेयर डाउनलोड हो रहा है...',

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

@ -1866,6 +1866,7 @@ const Map<String, String> hrHR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Već je ažurno',
'otaNoServerVersion': 'Na poslužitelju nema verzije firmvera za ovaj uređaj',
'otaCurrentVersion': 'Trenutačna verzija',
'otaDone': 'Gotovo',
'otaDownloading': 'Preuzimanje firmvera...',

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

@ -1869,6 +1869,7 @@ const Map<String, String> huHU = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Már naprakész',
'otaNoServerVersion': 'A kiszolgálón nincs firmware-verzió ehhez az eszközhöz',
'otaCurrentVersion': 'Jelenlegi verzió',
'otaDone': 'Kész',
'otaDownloading': 'Firmware letöltése...',

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

@ -1850,6 +1850,7 @@ const Map<String, String> idID = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Sudah versi terbaru',
'otaNoServerVersion': 'Tidak ada versi firmware untuk perangkat ini di server',
'otaCurrentVersion': 'Versi saat ini',
'otaDone': 'Selesai',
'otaDownloading': 'Mengunduh firmware...',

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

@ -1847,6 +1847,7 @@ const Map<String, String> isIS = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Þegar uppfært',
'otaNoServerVersion': 'Engin fastbúnaðarútgáfa fyrir þetta tæki á þjóninum',
'otaCurrentVersion': 'Núverandi útgáfa',
'otaDone': 'Lokið',
'otaDownloading': 'Sæki fastbúnað...',

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

@ -1888,6 +1888,7 @@ const Map<String, String> itIT = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Già aggiornato',
'otaNoServerVersion': 'Nessuna versione del firmware per questo dispositivo sul server',
'otaCurrentVersion': 'Versione attuale',
'otaDone': 'Fatto',
'otaDownloading': 'Download del firmware...',

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

@ -1886,6 +1886,7 @@ const Map<String, String> jaJP = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '最新の状態です',
'otaNoServerVersion': 'サーバーにこのデバイスのファームウェアがありません',
'otaCurrentVersion': '現在のバージョン',
'otaDone': '完了',
'otaDownloading': 'ファームウェアをダウンロード中...',

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

@ -1879,6 +1879,7 @@ const Map<String, String> koKR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '이미 최신 버전입니다',
'otaNoServerVersion': '서버에 이 기기의 펌웨어 버전이 없습니다',
'otaCurrentVersion': '현재 버전',
'otaDone': '완료',
'otaDownloading': '펌웨어 다운로드 중...',

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

@ -1865,6 +1865,7 @@ const Map<String, String> ltLT = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jau naujausia versija',
'otaNoServerVersion': 'Serveryje nėra šio įrenginio programinės įrangos versijos',
'otaCurrentVersion': 'Dabartinė versija',
'otaDone': 'Atlikta',
'otaDownloading': 'Atsisiunčiama programinė aparatinė įranga...',

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

@ -1870,6 +1870,7 @@ const Map<String, String> lvLV = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Jau ir jaunākā versija',
'otaNoServerVersion': 'Serverī nav šīs ierīces aparātprogrammatūras versijas',
'otaCurrentVersion': 'Pašreizējā versija',
'otaDone': 'Gatavs',
'otaDownloading': 'Notiek programmaparatūras lejupielāde...',

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

@ -1854,6 +1854,7 @@ const Map<String, String> msMY = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Sudah versi terkini',
'otaNoServerVersion': 'Tiada versi perisian tegar untuk peranti ini pada pelayan',
'otaCurrentVersion': 'Versi semasa',
'otaDone': 'Selesai',
'otaDownloading': 'Memuat turun perisian tegar...',

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

@ -1848,6 +1848,7 @@ const Map<String, String> nbNO = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Allerede oppdatert',
'otaNoServerVersion': 'Ingen fastvareversjon for denne enheten på serveren',
'otaCurrentVersion': 'Gjeldende versjon',
'otaDone': 'Ferdig',
'otaDownloading': 'Laster ned fastvare...',

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

@ -1747,6 +1747,7 @@ const Map<String, String> nlNL = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Al up-to-date',
'otaNoServerVersion': 'Geen firmwareversie voor dit apparaat op de server',
'otaCurrentVersion': 'Huidige versie',
'otaDone': 'Klaar',
'otaDownloading': 'Firmware downloaden...',

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

@ -1876,6 +1876,7 @@ const Map<String, String> plPL = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Masz najnowszą wersję',
'otaNoServerVersion': 'Brak wersji oprogramowania układowego dla tego urządzenia na serwerze',
'otaCurrentVersion': 'Bieżąca wersja',
'otaDone': 'Gotowe',
'otaDownloading': 'Pobieranie oprogramowania...',

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

@ -2091,6 +2091,7 @@ const Map<String, String> ptPT = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Já está atualizado',
'otaNoServerVersion': 'Nenhuma versão de firmware para este dispositivo no servidor',
'otaCurrentVersion': 'Versão atual',
'otaDone': 'Concluído',
'otaDownloading': 'A transferir firmware...',

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

@ -1878,6 +1878,7 @@ const Map<String, String> roRO = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Deja la zi',
'otaNoServerVersion': 'Nu există o versiune de firmware pentru acest dispozitiv pe server',
'otaCurrentVersion': 'Versiunea curentă',
'otaDone': 'Gata',
'otaDownloading': 'Se descarcă firmware-ul...',

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

@ -2066,6 +2066,7 @@ const Map<String, String> ruRU = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Установлена последняя версия',
'otaNoServerVersion': 'На сервере нет версии прошивки для этого устройства',
'otaCurrentVersion': 'Текущая версия',
'otaDone': 'Готово',
'otaDownloading': 'Загрузка прошивки...',

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

@ -1859,6 +1859,7 @@ const Map<String, String> skSK = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Už je aktuálny',
'otaNoServerVersion': 'Na serveri nie je verzia firmvéru pre toto zariadenie',
'otaCurrentVersion': 'Aktuálna verzia',
'otaDone': 'Hotovo',
'otaDownloading': 'Sťahovanie firmvéru...',

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

@ -1862,6 +1862,7 @@ const Map<String, String> slSI = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Že posodobljeno',
'otaNoServerVersion': 'Na strežniku ni različice vdelane programske opreme za to napravo',
'otaCurrentVersion': 'Trenutna različica',
'otaDone': 'Končano',
'otaDownloading': 'Prenašanje vdelane programske opreme...',

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

@ -1858,6 +1858,7 @@ const Map<String, String> srRS = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Već je ažurno',
'otaNoServerVersion': 'Na serveru nema verzije firmvera za ovaj uređaj',
'otaCurrentVersion': 'Trenutna verzija',
'otaDone': 'Gotovo',
'otaDownloading': 'Preuzimanje firmvera...',

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

@ -1854,6 +1854,7 @@ const Map<String, String> svSE = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Redan uppdaterad',
'otaNoServerVersion': 'Ingen fast programvaruversion för den här enheten på servern',
'otaCurrentVersion': 'Nuvarande version',
'otaDone': 'Klar',
'otaDownloading': 'Laddar ner fast programvara...',

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

@ -1807,6 +1807,7 @@ const Map<String, String> thTH = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'เป็นเวอร์ชันล่าสุดแล้ว',
'otaNoServerVersion': 'ไม่มีเวอร์ชันเฟิร์มแวร์สำหรับอุปกรณ์นี้บนเซิร์ฟเวอร์',
'otaCurrentVersion': 'เวอร์ชันปัจจุบัน',
'otaDone': 'เสร็จสิ้น',
'otaDownloading': 'กำลังดาวน์โหลดเฟิร์มแวร์...',

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

@ -1892,6 +1892,7 @@ const Map<String, String> tlPH = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Pinakabago na',
'otaNoServerVersion': 'Walang bersyon ng firmware para sa device na ito sa server',
'otaCurrentVersion': 'Kasalukuyang bersyon',
'otaDone': 'Tapos na',
'otaDownloading': 'Dina-download ang firmware...',

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

@ -1846,6 +1846,7 @@ const Map<String, String> trTR = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Zaten güncel',
'otaNoServerVersion': 'Sunucuda bu cihaz için bellenim sürümü yok',
'otaCurrentVersion': 'Geçerli sürüm',
'otaDone': 'Tamam',
'otaDownloading': 'Ürün yazılımı indiriliyor...',

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

@ -1866,6 +1866,7 @@ const Map<String, String> ukUA = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Установлено найновішу версію',
'otaNoServerVersion': 'На сервері немає версії прошивки для цього пристрою',
'otaCurrentVersion': 'Поточна версія',
'otaDone': 'Готово',
'otaDownloading': 'Завантаження прошивки...',

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

@ -1826,6 +1826,7 @@ const Map<String, String> viVN = {
'genderLockedByIdVerify': 'Gender is set from your verified ID and cannot be changed',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': 'Đã là phiên bản mới nhất',
'otaNoServerVersion': 'Máy chủ chưa có phiên bản phần mềm cho thiết bị này',
'otaCurrentVersion': 'Phiên bản hiện tại',
'otaDone': 'Xong',
'otaDownloading': 'Đang tải phần mềm...',

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

@ -1873,6 +1873,7 @@ const Map<String, String> zhCN = {
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本',
'otaNoServerVersion': '服务端暂无该设备的固件版本',
'otaCurrentVersion': '当前版本',
'otaDone': '完成',
'otaDownloading': '正在下载固件...',

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

@ -1856,6 +1856,7 @@ const Map<String, String> zhHK = {
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本',
'otaNoServerVersion': '伺服器暫無此裝置的韌體版本',
'otaCurrentVersion': '目前版本',
'otaDone': '完成',
'otaDownloading': '正在下載韌體...',

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

@ -1856,6 +1856,7 @@ const Map<String, String> zhTW = {
'genderLockedByIdVerify': '性别已根据实名认证信息确定,不可修改',
// ---- 恒玄(BES)固件 OTA 升级页 ----
'otaAlreadyLatest': '已是最新版本',
'otaNoServerVersion': '伺服器暫無此裝置的韌體版本',
'otaCurrentVersion': '目前版本',
'otaDone': '完成',
'otaDownloading': '正在下載韌體...',

7
apps/client/lib/data/models/device_images.dart

@ -16,6 +16,13 @@ class DeviceImages {
DeviceImages._();
static const String _tag = 'DeviceImages';
/// ⚠️ 目录里的图**必须是去背的透明 PNG**,`default.png` / `default_icon.png`
/// 也不例外。设备页的展示区是横宽的(约 1.14:1),而出图习惯是竖版海报
/// (892×1152,0.77:1)——带不透明底色的竖图用 `BoxFit.contain` 塞进去,
/// 会变成中间一条窄的色块卡片、左右大片留白,且底色与页面背景(尤其深色模式)
/// 硬碰硬。2026-09-14 之前的默认图正是这样,表现为「图片和显示框对不上」。
/// 去背之后主体自己铺满框、周围就是页面背景,不需要任何代码适配。
static const String dir = 'assets/images/devices/';
static const String defaultImage = '${dir}default.png';
static const String defaultIcon = '${dir}default_icon.png';

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

@ -37,12 +37,31 @@ class AgentDefaults {
static void bind(DeviceHub hub) {
_worker?.dispose();
_worker = ever<List<DeviceSession>>(hub.sessions, (list) {
_worker = ever<List<DeviceSession>>(hub.sessions, (_) => _apply(hub));
// ⚠️ 还要对**当前状态**跑一次:GetX 的 ever 只在之后的赋值上触发,
// 拿不到绑定那一刻已经在的值。设备是被动回连上来的(原生扫到广播就接),
// 完全可能赶在 bind 之前就绪 —— 那样 AI 页这一整轮都不会按设备铺编排,
// 而且不报任何错,表现就是「设备明明连着,功能却是全量的」。
_apply(hub);
}
/// 按「当前连着的设备,没有就按最近连过的那台」铺默认编排。
///
/// ⚠️ 断开后必须回退到 [DeviceHub.lastKnownVendor],不能什么都不做:
/// 设备页的产品图和「设备管理」在断连后仍然显示最近那台设备,AI 页却停在
/// 上一台设备铺下的编排里 —— 于是出现「图是 Echo-one 耳机、功能却是车载香薰
/// 那三个」。三处必须同一个口径,见 DeviceHub._lastKnown。
static void _apply(DeviceHub hub) {
final list = hub.sessions;
if (list.isNotEmpty) {
for (final s in list) {
final cat = _vendorCategory[s.vendor];
if (cat != null) applyAgentDefaultsFor(cat);
}
});
return;
}
final cat = _vendorCategory[hub.lastKnownVendor];
if (cat != null) applyAgentDefaultsFor(cat);
}
static void applyAgentDefaultsFor(String categoryId) {

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

@ -40,14 +40,93 @@ class AgentModuleService extends GetxService {
.where((id) => AgentModules.byId(id) != null)
.toList();
enabledIds.assignAll(list);
// ⚠️ 当前这份编排如果是**设备默认铺下来的**(用户没动过),三个一次性迁移
// 一律跳过。它们是给「用户自己的编排」补新功能用的,而设备编排本来就该
// 只有那几项 —— 尤其 _migrateDefaultLayout 会把 defaultEnabled 里缺的全部
// 追加回来,等于把「连上香薰只显示 Smartcar/现场录音/语音纪要」直接抹掉。
//
// 2026-09-14 线上就是这么坏的:香薰编排铺成 3 项 → 重启 App → 迁移追加成
// 11 项 → 而基线 _lastAppliedKey 还停在 3 项 → 之后 applyDeviceDefaults
// 一比较发现「对不上」,判成用户动过编排,**从此再也不铺**。表现就是
// 连着香薰却显示全量功能,且怎么重连都恢复不了。
if (_matchesLastApplied()) return;
_migrateEmaiAssistant();
_migrateDefaultLayout();
_migrateDefaultOrder();
_rebaseDeviceDefaultsBaseline();
} else {
enabledIds.assignAll(AgentModules.defaultEnabled);
}
}
/// 当前编排是否与「上一次自动铺下去的那份」逐项相同 —— 即用户没动过。
bool _matchesLastApplied() {
if (!_storage.hasData(_lastAppliedKey)) return false;
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
.map((e) => e.toString())
.toList();
return last.isNotEmpty && _sameOrder(enabledIds, last);
}
/// 一次性纠正:把已经被 [_migrateDefaultLayout] 追加过的设备编排重新认作基线。
///
/// 修上面那条时序 bug 之前装过的机器,storage 里已经是「enabledIds=11 项、
/// 基线=3 项」的坏状态,而迁移带 migratedKey 不会再跑一次,光修 _load 救不回来。
///
/// 判据收得很紧,必须**同时**满足:基线存在、当前编排以基线为前缀、多出来的项
/// 全部来自 defaultEnabled —— 这正是 _migrateDefaultLayout「保持原序、把缺的
/// append 到尾部」留下的形态。
///
/// 纠正的是**基线**不是编排:把基线抬到当前值,界面此刻不变,等下次连上设备时
/// 由 applyDeviceDefaults 正常铺成该设备的编排 —— 那本来就是用户预期的时机。
/// (代价:用户如果**恰好**手动加的也全是 defaultEnabled 里的项、且加在尾部,
/// 会被误判成迁移污染,下次连设备时编排被重铺一次。两种误判范围相同,
/// 选这个是因为它不会在启动瞬间凭空改变用户看到的界面。)
void _rebaseDeviceDefaultsBaseline() {
const rebaseKey = 'ai_tab_device_defaults_rebase_v1';
if (_storage.read<bool>(rebaseKey) == true) return;
_storage.write(rebaseKey, true);
if (!_storage.hasData(_lastAppliedKey)) return;
final last = (_storage.read<List>(_lastAppliedKey) ?? const [])
.map((e) => e.toString())
.toList();
if (!isLayoutMigrationArtifact(
enabled: enabledIds,
lastApplied: last,
defaults: AgentModules.defaultEnabled,
)) {
return;
}
_storage.write(_lastAppliedKey, enabledIds.toList());
}
/// 当前编排看起来是不是「设备默认编排 + 被一次性迁移追加的全量项」。
///
/// 形态要求([_migrateDefaultLayout] 的行为特征):
/// 1. [lastApplied] 非空,且原样是 [enabled] 的**前缀**(顺序都没动);
/// 2. [enabled] 确实更长;
/// 3. 多出来的项**全部**出自 [defaults] —— 迁移只会补这些。
///
/// 抽成纯函数是为了能脱离 GetStorage 测试,见 test/agent_module_defaults_test.dart。
static bool isLayoutMigrationArtifact({
required List<String> enabled,
required List<String> lastApplied,
required List<String> defaults,
}) {
if (lastApplied.isEmpty || enabled.length <= lastApplied.length) {
return false;
}
for (var i = 0; i < lastApplied.length; i++) {
if (enabled[i] != lastApplied[i]) return false;
}
for (final id in enabled.sublist(lastApplied.length)) {
if (!defaults.contains(id)) return false;
}
return true;
}
/// 一次性迁移:默认排序调整(语音记录助手→现场录音→通话录音→…)。
///
/// 只在用户**没自己排过**时才动——即当前启用集合与默认集合完全一致,

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

@ -5,6 +5,19 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import '../../core/utils/logger.dart';
/// 这条语音链路要用手机的哪些能力。决定 AVAudioSession 的类别,
/// 而类别直接决定**声音从哪儿出来**。
enum EmaiAudioProfile {
/// 手机麦克风 + 手机放音(EMAI 页「按住说话」)。必须 playAndRecord。
phoneMic,
/// 只放音,麦克风在**设备**上(Smartcar 车载香薰按键唤醒:采音走 BLE opus,
/// 手机一个字节都不录)。
///
/// 这一档必须用 playback 而不是 playAndRecord —— 见 [ensure] 里的说明。
playbackOnly,
}
/// EMAI 语音链路(麦克风上行 + TTS 下行)共用的一份 AVAudioSession 配置。
///
/// 为什么要抽出来:麦克风和播放器是两个模块,各自配一次会话就会互相覆盖。
@ -21,24 +34,70 @@ class EmaiAudioSession {
static const String _tag = 'EmaiAudioSession';
static bool _configured = false;
/// 当前生效的档位;换档要重新 configure,不能像原来那样只配一次。
static EmaiAudioProfile? _configured;
static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
/// 配置(只做一次)并激活会话。失败只记日志,不抛——
/// 按档位配置并激活会话。失败只记日志,不抛——
/// 会话配不上时让调用方继续尝试,总比整条链路直接不启动好。
static Future<void> ensure() async {
///
/// ⚠️ **playbackOnly 这一档是有来历的**(2026-09-14 修):车载香薰唤醒 AI 后
/// 回复声音从**手机扬声器**出来,而不是从香薰喇叭出来。香薰的下行本来就设计成
/// 走经典蓝牙 A2DP(`_SmartcarPhoneSpeaker` 的注释写的就是「A2DP 连着时从设备
/// 喇叭出来」),坏在这份会话配置上,两处叠加:
/// 1. `defaultToSpeaker` 把输出钉在内置扬声器上;
/// 2. 选项里只有 `allowBluetooth`(那是 HFP 免提),**没有 allowBluetoothA2dp**
/// —— playAndRecord 类别下 A2DP 输出默认是关的。
/// 而这条链路手机压根不录音,用 playAndRecord 本身就是错配。改成 playback 之后
/// 输出走系统当前路由,A2DP 连着就从设备喇叭出。
///
/// phoneMic 那一档保留 defaultToSpeaker(去掉会退回听筒,那是更糟的退化),
/// 只补上 allowBluetoothA2dp。
static Future<void> ensure(
{EmaiAudioProfile profile = EmaiAudioProfile.phoneMic}) async {
if (!_apply) return;
try {
final session = await AudioSession.instance;
if (!_configured) {
_configured = true;
await session.configure(AudioSessionConfiguration(
if (_configured != profile) {
_configured = profile;
await session.configure(profile == EmaiAudioProfile.playbackOnly
? _playbackOnlyConfig()
: _phoneMicConfig());
}
await session.setActive(true);
} catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试): $e');
}
}
/// 只放音:输出跟随系统路由(蓝牙 A2DP / AirPlay / 扬声器)。
static AudioSessionConfiguration _playbackOnlyConfig() =>
AudioSessionConfiguration(
avAudioSessionCategory: AVAudioSessionCategory.playback,
// ⚠️ 不要写 defaultToSpeaker:它只在 playAndRecord 下合法,而且正是
// 「声音不从蓝牙设备出来」的那个元凶。
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.allowBluetoothA2dp |
AVAudioSessionCategoryOptions.allowAirPlay,
avAudioSessionMode: AVAudioSessionMode.defaultMode,
androidAudioAttributes: const AndroidAudioAttributes(
contentType: AndroidAudioContentType.speech,
usage: AndroidAudioUsage.media,
),
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
);
static AudioSessionConfiguration _phoneMicConfig() =>
AudioSessionConfiguration(
// iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
// allowBluetooth 是 HFP(免提,双向窄带);allowBluetoothA2dp 才是高音质
// 单向输出。两个都给,让系统按当前是否真的在录音去挑。
avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth,
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.allowBluetoothA2dp,
// ⚠️ iOS 固定 voiceChat:record 开了 echoCancel,它会打开
// voice-processing,系统随之把 mode 改成 voiceChat 并一直持有
// (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。
@ -57,11 +116,5 @@ class EmaiAudioSession {
),
// 助手说话时把背景音乐暂停,而不是压低——压低了在嘈杂环境还是听不清
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
));
}
await session.setActive(true);
} catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试): $e');
}
}
);
}

14
apps/client/lib/data/utils/pcm_stream_player.dart

@ -28,6 +28,7 @@ class PcmStreamPlayer {
PcmStreamPlayer({
this.sampleRate = 24000,
this.channels = 1,
this.playbackOnly = false,
});
static const String _tag = 'PcmStreamPlayer';
@ -38,6 +39,13 @@ class PcmStreamPlayer {
final int sampleRate;
final int channels;
/// 这条链路手机**不录音**(麦克风在设备上)。
///
/// true 时音频会话走 [EmaiAudioProfile.playbackOnly]:输出跟随系统路由,
/// 蓝牙 A2DP 连着就从设备喇叭出来。默认 false 是手机麦克风那条链路,
/// 必须 playAndRecord,不能改。
final bool playbackOnly;
/// iOS 专用的原生 PCM 播放通道,见 [_iosNative] 的说明
static const MethodChannel _iosChannel =
MethodChannel('azure_speech/pcm_player');
@ -85,7 +93,11 @@ class PcmStreamPlayer {
///
/// 显式 setActive(true) 是把会话按本方的配置重新激活一次,
/// 相当于「录音刚放开,播放把它拿回来」。
Future<void> _ensureSession() => EmaiAudioSession.ensure();
Future<void> _ensureSession() => EmaiAudioSession.ensure(
profile: playbackOnly
? EmaiAudioProfile.playbackOnly
: EmaiAudioProfile.phoneMic,
);
/// 正在进行中的 [begin]。[end] 要等它落地才能判断这一轮有没有音频——
/// 回复很短时(音频首包和 RespondingEnded 几乎同时到)`_active` 还是 false,

35
apps/client/lib/devices/device_hub.dart

@ -108,16 +108,35 @@ class DeviceHub extends GetxService {
return null;
}
/// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。
/// 从没连过任何设备返回空串。
String get lastKnownName {
if (primaryName.value.isNotEmpty) return primaryName.value;
for (final p in _plugins.values) {
final d = p.lastKnownDevice;
if (d != null && d.name.isNotEmpty) return d.name;
/// 最近连过(或正连着)的那台设备:名字 + 出自哪家插件。
///
/// ⚠️ 名字和厂商**必须同源**,不能各算各的:设备页的产品图按名字取、
/// AI 页的默认编排按厂商取,两者指向不同设备就会出现「图是耳机、
/// 功能却是香薰的」这种自相矛盾的界面。
///
/// ⚠️ 已知口径局限:没有活跃会话时按**插件注册顺序**取第一个连过的,
/// 不是按「最近连接时间」。同时连过耳机和香薰、然后都断开时,
/// 显示的是注册在前的那家(当前是恒玄),而不是真正最近用的那台。
({String name, String vendor}) get _lastKnown {
final p = primary;
if (p != null && p.info.name.isNotEmpty) {
return (name: p.info.name, vendor: p.vendor);
}
for (final plugin in _plugins.values) {
final d = plugin.lastKnownDevice;
if (d != null && d.name.isNotEmpty) {
return (name: d.name, vendor: plugin.vendorKey);
}
return '';
}
return (name: '', vendor: '');
}
/// 最近连过(或正连着)的设备名,用于断连后仍显示「我的那台设备」。
/// 从没连过任何设备返回空串。
String get lastKnownName => _lastKnown.name;
/// 同一台设备出自哪家厂商插件。AI 页按它铺该品类的默认功能编排。
String get lastKnownVendor => _lastKnown.vendor;
bool get hasKnownDevice => lastKnownName.isNotEmpty;

6
apps/client/lib/devices/smartcar/smartcar_device_plugin.dart

@ -936,8 +936,12 @@ class _SmartcarMic implements DeviceAudioSource {
class _SmartcarPhoneSpeaker implements DeviceAudioSink {
_SmartcarPhoneSpeaker();
/// ⚠️ `playbackOnly: true` 是这条链路能从**香薰喇叭**出声的关键:
/// 采音在设备上(BLE opus),手机一个字节都不录,会话就该用 playback 类别。
/// 用 playAndRecord + defaultToSpeaker 会把声音钉死在手机扬声器上,
/// 表现就是「连着香薰、AI 却从手机外放说话」。见 EmaiAudioSession.ensure。
final PcmStreamPlayer _player =
PcmStreamPlayer(sampleRate: 16000, channels: 1);
PcmStreamPlayer(sampleRate: 16000, channels: 1, playbackOnly: true);
@override
AudioRoute get route => AudioRoute.ai;

17
apps/client/lib/modules/ota_upgrade/controllers/device_ota_controller.dart

@ -51,6 +51,13 @@ class DeviceOtaController extends GetxController {
// ---- 响应式状态 ----
final RxString currentVersion = ''.obs;
final RxString latestVersion = ''.obs;
/// 压根没找到这台设备对应的产品配置(设备名对不上、也没有绑定关系)。
///
/// 与「找到了产品、但后台还没给它传过固件」是两回事,UI 上必须分开说:
/// 前者是配置/绑定的问题,后者是运营还没上传。两种都显示成绿色的
/// 「已是最新」会让人以为固件是新的 —— 2026-09-14 车载香薰就这么误导过一次。
final RxBool productMissing = false.obs;
final RxString updateDescription = ''.obs;
final RxBool isUpdateAvailable = false.obs;
final RxBool isConnected = true.obs;
@ -153,10 +160,12 @@ class DeviceOtaController extends GetxController {
final product = _findProduct(deviceName.value);
if (product == null) {
Logger.w(_tag, '未找到设备对应的产品配置: ${deviceName.value}');
productMissing.value = true;
latestVersion.value = '';
updateDescription.value = '';
_downloadUrl = '';
} else {
productMissing.value = false;
latestVersion.value = product.version?.split('+').first.trim() ?? '';
updateDescription.value = product.updatedescription ?? '';
_downloadUrl = product.updatepackageaddress ?? '';
@ -166,6 +175,14 @@ class DeviceOtaController extends GetxController {
'服务端最新=${product.version} → 解析后=${latestVersion.value} '
'设备当前=${currentVersion.value} '
'产品表共${AppConfig.config.products.length}个');
if (latestVersion.value.isEmpty) {
// 拉到产品了、只是后台没传固件。和「拉取失败」是两回事,写清楚,
// 免得又去查网络和接口。
Logger.w(
_tag,
'产品 ${product.devicename}(${product.id}) 后台没有配置固件版本,'
'去后台「产品管理 → 版本」上传并设为当前使用版本');
}
}
_refreshUpdateAvailable();
}

26
apps/client/lib/modules/ota_upgrade/views/device_ota_view.dart

@ -165,6 +165,23 @@ class DeviceOtaView extends GetView<DeviceOtaController> {
12.verticalSpace,
Obx(() {
final available = controller.isUpdateAvailable.value;
// ⚠️ 三态,别塌成「有新版本 / 已是最新」两态:服务端没给出版本时
// 报「已是最新」是**假话**——固件可能压根没上传过,用户会以为
// 自己手上是最新固件而不再去查。2026-09-14 车载香薰踩过。
final String text;
final IconData icon;
if (controller.productMissing.value) {
// 设备名对不上产品表、又没有绑定关系,这是配置/绑定问题
text = 'otaProductInfoUnavailable'.tr;
icon = Icons.help_outline;
} else if (controller.latestVersion.value.trim().isEmpty) {
// 产品找到了,只是后台还没传固件
text = 'otaNoServerVersion'.tr;
icon = Icons.info_outline;
} else {
text = available ? 'otaUpdateAvailable'.tr : 'otaAlreadyLatest'.tr;
icon = available ? Icons.system_update : Icons.check_circle;
}
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 8.h, horizontal: 12.w),
@ -176,19 +193,22 @@ class DeviceOtaView extends GetView<DeviceOtaController> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
available ? Icons.system_update : Icons.check_circle,
icon,
size: 16.w,
color: available ? _accent : Colors.grey[500],
),
6.horizontalSpace,
Text(
available ? 'otaUpdateAvailable'.tr : 'otaAlreadyLatest'.tr,
Flexible(
child: Text(
text,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13.sp,
color: available ? _accent : Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
),
],
),
);

81
apps/client/test/agent_module_defaults_test.dart

@ -0,0 +1,81 @@
import 'package:eaimar/data/models/agent_module.dart';
import 'package:eaimar/data/services/agent_module_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
// 车载香薰连上时铺的那三个
const carAroma = ['smartcarChat', 'fieldRecording', 'meeting'];
const defaults = ['call', 'simultaneous', 'meeting', 'fieldRecording'];
group('isLayoutMigrationArtifact', () {
// 这正是 2026-09-14 线上那台机的状态:香薰编排铺过 3 项,
// 重启后被 _migrateDefaultLayout 把默认全量追加到尾部,
// 于是基线对不上、从此再也不按设备铺编排。
test('设备编排被追加了默认项 → 认得出来', () {
expect(
AgentModuleService.isLayoutMigrationArtifact(
enabled: [...carAroma, 'call', 'simultaneous'],
lastApplied: carAroma,
defaults: defaults,
),
isTrue,
);
});
test('用户把设备编排里的项删掉/换过顺序 → 不是迁移造成的,不能碰', () {
expect(
AgentModuleService.isLayoutMigrationArtifact(
enabled: ['fieldRecording', 'smartcarChat', 'meeting', 'call'],
lastApplied: carAroma,
defaults: defaults,
),
isFalse,
);
});
test('多出来的项不在默认全量里(用户从广场加的)→ 不认', () {
expect(
AgentModuleService.isLayoutMigrationArtifact(
enabled: [...carAroma, 'pictureTranslation'],
lastApplied: carAroma,
defaults: defaults,
),
isFalse,
);
});
test('编排与基线完全一致 → 没被追加过', () {
expect(
AgentModuleService.isLayoutMigrationArtifact(
enabled: carAroma,
lastApplied: carAroma,
defaults: defaults,
),
isFalse,
);
});
test('从没自动铺过(基线为空)→ 不认', () {
expect(
AgentModuleService.isLayoutMigrationArtifact(
enabled: [...carAroma, 'call'],
lastApplied: const [],
defaults: defaults,
),
isFalse,
);
});
});
// 设备编排的内容本身也守一下:香薰就该是这三个,别被顺手改掉。
test('车载香薰的默认编排是 Smartcar / 现场录音 / 语音纪要', () {
expect(
AgentModules.defaultsForCategory('carAroma'),
[
AgentModules.smartcarChat,
AgentModules.fieldRecording,
AgentModules.meeting,
],
);
});
}
Loading…
Cancel
Save