From 8ebc1b67acda99240080e991b013f40575a80ff1 Mon Sep 17 00:00:00 2001 From: liu weikai Date: Fri, 10 Jul 2026 13:52:00 +0800 Subject: [PATCH] Improve chat history and Wi-Fi runtime UX Add paged chat conversation storage and presentation, preserve MQTT ingress labels, and improve conversation bubble alignment/contrast. Introduce a unified TopBar power presenter with debounced battery status updates. Expand Wi-Fi runtime profile handling, settings backup coverage, and Reticulum directory support. --- AGENTS.md | 2 +- .../src/esp32_lvgl_idf_app_facade_runtime.cpp | 2 + cmake/TrailMateLinuxSources.cmake | 1 + docs/protocol_runtime_budget_policy.md | 38 ++ .../SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md | 585 ++++++++++++++++++ docs/wifi_access_resource_policy.md | 41 +- .../src/chat_message_mapper.cpp | 1 + .../tests/test_chat_message_mapper.cpp | 2 +- .../include/chat/infra/store/ram_store.h | 4 + .../include/chat/ports/i_chat_store.h | 39 ++ .../include/chat/usecase/chat_service.h | 4 + .../core_chat/src/infra/lxmf/lxmf_wire.cpp | 74 ++- .../core_chat/src/infra/store/ram_store.cpp | 24 +- .../core_chat/src/usecase/chat_service.cpp | 53 +- .../tests/test_chat_service_resend.cpp | 46 ++ .../platform/ui/reticulum_directory_runtime.h | 1 + .../include/platform/ui/wifi_runtime.h | 1 + .../ui_presentation/chat/chat_message_ref.h | 1 + .../chat/chat_workspace_snapshot.h | 13 +- .../chat/chat_conversation_components.h | 35 +- .../ui/screens/chat/chat_ui_controller.h | 1 + .../chat_conversation_components_watch.h | 13 +- .../ui_shared/include/ui/widgets/top_bar.h | 1 + .../ui/widgets/top_bar_power_presenter.h | 24 + .../chat_presentation_source.cpp | 19 +- .../team_chat_presentation_source.cpp | 2 +- .../screens/chat/chat_compose_components.cpp | 2 - .../chat/chat_conversation_components.cpp | 395 +++++++++--- .../screens/chat/chat_conversation_styles.cpp | 9 +- .../chat/chat_message_list_components.cpp | 2 - .../ui/screens/chat/chat_ui_controller.cpp | 43 +- .../chat_conversation_components_watch.cpp | 13 + .../contacts/contacts_page_components.cpp | 67 +- .../contacts/contacts_page_runtime.cpp | 7 +- .../extensions/extensions_page_runtime.cpp | 4 +- .../ui/screens/network/network_page_shell.cpp | 61 +- .../settings/settings_page_components.cpp | 19 +- .../tracker/tracker_page_components.cpp | 154 +++-- modules/ui_shared/src/ui/widgets/top_bar.cpp | 8 + .../ui/widgets/top_bar_power_presenter.cpp | 237 +++++++ .../tests/test_chat_presentation_source.cpp | 202 ++++++ .../infra/reticulum/reticulum_interfaces.h | 2 + .../chat/infra/store/sd_store.h | 6 +- .../src/app_event_runtime_support.cpp | 3 + platform/esp/arduino_common/src/app_tasks.cpp | 160 ++--- .../src/chat/infra/lxmf/lxmf_adapter.cpp | 168 +++-- .../infra/reticulum/reticulum_interfaces.cpp | 67 +- .../src/chat/infra/store/sd_store.cpp | 31 +- .../src/platform_ui_http_client_runtime.cpp | 2 +- ...latform_ui_reticulum_directory_runtime.cpp | 91 ++- .../src/platform_ui_route_storage.cpp | 31 +- .../platform_ui_settings_backup_runtime.cpp | 21 + .../src/platform_ui_wifi_access_runtime.cpp | 56 +- platform/esp/arduino_common/src/ui_common.cpp | 13 +- .../platform/esp/common/wifi_runtime_impl.h | 268 +++++++- .../src/platform_ui_wifi_runtime.cpp | 212 ++++++- platform/esp/idf_common/src/ui_common.cpp | 13 +- .../include/chat/linux_sqlite_chat_store.h | 5 + .../src/chat/linux_sqlite_chat_store.cpp | 75 ++- .../ui/reticulum_directory_runtime.cpp | 6 + .../common/src/platform/ui/wifi_runtime.cpp | 13 + .../chat/infra/store/internal_fs_store.h | 4 + .../chat/infra/store/internal_fs_store.cpp | 25 +- .../src/platform_ui_wifi_runtime.cpp | 7 + ...est_reticulum_supported_subset_vectors.cpp | 26 +- 65 files changed, 3081 insertions(+), 474 deletions(-) create mode 100644 docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md create mode 100644 modules/ui_shared/include/ui/widgets/top_bar_power_presenter.h create mode 100644 modules/ui_shared/src/ui/widgets/top_bar_power_presenter.cpp diff --git a/AGENTS.md b/AGENTS.md index 9f7f6de8..8a81502d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **trail-mate** (43886 symbols, 82034 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **trail-mate** (50845 symbols, 95119 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). diff --git a/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp b/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp index 7a503457..d31a0de4 100644 --- a/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp +++ b/apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp @@ -44,6 +44,7 @@ #include "team/usecase/team_track_sampler.h" #include "ui/chat_ui_runtime.h" #include "ui/screens/team/team_page_shell.h" +#include "ui/widgets/top_bar_power_presenter.h" #include #include @@ -1290,6 +1291,7 @@ class IdfAppFacadeRuntime final : public app::IAppFacade { runtime->update(); } + ::ui::widgets::top_bar_power::tick(); } void dispatchPendingEvents(std::size_t max_events = 32) override diff --git a/cmake/TrailMateLinuxSources.cmake b/cmake/TrailMateLinuxSources.cmake index 7204a208..b271bfb3 100644 --- a/cmake/TrailMateLinuxSources.cmake +++ b/cmake/TrailMateLinuxSources.cmake @@ -499,6 +499,7 @@ set(TRAIL_MATE_LINUX_UI_SHELL_SOURCES "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/text_candidate_picker.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/toast/toast_widget.cpp" "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/top_bar.cpp" + "${TRAIL_MATE_UI_SHARED_SRC_ROOT}/ui/widgets/top_bar_power_presenter.cpp" ) # --------------------------------------------------------------------------- diff --git a/docs/protocol_runtime_budget_policy.md b/docs/protocol_runtime_budget_policy.md index 12839e52..8246b33b 100644 --- a/docs/protocol_runtime_budget_policy.md +++ b/docs/protocol_runtime_budget_policy.md @@ -8,6 +8,13 @@ card, and NVS budgets before trying to ingest background network traffic. Reticulum runs as a low-frequency terminal ingress runtime. +Reticulum `All` carrier policy is a Wi-Fi-preferred auto policy, not a +desktop-style dual-interface router policy. When the Reticulum Wi-Fi gateway is +configured and ready, normal Reticulum TX, RX, and raw LoRa packet ingestion use +the Wi-Fi gateway only. LoRa is the fallback carrier only while the Wi-Fi +gateway is not ready. Explicit `LoRaOnly` and `WifiGatewayOnly` policies keep +their literal meanings. + When the screen is on, realtime processing is limited to: - LXMF direct traffic addressed to this device. @@ -24,6 +31,32 @@ announces may publish one coalesced contact-store update at a low awake-screen rate so Contacts and Chat can show the sender's display name without waiting for SD or NVS persistence. +## MeshChat-Compatible Discovery Projection + +Trail Mate's Reticulum UI projections follow the same product-facing discovery +split as Reticulum MeshChat: + +- Contacts and Chat consume `lxmf.delivery` announces as chat peers. The peer + display name is decoded from LXMF announce app data using the upstream shape: + raw UTF-8 display names and msgpack arrays whose first element is the display + name are both valid. Current upstream LXMF arrays may contain additional + fields such as stamp cost and supported functionality; supported parsers must + ignore trailing fields they do not need. +- Network consumes `nomadnetwork.node` announces as Nomad nodes. Their display + name is decoded from text app data and is separate from LXMF peer naming. +- `lxmf.propagation`, `call.audio`, and unknown announces may be stored for + routing, call, or diagnostic use, but they must not be promoted into Contacts + as ordinary chat peers unless a Trail Mate business flow explicitly needs + them. +- Destination and identity hashes remain address/search metadata. They must not + be used as the default display name. If a Reticulum peer has no display name, + the UI uses `Anonymous Peer`; if a Nomad node has no display name, the UI uses + `Anonymous Node`. +- The UI must not infer display names from hash shape, old local records, + destination prefixes, or other historical artefacts. If a stored record + contains an incorrect name, that is stored data to fix or replace, not a + runtime display-name fallback. + ## Required Boundaries - `pollIncomingText()` and `pollIncomingData()` may only return already @@ -53,6 +86,11 @@ for SD or NVS persistence. - RX hot-path logging must be summary-first. Detailed logs are acceptable for local/realtime traffic and diagnostics, but public discovery should normally be represented by periodic counters. +- Shared LoRa RX task logging must also be summary-first. Normal IRQ/RX_DONE + packets must not emit per-packet serial lines on ESP devices, because short + packet bursts can stall UI-visible work even before packets reach Reticulum. + If the mesh queue is full, the radio task must drop and count the packet + instead of blocking on queue send. - Reticulum runtime code executed by `mesh_task` must not allocate MTU-sized packet buffers or queued packet records as automatic locals. Carrier packet scratch storage belongs in adapter/interface members, and `mesh_task` needs diff --git a/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md b/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md new file mode 100644 index 00000000..8100416e --- /dev/null +++ b/docs/specification/SETTINGS_ARCHITECTURE_REFACTOR_SPEC.md @@ -0,0 +1,585 @@ +# Settings Architecture Refactor Specification + +Status date: 2026-07-09 + +本文档定义 Trail Mate settings 的下一阶段改造规格。它不是一次小修小补的 UI +整理,而是把“配置是什么、属于哪个协议、如何展示、如何持久化、如何从 SD 恢复、如何 +应用到运行时”重新拉直。 + +本规格先落地文档,不改变运行时代码。实际实现必须按本文分阶段推进,并在修改任何 +函数、类或方法前按仓库规则做 GitNexus impact analysis。 + +## User Goal + +目标用户场景是 Trail Mate 可以脱离手机工作: + +- 选择 Meshtastic、MeshCore 或 Reticulum 后,settings 只展示该协议真正相关的项目。 +- Meshtastic 的 MQTT 只服务 Meshtastic;MeshCore 的 MQTT 只服务 MeshCore;两者不互通。 +- MQTT 只支持轻负担模式:不实现 TLS,不实现额外 MQTT payload 加密层。 +- MQTT 启用是显式配置;Wi-Fi 关闭时 runtime 必须停 MQTT,Wi-Fi 开启时不得自动把 MQTT + 配置改成启用。 +- 如果某个协议配置了 MQTT 并且 runtime 正在使用 Wi-Fi MQTT transport,该协议对应的 + BLE phone dependency 应被压低或关闭,避免用户误以为还必须连手机。 +- 所有用户配置都必须可持久化,并能从 SD 备份恢复。 +- 备份恢复不能为了“人类可读”牺牲 ESP 内存安全;当前整包 JSON/cJSON 模式需要替换。 + +## Distinctions + +这些概念必须在代码和 UI 中分开,不能继续混在 `Chat` / `Network` 两个大筐里。 + +| Concept | Meaning | Must not be confused with | +| --- | --- | --- | +| Protocol | Meshtastic、MeshCore、Reticulum 的协议语义和节点身份体系 | transport、radio preset、UI 页面 | +| Radio profile | LoRa 频点、带宽、扩频因子、编码率、tx power、region/preset | channel name、PSK、broadcast/private | +| Channel / group | 协议内的群组、slot、topic 或 destination 配置 | 空口参数 | +| Transport | LoRa、BLE phone link、Wi-Fi MQTT、Reticulum TCP/UDP 等承载方式 | protocol 本身 | +| Conversation | UI 里的广播会话、私聊会话、联系人上下文 | Meshtastic channel slot 或 MeshCore channel slot | +| Device settings | 屏幕、语言、GPS、地图、Wi-Fi、owner name、隐私等跨协议设置 | 当前 active protocol 的 profile | +| Persistence | NVS/Preferences/IDF store/SD backup 的落盘事实 | UI widget state | +| Apply runtime | 把配置变更应用到 radio、MQTT、BLE、GPS、privacy policy | 保存配置 | + +### Channel, Radio, Broadcast, Private + +空口参数决定“谁能在 RF 层听见谁”:频率、带宽、扩频因子、编码率、tx power、region 或 +preset 必须兼容,两个设备才可能互相收发 LoRa frame。 + +Channel/group 决定“收到 frame 后属于哪个协议群组或密钥域”:Meshtastic 使用 channel +slot/name/key/hash,MeshCore 使用 channel slot/name/key/public-channel fallback,Reticulum +使用 destination、announce、interface 和 identity。它们不是同一种对象,不能做一个泛化的 +`channel` 然后让三个协议硬套。 + +广播和私聊是寻址语义:广播表示发给当前协议/channel/group 中的所有可见节点;私聊表示 +发给一个 node id、destination 或 peer,并可能涉及 ack、route、retry、session 状态。广播 +或私聊不改变空口参数,也不自动创建 channel。 + +MQTT 是 transport,不是第四种协议,也不是跨协议桥。Meshtastic MQTT 下来的 Meshtastic +packet 应走 Meshtastic 接收路径;MeshCore MQTT 下来的 MeshCore packet 应走 MeshCore 接收 +路径。二者没有互通要求。 + +## Current Code Inventory + +本节记录当前实现事实,作为改造前的基线。 + +| Area | Current owner | Observed shape | +| --- | --- | --- | +| Global config aggregate | `modules/core_sys/include/app/app_config.h` | `AppConfig` 同时承载 device、chat、GPS、map、privacy、Meshtastic、MeshCore、Reticulum、MQTT、legacy channel 字段 | +| Protocol config object | `modules/core_chat/include/chat/domain/chat_types.h` | `chat::MeshConfig` 被三个协议复用,里面同时有 radio、Meshtastic channel、MeshCore channel、MQTT、Reticulum group/interface 字段 | +| Shared LVGL settings | `modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp` | `kChatItems` / `kNetworkItems` 混合协议项,通过 `pref_key` 字符串和 `should_show_item` 做隐藏 | +| Shared settings state | `modules/ui_shared/include/ui/screens/settings/settings_state.h` | 一个大 UI state 同时缓存 chat、network、MT MQTT、MC MQTT、Reticulum、device 字段 | +| Mono settings | `modules/ui_mono/src/runtime.cpp` | 保留大量 MT/MC 设置处理代码,但当前 radio item list 只暴露少数入口,能力与 UI 展示不一致 | +| GTK settings | `apps/linux_uconsole_gtk/src/platform/gtk/gtk_uconsole_settings_logic.cpp` | 已有按协议 stack/page 切换的雏形,可作为“协议页”思路参考,但不应直接复制 GTK widget 逻辑 | +| Arduino ESP persistence | `platform/esp/arduino_common/src/app_config_store.cpp` | 使用 Preferences 按字段保存,大量 key 已存在,但字段覆盖靠手写 load/save 保持同步 | +| IDF persistence | `apps/esp32_lvgl/src/esp32_lvgl_idf_app_facade_runtime.cpp` | 把整个 `AppConfig` 包进 raw blob,按 `sizeof(AppConfig)` 判断版本,结构一变旧配置就会被拒绝 | +| SD settings backup | `platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp` | 当前 path 是 `/trailmate/settings-backup.json`,用 cJSON 构造/解析整棵树,并读入整文件 | +| Store API | `modules/core_sys/include/platform/ui/settings_store.h` | 同时提供 `get_blob(std::vector&)` 和 `get_blob_into(...)`;新 ESP 路径应优先使用 bounded buffer 版本 | +| Apply facade | `modules/core_sys/include/app/app_facades.h` | UI 可直接拿 `getConfig()` 修改,再调用 `saveConfig()`、`applyMeshConfig()`、`applyUserInfo()` 等 apply 方法 | + +## Problems + +### 1. Settings taxonomy is wrong + +`Chat` 与 `Network` 现在不是产品概念,而是历史容器。结果是: + +- Meshtastic MQTT、MeshCore MQTT、Reticulum Wi-Fi interface 都可能出现在同一类页面里。 +- `chat_psk` 这种名字无法表达它到底是 Meshtastic channel key 还是 MeshCore channel key。 +- 用户选择协议后仍会看到另一个协议的残留项,或者必须靠 string blacklist 隐藏。 +- 新增 channel management 时无法自然表达 “Meshtastic channel slot” 与 “MeshCore channel slot”。 + +### 2. Config ownership is too broad + +`AppConfig` 和 `MeshConfig` 目前是运行期大对象。它们可以作为过渡兼容层,但不能继续作为 +长期 settings schema。原因: + +- 字段归属不清,导致 UI、Preferences、SD backup、协议 apply 都各自记一份事实。 +- ESP stack hygiene 已把 `AppConfig`、`MeshConfig` 列为危险 automatic local 类型。 +- 新增 channel 列表或更多协议 profile 时,如果继续塞进这两个 struct,会持续放大内存风险。 + +### 3. Persistence is not schema-driven + +Arduino Preferences 当前按字段保存,比 raw blob 稳定,但每个字段需要手写 load/save、默认值、 +迁移逻辑。新增一个 settings 字段时,很容易漏掉 SD backup 或某个 UI。 + +IDF raw blob 以 `sizeof(AppConfig)` 为兼容条件,这对后续拆分结构非常脆弱。任何 `AppConfig` +布局变化都可能导致旧配置无法加载。 + +### 4. SD JSON backup is too heavy + +当前 JSON 方案的优点是可读,但在 ESP 上代价偏大: + +- restore 需要把整个文件读进内存。 +- cJSON parse 会构造整棵树。 +- backup print 会生成完整字符串。 +- 旧代码中存在 `std::string`、`std::vector`、whole-document parse/print 的组合。 + +这与“配置可完整落 SD,并能可靠恢复”的目标相冲突。恢复配置应该是低内存路径,而不是 +最容易把设备推到 heap/stack 边界的路径。 + +### 5. Apply semantics are scattered + +UI 直接改 `getConfig()`,再按字段手动调用不同 apply 方法。这样很难保证: + +- 修改协议时,BLE/MQTT/LoRa/runtime 状态都按同一套规则切换。 +- Wi-Fi 关闭只停 MQTT runtime,不悄悄改用户配置。 +- MQTT 成功上行时 UI 不再只等 LoRa 成功才算 sent。 +- 从 MQTT 收到的节点和 LoRa 收到的节点进入同一 contact/nearby/chat projection。 + +这些问题已经在 MQTT 调试中暴露过,settings refactor 需要把 runtime impact 作为字段元数据 +的一部分,而不是散落在回调里。 + +## Target Architecture + +### Layer Shape + +目标结构如下: + +```text +Settings UI + -> SettingsDescriptor tables + -> SettingsEditSession / field-level draft + -> SettingsTransaction + -> SettingsValidator + normalizer + -> SettingsPersistence + -> RuntimeApplyDispatcher + -> Protocol/runtime adapters +``` + +`AppConfig` 在第一阶段继续存在,但应降级为 compatibility backing store,不再作为 settings +schema 的唯一事实来源。 + +### Domain Buckets + +长期结构应把配置分成这些 owner: + +| Owner | Examples | +| --- | --- | +| `DeviceSettings` | owner long/short name、locale、screen、time、battery/display policy | +| `ConnectivitySettings` | Wi-Fi credentials、Wi-Fi enable policy、network limits | +| `GpsMapSettings` | GPS power/publish policy、map tile/cache/source、tracker defaults | +| `PrivacySettings` | ignored nodes、contact alert policy、location visibility | +| `MeshtasticProfile` | radio preset、region、hops、node info、channels、Meshtastic MQTT | +| `MeshCoreProfile` | radio profile、channel slot/name/key、public-channel fallback、MeshCore MQTT | +| `ReticulumProfile` | identity、LoRa interface、Wi-Fi interface、LXMF/announce groups | +| `ChatPresentationSettings` | active conversation defaults、notification/presentation preferences | +| `BackupRestoreSettings` | backup version、restore policy、sensitive export policy | + +这些 owner 可以先映射到现有 `AppConfig` 字段,但 schema 命名必须先按 owner 设计,避免未来 +继续把三种协议塞回 `Chat` / `Network`。 + +### Settings Descriptor + +每个可展示/持久化的 field 必须有一条静态 descriptor。descriptor 应该是小的 `constexpr` +表项,避免动态分配和重型 callback。 + +建议 descriptor 至少包含: + +| Metadata | Purpose | +| --- | --- | +| stable field id | 编译期 enum,不用任意字符串做业务判断 | +| owner/profile | device、connectivity、mt、mc、reticulum 等 | +| UI section | 决定显示在哪个协议页或设备页 | +| type | bool、u8、i32、enum、bounded string、hex blob、secret | +| bounds | 字符串最大长度、数值范围、blob 最大长度 | +| protocol mask | MT/MC/Reticulum/global 的可见性 | +| capability mask | 板子是否支持 Wi-Fi、BLE、GPS、LoRa、SD | +| runtime impact | none、save-only、apply-mesh、apply-user、apply-gps、restart-mqtt、restart-ble | +| storage key | NVS key、SD key、legacy key | +| default provider | 按协议/地区/板型给默认值 | +| migration rule | 从旧 key、旧 blob、旧 JSON 恢复时如何写入 | +| sensitive flag | PSK、MQTT password、Wi-Fi password 等 | + +UI 层只消费 descriptor 和当前 protocol/capability,不能再写 `if (pref_key == "...")` 作为主要 +可见性规则。 + +### Protocol-Specific UI + +Settings 顶层建议拆为: + +- Device +- Connectivity +- Protocol +- Channels +- MQTT +- GPS & Map +- Privacy +- Backup & Restore +- Diagnostics + +其中 `Protocol`、`Channels`、`MQTT` 的内容由 active protocol 决定。 + +Meshtastic 页面应展示: + +- Meshtastic radio preset/region/modem preset/hops/tx power。 +- Meshtastic channel slots。第一阶段可继续 primary/secondary,schema 必须预留 slot list。 +- Meshtastic MQTT:enabled、preset、host、port、username、password、root topic、uplink/downlink。 +- BLE phone link policy:当 Meshtastic MQTT runtime 可用时,BLE 可被关闭或降级。 + +MeshCore 页面应展示: + +- MeshCore radio profile/region/channel slot/tx power。 +- MeshCore channel name/key/public channel fallback。 +- MeshCore MQTT:enabled、preset、host、port、username、password、root topic、uplink/downlink。 +- MeshCore contact/nearby projection 必须与 LoRa receive 一样处理 MQTT receive 的节点。 + +Reticulum 页面应展示: + +- Reticulum identity/status。 +- LoRa interface 参数。 +- Wi-Fi interface 参数。 +- LXMF/announce groups。 +- 不展示 MQTT,因为当前目标不包含 Reticulum MQTT。 + +### MQTT Policy + +MQTT 是 protocol-scoped transport: + +```text +MeshtasticProfile.mqtt -> Meshtastic MQTT runtime only +MeshCoreProfile.mqtt -> MeshCore MQTT runtime only +``` + +运行态 eligibility: + +```text +configured = profile.mqtt.enabled && host not empty && port > 0 +eligible = configured && wifi_runtime.connected && protocol == active_protocol +``` + +约束: + +- `wifi_runtime.connected == false` 时必须 stop MQTT runtime。 +- Wi-Fi 变为 connected 时,只能让已经启用且配置完整的 MQTT runtime 变为 eligible;不得自动把 + `profile.mqtt.enabled` 从 false 改为 true。 +- Plain MQTT only:`tls=false` 是唯一支持形态;UI 不提供 TLS 开关,代码也不引入 TLS 客户端。 +- MQTT username/password 可支持,因为它不是 TLS;但必须按 secret 字段处理。 +- MQTT receive 必须进入与 LoRa receive 相同的协议 projection:chat message、delivery status、 + contacts/nearby、node info、position、notification。 +- MQTT uplink 成功不能被 LoRa TX 失败覆盖成 failed;delivery outcome 应区分 transport。 + +### Default Presets + +默认 MQTT preset 不能以个人 broker 作为默认值。默认表应是协议 owner 的一部分: + +| Protocol | Default preset intent | +| --- | --- | +| Meshtastic | mainstream Meshtastic public MQTT preset, plaintext transport, default root/topic/channel matching current community convention | +| MeshCore | mainstream MeshCore public/community preset if available; otherwise disabled with empty custom host until用户选择 preset | + +实现时不把某个个人域名硬编码成默认。个人 broker 可以存在于 custom preset 或用户配置里。 + +## SD Backup Format + +### Decision + +新格式默认不使用 JSON。 + +采用 line-oriented typed key-value 格式,目标是: + +- 可人工检查。 +- 可流式读取。 +- 每次只需要一个 bounded line buffer。 +- 不需要 cJSON tree。 +- 不需要把整个文件读入 `std::string`。 +- 不需要 `std::vector` 承接 whole blob。 + +建议文件名: + +```text +/trailmate/settings-backup.tms +/trailmate/settings-backup.tmp +``` + +`.json` 旧文件可以在迁移期作为 legacy restore input,但新备份写出必须使用 `.tms`。 + +### Format Sketch + +```text +TMSET2 +schema.version=u16:2 +created.unix=u32:1783500000 +device.owner.long=str:Trail Mate +device.owner.short=str:TM +protocol.active=enum:meshtastic + +mt.radio.region=enum:CN +mt.radio.modem_preset=enum:LONG_FAST +mt.channel.0.name=str:LongFast +mt.channel.0.psk=hex:01020304... +mt.mqtt.enabled=bool:1 +mt.mqtt.host=str:mqtt.meshtastic.org +mt.mqtt.port=u16:1883 +mt.mqtt.root=str:msh/CN +mt.mqtt.username=str:meshdev +mt.mqtt.password=secret:large4cats + +mc.channel.slot=u8:0 +mc.channel.name=str:public +mc.channel.key=hex: +mc.mqtt.enabled=bool:0 + +checksum.crc32=hex:89ABCDEF +``` + +Rules: + +- First line is magic: `TMSET2`. +- Max line length is fixed, initially 256 or 384 bytes. Any longer line is skipped with a diagnostic. +- Key is ASCII stable storage key. +- Type prefix is mandatory. +- Strings are bounded by descriptor metadata. +- Hex blob max length is bounded by descriptor metadata before decoding. +- Unknown keys are ignored but counted. +- Known key with invalid value is rejected and reported, not partially applied. +- Restore writes through `SettingsTransaction`; it must not directly mutate random globals. +- Backup write uses temp file + fsync/close + rename where backend supports it. +- CRC covers all lines before checksum. + +### Why Not Binary TLV First + +Binary TLV is smaller and faster, but it is harder to inspect and repair on SD. The line KV format is the +better first target because it keeps manual recovery possible without the cJSON memory cost. A future binary +TLV export can be added for factory/provisioning use, but it should not be the only user backup format. + +### Sensitive Fields + +为了满足“完全从 SD 恢复”,Wi-Fi password、MQTT password、channel PSK 应可进入备份。UI 必须 +把这些字段标记为 sensitive,并在手动导出/恢复界面给出明确提示。 + +实现上 sensitive 只影响 UI 呈现和日志脱敏,不意味着不落盘。用户选择 SD backup 时,目标是 +恢复一台离线设备的完整配置。 + +## Persistence Model + +每个字段必须通过同一份 descriptor 声明其持久化位置。 + +### Arduino Preferences + +现有 Preferences key 可以保留,但 schema 要成为覆盖清单: + +- load 时按 descriptor 读 key,应用 default/migration。 +- save 时按 descriptor 写 key。 +- 对 blob/secret 使用 bounded buffer。 +- 对旧 key 做一次 migration,不在 UI 回调里散写兼容逻辑。 + +### IDF Store + +raw `sizeof(AppConfig)` blob 只能作为 legacy input。新路径必须是版本化字段 store: + +- 读取旧 raw blob 时,迁移到 schema field store。 +- 新保存不再依赖 `sizeof(AppConfig)`。 +- 如果为了启动速度保留 compact snapshot,也必须有独立 schema version 和 field-level fallback。 + +### SD Backup + +SD backup 是 cross-store restore source,不是运行时唯一 store。启动时不应每次从 SD 覆盖 NVS。 +恢复应该是显式动作: + +```text +User chooses Restore + -> parse .tms stream + -> validate descriptors + -> build transaction + -> persist to primary store + -> apply affected runtimes + -> emit UI result +``` + +## Memory Budget Rules + +实际实现必须遵守: + +- 不在 ESP task stack 上创建 `AppConfig`、`chat::MeshConfig`、protobuf frame、大 byte array。 +- Settings UI edit session 不复制整份 `AppConfig`;只保存 field-level dirty value 或 active editor + buffer。 +- SD restore 不读完整文件,不构造树,不用 `cJSON_ParseWithLength` 作为新路径。 +- 不引入 `std::deque` 到 ESP BLE/Meshtastic bridge headers。 +- 新 schema table 使用 static/constexpr storage。 +- 新 channel list 使用固定上限和显式 drop/error policy,不能无界增长。 +- 大字符串格式化使用 caller-provided buffer 或小 scratch owner,不把临时大对象放在回调栈上。 + +## Packaged Delivery Plan + +This refactor is delivered as one cohesive feature package, not as user-visible partial phases. +The steps below are an internal construction sequence only. The final deliverable must include +schema, protocol-aware UI, persistence, SD backup/restore, runtime apply behavior, tests and +verification together. + +No intermediate state should be considered complete if it leaves settings half migrated, exposes +new protocol pages without matching persistence, or writes a new SD backup format without restore. + +### Slice 0: Specification and Audit + +Deliverables: + +- 本文档。 +- 当前 settings/persistence/apply 代码清单。 +- 确认 JSON 备份替换方向。 + +No runtime behavior change. + +### Slice 1: Descriptor Read Model + +Introduce descriptor tables and read accessors without changing existing UI behavior. + +Deliverables: + +- `SettingsFieldId` enum。 +- protocol/global owner metadata。 +- field descriptors for all currently visible settings。 +- tests that every field has default, storage key, owner, visibility, runtime impact。 + +Compatibility: + +- Existing `AppConfig` remains backing store。 +- Existing LVGL settings can still use old state while descriptors are validated in tests。 + +### Slice 2: Transaction and Apply Dispatcher + +Move settings mutation through a small transaction boundary. + +Deliverables: + +- field-level set/get APIs。 +- validator/normalizer。 +- runtime impact diff。 +- dispatcher that calls `applyMeshConfig()`、`applyUserInfo()`、`applyPositionConfig()`、 + MQTT restart/stop and BLE policy in one place。 + +Compatibility: + +- Existing UI callbacks can be converted incrementally field by field。 + +### Slice 3: Protocol-Aware UI Sections + +Replace `kChatItems` / `kNetworkItems` as primary organization. + +Deliverables: + +- Device/Connectivity/Protocol/Channels/MQTT/GPS & Map/Privacy/Backup sections。 +- Active protocol filter from descriptor metadata。 +- Board capability filter from descriptor metadata。 +- No business visibility based on `pref_key` string comparisons。 + +Acceptance: + +- Selecting Meshtastic shows Meshtastic channel/MQTT/radio settings only。 +- Selecting MeshCore shows MeshCore channel/MQTT/radio settings only。 +- Selecting Reticulum shows Reticulum interface/group settings and hides MQTT。 + +### Slice 4: Lightweight SD Backup + +Replace default JSON backup writer/reader with `.tms`. + +Deliverables: + +- streaming writer。 +- streaming parser。 +- fixed max line length。 +- CRC。 +- descriptor-backed export/restore coverage。 +- legacy `.json` restore either removed or isolated behind explicit compatibility path with strict size cap。 + +Acceptance: + +- Full settings backup/restore succeeds without whole-file allocation。 +- Unknown future keys are ignored safely。 +- Sensitive fields restore correctly and logs are redacted。 + +### Slice 5: Channel Management + +Introduce protocol-specific channel/group management. + +Deliverables: + +- Meshtastic channel slot model。 +- MeshCore channel slot model。 +- Reticulum group/destination model remains separate。 +- Create/join/share flow for supported protocols。 +- QR/import/export payload generation on demand, using bounded scratch storage。 + +Acceptance: + +- Creating a Meshtastic channel does not mutate MeshCore fields。 +- Creating a MeshCore channel does not mutate Meshtastic fields。 +- Broadcast/private conversation selection references protocol-specific channel identity explicitly。 + +### Slice 6: Retire Raw Struct Persistence + +After migrations are covered by tests and field store is proven: + +- Stop writing raw `AppConfig` blobs。 +- Keep one-way read migration for a bounded release window。 +- Remove legacy keys only after backup/restore and migration tests prove no supported user path is lost。 + +### Package Acceptance + +The package is not done until all of these are true: + +- Protocol selection changes visible settings, stored settings and runtime apply behavior together。 +- Meshtastic, MeshCore and Reticulum each have their own settings surface; hidden fields are hidden by + descriptor/capability metadata, not by ad hoc string checks。 +- Meshtastic MQTT and MeshCore MQTT can be configured independently and are persisted/restored。 +- Wi-Fi off stops MQTT runtime; Wi-Fi on does not auto-enable MQTT config。 +- SD backup writes `.tms`, restore reads `.tms`, and all user settings covered by descriptors round trip。 +- Legacy Preferences/IDF/raw config paths migrate into the new schema without losing existing user settings。 +- Settings UI does not create new large ESP stack objects or whole-config drafts。 +- Tests and stack hygiene checks pass for the touched areas。 + +## Verification Requirements + +Before implementation PR/commit: + +- Run GitNexus impact analysis before each edited symbol, and warn before HIGH/CRITICAL edits。 +- Run unit tests for descriptor coverage, migration, transaction diff and backup restore parser。 +- Run `python3 scripts/check_esp_stack_hygiene.py` when touching settings save/load, ESP BLE, + Meshtastic bridge, or app config code。 +- For PlatformIO build/upload/monitor, use background process + log polling as required by repo rules。 +- Run `detect_changes()` before commit to verify affected symbols and flows。 + +Suggested tests: + +| Test | Purpose | +| --- | --- | +| descriptor coverage snapshot | every field has owner, protocol visibility, storage key, default, impact | +| protocol visibility matrix | MT/MC/Reticulum show different settings | +| legacy Preferences migration | existing NVS keys map to schema fields | +| IDF raw blob migration | old raw config can migrate once | +| `.tms` round trip | export -> restore produces equivalent config | +| `.tms` malformed input | long line, bad type, bad hex, unknown key, bad CRC handled safely | +| MQTT policy matrix | Wi-Fi off stops runtime; Wi-Fi on does not enable config; protocol switch stops old runtime | +| contact projection parity | MQTT receive and LoRa receive update contacts/nearby through same app event path | + +## Explicit Non-Goals + +- 不做 Meshtastic 与 MeshCore MQTT 互通。 +- 不为 MQTT 增加 TLS。 +- 不把 Reticulum 伪装成 MQTT/channel 页面。 +- 不把一个 generic `Channel` 类型强塞给三种协议。 +- 不在新备份路径使用 whole-document JSON。 +- 不继续用 raw `sizeof(AppConfig)` 作为新持久化格式。 +- 不为了快速 UI 隐藏继续扩大 `pref_key` string blacklist。 +- 不把大量 channel 或 QR/share payload 常驻塞进 `AppConfig`。 + +## Open Decisions + +| Decision | Recommendation | +| --- | --- | +| `.tms` max line length | Start at 256 bytes; allow 384 only if current MQTT/password fields need it | +| Legacy JSON restore | Keep one release as explicit compatibility restore with strict size cap, then remove | +| MeshCore public MQTT preset | Verify upstream/community default before hardcoding; otherwise default disabled with preset picker | +| Meshtastic channel slot count | Implement current primary/secondary first, schema list-ready | +| IDF protocol support | Current IDF runtime appears Meshtastic-only; full protocol UI must either expose capability limits or implement MC/RT there first | +| Sensitive backup UX | Default to full restore capability, with explicit warning/redaction rather than silently omitting secrets | + +## Implementation Guardrail + +Even though this is a single packaged feature, implementation should still proceed in a safe internal +order. The first code change after this spec should not rewrite all settings UI at once. The safest opening +move is: + +1. Add schema field IDs and descriptor coverage tests. +2. Map descriptors to existing `AppConfig` read paths. +3. Add protocol visibility tests for MT/MC/Reticulum. +4. Only then start moving UI sections and persistence writers. + +This keeps the refactor observable during development while still packaging the final user-facing result as +one complete settings architecture change. diff --git a/docs/wifi_access_resource_policy.md b/docs/wifi_access_resource_policy.md index bcf22cc6..67ef2341 100644 --- a/docs/wifi_access_resource_policy.md +++ b/docs/wifi_access_resource_policy.md @@ -8,7 +8,8 @@ the same small device resources. ## Distinctions - `platform::ui::wifi` is the Wi-Fi control plane. It owns STA configuration, - enable/disable, scanning, saved credentials, and the platform connection API. + enable/disable, scanning, saved credentials, saved network profiles, and the + platform connection API. - `platform::ui::wifi_access` is the Wi-Fi business access policy. Any product feature that wants to use Wi-Fi for protocol traffic or downloads must ask it for permission, connection, and traffic budget. @@ -33,6 +34,44 @@ the same small device resources. | Reticulum gateway Wi-Fi connect | `platform::ui::wifi_access::ensure_connected` | | Reticulum gateway socket connect/pump/send | `wifi_access::acquire` and `wifi_access::traffic_budget` | +## Wi-Fi Connect Memory Gate + +Starting an ESP STA connection is a resource boundary, not a harmless retry. +When the configured SSID is absent, the ESP-IDF Wi-Fi stack may enter scan and +connect paths that create internal timers and allocate internal RAM. On Trail +Mate devices this must be guarded before calling `esp_wifi_connect()`, because +low internal heap can otherwise abort the process inside the vendor Wi-Fi task +instead of returning a normal failure. + +The Wi-Fi control plane must therefore check internal RAM and the largest +contiguous internal block before every `esp_wifi_connect()` call. If the gate +fails, it must log a deferred memory line and return a normal connection +failure to the caller; the caller's existing `wifi_access` backoff then governs +the next attempt. This rule applies equally to manual settings connect, MQTT, +Reticulum Wi-Fi gateway, HTTP pre-connect, and OTA pre-connect paths. + +## Saved Wi-Fi Profiles + +Trail Mate devices must remember up to ten Wi-Fi STA profiles. The legacy +`wifi_ssid` and `wifi_password` settings are retained only as the current +preferred-profile projection for existing UI and backup compatibility; they are +not the only source of Wi-Fi truth. + +Settings must not expose the saved profile list as a separate management UI. +The Settings page shows one current SSID/password editor plus scan/connect +actions. When the user selects a scanned SSID that is already saved, the control +plane may restore its saved password into that single editor. When the scanned +SSID is new, the editor clears the password and waits for the user to enter it. + +Saving Wi-Fi credentials must insert or update that SSID at the front of the +saved profile list and keep older unique SSIDs behind it. Background auto +connect must attempt one saved profile per granted connection attempt and then +advance to the next profile on failure. It must not synchronously try the whole +list in one call, because an absent SSID can consume a full connect timeout. + +Manual connect with an explicit `Config` still targets only that profile. A +successful or newly saved manual profile becomes the preferred profile. + ## Illegal Access Paths - No product feature may include or call `esp_http_client` directly outside the diff --git a/modules/chat_presentation_adapters/src/chat_message_mapper.cpp b/modules/chat_presentation_adapters/src/chat_message_mapper.cpp index e764e1b8..4d5e9cd2 100644 --- a/modules/chat_presentation_adapters/src/chat_message_mapper.cpp +++ b/modules/chat_presentation_adapters/src/chat_message_mapper.cpp @@ -34,6 +34,7 @@ ui::chat::MessageIngressTransport mapMessageIngressTransport(chat::RxOrigin orig case chat::RxOrigin::LoRa: return ui::chat::MessageIngressTransport::LoRa; case chat::RxOrigin::External: + return ui::chat::MessageIngressTransport::Mqtt; case chat::RxOrigin::WiFi: return ui::chat::MessageIngressTransport::WiFi; case chat::RxOrigin::Unknown: diff --git a/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp b/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp index a640070b..e4c2d4b1 100644 --- a/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp +++ b/modules/chat_presentation_adapters/tests/test_chat_message_mapper.cpp @@ -38,7 +38,7 @@ void rxOriginsMapToIngressTransport() assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::LoRa) == ui::chat::MessageIngressTransport::LoRa); assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::External) == - ui::chat::MessageIngressTransport::WiFi); + ui::chat::MessageIngressTransport::Mqtt); assert(chat_presentation_adapters::mapMessageIngressTransport(chat::RxOrigin::WiFi) == ui::chat::MessageIngressTransport::WiFi); } diff --git a/modules/core_chat/include/chat/infra/store/ram_store.h b/modules/core_chat/include/chat/infra/store/ram_store.h index 8fa48d92..bdd413cc 100644 --- a/modules/core_chat/include/chat/infra/store/ram_store.h +++ b/modules/core_chat/include/chat/infra/store/ram_store.h @@ -27,6 +27,10 @@ class RamStore : public IChatStore void append(const ChatMessage& msg) override; std::vector loadRecent(const ConversationId& conv, size_t n) override; + std::vector loadPageFromLatest(const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) override; std::vector loadConversationPage(size_t offset, size_t limit, size_t* total) override; diff --git a/modules/core_chat/include/chat/ports/i_chat_store.h b/modules/core_chat/include/chat/ports/i_chat_store.h index c9ded377..64f5e288 100644 --- a/modules/core_chat/include/chat/ports/i_chat_store.h +++ b/modules/core_chat/include/chat/ports/i_chat_store.h @@ -34,6 +34,45 @@ class IChatStore */ virtual std::vector loadRecent(const ConversationId& conv, size_t n) = 0; + /** + * @brief Load one message page counted backwards from the newest message + * @param conv Conversation ID + * @param offset_from_latest Number of newer messages to skip (0 means newest page) + * @param limit Max messages to return + * @param total Optional total message count for the conversation + * @return Vector of messages in chronological order (oldest first) + */ + virtual std::vector loadPageFromLatest(const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) + { + if (limit == 0) + { + if (total) + { + *total = 0; + } + return {}; + } + + const size_t window_limit = offset_from_latest + limit; + std::vector window = loadRecent(conv, window_limit); + if (total) + { + *total = window.size(); + } + if (offset_from_latest >= window.size()) + { + return {}; + } + + const size_t end = window.size() - offset_from_latest; + const size_t start = (end > limit) ? (end - limit) : 0; + return std::vector(window.begin() + static_cast(start), + window.begin() + static_cast(end)); + } + /** * @brief Load conversation list metadata * @param offset Start offset (pagination) diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index 5b2640d7..6f4d62ee 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -109,6 +109,10 @@ class ChatService * @brief Get recent messages for a conversation */ std::vector getRecentMessages(const ConversationId& conv, size_t limit) const; + std::vector getMessagePageFromLatest(const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) const; std::vector getConversations(size_t offset, size_t limit, size_t* total) const; int getTotalUnread() const; diff --git a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp index 71b5f5a3..b93c190c 100644 --- a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp +++ b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp @@ -594,7 +594,7 @@ bool packPeerAnnounceAppData(const char* display_name, return false; } } - else if (!appendString(name_bytes, name_len, out_data, *inout_len, used)) + else if (!appendBin(name_bytes, name_len, out_data, *inout_len, used)) { return false; } @@ -615,6 +615,24 @@ bool packPeerAnnounceAppData(const char* display_name, return true; } +void copyAnnounceDisplayName(const std::vector& name, + char* out_display_name, + size_t display_name_len) +{ + if (!out_display_name || display_name_len == 0) + { + return; + } + const size_t copy_len = std::min(name.size(), display_name_len - 1); + for (size_t i = 0; i < copy_len; ++i) + { + const uint8_t byte = name[i]; + out_display_name[i] = + (byte == '\t' || byte == '\r' || byte == '\n') ? ' ' : static_cast(byte); + } + out_display_name[copy_len] = '\0'; +} + bool unpackPeerAnnounceAppData(const uint8_t* data, size_t len, char* out_display_name, size_t display_name_len, bool* out_has_stamp_cost, @@ -640,7 +658,13 @@ bool unpackPeerAnnounceAppData(const uint8_t* data, size_t len, cursor.len = len; cursor.pos = 0; size_t count = 0; - if (!readArrayHeader(cursor, &count) || count != 2) + if (!readArrayHeader(cursor, &count)) + { + std::vector legacy_name(data, data + len); + copyAnnounceDisplayName(legacy_name, out_display_name, display_name_len); + return out_display_name[0] != '\0'; + } + if (count < 1) { return false; } @@ -664,9 +688,12 @@ bool unpackPeerAnnounceAppData(const uint8_t* data, size_t len, { return false; } - const size_t copy_len = std::min(name.size(), display_name_len - 1); - memcpy(out_display_name, name.data(), copy_len); - out_display_name[copy_len] = '\0'; + copyAnnounceDisplayName(name, out_display_name, display_name_len); + } + + if (count == 1) + { + return true; } if (!peekByte(cursor, &next)) @@ -675,21 +702,34 @@ bool unpackPeerAnnounceAppData(const uint8_t* data, size_t len, } if (next == 0xC0) { - return readNil(cursor); + if (!readNil(cursor)) + { + return false; + } + } + else + { + uint32_t stamp = 0; + if (!readUint(cursor, &stamp)) + { + return false; + } + if (out_has_stamp_cost) + { + *out_has_stamp_cost = true; + } + if (out_stamp_cost) + { + *out_stamp_cost = static_cast(stamp); + } } - uint32_t stamp = 0; - if (!readUint(cursor, &stamp)) + for (size_t index = 2; index < count; ++index) { - return false; - } - if (out_has_stamp_cost) - { - *out_has_stamp_cost = true; - } - if (out_stamp_cost) - { - *out_stamp_cost = static_cast(stamp); + if (!skipObject(cursor)) + { + return false; + } } return true; } diff --git a/modules/core_chat/src/infra/store/ram_store.cpp b/modules/core_chat/src/infra/store/ram_store.cpp index a0c1d004..ad766c71 100644 --- a/modules/core_chat/src/infra/store/ram_store.cpp +++ b/modules/core_chat/src/infra/store/ram_store.cpp @@ -41,14 +41,34 @@ void RamStore::append(const ChatMessage& msg) } std::vector RamStore::loadRecent(const ConversationId& conv, size_t n) +{ + return loadPageFromLatest(conv, 0, n, nullptr); +} + +std::vector RamStore::loadPageFromLatest(const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) { const ConversationStorage& storage = getConversationStorage(conv); std::vector result; size_t count = storage.messages.size(); - size_t start = (count > n) ? (count - n) : 0; + if (total) + { + *total = count; + } + if (limit == 0 || offset_from_latest >= count) + { + return result; + } - for (size_t i = start; i < count; i++) + const size_t available = count - offset_from_latest; + const size_t to_read = std::min(limit, available); + size_t start = count - offset_from_latest - to_read; + const size_t end = start + to_read; + + for (size_t i = start; i < end; i++) { result.push_back(storage.messages[i].message); } diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 4c7a5381..d683fa3f 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -113,6 +113,38 @@ void format_reticulum_hash_prefix(const ReticulumPeerIdentity* identity, static_cast(identity->destination_hash[3])); } +void format_log_text_preview(const std::string& text, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + const size_t max_copy = out_len - 1U; + size_t used = 0; + for (char value : text) + { + if (used >= max_copy) + { + break; + } + const unsigned char c = static_cast(value); + if (c == '\r' || c == '\n' || c == '\t') + { + out[used++] = ' '; + } + else if (c < 0x20U || c == 0x7FU) + { + out[used++] = '.'; + } + else + { + out[used++] = value; + } + } + out[used] = '\0'; +} + ChatService::ChatService(ChatModel& model, IMeshAdapter& adapter, IChatStore& store, @@ -228,15 +260,18 @@ MeshSendResult ChatService::sendTextResolvedDetailed( reticulum_destination && hasReticulumDestinationIdentity(*reticulum_destination); char dest_hash[12] = {}; + char text_preview[64] = {}; format_reticulum_hash_prefix(reticulum_destination, dest_hash, sizeof(dest_hash)); - CHAT_SERVICE_DIAG_LOG("[ChatService][TX] begin protocol=%s mode=%s ch=%u peer=%08lX forced=%lu dest=%s len=%u\n", + format_log_text_preview(text, text_preview, sizeof(text_preview)); + CHAT_SERVICE_DIAG_LOG("[ChatService][TX] begin protocol=%s mode=%s ch=%u peer=%08lX forced=%lu dest=%s len=%u text=\"%s\"\n", protocol_name(active_protocol_), has_reticulum_destination ? "reticulum_destination" : "peer", static_cast(channel), static_cast(normalize_conversation_peer(peer)), static_cast(forced_msg_id), dest_hash, - static_cast(text.size())); + static_cast(text.size()), + text_preview); MeshSendResult result = has_reticulum_destination ? adapter_.sendTextToReticulumDestination(channel, @@ -280,11 +315,12 @@ MeshSendResult ChatService::sendTextResolvedDetailed( } store_.append(msg); - CHAT_SERVICE_DIAG_LOG("[ChatService][TX] stored msg=%lu status=%u peer=%08lX dest=%s\n", + CHAT_SERVICE_DIAG_LOG("[ChatService][TX] stored msg=%lu status=%u peer=%08lX dest=%s text=\"%s\"\n", static_cast(msg.msg_id), static_cast(msg.status), static_cast(msg.peer), - dest_hash); + dest_hash, + text_preview); if (result.ok && result.msg_id != 0) { @@ -412,6 +448,15 @@ std::vector ChatService::getRecentMessages(const ConversationId& co return store_.loadRecent(conv, limit); } +std::vector ChatService::getMessagePageFromLatest( + const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) const +{ + return store_.loadPageFromLatest(conv, offset_from_latest, limit, total); +} + std::vector ChatService::getConversations(size_t offset, size_t limit, size_t* total) const diff --git a/modules/core_chat/tests/test_chat_service_resend.cpp b/modules/core_chat/tests/test_chat_service_resend.cpp index a43c56cb..a519169b 100644 --- a/modules/core_chat/tests/test_chat_service_resend.cpp +++ b/modules/core_chat/tests/test_chat_service_resend.cpp @@ -481,5 +481,51 @@ int main() identity); } + { + chat::RamStore paging_store; + const chat::ConversationId paging_conv(chat::ChannelId::PRIMARY, + 0, + chat::MeshProtocol::Meshtastic); + for (chat::MessageId id = 1; id <= 20; ++id) + { + chat::ChatMessage page_msg; + page_msg.channel = paging_conv.channel; + page_msg.peer = paging_conv.peer; + page_msg.protocol = paging_conv.protocol; + page_msg.msg_id = id; + page_msg.timestamp = id; + page_msg.text = "page"; + page_msg.status = chat::MessageStatus::Incoming; + paging_store.append(page_msg); + } + + size_t total = 0; + const auto newest = + paging_store.loadPageFromLatest(paging_conv, 0, 5, &total); + assert(total == 20); + assert(newest.size() == 5); + assert(newest.front().msg_id == 16); + assert(newest.back().msg_id == 20); + + const auto middle = + paging_store.loadPageFromLatest(paging_conv, 5, 5, &total); + assert(total == 20); + assert(middle.size() == 5); + assert(middle.front().msg_id == 11); + assert(middle.back().msg_id == 15); + + const auto oldest = + paging_store.loadPageFromLatest(paging_conv, 15, 5, &total); + assert(total == 20); + assert(oldest.size() == 5); + assert(oldest.front().msg_id == 1); + assert(oldest.back().msg_id == 5); + + const auto beyond = + paging_store.loadPageFromLatest(paging_conv, 20, 5, &total); + assert(total == 20); + assert(beyond.empty()); + } + return 0; } diff --git a/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h b/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h index 20c5d009..3ca77581 100644 --- a/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h +++ b/modules/core_sys/include/platform/ui/reticulum_directory_runtime.h @@ -16,6 +16,7 @@ enum class AnnounceAspect : uint8_t LxmfDelivery = 1, LxmfPropagation = 2, CallAudio = 3, + NomadNetworkNode = 4, }; enum class EntrySource : uint8_t diff --git a/modules/core_sys/include/platform/ui/wifi_runtime.h b/modules/core_sys/include/platform/ui/wifi_runtime.h index d06f219d..4cb8309c 100644 --- a/modules/core_sys/include/platform/ui/wifi_runtime.h +++ b/modules/core_sys/include/platform/ui/wifi_runtime.h @@ -53,6 +53,7 @@ struct ScanResult bool is_supported(); bool load_config(Config& out); bool save_config(const Config& config); +bool find_saved_config(const char* ssid, Config& out); bool apply_enabled(bool enabled); bool connect(const Config* override_config = nullptr); void disconnect(); diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h b/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h index 84407ba5..ae9c51ba 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_message_ref.h @@ -44,6 +44,7 @@ enum class MessageIngressTransport : uint8_t { Unknown, LoRa, + Mqtt, WiFi, }; diff --git a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h index a63ddf80..948960bf 100644 --- a/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h +++ b/modules/ui_presentation/include/ui_presentation/chat/chat_workspace_snapshot.h @@ -114,12 +114,17 @@ struct ChatWorkspaceSnapshot ui::SnapshotHeader header; static constexpr size_t kMaxLocationParticipants = 16; + static constexpr size_t kMaxMessages = 20; ConversationRow conversations[16]{}; size_t conversation_count = 0; - MessageRow messages[24]{}; + MessageRow messages[kMaxMessages]{}; size_t message_count = 0; + uint16_t message_offset = 0; + uint16_t message_total_count = 0; + bool has_older_messages = false; + bool has_newer_messages = false; ConversationLocationParticipant location_participants[kMaxLocationParticipants]{}; size_t location_participant_count = 0; @@ -193,11 +198,15 @@ inline void resetChatWorkspaceSnapshot(ChatWorkspaceSnapshot& out) } out.conversation_count = 0; - for (size_t i = 0; i < 24; ++i) + for (size_t i = 0; i < ChatWorkspaceSnapshot::kMaxMessages; ++i) { resetMessageRow(out.messages[i]); } out.message_count = 0; + out.message_offset = 0; + out.message_total_count = 0; + out.has_older_messages = false; + out.has_newer_messages = false; for (size_t i = 0; i < ChatWorkspaceSnapshot::kMaxLocationParticipants; ++i) { diff --git a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h index 937bab06..53349300 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_conversation_components.h @@ -14,7 +14,9 @@ #include "ui/widgets/map/map_viewport.h" #include "ui/widgets/top_bar.h" #include "ui_presentation/chat/chat_message_ref.h" +#include "ui_presentation/chat/chat_workspace_snapshot.h" #include "ui_presentation/map/map_overlay_snapshot.h" +#include #include #include #include @@ -34,7 +36,9 @@ class ChatConversationScreen public: enum class ActionIntent { - Reply + Reply, + LoadOlder, + LoadLatest }; enum class MessageActionIntent @@ -70,6 +74,10 @@ class ChatConversationScreen void setBackCallback(void (*cb)(void*), void* user_data); void setReplyEnabled(bool enabled); bool isReplyEnabled() const { return reply_enabled_; } + void setHistoryPaging(bool has_older, + bool has_newer, + uint16_t offset, + uint16_t total_count); void setLocationOverlay(const ::ui::map::MapOverlaySnapshot& overlay); void toggleLocationMap(); void cycleLocationMapLayer(); @@ -140,6 +148,10 @@ class ChatConversationScreen lv_obj_t* msg_list_ = nullptr; lv_obj_t* action_bar_ = nullptr; lv_obj_t* reply_btn_ = nullptr; + lv_obj_t* load_older_btn_ = nullptr; + lv_obj_t* load_older_label_ = nullptr; + lv_obj_t* load_latest_btn_ = nullptr; + lv_obj_t* load_latest_label_ = nullptr; lv_obj_t* compose_btn_ = nullptr; // kept for compatibility (not created in v0) lv_obj_t* location_panel_ = nullptr; lv_obj_t* location_map_host_ = nullptr; @@ -164,25 +176,41 @@ class ChatConversationScreen ::ui::chat::MessageDeliveryState::Unknown; lv_obj_t* container = nullptr; // row lv_obj_t* bubble = nullptr; + lv_obj_t* meta_row = nullptr; + lv_obj_t* sender_label = nullptr; + lv_obj_t* source_label = nullptr; lv_obj_t* text_label = nullptr; // inside bubble - lv_obj_t* time_label = nullptr; // reserved (not used) + lv_obj_t* time_label = nullptr; // inside meta row lv_obj_t* status_label = nullptr; // reserved (not used) std::unique_ptr retry_ctx; bool retry_enabled = false; }; std::vector messages_; - static constexpr size_t MAX_DISPLAY_MESSAGES = 100; + static constexpr size_t MAX_DISPLAY_MESSAGES = + ::ui::chat::ChatWorkspaceSnapshot::kMaxMessages; LifetimeGuard* guard_ = nullptr; std::vector timers_; conversation::input::Binding input_binding_{}; ActionContext reply_ctx_{}; + ActionContext load_older_ctx_{}; + ActionContext load_latest_ctx_{}; bool reply_enabled_ = true; + bool history_has_older_ = false; + bool history_has_newer_ = false; + uint16_t history_offset_ = 0; + uint16_t history_total_count_ = 0; + bool history_auto_load_pending_ = false; + bool history_scroll_position_valid_ = false; + lv_coord_t history_last_scroll_y_ = 0; bool location_map_visible_ = false; bool location_map_created_ = false; void createMessageItem(const ::ui::chat::MessageRow& row); + void createHistoryControls(); + void updateHistoryControls(); + void handleScroll(); void enableRetryAction(MessageItem& item); void disableRetryAction(MessageItem& item); void createLocationPanel(); @@ -193,6 +221,7 @@ class ChatConversationScreen static void action_event_cb(lv_event_t* e); static void message_action_event_cb(lv_event_t* e); + static void scroll_event_cb(lv_event_t* e); static void async_action_cb(void* user_data); static void async_message_action_cb(void* user_data); static void async_back_cb(void* user_data); diff --git a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h index bce3e587..b7a96469 100644 --- a/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h +++ b/modules/ui_shared/include/ui/screens/chat/chat_ui_controller.h @@ -178,6 +178,7 @@ class UiController : public IChatUiRefreshSink ::ui::chat::ChatWorkspaceSnapshot chat_snapshot_buffer_{}; ::ui::chat::ChatWorkspaceSnapshot team_chat_snapshot_buffer_{}; bool conversation_list_dirty_ = true; + bool conversation_list_loaded_ = false; static void key_verify_submit_event_cb(lv_event_t* e); static void key_verify_close_event_cb(lv_event_t* e); diff --git a/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h b/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h index 073cb71f..fc070a60 100644 --- a/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h +++ b/modules/ui_shared/include/ui/screens/chat_watch/chat_conversation_components_watch.h @@ -3,6 +3,8 @@ #include "chat/domain/chat_types.h" #include "lvgl.h" #include "ui_presentation/chat/chat_message_ref.h" +#include "ui_presentation/chat/chat_workspace_snapshot.h" +#include #include namespace ui::chat @@ -18,7 +20,9 @@ class ChatConversationScreen public: enum class ActionIntent { - Reply + Reply, + LoadOlder, + LoadLatest }; enum class MessageActionIntent @@ -54,6 +58,10 @@ class ChatConversationScreen void setBackCallback(void (*cb)(void*), void* user_data); void setReplyEnabled(bool enabled); bool isReplyEnabled() const { return reply_enabled_; } + void setHistoryPaging(bool has_older, + bool has_newer, + uint16_t offset, + uint16_t total_count); private: struct LifetimeGuard @@ -99,7 +107,8 @@ class ChatConversationScreen void* back_cb_user_data_ = nullptr; std::vector messages_; - static constexpr size_t MAX_DISPLAY_MESSAGES = 100; + static constexpr size_t MAX_DISPLAY_MESSAGES = + ::ui::chat::ChatWorkspaceSnapshot::kMaxMessages; LifetimeGuard* guard_ = nullptr; bool reply_enabled_ = true; diff --git a/modules/ui_shared/include/ui/widgets/top_bar.h b/modules/ui_shared/include/ui/widgets/top_bar.h index 920a92a5..b9329f8f 100644 --- a/modules/ui_shared/include/ui/widgets/top_bar.h +++ b/modules/ui_shared/include/ui/widgets/top_bar.h @@ -18,6 +18,7 @@ struct TopBarConfig lv_obj_t* back_btn_override = nullptr; // Use existing back button (keeps its style) lv_obj_t* title_label_override = nullptr; // Use existing title label if provided bool create_back = true; // Create new back button when no override + bool power_indicator = true; // Presenter owns right label as battery/status power slot lv_coord_t height = 0; // 0 = use current page profile height }; diff --git a/modules/ui_shared/include/ui/widgets/top_bar_power_presenter.h b/modules/ui_shared/include/ui/widgets/top_bar_power_presenter.h new file mode 100644 index 00000000..d2d7fcaa --- /dev/null +++ b/modules/ui_shared/include/ui/widgets/top_bar_power_presenter.h @@ -0,0 +1,24 @@ +/** + * @file top_bar_power_presenter.h + * @brief Unified presenter for TopBar right-side power status. + */ + +#pragma once + +#include "ui/widgets/top_bar.h" + +namespace ui +{ +namespace widgets +{ +namespace top_bar_power +{ + +void bind(TopBar& bar); +void unbind(TopBar& bar); +void tick(); +void refresh_now(); + +} // namespace top_bar_power +} // namespace widgets +} // namespace ui diff --git a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp index df29317d..61b8e894 100644 --- a/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/chat_presentation_source.cpp @@ -5,6 +5,7 @@ #include "chat_presentation_adapters/chat_message_mapper.h" #include "ui_presentation/common/fixed_text.h" +#include #include #include #include @@ -16,7 +17,7 @@ namespace { constexpr std::size_t kMaxConversationRows = 16; -constexpr std::size_t kMaxMessageRows = 24; +constexpr std::size_t kMaxMessageRows = ::ui::chat::ChatWorkspaceSnapshot::kMaxMessages; constexpr double kCoordinateScale = 10000000.0; template @@ -379,10 +380,6 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( const ui::chat::ChatWorkspaceRequest& request, ui::chat::ChatWorkspaceSnapshot& out) const { - // Message paging is deferred until ChatService exposes a stable - // presentation-safe paging API. - (void)request.message_offset; - ui::chat::resetChatWorkspaceSnapshot(out); out.header.valid = true; out.header.version = 1; @@ -418,8 +415,12 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( if (chat_presentation_adapters::toCoreConversationId(request.selected, core_selected)) { + std::size_t total_messages = 0; const auto messages = - chat_service_.getRecentMessages(core_selected, kMaxMessageRows); + chat_service_.getMessagePageFromLatest(core_selected, + request.message_offset, + kMaxMessageRows, + &total_messages); const ::chat::NodeId self_node = mesh_adapter_ != nullptr ? mesh_adapter_->getNodeId() : 0; if (self_node != 0) @@ -432,6 +433,12 @@ bool ChatPresentationSource::buildChatWorkspaceSnapshot( appendNodePositionParticipant( out, contact_service_, core_selected.peer, false); } + out.message_offset = request.message_offset; + out.message_total_count = static_cast( + std::min(total_messages, 0xFFFFU)); + out.has_newer_messages = request.message_offset > 0; + out.has_older_messages = + request.message_offset + messages.size() < total_messages; out.message_count = messages.size() < kMaxMessageRows ? messages.size() : kMaxMessageRows; diff --git a/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp b/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp index db02d45a..e8a9ad01 100644 --- a/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/team_chat_presentation_source.cpp @@ -18,7 +18,7 @@ namespace ui::presentation_sources namespace { -constexpr std::size_t kMaxMessageRows = 24; +constexpr std::size_t kMaxMessageRows = ::ui::chat::ChatWorkspaceSnapshot::kMaxMessages; constexpr double kCoordinateScale = 10000000.0; uint32_t foldTeamId(const ::team::TeamId& team_id) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp index 46816ee2..1bdfa9d8 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_compose_components.cpp @@ -238,7 +238,6 @@ void ChatComposeScreen::init_topbar() } ::ui::widgets::top_bar_set_title(impl_->w.top_bar, title_buf); - ui_update_top_bar_battery(impl_->w.top_bar); ::ui::widgets::top_bar_set_back_callback(impl_->w.top_bar, on_back, this); } @@ -247,7 +246,6 @@ void ChatComposeScreen::setHeaderText(const char* title, const char* status) if (!impl_) return; if (title) ::ui::widgets::top_bar_set_title(impl_->w.top_bar, title); if (status) ::ui::widgets::top_bar_set_right_text(impl_->w.top_bar, status); - ui_update_top_bar_battery(impl_->w.top_bar); } void ChatComposeScreen::setActionLabels(const char* send_label, const char* cancel_label) diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index d3e57d23..297fd402 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -25,7 +25,6 @@ #include #include #include -#include #ifndef CHAT_CONVERSATION_LOG_ENABLE #define CHAT_CONVERSATION_LOG_ENABLE 0 @@ -57,6 +56,7 @@ constexpr lv_coord_t kLocationMapInnerSize = 188; constexpr lv_coord_t kLocationMapOuterSize = kLocationMapInnerSize + (kLocationMapBorderPx * 2); constexpr lv_coord_t kLocationMapFitPadding = 14; +constexpr lv_coord_t kMetaChipMinWidth = 18; lv_coord_t bubble_pad_x() { @@ -77,6 +77,79 @@ void make_plain(lv_obj_t* obj) lv_obj_set_style_bg_opa(obj, LV_OPA_TRANSP, 0); } +lv_obj_t* create_meta_row(lv_obj_t* parent, lv_coord_t max_width, bool is_self) +{ + lv_obj_t* row = lv_obj_create(parent); + make_plain(row); + lv_obj_set_size(row, + std::max(max_width, kMetaChipMinWidth), + LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_flex_align(row, + is_self ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START, + LV_FLEX_ALIGN_CENTER, + is_self ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_column(row, + ::ui::page_profile::is_dense() ? 3 : 4, + 0); + lv_obj_set_style_pad_row(row, + ::ui::page_profile::is_dense() ? 2 : 3, + 0); + return row; +} + +lv_obj_t* create_meta_chip(lv_obj_t* parent, + const char* text, + lv_color_t bg_color, + lv_coord_t max_width) +{ + if (!parent || !text || text[0] == '\0') + { + return nullptr; + } + + const bool dense = ::ui::page_profile::is_dense(); + const lv_coord_t pad_x = dense ? 6 : 8; + const lv_coord_t pad_y = dense ? 2 : 3; + const lv_coord_t safe_max_width = + std::max(max_width, kMetaChipMinWidth); + + lv_obj_t* chip = lv_obj_create(parent); + lv_obj_set_size(chip, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_max_width(chip, safe_max_width, LV_PART_MAIN); + lv_obj_set_style_bg_color(chip, bg_color, LV_PART_MAIN); + lv_obj_set_style_bg_opa(chip, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_border_width(chip, 0, LV_PART_MAIN); + lv_obj_set_style_radius(chip, dense ? 7 : 10, LV_PART_MAIN); + lv_obj_set_style_shadow_width(chip, 0, LV_PART_MAIN); + lv_obj_set_style_pad_left(chip, pad_x, LV_PART_MAIN); + lv_obj_set_style_pad_right(chip, pad_x, LV_PART_MAIN); + lv_obj_set_style_pad_top(chip, pad_y, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(chip, pad_y, LV_PART_MAIN); + lv_obj_set_style_min_height(chip, dense ? 16 : 20, LV_PART_MAIN); + lv_obj_clear_flag(chip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_clear_flag(chip, LV_OBJ_FLAG_CLICKABLE); + + lv_obj_t* label = lv_label_create(chip); + lv_obj_set_width(label, LV_SIZE_CONTENT); + lv_obj_set_style_max_width( + label, + std::max(safe_max_width - (2 * pad_x), kMetaChipMinWidth), + LV_PART_MAIN); + lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(label, lv_color_hex(0x3A2A1A), LV_PART_MAIN); + lv_obj_set_style_bg_opa(label, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_pad_all(label, 0, LV_PART_MAIN); + lv_label_set_long_mode(label, LV_LABEL_LONG_CLIP); + ::ui::i18n::set_label_text_raw(label, text); + ::ui::fonts::apply_localized_font( + label, + lv_label_get_text(label), + ::ui::page_profile::resolve_caption_font()); + lv_obj_center(label); + return label; +} + void set_hidden(lv_obj_t* obj, bool hidden) { if (!obj || !lv_obj_is_valid(obj)) @@ -270,6 +343,8 @@ const char* message_ingress_label(::ui::chat::MessageIngressTransport transport) { case ::ui::chat::MessageIngressTransport::LoRa: return "LoRa"; + case ::ui::chat::MessageIngressTransport::Mqtt: + return "MQTT"; case ::ui::chat::MessageIngressTransport::WiFi: return "Wi-Fi"; case ::ui::chat::MessageIngressTransport::Unknown: @@ -294,60 +369,61 @@ static void format_message_time(char* out, size_t out_len, uint32_t ts) } uint32_t now_epoch = sys::epoch_seconds_now(); - bool ts_is_epoch = is_valid_epoch_ts(ts); + const bool ts_is_epoch = is_valid_epoch_ts(ts); bool now_is_epoch = is_valid_epoch_ts(now_epoch); - uint32_t now_secs = now_is_epoch ? now_epoch : static_cast(sys::millis_now() / 1000U); - if (ts_is_epoch && !now_is_epoch) - { - ts_is_epoch = false; - } + uint32_t now_secs = + ts_is_epoch + ? (now_is_epoch ? now_epoch : ts) + : static_cast(sys::millis_now() / 1000U); if (now_secs < ts) { now_secs = ts; } uint32_t diff = now_secs - ts; - if (!ts_is_epoch) + if (diff < 60U) { - if (diff < 60U) - { - snprintf(out, out_len, "%s", ::ui::i18n::tr("now")); - return; - } - if (diff < 3600U) - { - snprintf(out, out_len, "%s", ::ui::i18n::format("%um", static_cast(diff / 60U)).c_str()); - return; - } - if (diff < kSecondsPerDay) - { - snprintf(out, out_len, "%s", ::ui::i18n::format("%uh", static_cast(diff / 3600U)).c_str()); - return; - } - if (diff < kSecondsPerMonth) - { - snprintf(out, out_len, "%s", ::ui::i18n::format("%ud", static_cast(diff / kSecondsPerDay)).c_str()); - return; - } - if (diff < kSecondsPerYear) - { - snprintf(out, out_len, "%s", ::ui::i18n::format("%umo", static_cast(diff / kSecondsPerMonth)).c_str()); - return; - } - snprintf(out, out_len, "%s", ::ui::i18n::format("%uy", static_cast(diff / kSecondsPerYear)).c_str()); + snprintf(out, out_len, "%s", ::ui::i18n::tr("now")); return; } - - time_t t = ui_apply_timezone_offset(static_cast(ts)); - struct tm* info = gmtime(&t); - if (info) + if (diff < 3600U) { - strftime(out, out_len, "%H:%M", info); + snprintf(out, + out_len, + "%u min ago", + static_cast(diff / 60U)); + return; } - else + if (diff < kSecondsPerDay) { - snprintf(out, out_len, "--"); + snprintf(out, + out_len, + "%u hr ago", + static_cast(diff / 3600U)); + return; } + if (diff < kSecondsPerMonth) + { + const unsigned days = static_cast(diff / kSecondsPerDay); + snprintf(out, + out_len, + "%u day%s ago", + days, + days == 1U ? "" : "s"); + return; + } + if (diff < kSecondsPerYear) + { + snprintf(out, + out_len, + "%u mo ago", + static_cast(diff / kSecondsPerMonth)); + return; + } + snprintf(out, + out_len, + "%u yr ago", + static_cast(diff / kSecondsPerYear)); } static bool sender_token_is_valid(const std::string& sender) @@ -438,6 +514,11 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat chat::ui::conversation::styles::apply_msg_list(msg_list_); chat::ui::conversation::styles::apply_action_bar(action_bar_); chat::ui::conversation::styles::apply_reply_btn(reply_btn_); + if (msg_list_) + { + lv_obj_add_event_cb(msg_list_, scroll_event_cb, LV_EVENT_SCROLL, this); + } + createHistoryControls(); // Primary compose entry label. ::ui::i18n::set_label_text(w.reply_label, "Send"); @@ -448,7 +529,6 @@ ChatConversationScreen::ChatConversationScreen(lv_obj_t* parent, chat::Conversat ::ui::widgets::top_bar_init(top_bar_, container_); const char* title = (conv_.peer == 0) ? ::ui::i18n::tr("Broadcast") : ::ui::i18n::tr("Direct"); ::ui::widgets::top_bar_set_title(top_bar_, title); - ::ui::widgets::top_bar_set_right_text(top_bar_, ""); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); if (top_bar_.container) { @@ -497,6 +577,73 @@ ChatConversationScreen::~ChatConversationScreen() } } +void ChatConversationScreen::createHistoryControls() +{ + if (!msg_list_) + { + return; + } + + load_older_ctx_.screen = this; + load_older_ctx_.intent = ActionIntent::LoadOlder; + load_older_btn_ = lv_btn_create(msg_list_); + lv_obj_set_size(load_older_btn_, + LV_PCT(100), + ::ui::page_profile::resolve_control_button_height()); + lv_obj_clear_flag(load_older_btn_, LV_OBJ_FLAG_SCROLLABLE); + chat::ui::conversation::styles::apply_reply_btn(load_older_btn_); + load_older_label_ = lv_label_create(load_older_btn_); + ::ui::i18n::set_label_text(load_older_label_, "Load older"); + chat::ui::conversation::styles::apply_reply_label(load_older_label_); + ::ui::fonts::apply_localized_font( + load_older_label_, lv_label_get_text(load_older_label_), ::ui::fonts::ui_chrome_font()); + lv_obj_center(load_older_label_); + lv_obj_add_event_cb(load_older_btn_, + action_event_cb, + LV_EVENT_CLICKED, + &load_older_ctx_); + + load_latest_ctx_.screen = this; + load_latest_ctx_.intent = ActionIntent::LoadLatest; + load_latest_btn_ = lv_btn_create(msg_list_); + lv_obj_set_size(load_latest_btn_, + LV_PCT(100), + ::ui::page_profile::resolve_control_button_height()); + lv_obj_clear_flag(load_latest_btn_, LV_OBJ_FLAG_SCROLLABLE); + chat::ui::conversation::styles::apply_reply_btn(load_latest_btn_); + load_latest_label_ = lv_label_create(load_latest_btn_); + ::ui::i18n::set_label_text(load_latest_label_, "Latest"); + chat::ui::conversation::styles::apply_reply_label(load_latest_label_); + ::ui::fonts::apply_localized_font( + load_latest_label_, lv_label_get_text(load_latest_label_), ::ui::fonts::ui_chrome_font()); + lv_obj_center(load_latest_label_); + lv_obj_add_event_cb(load_latest_btn_, + action_event_cb, + LV_EVENT_CLICKED, + &load_latest_ctx_); + + updateHistoryControls(); +} + +void ChatConversationScreen::updateHistoryControls() +{ + if (!guard_ || !guard_->alive || !msg_list_ || !lv_obj_is_valid(msg_list_)) + { + return; + } + + if (load_older_btn_ && lv_obj_is_valid(load_older_btn_)) + { + set_hidden(load_older_btn_, true); + lv_obj_move_to_index(load_older_btn_, 0); + } + if (load_latest_btn_ && lv_obj_is_valid(load_latest_btn_)) + { + set_hidden(load_latest_btn_, !history_has_newer_); + lv_obj_move_to_index(load_latest_btn_, lv_obj_get_child_cnt(msg_list_) - 1); + } +} + void ChatConversationScreen::addMessage(const ::ui::chat::MessageRow& row) { if (!guard_ || !guard_->alive || !msg_list_ || !lv_obj_is_valid(msg_list_)) @@ -538,6 +685,8 @@ void ChatConversationScreen::scrollToBottom() if (guard_ && guard_->alive && msg_list_) { lv_obj_scroll_to_y(msg_list_, LV_COORD_MAX, LV_ANIM_OFF); + history_last_scroll_y_ = lv_obj_get_scroll_y(msg_list_); + history_scroll_position_valid_ = true; } } @@ -626,7 +775,6 @@ void ChatConversationScreen::updateBatteryFromBoard() { return; } - ui_update_top_bar_battery(top_bar_); } void ChatConversationScreen::setBackCallback(void (*cb)(void*), void* user_data) @@ -656,6 +804,52 @@ void ChatConversationScreen::setReplyEnabled(bool enabled) } } +void ChatConversationScreen::setHistoryPaging(bool has_older, + bool has_newer, + uint16_t offset, + uint16_t total_count) +{ + history_has_older_ = has_older; + history_has_newer_ = has_newer; + history_offset_ = offset; + history_total_count_ = total_count; + history_auto_load_pending_ = false; + history_scroll_position_valid_ = false; + history_last_scroll_y_ = 0; + updateHistoryControls(); +} + +void ChatConversationScreen::handleScroll() +{ + if (!guard_ || !guard_->alive || !msg_list_ || !lv_obj_is_valid(msg_list_)) + { + return; + } + + const lv_coord_t scroll_y = lv_obj_get_scroll_y(msg_list_); + if (!history_scroll_position_valid_) + { + history_scroll_position_valid_ = true; + history_last_scroll_y_ = scroll_y; + return; + } + + const bool scrolling_toward_older = scroll_y < history_last_scroll_y_; + history_last_scroll_y_ = scroll_y; + if (!scrolling_toward_older || scroll_y > 0) + { + return; + } + + if (!history_has_older_ || history_auto_load_pending_ || !action_cb_) + { + return; + } + + history_auto_load_pending_ = true; + schedule_action_async(ActionIntent::LoadOlder); +} + void ChatConversationScreen::setLocationOverlay( const ::ui::map::MapOverlaySnapshot& overlay) { @@ -940,75 +1134,65 @@ void ChatConversationScreen::createMessageItem(const ::ui::chat::MessageRow& row chat::ui::conversation::styles::apply_bubble(bubble, is_self); chat::ui::layout::set_bubble_max_width(bubble, max_bubble_w); - item.time_label = chat::ui::layout::create_bubble_time(bubble); - chat::ui::conversation::styles::apply_bubble_time(item.time_label); - char time_buf[16]; + char time_buf[24]; format_message_time( time_buf, sizeof(time_buf), timestamp_from_presentation_label(row.time_label)); const char* ingress_label = !is_self ? message_ingress_label(row.ingress_transport) : nullptr; - if (conv_.peer == 0) + + std::string sender; + if (is_self) { - std::string sender; - if (is_self) + sender = app::configFacade().getConfig().short_name; + if (sender.empty() && !row.sender_label.empty()) { - sender = app::configFacade().getConfig().short_name; - if (sender.empty() && !row.sender_label.empty()) - { - sender = row.sender_label.c_str(); - } - if (sender.empty()) - { - sender = "Me"; - } + sender = row.sender_label.c_str(); } - else if (!row.sender_label.empty()) + if (sender.empty()) { - sender = inferred_sender.empty() ? row.sender_label.c_str() : inferred_sender; + sender = "Me"; } - else if (row.sender_node_id == 0) - { - sender = inferred_sender.empty() ? ::ui::i18n::tr("Unknown") : inferred_sender; - } - else - { - sender = app::messagingFacade().getContactService().getContactName( - row.sender_node_id); - if (sender.empty()) - { - char buf[16]; - snprintf(buf, sizeof(buf), "%04lX", - static_cast(row.sender_node_id & 0xFFFF)); - sender = buf; - } - } - std::string line = sender; - if (ingress_label && ingress_label[0] != '\0') - { - line += " / "; - line += ingress_label; - } - line += " / "; - line += time_buf; - ::ui::i18n::set_content_label_text_raw(item.time_label, line.c_str()); + } + else if (!row.sender_label.empty()) + { + sender = inferred_sender.empty() ? row.sender_label.c_str() : inferred_sender; + } + else if (row.sender_node_id == 0) + { + sender = inferred_sender.empty() ? ::ui::i18n::tr("Unknown") : inferred_sender; } else { - if (ingress_label && ingress_label[0] != '\0') + sender = app::messagingFacade().getContactService().getContactName( + row.sender_node_id); + if (sender.empty()) { - char line[32] = {}; - std::snprintf(line, sizeof(line), "%s / %s", ingress_label, time_buf); - ::ui::i18n::set_label_text_raw(item.time_label, line); + char buf[16]; + snprintf(buf, + sizeof(buf), + "%04lX", + static_cast(row.sender_node_id & 0xFFFF)); + sender = buf; } - else - { - ::ui::i18n::set_label_text_raw(item.time_label, time_buf); - } - ::ui::fonts::apply_localized_font( - item.time_label, lv_label_get_text(item.time_label), ::ui::fonts::ui_chrome_font()); } + const lv_coord_t max_meta_w = + std::max(max_bubble_w - 2 * bubble_pad_x(), 24); + item.meta_row = create_meta_row(bubble, max_meta_w, is_self); + item.sender_label = + create_meta_chip(item.meta_row, sender.c_str(), lv_color_hex(0xF1B75A), max_meta_w); + if (ingress_label && ingress_label[0] != '\0') + { + item.source_label = create_meta_chip( + item.meta_row, + ingress_label, + lv_color_hex(0xCFE4FF), + max_meta_w); + } + item.time_label = + create_meta_chip(item.meta_row, time_buf, lv_color_hex(0xD4F0D2), max_meta_w); + item.text_label = chat::ui::layout::create_bubble_text(bubble); chat::ui::conversation::styles::apply_bubble_text(item.text_label); if (row.has_team_rich_payload && @@ -1129,7 +1313,7 @@ void ChatConversationScreen::action_event_cb(lv_event_t* e) { return; } - if (!screen->reply_enabled_) + if (ctx->intent == ActionIntent::Reply && !screen->reply_enabled_) { return; } @@ -1151,6 +1335,16 @@ void ChatConversationScreen::message_action_event_cb(lv_event_t* e) screen->schedule_message_action_async(ctx->intent, ctx->ref); } +void ChatConversationScreen::scroll_event_cb(lv_event_t* e) +{ + auto* screen = static_cast(lv_event_get_user_data(e)); + if (!screen) + { + return; + } + screen->handleScroll(); +} + void ChatConversationScreen::async_action_cb(void* user_data) { auto* payload = static_cast(user_data); @@ -1315,6 +1509,11 @@ void ChatConversationScreen::handle_root_deleted() back_cb_ = nullptr; back_cb_user_data_ = nullptr; reply_ctx_.screen = nullptr; + load_older_ctx_.screen = nullptr; + load_latest_ctx_.screen = nullptr; + history_auto_load_pending_ = false; + history_scroll_position_valid_ = false; + history_last_scroll_y_ = 0; chat::ui::conversation::input::cleanup(&input_binding_); ::ui::widgets::map::destroy(location_map_runtime_); @@ -1332,6 +1531,10 @@ void ChatConversationScreen::handle_root_deleted() msg_list_ = nullptr; action_bar_ = nullptr; reply_btn_ = nullptr; + load_older_btn_ = nullptr; + load_older_label_ = nullptr; + load_latest_btn_ = nullptr; + load_latest_label_ = nullptr; compose_btn_ = nullptr; location_panel_ = nullptr; location_map_host_ = nullptr; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp index cb376d0b..5fd209ca 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_styles.cpp @@ -32,8 +32,8 @@ static constexpr lv_coord_t kBubblePadX = 10; static constexpr lv_coord_t kBubblePadY = 6; static constexpr lv_coord_t kBubbleRadius = 12; -static const lv_color_t kBubbleOther = lv_color_hex(0xFFF7E9); -static const lv_color_t kBubbleSelf = lv_color_hex(0xFFF0D3); +static const lv_color_t kBubbleOther = lv_color_hex(0xFFF8E8); +static const lv_color_t kBubbleSelf = lv_color_hex(0xDDF3EA); static const lv_color_t kTextColor = lv_color_hex(0x3A2A1A); void init_once() @@ -105,7 +105,8 @@ void init_once() lv_style_init(&s_bubble_base); lv_style_set_bg_opa(&s_bubble_base, LV_OPA_COVER); - lv_style_set_border_width(&s_bubble_base, 0); + lv_style_set_border_width(&s_bubble_base, 1); + lv_style_set_border_color(&s_bubble_base, lv_color_hex(0xD7B979)); lv_style_set_radius(&s_bubble_base, bubble_radius); lv_style_set_pad_left(&s_bubble_base, bubble_pad_x); lv_style_set_pad_right(&s_bubble_base, bubble_pad_x); @@ -117,9 +118,11 @@ void init_once() lv_style_init(&s_bubble_self); lv_style_set_bg_color(&s_bubble_self, kBubbleSelf); + lv_style_set_border_color(&s_bubble_self, lv_color_hex(0x8FCDB9)); lv_style_init(&s_bubble_other); lv_style_set_bg_color(&s_bubble_other, kBubbleOther); + lv_style_set_border_color(&s_bubble_other, lv_color_hex(0xE2C487)); lv_style_init(&s_bubble_text); lv_style_set_text_color(&s_bubble_text, kTextColor); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp index 1fa00a00..73081983 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_message_list_components.cpp @@ -484,7 +484,6 @@ ChatMessageListScreen::ChatMessageListScreen(lv_obj_t* parent) // ---------- Top bar (existing widget, unchanged) ---------- ::ui::widgets::top_bar_init(top_bar_, container_); ::ui::widgets::top_bar_set_title(top_bar_, ::ui::i18n::tr("MESSAGES")); - ::ui::widgets::top_bar_set_right_text(top_bar_, "--:-- --%"); ::ui::widgets::top_bar_set_back_callback(top_bar_, handle_back, this); if (top_bar_.container) { @@ -788,7 +787,6 @@ void ChatMessageListScreen::updateBatteryFromBoard() return; } ::ui::components::air_status_footer::refresh(air_status_footer_); - ui_update_top_bar_battery(top_bar_); } bool ChatMessageListScreen::searchActive() const diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 726181d0..c0472779 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -159,12 +159,7 @@ std::string base_conversation_name(const chat::ConversationId& conv) if (has_reticulum_destination(conv)) { - const std::string destination_label = - reticulum_destination_label(conv.reticulum_identity); - if (!destination_label.empty()) - { - return destination_label; - } + return "Anonymous Peer"; } if (conv.peer == 0) @@ -475,6 +470,10 @@ void applySnapshotMessagesToConversation( { conversation.addMessage(snapshot.messages[i]); } + conversation.setHistoryPaging(snapshot.has_older_messages, + snapshot.has_newer_messages, + snapshot.message_offset, + snapshot.message_total_count); conversation.setLocationOverlay(buildConversationLocationOverlay(snapshot)); conversation.scrollToBottom(); } @@ -1379,7 +1378,7 @@ void UiController::refreshUnreadCounts(const bool force_reload) return; } - if (force_reload || conversation_list_dirty_ || cached_conversations_.empty()) + if (force_reload || conversation_list_dirty_ || !conversation_list_loaded_) { syncConversationListFromStore(); } @@ -1404,6 +1403,7 @@ void UiController::syncConversationListFromStore() } conversation_list_dirty_ = false; + conversation_list_loaded_ = true; } void UiController::normalizeConversationNames(std::vector& convs) const @@ -1849,6 +1849,35 @@ void UiController::onTeamPositionIconSelected(uint8_t icon_id) void UiController::handleConversationAction(ChatConversationScreen::ActionIntent intent) { + if (intent == ChatConversationScreen::ActionIntent::LoadOlder) + { + if (team_conv_active_) + { + return; + } + if (!loadChatSnapshot() || !chat_snapshot_buffer_.has_older_messages) + { + return; + } + const uint16_t next_offset = static_cast( + chat_snapshot_buffer_.message_offset + + ::ui::chat::ChatWorkspaceSnapshot::kMaxMessages); + chat_model_.setMessageOffset(next_offset); + reloadConversationView(); + return; + } + + if (intent == ChatConversationScreen::ActionIntent::LoadLatest) + { + if (team_conv_active_) + { + return; + } + chat_model_.setMessageOffset(0); + reloadConversationView(); + return; + } + if (intent == ChatConversationScreen::ActionIntent::Reply) { if (!team_conv_active_ && current_conv_.protocol != chat_support::active_mesh_protocol()) diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp index 79be9fd2..8893c430 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp @@ -73,6 +73,8 @@ const char* message_ingress_label(::ui::chat::MessageIngressTransport transport) { case ::ui::chat::MessageIngressTransport::LoRa: return "LoRa"; + case ::ui::chat::MessageIngressTransport::Mqtt: + return "MQTT"; case ::ui::chat::MessageIngressTransport::WiFi: return "Wi-Fi"; case ::ui::chat::MessageIngressTransport::Unknown: @@ -399,6 +401,17 @@ void ChatConversationScreen::setReplyEnabled(bool enabled) } } +void ChatConversationScreen::setHistoryPaging(bool has_older, + bool has_newer, + uint16_t offset, + uint16_t total_count) +{ + (void)has_older; + (void)has_newer; + (void)offset; + (void)total_count; +} + void ChatConversationScreen::schedule_action_async(ActionIntent intent) { if (!action_cb_) diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index e25c8c0b..ba3e354b 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -91,6 +91,7 @@ static uint32_t s_compose_peer_id = 0; static chat::ChannelId s_compose_channel = chat::ChannelId::PRIMARY; static chat::MeshProtocol s_compose_protocol = chat::MeshProtocol::Meshtastic; static chat::ConversationId s_compose_conversation{}; +static std::string s_compose_target_display_name; static bool s_refreshing_ui = false; static lv_coord_t page_button_height() { @@ -191,6 +192,38 @@ static void format_reticulum_hash_prefix(const chat::ReticulumPeerIdentity& iden static_cast(identity.destination_hash[3])); } +static void format_log_text_preview(const std::string& text, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + const size_t max_copy = out_len - 1U; + size_t used = 0; + for (char value : text) + { + if (used >= max_copy) + { + break; + } + const unsigned char c = static_cast(value); + if (c == '\r' || c == '\n' || c == '\t') + { + out[used++] = ' '; + } + else if (c < 0x20U || c == 0x7FU) + { + out[used++] = '.'; + } + else + { + out[used++] = value; + } + } + out[used] = '\0'; +} + static void format_reticulum_hash_text(const uint8_t* hash, char* out, size_t out_len) { if (!out || out_len == 0) @@ -2764,19 +2797,23 @@ static void open_chat_compose() if (g_contacts_state.current_mode == ContactsMode::Groups) { conv.reticulum_identity = reticulum_destination; - char dest_hash[12] = {}; - format_reticulum_hash_prefix(reticulum_destination, dest_hash, sizeof(dest_hash)); - std::printf("[Contacts][RTGroup] compose_open title=%s channel=%u peer=%08lX dest=%s\n", - title.c_str(), - static_cast(channel), - static_cast(peer_id), - dest_hash); } else if (node && protocol == chat::MeshProtocol::Reticulum && chat::hasReticulumDestinationIdentity(node->reticulum_identity)) { conv.reticulum_identity = node->reticulum_identity; } + char compose_dest_hash[12] = {}; + format_reticulum_hash_prefix(conv.reticulum_identity, + compose_dest_hash, + sizeof(compose_dest_hash)); + std::printf("[Contacts][TX] compose_open protocol=%s group=%u target=\"%s\" ch=%u peer=%08lX dest=%s\n", + chat::infra::meshProtocolName(protocol), + g_contacts_state.current_mode == ContactsMode::Groups ? 1U : 0U, + title.c_str(), + static_cast(channel), + static_cast(peer_id), + compose_dest_hash); g_contacts_state.compose_screen = new chat::ui::ChatComposeScreen(parent, conv); g_contacts_state.compose_screen->setActionCallback(on_compose_action, nullptr); g_contacts_state.compose_screen->setBackCallback(on_compose_back, nullptr); @@ -2803,6 +2840,7 @@ static void open_chat_compose() s_compose_channel = channel; s_compose_protocol = protocol; s_compose_conversation = conv; + s_compose_target_display_name = title; s_compose_is_team = (g_contacts_state.current_mode == ContactsMode::Team); if (s_compose_is_team) { @@ -2853,6 +2891,7 @@ static void close_chat_compose() s_compose_channel = chat::ChannelId::PRIMARY; s_compose_protocol = chat::MeshProtocol::Meshtastic; s_compose_conversation = chat::ConversationId{}; + s_compose_target_display_name.clear(); s_compose_is_team = false; if (s_compose_from_conversation && g_contacts_state.conversation_screen) @@ -2989,25 +3028,31 @@ static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, if (g_contacts_state.chat_service) { char dest_hash[12] = {}; + char text_preview[64] = {}; format_reticulum_hash_prefix(s_compose_conversation.reticulum_identity, dest_hash, sizeof(dest_hash)); - std::printf("[Contacts][RTGroup] compose_send begin group=%u protocol=%s ch=%u peer=%08lX dest=%s len=%u\n", + format_log_text_preview(text, text_preview, sizeof(text_preview)); + std::printf("[Contacts][TX] send_begin group=%u protocol=%s target=\"%s\" ch=%u peer=%08lX dest=%s len=%u text=\"%s\"\n", reticulum_group_send ? 1U : 0U, chat::infra::meshProtocolName(s_compose_protocol), + s_compose_target_display_name.c_str(), static_cast(s_compose_channel), static_cast(s_compose_peer_id), dest_hash, - static_cast(text.size())); + static_cast(text.size()), + text_preview); const chat::MeshSendResult result = g_contacts_state.chat_service->sendTextToConversationDetailed( s_compose_conversation, text); - std::printf("[Contacts][RTGroup] compose_send end ok=%u msg=%lu failure=%u dest=%s\n", + std::printf("[Contacts][TX] send_end ok=%u msg=%lu failure=%u target=\"%s\" dest=%s text=\"%s\"\n", result.ok ? 1U : 0U, static_cast(result.msg_id), static_cast(result.failure), - dest_hash); + s_compose_target_display_name.c_str(), + dest_hash, + text_preview); if (!result.ok || result.msg_id == 0) { ::ui::feedback::show_notice( diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp index 7ca4d813..28297a5f 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp @@ -225,11 +225,8 @@ void copy_node_text(char* out, std::size_t out_len, const char* text) std::string reticulum_record_fallback_name( const chat::ReticulumPeerIdentity& identity) { - char prefix[12] = {}; - format_reticulum_hash_prefix(identity, prefix, sizeof(prefix)); - return prefix[0] != '\0' && std::strcmp(prefix, "-") != 0 - ? std::string(prefix) - : std::string("Reticulum"); + (void)identity; + return "Anonymous Peer"; } chat::contacts::NodeInfo node_from_lxmf_address( diff --git a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp index f6d7ca20..40549c46 100644 --- a/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/extensions/extensions_page_runtime.cpp @@ -1310,7 +1310,9 @@ void enter(const shell::Host* host, lv_obj_t* parent) s_runtime.root = two_pane_layout::create_root(parent, root_spec); apply_root_style(s_runtime.root); - ::ui::widgets::top_bar_init(s_runtime.top_bar, s_runtime.root); + ::ui::widgets::TopBarConfig top_bar_config{}; + top_bar_config.power_indicator = false; + ::ui::widgets::top_bar_init(s_runtime.top_bar, s_runtime.root, top_bar_config); ::ui::widgets::top_bar_set_title(s_runtime.top_bar, ::ui::i18n::tr("Extensions")); ::ui::widgets::top_bar_set_back_callback(s_runtime.top_bar, on_back, nullptr); diff --git a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp index 1bfc4ac4..ecba1bb0 100644 --- a/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp +++ b/modules/ui_shared/src/ui/screens/network/network_page_shell.cpp @@ -357,12 +357,43 @@ const char* aspect_label(rtdir::AnnounceAspect aspect) return "Prop"; case rtdir::AnnounceAspect::CallAudio: return "Call"; + case rtdir::AnnounceAspect::NomadNetworkNode: + return "Nomad"; case rtdir::AnnounceAspect::Unknown: default: return "Node"; } } +const char* announce_display_label(const rtdir::AnnounceRecord& announce, + const char* fallback) +{ + if (announce.display_name[0] != '\0') + { + return announce.display_name; + } + if (announce.aspect == rtdir::AnnounceAspect::NomadNetworkNode) + { + return "Anonymous Node"; + } + if (announce.aspect == rtdir::AnnounceAspect::LxmfDelivery || + announce.aspect == rtdir::AnnounceAspect::CallAudio) + { + return "Anonymous Peer"; + } + return fallback ? fallback : ""; +} + +const char* address_display_label(const rtdir::LxmfAddressRecord& address, + const char* fallback) +{ + if (address.display_name[0] != '\0') + { + return address.display_name; + } + return fallback ? fallback : "Anonymous Peer"; +} + bool contains_ci(const char* text, const char* query) { if (!query || query[0] == '\0') @@ -414,7 +445,7 @@ bool announce_matches_search(const rtdir::AnnounceRecord& announce) char identity[kHashTextLen] = {}; format_hash_hex(announce.destination_hash, destination, sizeof(destination)); format_hash_hex(announce.identity_hash, identity, sizeof(identity)); - return contains_ci(announce.display_name, g_state.search_query) || + return contains_ci(announce_display_label(announce, ""), g_state.search_query) || contains_ci(destination, g_state.search_query) || contains_ci(identity, g_state.search_query) || contains_ci(aspect_label(announce.aspect), g_state.search_query); @@ -430,7 +461,7 @@ bool address_matches_search(const rtdir::LxmfAddressRecord& address) char identity[kHashTextLen] = {}; format_hash_hex(address.destination_hash, destination, sizeof(destination)); format_hash_hex(address.identity_hash, identity, sizeof(identity)); - return contains_ci(address.display_name, g_state.search_query) || + return contains_ci(address_display_label(address, ""), g_state.search_query) || contains_ci(destination, g_state.search_query) || contains_ci(identity, g_state.search_query); } @@ -959,9 +990,7 @@ void render_home_page() format_hash_hex(g_state.announces[0].destination_hash, dest, sizeof(dest)); char target[kAddressTextLen] = {}; std::snprintf(target, sizeof(target), "%s:/page/index.mu", dest); - const char* label = g_state.announces[0].display_name[0] != '\0' - ? g_state.announces[0].display_name - : "Latest announce"; + const char* label = announce_display_label(g_state.announces[0], "Latest announce"); add_terminal_link(target, label); } } @@ -988,9 +1017,7 @@ void render_collection_page(bool favourites) char target[kAddressTextLen] = {}; format_hash_hex(address.destination_hash, dest, sizeof(dest)); std::snprintf(target, sizeof(target), "%s:/page/index.mu", dest); - add_terminal_link(target, - address.display_name[0] != '\0' ? address.display_name - : dest); + add_terminal_link(target, address_display_label(address, "Anonymous Peer")); ++visible; } if (visible == 0) @@ -1011,9 +1038,7 @@ void render_collection_page(bool favourites) char target[kAddressTextLen] = {}; format_hash_hex(announce.destination_hash, dest, sizeof(dest)); std::snprintf(target, sizeof(target), "%s:/page/index.mu", dest); - add_terminal_link(target, - announce.display_name[0] != '\0' ? announce.display_name - : dest); + add_terminal_link(target, announce_display_label(announce, dest)); } if (g_state.announce_count == 0) { @@ -1027,13 +1052,13 @@ void render_node_page(const rtdir::AnnounceRecord* announce, { clear_viewport(); const char* title = nullptr; - if (address_record && address_record->display_name[0] != '\0') + if (address_record) { - title = address_record->display_name; + title = address_display_label(*address_record, "Anonymous Peer"); } - else if (announce && announce->display_name[0] != '\0') + else if (announce) { - title = announce->display_name; + title = announce_display_label(*announce, destination_text); } else { @@ -1364,8 +1389,7 @@ void render_directory_list() format_hash_prefix(address.destination_hash, destination, sizeof(destination)); char meta[64] = {}; std::snprintf(meta, sizeof(meta), "%s LXMF", destination); - create_directory_row(address.display_name[0] != '\0' ? address.display_name - : destination, + create_directory_row(address_display_label(address, "Anonymous Peer"), meta, LV_SYMBOL_STAR, DirectoryRowKind::Favourite, @@ -1391,8 +1415,7 @@ void render_directory_list() destination, aspect_label(announce.aspect), static_cast(announce.hops)); - create_directory_row(announce.display_name[0] != '\0' ? announce.display_name - : destination, + create_directory_row(announce_display_label(announce, destination), meta, "A", DirectoryRowKind::Announce, diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 4f6a1a17..433f9dd7 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -3113,12 +3113,19 @@ static void on_option_clicked(lv_event_t* e) const wifi_runtime::ScanResult& result = dynamic_options().wifi_scan_results[static_cast(payload->value)]; copy_bounded(g_settings.wifi_ssid, sizeof(g_settings.wifi_ssid), result.ssid); - wifi_runtime::Config config{}; - config.enabled = g_settings.wifi_enabled; - copy_bounded(config.ssid, sizeof(config.ssid), g_settings.wifi_ssid); - copy_bounded(config.password, sizeof(config.password), g_settings.wifi_password); - (void)wifi_runtime::save_config(config); - refresh_wifi_state_from_runtime(); + wifi_runtime::Config saved_config{}; + if (wifi_runtime::find_saved_config(g_settings.wifi_ssid, saved_config)) + { + copy_bounded(g_settings.wifi_password, + sizeof(g_settings.wifi_password), + saved_config.password); + saved_config.enabled = g_settings.wifi_enabled; + (void)wifi_runtime::save_config(saved_config); + } + else + { + g_settings.wifi_password[0] = '\0'; + } rebuild_list = true; } } diff --git a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp index 0a9d0476..5fc951b2 100644 --- a/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/tracker/tracker_page_components.cpp @@ -9,6 +9,7 @@ #include "ui/assets/fonts/font_utils.h" #include "ui/localization.h" #include "ui/page/page_profile.h" +#include "ui/runtime/ui_feedback.h" #include "ui/screens/tracker/tracker_page_input.h" #include "ui/screens/tracker/tracker_page_layout.h" #include "ui/screens/tracker/tracker_state.h" @@ -1673,6 +1674,14 @@ bool route_preview_download_button_disabled() return route_preview_all_image_caches_ready(); } +void show_route_preview_notice(const char* message, uint32_t duration_ms = 1800) +{ + if (message && message[0] != '\0') + { + ::ui::feedback::show_notice(message, duration_ms); + } +} + void set_button_disabled(lv_obj_t* btn, bool disabled) { if (!btn || !lv_obj_is_valid(btn)) @@ -1853,56 +1862,45 @@ void update_route_preview_status() const std::size_t saved_count = preview_saved_image_count(); const std::size_t cached_count = preview_cached_image_count(); const auto download_status = platform::ui::route_storage::route_image_download_status(); - char status[160]; - if (s_preview_images.empty()) + + if (state.route_preview_status_label && + lv_obj_is_valid(state.route_preview_status_label)) { - std::snprintf(status, - sizeof(status), - "%u pts %.1fkm %.0f-%.0fm", - static_cast(s_preview_route_points.size()), - s_preview_metrics.distance_m / 1000.0, - s_preview_metrics.min_altitude_m, - s_preview_metrics.max_altitude_m); + if (s_preview_images.empty()) + { + lv_obj_add_flag(state.route_preview_status_label, LV_OBJ_FLAG_HIDDEN); + } + else + { + char status[24]{}; + std::snprintf(status, + sizeof(status), + "%u/%u", + static_cast(saved_count), + static_cast(s_preview_images.size())); + ::ui::i18n::set_label_text_raw(state.route_preview_status_label, status); + lv_obj_clear_flag(state.route_preview_status_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_align(state.route_preview_status_label, LV_ALIGN_TOP_RIGHT, -5, 5); + lv_obj_move_foreground(state.route_preview_status_label); + } } - else - { - const bool selected_ok = - s_preview_selected_image >= 0 && - s_preview_selected_image < static_cast(s_preview_images.size()); - const char* local_state = selected_ok && - s_preview_images[static_cast(s_preview_selected_image)].downloaded - ? "local" - : "remote"; - std::snprintf(status, - sizeof(status), - "Img %u/%u %s saved %u/%u cache %u/%u %.1fkm", - static_cast(s_preview_selected_image + 1), - static_cast(s_preview_images.size()), - local_state, - static_cast(saved_count), - static_cast(s_preview_images.size()), - static_cast(cached_count), - static_cast(s_preview_images.size()), - s_preview_metrics.distance_m / 1000.0); - } - std::string status_text = status; - if (!s_preview_status_text.empty()) - { - status_text += " | "; - status_text += s_preview_status_text; - } - if (state.route_preview_status_label) - { - ::ui::i18n::set_label_text_raw(state.route_preview_status_label, status_text.c_str()); - } - if (state.route_preview_progress) + + if (state.route_preview_progress && + lv_obj_is_valid(state.route_preview_progress)) { + if (s_preview_images.empty()) + { + lv_obj_add_flag(state.route_preview_progress, LV_OBJ_FLAG_HIDDEN); + lv_bar_set_value(state.route_preview_progress, 0, LV_ANIM_OFF); + return; + } + lv_obj_clear_flag(state.route_preview_progress, LV_OBJ_FLAG_HIDDEN); int value = 0; if (route_preview_download_status_matches(download_status) && download_status.total > 0) { value = static_cast((download_status.processed * 100U) / download_status.total); } - else if (!s_preview_images.empty()) + else { const std::size_t numerator = saved_count >= s_preview_images.size() ? cached_count : saved_count; @@ -2405,21 +2403,34 @@ void on_route_preview_key(lv_event_t* e) void on_route_preview_download_clicked(lv_event_t*) { assign_preview_image_paths(); + const std::size_t initial_saved_count = preview_saved_image_count(); + std::printf("[RoutePreview][down] clicked asset=%s images=%u saved=%u\n", + s_preview_asset_id.c_str(), + static_cast(s_preview_images.size()), + static_cast(initial_saved_count)); if (s_preview_images.empty()) { s_preview_status_text = "No route images"; s_preview_download_state = RoutePreviewDownloadState::Failed; + std::printf("[RoutePreview][down] blocked reason=no_images asset=%s\n", + s_preview_asset_id.c_str()); + show_route_preview_notice("No route images"); update_route_preview_status(); update_route_preview_buttons(); return; } const std::size_t total = s_preview_images.size(); - std::size_t saved_count = preview_saved_image_count(); + std::size_t saved_count = initial_saved_count; char detail[96]{}; if (route_preview_download_busy_for_current_route()) { + std::printf("[RoutePreview][down] already_running asset=%s saved=%u/%u\n", + s_preview_asset_id.c_str(), + static_cast(saved_count), + static_cast(total)); + show_route_preview_notice("Download in progress"); sync_route_preview_download_status(); ensure_route_preview_download_poll_timer(); return; @@ -2429,6 +2440,11 @@ void on_route_preview_download_clicked(lv_event_t*) { if (!route_preview_all_image_caches_ready()) { + std::printf("[RoutePreview][down] cache_start asset=%s saved=%u/%u\n", + s_preview_asset_id.c_str(), + static_cast(saved_count), + static_cast(total)); + show_route_preview_notice("Preparing image cache"); ensure_route_preview_image_cache_build(); return; } @@ -2439,6 +2455,10 @@ void on_route_preview_download_clicked(lv_event_t*) static_cast(total), static_cast(total)); s_preview_status_text = detail; + std::printf("[RoutePreview][down] blocked reason=already_ready asset=%s total=%u\n", + s_preview_asset_id.c_str(), + static_cast(total)); + show_route_preview_notice("Images already saved"); update_route_preview_status(); update_route_preview_buttons(); return; @@ -2449,6 +2469,14 @@ void on_route_preview_download_clicked(lv_event_t*) { s_preview_status_text = "Wi-Fi required"; s_preview_download_state = RoutePreviewDownloadState::Failed; + std::printf("[RoutePreview][down] blocked reason=wifi_required supported=%u enabled=%u connected=%u credentials=%u state=%u msg=%s\n", + wifi.supported ? 1U : 0U, + wifi.enabled ? 1U : 0U, + wifi.connected ? 1U : 0U, + wifi.has_credentials ? 1U : 0U, + static_cast(wifi.state), + wifi.message); + show_route_preview_notice("Wi-Fi required"); update_route_preview_status(); update_route_preview_buttons(); return; @@ -2459,6 +2487,9 @@ void on_route_preview_download_clicked(lv_event_t*) { s_preview_status_text = "Create image dir failed"; s_preview_download_state = RoutePreviewDownloadState::Failed; + std::printf("[RoutePreview][down] blocked reason=asset_dir_failed asset=%s\n", + s_preview_asset_id.c_str()); + show_route_preview_notice("Create image dir failed"); update_route_preview_status(); update_route_preview_buttons(); return; @@ -2473,6 +2504,12 @@ void on_route_preview_download_clicked(lv_event_t*) static_cast(saved_count), static_cast(total)); s_preview_status_text = detail; + std::printf("[RoutePreview][down] starting asset=%s saved=%u/%u wifi_ssid=%s ip=%s\n", + s_preview_asset_id.c_str(), + static_cast(saved_count), + static_cast(total), + wifi.ssid, + wifi.ip); update_route_preview_status(); update_route_preview_buttons(); @@ -2493,11 +2530,19 @@ void on_route_preview_download_clicked(lv_event_t*) { s_preview_download_state = RoutePreviewDownloadState::Failed; s_preview_status_text = error.empty() ? "Start download failed" : error; + std::printf("[RoutePreview][down] start_failed asset=%s error=%s\n", + s_preview_asset_id.c_str(), + s_preview_status_text.c_str()); + show_route_preview_notice(s_preview_status_text.c_str(), 2200); update_route_preview_status(); update_route_preview_buttons(); return; } + std::printf("[RoutePreview][down] task_started asset=%s total=%u\n", + s_preview_asset_id.c_str(), + static_cast(items.size())); + show_route_preview_notice("Download started"); s_preview_download_refresh_map_on_finish = true; ensure_route_preview_download_poll_timer(); sync_route_preview_download_status(); @@ -2548,16 +2593,29 @@ void render_route_preview_page() state.route_preview_elevation_panel = nullptr; - state.route_preview_status_label = lv_label_create(state.route_preview_page); - lv_obj_set_width(state.route_preview_status_label, LV_PCT(100)); - lv_label_set_long_mode(state.route_preview_status_label, LV_LABEL_LONG_DOT); + state.route_preview_status_label = lv_label_create(state.route_preview_map_host); + lv_obj_set_size(state.route_preview_status_label, 52, 18); + lv_obj_add_flag(state.route_preview_status_label, LV_OBJ_FLAG_IGNORE_LAYOUT); + lv_obj_add_flag(state.route_preview_status_label, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(state.route_preview_status_label, LV_OBJ_FLAG_CLICKABLE); + lv_label_set_long_mode(state.route_preview_status_label, LV_LABEL_LONG_CLIP); + lv_obj_set_style_bg_color(state.route_preview_status_label, lv_color_hex(0x1C1812), LV_PART_MAIN); + lv_obj_set_style_bg_opa(state.route_preview_status_label, LV_OPA_70, LV_PART_MAIN); + lv_obj_set_style_border_width(state.route_preview_status_label, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(state.route_preview_status_label, lv_color_hex(0xFFF3DF), LV_PART_MAIN); + lv_obj_set_style_radius(state.route_preview_status_label, 4, LV_PART_MAIN); + lv_obj_set_style_pad_top(state.route_preview_status_label, 1, LV_PART_MAIN); + lv_obj_set_style_pad_bottom(state.route_preview_status_label, 1, LV_PART_MAIN); lv_obj_set_style_text_font( - state.route_preview_status_label, ::ui::fonts::localized_font(::ui::fonts::ui_chrome_font()), 0); - lv_obj_set_style_text_color(state.route_preview_status_label, lv_color_hex(kPanelTextMuted), 0); + state.route_preview_status_label, ::ui::fonts::localized_font(&lv_font_montserrat_12), 0); + lv_obj_set_style_text_color(state.route_preview_status_label, lv_color_hex(0xFFF3DF), 0); + lv_obj_set_style_text_align(state.route_preview_status_label, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(state.route_preview_status_label, LV_ALIGN_TOP_RIGHT, -5, 5); state.route_preview_progress = lv_bar_create(state.route_preview_page); lv_obj_set_size(state.route_preview_progress, LV_PCT(100), 7); lv_bar_set_range(state.route_preview_progress, 0, 100); + lv_bar_set_value(state.route_preview_progress, 0, LV_ANIM_OFF); lv_obj_t* row = lv_obj_create(state.route_preview_page); lv_obj_set_size(row, LV_PCT(100), action_menu_button_height()); diff --git a/modules/ui_shared/src/ui/widgets/top_bar.cpp b/modules/ui_shared/src/ui/widgets/top_bar.cpp index 7a6b8fd3..e68bd549 100644 --- a/modules/ui_shared/src/ui/widgets/top_bar.cpp +++ b/modules/ui_shared/src/ui/widgets/top_bar.cpp @@ -11,6 +11,7 @@ #include "ui/assets/fonts/font_utils.h" #include "ui/page/page_profile.h" #include "ui/ui_theme.h" +#include "ui/widgets/top_bar_power_presenter.h" #if !defined(LV_FONT_MONTSERRAT_16) || !LV_FONT_MONTSERRAT_16 #define lv_font_montserrat_16 lv_font_montserrat_14 @@ -153,6 +154,11 @@ void top_bar_init(TopBar& bar, lv_obj_t* parent, const TopBarConfig& config) lv_obj_set_style_text_align(bar.right_label, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_set_style_text_font(bar.right_label, text_font, 0); lv_obj_set_style_bg_opa(bar.right_label, LV_OPA_TRANSP, 0); + + if (config.power_indicator) + { + top_bar_power::bind(bar); + } } void top_bar_set_title(TopBar& bar, const char* title) @@ -180,6 +186,8 @@ void top_bar_set_right_text(TopBar& bar, const char* text) return; } + top_bar_power::unbind(bar); + const char* current = lv_label_get_text(bar.right_label); if (current != nullptr && std::strcmp(current, text) == 0) { diff --git a/modules/ui_shared/src/ui/widgets/top_bar_power_presenter.cpp b/modules/ui_shared/src/ui/widgets/top_bar_power_presenter.cpp new file mode 100644 index 00000000..f043c1af --- /dev/null +++ b/modules/ui_shared/src/ui/widgets/top_bar_power_presenter.cpp @@ -0,0 +1,237 @@ +/** + * @file top_bar_power_presenter.cpp + * @brief Shared TopBar power presenter with cached, debounced board sampling. + */ + +#include "ui/widgets/top_bar_power_presenter.h" + +#include +#include +#include +#include + +#include "platform/ui/device_runtime.h" +#include "sys/clock.h" +#include "ui/formatters.h" + +namespace ui +{ +namespace widgets +{ +namespace top_bar_power +{ +namespace +{ +constexpr std::size_t kMaxTargets = 8; +constexpr std::size_t kLabelLen = 32; +constexpr uint32_t kRefreshIntervalMs = 1000; +constexpr uint8_t kChargingChangeStableSamples = 2; + +struct PresenterState +{ + TopBar* targets[kMaxTargets] = {}; + char label[kLabelLen] = "--"; + bool has_label = false; + bool has_sample_time = false; + uint32_t last_sample_ms = 0; + bool has_charging = false; + bool charging = false; + bool pending_charging = false; + uint8_t pending_charging_count = 0; +}; + +PresenterState& state() +{ + static PresenterState s_state; + return s_state; +} + +bool has_target(const PresenterState& presenter) +{ + for (TopBar* target : presenter.targets) + { + if (target != nullptr && target->right_label != nullptr) + { + return true; + } + } + return false; +} + +bool contains_target(const PresenterState& presenter, TopBar& bar) +{ + for (TopBar* target : presenter.targets) + { + if (target == &bar) + { + return true; + } + } + return false; +} + +void apply_cached_label(PresenterState& presenter) +{ + if (!presenter.has_label) + { + return; + } + + for (TopBar* target : presenter.targets) + { + if (target != nullptr && target->right_label != nullptr) + { + top_bar_set_right_text_ascii(*target, presenter.label); + } + } +} + +void observe_charging(PresenterState& presenter, bool charging) +{ + if (!presenter.has_charging) + { + presenter.has_charging = true; + presenter.charging = charging; + presenter.pending_charging = charging; + presenter.pending_charging_count = 0; + return; + } + + if (charging == presenter.charging) + { + presenter.pending_charging = charging; + presenter.pending_charging_count = 0; + return; + } + + if (charging != presenter.pending_charging) + { + presenter.pending_charging = charging; + presenter.pending_charging_count = 1; + return; + } + + if (presenter.pending_charging_count < kChargingChangeStableSamples) + { + ++presenter.pending_charging_count; + } + if (presenter.pending_charging_count >= kChargingChangeStableSamples) + { + presenter.charging = charging; + presenter.pending_charging_count = 0; + } +} + +void sample_and_apply(PresenterState& presenter, uint32_t now_ms) +{ + const platform::ui::device::BatteryInfo info = + platform::ui::device::battery_info(); + observe_charging(presenter, info.charging); + + char next_label[kLabelLen] = "--"; + ui_format_battery(info.level, + presenter.has_charging ? presenter.charging : info.charging, + next_label, + sizeof(next_label)); + + presenter.last_sample_ms = now_ms; + presenter.has_sample_time = true; + if (!presenter.has_label || std::strcmp(presenter.label, next_label) != 0) + { + std::snprintf(presenter.label, sizeof(presenter.label), "%s", next_label); + presenter.has_label = true; + apply_cached_label(presenter); + return; + } + + presenter.has_label = true; + apply_cached_label(presenter); +} + +void on_top_bar_deleted(lv_event_t* e) +{ + TopBar* bar = static_cast(lv_event_get_user_data(e)); + if (bar != nullptr) + { + unbind(*bar); + } +} + +} // namespace + +void bind(TopBar& bar) +{ + if (bar.right_label == nullptr) + { + return; + } + + PresenterState& presenter = state(); + if (!contains_target(presenter, bar)) + { + for (TopBar*& target : presenter.targets) + { + if (target == nullptr) + { + target = &bar; + if (bar.container != nullptr) + { + lv_obj_add_event_cb( + bar.container, on_top_bar_deleted, LV_EVENT_DELETE, &bar); + } + break; + } + } + } + + if (presenter.has_label) + { + top_bar_set_right_text_ascii(bar, presenter.label); + return; + } + + refresh_now(); +} + +void unbind(TopBar& bar) +{ + PresenterState& presenter = state(); + for (TopBar*& target : presenter.targets) + { + if (target == &bar) + { + target = nullptr; + } + } +} + +void tick() +{ + PresenterState& presenter = state(); + if (!has_target(presenter)) + { + return; + } + + const uint32_t now_ms = sys::millis_now(); + if (presenter.has_sample_time && + static_cast(now_ms - presenter.last_sample_ms) < kRefreshIntervalMs) + { + return; + } + sample_and_apply(presenter, now_ms); +} + +void refresh_now() +{ + PresenterState& presenter = state(); + if (!has_target(presenter)) + { + return; + } + sample_and_apply(presenter, sys::millis_now()); +} + +} // namespace top_bar_power +} // namespace widgets +} // namespace ui diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index d7382fcc..df619d6a 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -230,6 +230,154 @@ class FakeContactStore final : public ::chat::contacts::IContactStore size_t getCount() const override { return 0; } }; +class PagingStore final : public ::chat::IChatStore +{ + public: + void append(const ::chat::ChatMessage& msg) override + { + messages_.push_back(msg); + } + + std::vector<::chat::ChatMessage> loadRecent(const ::chat::ConversationId& conv, + size_t n) override + { + return loadPageFromLatest(conv, 0, n, nullptr); + } + + std::vector<::chat::ChatMessage> loadPageFromLatest( + const ::chat::ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) override + { + size_t count = 0; + for (const auto& msg : messages_) + { + if (::chat::conversationIdForMessage(msg) == conv) + { + ++count; + } + } + if (total) + { + *total = count; + } + if (limit == 0 || offset_from_latest >= count) + { + return {}; + } + + const size_t available = count - offset_from_latest; + const size_t to_read = available < limit ? available : limit; + const size_t start = count - offset_from_latest - to_read; + const size_t end = start + to_read; + + std::vector<::chat::ChatMessage> out; + out.reserve(to_read); + size_t index = 0; + for (const auto& msg : messages_) + { + if (!(::chat::conversationIdForMessage(msg) == conv)) + { + continue; + } + if (index >= start && index < end) + { + out.push_back(msg); + } + ++index; + } + return out; + } + + std::vector<::chat::ConversationMeta> loadConversationPage(size_t offset, + size_t limit, + size_t* total) override + { + const size_t count = messages_.empty() ? 0U : 1U; + if (total) + { + *total = count; + } + if (messages_.empty() || offset > 0) + { + return {}; + } + + const auto& latest = messages_.back(); + ::chat::ConversationMeta meta; + meta.id = ::chat::conversationIdForMessage(latest); + meta.preview = latest.text; + meta.last_timestamp = latest.timestamp; + return {meta}; + } + + void setUnread(const ::chat::ConversationId&, int unread) override + { + unread_ = unread; + } + + int getUnread(const ::chat::ConversationId&) const override + { + return unread_; + } + + void clearConversation(const ::chat::ConversationId& conv) override + { + std::vector<::chat::ChatMessage> kept; + kept.reserve(messages_.size()); + for (const auto& msg : messages_) + { + if (!(::chat::conversationIdForMessage(msg) == conv)) + { + kept.push_back(msg); + } + } + messages_ = kept; + } + + void clearAll() override + { + messages_.clear(); + unread_ = 0; + } + + bool updateMessageStatus(::chat::MessageId msg_id, + ::chat::MessageStatus status) override + { + for (auto& msg : messages_) + { + if (msg.msg_id == msg_id) + { + msg.status = status; + return true; + } + } + return false; + } + + bool getMessage(::chat::MessageId msg_id, + ::chat::ChatMessage* out) const override + { + for (const auto& msg : messages_) + { + if (msg.msg_id == msg_id) + { + if (out) + { + *out = msg; + } + return true; + } + } + return false; + } + + private: + std::vector<::chat::ChatMessage> messages_; + int unread_ = 0; +}; + ui::chat::ConversationId directPeer(uint32_t peer) { ui::chat::ConversationId id; @@ -477,6 +625,60 @@ int main() assert(snapshot.messages[0].sender_node_id == 0x648144D4); assert(std::strcmp(snapshot.messages[0].sender_label.c_str(), "Mother") == 0); + const uint32_t paging_peer = 0x00ABCDEF; + contacts.updateNodeInfo(paging_peer, + "CDEF", + "Pager", + 0.0f, + 0.0f, + 1700000000U, + 0, + ::chat::contacts::kNodeRoleUnknown, + 0xFF, + 0, + 0xFF); + ::chat::ChatModel paging_model; + FakeMeshAdapter paging_mesh; + paging_mesh.self_node_id = mesh.self_node_id; + PagingStore paging_store; + ::chat::ChatService paging_service(paging_model, paging_mesh, paging_store); + ui::presentation_sources::ChatPresentationSource paging_source( + paging_service, &contacts, &delivery_read_model, &paging_mesh); + const ui::chat::ConversationId paging = directPeer(paging_peer); + for (::chat::MessageId id = 1; id <= 25; ++id) + { + ::chat::ChatMessage page_msg; + page_msg.protocol = ::chat::MeshProtocol::Meshtastic; + page_msg.channel = ::chat::ChannelId::PRIMARY; + page_msg.from = paging_peer; + page_msg.peer = paging_peer; + page_msg.msg_id = 2000 + id; + page_msg.timestamp = 1700000000U + id; + page_msg.text = "page-" + std::to_string(id); + page_msg.status = ::chat::MessageStatus::Incoming; + paging_store.append(page_msg); + } + + ui::chat::ChatWorkspaceRequest paging_request; + paging_request.selected = paging; + paging_request.message_offset = 0; + assert(paging_source.buildChatWorkspaceSnapshot(paging_request, snapshot)); + assert(snapshot.message_count == ui::chat::ChatWorkspaceSnapshot::kMaxMessages); + assert(snapshot.message_total_count == 25); + assert(!snapshot.has_newer_messages); + assert(snapshot.has_older_messages); + assert(std::strcmp(snapshot.messages[0].text.c_str(), "page-6") == 0); + assert(std::strcmp(snapshot.messages[19].text.c_str(), "page-25") == 0); + + paging_request.message_offset = ui::chat::ChatWorkspaceSnapshot::kMaxMessages; + assert(paging_source.buildChatWorkspaceSnapshot(paging_request, snapshot)); + assert(snapshot.message_count == 5); + assert(snapshot.message_total_count == 25); + assert(snapshot.has_newer_messages); + assert(!snapshot.has_older_messages); + assert(std::strcmp(snapshot.messages[0].text.c_str(), "page-1") == 0); + assert(std::strcmp(snapshot.messages[4].text.c_str(), "page-5") == 0); + service.setActiveProtocol(::chat::MeshProtocol::MeshCore); ::chat::MeshIncomingText unknown_meshcore_incoming{}; unknown_meshcore_incoming.channel = ::chat::ChannelId::PRIMARY; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h index 7800560d..c4f16410 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h @@ -177,6 +177,8 @@ class ReticulumInterfaceSet bool loraAllowed() const; bool wifiAllowed() const; + bool loraSelectedForRuntime() const; + bool wifiSelectedForRuntime() const; }; } // namespace chat::reticulum::interfaces diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h index 973de4c5..58fd1acd 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h @@ -22,7 +22,7 @@ class SdStore final : public IChatStore public: static constexpr const char* kDir = "/chat"; static constexpr const char* kIndexFile = "/chat/index.bin"; - static constexpr size_t kMaxMessagesPerConv = 100; + static constexpr size_t kMaxMessagesPerConv = 1000; static constexpr size_t kMaxTextLen = 233; static constexpr size_t kPreviewLen = 48; @@ -33,6 +33,10 @@ class SdStore final : public IChatStore void append(const ChatMessage& msg) override; std::vector loadRecent(const ConversationId& conv, size_t n) override; + std::vector loadPageFromLatest(const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) override; std::vector loadConversationPage(size_t offset, size_t limit, size_t* total) override; diff --git a/platform/esp/arduino_common/src/app_event_runtime_support.cpp b/platform/esp/arduino_common/src/app_event_runtime_support.cpp index 40c19663..2c449f27 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -16,6 +16,7 @@ #include "ui/localization.h" #include "ui/runtime/ui_feedback.h" #include "ui/screens/team/team_page_shell.h" +#include "ui/widgets/top_bar_power_presenter.h" #include "ui_chat_runtime/chat_delivery_feedback_controller.h" namespace platform::esp::arduino_common @@ -182,6 +183,8 @@ void tickUiRuntime(app::IAppFacade& app_context) { chat_ui_runtime->update(); } + + ::ui::widgets::top_bar_power::tick(); } bool handleUiEvent(app::IAppFacade& app_context, sys::Event* event) diff --git a/platform/esp/arduino_common/src/app_tasks.cpp b/platform/esp/arduino_common/src/app_tasks.cpp index f816af3c..ac9db8ab 100644 --- a/platform/esp/arduino_common/src/app_tasks.cpp +++ b/platform/esp/arduino_common/src/app_tasks.cpp @@ -35,6 +35,18 @@ constexpr TickType_t kRadioDisplayPressurePollDelay = pdMS_TO_TICKS(50); constexpr uint32_t kRadioDisplayPressureWindowMs = 300; constexpr uint32_t kRadioTaskStackBytes = 3 * 1024; constexpr uint32_t kMeshTaskStackBytes = 6 * 1024; +constexpr uint32_t kRadioRxSummaryIntervalMs = 5000; + +struct RadioRxSummary +{ + uint32_t packets = 0; + uint32_t bytes = 0; + uint32_t queue_drops = 0; + uint32_t alloc_drops = 0; + uint32_t read_failures = 0; + uint32_t other_irqs = 0; + uint32_t last_log_ms = 0; +}; bool display_spi_pressure_for_radio() { @@ -93,29 +105,6 @@ uint32_t radio_terminal_irq_mask() return mask; } -void append_irq_flag(char* buf, size_t len, const char* name, bool set) -{ - if (!set || !buf || len == 0) - { - return; - } - size_t used = strlen(buf); - if (used >= len - 1) - { - return; - } - if (used > 0) - { - strncat(buf, "|", len - used - 1); - used = strlen(buf); - if (used >= len - 1) - { - return; - } - } - strncat(buf, name, len - used - 1); -} - bool radio_read_state_has_payload(int state) { if (state == RADIOLIB_ERR_NONE) @@ -132,75 +121,34 @@ bool radio_read_state_has_payload(int state) #endif } -const char* describe_irq_flags(uint32_t flags, char* buf, size_t len) +void maybe_log_radio_rx_summary(RadioRxSummary& summary) { - if (!buf || len == 0) +#if LORA_LOG_ENABLE + const uint32_t now_ms = millis(); + if (summary.last_log_ms == 0) { - return ""; + summary.last_log_ms = now_ms; } - buf[0] = '\0'; -#if defined(RADIOLIB_SX126X_IRQ_RX_DONE) - append_irq_flag(buf, len, "RX_DONE", (flags & RADIOLIB_SX126X_IRQ_RX_DONE) != 0); -#endif -#if defined(RADIOLIB_SX126X_IRQ_CRC_ERR) - append_irq_flag(buf, len, "CRC_ERR", (flags & RADIOLIB_SX126X_IRQ_CRC_ERR) != 0); -#endif -#if defined(RADIOLIB_SX126X_IRQ_HEADER_ERR) - append_irq_flag(buf, len, "HEADER_ERR", (flags & RADIOLIB_SX126X_IRQ_HEADER_ERR) != 0); -#endif -#if defined(RADIOLIB_SX126X_IRQ_TIMEOUT) - append_irq_flag(buf, len, "TIMEOUT", (flags & RADIOLIB_SX126X_IRQ_TIMEOUT) != 0); -#endif -#if defined(RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID) - append_irq_flag(buf, len, "SYNCWORD", (flags & RADIOLIB_SX126X_IRQ_SYNC_WORD_VALID) != 0); -#endif -#if defined(RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED) - append_irq_flag(buf, len, "PREAMBLE", (flags & RADIOLIB_SX126X_IRQ_PREAMBLE_DETECTED) != 0); -#endif -#if defined(RADIOLIB_SX128X_IRQ_RX_DONE) - append_irq_flag(buf, len, "RX_DONE", (flags & RADIOLIB_SX128X_IRQ_RX_DONE) != 0); -#endif -#if defined(RADIOLIB_SX128X_IRQ_CRC_ERR) - append_irq_flag(buf, len, "CRC_ERR", (flags & RADIOLIB_SX128X_IRQ_CRC_ERR) != 0); -#endif -#if defined(RADIOLIB_SX128X_IRQ_HEADER_ERR) - append_irq_flag(buf, len, "HEADER_ERR", (flags & RADIOLIB_SX128X_IRQ_HEADER_ERR) != 0); -#endif -#if defined(RADIOLIB_SX128X_IRQ_TIMEOUT) - append_irq_flag(buf, len, "TIMEOUT", (flags & RADIOLIB_SX128X_IRQ_TIMEOUT) != 0); -#endif -#if defined(RADIOLIB_SX128X_IRQ_SYNC_WORD_VALID) - append_irq_flag(buf, len, "SYNCWORD", (flags & RADIOLIB_SX128X_IRQ_SYNC_WORD_VALID) != 0); -#endif -#if defined(RADIOLIB_SX128X_IRQ_PREAMBLE_DETECTED) - append_irq_flag(buf, len, "PREAMBLE", (flags & RADIOLIB_SX128X_IRQ_PREAMBLE_DETECTED) != 0); -#endif -#if defined(ARDUINO_LILYGO_LORA_LR1121) -#if defined(RADIOLIB_LR11X0_IRQ_RX_DONE) - append_irq_flag(buf, len, "RX_DONE", (flags & RADIOLIB_LR11X0_IRQ_RX_DONE) != 0); -#endif -#if defined(RADIOLIB_LR11X0_IRQ_CRC_ERR) - append_irq_flag(buf, len, "CRC_ERR", (flags & RADIOLIB_LR11X0_IRQ_CRC_ERR) != 0); -#endif -#if defined(RADIOLIB_LR11X0_IRQ_HEADER_ERR) - append_irq_flag(buf, len, "HEADER_ERR", (flags & RADIOLIB_LR11X0_IRQ_HEADER_ERR) != 0); -#endif -#if defined(RADIOLIB_LR11X0_IRQ_TIMEOUT) - append_irq_flag(buf, len, "TIMEOUT", (flags & RADIOLIB_LR11X0_IRQ_TIMEOUT) != 0); -#endif -#if defined(RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) - append_irq_flag(buf, len, "SYNC_HDR", (flags & RADIOLIB_LR11X0_IRQ_SYNC_WORD_HEADER_VALID) != 0); -#endif -#if defined(RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED) - append_irq_flag(buf, len, "PREAMBLE", (flags & RADIOLIB_LR11X0_IRQ_PREAMBLE_DETECTED) != 0); -#endif -#endif - if (buf[0] == '\0') + if ((now_ms - summary.last_log_ms) < kRadioRxSummaryIntervalMs) { - strncpy(buf, "-", len - 1); - buf[len - 1] = '\0'; + return; } - return buf; + if (summary.packets != 0 || summary.queue_drops != 0 || summary.alloc_drops != 0 || + summary.read_failures != 0 || summary.other_irqs != 0) + { + Serial.printf("[LORA] RX stats packets=%lu bytes=%lu queue_drop=%lu alloc_drop=%lu read_fail=%lu other_irq=%lu\n", + static_cast(summary.packets), + static_cast(summary.bytes), + static_cast(summary.queue_drops), + static_cast(summary.alloc_drops), + static_cast(summary.read_failures), + static_cast(summary.other_irqs)); + } + summary = RadioRxSummary{}; + summary.last_log_ms = now_ms; +#else + (void)summary; +#endif } } // namespace @@ -389,6 +337,7 @@ void AppTasks::radioTask(void* pvParameters) uint8_t rx_buffer[255]; const uint32_t rx_done_mask = radio_rx_done_mask(); const uint32_t terminal_irq_mask = radio_terminal_irq_mask(); + RadioRxSummary rx_summary{}; while (true) { @@ -483,11 +432,6 @@ void AppTasks::radioTask(void* pvParameters) uint32_t irq = board_->getRadioIrqFlags(); if ((irq & rx_done_mask) != 0) { - char irq_desc[96]; - const char* flags = describe_irq_flags(irq, irq_desc, sizeof(irq_desc)); - LORA_LOG("[LORA] IRQ RX_DONE irq=0x%08lX flags=%s\n", - static_cast(irq), - flags); packet_length = static_cast(board_->getRadioPacketLength(true)); if (packet_length > 0 && packet_length <= 255) { @@ -504,23 +448,22 @@ void AppTasks::radioTask(void* pvParameters) rx_packet.rssi = board_->getRadioRSSI(); rx_packet.snr = board_->getRadioSNR(); - LORA_LOG("[LORA] RX len=%d state=%d flags=%s\n", - packet_length, - state, - flags); - // Send to mesh queue - xQueueSend(mesh_queue_, &rx_packet, portMAX_DELAY); + ++rx_summary.packets; + rx_summary.bytes += static_cast(packet_length); + if (xQueueSend(mesh_queue_, &rx_packet, 0) != pdPASS) + { + free(rx_packet.data); + ++rx_summary.queue_drops; + } + } + else + { + ++rx_summary.alloc_drops; } } else { - char irq_desc[96]; - const char* flags = describe_irq_flags(irq, irq_desc, sizeof(irq_desc)); - LORA_LOG("[LORA] RX read fail len=%d state=%d irq=0x%08lX flags=%s\n", - packet_length, - state, - static_cast(irq), - flags); + ++rx_summary.read_failures; board_->clearRadioIrqFlags(irq); } } @@ -529,11 +472,7 @@ void AppTasks::radioTask(void* pvParameters) } else if (irq) { - char irq_desc[96]; - const char* flags = describe_irq_flags(irq, irq_desc, sizeof(irq_desc)); - LORA_LOG("[LORA] IRQ other irq=0x%08lX flags=%s\n", - static_cast(irq), - flags); + ++rx_summary.other_irqs; board_->clearRadioIrqFlags(irq); if ((irq & terminal_irq_mask) != 0) { @@ -554,6 +493,7 @@ void AppTasks::radioTask(void* pvParameters) LORA_LOG("[LORA] RX restart fail state=%d\n", rx_state); } } + maybe_log_radio_rx_summary(rx_summary); } vTaskDelay(display_spi_pressure_for_radio() diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp index 83e3eb81..716b474a 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -92,6 +92,8 @@ constexpr const char* kPeersPrefsNs = "lxmf_peers"; constexpr const char* kPeersPrefsKey = "peers"; constexpr const char* kPeersPrefsVer = "ver"; constexpr const char* kPeersPrefsCrc = "crc"; +constexpr const char* kAnonymousPeerDisplayName = "Anonymous Peer"; +constexpr const char* kAnonymousNodeDisplayName = "Anonymous Node"; constexpr uint8_t kPeersPrefsVersion = 1; constexpr uint8_t kPropagationMetaName = 0x01; @@ -152,6 +154,55 @@ void formatHashHex(const uint8_t* hash, size_t hash_len, char* out, size_t out_l } } +void formatLogTextPreview(const std::string& text, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + const size_t max_copy = out_len - 1U; + size_t used = 0; + for (char value : text) + { + if (used >= max_copy) + { + break; + } + const unsigned char c = static_cast(value); + if (c == '\r' || c == '\n' || c == '\t') + { + out[used++] = ' '; + } + else if (c < 0x20U || c == 0x7FU) + { + out[used++] = '.'; + } + else + { + out[used++] = value; + } + } + out[used] = '\0'; +} + +const char* txBearerName(const reticulum::interfaces::TxResult& result) +{ + if (result.lora_ok && result.wifi_ok) + { + return "lora+wifi"; + } + if (result.lora_ok) + { + return "lora"; + } + if (result.wifi_ok) + { + return "wifi"; + } + return "none"; +} + const char* localDestinationKindLabel(runtime::LocalDestinationKind kind) { switch (kind) @@ -558,6 +609,18 @@ bool isCallAudioAnnounce(const reticulum::ParsedAnnounce& announce) return hashesEqual(expected_name_hash, announce.name_hash, sizeof(expected_name_hash)); } +bool isNomadNetworkNodeAnnounce(const reticulum::ParsedAnnounce& announce) +{ + if (!announce.valid || !announce.name_hash) + { + return false; + } + + uint8_t expected_name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("nomadnetwork", "node", expected_name_hash); + return hashesEqual(expected_name_hash, announce.name_hash, sizeof(expected_name_hash)); +} + bool packetContextUsesRawLinkPayload(uint8_t context) { return context == static_cast(reticulum::PacketContext::Keepalive) || @@ -772,11 +835,14 @@ MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, { (void)forced_msg_id; - Serial.printf("[LXMF][DirectTX] begin ch=%u peer=%08lX len=%u ready=%u\n", + char text_preview[64] = {}; + formatLogTextPreview(text, text_preview, sizeof(text_preview)); + Serial.printf("[LXMF][DirectTX] begin ch=%u peer=%08lX len=%u ready=%u text=\"%s\"\n", static_cast(channel), static_cast(peer), static_cast(text.size()), - isReady() ? 1U : 0U); + isReady() ? 1U : 0U, + text_preview); if (channel != ChannelId::PRIMARY || text.empty() || peer == 0) { @@ -802,7 +868,12 @@ MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, return MeshSendResult::fail(MeshOperationFailure::PeerKeyMissing); } char peer_hash[12] = {}; + char peer_dest_full[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; formatHashPrefix(peer_info->destination_hash, peer_hash, sizeof(peer_hash)); + formatHashHex(peer_info->destination_hash, + sizeof(peer_info->destination_hash), + peer_dest_full, + sizeof(peer_dest_full)); if (shouldRequestPath(*peer_info)) { @@ -912,14 +983,20 @@ MeshSendResult LxmfAdapter::sendTextDetailed(ChannelId channel, const MessageId message_id = messageIdFromHash(message_hash); char message_hash_prefix[12] = {}; formatHashPrefix(message_hash, message_hash_prefix, sizeof(message_hash_prefix)); - Serial.printf("[LXMF][DirectTX] result ok=%u msg=%lu hash=%s peer=%08lX dest=%s path=%s payload_len=%u\n", + const auto& tx_result = interfaces_.lastTxResult(); + Serial.printf("[LXMF][DirectTX] result ok=%u msg=%lu hash=%s peer=%08lX name=\"%s\" dest=%s dest_full=%s path=%s bearer=%s complete=%u payload_len=%u text=\"%s\"\n", ok ? 1U : 0U, static_cast(message_id), message_hash_prefix, static_cast(peer_info->node_id), + peer_info->display_name[0] != '\0' ? peer_info->display_name : "", peer_hash, + peer_dest_full, send_path, - static_cast(packed_payload_len)); + txBearerName(tx_result), + tx_result.reachedRequiredInterfaces() ? 1U : 0U, + static_cast(packed_payload_len), + text_preview); MeshSendResult result = ok ? MeshSendResult::success(message_id) : MeshSendResult::fail(MeshOperationFailure::RadioTxFailed, message_id); @@ -935,13 +1012,22 @@ MeshSendResult LxmfAdapter::sendTextToReticulumDestination( const ReticulumPeerIdentity& destination) { char dest_hash[12] = {}; + char dest_full[(reticulum::kTruncatedHashSize * 2U) + 1U] = {}; + char text_preview[64] = {}; formatHashPrefix(destination.destination_hash, dest_hash, sizeof(dest_hash)); - Serial.printf("[LXMF][GroupTX] begin ch=%u forced=%lu dest=%s len=%u ready=%u\n", + formatHashHex(destination.destination_hash, + sizeof(destination.destination_hash), + dest_full, + sizeof(dest_full)); + formatLogTextPreview(text, text_preview, sizeof(text_preview)); + Serial.printf("[LXMF][GroupTX] begin ch=%u forced=%lu dest=%s dest_full=%s len=%u ready=%u text=\"%s\"\n", static_cast(channel), static_cast(forced_msg_id), dest_hash, + dest_full, static_cast(text.size()), - isReady() ? 1U : 0U); + isReady() ? 1U : 0U, + text_preview); if (channel != ChannelId::PRIMARY || text.empty() || !hasReticulumDestinationIdentity(destination)) @@ -1007,11 +1093,16 @@ MeshSendResult LxmfAdapter::sendTextToReticulumDestination( static_cast(packed_payload_len), static_cast(packet_len)); const bool ok = routeAndSendPacket(packet, packet_len, true); - Serial.printf("[LXMF][GroupTX] raw_send ok=%u msg=%lu dest=%s packet_len=%u\n", + const auto& tx_result = interfaces_.lastTxResult(); + Serial.printf("[LXMF][GroupTX] raw_send ok=%u msg=%lu dest=%s dest_full=%s bearer=%s complete=%u packet_len=%u text=\"%s\"\n", ok ? 1U : 0U, static_cast(message_id), dest_hash, - static_cast(packet_len)); + dest_full, + txBearerName(tx_result), + tx_result.reachedRequiredInterfaces() ? 1U : 0U, + static_cast(packet_len), + text_preview); MeshSendResult result = ok ? MeshSendResult::success(message_id) : MeshSendResult::fail(MeshOperationFailure::RadioTxFailed, message_id); @@ -2237,6 +2328,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len const bool delivery_announce = isLxmfDeliveryAnnounce(announce); const bool propagation_announce = isLxmfPropagationAnnounce(announce); const bool call_audio_announce = isCallAudioAnnounce(announce); + const bool nomad_node_announce = isNomadNetworkNodeAnnounce(announce); LocalDestinationKind local_kind = LocalDestinationKind::Delivery; const bool local_destination = isLocalDestinationHash(packet.destination_hash, &local_kind); @@ -2256,17 +2348,10 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len uint8_t stamp_cost = 0; if (call_audio_announce && announce.app_data && announce.app_data_len != 0) { - const size_t name_len = - std::min(announce.app_data_len, sizeof(announce_display_name) - 1U); - memcpy(announce_display_name, announce.app_data, name_len); - announce_display_name[name_len] = '\0'; - for (char& ch : announce_display_name) - { - if (ch == '\t' || ch == '\r' || ch == '\n') - { - ch = ' '; - } - } + (void)copyTextAppDataDisplayName(announce.app_data, + announce.app_data_len, + announce_display_name, + sizeof(announce_display_name)); } else if (delivery_announce && announce.app_data && announce.app_data_len != 0 && unpackPeerAnnounceAppData(announce.app_data, @@ -2279,7 +2364,15 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len (void)has_stamp_cost; (void)stamp_cost; } - else if (!(delivery_announce || propagation_announce || call_audio_announce) && + else if (nomad_node_announce && announce.app_data && announce.app_data_len != 0) + { + (void)copyTextAppDataDisplayName(announce.app_data, + announce.app_data_len, + announce_display_name, + sizeof(announce_display_name)); + } + else if (!(delivery_announce || propagation_announce || call_audio_announce || + nomad_node_announce) && announce.app_data && announce.app_data_len != 0) { (void)copyTextAppDataDisplayName(announce.app_data, @@ -2287,6 +2380,18 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len announce_display_name, sizeof(announce_display_name)); } + if ((delivery_announce || call_audio_announce) && announce_display_name[0] == '\0') + { + copyCString(announce_display_name, + sizeof(announce_display_name), + kAnonymousPeerDisplayName); + } + else if (nomad_node_announce && announce_display_name[0] == '\0') + { + copyCString(announce_display_name, + sizeof(announce_display_name), + kAnonymousNodeDisplayName); + } rtdir::AnnounceRecord directory_announce{}; directory_announce.valid = true; @@ -2302,7 +2407,9 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len : (propagation_announce ? rtdir::AnnounceAspect::LxmfPropagation : (call_audio_announce ? rtdir::AnnounceAspect::CallAudio - : rtdir::AnnounceAspect::Unknown)); + : (nomad_node_announce + ? rtdir::AnnounceAspect::NomadNetworkNode + : rtdir::AnnounceAspect::Unknown))); directory_announce.source = packet.context == static_cast(reticulum::PacketContext::PathResponse) ? rtdir::EntrySource::PathResponse @@ -2338,7 +2445,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len local_destination; if (log_announce_detail) { - Serial.printf("[LXMF][AnnounceRX] seen dest=%s identity=%s hops=%u context=%u app_len=%u delivery=%u propagation=%u call=%u local=%u kind=%s\n", + Serial.printf("[LXMF][AnnounceRX] seen dest=%s identity=%s hops=%u context=%u app_len=%u delivery=%u propagation=%u call=%u nomad=%u local=%u kind=%s\n", packet_hash_hex, identity_hash_hex, static_cast(packet.hops), @@ -2347,6 +2454,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len delivery_announce ? 1U : 0U, propagation_announce ? 1U : 0U, call_audio_announce ? 1U : 0U, + nomad_node_announce ? 1U : 0U, local_destination ? 1U : 0U, localDestinationKindLabel(local_kind)); } @@ -2406,8 +2514,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len } else if (peer.display_name[0] == '\0') { - snprintf(peer.display_name, sizeof(peer.display_name), - "%08lX", static_cast(peer.node_id)); + copyCString(peer.display_name, sizeof(peer.display_name), kAnonymousPeerDisplayName); } const bool address_refresh_due = @@ -6217,11 +6324,6 @@ LxmfAdapter::PeerInfo* LxmfAdapter::upsertPeerFromAddressRecord( peer.last_seen_s = record.last_seen_s != 0 ? record.last_seen_s : currentTimestampSeconds(); peer.last_path_request_ms = 0; copyCString(peer.display_name, sizeof(peer.display_name), record.display_name); - if (peer.display_name[0] == '\0') - { - snprintf(peer.display_name, sizeof(peer.display_name), - "%08lX", static_cast(peer.node_id)); - } if (queue_update) { queuePeerUpdate(peer); @@ -6417,7 +6519,7 @@ void LxmfAdapter::publishPeerUpdate(const PeerInfo& peer) const auto* node_event = new sys::NodeInfoUpdateEvent( peer.node_id, short_name, - peer.display_name[0] != '\0' ? peer.display_name : short_name, + peer.display_name, interfaces_.lastRxSnr(), interfaces_.lastRxRssi(), peer.last_seen_s, @@ -6533,12 +6635,6 @@ void LxmfAdapter::loadPersistedPeers() peer.last_path_request_ms = 0; copyCString(peer.display_name, sizeof(peer.display_name), record.display_name); - if (peer.display_name[0] == '\0') - { - snprintf(peer.display_name, sizeof(peer.display_name), - "%08lX", static_cast(peer.node_id)); - } - queuePeerUpdate(peer); } } diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp index cba6c018..6d6dfd4b 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp @@ -27,6 +27,23 @@ const char* boolLabel(bool value) return value ? "true" : "false"; } +const char* txBearerName(const TxResult& result) +{ + if (result.lora_ok && result.wifi_ok) + { + return "lora+wifi"; + } + if (result.lora_ok) + { + return "lora"; + } + if (result.wifi_ok) + { + return "wifi"; + } + return "none"; +} + bool copyHost(char* out, size_t out_len, const char* value) { if (!out || out_len == 0) @@ -670,8 +687,8 @@ void ReticulumInterfaceSet::maintain() bool ReticulumInterfaceSet::hasReadyInterface() const { - return (loraAllowed() && lora_.isReady()) || - (wifiAllowed() && wifi_.isReady()); + return (loraSelectedForRuntime() && lora_.isReady()) || + (wifiSelectedForRuntime() && wifi_.isReady()); } bool ReticulumInterfaceSet::hasReadyWifiGateway() const @@ -694,9 +711,9 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) maintain(); - last_tx_result_.lora_required = loraAllowed(); + last_tx_result_.lora_required = loraSelectedForRuntime(); last_tx_result_.lora_ready = last_tx_result_.lora_required && lora_.isReady(); - last_tx_result_.wifi_required = wifiAllowed() && wifi_.isConfigured(); + last_tx_result_.wifi_required = wifiSelectedForRuntime() && wifi_.isConfigured(); last_tx_result_.wifi_ready = last_tx_result_.wifi_required && wifi_.isReady(); if (last_tx_result_.lora_ready) @@ -709,15 +726,17 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) } const bool sent = last_tx_result_.sent(); - Serial.printf("[Reticulum][IF][TX] raw_len=%u lora_req=%u lora_ready=%u lora=%u wifi_req=%u wifi_ready=%u wifi=%u sent=%u\n", + Serial.printf("[Reticulum][IF][TX] raw_len=%u bearer=%s lora_req=%u lora_ready=%u lora=%u wifi_req=%u wifi_ready=%u wifi=%u sent=%u complete=%u\n", static_cast(len), + txBearerName(last_tx_result_), last_tx_result_.lora_required ? 1U : 0U, last_tx_result_.lora_ready ? 1U : 0U, last_tx_result_.lora_ok ? 1U : 0U, last_tx_result_.wifi_required ? 1U : 0U, last_tx_result_.wifi_ready ? 1U : 0U, last_tx_result_.wifi_ok ? 1U : 0U, - sent ? 1U : 0U); + sent ? 1U : 0U, + last_tx_result_.reachedRequiredInterfaces() ? 1U : 0U); return sent; } @@ -739,12 +758,14 @@ bool ReticulumInterfaceSet::sendPacketWifiOnly(const uint8_t* data, size_t len) } const bool sent = last_tx_result_.sent(); - Serial.printf("[Reticulum][IF][TX] raw_len=%u mode=wifi_only wifi_req=%u wifi_ready=%u wifi=%u sent=%u\n", + Serial.printf("[Reticulum][IF][TX] raw_len=%u mode=wifi_only bearer=%s wifi_req=%u wifi_ready=%u wifi=%u sent=%u complete=%u\n", static_cast(len), + txBearerName(last_tx_result_), last_tx_result_.wifi_required ? 1U : 0U, last_tx_result_.wifi_ready ? 1U : 0U, last_tx_result_.wifi_ok ? 1U : 0U, - sent ? 1U : 0U); + sent ? 1U : 0U, + last_tx_result_.reachedRequiredInterfaces() ? 1U : 0U); return sent; } @@ -757,17 +778,19 @@ bool ReticulumInterfaceSet::pollIncomingPacket(RxPacket* out) maintain(); + const bool lora_selected = loraSelectedForRuntime(); + const bool wifi_selected = wifiSelectedForRuntime(); for (uint8_t i = 0; i < 2; ++i) { const uint8_t index = static_cast((next_poll_index_ + i) % 2U); bool got = false; if (index == 0) { - got = loraAllowed() && lora_.pollPacket(out); + got = lora_selected && lora_.pollPacket(out); } else { - got = wifiAllowed() && wifi_.pollPacket(out); + got = wifi_selected && wifi_.pollPacket(out); } if (got) { @@ -783,12 +806,12 @@ bool ReticulumInterfaceSet::pollIncomingPacket(RxPacket* out) bool ReticulumInterfaceSet::pollLegacyIncomingData(MeshIncomingData* out) { - return lora_.pollLegacyIncomingData(out); + return loraSelectedForRuntime() && lora_.pollLegacyIncomingData(out); } void ReticulumInterfaceSet::handleRawPacket(const uint8_t* data, size_t size) { - if (loraAllowed()) + if (loraSelectedForRuntime()) { lora_.handleRawPacket(data, size); } @@ -829,4 +852,24 @@ bool ReticulumInterfaceSet::wifiAllowed() const config_.reticulum_interface_policy != ReticulumInterfacePolicy::LoRaOnly; } +bool ReticulumInterfaceSet::loraSelectedForRuntime() const +{ + if (!loraAllowed()) + { + return false; + } + return config_.reticulum_interface_policy != ReticulumInterfacePolicy::All || + !wifi_.isReady(); +} + +bool ReticulumInterfaceSet::wifiSelectedForRuntime() const +{ + if (!wifiAllowed()) + { + return false; + } + return config_.reticulum_interface_policy == ReticulumInterfacePolicy::WifiGatewayOnly || + wifi_.isReady(); +} + } // namespace chat::reticulum::interfaces diff --git a/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp b/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp index 956fc40c..c2e40563 100644 --- a/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp +++ b/platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp @@ -162,9 +162,21 @@ void SdStore::append(const ChatMessage& msg) } std::vector SdStore::loadRecent(const ConversationId& conv, size_t n) +{ + return loadPageFromLatest(conv, 0, n, nullptr); +} + +std::vector SdStore::loadPageFromLatest(const ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) { std::vector out; - if (!ready_ || n == 0) + if (total) + { + *total = 0; + } + if (!ready_ || limit == 0) { return out; } @@ -189,9 +201,22 @@ std::vector SdStore::loadRecent(const ConversationId& conv, size_t return out; } - const size_t to_read = std::min(n, header.count); + if (total) + { + *total = header.count; + } + if (offset_from_latest >= header.count) + { + file.close(); + return out; + } + + const size_t available = header.count - offset_from_latest; + const size_t to_read = std::min(limit, available); + const size_t logical_start = header.count - offset_from_latest - to_read; const uint16_t start = static_cast( - (header.head + kMaxMessagesPerConv - to_read) % kMaxMessagesPerConv); + (header.head + kMaxMessagesPerConv - header.count + logical_start) % + kMaxMessagesPerConv); out.reserve(to_read); for (size_t index = 0; index < to_read; ++index) diff --git a/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp index 2639d82a..80361824 100644 --- a/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_http_client_runtime.cpp @@ -16,7 +16,7 @@ namespace platform::ui::http_client namespace { -constexpr std::size_t kTlsLargeAllocThresholdBytes = 4096; +constexpr std::size_t kTlsLargeAllocThresholdBytes = 1024; bool http_buffer_prefers_psram(const Request& request) { diff --git a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp index ed0511ab..73542c11 100644 --- a/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_reticulum_directory_runtime.cpp @@ -42,10 +42,11 @@ constexpr TickType_t kAsyncBetweenFlushDelay = pdMS_TO_TICKS(250); constexpr TickType_t kAsyncMaintenanceClosedDelay = pdMS_TO_TICKS(15000); constexpr uint32_t kAsyncMaintenanceStableMs = 8000; constexpr uint32_t kAsyncDeferLogIntervalMs = 30000; -constexpr uint32_t kAsyncTaskStackBytes = 6 * 1024; +constexpr uint32_t kAsyncTaskStackBytes = 5 * 1024; constexpr UBaseType_t kAsyncTaskPriority = tskIDLE_PRIORITY + 1; constexpr const char* kMaintenanceDeferredMessage = "Reticulum directory maintenance deferred"; +constexpr uint32_t kAsyncTaskCreateFailLogIntervalMs = 10000; enum class PendingDirectoryKind : uint8_t { @@ -91,6 +92,7 @@ struct AsyncDirectoryState AsyncDirectoryState* s_async_state = nullptr; uint32_t s_maintenance_window_entered_ms = 0; uint32_t s_last_maintenance_defer_log_ms = 0; +uint32_t s_last_task_create_fail_log_ms = 0; using DirectoryIoGate = bool (*)(); @@ -425,6 +427,37 @@ void log_maintenance_deferred(const char* stage) ::platform::ui::screen::is_saver_active() ? 1 : 0); } +void log_async_task_create_failed(BaseType_t rc) +{ + const uint32_t now = monotonic_ms(); + if (s_last_task_create_fail_log_ms != 0 && + now - s_last_task_create_fail_log_ms < kAsyncTaskCreateFailLogIntervalMs) + { + return; + } + s_last_task_create_fail_log_ms = now; + std::printf("[Reticulum][Directory] async task_create_failed rc=%ld " + "internal_free=%u internal_largest=%u psram_free=%u psram_largest=%u\n", + static_cast(rc), + static_cast( + heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)), + static_cast( + heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)), + static_cast( + heap_caps_get_free_size(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)), + static_cast( + heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT))); +} + +void log_async_worker_stack(const char* stage) +{ + const UBaseType_t free_words = uxTaskGetStackHighWaterMark(nullptr); + std::printf("[Reticulum][Directory] async %s stack_free_words=%u stack_free_bytes=%u\n", + stage ? stage : "-", + static_cast(free_words), + static_cast(free_words * sizeof(StackType_t))); +} + AsyncDirectoryState* allocate_async_state() { void* storage = heap_caps_malloc_prefer(sizeof(AsyncDirectoryState), @@ -438,6 +471,32 @@ AsyncDirectoryState* allocate_async_state() return storage ? new (storage) AsyncDirectoryState() : nullptr; } +bool start_async_worker_task(AsyncDirectoryState& state) +{ + if (state.task) + { + return true; + } + if (!state.mutex || !state.signal) + { + return false; + } + + const BaseType_t ok = xTaskCreate(async_task_entry, + "rtdir_io", + kAsyncTaskStackBytes, + &state, + kAsyncTaskPriority, + &state.task); + if (ok != pdPASS) + { + log_async_task_create_failed(ok); + state.task = nullptr; + return false; + } + return true; +} + AsyncDirectoryState* ensure_async_worker() { if (!s_async_state) @@ -462,22 +521,12 @@ AsyncDirectoryState* ensure_async_worker() { state->signal = xQueueCreate(1, sizeof(uint8_t)); } - if (!state->task && state->mutex && state->signal) + if (!state->mutex || !state->signal) { - const BaseType_t ok = xTaskCreate(async_task_entry, - "rtdir_io", - kAsyncTaskStackBytes, - state, - kAsyncTaskPriority, - &state->task); - if (ok != pdPASS) - { - std::printf("[Reticulum][Directory] async task_create_failed rc=%ld\n", - static_cast(ok)); - state->task = nullptr; - } + return nullptr; } - return state->mutex && state->signal && state->task ? state : nullptr; + (void)start_async_worker_task(*state); + return state; } PendingAnnounce* find_announce_slot(AsyncDirectoryState& state, @@ -645,6 +694,11 @@ void requeue_active_address(AsyncDirectoryState& state) void signal_async_worker(AsyncDirectoryState& state) { + (void)start_async_worker_task(state); + if (!state.signal) + { + return; + } const uint8_t signal = 1; (void)xQueueOverwrite(state.signal, &signal); } @@ -657,6 +711,7 @@ void async_task_entry(void* context) vTaskDelete(nullptr); return; } + log_async_worker_stack("start"); uint8_t signal = 0; for (;;) @@ -1055,6 +1110,8 @@ std::string aspect_text(AnnounceAspect aspect) return "lxmf.propagation"; case AnnounceAspect::CallAudio: return "call.audio"; + case AnnounceAspect::NomadNetworkNode: + return "nomadnetwork.node"; case AnnounceAspect::Unknown: default: return "unknown"; @@ -1075,6 +1132,10 @@ AnnounceAspect parse_aspect(std::string_view aspect) { return AnnounceAspect::CallAudio; } + if (aspect == "nomadnetwork.node") + { + return AnnounceAspect::NomadNetworkNode; + } return AnnounceAspect::Unknown; } diff --git a/platform/esp/arduino_common/src/platform_ui_route_storage.cpp b/platform/esp/arduino_common/src/platform_ui_route_storage.cpp index 48b29ab5..44aabb92 100644 --- a/platform/esp/arduino_common/src/platform_ui_route_storage.cpp +++ b/platform/esp/arduino_common/src/platform_ui_route_storage.cpp @@ -32,7 +32,7 @@ constexpr const char* kRouteAssetThumbSubdir = "thumbs"; constexpr const char* kRouteAssetViewSubdir = "views"; constexpr std::size_t kRouteDownloadBufferSize = 512; constexpr int kRouteDownloadTxBufferSize = 512; -constexpr uint32_t kRouteImageWorkerStackBytes = 12 * 1024; +constexpr uint32_t kRouteImageWorkerStackBytes = 10 * 1024; constexpr UBaseType_t kRouteImageWorkerPriority = 2; constexpr uint16_t kRouteThumbWidth = 200; constexpr uint16_t kRouteThumbHeight = 120; @@ -41,9 +41,10 @@ constexpr uint16_t kRouteViewHeight = 180; constexpr std::size_t kRouteCacheInternalReserveBytes = 96 * 1024; constexpr std::size_t kRouteCacheInternalSlackBytes = 8 * 1024; constexpr std::size_t kRouteJpegDecoderWorkBytes = 3100; -constexpr std::size_t kRouteHttpInternalFreeTargetBytes = 72 * 1024; -constexpr std::size_t kRouteHttpInternalLargestTargetBytes = 24 * 1024; +constexpr std::size_t kRouteHttpInternalFreeTargetBytes = 48 * 1024; +constexpr std::size_t kRouteHttpInternalLargestTargetBytes = 12 * 1024; constexpr std::size_t kRouteImageDownloadAttempts = 3; +constexpr int kRouteImageMemoryWaitAttempts = 16; constexpr TickType_t kRouteImageRetryDelayTicks = pdMS_TO_TICKS(1600); constexpr TickType_t kRouteImageMemoryWaitTicks = pdMS_TO_TICKS(250); @@ -323,24 +324,25 @@ bool route_http_memory_ready() kRouteHttpInternalLargestTargetBytes; } -void wait_for_route_http_memory() +bool wait_for_route_http_memory() { if (route_http_memory_ready()) { - return; + return true; } log_route_http_memory("wait_begin"); - for (int attempt = 0; attempt < 8; ++attempt) + for (int attempt = 0; attempt < kRouteImageMemoryWaitAttempts; ++attempt) { vTaskDelay(kRouteImageMemoryWaitTicks); if (route_http_memory_ready()) { log_route_http_memory("wait_ready"); - return; + return true; } } log_route_http_memory("wait_continue"); + return false; } bool route_image_http_error_retryable(const std::string& error) @@ -348,6 +350,7 @@ bool route_image_http_error_retryable(const std::string& error) return error == "Open HTTP request failed" || error == "Fetch HTTP headers failed" || error == "Read HTTP response failed" || + error == "Low HTTP memory" || error == "Create HTTP client failed"; } @@ -946,8 +949,15 @@ void route_image_worker_task(void* param) RouteImageDownloadResult result{}; for (std::size_t attempt = 0; attempt < kRouteImageDownloadAttempts; ++attempt) { - wait_for_route_http_memory(); - result = download_route_image(item.url, item.output_path); + if (wait_for_route_http_memory()) + { + result = download_route_image(item.url, item.output_path); + } + else + { + result = {}; + result.error = "Low HTTP memory"; + } if (result.ok || !route_image_http_error_retryable(result.error) || attempt + 1U >= kRouteImageDownloadAttempts) @@ -1073,6 +1083,9 @@ void route_image_worker_task(void* param) s_image_batch.worker_task = nullptr; s_image_batch.launch_pending = false; } + std::vector().swap(download_items); + std::vector().swap(cache_items); + std::string().swap(asset_id); log_route_worker_stack("finish"); vTaskDelete(nullptr); } diff --git a/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp index e4736cf3..805ff3aa 100644 --- a/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_settings_backup_runtime.cpp @@ -56,6 +56,27 @@ constexpr ExtraKey kExtraKeys[] = { {"settings", "wifi_enabled", "wifi_enabled", ValueType::Bool}, {"settings", "wifi_ssid", "wifi_ssid", ValueType::String}, {"settings", "wifi_password", "wifi_password", ValueType::String}, + {"settings", "wifi_profile_count", "wifi_prof_count", ValueType::Int}, + {"settings", "wifi_ssid_0", "wifi_ssid_0", ValueType::String}, + {"settings", "wifi_password_0", "wifi_pass_0", ValueType::String}, + {"settings", "wifi_ssid_1", "wifi_ssid_1", ValueType::String}, + {"settings", "wifi_password_1", "wifi_pass_1", ValueType::String}, + {"settings", "wifi_ssid_2", "wifi_ssid_2", ValueType::String}, + {"settings", "wifi_password_2", "wifi_pass_2", ValueType::String}, + {"settings", "wifi_ssid_3", "wifi_ssid_3", ValueType::String}, + {"settings", "wifi_password_3", "wifi_pass_3", ValueType::String}, + {"settings", "wifi_ssid_4", "wifi_ssid_4", ValueType::String}, + {"settings", "wifi_password_4", "wifi_pass_4", ValueType::String}, + {"settings", "wifi_ssid_5", "wifi_ssid_5", ValueType::String}, + {"settings", "wifi_password_5", "wifi_pass_5", ValueType::String}, + {"settings", "wifi_ssid_6", "wifi_ssid_6", ValueType::String}, + {"settings", "wifi_password_6", "wifi_pass_6", ValueType::String}, + {"settings", "wifi_ssid_7", "wifi_ssid_7", ValueType::String}, + {"settings", "wifi_password_7", "wifi_pass_7", ValueType::String}, + {"settings", "wifi_ssid_8", "wifi_ssid_8", ValueType::String}, + {"settings", "wifi_password_8", "wifi_pass_8", ValueType::String}, + {"settings", "wifi_ssid_9", "wifi_ssid_9", ValueType::String}, + {"settings", "wifi_password_9", "wifi_pass_9", ValueType::String}, {"settings", "display_locale", "disp_locale", ValueType::String}, {"settings", "enabled_imes", "enabled_imes", ValueType::String}, {"settings", "timezone_offset", "timezone_offset", ValueType::Int}, diff --git a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp index c01f5968..61e5638b 100644 --- a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp @@ -18,6 +18,7 @@ namespace constexpr std::uint32_t kWakeProtectionMs = 6000; constexpr std::uint32_t kConnectBackoffMs = 10000; +constexpr std::uint32_t kForegroundDownloadSettleMs = 1500; constexpr std::size_t kClientCount = 6; struct RuntimeState @@ -27,6 +28,7 @@ struct RuntimeState Client owner = Client::Unknown; AccessKind active_kind = AccessKind::WifiConnect; std::uint32_t active_since_ms = 0; + std::uint32_t foreground_download_settle_until_ms = 0; std::uint32_t wake_protected_until_ms = 0; bool saw_screen_sample = false; bool last_sleeping = false; @@ -115,6 +117,11 @@ bool foreground_download_owner(Client client) return client == Client::RouteStorage; } +bool messaging_client(Client client) +{ + return client == Client::MeshMqtt || client == Client::ReticulumGateway; +} + bool foreground_download_active_for_other(const Request& request) { bool active = false; @@ -128,6 +135,19 @@ bool foreground_download_active_for_other(const Request& request) return active; } +bool foreground_download_settle_active_for(Client client, std::uint32_t now_ms) +{ + if (!messaging_client(client)) + { + return false; + } + std::uint32_t settle_until_ms = 0; + portENTER_CRITICAL(&s_lock); + settle_until_ms = s_state.foreground_download_settle_until_ms; + portEXIT_CRITICAL(&s_lock); + return static_cast(settle_until_ms - now_ms) > 0; +} + bool acquire_http(const Request& request, ScreenPhase phase, Lease& out) { if (::platform::ui::reticulum_call::realtime_mode_active()) @@ -195,8 +215,12 @@ bool long_lived_allowed(const Request& request, ScreenPhase phase, Lease& out) return false; } if (foreground_download_active_for_other(request) && - (request.client == Client::MeshMqtt || - request.client == Client::ReticulumGateway)) + messaging_client(request.client)) + { + out.decision = Decision::Busy; + return false; + } + if (foreground_download_settle_active_for(request.client, sys::millis_now())) { out.decision = Decision::Busy; return false; @@ -282,8 +306,11 @@ bool ensure_connected(const Request& request, Decision* out_decision) decision = Decision::OtaExclusive; } else if (foreground_download_active_for_other(request) && - (request.client == Client::MeshMqtt || - request.client == Client::ReticulumGateway)) + messaging_client(request.client)) + { + decision = Decision::Busy; + } + else if (foreground_download_settle_active_for(request.client, now_ms)) { decision = Decision::Busy; } @@ -389,17 +416,26 @@ void release(const Lease& lease) Client owner = Client::Unknown; AccessKind kind = AccessKind::WifiConnect; std::uint32_t held_ms = 0; + bool route_download_released = false; portENTER_CRITICAL(&s_lock); owner = s_state.owner; kind = s_state.active_kind; held_ms = s_state.active_since_ms == 0 ? 0 : now_ms - s_state.active_since_ms; if (s_state.http_active && owner == lease.client && kind == lease.kind) { + route_download_released = owner == Client::RouteStorage && + kind == AccessKind::HttpDownload && + !s_state.ota_active; s_state.http_active = false; s_state.ota_active = false; s_state.owner = Client::Unknown; s_state.active_kind = AccessKind::WifiConnect; s_state.active_since_ms = 0; + if (route_download_released) + { + s_state.foreground_download_settle_until_ms = + now_ms + kForegroundDownloadSettleMs; + } } portEXIT_CRITICAL(&s_lock); @@ -416,21 +452,27 @@ TrafficBudget traffic_budget(Client client, Priority priority) TrafficBudget budget = base_budget(client, phase); portENTER_CRITICAL(&s_lock); + const std::uint32_t now_ms = sys::millis_now(); const bool http_active = s_state.http_active; const bool ota = s_state.ota_active; const Client owner = s_state.owner; const AccessKind active_kind = s_state.active_kind; + const std::uint32_t foreground_settle_until_ms = + s_state.foreground_download_settle_until_ms; portEXIT_CRITICAL(&s_lock); - const bool messaging_client = - client == Client::MeshMqtt || client == Client::ReticulumGateway; const bool foreground_download_active = http_active && !ota && active_kind == AccessKind::HttpDownload && foreground_download_owner(owner); + const bool foreground_download_settle_active = + messaging_client(client) && + static_cast(foreground_settle_until_ms - now_ms) > 0; - if (ota || (foreground_download_active && messaging_client)) + if (ota || + ((foreground_download_active || foreground_download_settle_active) && + messaging_client(client))) { budget.allow_connect = false; budget.allow_read = false; diff --git a/platform/esp/arduino_common/src/ui_common.cpp b/platform/esp/arduino_common/src/ui_common.cpp index f18ff749..87648550 100644 --- a/platform/esp/arduino_common/src/ui_common.cpp +++ b/platform/esp/arduino_common/src/ui_common.cpp @@ -4,11 +4,13 @@ */ #include "ui/ui_common.h" -#include "board/BoardBase.h" +#include + #include "lvgl.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/ui/settings_store.h" #include "platform/ui/timezone_profile.h" +#include "ui/widgets/top_bar_power_presenter.h" #if LV_USE_SNAPSHOT extern "C" lv_draw_buf_t* lv_snapshot_take(lv_obj_t* obj, lv_color_format_t cf); extern "C" void lv_draw_buf_destroy(lv_draw_buf_t* draw_buf); @@ -18,8 +20,6 @@ extern "C" void lv_draw_buf_destroy(lv_draw_buf_t* draw_buf); #include #include -extern BoardBase& board; - namespace { constexpr const char* kPrefsNs = "settings"; @@ -52,11 +52,8 @@ void ensure_timezone_loaded() void ui_update_top_bar_battery(ui::widgets::TopBar& bar) { - char battery_buf[24] = "--"; - int battery = board.getBatteryLevel(); - bool charging = board.isCharging(); - ui_format_battery(battery, charging, battery_buf, sizeof(battery_buf)); - ui::widgets::top_bar_set_right_text_ascii(bar, battery_buf); + ui::widgets::top_bar_power::bind(bar); + ui::widgets::top_bar_power::tick(); } int ui_get_timezone_offset_min() diff --git a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h index be421502..d9323c3f 100644 --- a/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h +++ b/platform/esp/common/include/platform/esp/common/wifi_runtime_impl.h @@ -37,6 +37,8 @@ constexpr const char* kSettingsNs = "settings"; constexpr const char* kWifiEnabledKey = "wifi_enabled"; constexpr const char* kWifiSsidKey = "wifi_ssid"; constexpr const char* kWifiPasswordKey = "wifi_password"; +constexpr const char* kWifiProfileCountKey = "wifi_prof_count"; +constexpr std::size_t kWifiProfileCapacity = 10; constexpr uint32_t kBleRetryDelayMs = 180; constexpr int kWifiTxBufferTypeStatic = 0; constexpr int kWifiTxBufferTypeDynamic = 1; @@ -51,6 +53,8 @@ constexpr int kWifiRetryDynamicTxBufNum = 8; constexpr int kWifiRetryCacheTxBufNum = 4; constexpr int kWifiRetryMgmtSbufNum = 6; constexpr int kWifiRetryRxMgmtBufNum = 5; +constexpr std::size_t kWifiConnectMinInternalFreeBytes = 32U * 1024U; +constexpr std::size_t kWifiConnectMinInternalLargestBlockBytes = 8U * 1024U; enum class WifiInitProfile : uint8_t { @@ -66,11 +70,15 @@ struct RuntimeState bool wifi_initialized = false; bool ble_paused_for_wifi = false; bool config_cached = false; + bool profiles_cached = false; bool connected = false; bool connecting = false; bool scanning = false; int rssi = -127; Config config{}; + Config profiles[kWifiProfileCapacity] = {}; + std::size_t profile_count = 0; + std::size_t next_profile_index = 0; char ssid[kMaxSsidLength + 1] = {}; char ip[kMaxIpLength + 1] = {}; char message[kMaxStatusMessageLength + 1] = {}; @@ -111,6 +119,28 @@ void log_heap_snapshot(const char* stage) static_cast(psram_largest_block_bytes())); } +void set_status_message(const char* message); + +bool internal_memory_ready_for_wifi_connect(const char* stage) +{ + const std::size_t ram_free = internal_free_bytes(); + const std::size_t ram_largest = internal_largest_block_bytes(); + if (ram_free >= kWifiConnectMinInternalFreeBytes && + ram_largest >= kWifiConnectMinInternalLargestBlockBytes) + { + return true; + } + + std::printf("[WiFi][MEM] connect deferred stage=%s ram_free=%u ram_largest=%u min_free=%u min_largest=%u\n", + stage ? stage : "connect", + static_cast(ram_free), + static_cast(ram_largest), + static_cast(kWifiConnectMinInternalFreeBytes), + static_cast(kWifiConnectMinInternalLargestBlockBytes)); + set_status_message("Wi-Fi memory low"); + return false; +} + const char* wifi_profile_name(WifiInitProfile profile) { switch (profile) @@ -196,6 +226,124 @@ void cache_config(const Config& config) s_runtime.config_cached = true; } +bool has_saved_credentials(const Config& config); + +void profile_key(char* out, std::size_t out_len, const char* field, std::size_t index) +{ + if (!out || out_len == 0) + { + return; + } + std::snprintf(out, out_len, "wifi_%s_%u", field ? field : "profile", static_cast(index)); +} + +bool same_ssid(const Config& lhs, const Config& rhs) +{ + return std::strncmp(lhs.ssid, rhs.ssid, sizeof(lhs.ssid)) == 0; +} + +void clear_profiles() +{ + for (Config& profile : s_runtime.profiles) + { + profile = Config{}; + } + s_runtime.profile_count = 0; + s_runtime.next_profile_index = 0; + s_runtime.profiles_cached = true; +} + +void append_profile_unique(const Config& profile) +{ + if (!has_saved_credentials(profile)) + { + return; + } + for (std::size_t i = 0; i < s_runtime.profile_count; ++i) + { + if (same_ssid(s_runtime.profiles[i], profile)) + { + return; + } + } + if (s_runtime.profile_count >= kWifiProfileCapacity) + { + return; + } + s_runtime.profiles[s_runtime.profile_count] = profile; + s_runtime.profiles[s_runtime.profile_count].enabled = true; + ++s_runtime.profile_count; +} + +void upsert_profile_front(const Config& profile) +{ + if (!has_saved_credentials(profile)) + { + return; + } + + std::size_t existing = s_runtime.profile_count; + for (std::size_t i = 0; i < s_runtime.profile_count; ++i) + { + if (same_ssid(s_runtime.profiles[i], profile)) + { + existing = i; + break; + } + } + + std::size_t last = s_runtime.profile_count; + if (existing < s_runtime.profile_count) + { + last = existing; + } + else if (s_runtime.profile_count < kWifiProfileCapacity) + { + last = s_runtime.profile_count; + ++s_runtime.profile_count; + } + else + { + last = kWifiProfileCapacity - 1U; + } + + for (std::size_t i = last; i > 0; --i) + { + s_runtime.profiles[i] = s_runtime.profiles[i - 1U]; + } + s_runtime.profiles[0] = profile; + s_runtime.profiles[0].enabled = true; + s_runtime.next_profile_index = 0; + s_runtime.profiles_cached = true; +} + +bool persist_profiles() +{ + ::platform::ui::settings_store::put_int(kSettingsNs, + kWifiProfileCountKey, + static_cast(s_runtime.profile_count)); + bool ok = true; + for (std::size_t i = 0; i < kWifiProfileCapacity; ++i) + { + char ssid_key[24] = {}; + char password_key[24] = {}; + profile_key(ssid_key, sizeof(ssid_key), "ssid", i); + profile_key(password_key, sizeof(password_key), "password", i); + const Config& profile = s_runtime.profiles[i]; + ok = ::platform::ui::settings_store::put_string( + kSettingsNs, + ssid_key, + i < s_runtime.profile_count ? profile.ssid : "") && + ok; + ok = ::platform::ui::settings_store::put_string( + kSettingsNs, + password_key, + i < s_runtime.profile_count ? profile.password : "") && + ok; + } + return ok; +} + bool read_config_from_store(Config& out) { out = Config{}; @@ -213,6 +361,38 @@ bool read_config_from_store(Config& out) copy_bounded(out.password, sizeof(out.password), value.c_str()); } + clear_profiles(); + const int stored_count = std::clamp( + ::platform::ui::settings_store::get_int(kSettingsNs, kWifiProfileCountKey, 0), + 0, + static_cast(kWifiProfileCapacity)); + for (int i = 0; i < stored_count; ++i) + { + Config profile{}; + profile.enabled = true; + char ssid_key[24] = {}; + char password_key[24] = {}; + profile_key(ssid_key, sizeof(ssid_key), "ssid", static_cast(i)); + profile_key(password_key, sizeof(password_key), "password", static_cast(i)); + value.clear(); + if (::platform::ui::settings_store::get_string(kSettingsNs, ssid_key, value)) + { + copy_bounded(profile.ssid, sizeof(profile.ssid), value.c_str()); + } + value.clear(); + if (::platform::ui::settings_store::get_string(kSettingsNs, password_key, value)) + { + copy_bounded(profile.password, sizeof(profile.password), value.c_str()); + } + append_profile_unique(profile); + } + append_profile_unique(out); + if (s_runtime.profile_count > 0) + { + copy_bounded(out.ssid, sizeof(out.ssid), s_runtime.profiles[0].ssid); + copy_bounded(out.password, sizeof(out.password), s_runtime.profiles[0].password); + } + cache_config(out); return true; } @@ -632,6 +812,13 @@ bool load_config(Config& out) bool save_config(const Config& config) { + if (!s_runtime.profiles_cached) + { + Config ignored{}; + (void)read_config_from_store(ignored); + } + upsert_profile_front(config); + const bool profiles_ok = persist_profiles(); const bool ssid_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiSsidKey, config.ssid); const bool password_ok = @@ -639,7 +826,31 @@ bool save_config(const Config& config) ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, config.enabled); cache_config(config); refresh_runtime_status_message(); - return ssid_ok && password_ok; + return profiles_ok && ssid_ok && password_ok; +} + +bool find_saved_config(const char* ssid, Config& out) +{ + out = Config{}; + if (!ssid || ssid[0] == '\0') + { + return false; + } + if (!s_runtime.profiles_cached) + { + Config ignored{}; + (void)read_config_from_store(ignored); + } + for (std::size_t i = 0; i < s_runtime.profile_count; ++i) + { + if (std::strncmp(s_runtime.profiles[i].ssid, ssid, sizeof(s_runtime.profiles[i].ssid)) == 0) + { + out = s_runtime.profiles[i]; + out.enabled = s_runtime.config_cached ? s_runtime.config.enabled : out.enabled; + return true; + } + } + return false; } bool apply_enabled(bool enabled) @@ -690,18 +901,8 @@ bool apply_enabled(bool enabled) return true; } -bool connect(const Config* override_config) +static bool connect_single_profile(const Config& config) { - Config config{}; - if (override_config) - { - config = *override_config; - } - else - { - (void)load_config(config); - } - if (!config.enabled) { set_status_message("Enable Wi-Fi first"); @@ -719,6 +920,11 @@ bool connect(const Config* override_config) return false; } + if (!internal_memory_ready_for_wifi_connect("before esp_wifi_connect")) + { + return false; + } + wifi_config_t wifi_config{}; copy_bounded(reinterpret_cast(wifi_config.sta.ssid), sizeof(wifi_config.sta.ssid), @@ -737,6 +943,7 @@ bool connect(const Config* override_config) return false; } + cache_config(config); clear_connection_details(); s_runtime.connecting = true; refresh_runtime_status_message(); @@ -771,6 +978,43 @@ bool connect(const Config* override_config) return false; } +bool connect(const Config* override_config) +{ + if (override_config) + { + return connect_single_profile(*override_config); + } + + Config config{}; + (void)load_config(config); + if (!config.enabled) + { + set_status_message("Enable Wi-Fi first"); + return false; + } + if (s_runtime.profile_count == 0) + { + set_status_message("SSID is not set"); + return false; + } + + const std::size_t index = s_runtime.next_profile_index % s_runtime.profile_count; + Config candidate = s_runtime.profiles[index]; + candidate.enabled = config.enabled; + std::printf("[WiFi] auto connect profile index=%u/%u ssid=%s\n", + static_cast(index + 1U), + static_cast(s_runtime.profile_count), + candidate.ssid); + const bool connected = connect_single_profile(candidate); + if (connected) + { + s_runtime.next_profile_index = index; + return true; + } + s_runtime.next_profile_index = (index + 1U) % s_runtime.profile_count; + return false; +} + void disconnect() { if (s_runtime.wifi_started) diff --git a/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp b/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp index 6ec57568..3812c515 100644 --- a/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp +++ b/platform/esp/idf_common/src/platform_ui_wifi_runtime.cpp @@ -2,6 +2,7 @@ #include "platform/ui/wifi_runtime.h" +#include #include #include #include @@ -30,12 +31,24 @@ constexpr const char* kSettingsNs = "settings"; constexpr const char* kWifiEnabledKey = "wifi_enabled"; constexpr const char* kWifiSsidKey = "wifi_ssid"; constexpr const char* kWifiPasswordKey = "wifi_password"; +constexpr const char* kWifiProfileCountKey = "wifi_prof_count"; +constexpr std::size_t kWifiProfileCapacity = 10; constexpr uint32_t kWifiFeatureMask = TM_C6_FEATURE_WIFI_STA | TM_C6_FEATURE_WIFI_AP; #if defined(TRAIL_MATE_WIFI_RUNTIME_C6_ASYNC_SCAN) constexpr uint32_t kC6ScanWaitTimeoutMs = 10000; constexpr uint32_t kC6ScanPollIntervalMs = 100; #endif +struct RuntimeState +{ + bool profiles_cached = false; + Config profiles[kWifiProfileCapacity] = {}; + std::size_t profile_count = 0; + std::size_t next_profile_index = 0; +}; + +RuntimeState s_runtime{}; + void copy_text(char* out, std::size_t out_len, const char* text) { if (!out || out_len == 0) @@ -50,6 +63,127 @@ void copy_config_text(char* out, std::size_t out_len, const char* text) copy_text(out, out_len, text && text[0] != '\0' ? text : ""); } +bool has_saved_credentials(const Config& config) +{ + return config.ssid[0] != '\0'; +} + +void profile_key(char* out, std::size_t out_len, const char* field, std::size_t index) +{ + if (!out || out_len == 0) + { + return; + } + std::snprintf(out, out_len, "wifi_%s_%u", field ? field : "profile", static_cast(index)); +} + +bool same_ssid(const Config& lhs, const Config& rhs) +{ + return std::strncmp(lhs.ssid, rhs.ssid, sizeof(lhs.ssid)) == 0; +} + +void clear_profiles() +{ + for (Config& profile : s_runtime.profiles) + { + profile = Config{}; + } + s_runtime.profile_count = 0; + s_runtime.next_profile_index = 0; + s_runtime.profiles_cached = true; +} + +void append_profile_unique(const Config& profile) +{ + if (!has_saved_credentials(profile)) + { + return; + } + for (std::size_t i = 0; i < s_runtime.profile_count; ++i) + { + if (same_ssid(s_runtime.profiles[i], profile)) + { + return; + } + } + if (s_runtime.profile_count >= kWifiProfileCapacity) + { + return; + } + s_runtime.profiles[s_runtime.profile_count] = profile; + s_runtime.profiles[s_runtime.profile_count].enabled = true; + ++s_runtime.profile_count; +} + +void upsert_profile_front(const Config& profile) +{ + if (!has_saved_credentials(profile)) + { + return; + } + + std::size_t existing = s_runtime.profile_count; + for (std::size_t i = 0; i < s_runtime.profile_count; ++i) + { + if (same_ssid(s_runtime.profiles[i], profile)) + { + existing = i; + break; + } + } + + std::size_t last = s_runtime.profile_count; + if (existing < s_runtime.profile_count) + { + last = existing; + } + else if (s_runtime.profile_count < kWifiProfileCapacity) + { + last = s_runtime.profile_count; + ++s_runtime.profile_count; + } + else + { + last = kWifiProfileCapacity - 1U; + } + + for (std::size_t i = last; i > 0; --i) + { + s_runtime.profiles[i] = s_runtime.profiles[i - 1U]; + } + s_runtime.profiles[0] = profile; + s_runtime.profiles[0].enabled = true; + s_runtime.next_profile_index = 0; + s_runtime.profiles_cached = true; +} + +bool persist_profiles() +{ + ::platform::ui::settings_store::put_int(kSettingsNs, + kWifiProfileCountKey, + static_cast(s_runtime.profile_count)); + bool ok = true; + for (std::size_t i = 0; i < kWifiProfileCapacity; ++i) + { + char ssid_key[24] = {}; + char password_key[24] = {}; + profile_key(ssid_key, sizeof(ssid_key), "ssid", i); + profile_key(password_key, sizeof(password_key), "password", i); + const Config& profile = s_runtime.profiles[i]; + ok = ::platform::ui::settings_store::put_string( + kSettingsNs, + ssid_key, + i < s_runtime.profile_count ? profile.ssid : "") && + ok; + ok = ::platform::ui::settings_store::put_string( + kSettingsNs, + password_key, + i < s_runtime.profile_count ? profile.password : "") && + ok; + } + return ok; +} + void format_ipv4(uint32_t addr, char* out, std::size_t out_len) { if (!out || out_len == 0) @@ -86,17 +220,54 @@ Config load_saved_config() { copy_text(out.password, sizeof(out.password), value.c_str()); } + clear_profiles(); + const int stored_count = std::clamp( + ::platform::ui::settings_store::get_int(kSettingsNs, kWifiProfileCountKey, 0), + 0, + static_cast(kWifiProfileCapacity)); + for (int i = 0; i < stored_count; ++i) + { + Config profile{}; + profile.enabled = true; + char ssid_key[24] = {}; + char password_key[24] = {}; + profile_key(ssid_key, sizeof(ssid_key), "ssid", static_cast(i)); + profile_key(password_key, sizeof(password_key), "password", static_cast(i)); + value.clear(); + if (::platform::ui::settings_store::get_string(kSettingsNs, ssid_key, value)) + { + copy_text(profile.ssid, sizeof(profile.ssid), value.c_str()); + } + value.clear(); + if (::platform::ui::settings_store::get_string(kSettingsNs, password_key, value)) + { + copy_text(profile.password, sizeof(profile.password), value.c_str()); + } + append_profile_unique(profile); + } + append_profile_unique(out); + if (s_runtime.profile_count > 0) + { + copy_text(out.ssid, sizeof(out.ssid), s_runtime.profiles[0].ssid); + copy_text(out.password, sizeof(out.password), s_runtime.profiles[0].password); + } return out; } bool save_saved_config(const Config& config) { + if (!s_runtime.profiles_cached) + { + (void)load_saved_config(); + } + upsert_profile_front(config); + const bool profiles_ok = persist_profiles(); const bool ssid_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiSsidKey, config.ssid); const bool password_ok = ::platform::ui::settings_store::put_string(kSettingsNs, kWifiPasswordKey, config.password); ::platform::ui::settings_store::put_bool(kSettingsNs, kWifiEnabledKey, config.enabled); - return ssid_ok && password_ok; + return profiles_ok && ssid_ok && password_ok; } c6::WifiCompanionConfig make_companion_wifi_config(const Config& config) @@ -196,6 +367,28 @@ bool save_config(const Config& config) return save_saved_config(config); } +bool find_saved_config(const char* ssid, Config& out) +{ + out = Config{}; + if (!ssid || ssid[0] == '\0') + { + return false; + } + if (!s_runtime.profiles_cached) + { + (void)load_saved_config(); + } + for (std::size_t i = 0; i < s_runtime.profile_count; ++i) + { + if (std::strncmp(s_runtime.profiles[i].ssid, ssid, sizeof(s_runtime.profiles[i].ssid)) == 0) + { + out = s_runtime.profiles[i]; + return true; + } + } + return false; +} + bool apply_enabled(bool enabled) { Config config = load_saved_config(); @@ -211,8 +404,23 @@ bool apply_enabled(bool enabled) bool connect(const Config* override_config) { Config config = override_config ? *override_config : load_saved_config(); + if (!override_config && s_runtime.profile_count > 0) + { + const std::size_t index = s_runtime.next_profile_index % s_runtime.profile_count; + config = s_runtime.profiles[index]; + config.enabled = true; + std::printf("[WiFi][C6] auto connect profile index=%u/%u ssid=%s\n", + static_cast(index + 1U), + static_cast(s_runtime.profile_count), + config.ssid); + s_runtime.next_profile_index = (index + 1U) % s_runtime.profile_count; + } config.enabled = true; - if (!save_saved_config(config) || !c6_present()) + if (override_config && !save_saved_config(config)) + { + return false; + } + if (!has_saved_credentials(config) || !c6_present()) { return false; } diff --git a/platform/esp/idf_common/src/ui_common.cpp b/platform/esp/idf_common/src/ui_common.cpp index bdca3f2c..82e8cea5 100644 --- a/platform/esp/idf_common/src/ui_common.cpp +++ b/platform/esp/idf_common/src/ui_common.cpp @@ -8,6 +8,7 @@ #include "platform/ui/device_runtime.h" #include "platform/ui/settings_store.h" #include "platform/ui/timezone_profile.h" +#include "ui/widgets/top_bar_power_presenter.h" extern "C" lv_draw_buf_t* lv_snapshot_take(lv_obj_t* obj, lv_color_format_t cf); extern "C" void lv_draw_buf_destroy(lv_draw_buf_t* draw_buf); @@ -45,16 +46,8 @@ void ensure_timezone_loaded() void ui_update_top_bar_battery(ui::widgets::TopBar& bar) { - const platform::ui::device::BatteryInfo info = platform::ui::device::battery_info(); - if (info.level < 0) - { - ui::widgets::top_bar_set_right_text_ascii(bar, info.charging ? "USB" : "--"); - return; - } - - char battery_buf[32] = "--"; - ui_format_battery(info.level, info.charging, battery_buf, sizeof(battery_buf)); - ui::widgets::top_bar_set_right_text_ascii(bar, battery_buf); + ui::widgets::top_bar_power::bind(bar); + ui::widgets::top_bar_power::tick(); } int ui_get_timezone_offset_min() diff --git a/platform/linux/common/include/chat/linux_sqlite_chat_store.h b/platform/linux/common/include/chat/linux_sqlite_chat_store.h index 7d8da6b3..ef76d8ad 100644 --- a/platform/linux/common/include/chat/linux_sqlite_chat_store.h +++ b/platform/linux/common/include/chat/linux_sqlite_chat_store.h @@ -18,6 +18,11 @@ class LinuxSqliteChatStore final : public ::chat::IChatStore std::vector<::chat::ChatMessage> loadRecent( const ::chat::ConversationId& conv, std::size_t n) override; + std::vector<::chat::ChatMessage> loadPageFromLatest( + const ::chat::ConversationId& conv, + std::size_t offset_from_latest, + std::size_t limit, + std::size_t* total) override; std::vector<::chat::ConversationMeta> loadConversationPage( std::size_t offset, std::size_t limit, diff --git a/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp b/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp index 8efcbdae..f838386b 100644 --- a/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp +++ b/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp @@ -657,7 +657,20 @@ std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadRecent( const ::chat::ConversationId& conv, std::size_t n) { - if (n == 0U) + return loadPageFromLatest(conv, 0, n, nullptr); +} + +std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadPageFromLatest( + const ::chat::ConversationId& conv, + std::size_t offset_from_latest, + std::size_t limit, + std::size_t* total) +{ + if (total != nullptr) + { + *total = 0; + } + if (limit == 0U) { return {}; } @@ -670,6 +683,13 @@ std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadRecent( } sqlite3_stmt* stmt = nullptr; + constexpr const char* kLegacyCountSql = + "SELECT COUNT(*) FROM chat_messages " + "WHERE protocol=?1 AND channel=?2 AND peer=?3;"; + constexpr const char* kReticulumCountSql = + "SELECT COUNT(*) FROM chat_messages " + "WHERE protocol=?1 AND channel=?2 AND " + "reticulum_identity_valid != 0 AND reticulum_destination_hash=?3;"; constexpr const char* kLegacySql = "SELECT protocol, channel, peer, from_node, msg_id, timestamp, text, " "team_location_icon, has_geo, geo_lat_e7, geo_lon_e7, status, " @@ -677,7 +697,7 @@ std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadRecent( "reticulum_identity_hash " "FROM chat_messages " "WHERE protocol=?1 AND channel=?2 AND peer=?3 " - "ORDER BY sequence DESC LIMIT ?4;"; + "ORDER BY sequence DESC LIMIT ?4 OFFSET ?5;"; constexpr const char* kReticulumSql = "SELECT protocol, channel, peer, from_node, msg_id, timestamp, text, " "team_location_icon, has_geo, geo_lat_e7, geo_lon_e7, status, " @@ -686,8 +706,39 @@ std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadRecent( "FROM chat_messages " "WHERE protocol=?1 AND channel=?2 AND " "reticulum_identity_valid != 0 AND reticulum_destination_hash=?3 " - "ORDER BY sequence DESC LIMIT ?4;"; + "ORDER BY sequence DESC LIMIT ?4 OFFSET ?5;"; const bool use_reticulum_key = hasReticulumConversationKey(conv); + + if (total != nullptr) + { + const char* count_sql = + use_reticulum_key ? kReticulumCountSql : kLegacyCountSql; + if (sqlite3_prepare_v2(handle.db, + count_sql, + -1, + &stmt, + nullptr) == SQLITE_OK) + { + const bool bound = + use_reticulum_key + ? (sqlite3_bind_int(stmt, 1, protocolValue(conv.protocol)) == + SQLITE_OK && + sqlite3_bind_int(stmt, 2, channelValue(conv.channel)) == + SQLITE_OK && + bindReticulumDestinationHash( + stmt, 3, conv.reticulum_identity)) + : bindConversation(stmt, 1, conv); + if (bound && sqlite3_step(stmt) == SQLITE_ROW) + { + *total = static_cast( + std::max( + 0, sqlite3_column_int64(stmt, 0))); + } + } + sqlite3_finalize(stmt); + stmt = nullptr; + } + const char* sql = use_reticulum_key ? kReticulumSql : kLegacySql; if (sqlite3_prepare_v2(handle.db, sql, -1, &stmt, nullptr) != SQLITE_OK) { @@ -703,13 +754,27 @@ std::vector<::chat::ChatMessage> LinuxSqliteChatStore::loadRecent( sqlite3_bind_int(stmt, 2, channelValue(conv.channel)) == SQLITE_OK && bindReticulumDestinationHash(stmt, 3, conv.reticulum_identity) && - sqlite3_bind_int64(stmt, 4, static_cast(n)) == + sqlite3_bind_int64(stmt, + 4, + static_cast(limit)) == + SQLITE_OK && + sqlite3_bind_int64( + stmt, + 5, + static_cast(offset_from_latest)) == SQLITE_OK; } else { bound = bindConversation(stmt, 1, conv) && - sqlite3_bind_int64(stmt, 4, static_cast(n)) == + sqlite3_bind_int64(stmt, + 4, + static_cast(limit)) == + SQLITE_OK && + sqlite3_bind_int64( + stmt, + 5, + static_cast(offset_from_latest)) == SQLITE_OK; } if (bound) diff --git a/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp b/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp index 769c8167..dd8aa20c 100644 --- a/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp +++ b/platform/linux/common/src/platform/ui/reticulum_directory_runtime.cpp @@ -419,6 +419,8 @@ std::string aspect_text(AnnounceAspect aspect) return "lxmf.propagation"; case AnnounceAspect::CallAudio: return "call.audio"; + case AnnounceAspect::NomadNetworkNode: + return "nomadnetwork.node"; case AnnounceAspect::Unknown: default: return "unknown"; @@ -439,6 +441,10 @@ AnnounceAspect parse_aspect(std::string_view aspect) { return AnnounceAspect::CallAudio; } + if (aspect == "nomadnetwork.node") + { + return AnnounceAspect::NomadNetworkNode; + } return AnnounceAspect::Unknown; } diff --git a/platform/linux/common/src/platform/ui/wifi_runtime.cpp b/platform/linux/common/src/platform/ui/wifi_runtime.cpp index aa46f3b9..25d705c1 100644 --- a/platform/linux/common/src/platform/ui/wifi_runtime.cpp +++ b/platform/linux/common/src/platform/ui/wifi_runtime.cpp @@ -68,6 +68,19 @@ bool save_config(const Config& config) return ssid_ok && password_ok; } +bool find_saved_config(const char* ssid, Config& out) +{ + out = Config{}; + Config config{}; + (void)load_config(config); + if (ssid && ssid[0] != '\0' && std::strcmp(config.ssid, ssid) == 0) + { + out = config; + return true; + } + return false; +} + bool apply_enabled(bool enabled) { return !enabled; diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h index 57534a00..383a8978 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/chat/infra/store/internal_fs_store.h @@ -18,6 +18,10 @@ class InternalFsStore final : public ::chat::IChatStore void append(const ::chat::ChatMessage& msg) override; std::vector<::chat::ChatMessage> loadRecent(const ::chat::ConversationId& conv, size_t n) override; + std::vector<::chat::ChatMessage> loadPageFromLatest(const ::chat::ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) override; std::vector<::chat::ConversationMeta> loadConversationPage(size_t offset, size_t limit, size_t* total) override; diff --git a/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp b/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp index aa974230..a5b8635e 100644 --- a/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp +++ b/platform/nrf52/arduino_common/src/chat/infra/store/internal_fs_store.cpp @@ -76,13 +76,34 @@ void InternalFsStore::append(const ::chat::ChatMessage& msg) } std::vector<::chat::ChatMessage> InternalFsStore::loadRecent(const ::chat::ConversationId& conv, size_t n) +{ + return loadPageFromLatest(conv, 0, n, nullptr); +} + +std::vector<::chat::ChatMessage> InternalFsStore::loadPageFromLatest( + const ::chat::ConversationId& conv, + size_t offset_from_latest, + size_t limit, + size_t* total) { const ConversationStorage& storage = getConversationStorage(conv); std::vector<::chat::ChatMessage> result; size_t count = storage.messages.size(); - size_t start = (count > n) ? (count - n) : 0; - for (size_t i = start; i < count; ++i) + if (total) + { + *total = count; + } + if (limit == 0 || offset_from_latest >= count) + { + return result; + } + + const size_t available = count - offset_from_latest; + const size_t to_read = std::min(limit, available); + size_t start = count - offset_from_latest - to_read; + const size_t end = start + to_read; + for (size_t i = start; i < end; ++i) { result.push_back(storage.messages[i].message); } diff --git a/platform/nrf52/arduino_common/src/platform_ui_wifi_runtime.cpp b/platform/nrf52/arduino_common/src/platform_ui_wifi_runtime.cpp index 7478a638..73a35b05 100644 --- a/platform/nrf52/arduino_common/src/platform_ui_wifi_runtime.cpp +++ b/platform/nrf52/arduino_common/src/platform_ui_wifi_runtime.cpp @@ -22,6 +22,13 @@ bool save_config(const Config& config) return false; } +bool find_saved_config(const char* ssid, Config& out) +{ + (void)ssid; + out = Config{}; + return false; +} + bool apply_enabled(bool enabled) { (void)enabled; diff --git a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp index 78c67eaf..c275d09f 100644 --- a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp +++ b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp @@ -55,7 +55,7 @@ constexpr const char* kProofPacket = constexpr const char* kTextPayload = "94cb40934a0000000000c405547261696cc40f68656c6c6f207265746963756c756d80"; constexpr const char* kPeerAnnounceAppData = - "92a87669636c69752d31c0"; + "92c4087669636c69752d31c0"; constexpr const char* kAppDataPayload = "544d4150010100001234aabbccdd01020304deadbeef"; constexpr const char* kMessageHash = @@ -385,6 +385,30 @@ void expectLxmfEnvelopeVectors() assert(!has_stamp_cost); assert(stamp_cost == 0U); + const std::vector lxmf_current_announce = + fromHex("93c4087669636c69752d31c09101"); + assert(lxmf::unpackPeerAnnounceAppData(lxmf_current_announce.data(), + lxmf_current_announce.size(), + display_name, + sizeof(display_name), + &has_stamp_cost, + &stamp_cost)); + assert(std::strcmp(display_name, "vicliu-1") == 0); + assert(!has_stamp_cost); + assert(stamp_cost == 0U); + + const std::vector raw_utf8_announce = + fromHex("7669636c69752d31"); + assert(lxmf::unpackPeerAnnounceAppData(raw_utf8_announce.data(), + raw_utf8_announce.size(), + display_name, + sizeof(display_name), + &has_stamp_cost, + &stamp_cost)); + assert(std::strcmp(display_name, "vicliu-1") == 0); + assert(!has_stamp_cost); + assert(stamp_cost == 0U); + const std::vector legacy_bin_announce = fromHex("92c4087669636c69752d31c0"); assert(lxmf::unpackPeerAnnounceAppData(legacy_bin_announce.data(),