From f2ff45283d388d3f7db3a08009a9ccdc678b4a7b Mon Sep 17 00:00:00 2001 From: liu weikai Date: Sun, 19 Jul 2026 22:41:51 +0800 Subject: [PATCH] Refactor Reticulum runtime ownership boundaries Add Reticulum runtime budget, announce scheduling, deferred discovery, RX telemetry, and adapter scratch owners. Route notification feedback through a platform notification runtime, keep product calls on Sideband LXST, and classify Reticulum network projections. Unify LoRa TX budget handling across protocol actions, ACK retry, app sends, and MQTT downlink relay; document frozen runtime ownership rules and add smoke coverage. --- ...sp32_lvgl_font_hot_path_contract_smoke.cpp | 2 + apps/linux_sim_shell/CMakeLists.txt | 81 +++ .../tests/uconsole_chat_dedup_smoke.cpp | 3 +- docs/MULTI_PROTOCOL_SUPPORT.md | 22 +- docs/RETICULUM_CONFORMANCE_BASELINE.md | 2 +- docs/protocol_runtime_budget_policy.md | 71 +- docs/reticulum_client_architecture.md | 83 ++- .../CHAT_DELIVERY_FEEDBACK_SPEC.md | 11 +- .../CHAT_DELIVERY_RUNTIME_SPEC.md | 58 +- .../CHAT_PRESENTATION_IDENTITY_SPEC.md | 10 + .../CHAT_RUNTIME_EVENT_PUMP_SPEC.md | 15 +- .../CHAT_WORKSPACE_MODEL_SPEC.md | 41 ++ docs/specification/LOCALE_PACKS.md | 13 +- docs/specification/LOCALIZATION_SPEC.md | 44 +- .../POST_REFACTOR_ARCHITECTURE_FREEZE.md | 9 + .../PROTOCOL_RUNTIME_DESIGN_SPEC.md | 12 + .../RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md | 563 +++++++++++++++ docs/ui_localization_plan.md | 4 +- docs/wifi_access_resource_policy.md | 5 + .../include/chat/infra/store/ram_store.h | 2 +- .../include/chat/ports/i_chat_store.h | 2 +- .../chat/read/chat_read_state_ledger.h | 22 + .../include/chat/usecase/chat_service.h | 4 +- .../core_chat/src/infra/store/ram_store.cpp | 3 +- .../src/read/chat_read_state_ledger.cpp | 30 + .../core_chat/src/usecase/chat_service.cpp | 6 +- .../tests/test_chat_read_state_ledger.cpp | 88 +++ .../test_esp_sd_store_read_state_contract.cpp | 122 ++++ .../tests/test_lxmf_announce_scheduler.cpp | 35 + .../test_lxmf_deferred_discovery_queue.cpp | 71 ++ .../tests/test_lxmf_runtime_budget.cpp | 80 +++ ...test_meshtastic_mqtt_downlink_contract.cpp | 44 +- .../test_reticulum_call_product_contract.cpp | 117 ++++ .../ui/reticulum_network_projection_policy.h | 53 ++ .../test_reticulum_projection_policy.cpp | 96 +++ .../tests/test_team_app_data_poll_order.cpp | 2 +- modules/ui_mono/src/runtime.cpp | 2 +- .../src/ui/i18n/resource_pack_registry.cpp | 2 +- .../runtime_chat_action_sink.cpp | 5 +- .../ui/screens/network/network_page_shell.cpp | 16 +- .../tests/test_chat_presentation_source.cpp | 3 +- .../chat/infra/lxmf/lxmf_adapter.h | 88 +-- .../chat/infra/lxmf/lxmf_adapter_scratch.h | 40 ++ .../chat/infra/lxmf/lxmf_announce_scheduler.h | 42 ++ .../chat/infra/lxmf/lxmf_call_profile.h | 13 + .../lxmf/lxmf_deferred_discovery_queue.h | 47 ++ .../chat/infra/lxmf/lxmf_peer_directory.h | 27 + .../chat/infra/lxmf/lxmf_runtime_budget.h | 38 + .../chat/infra/lxmf/lxmf_rx_telemetry.h | 56 ++ .../chat/infra/meshtastic/mt_adapter.h | 24 +- .../chat/infra/store/sd_store.h | 48 +- .../esp/arduino_common/notification_runtime.h | 28 + .../src/app_event_runtime_support.cpp | 19 +- .../src/app_runtime_support.cpp | 14 +- .../src/chat/infra/lxmf/lxmf_adapter.cpp | 658 +++++------------- .../infra/lxmf/lxmf_announce_scheduler.cpp | 96 +++ .../lxmf/lxmf_deferred_discovery_queue.cpp | 109 +++ .../infra/lxmf/lxmf_lxst_telephony_client.cpp | 9 + .../chat/infra/lxmf/lxmf_peer_directory.cpp | 88 +++ .../chat/infra/lxmf/lxmf_runtime_budget.cpp | 99 +++ .../src/chat/infra/lxmf/lxmf_rx_telemetry.cpp | 146 ++++ .../src/chat/infra/meshtastic/mt_adapter.cpp | 162 +++-- .../src/chat/infra/store/sd_store.cpp | 385 +++++++++- .../src/notification_runtime.cpp | 58 ++ .../src/platform_ui_device_runtime.cpp | 5 +- .../include/chat/linux_sqlite_chat_store.h | 2 +- .../src/chat/linux_sqlite_chat_store.cpp | 12 +- .../src/uconsole_chat_workspace_model.cpp | 5 +- .../chat/infra/store/internal_fs_store.h | 2 +- .../chat/infra/store/internal_fs_store.cpp | 5 +- 70 files changed, 3423 insertions(+), 756 deletions(-) create mode 100644 docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md create mode 100644 modules/core_chat/include/chat/read/chat_read_state_ledger.h create mode 100644 modules/core_chat/src/read/chat_read_state_ledger.cpp create mode 100644 modules/core_chat/tests/test_chat_read_state_ledger.cpp create mode 100644 modules/core_chat/tests/test_esp_sd_store_read_state_contract.cpp create mode 100644 modules/core_chat/tests/test_lxmf_announce_scheduler.cpp create mode 100644 modules/core_chat/tests/test_lxmf_deferred_discovery_queue.cpp create mode 100644 modules/core_chat/tests/test_lxmf_runtime_budget.cpp create mode 100644 modules/core_chat/tests/test_reticulum_call_product_contract.cpp create mode 100644 modules/core_sys/include/platform/ui/reticulum_network_projection_policy.h create mode 100644 modules/core_sys/tests/test_reticulum_projection_policy.cpp create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter_scratch.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_rx_telemetry.h create mode 100644 platform/esp/arduino_common/include/platform/esp/arduino_common/notification_runtime.h create mode 100644 platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_announce_scheduler.cpp create mode 100644 platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_deferred_discovery_queue.cpp create mode 100644 platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_runtime_budget.cpp create mode 100644 platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_rx_telemetry.cpp create mode 100644 platform/esp/arduino_common/src/notification_runtime.cpp diff --git a/apps/esp32_lvgl/tests/esp32_lvgl_font_hot_path_contract_smoke.cpp b/apps/esp32_lvgl/tests/esp32_lvgl_font_hot_path_contract_smoke.cpp index 1136f735..e01a57de 100644 --- a/apps/esp32_lvgl/tests/esp32_lvgl_font_hot_path_contract_smoke.cpp +++ b/apps/esp32_lvgl/tests/esp32_lvgl_font_hot_path_contract_smoke.cpp @@ -53,6 +53,8 @@ int main(int argc, char** argv) repo_root / "modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp"); assert(contains(registry, "bool s_defer_font_load_overlay_present = false;")); + assert(contains(registry, "constexpr bool kAllowSynchronousContentSupplementFontLoad = false;")); + assert(contains(registry, "constexpr bool kAllowDeferredContentSupplementFontLoad = true;")); assert(contains(registry, "font_load_overlay_policy()")); assert(contains(registry, "Policy::Overlay")); assert(contains(registry, "Policy::OverlayImmediate")); diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index b54b4dae..ad7877df 100644 --- a/apps/linux_sim_shell/CMakeLists.txt +++ b/apps/linux_sim_shell/CMakeLists.txt @@ -102,6 +102,7 @@ if(BUILD_TESTING) "${TRAIL_MATE_REPO_ROOT}/modules/ui_shared/tests/test_chat_presentation_source.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_outbox_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" @@ -159,6 +160,7 @@ if(BUILD_TESTING) "${TRAIL_MATE_REPO_ROOT}/platform/linux/common/src/platform/linux/runtime_paths.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_outbox_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" @@ -185,6 +187,7 @@ if(BUILD_TESTING) "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_chat_service_resend.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_outbox_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" @@ -256,11 +259,88 @@ if(BUILD_TESTING) add_test(NAME trailmate_chat_message_ledger_smoke COMMAND trailmate_chat_message_ledger_smoke) + add_executable(trailmate_chat_read_state_ledger_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_chat_read_state_ledger.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp") + target_include_directories(trailmate_chat_read_state_ledger_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_chat_read_state_ledger_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_chat_read_state_ledger_smoke + COMMAND trailmate_chat_read_state_ledger_smoke) + + add_executable(trailmate_esp_sd_store_read_state_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_esp_sd_store_read_state_contract.cpp") + target_compile_features(trailmate_esp_sd_store_read_state_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_esp_sd_store_read_state_contract_smoke + COMMAND trailmate_esp_sd_store_read_state_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + + add_executable(trailmate_reticulum_projection_policy_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/tests/test_reticulum_projection_policy.cpp") + target_include_directories(trailmate_reticulum_projection_policy_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_reticulum_projection_policy_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_reticulum_projection_policy_smoke + COMMAND trailmate_reticulum_projection_policy_smoke) + + add_executable(trailmate_reticulum_call_product_contract_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_reticulum_call_product_contract.cpp") + target_compile_features(trailmate_reticulum_call_product_contract_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_reticulum_call_product_contract_smoke + COMMAND trailmate_reticulum_call_product_contract_smoke + "${TRAIL_MATE_REPO_ROOT}") + + add_executable(trailmate_lxmf_runtime_budget_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_lxmf_runtime_budget.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_runtime_budget.cpp") + target_include_directories(trailmate_lxmf_runtime_budget_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/include") + target_compile_features(trailmate_lxmf_runtime_budget_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_lxmf_runtime_budget_smoke + COMMAND trailmate_lxmf_runtime_budget_smoke) + + add_executable(trailmate_lxmf_announce_scheduler_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_lxmf_announce_scheduler.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_announce_scheduler.cpp") + target_include_directories(trailmate_lxmf_announce_scheduler_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/include") + target_compile_features(trailmate_lxmf_announce_scheduler_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_lxmf_announce_scheduler_smoke + COMMAND trailmate_lxmf_announce_scheduler_smoke) + + add_executable(trailmate_lxmf_deferred_discovery_queue_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_lxmf_deferred_discovery_queue.cpp" + "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_deferred_discovery_queue.cpp") + target_include_directories(trailmate_lxmf_deferred_discovery_queue_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" + "${TRAIL_MATE_REPO_ROOT}/platform/esp/arduino_common/include" + "${TRAIL_MATE_REPO_ROOT}/platform/shared/include") + target_compile_features(trailmate_lxmf_deferred_discovery_queue_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_lxmf_deferred_discovery_queue_smoke + COMMAND trailmate_lxmf_deferred_discovery_queue_smoke) + add_executable(trailmate_chat_delivery_event_projection_adapter_smoke "${TRAIL_MATE_REPO_ROOT}/modules/ui_chat_runtime/tests/test_chat_delivery_event_projection_adapter.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/ui_chat_runtime/src/chat_delivery_event_projection_adapter.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_outbox_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/mesh_protocol_utils.cpp" @@ -883,6 +963,7 @@ if(BUILD_TESTING) "${TRAIL_MATE_REPO_ROOT}/modules/core_team/tests/test_team_app_data_poll_order.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/domain/chat_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/usecase/chat_service.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/read/chat_read_state_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_message_ledger.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_outbox_service.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" diff --git a/apps/linux_uconsole_gtk/tests/uconsole_chat_dedup_smoke.cpp b/apps/linux_uconsole_gtk/tests/uconsole_chat_dedup_smoke.cpp index bd9bb255..387f5163 100644 --- a/apps/linux_uconsole_gtk/tests/uconsole_chat_dedup_smoke.cpp +++ b/apps/linux_uconsole_gtk/tests/uconsole_chat_dedup_smoke.cpp @@ -157,9 +157,10 @@ class FakeChatStore final : public ::chat::IChatStore return {}; } - void setUnread(const ::chat::ConversationId&, int unread) override + bool setUnread(const ::chat::ConversationId&, int unread) override { unread_ = unread; + return true; } int getUnread(const ::chat::ConversationId&) const override diff --git a/docs/MULTI_PROTOCOL_SUPPORT.md b/docs/MULTI_PROTOCOL_SUPPORT.md index 8191040f..cc45c787 100644 --- a/docs/MULTI_PROTOCOL_SUPPORT.md +++ b/docs/MULTI_PROTOCOL_SUPPORT.md @@ -57,6 +57,26 @@ > 不再进行协议预判,也不维护节点协议映射。 +### 业务状态统一边界 + +“单协议运行”不表示每个协议可以各自拥有一套 UI 消息状态。 + +MT / MC / RT adapter 只能把协议事实映射成 protocol-aware event: + +- message identity +- queued / sending / sent / delivered / failed +- failure kind +- read/unread reference +- retry eligibility + +这些事实必须进入共享的 `MessageLedger`、`ChatDeliveryEventProjector`、 +`ReadStateLedger` 和 conversation projection。UI 上的气泡状态 badge、 +conversation unread badge、发送失败反馈和 retry 动作都不得从协议 adapter +私有状态直接推断。 + +完整 owner 边界见 +`docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`。 + --- ## 4) Settings 规划 @@ -102,7 +122,7 @@ These settings write into the existing `AppConfig` / `MeshConfig` fields and use - Meshtastic:功能完整(含 NodeInfo、channel identity/config、plain MQTT client) - MeshCore:RAW_CUSTOM 文本收发闭环,channel slot/name/key 可在设备端配置,plain MQTT client 已有独立配置入口 -- Reticulum:作为独立协议运行,Settings 暴露 bearer/gateway/identity 配置 +- Reticulum:作为默认独立协议运行,Settings 暴露 bearer/gateway/identity 配置;产品 call path 只支持 Sideband-compatible LXST - BLE 手机桥:ESP Arduino 当前产品固件不编译、不启动、不在 Settings 中展示 --- diff --git a/docs/RETICULUM_CONFORMANCE_BASELINE.md b/docs/RETICULUM_CONFORMANCE_BASELINE.md index b9f22815..eb16037c 100644 --- a/docs/RETICULUM_CONFORMANCE_BASELINE.md +++ b/docs/RETICULUM_CONFORMANCE_BASELINE.md @@ -96,7 +96,7 @@ implementation changes. | --- | --- | --- | --- | --- | | RCNF-001 | Protocol selector | User-facing choices are Meshtastic, MeshCore, and Reticulum; legacy `RNode` and `LXMF` values remain compatibility/internal details | product-extension | Resolved for product protocol naming; core runtime selection and ESP adapter factory now route legacy `RNode` values to a concrete product-level `ReticulumAdapter` rather than exposing a raw RNode or LXMF user protocol | | RCNF-002 | Reference coverage | `microReticulum` does not provide a complete LXMF messaging stack | upstream-gap | Use it only for Reticulum network-stack checks | -| RCNF-003 | Address projection | Trail Mate still carries 32-bit node ids as compatibility projections, while V9 node-store persistence, chat message metadata, core in-memory conversation keys, and incoming duplicate detection keep/use full Reticulum destination identity when available | intentional-subset | Continue migrating persistent chat-store grouping, unread keys, SD/index paths, and UI presentation conversation ids to carry Reticulum destination identity | +| RCNF-003 | Address projection | Trail Mate still carries 32-bit node ids as compatibility projections, while V9 node-store persistence, chat message metadata, core in-memory conversation keys, and incoming duplicate detection keep/use full Reticulum destination identity when available | intentional-subset | Continue migrating persistent chat-store grouping, `ReadStateLedger` keys, SD/index projections, and UI presentation conversation ids to carry Reticulum destination identity. Index/header unread mirrors are not authoritative under `RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md` | | RCNF-004 | MTU model | Trail Mate currently uses a fixed embedded Reticulum MTU constant | intentional-subset | Verify against interface MTU behavior before changing runtime | | RCNF-005 | Runtime ownership | `LxmfAdapter` still owns LXMF envelope/service orchestration, signature verification, queue policy, link response sending, and propagated delivery acceptance side effects, while transport, link, resource, propagation state, propagation request response planning, and verified packed-payload delivery classification/materialisation are now split into dedicated runtime helpers | missing-feature | Continue splitting remaining LXMF service orchestration after conformance tests expose stable boundaries | | RCNF-006 | Announce validation | Trail Mate parses and cryptographically validates the `microReticulum` announce vectors in conformance smoke coverage | missing-feature | Resolved for the checked announce-vector subset | diff --git a/docs/protocol_runtime_budget_policy.md b/docs/protocol_runtime_budget_policy.md index edf5395e..9137fd7c 100644 --- a/docs/protocol_runtime_budget_policy.md +++ b/docs/protocol_runtime_budget_policy.md @@ -54,8 +54,10 @@ Trail Mate's Reticulum UI projections follow client-facing object semantics: 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. +- Network consumes non-contact Reticulum announces. `nomadnetwork.node` + announces are web/service nodes, `lxmf.propagation` announces are message + relays, `lxst.telephony` and legacy `call.audio` announces are telephony + services, and unknown announces are diagnostics. These are not Contacts rows. - A verified `lxst.telephony` destination may enrich a person already joined by identity; it does not create a service-shaped contact by itself. - `lxmf.propagation`, legacy `call.audio`, and unknown announces may be stored @@ -148,6 +150,71 @@ than the visible UI window, preferably in PSRAM-backed storage when available. Reticulum Contacts search may stream over the SD address book, but the UI must still cap the number of projected rows. +## LoRa TX Scheduler Budget + +LoRa TX is an air-time budgeted runtime resource. It is not safe for UI, +event-bus, BLE/phone facade, MQTT RX, key-verification RX, or application action +paths to synchronously push arbitrary packets to the radio. + +Required scheduler model: + +- Public adapter APIs such as `sendText()` and `sendAppData()` enqueue work and + return whether the work was accepted by the scheduler. +- Runtime protocol effects, key verification replies, routing replies, ACK + retry, and MQTT downlink relay enqueue into bounded queues. +- One periodic adapter tick owns the air-time budget. The tick drains protocol + actions, ACK retry, ordinary sends, and MQTT downlink under the same + `kLoRaAirTxBudgetPerTick`. +- `min_tx_interval_ms_` is global across those TX owners. A recent TX from one + owner defers every other owner. +- MQTT downlink relay must keep official gateway behavior, but must deduplicate + by `from + id + channel`, bound queue depth, bound per-tick drain, and report + full/deferred/drop reasons. +- UI projections may show queued/deferred/failed states, but UI must not block + waiting for the radio task or retry loop. + +Forbidden scheduler shapes: + +- `injectMqttEnvelope()` or MQTT RX hot path calling `transmitWirePacket()`. +- `sendAppData()` directly calling `transmitWirePacket()` as the normal public + path. +- Key verification RX handlers synchronously transmitting replies. +- Separate local drain counters that allow protocol actions, app sends, ACK + retry, and MQTT downlink each to consume a full TX slot in the same tick. + +## Reticulum Runtime Owner Budget + +The embedded Reticulum adapter is allowed to coordinate owners, but it must not +re-own runtime state that already has a policy owner: + +- `RuntimeBudget` owns phase-to-budget decisions. +- `AnnounceScheduler` owns announce pending/retry/rebroadcast cadence. +- `DeferredDiscoveryQueue` owns bounded public discovery deferral. +- `RawRxTelemetry` owns RX summary and suppression counters. +- `AdapterScratchBuffers` owns long-lived MTU packet scratch. +- `PeerDirectoryService` owns Reticulum peer hot-load and projection queueing. + +This keeps UI responsiveness and packet fairness reviewable: budget decisions, +queue pressure, telemetry counters, and projection backpressure can each be +tested without reading the whole adapter. + +## Notification / Audio Budget + +Notification is a product policy runtime. Chat, Contacts, Settings, and Team +events may request feedback, but they must not directly own the speaker or +vibrator. + +Required model: + +- Message notifications, contact/person notifications, and Settings tone + preview call Notification runtime. +- Notification runtime reads user policy and emits tone/vibration intent. +- Platform audio adapter owns the ES8311/I2S speaker and microphone session. +- Call ring and call media have realtime priority. Non-call notification audio + must not steal an active call audio session. +- If an audio owner cannot play, it must expose a failure/deferred result or log + from the owner boundary. + ## Regression Checks `scripts/check_reticulum_runtime_budget_policy.py` enforces the highest-risk diff --git a/docs/reticulum_client_architecture.md b/docs/reticulum_client_architecture.md index e736c4ac..b6f83489 100644 --- a/docs/reticulum_client_architecture.md +++ b/docs/reticulum_client_architecture.md @@ -2,6 +2,11 @@ Status: accepted for implementation +This document is a Reticulum-specific child of +`docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. If Reticulum +implementation work conflicts with that ownership freeze, update the freeze +and this document before changing code. + ## Product boundary Trail Mate is a Reticulum client. It is not a general-purpose Reticulum @@ -94,16 +99,20 @@ runtime. ## Projection contract -Contacts contains identities that can represent a person in messaging or -telephony. A record is eligible when it has a verified LXMF delivery destination -or a verified LXST telephony destination that can be joined to an identity. -Propagation nodes, Nomad/web services, unknown announces, gateways, interfaces, -and path hops are excluded. +Contacts contains identities that can represent a person in messaging. A record +is eligible only after it has a valid LXMF address/person projection with a +destination hash, identity hash, encryption public key, and signing public key. +Favorites, manual imports, and ignored state are contact-book facts. Runtime +announces may appear as nearby people, but propagation nodes, Nomad/web +services, telephony services, unknown announces, gateways, interfaces, and path +hops are excluded. -Network contains Nomad/Micron service destinations and their path/service -metadata. Propagation nodes are maintained by the propagation client and are -not presented as contacts. Gateways and interfaces are connection diagnostics, -not directory entries. +Network contains non-contact Reticulum projections. `lxmf.propagation` is a +message relay projection, `nomadnetwork.node` is a web/service projection, +`lxst.telephony` and legacy `call.audio` are telephony-service projections, and +unknown announces are diagnostics. PropagationClient may maintain relay metadata +in the background; Contacts never reads raw announce records to recreate these +items. ## LXST call state machine @@ -166,6 +175,59 @@ buffer with underflow recovery and overflow accounting; capture cannot block speaker cadence. The platform audio adapter owns ES8311/I2S setup, microphone gain, speaker volume, and teardown. +## Notification and audio design + +Notification is a product runtime, not a side effect of Chat UI or Contacts UI. +Incoming messages, contact/person discovery notifications, and Settings tone +preview all call the notification runtime. That runtime reads user policy +(`chat_message_alerts`, `chat_contact_alerts`, vibration, tone volume) and +emits tone/vibration intents to the platform audio owner. + +The platform audio owner is the only code allowed to open, configure, and close +ES8311/I2S hardware. Call ring, call media playback, message tones, and preview +tones may have different owners at the policy level, but they must rendezvous at +the same audio adapter boundary. If call media is active, non-call notification +audio must fail or defer explicitly instead of silently stealing the audio +session. + +## LoRa TX scheduler design + +Meshtastic LoRa TX is a scheduler-owned resource. `sendText()`, +`sendAppData()`, key verification, runtime protocol effects, ACK retry, and +MQTT downlink relay enqueue work. The periodic adapter tick drains work through +a single air-time budget and shared `min_tx_interval_ms_`. + +This means: + +- A public send API returning success means "accepted by the scheduler". +- Radio transmission happens from the scheduler tick, never from UI/event/RX + hot paths. +- MQTT downlink keeps gateway relay semantics, but `from + id + channel` + duplicate suppression and bounded queues protect the LoRa air and UI. +- Queue-full, duty-cycle, radio-offline, and retry exhaustion are explicit + deferred/drop/failure reasons; they are not represented by frozen UI. + +## Reticulum adapter owner cleanup + +`LxmfAdapter` remains the product protocol facade, but it must not own every +runtime fact itself. The following embedded Reticulum owners are mandatory: + +- `RuntimeBudget` owns call/nomad/sleep/saver/P4-screen runtime scheduling + policy. +- `AnnounceScheduler` owns local announce pending, retry, interval, and + rebroadcast throttling. +- `DeferredDiscoveryQueue` owns bounded public-discovery deferral, + packet-hash de-duplication, and drop-oldest accounting. +- `RawRxTelemetry` owns RX summary counters and suppressed-detail log cadence. +- `AdapterScratchBuffers` owns MTU-sized packet scratch storage. +- `PeerDirectoryService` owns Reticulum peer directory persistence, hot-load, + and projection queueing; adapter only publishes the final projection event. + +Any future change that reintroduces these facts as ad-hoc fields in +`LxmfAdapter` is a boundary violation. New link, path, propagation, ping, +network-page, and call facts must move toward their existing owners rather than +adding branches to the adapter. + ## UI interruption contract The call experience is a page, not a modal. A global interruption navigator @@ -203,7 +265,8 @@ bottom-aligned volume shortcuts. It does not own call or resource state. - Direct and propagation copies of one LXMF hash create one chat item and one unread transition across reboot. - Contacts excludes propagation, service, unknown, gateway, and interface - entries; Network exposes Nomad services. + entries; Network exposes relay, web/service, telephony-service, and unknown + diagnostic projections. - Message delivery status is proof/receipt-backed. - `LxmfAdapter` no longer owns the main Reticulum fact state and is reduced to a facade/coordinator shell. diff --git a/docs/specification/CHAT_DELIVERY_FEEDBACK_SPEC.md b/docs/specification/CHAT_DELIVERY_FEEDBACK_SPEC.md index 9d0be714..d743713f 100644 --- a/docs/specification/CHAT_DELIVERY_FEEDBACK_SPEC.md +++ b/docs/specification/CHAT_DELIVERY_FEEDBACK_SPEC.md @@ -65,7 +65,9 @@ created or queued an outgoing message. It does not mean final delivery success. ### Delivery Result Event `ChatSendResultEvent` is the current compatibility event for final send -outcome. +outcome. New active protocol paths must publish protocol-aware delivery facts +with failure kind before they reach the feedback controller; they must not add +another `msg_id + bool` result path. Semantics: @@ -75,9 +77,10 @@ Semantics: - The event must be processed after or together with the corresponding `ChatService::handleSendResult(...)` state update. -Future protocol runtimes may publish richer delivery events, but they must -preserve the same boundary: final user feedback is produced from runtime -delivery facts, not from page polling. +Protocol runtimes may publish richer delivery events, but they must preserve +the same boundary: final user feedback is produced from runtime delivery facts, +not from page polling. Message identity and deduplication rules follow +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. ### ChatDeliveryFeedbackController diff --git a/docs/specification/CHAT_DELIVERY_RUNTIME_SPEC.md b/docs/specification/CHAT_DELIVERY_RUNTIME_SPEC.md index 754e00f5..0f6c8e3e 100644 --- a/docs/specification/CHAT_DELIVERY_RUNTIME_SPEC.md +++ b/docs/specification/CHAT_DELIVERY_RUNTIME_SPEC.md @@ -25,6 +25,23 @@ Final user-visible send feedback is also runtime feedback. It must flow through the delivery feedback mechanism defined in `CHAT_DELIVERY_FEEDBACK_SPEC.md`, not through a page-local compose widget. +Delivery state and read state are separate ledgers: + +```text +Protocol send / ACK / proof / receipt + -> MessageLedger / ChatDeliveryEventProjector + -> delivery projection + +User opens or marks a conversation read + -> ReadStateLedger + -> unread projection +``` + +`ChatDeliveryReadModel` may project outgoing status, but it must not own +read/unread, app badge counts, or conversation read watermarks. Those facts +belong to `ReadStateLedger` as defined in +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. + ## Types Phase 7.1 introduces: @@ -56,8 +73,9 @@ Phase 7.3 adds: `ProjectingChatDeliveryEventPort` adapts that port to `ChatDeliveryEventProjector`. -`ChatDeliveryEventProjectionAdapter` maps existing `ChatSendResultEvent` and -ACK timeout hooks into the delivery event port. +`ChatDeliveryEventProjectionAdapter` maps protocol-aware send result events and +ACK timeout hooks into the delivery event port. New active paths must not reduce +send results to `msg_id + bool`; they must preserve protocol and failure kind. `ChatDeliveryMessageProjection` maps existing coarse `ChatMessage::status` into delivery records. @@ -132,13 +150,28 @@ into `MessageRow`. ## Message Reference -Phase 7.1 uses `ChatDeliveryRef` as a compatibility reference: +`ChatDeliveryRef` must identify a message inside one protocol namespace. +Bare `msg_id` is not sufficient for active MT / MC / RT delivery paths. + +Phase 7.1 introduced `ChatDeliveryRef` as a compatibility reference: - `local_id` - `protocol_id` - `nonce_or_seq` -Existing `ChatMessage::msg_id` maps to `protocol_id` first. +Existing `ChatMessage::msg_id` may map to `protocol_id` first for compatibility, +but active send-result, retry, and presentation lookup paths must carry the +message protocol alongside the protocol id. + +Required mapping intent: + +- Meshtastic: protocol + from/to + packet id + channel when available. +- MeshCore: protocol + frame/app ACK identity or route/control identity. +- Reticulum: protocol + LXMF hash and destination identity. + +The UI may render a small status badge, but the badge must be a projection of +this protocol-aware reference. A renderer must not look up or retry messages by +bare `msg_id`. ## Failure Kinds @@ -152,6 +185,17 @@ Phase 7.1 recognizes: - `Rejected` - `Unknown` +New protocol adapters must map failures before they reach the delivery +projector. `Unknown` is a compatibility fallback, not a normal design target. + +Examples: + +- ACK wait expired: `AckTimeout` +- radio or transport rejected TX: `RadioSendFailed` +- peer or local identity missing: `PeerKeyMissing` / `LocalIdentityMissing` +- active protocol cannot send this conversation: `UnsupportedProtocol` +- runtime policy rejected the operation: `Rejected` + ## Non-Goals Phase 7.1 does not implement a full retry engine. @@ -165,3 +209,9 @@ Phase 7.1 does not make `ChatWorkspaceModel` own delivery state. Phase 7.1 does not make renderers infer pending/failure. Phase 7.1 does not resolve Team rich payload delivery semantics. + +## Relationship To Runtime Ownership Freeze + +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md` is the higher-level boundary document for +message, delivery, retry, read/unread, and projection ownership. If a future +delivery change needs a new owner, update that document first, then this spec. diff --git a/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md b/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md index 3ce0c200..6c425c54 100644 --- a/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md +++ b/docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md @@ -17,6 +17,11 @@ Those two identities are related by an adapter mapper. `ui_presentation` must not include `chat/domain/chat_types.h`, `ChatService`, `ContactService`, `IMeshAdapter`, store cursors, or platform/runtime headers. +Message, delivery, read/unread, retry, and projection ownership is governed by +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. This file only defines presentation +identity mapping; it does not authorize presentation code to own business +state. + ## Direction ```text @@ -142,6 +147,11 @@ Dropping the protocol and sending only by channel/peer is invalid because it turns a readable cross-protocol conversation into a send target for the active protocol. +The same rule applies to `markRead` and retry actions. A presentation +`MessageRef` or `ConversationId` must be mapped back to a protocol-aware +business reference before a runtime command is issued. Bare `msg_id`, peer id, +or channel id is not a valid cross-protocol command key. + ## Source/Sink Adapter Contract Chat presentation adapters are the first layer allowed to touch real chat diff --git a/docs/specification/CHAT_RUNTIME_EVENT_PUMP_SPEC.md b/docs/specification/CHAT_RUNTIME_EVENT_PUMP_SPEC.md index dca5692f..101cda91 100644 --- a/docs/specification/CHAT_RUNTIME_EVENT_PUMP_SPEC.md +++ b/docs/specification/CHAT_RUNTIME_EVENT_PUMP_SPEC.md @@ -12,6 +12,11 @@ UI refresh belongs to the controller. The controller must not own runtime event projection or runtime scheduling. +The event pump is a router, not a business-state owner. Message delivery, +read/unread, retry, and protocol identity ownership is frozen by +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`; this pump must route events into those +owners and notify UI refresh sinks from their projections. + ## Types ### `IChatUiRefreshSink` @@ -91,12 +96,16 @@ event-driven delivery feedback mechanism in ### `ChatNewMessageEvent` -1. Chat service already owns message storage. -2. `ChatPageRuntimeEventPump` calls `IChatUiRefreshSink::onRuntimeMessageArrived(...)`. +1. The message has already entered the message owner path. +2. Read/unread changes, if any, are produced by the read-state owner path. +3. `ChatPageRuntimeEventPump` calls `IChatUiRefreshSink::onRuntimeMessageArrived(...)`. ### `ChatUnreadChangedEvent` -1. `ChatPageRuntimeEventPump` calls `IChatUiRefreshSink::onRuntimeUnreadChanged()`. +1. `ReadStateLedger` / projection state has changed. +2. `ChatPageRuntimeEventPump` calls `IChatUiRefreshSink::onRuntimeUnreadChanged()`. + +The pump must not clear badges or mutate unread counters directly. ### Key Verification Events diff --git a/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md b/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md index d711a764..4fff0aca 100644 --- a/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md +++ b/docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md @@ -21,6 +21,7 @@ It does not own: - ACK tracker - retry state - failure inference +- read/unread ledger ## Pattern @@ -55,6 +56,46 @@ ChatWorkspaceModel::markRead(id) The model forwards actions to `IChatActionSink`. +## Read State Authority + +`ChatWorkspaceModel::markRead(id)` is a UI intent. It is not the authoritative +read-state mutation. + +The authoritative read/unread owner is `ReadStateLedger`, as frozen by +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. + +Required flow: + +```text +Renderer + -> ChatWorkspaceModel::markRead(...) + -> IChatActionSink + -> app/runtime read command + -> ReadStateLedger commit or pending result + -> ConversationProjectionStore / ChatPresentationSource + -> unread badge projection +``` + +The workspace model may optimistically keep local selection and offsets, but it +must not claim durable read success. Conversation index entries, SD file +headers, unread counters, and app badges are projections of the ledger. They +may cache or mirror read state, but they must be rebuildable from +`MessageLedger + ReadStateLedger`. + +Renderers must not hide an unread badge as the source of truth. They may only +show the projection returned by `IChatPresentationSource`, or a clearly pending +state produced by the read command path. + +Mark-read failure semantics must stay explicit: + +- committed: projection may clear the unread badge. +- pending: projection may show a temporary pending read state. +- failed: projection must not pretend the conversation is read. + +This rule applies equally to Meshtastic, MeshCore, and Reticulum conversations. +The read reference must preserve protocol identity; a bare message id or peer id +is not a valid cross-protocol read key. + ## Protocol Send Eligibility Conversation protocol and active send protocol are separate facts. diff --git a/docs/specification/LOCALE_PACKS.md b/docs/specification/LOCALE_PACKS.md index de211384..051d9068 100644 --- a/docs/specification/LOCALE_PACKS.md +++ b/docs/specification/LOCALE_PACKS.md @@ -2,10 +2,13 @@ 本文档解释 pack 机制与打包细节。 整个本地化系统的规范性规格现在位于 -[`docs/specs/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md)。 -如果两份文档之间存在冲突,以 `LOCALIZATION_SPEC.md` 为准。 +[`docs/specification/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md)。 +runtime owner 总边界见 +[`docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`](./RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md)。 +如果这些文档之间存在冲突,以 `RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md` 和 +`LOCALIZATION_SPEC.md` 为准。 语言包打包、发布、版本、archive 与 catalog 更新规则见 -[`docs/specs/LOCALE_PACK_RELEASE_SPEC.md`](./LOCALE_PACK_RELEASE_SPEC.md)。 +[`docs/specification/LOCALE_PACK_RELEASE_SPEC.md`](./LOCALE_PACK_RELEASE_SPEC.md)。 ## 目标 @@ -122,9 +125,9 @@ CJK 字体 pack 必须把常用中文/全角标点当作 pack 资源处理,而 1. 从 `settings/display_locale` 解析当前活动 locale。 2. 当该 locale 被激活时,立即加载活动 UI font pack。 -3. 活动 content font pack 采用惰性加载,只在 content-scope 文本真正需要时才加载。 +3. 活动 content font pack 采用 owner-controlled 惰性加载,只在 content-scope 文本真正需要时请求加载。 4. ESP 上,如果活动 locale 显式声明 `preferred_content_supplement_packs`,registry 可以在 locale 激活阶段按 supplement 预算预加载这些已编目的 content supplement。 -5. 如果当前文本包含活动 content chain 尚未覆盖的 codepoint,则惰性加载额外的 content supplement pack。 +5. 如果当前文本包含活动 content chain 尚未覆盖的 codepoint,则由 `FontRuntimeCoordinator` / `ResourcePackRegistry` 安排额外 content supplement pack 的前台加载、延迟重试或失败诊断;页面/widget 不得私自读取字体,也不得永久跳过已安装可用字体。 6. 切换 locale 时,会卸载所有运行时已加载的外部字体,并从头重建整条链。 这意味着,一个设备可以安装很多 pack,但任意时刻真正驻留在 RAM 中的只会是 active locale 需要的 UI/content 字体,以及当前 memory profile 允许的少量 content supplement。 diff --git a/docs/specification/LOCALIZATION_SPEC.md b/docs/specification/LOCALIZATION_SPEC.md index 28bec053..cd8d5db9 100644 --- a/docs/specification/LOCALIZATION_SPEC.md +++ b/docs/specification/LOCALIZATION_SPEC.md @@ -487,24 +487,27 @@ English、基础特殊字符输入与精选 emoji 输入必须在没有任何外 这两条链不能被简化成“只要非 ASCII 就统一切换某个 CJK 字体”的旁路实现。 -### 4.5.1 Content Font Load Is Not A Render-Side Blocking Operation +### 4.5.1 Content Font Load Must Be Owned And User-Visible -内容文本缺字检测与外部字体加载是两个不同动作。 +内容文本缺字检测与外部字体加载是两个不同动作,但缺字不能被永久跳过。 1. `ensure_content_font_for_text()` 可以发现当前内容字体链缺少某些 codepoint。 -2. 它不能在 ESP / display-shared SD-SPI 设备的页面渲染、列表构建、LVGL 事件或 timer 路径里同步加载外部 `font.bin`。 -3. ESP 上的内容补充字体加载必须被视为后台/显式动作;在后台 runtime 完成前,页面使用当前已加载字体链并记录缺字诊断。 -4. 固件内置字体只有在 runtime pack manifest 以 `source=builtin` 显式声明时才算运行时 loaded 状态;ESP 不得隐式注册 CJK 内置字体来替代外部 `zh-hans-core/font.bin`。 -5. 已加载字体是否覆盖某个 codepoint 必须以实际 glyph lookup 为准;manifest/range 只能用于选择候选 pack,不能替代渲染能力判断。 -6. 外部 `font.bin` 加载失败后必须进入 backoff,不能因为多条联系人名、聊天消息或节点名重复触发同一个失败文件读取。 -7. 显式切换 locale 时加载 UI 字体属于 locale 激活流程;这不能被内容文本缺字路径复用成隐式 SD 读。 -8. 外部 `source=binfont` 字体 pack 必须在 catalog 阶段验证 `font.bin` 路径可规范化且可打开;缺少 payload 的 locale 不能进入可选 locale 列表。 -9. 任何用户可见路径中被运行时明确允许的同步外部字体加载,必须先显示阻塞式 busy modal,加载完成或失败后关闭。这个规则不以字体包大小为条件;纯 deferred/backoff 路径只记录诊断,不显示“正在加载”的假窗口。 -10. “显示 busy modal” 的代码语义不是只创建 LVGL 对象,而是必须在进入 `lv_binfont_create()` / 外部 `font.bin` 读取之前,强制把 modal flush 到屏幕。当前绑定点是 `resource_pack_registry.cpp` 的 `ScopedFontLoadOverlay`,它是 `load_font_pack()` 的唯一同步字体加载 UI 边界。 -11. ESP 上所有通过 LVGL FS 读取外部 `font.bin` 的同步加载,都必须进入完整的 shared-SPI bus transaction。`lv_begin_external_font_load_fs_scope()` 不能只是 depth flag 或“让每次 FS callback 多等一点”的旁路;它必须成功取得 runtime bus token 后,才允许进入 `lv_binfont_create()`。 -12. 外部字体加载事务取得 bus token 失败属于瞬时 `bus_busy`,只能进入短退避并保留后续重试机会;只有已经取得 bus token 但 `lv_binfont_create()` 返回空,才按字体文件/格式失败进入长 backoff。 +2. 它不能自己决定“因为在热路径、因为 active locale 是 `en`、因为这是 content supplement,所以不加载”。 +3. 它必须把缺字事实交给 `FontRuntimeCoordinator` / `ResourcePackRegistry`,由统一 owner 选择已加载字体、安排前台加载、延迟重试或报告失败。 +4. CJK/Japanese/Korean/Arabic 等 content text 的字体需求不由 display locale 决定;`active_locale=en` 时,中文聊天、联系人名、Network/Nomad 内容仍然可以触发已安装 `zh-hans-core` 等 content supplement 加载。 +5. 普通页面渲染、列表构建、LVGL 事件或 timer 路径不得无主静默阻塞 SD-backed `font.bin` 读取。 +6. 同步外部字体加载是合法路径,但它必须是用户可见的 foreground operation:先显示 loading/progress/busy modal 或页面,强制 flush 到屏幕,再进入 `lv_binfont_create()` / 外部 `font.bin` 读取。 +7. 如果运行时选择 deferred load,页面可以暂时使用当前已加载字体链并记录缺字诊断;deferred 只能是 pending/retry 状态,不能变成永久 hard skip。 +8. 固件内置字体只有在 runtime pack manifest 以 `source=builtin` 显式声明时才算运行时 loaded 状态;ESP 不得隐式注册 CJK 内置字体来替代外部 `zh-hans-core/font.bin`。 +9. 已加载字体是否覆盖某个 codepoint 必须以实际 glyph lookup 为准;manifest/range 只能用于选择候选 pack,不能替代渲染能力判断。 +10. 外部 `font.bin` 加载失败后必须进入 backoff,不能因为多条联系人名、聊天消息或节点名重复触发同一个失败文件读取。 +11. 显式切换 locale 时加载 UI 字体属于 locale 激活流程;内容文本缺字路径可以请求内容字体 owner,但不能在页面/widget 内私自复用 SD 读。 +12. 外部 `source=binfont` 字体 pack 必须在 catalog 阶段验证 `font.bin` 路径可规范化且可打开;缺少 payload 的 locale 不能进入可选 locale 列表。 +13. “显示 busy modal” 的代码语义不是只创建 LVGL 对象,而是必须在进入 `lv_binfont_create()` / 外部 `font.bin` 读取之前,强制把 modal flush 到屏幕。当前绑定点是 `resource_pack_registry.cpp` 的 `ScopedFontLoadOverlay`,它是 `load_font_pack()` 的唯一同步字体加载 UI 边界。 +14. ESP 上所有通过 LVGL FS 读取外部 `font.bin` 的同步加载,都必须进入完整的 shared-SPI bus transaction。`lv_begin_external_font_load_fs_scope()` 不能只是 depth flag 或“让每次 FS callback 多等一点”的旁路;它必须成功取得 runtime bus token 后,才允许进入 `lv_binfont_create()`。 +15. 外部字体加载事务取得 bus token 失败属于瞬时 `bus_busy`,只能进入短退避并保留后续重试机会;只有已经取得 bus token 但 `lv_binfont_create()` 返回空,才按字体文件/格式失败进入长 backoff。 -这条规则的目标是保护 UI 实时域:联系人页、聊天页、地图 overlay、节点详情页等内容页面不得因为遇到中文/日文/韩文/阿拉伯文本而把 UI 线程拖入 SD 阻塞 IO。 +这条规则的目标是同时保护 UI 实时域和内容可读性:联系人页、聊天页、地图 overlay、节点详情页等内容页面不得因为遇到中文/日文/韩文/阿拉伯文本而静默拖入无主 SD 阻塞 IO,也不得为了避免阻塞而让可用字体永远不加载。 ### 4.5.1.1 External Font Load Transaction @@ -892,17 +895,18 @@ Glyph 判定边界: 如果与其他文档冲突,优先级如下: -1. `docs/LOCALIZATION_SPEC.md` -2. `docs/specs/LOCALE_PACK_RELEASE_SPEC.md` -3. `docs/LOCALE_PACKS.md` -4. 各 `packs//README.md` -5. `docs/ui_localization_plan.md` +1. `docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md` +2. `docs/specification/LOCALIZATION_SPEC.md` +3. `docs/specification/LOCALE_PACK_RELEASE_SPEC.md` +4. `docs/LOCALE_PACKS.md` +5. 各 `packs//README.md` +6. `docs/ui_localization_plan.md` 其中: 1. `docs/LOCALE_PACKS.md` - 负责解释 pack 机制、布局和字段 -2. `docs/specs/LOCALE_PACK_RELEASE_SPEC.md` +2. `docs/specification/LOCALE_PACK_RELEASE_SPEC.md` - 负责解释打包、发布、版本、catalog、archive 与更新可见性 3. `docs/ui_localization_plan.md` - 仅保留历史演进价值,不再作为当前设计依据 diff --git a/docs/specification/POST_REFACTOR_ARCHITECTURE_FREEZE.md b/docs/specification/POST_REFACTOR_ARCHITECTURE_FREEZE.md index bda0509f..5b402a5b 100644 --- a/docs/specification/POST_REFACTOR_ARCHITECTURE_FREEZE.md +++ b/docs/specification/POST_REFACTOR_ARCHITECTURE_FREEZE.md @@ -27,6 +27,11 @@ New UI/runtime work should enter through this shape: Fallback may exist only as compatibility containment and must not become the default path again. +Runtime fact ownership is frozen by +`docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. The pipeline above +does not authorize UI, Settings, renderers, or platform adapters to own message, +read/unread, protocol, font, call, or resource facts directly. + ## Explicit Prohibitions 1. Renderers must not select UX packs. @@ -47,6 +52,10 @@ default path again. 14. `docs/archive` must not contain a source archive. 15. New `legacy_source_descriptor` files are forbidden. 16. New transitional UI layers are forbidden. +17. New runtime fact bypasses are forbidden. Message, read/unread, Reticulum + path/link/call, Wi-Fi lease, audio, MQTT downlink relay, and font loading + changes must identify the owner named in + `RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md` before implementation. ## Removed Legacy Roots diff --git a/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md b/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md index bac144dc..4a9cf8e5 100644 --- a/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md +++ b/docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md @@ -144,6 +144,11 @@ Runtime 是协议真相所在: - `MeshCoreRuntime` 解释 MeshCore Intent、incoming frame、trace path、NodeInfo control、tick; - runtime 可以拥有 State,但不能拥有平台 IO。 +Runtime 可以拥有协议状态机,但不能绕过产品业务 ledger。文本消息、投递状态、 +read/unread、conversation badge、retry eligibility 必须进入共享 Chat/Message owner, +而不是停留在某个协议 adapter 的私有状态中。这个边界由 +`RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md` 冻结。 + ### Facade Facade 是 UI / ChatService 面对协议系统的稳定用例入口。它不是 protocol runtime 的别名,也不是 @@ -180,6 +185,11 @@ Facade 的职责是隔离上层与协议编排。上层不应该直接拼 `Proto 也不应该直接遍历 `ProtocolEffects` 后调用 executor。active UI / ChatService 入口必须迁入 `MeshProtocolFacade` 或本规格明确命名的等价边界。 +Facade 返回的 app-facing result 必须保持 protocol-aware identity。对于 chat send/read/retry +相关结果,facade 或 adapter 不得降级成 bare `msg_id + bool`。协议 runtime 可以输出协议事实, +但消息业务状态必须由 `MessageLedger`、`ChatDeliveryEventProjector` 和 `ReadStateLedger` +统一投影。 + `MeshProtocolFacade` 默认捕获 `EmitActionResultEffect`、`PublishIncomingTextEffect`、 `PublishIncomingDataEffect`、`PublishNodeInfoEffect` 等 app-facing projection,让 UI 可以从 `MeshProtocolFacadeResult` 读取结果而不把 projection 当平台 IO 执行。平台 adapter 需要把这些 @@ -727,6 +737,8 @@ Before changing protocol code: 6. Update this spec when the pattern boundary changes. 7. Update `PROTOCOL_ADAPTER_DRIFT_AUDIT.md` when a drift item is resolved or accepted. 8. Run GitNexus impact analysis before edits and `detect-changes` before commit. +9. Verify message, delivery, read/unread, Contacts, Network, and call resources + still obey `RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. ## Relationship To Other Specs diff --git a/docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md b/docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md new file mode 100644 index 00000000..013a4749 --- /dev/null +++ b/docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md @@ -0,0 +1,563 @@ +# Runtime Ownership Boundary Freeze + +Status: normative + +本文档冻结 Trail Mate runtime 关键机制的 ownership 边界。它不是阶段计划,也不是事故复盘。 +后续修改必须先遵守这里的 owner 关系,再考虑局部实现。不能再用页面补丁、协议旁路、 +storage 双写侥幸、或资源临时判断去绕开主路径。 + +如果本文档与旧的实现说明冲突,以本文档为准;旧文档必须被更新,而不是让代码继续选择 +更方便的旁路。 + +## One Rule + +一个事实只能有一个权威 owner。 + +其他模块只能提交 intent、消费 projection、或执行 owner 给出的 effect。任何模块只要同时 +“读取事实、改写事实、解释失败、刷新 UI”,就已经越界。 + +## Scope + +本文档冻结以下机制: + +1. UI 与 runtime 的分工。 +2. MT / MC / RT 三协议消息投递状态。 +3. read / unread / badge 状态。 +4. Reticulum direct / propagation 去重与确认。 +5. Reticulum Sideband/LXST call 与 realtime resource lease。 +6. MQTT downlink 到 LoRa 的空口预算与 UI 非阻塞关系。 +7. 外部字体和语言包加载。 +8. Contacts / Network 投影分类。 +9. God file 拆解后的 owner 迁移规则。 + +## Non-Negotiable Boundaries + +### UI + +UI 只允许: + +1. 发出用户 intent。 +2. 展示 projection snapshot。 +3. 展示 runtime 明确给出的 pending / failure / progress。 +4. 管理页面本地选择、滚动位置、焦点和可见导航。 + +UI 不允许: + +1. 直接解析协议包、announce、LXMF envelope、Meshtastic protobuf 或 MeshCore frame。 +2. 直接改 read/unread、delivery、contact、path、link、call、font loaded 状态。 +3. 为了解决显示问题私自加载字体或访问 SD 字体文件。 +4. 为了 call、download、MQTT 或 LoRa 直接停止硬件资源。 +5. 通过隐藏 badge、刷新列表、删除 item 等方式伪装业务状态已经改变。 + +### Settings + +Settings 只提交 product intent。 + +Settings 不允许决定协议内部 wire profile、packet context、call fallback、resource lease 或 +字体加载策略。Settings 可以选择 active protocol、Wi-Fi profile、Reticulum gateway、通知策略、 +音量和 locale,但不能把这些选择实现成绕过 runtime owner 的私有分支。 + +### Protocol Adapters + +MT / MC / RT adapter 可以拥有 wire codec、平台 IO 适配、队列接入和协议 runtime 组合。 + +它们不允许拥有通用业务状态。消息状态、read/unread、conversation badge、联系人投影、 +发送重试、去重 ledger 必须进入共享 owner,再投影给 UI。 + +协议差异必须以 protocol-aware event 表达,而不是把 UI 或 ChatService 退回到 bare msg_id、 +node id 或 packet id。 + +### Store And Index + +index、conversation list、message list cache、header mirror 都是 projection 或 cache。它们可以 +加速显示,但不能成为业务事实权威。 + +如果一个状态重启后应该保持一致,它必须有独立 owner 或 ledger。靠多个文件同时写成功来维持 +状态,属于未收敛设计。 + +## Authoritative Owners + +| Fact | Owner | Projection | Hard invariant | +| --- | --- | --- | --- | +| Outgoing/incoming message identity | `MessageLedger` | Chat message rows | MT/MC/RT 都必须带 protocol-aware identity,不得只靠裸 `msg_id` | +| Delivery state | `MessageLedger` + `ChatDeliveryEventProjector` | Message badge, feedback | `Delivered` 必须来自 ACK/proof/receipt 或协议等价事实 | +| Read/unread state | `ReadStateLedger` | Conversation badge, unread budget, app badge | Index/header 只能镜像,不能是权威 | +| Conversation list | `ConversationProjectionStore` | Chat workspace snapshot | 可重建,不得反向改 ledger | +| UI chat state | `ChatWorkspaceModel` | Renderer | 只保存 selection/offset,不保存业务状态 | +| Runtime events | `ChatPageRuntimeEventPump` | UI refresh sink | 事件泵路由事件,不渲染、不推断业务结果 | +| Reticulum destination | `DestinationRegistry` | Contacts/Network row | Full destination hash/aspect 是权威,projected node id 不是 | +| Reticulum path | `PathManager` | Path diagnostics, send eligibility | Freshness/replay/coalescing/expiry 只能在一个地方裁决 | +| Reticulum link | `LinkManager` | Call/link status | Link open/identify/keepalive/close 只有一个 lifecycle owner | +| Reticulum announce ingest | `AnnounceIngestor` | Contacts/Network/propagation metadata | 验签、identity/destination 关联、path observation 统一完成 | +| Reticulum packet routing | `ReticulumPacketRouter` | Domain events | Packet type/context 到 owner 的路由只有一个入口 | +| Propagation sync | `PropagationClient` + propagation seen/ack ledger | Chat projection | 重复 offer 不能产生重复消息或重复 unread | +| Reticulum call | `LxstTelephonyClient` | Call Page projection | 产品 call path 只支持 Sideband/LXST | +| Call resources | Call realtime leases + `WifiAccessRuntime` | Call Page progress/failure | UI 不直接抢占 Wi-Fi/LoRa/GPS/audio | +| Audio hardware | Platform audio adapter | Ring/call volume projection | ES8311/I2S/mic/speaker setup teardown 只有一个 owner | +| Notification policy | Notification policy runtime | Tone/vibration/notice intents | 消息提示、联系人提示、静音/震动/音量只消费业务 projection | +| Font loading | `FontRuntimeCoordinator` + `ResourcePackRegistry` | Loading page/modal + refreshed font chain | 缺字不得被 active locale 或 hot-path 永久拦掉 | +| MQTT downlink relay | Meshtastic runtime TX queue / air-time budget owner | Send/deferred/drop state | UI 不等待 LoRa TX,MQTT burst 不直接占满 UI tick | + +## Runtime Overview Design + +概要设计固定为四个 runtime 面向产品组合,而不是页面补丁组合: + +```text +Product intent + -> Protocol facade + -> Domain owner + -> Ledger / queue / lease + -> Projection + -> UI renderer +``` + +1. Reticulum call 由 `LxstTelephonyClient` 拥有协议事实,由 Call realtime leases + 拥有 Wi-Fi/LoRa/GPS/sleep/audio 资源事实,由 Call Page 展示 projection。 +2. Notification 由 Notification runtime 拥有产品策略事实,platform audio adapter 拥有 + ES8311/I2S/扬声器/麦克风硬件事实。消息事件、联系人事件和 Settings 预览只能提交 + notification intent。 +3. Contacts 只消费 person/contact projection。Network 消费 service/relay/web/unknown + projection。二者都不解析 Reticulum announce。 +4. LoRa TX 由协议 adapter 的 TX scheduler 拥有空口事实。业务层只能 enqueue。 + `sendAppData()` 成功表示进入 scheduler,不表示已经占用空口发射完成。 +5. `LxmfAdapter` 只能作为 Reticulum facade/coordinator shell 存在。新增功能必须优先落在 + DestinationRegistry、PathManager、LinkManager、AnnounceIngestor、ReticulumPacketRouter、 + PropagationClient、PingService、NetworkPageClient 或 LxstTelephonyClient。 +6. Adapter 内不允许重新引入独立的调度状态、RX 统计状态、deferred discovery queue + 或 MTU scratch 数组;这些事实分别属于 `RuntimeBudget`、`RawRxTelemetry`、 + `DeferredDiscoveryQueue` 和 `AdapterScratchBuffers`。 + +## Runtime Detailed Design + +### Reticulum Call + +详细设计: + +1. 产品 call profile 固定为 Sideband-compatible `lxst.telephony`。 +2. 用户主动拨出直接进入 hard preempt,因为用户已经明确提交通话 intent。 +3. 来电 LinkRequest 可进入 identifying/ringing 资源阶段;接听前不报告已接通。 +4. 接听必须先拿到 hard realtime lease,再启动 media session。任一步失败都进入明确失败, + 不自动接听。 +5. 通话中只允许当前 `link_id` 的 LXST audio RX/TX。 +6. 挂断/远端关闭/媒体失败/timeout 必须统一进入 Closing,再释放 lease。 +7. MeshChat `call.audio` 只允许作为默认不注册的源代码兼容 adapter,不允许进入 product + Settings、不允许自动 fallback、不允许主 LXST path 分支依赖它。 + +### Notification And Audio + +详细设计: + +1. `ChatNewMessageEvent`、`NodeInfoUpdateEvent`、Settings 音量预览都必须调用 + Notification runtime。 +2. Notification runtime 读取 message alerts、contact alerts、vibration、tone volume 等 + product policy,输出 tone/vibration intent。 +3. Notification runtime 不允许解析消息协议、不允许改 unread、不允许绕过 platform audio + adapter。 +4. Call ring 和 call media 仍由 Call realtime/audio owner 控制;Notification runtime + 不得在 `ActiveCall` 抢占通话音频。 +5. Platform audio adapter 是唯一硬件 owner,负责 ES8311/I2S/mic/speaker session open、 + volume、gain、mute、teardown。 + +### Contacts / Network + +详细设计: + +1. Contacts 使用 `ReticulumContactProjectionPolicy`,只投影有效 LXMF address/person 记录: + favorite/manual/import 为 Contact,runtime announce 为 Announced,ignored 为 Ignored。 +2. Contacts 不显示 propagation、Nomad/web/service、unknown、gateway、interface 或 path hop。 +3. Network 使用 `ReticulumNetworkProjectionPolicy`,投影非联系人 announce: + `lxmf.propagation` 为 Message Relay,`nomadnetwork.node` 为 Web/Service, + `lxst.telephony`/legacy `call.audio` 为 Telephony Service,unknown 为 Unknown Service。 +4. PropagationClient 可以后台维护 relay metadata;UI 是否显示 relay 由 Network projection + policy 决定,不能通过 Contacts 旁路显示。 +5. Destination hash 和 projected node id 只是地址/搜索 metadata,不是联系人身份权威。 +6. `PeerDirectoryService` 拥有 Reticulum peer directory 的读写、热加载和投影队列; + adapter 只作为 `IPeerProjectionSink` 发布最终 NodeInfo/Protocol update event。 + +### Reticulum Runtime Owners + +详细设计: + +1. `RuntimeBudget` 是 call/nomad/sleep/saver/P4 screen 阶段的唯一调度策略输出。 + adapter 只能提供输入事实,不能复制阶段判定。 +2. `AnnounceScheduler` 拥有本机 announce pending、retry、interval 和 rebroadcast + 节流状态。adapter 只执行签名、组包和实际 TX。 +3. `DeferredDiscoveryQueue` 拥有 public discovery 的 bounded queue、drop-oldest 和 + packet-hash 去重。adapter 只判断是否 defer 和如何 replay。 +4. `RawRxTelemetry` 拥有 RX summary counters、LoRa discovery detail 抑制和 LoRa + ignored announce 抑制。adapter 不保存这些 counter。 +5. `AdapterScratchBuffers` 是 MTU 级 packet scratch 的长期 owner。新增 MTU buffer + 不能以裸字段散落在 adapter。 + +### LoRa TX Scheduler + +详细设计: + +1. 所有会占用 LoRa 空口的发送都必须进入同一个 scheduler tick。 +2. `sendText()`、`sendAppData()`、key verification、runtime protocol effects、MQTT + downlink relay、ACK retry 都不能从 UI/event/RX path 直接同步阻塞 radio TX。 +3. 每个 tick 持有 `kLoRaAirTxBudgetPerTick`。协议动作、ACK retry、普通消息、MQTT + downlink 成功 enqueue radio TX 时都消耗这个预算。 +4. `min_tx_interval_ms_` 是跨 TX owner 的共享节流,不是某个队列自己的局部判断。 +5. MQTT downlink 保持官方 gateway relay 语义,但必须按 `from + id + channel` 去重, + 入队,按预算 drain;队列满必须产生 drop/deferred reason,而不是卡 UI。 +6. UI 只能展示 Queued/Sending/Sent/Delivered/Failed 或 deferred/drop projection,不能等待 + LoRa TX 完成后才继续渲染。 + +## Notification Policy Contract + +通知策略是 product policy,不是消息存储、协议 adapter 或音频驱动的副作用。 + +Settings 可以配置: + +1. message alerts enabled/disabled。 +2. contact alerts: none / contacts only / all discovered people,或等价用户可理解选项。 +3. vibration enabled/disabled。 +4. message tone volume。 +5. call ring volume。 + +通知 runtime 只能消费: + +1. message projection。 +2. contact/person projection。 +3. read/unread projection。 +4. user notification policy。 +5. active interruption/call state。 + +通知 runtime 可以输出: + +1. play message tone intent。 +2. start/stop call ring intent。 +3. vibrate intent。 +4. on-screen notice intent。 + +它不允许: + +1. 自己判定消息 delivered。 +2. 自己清 unread。 +3. 直接解析 protocol packet。 +4. 绕过 platform audio adapter 播放声音。 +5. 在 call active/exclusive 时启动非通话音频。 + +消息提示音、来电铃声、Settings 音量预览、通话播放都必须经过同一个 platform audio owner。 +如果音频 owner 不可用,通知 runtime 只能得到显式失败或 deferred 结果,不能静默吞掉声音。 + +## Message State Contract + +消息状态是抽象业务状态,协议 adapter 只负责把协议事实映射进它。 + +允许的业务状态: + +1. `Queued`: 已进入本地 outbox 或等待 runtime 机会。 +2. `Sending`: 正在发送或等待协议收据。 +3. `Sent`: 已发出但协议没有或不承诺端到端送达证明。 +4. `Delivered`: 已收到 ACK、proof、receipt 或协议定义的等价送达事实。 +5. `Failed`: 发送被拒绝、无线发送失败、ACK 超时、资源不可用或协议不支持。 + +规则: + +1. MT direct 且需要 ACK:`Queued -> Sending -> Delivered/Failed`。 +2. MT broadcast/group 或 ackless 成功:`Queued -> Sending -> Sent`。 +3. MC app ACK 完成:进入 `Delivered`。 +4. MC app ACK 超时:进入 `Failed(AckTimeout)`。 +5. RT LXMF proof/receipt 完成:进入 `Delivered`。 +6. RT propagation 本地接收成功不等于远端 delivered;它只证明本机 durable accepted。 +7. 同一个裸 `msg_id` 出现在 MT/MC/RT 时,只能更新匹配 protocol 的 message ref。 +8. UI badge 可以只显示简化文字,但状态来源必须是 ledger/projection。 + +禁止: + +1. 继续发只有 `msg_id + bool` 的最终业务事件作为新路径。 +2. 让 renderer 根据“发送函数返回 true”显示已送达。 +3. 在 retry、delivery action、presentation lookup 中丢掉 protocol 字段。 +4. 因为找不到消息就创建另一个同内容 outgoing item。 + +## Read And Unread Contract + +`ReadStateLedger` 是 read/unread 的唯一权威。 + +它必须表达: + +1. protocol。 +2. conversation identity。 +3. last read durable cursor 或等价 read watermark。 +4. commit 状态。 +5. 必要时的 pending/failed mark-read 结果。 + +读取规则: + +1. unread count 由 `MessageLedger + ReadStateLedger` 推导。 +2. conversation index、SD header、app badge、screen badge 都是投影。 +3. 重启后必须从 ledger 恢复同一个 unread 结果。 +4. projection 可以落后,但不能与 ledger 长期冲突。 + +写入规则: + +1. `ChatWorkspaceModel::markRead(...)` 只是 UI intent。 +2. `IChatActionSink` 把 intent 交给 app/runtime service。 +3. app/runtime service 提交 `ReadStateLedger`。 +4. projection store 收到 committed 或 pending 事实后刷新 badge。 +5. durable commit 失败时必须保留可解释失败或 pending,而不是 UI 假成功。 + +禁止: + +1. 只改 index/header 却不改 ledger。 +2. 只在 UI 隐藏 unread badge。 +3. read 状态以某个页面是否打开作为权威。 +4. Reticulum direct 和 propagation 两条路径各自增加 unread。 + +## Reticulum Client Contract + +Trail Mate 是 Reticulum client,不是通用 transport node、propagation node、gateway 或 service host。 + +产品能力固定为: + +1. LXMF direct delivery。 +2. LXMF propagation retrieval。 +3. client 所需的 path discovery、identity recall、link lifecycle、proof/receipt。 +4. Sideband-compatible `lxst.telephony` call。 +5. Nomad/Micron 服务发现和浏览,投影到 Network。 + +Reticulum 主路径必须遵守: + +1. `ReticulumPacketRouter` 是唯一入口。 +2. `AnnounceIngestor` 统一完成 announce 验签、identity/destination 关联和 path observation。 +3. `DestinationRegistry` 拥有 destination truth。 +4. `PathManager` 拥有 path truth。 +5. `LinkManager` 拥有 link truth。 +6. `MessageLedger` 拥有 LXMF idempotency。 +7. `PropagationClient` 拥有 propagation offer/ack/seen。 +8. `LxstTelephonyClient` 拥有 call truth。 + +禁止: + +1. UI、notification、Settings 或 Contacts 解析 Reticulum wire bytes。 +2. `LxmfAdapter` 再次拥有 path、link、message、propagation、call 主状态。 +3. 在主 LXST call path 中加入 MeshChat `call.audio` fallback 分支。 +4. 在 product Settings 中显示 call protocol selector。 + +MeshChat `call.audio` 可以保留为源代码兼容/协议研究 adapter,但默认不注册、不进入产品图、 +不自动 fallback、不作为用户可选配置。 + +## Reticulum Propagation Contract + +Propagation 的重复 offer 是 Reticulum/LXMF 网络行为的一部分;重复展示给用户不是可接受行为。 + +规则: + +1. Direct 和 propagation 必须在 LXMF envelope validation 之前或之中汇合到同一 message ledger。 +2. 完整 LXMF message hash 是跨重启、跨 direct/propagation 的 idempotency key。 +3. 重复 offer 可以更新 transport metadata、last seen、source path,但不能创建新消息。 +4. 本机只有在消息 durable accepted 后才发送 propagation acknowledgement。 +5. ack/seen ledger 必须能跨重启阻止重复用户可见 delivery。 + +禁止: + +1. propagation 每次拉取都 append 聊天记录。 +2. ack 在 durable message commit 前发出。 +3. 用 sender + timestamp + text 这种弱 key 替代 LXMF hash。 +4. direct 和 propagation 各自维护重复检测。 + +## Call Realtime Contract + +产品 call path 是 Sideband-compatible LXST。接听体验是 Call Page,不是 UI modal。 + +状态: + +1. `Idle` +2. `IncomingIdentifying` +3. `IncomingRinging` +4. `PathResolving` +5. `LinkConnecting` +6. `ResourceAcquiring` +7. `MediaPreparing` +8. `Active` +9. `Closing` + +资源规则: + +1. Incoming identifying/ringing 拥有 Call Page,并 soft-preempt Wi-Fi。 +2. Incoming ringing 暂停 LoRa 和 GPS。BLE 在 ESP 产品固件中不编译,不存在 runtime lease。 +3. 用户主动拨出直接进入 hard preempt。 +4. 用户接听后先获取 hard preempt 和 audio session,再报告接听成功。 +5. Active/Closing 阶段只允许当前 call link 的音频流量。 +6. 非可中断 critical operation 导致接听/拨出失败,不自动接听。 +7. 同时来电或通话中来电必须快速失败,不能排队成另一个 UI call。 +8. Closing 持有 exclusive lease,直到 LinkClose 发出/观察到或 bounded cleanup 完成。 + +UI 规则: + +1. Call Page 展示 caller、identifying/connecting/active/closing/failure。 +2. 接听、拒接、挂断、音量快捷键是页面 action。 +3. 页面底部展示通话期间可用的快捷键。 +4. Call Page 不直接停止 Wi-Fi、LoRa、GPS、audio 或 MQTT。 + +Audio 规则: + +1. Platform audio adapter 拥有 ES8311/I2S/mic/speaker setup/teardown。 +2. Ring tone、message tone、settings tone、call playback 都必须进入同一 audio owner。 +3. 接听后默认 speaker volume 可提升到通话 profile 的最大安全音量。 +4. RX decode/playback 与 TX capture/encode 不能互相阻塞。 +5. 任何 echo suppression、gain、jitter buffer 改动必须属于 media session,不得散落在 UI。 + +## MQTT Downlink And LoRa Air-Time Contract + +Meshtastic MQTT downlink 可以保持 gateway 语义,但必须经过统一空口预算。 + +规则: + +1. MQTT downlink 先进入 projection/ingest,不直接在 MQTT callback 中同步 LoRa TX。 +2. LoRa TX 进入统一 TX queue 和 air-time budget。 +3. downlink relay 必须按 `from + packet id + channel` 强去重。 +4. 每个 tick 限制 downlink drain 数量。 +5. UI 只消费 projection,不等待 LoRa TX 完成。 +6. 队列满或预算不足时,消息状态进入 queued/deferred/drop reason,而不是卡住 UI。 +7. LoRa 空口长包、重复 burst、route flood 不能占用 display/input wake path。 + +禁止: + +1. MQTT callback 直接循环发 LoRa。 +2. 为了防卡死永久禁掉 downlink-to-LoRa 官方语义。 +3. 让 UI tick 承担 relay flush。 +4. 没有去重地把同一 downlink burst 多次打到空口。 + +## Font And Localization Runtime Contract + +字体是 runtime resource,不是页面私有修复点。 + +核心规则: + +1. CJK/Japanese/Korean/Arabic 等 content text 的字形需求不由 active display locale 决定。 +2. `active_locale=en` 时,如果聊天、Network、Contacts 或 Nomad 页面出现中文内容,已安装且可用的 + `zh-hans-core` 等 content supplement 仍必须允许加载和加入 content font chain。 +3. 缺字检测可以发生在内容路径,但加载决策必须交给 `FontRuntimeCoordinator` / + `ResourcePackRegistry`。 +4. 同步外部字体加载是允许的,但只能作为用户可见的 foreground operation: + 显示 loading/progress/busy 页面或 modal,flush 到屏幕,拿到 SD/shared-SPI lease,然后加载。 +5. 普通 render/list/timer 路径不得无主静默阻塞 SD IO。 +6. 总线忙、内存不足、文件损坏必须形成可解释诊断和重试/失败状态,不能被永久 hard skip。 +7. 页面不得因为 `ui_hot_path`、`active_locale`、或 `content_supplement` 标签直接否决字体加载。 + +禁止: + +1. 页面/widget 直接读取 `font.bin`。 +2. 在 renderer 中用“非 ASCII 就切 CJK 字体”的旁路替代 font chain。 +3. 以 active locale 不是中文为理由阻止中文 content font。 +4. 以保护 UI 为理由让中文永久显示 tofu boxes。 +5. 创建 LVGL modal 但未 flush 就进入 `lv_binfont_create()`。 + +## Contacts And Network Projection Contract + +Contacts 按字面意思,只显示可通信的人或身份。 + +允许进入 Contacts: + +1. verified LXMF person destination。 +2. verified LXST telephony destination。 +3. 能关联到同一个身份的人名、短名、地址和可通信 destination。 + +不得进入 Contacts: + +1. propagation node。 +2. gateway/interface/path hop。 +3. Nomad/web/service。 +4. unknown announce。 +5. relay-only 或 message infrastructure。 + +Network 显示网络能力和服务: + +1. Nomad/Micron service。 +2. web/service destination。 +3. propagation node 状态。 +4. gateway/interface diagnostics。 +5. path/interface health。 + +Contacts 和 Network 都只能消费 projection,不允许读取 raw announce 或直接维护 protocol truth。 + +## God File Burn-Down Contract + +拆 God file 不是物理拆文件,而是 owner 迁移。 + +每迁移一个事实必须一次完成: + +1. 建立 owner。 +2. 迁移状态和不变量。 +3. Adapter 调用 owner。 +4. 删除 Adapter 中旧状态和旧 mutation。 +5. 增加或更新合同测试。 + +完成前不得宣称 facade 化。`LxmfAdapter` 只有在以下条件满足时才算 facade: + +1. 对外方法只转发 use-case。 +2. path/link/destination/message/propagation/call state 不在 adapter 内直接写。 +3. UI 和 Settings 不读 adapter 内部协议细节。 +4. old mutation 已删除,而不是留作 fallback。 +5. compatibility code 与 product graph 隔离。 + +## Prohibited Patch Patterns + +以下修改方式禁止进入主线: + +1. 页面级特殊判断修复协议或存储问题。 +2. `if (reticulum_call::realtime_mode_active()) return;` 这类散落资源判断。 +3. 绕开 `ChatDeliveryEventProjector` 更新消息状态。 +4. 绕开 `ReadStateLedger` 更新 unread。 +5. 绕开 `MessageLedger` 接收 direct/propagation 消息。 +6. 绕开 `FontRuntimeCoordinator` 加载或拒绝字体。 +7. 绕开 `WifiAccessRuntime` 抢占 Wi-Fi。 +8. 在 Settings 中暴露尚未形成产品闭环的兼容/实验协议分支。 +9. 为了让当前 case 通过而让主路径永远不命中。 +10. 新增一个 owner 但不删除旧 owner。 + +## Change Gate + +修改实现前必须回答: + +1. 这次改动的事实 owner 是谁? +2. intent 从哪里进入? +3. effect 由谁执行? +4. projection 从哪里产生? +5. durable state 在哪里提交? +6. 重启后如何恢复? +7. 协议字段是否保留 protocol-aware identity? +8. UI 是否只看 snapshot/projection? +9. 资源 lease 是否由 runtime owner 申请? +10. 是否存在旧旁路仍可命中? + +如果任一问题没有答案,先补 owner/spec/test,再改实现。 + +## Required Regression Contracts + +后续相关修改至少需要覆盖以下合同: + +1. `active_locale=en` 时中文聊天、Network/Nomad 内容能触发受控字体加载并最终使用 content font。 +2. 字体加载前用户能看到 loading/progress/busy 状态,且不是静默 SD 阻塞。 +3. mark-read durable commit 后重启 unread 不复活。 +4. mark-read commit 失败不会让 UI 假装成功。 +5. direct 与 propagation 同一 LXMF hash 只产生一条消息和一次 unread transition。 +6. MT/MC/RT 相同裸 id 只更新对应 protocol 的 delivery/read 状态。 +7. ackless send 不会永久显示 Sending。 +8. failed send 有 protocol-aware failure kind。 +9. MQTT downlink burst 不阻塞 UI wake/render。 +10. MQTT downlink relay 经 LoRa queue/air-time budget 和去重。 +11. Incoming/active call resource lease 阶段与 UI Call Page 状态一致。 +12. 通话中再次来电快速失败。 +13. call ring、message tone、settings tone 和 call playback 都经过同一个 audio owner。 +14. message alerts/contact alerts/vibration/audio volume 策略不改变 delivery 或 unread 事实。 +15. Contacts 不出现 propagation、service、gateway/interface、unknown announce。 +16. Network 能呈现服务和网络基础设施,不污染 Contacts。 + +## Relationship To Existing Specs + +本文档是总边界冻结文档。相关细节继续由以下文档维护: + +1. `docs/specification/CHAT_DELIVERY_RUNTIME_SPEC.md` +2. `docs/specification/CHAT_WORKSPACE_MODEL_SPEC.md` +3. `docs/specification/CHAT_PRESENTATION_IDENTITY_SPEC.md` +4. `docs/specification/LOCALIZATION_SPEC.md` +5. `docs/specification/PROTOCOL_RUNTIME_DESIGN_SPEC.md` +6. `docs/reticulum_client_architecture.md` +7. `docs/wifi_access_resource_policy.md` +8. `docs/MULTI_PROTOCOL_SUPPORT.md` + +当实现与本文档冲突时,不能通过局部代码补丁解决;必须回到 owner 边界,修正主路径。 diff --git a/docs/ui_localization_plan.md b/docs/ui_localization_plan.md index fdc825bb..342b4a13 100644 --- a/docs/ui_localization_plan.md +++ b/docs/ui_localization_plan.md @@ -2,8 +2,8 @@ 历史说明: 本文件记录的是一份较早期的实现计划,已经不再是当前的规范性设计来源。 -当前本地化契约见 [`docs/LOCALIZATION_SPEC.md`](./LOCALIZATION_SPEC.md), -pack/runtime 机制说明见 [`docs/LOCALE_PACKS.md`](./LOCALE_PACKS.md)。 +当前本地化契约见 [`docs/specification/LOCALIZATION_SPEC.md`](specification/LOCALIZATION_SPEC.md), +pack/runtime 机制说明见 [`docs/specification/LOCALE_PACKS.md`](specification/LOCALE_PACKS.md)。 尤其需要注意的是,本计划中仍保留了 `display_language` 这类历史概念, 它们已经被当前的 locale-pack 架构取代。 diff --git a/docs/wifi_access_resource_policy.md b/docs/wifi_access_resource_policy.md index 8c266cb9..06a2247d 100644 --- a/docs/wifi_access_resource_policy.md +++ b/docs/wifi_access_resource_policy.md @@ -5,6 +5,11 @@ Trail Mate devices. It prevents HTTP downloads, MQTT, Reticulum Wi-Fi gateway, OTA, SD writes, and UI wake rendering from competing as independent owners of the same small device resources. +Runtime ownership rules for calls, protocol traffic, audio, font loading, and +UI projection are frozen by +`docs/specification/RUNTIME_OWNERSHIP_BOUNDARY_FREEZE.md`. This Wi-Fi policy is +the resource-specific child contract for the Wi-Fi owner. + ## Distinctions - `platform::ui::wifi` is the Wi-Fi control plane. It owns STA configuration, 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 40da403d..2f220cf8 100644 --- a/modules/core_chat/include/chat/infra/store/ram_store.h +++ b/modules/core_chat/include/chat/infra/store/ram_store.h @@ -34,7 +34,7 @@ class RamStore : public IChatStore std::vector loadConversationPage(size_t offset, size_t limit, size_t* total) override; - void setUnread(const ConversationId& conv, int unread) override; + bool setUnread(const ConversationId& conv, int unread) override; int getUnread(const ConversationId& conv) const override; void clearConversation(const ConversationId& conv) override; void clearAll() 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 e8fe5fcc..98c93aab 100644 --- a/modules/core_chat/include/chat/ports/i_chat_store.h +++ b/modules/core_chat/include/chat/ports/i_chat_store.h @@ -102,7 +102,7 @@ class IChatStore * @param conv Conversation ID * @param unread Unread count */ - virtual void setUnread(const ConversationId& conv, int unread) = 0; + virtual bool setUnread(const ConversationId& conv, int unread) = 0; /** * @brief Get unread count for conversation diff --git a/modules/core_chat/include/chat/read/chat_read_state_ledger.h b/modules/core_chat/include/chat/read/chat_read_state_ledger.h new file mode 100644 index 00000000..c2447fe6 --- /dev/null +++ b/modules/core_chat/include/chat/read/chat_read_state_ledger.h @@ -0,0 +1,22 @@ +#pragma once + +#include "chat/domain/chat_model.h" +#include "chat/ports/i_chat_store.h" + +namespace chat::read +{ + +class ChatReadStateLedger final +{ + public: + ChatReadStateLedger(ChatModel& model, IChatStore& store); + + bool markRead(const ConversationId& conversation, bool model_enabled); + int unread(const ConversationId& conversation) const; + + private: + ChatModel& model_; + IChatStore& store_; +}; + +} // namespace chat::read diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index 0abc2985..e41c1f40 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -11,6 +11,7 @@ #include "../ports/i_mesh_adapter.h" #include "chat/delivery/chat_delivery_event_port.h" #include "chat/delivery/chat_message_ledger.h" +#include "chat/read/chat_read_state_ledger.h" #include #include #include @@ -100,7 +101,7 @@ class ChatService * @brief Mark conversation as read * @param conv Conversation ID */ - void markConversationRead(const ConversationId& conv); + bool markConversationRead(const ConversationId& conv); /** * @brief Resend failed message @@ -273,6 +274,7 @@ class ChatService IMeshAdapter& adapter_; IChatStore& store_; delivery::ChatMessageLedger message_ledger_; + read::ChatReadStateLedger read_state_ledger_; ChannelId current_channel_; bool model_enabled_ = true; MeshProtocol active_protocol_ = MeshProtocol::Meshtastic; diff --git a/modules/core_chat/src/infra/store/ram_store.cpp b/modules/core_chat/src/infra/store/ram_store.cpp index be512633..0a7c71e1 100644 --- a/modules/core_chat/src/infra/store/ram_store.cpp +++ b/modules/core_chat/src/infra/store/ram_store.cpp @@ -143,10 +143,11 @@ std::vector RamStore::loadConversationPage(size_t offset, return list; } -void RamStore::setUnread(const ConversationId& conv, int unread) +bool RamStore::setUnread(const ConversationId& conv, int unread) { ConversationStorage& storage = getConversationStorage(conv); storage.unread_count = unread; + return true; } int RamStore::getUnread(const ConversationId& conv) const diff --git a/modules/core_chat/src/read/chat_read_state_ledger.cpp b/modules/core_chat/src/read/chat_read_state_ledger.cpp new file mode 100644 index 00000000..f2399988 --- /dev/null +++ b/modules/core_chat/src/read/chat_read_state_ledger.cpp @@ -0,0 +1,30 @@ +#include "chat/read/chat_read_state_ledger.h" + +namespace chat::read +{ + +ChatReadStateLedger::ChatReadStateLedger(ChatModel& model, IChatStore& store) + : model_(model), store_(store) +{ +} + +bool ChatReadStateLedger::markRead(const ConversationId& conversation, + bool model_enabled) +{ + if (!store_.setUnread(conversation, 0)) + { + return false; + } + if (model_enabled) + { + model_.markRead(conversation); + } + return true; +} + +int ChatReadStateLedger::unread(const ConversationId& conversation) const +{ + return store_.getUnread(conversation); +} + +} // namespace chat::read diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 136a1d67..d548d1bf 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -197,6 +197,7 @@ ChatService::ChatService(ChatModel& model, MeshProtocol active_protocol) : model_(model), adapter_(adapter), store_(store), message_ledger_(model, store), + read_state_ledger_(model, store), current_channel_(ChannelId::PRIMARY), active_protocol_(active_protocol) { @@ -581,10 +582,9 @@ void ChatService::clearConversation(const ConversationId& conv) recent_incoming_.clear(); } -void ChatService::markConversationRead(const ConversationId& conv) +bool ChatService::markConversationRead(const ConversationId& conv) { - model_.markRead(conv); - store_.setUnread(conv, 0); + return read_state_ledger_.markRead(conv, model_enabled_); } void ChatService::processIncoming() diff --git a/modules/core_chat/tests/test_chat_read_state_ledger.cpp b/modules/core_chat/tests/test_chat_read_state_ledger.cpp new file mode 100644 index 00000000..d4d615d6 --- /dev/null +++ b/modules/core_chat/tests/test_chat_read_state_ledger.cpp @@ -0,0 +1,88 @@ +#include "chat/infra/store/ram_store.h" +#include "chat/read/chat_read_state_ledger.h" + +#include + +namespace +{ + +chat::ConversationId conversation(chat::MeshProtocol protocol, + chat::ChannelId channel, + chat::NodeId peer) +{ + return chat::ConversationId(channel, peer, protocol); +} + +chat::ChatMessage incoming(chat::MessageId id, + const chat::ConversationId& conv) +{ + chat::ChatMessage message; + message.protocol = conv.protocol; + message.channel = conv.channel; + message.from = conv.peer == 0 ? 0x11223344 : conv.peer; + message.peer = conv.peer; + message.msg_id = id; + message.timestamp = 1000 + id; + message.text = "read"; + message.status = chat::MessageStatus::Incoming; + return message; +} + +class FailingUnreadStore final : public chat::RamStore +{ + public: + bool setUnread(const chat::ConversationId& conv, int unread) override + { + (void)conv; + (void)unread; + return false; + } +}; + +} // namespace + +int main() +{ + const chat::ConversationId mt_direct = + conversation(chat::MeshProtocol::Meshtastic, + chat::ChannelId::PRIMARY, + 0xAABBCCDD); + + chat::ChatModel model; + chat::RamStore store; + model.onIncoming(incoming(1, mt_direct)); + store.append(incoming(1, mt_direct)); + assert(model.getUnread(mt_direct) == 1); + assert(store.getUnread(mt_direct) == 1); + + chat::read::ChatReadStateLedger ledger(model, store); + assert(ledger.markRead(mt_direct, true)); + assert(model.getUnread(mt_direct) == 0); + assert(store.getUnread(mt_direct) == 0); + + chat::ChatModel failing_model; + FailingUnreadStore failing_store; + failing_model.onIncoming(incoming(2, mt_direct)); + failing_store.append(incoming(2, mt_direct)); + chat::read::ChatReadStateLedger failing_ledger(failing_model, + failing_store); + assert(!failing_ledger.markRead(mt_direct, true)); + assert(failing_model.getUnread(mt_direct) == 1); + assert(failing_store.getUnread(mt_direct) == 1); + + const chat::ConversationId reticulum_broadcast = + conversation(chat::MeshProtocol::Reticulum, + chat::ChannelId::PRIMARY, + 0); + chat::RamStore protocol_store; + chat::ChatModel protocol_model; + protocol_store.append(incoming(7, reticulum_broadcast)); + protocol_model.onIncoming(incoming(7, reticulum_broadcast)); + chat::read::ChatReadStateLedger protocol_ledger(protocol_model, + protocol_store); + assert(protocol_ledger.unread(reticulum_broadcast) == 1); + assert(protocol_ledger.markRead(reticulum_broadcast, true)); + assert(protocol_ledger.unread(reticulum_broadcast) == 0); + + return 0; +} diff --git a/modules/core_chat/tests/test_esp_sd_store_read_state_contract.cpp b/modules/core_chat/tests/test_esp_sd_store_read_state_contract.cpp new file mode 100644 index 00000000..c2a86317 --- /dev/null +++ b/modules/core_chat/tests/test_esp_sd_store_read_state_contract.cpp @@ -0,0 +1,122 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + assert(stream.is_open()); + std::ostringstream out; + out << stream.rdbuf(); + return out.str(); +} + +bool contains(const std::string& haystack, const char* needle) +{ + return haystack.find(needle) != std::string::npos; +} + +std::size_t positionOf(const std::string& haystack, const char* needle) +{ + const auto pos = haystack.find(needle); + assert(pos != std::string::npos); + return pos; +} + +std::size_t positionOfAfter(const std::string& haystack, + const char* needle, + std::size_t offset) +{ + const auto pos = haystack.find(needle, offset); + assert(pos != std::string::npos); + return pos; +} + +std::string bodyBetween(const std::string& source, + const char* begin, + const char* end) +{ + const auto begin_pos = positionOf(source, begin); + const auto end_pos = positionOfAfter(source, end, begin_pos); + return source.substr(begin_pos, end_pos - begin_pos); +} + +} // namespace + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path repo_root = argv[1]; + const std::string header = readFile( + repo_root / + "platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h"); + const std::string source = readFile( + repo_root / + "platform/esp/arduino_common/src/chat/infra/store/sd_store.cpp"); + + assert(contains(header, "kReadStateFile = \"/chat/read_state.bin\"")); + assert(contains(header, "struct ReadStateEntry")); + assert(contains(header, "kReadStateMagic")); + assert(contains(header, "readStateUnreadOrLegacy")); + assert(contains(header, "writeReadStateUnread")); + assert(contains(header, "removeReadStateEntry")); + + const std::string ctor_body = + bodyBetween(source, "SdStore::SdStore()", "void SdStore::append"); + assert(contains(ctor_body, "reconcileIndexUnread(entries)")); + assert(positionOf(ctor_body, "reconcileIndexUnread(entries)") < + positionOf(ctor_body, "writeIndex(entries)")); + + const std::string append_body = bodyBetween( + source, "bool SdStore::appendInternal", "std::vector SdStore::loadRecent"); + assert(positionOf(append_body, "already_committed") < + positionOf(append_body, "readStateUnreadOrLegacy(conv, &committed_unread)")); + assert(positionOf(append_body, "readStateUnreadOrLegacy(conv, &committed_unread)") < + positionOf(append_body, "updateIndexForMessage(msg, committed_unread)")); + assert(positionOf(append_body, "writeReadStateUnread(conv, unread)") < + positionOf(append_body, "updateIndexForMessage(msg, unread)")); + assert(positionOf(append_body, "CHAT_STORE_LOG(\"[AppContext] chat unread persist failed stage=read_state") < + positionOf(append_body, "removeReadStateEntry(conv)")); + + const std::string set_unread_body = + bodyBetween(source, "bool SdStore::setUnread", "int SdStore::getUnread"); + assert(positionOf(set_unread_body, "writeReadStateUnread(conv, unread_count)") < + positionOf(set_unread_body, "writeConversationUnread(conv, unread_count)")); + assert(positionOf(set_unread_body, "writeConversationUnread(conv, unread_count)") < + positionOf(set_unread_body, "entries[index].unread = unread_count")); + assert(positionOf(set_unread_body, "entries[index].unread = unread_count") < + positionOf(set_unread_body, "writeIndex(entries)")); + + const std::string get_unread_body = + bodyBetween(source, "int SdStore::getUnread", "void SdStore::clearConversation"); + assert(contains(get_unread_body, "readStateUnreadOrLegacy(conv, &unread)")); + + const std::string clear_conversation_body = + bodyBetween(source, "void SdStore::clearConversation", "void SdStore::clearAll"); + assert(contains(clear_conversation_body, "removeReadStateEntry(conv)")); + + const std::string clear_all_body = + bodyBetween(source, "void SdStore::clearAll", "bool SdStore::updateMessageStatus"); + assert(contains(clear_all_body, "sd_remove(kReadStateFile)")); + assert(contains(clear_all_body, "sd_remove(kTempReadStateFile)")); + assert(contains(clear_all_body, "sd_remove(kBackupReadStateFile)")); + + const std::string reconcile_body = + bodyBetween(source, "bool SdStore::reconcileIndexUnread", "bool SdStore::readIndex"); + assert(positionOf(reconcile_body, "readStateUnreadOrLegacy(conv, &durable_unread)") < + positionOf(reconcile_body, "durable_unread = entry.unread")); + assert(positionOf(reconcile_body, "durable_unread = entry.unread") < + positionOf(reconcile_body, "writeReadStateUnread(conv, durable_unread)")); + + const std::string rebuild_body = + bodyBetween(source, "void SdStore::rebuildIndex", "bool SdStore::loadFileHeader"); + assert(contains(rebuild_body, "readStateUnreadOrLegacy(conv, &ledger_unread)")); + assert(contains(rebuild_body, "writeReadStateUnread(conv, unread)")); + + return 0; +} diff --git a/modules/core_chat/tests/test_lxmf_announce_scheduler.cpp b/modules/core_chat/tests/test_lxmf_announce_scheduler.cpp new file mode 100644 index 00000000..fef533c1 --- /dev/null +++ b/modules/core_chat/tests/test_lxmf_announce_scheduler.cpp @@ -0,0 +1,35 @@ +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h" + +#include + +int main() +{ + chat::lxmf::runtime::AnnounceScheduler scheduler; + + scheduler.resetAfterConfig(1000, false); + assert(!scheduler.next(2000, false, 120000, 1500, 30000).should_send); + assert(scheduler.next(2500, false, 120000, 1500, 30000).should_send); + + scheduler.completeAttempt(2500, true, false); + assert(!scheduler.next(32000, false, 120000, 1500, 30000).should_send); + assert(scheduler.next(32500, false, 120000, 1500, 30000).should_send); + + scheduler.completeAttempt(32500, true, true); + assert(!scheduler.next(150000, false, 120000, 1500, 30000).should_send); + assert(scheduler.next(153000, false, 120000, 1500, 30000).should_send); + + scheduler.resetAfterConfig(200000, true); + assert(!scheduler.next(400000, true, 120000, 1500, 30000).should_send); + assert(!scheduler.beginManualBroadcast(true)); + + scheduler.resetAfterConfig(400000, false); + scheduler.markIdentityChanged(); + assert(!scheduler.next(400500, false, 120000, 1500, 30000).should_send); + assert(scheduler.next(401500, false, 120000, 1500, 30000).should_send); + + assert(scheduler.rebroadcastDue(500000, 60000)); + assert(!scheduler.rebroadcastDue(550000, 60000)); + assert(scheduler.rebroadcastDue(560000, 60000)); + + return 0; +} diff --git a/modules/core_chat/tests/test_lxmf_deferred_discovery_queue.cpp b/modules/core_chat/tests/test_lxmf_deferred_discovery_queue.cpp new file mode 100644 index 00000000..fe80693e --- /dev/null +++ b/modules/core_chat/tests/test_lxmf_deferred_discovery_queue.cpp @@ -0,0 +1,71 @@ +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h" + +#include +#include + +namespace +{ + +namespace reticulum = chat::reticulum; + +void fillHash(uint8_t hash[reticulum::kFullHashSize], uint8_t seed) +{ + for (std::size_t index = 0; index < reticulum::kFullHashSize; ++index) + { + hash[index] = static_cast(seed + index); + } +} + +reticulum::interfaces::RxPacket makePacket(uint8_t seed) +{ + reticulum::interfaces::RxPacket packet{}; + packet.len = 3; + packet.data[0] = seed; + packet.data[1] = static_cast(seed + 1U); + packet.data[2] = static_cast(seed + 2U); + packet.interface_kind = reticulum::interfaces::InterfaceKind::WifiGateway; + packet.interface_id = seed; + packet.rx_meta.rssi_dbm_x10 = static_cast(-600 + seed); + return packet; +} + +} // namespace + +int main() +{ + chat::lxmf::runtime::DeferredDiscoveryQueue queue; + + uint8_t first_hash[reticulum::kFullHashSize] = {}; + fillHash(first_hash, 1); + bool dropped = true; + assert(queue.push(makePacket(1), first_hash, &dropped)); + assert(!dropped); + assert(queue.contains(first_hash)); + + reticulum::interfaces::RxPacket out{}; + assert(queue.pop(&out)); + assert(out.len == 3); + assert(out.data[0] == 1); + assert(out.interface_kind == reticulum::interfaces::InterfaceKind::WifiGateway); + assert(out.interface_id == 1); + assert(out.rx_meta.rssi_dbm_x10 == -599); + assert(!queue.pop(&out)); + + for (uint8_t seed = 10; seed < 10 + chat::lxmf::runtime::DeferredDiscoveryQueue::kDepth + 1; ++seed) + { + uint8_t hash[reticulum::kFullHashSize] = {}; + fillHash(hash, seed); + assert(queue.push(makePacket(seed), hash, &dropped)); + } + assert(dropped); + + uint8_t oldest_hash[reticulum::kFullHashSize] = {}; + fillHash(oldest_hash, 10); + assert(!queue.contains(oldest_hash)); + + uint8_t newest_hash[reticulum::kFullHashSize] = {}; + fillHash(newest_hash, 10 + chat::lxmf::runtime::DeferredDiscoveryQueue::kDepth); + assert(queue.contains(newest_hash)); + + return 0; +} diff --git a/modules/core_chat/tests/test_lxmf_runtime_budget.cpp b/modules/core_chat/tests/test_lxmf_runtime_budget.cpp new file mode 100644 index 00000000..1157f379 --- /dev/null +++ b/modules/core_chat/tests/test_lxmf_runtime_budget.cpp @@ -0,0 +1,80 @@ +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h" + +#include +#include + +namespace +{ + +using chat::lxmf::runtime::makeRuntimeBudget; +using chat::lxmf::runtime::RuntimeBudgetInput; + +void assertPhase(const char* actual, const char* expected) +{ + assert(actual != nullptr); + assert(std::strcmp(actual, expected) == 0); +} + +} // namespace + +int main() +{ + RuntimeBudgetInput input{}; + input.max_ingress_packets_per_poll = 4; + input.call_ingress_packets_per_poll = 8; + + input.call_realtime_active = true; + auto budget = makeRuntimeBudget(input); + assertPhase(budget.phase, "call"); + assert(budget.live_packet_limit == 8); + assert(!budget.allow_announce_tx); + assert(!budget.allow_propagation_client); + assert(budget.drop_public_discovery); + + input = RuntimeBudgetInput{}; + input.max_ingress_packets_per_poll = 4; + input.call_ingress_packets_per_poll = 8; + input.nomad_request_active = true; + budget = makeRuntimeBudget(input); + assertPhase(budget.phase, "nomad"); + assert(budget.live_packet_limit == 4); + assert(!budget.allow_peer_projection); + assert(!budget.allow_announce_tx); + + input = RuntimeBudgetInput{}; + input.screen_sleeping = true; + budget = makeRuntimeBudget(input); + assertPhase(budget.phase, "sleep"); + assert(budget.live_packet_limit == 1); + assert(budget.deferred_discovery_limit == 1); + assert(budget.allow_public_discovery); + assert(!budget.allow_persistence); + + input = RuntimeBudgetInput{}; + input.screen_saver_active = true; + budget = makeRuntimeBudget(input); + assertPhase(budget.phase, "saver"); + assert(budget.live_packet_limit == 1); + assert(budget.deferred_discovery_limit == 0); + assert(budget.drop_public_discovery); + + input = RuntimeBudgetInput{}; + input.max_ingress_packets_per_poll = 4; + budget = makeRuntimeBudget(input); +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + assertPhase(budget.phase, "p4_screen"); + assert(budget.deferred_discovery_limit == 4); + assert(budget.allow_public_discovery); + assert(budget.allow_persistence); +#else + assertPhase(budget.phase, "screen"); + assert(budget.deferred_discovery_limit == 0); + assert(!budget.allow_public_discovery); + assert(!budget.allow_persistence); +#endif + assert(budget.allow_peer_projection); + assert(budget.allow_announce_tx); + assert(budget.allow_propagation_client); + + return 0; +} diff --git a/modules/core_chat/tests/test_meshtastic_mqtt_downlink_contract.cpp b/modules/core_chat/tests/test_meshtastic_mqtt_downlink_contract.cpp index 1ec1430c..c18136ae 100644 --- a/modules/core_chat/tests/test_meshtastic_mqtt_downlink_contract.cpp +++ b/modules/core_chat/tests/test_meshtastic_mqtt_downlink_contract.cpp @@ -59,7 +59,11 @@ int main(int argc, char** argv) assert(contains(header, "struct MqttDownlinkSeenEntry")); assert(contains(header, "mqtt_downlink_tx_queue_")); assert(contains(header, "mqtt_downlink_seen_")); - assert(contains(header, "kMqttDownlinkTxDrainPerTick")); + assert(contains(header, "kSendQueueDrainPerTick")); + assert(contains(header, "kLoRaAirTxBudgetPerTick")); + assert(contains(header, "bool processProtocolActionQueue(uint32_t now_ms,")); + assert(contains(header, "bool processMqttDownlinkTxQueue(uint32_t now_ms,")); + assert(contains(header, "uint8_t& tx_budget_remaining")); const std::size_t inject_begin = positionOf(source, "bool MtAdapter::injectMqttEnvelope"); @@ -75,15 +79,45 @@ int main(int argc, char** argv) const std::size_t process_send_begin = positionOf(source, "void MtAdapter::processSendQueue()"); - const std::size_t process_mqtt_begin = - positionOfAfter(source, "void MtAdapter::processMqttDownlinkTxQueue", process_send_begin); - assert(positionOfAfter(source, "processMqttDownlinkTxQueue(now);", process_send_begin) < + const std::size_t process_mqtt_begin = positionOfAfter( + source, "bool MtAdapter::processMqttDownlinkTxQueue", process_send_begin); + assert(positionOfAfter(source, + "uint8_t tx_budget_remaining = kLoRaAirTxBudgetPerTick;", + process_send_begin) < process_mqtt_begin); + assert(positionOfAfter(source, + "processProtocolActionQueue(now, tx_budget_remaining);", + process_send_begin) < process_mqtt_begin); + assert(positionOfAfter(source, "drained < kSendQueueDrainPerTick", process_send_begin) < process_mqtt_begin); - assert(contains(source, "drained < kMqttDownlinkTxDrainPerTick")); + assert(positionOfAfter(source, + "processMqttDownlinkTxQueue(now, tx_budget_remaining);", + process_send_begin) < process_mqtt_begin); + assert(contains(source, "tx_budget_remaining > 0")); + assert(contains(source, "--tx_budget_remaining;")); assert(contains(source, "isMqttDownlinkRecentlySeen(header.from, header.id, header.channel")); assert(contains(source, "reason=pending_queue_full")); assert(contains(source, "reason=airtime_budget")); assert(contains(source, "\"radio_queue_full\"")); + const std::size_t public_app_data = + positionOf(source, "bool MtAdapter::sendAppData(ChannelId channel"); + const std::size_t app_data_now = + positionOfAfter(source, "bool MtAdapter::sendAppDataNow", public_app_data); + const std::string public_app_data_body = + source.substr(public_app_data, app_data_now - public_app_data); + assert(contains(public_app_data_body, "runtime::SendPacketEffect packet{};")); + assert(contains(public_app_data_body, "return enqueueSendPacketAction(packet);")); + assert(notContains(public_app_data_body, "transmitWirePacket(")); + + const std::size_t key_verify_begin = + positionOf(source, "bool MtAdapter::sendKeyVerificationPacket"); + const std::size_t routing_ack_begin = + positionOfAfter(source, "bool MtAdapter::sendRoutingAck", key_verify_begin); + const std::string key_verify_body = + source.substr(key_verify_begin, routing_ack_begin - key_verify_begin); + assert(contains(key_verify_body, "runtime::SendPacketEffect packet{};")); + assert(contains(key_verify_body, "return enqueueSendPacketAction(packet);")); + assert(notContains(key_verify_body, "transmitWirePacket(")); + return 0; } diff --git a/modules/core_chat/tests/test_reticulum_call_product_contract.cpp b/modules/core_chat/tests/test_reticulum_call_product_contract.cpp new file mode 100644 index 00000000..7c8027b4 --- /dev/null +++ b/modules/core_chat/tests/test_reticulum_call_product_contract.cpp @@ -0,0 +1,117 @@ +#include +#include +#include +#include +#include + +namespace +{ + +std::string readFile(const std::filesystem::path& path) +{ + std::ifstream stream(path, std::ios::binary); + assert(stream.is_open()); + std::ostringstream out; + out << stream.rdbuf(); + return out.str(); +} + +bool contains(const std::string& haystack, const char* needle) +{ + return haystack.find(needle) != std::string::npos; +} + +bool notContains(const std::string& haystack, const char* needle) +{ + return !contains(haystack, needle); +} + +std::size_t positionOf(const std::string& haystack, const char* needle) +{ + const auto pos = haystack.find(needle); + assert(pos != std::string::npos); + return pos; +} + +std::size_t positionOfAfter(const std::string& haystack, + const char* needle, + std::size_t offset) +{ + const auto pos = haystack.find(needle, offset); + assert(pos != std::string::npos); + return pos; +} + +std::string sliceFunction(const std::string& source, + const char* begin, + const char* next) +{ + const std::size_t start = positionOf(source, begin); + const std::size_t end = positionOfAfter(source, next, start); + return source.substr(start, end - start); +} + +} // namespace + +int main(int argc, char** argv) +{ + assert(argc == 2); + const std::filesystem::path repo_root = argv[1]; + const std::string adapter = readFile( + repo_root / + "platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp"); + const std::string call_profile = readFile( + repo_root / + "platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h"); + const std::string telephony_client = readFile( + repo_root / + "platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_lxst_telephony_client.cpp"); + const std::string settings = readFile( + repo_root / + "modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp"); + const std::string notification = readFile( + repo_root / + "platform/esp/arduino_common/src/notification_runtime.cpp"); + const std::string event_runtime = readFile( + repo_root / + "platform/esp/arduino_common/src/app_event_runtime_support.cpp"); + + assert(contains(call_profile, "#define TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT 0")); + assert(contains(call_profile, "return ::platform::ui::reticulum_call::WireProfile::SidebandLxst;")); + assert(contains(call_profile, "#if TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT")); + assert(contains(telephony_client, "session.call_wire_profile = ReticulumCallWireProfile::SidebandLxst;")); + + const std::string start_call = sliceFunction( + adapter, + "MeshActionResult LxmfAdapter::startReticulumAudioCall", + "MeshActionResult LxmfAdapter::pingReticulumDestination"); + assert(contains(start_call, "ReticulumCallWireProfile::SidebandLxst")); + assert(notContains(start_call, "ReticulumCallWireProfile::MeshChatCallAudio")); + + const std::string call_destination = sliceFunction( + adapter, + "void callDestinationHashForIdentity", + "void fillRandomBytes"); + assert(contains(call_destination, "\"lxst\", \"telephony\"")); + const std::size_t compat_guard = + positionOf(call_destination, "#if TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT"); + const std::size_t meshchat_branch = + positionOfAfter(call_destination, + "ReticulumCallWireProfile::MeshChatCallAudio", + compat_guard); + assert(compat_guard < meshchat_branch); + assert(positionOfAfter(call_destination, "\"call\", \"audio\"", compat_guard) > + meshchat_branch); + + assert(notContains(settings, "Call Protocol")); + assert(notContains(settings, "MeshChat call")); + assert(notContains(settings, "call.audio compatibility")); + + assert(contains(notification, "board.playMessageTone();")); + assert(contains(notification, "board.vibrator();")); + assert(contains(event_runtime, "notification::play_alert(app_context, notification::AlertKind::Message)")); + assert(notContains(event_runtime, "board->playMessageTone();")); + assert(notContains(event_runtime, "board->vibrator();")); + + return 0; +} diff --git a/modules/core_sys/include/platform/ui/reticulum_network_projection_policy.h b/modules/core_sys/include/platform/ui/reticulum_network_projection_policy.h new file mode 100644 index 00000000..aa592cff --- /dev/null +++ b/modules/core_sys/include/platform/ui/reticulum_network_projection_policy.h @@ -0,0 +1,53 @@ +/** + * @file reticulum_network_projection_policy.h + * @brief Product projection policy for non-contact Reticulum announces. + */ + +#pragma once + +#include "platform/ui/reticulum_directory_runtime.h" + +#include + +namespace platform::ui::reticulum_network +{ + +enum class ProjectionBucket : uint8_t +{ + Hidden = 0, + MessageRelay = 1, + WebOrService = 2, + TelephonyService = 3, + UnknownService = 4, +}; + +inline ProjectionBucket classify( + const reticulum_directory::AnnounceRecord& record) +{ + if (!record.valid) + { + return ProjectionBucket::Hidden; + } + + switch (record.aspect) + { + case reticulum_directory::AnnounceAspect::LxmfPropagation: + return ProjectionBucket::MessageRelay; + case reticulum_directory::AnnounceAspect::NomadNetworkNode: + return ProjectionBucket::WebOrService; + case reticulum_directory::AnnounceAspect::CallAudio: + return ProjectionBucket::TelephonyService; + case reticulum_directory::AnnounceAspect::Unknown: + return ProjectionBucket::UnknownService; + case reticulum_directory::AnnounceAspect::LxmfDelivery: + default: + return ProjectionBucket::Hidden; + } +} + +inline bool visible(const reticulum_directory::AnnounceRecord& record) +{ + return classify(record) != ProjectionBucket::Hidden; +} + +} // namespace platform::ui::reticulum_network diff --git a/modules/core_sys/tests/test_reticulum_projection_policy.cpp b/modules/core_sys/tests/test_reticulum_projection_policy.cpp new file mode 100644 index 00000000..ea6081b7 --- /dev/null +++ b/modules/core_sys/tests/test_reticulum_projection_policy.cpp @@ -0,0 +1,96 @@ +#include "platform/ui/reticulum_contact_projection_policy.h" +#include "platform/ui/reticulum_network_projection_policy.h" + +#include +#include +#include + +namespace +{ + +namespace rtdir = ::platform::ui::reticulum_directory; +namespace rtcontacts = ::platform::ui::reticulum_contacts; +namespace rtnetwork = ::platform::ui::reticulum_network; + +void fill(uint8_t* data, std::size_t len, uint8_t value) +{ + for (std::size_t index = 0; index < len; ++index) + { + data[index] = static_cast(value + index); + } +} + +rtdir::LxmfAddressRecord valid_address() +{ + rtdir::LxmfAddressRecord record{}; + record.valid = true; + fill(record.destination_hash, sizeof(record.destination_hash), 1); + fill(record.identity_hash, sizeof(record.identity_hash), 17); + fill(record.enc_pub, sizeof(record.enc_pub), 33); + fill(record.sig_pub, sizeof(record.sig_pub), 65); + record.source = rtdir::EntrySource::RuntimeRx; + return record; +} + +rtdir::AnnounceRecord announce(rtdir::AnnounceAspect aspect) +{ + rtdir::AnnounceRecord record{}; + record.valid = true; + record.aspect = aspect; + return record; +} + +void contacts_policy_keeps_contacts_person_shaped() +{ + auto runtime = valid_address(); + assert(rtcontacts::classify(runtime) == + rtcontacts::ProjectionBucket::Announced); + + auto favorite = runtime; + favorite.favorite = true; + assert(rtcontacts::classify(favorite) == + rtcontacts::ProjectionBucket::Contact); + + auto manual = runtime; + manual.source = rtdir::EntrySource::Manual; + assert(rtcontacts::classify(manual) == + rtcontacts::ProjectionBucket::Contact); + + auto ignored = runtime; + ignored.ignored = true; + assert(rtcontacts::classify(ignored) == + rtcontacts::ProjectionBucket::Ignored); + + auto invalid = runtime; + invalid.valid = false; + assert(rtcontacts::classify(invalid) == + rtcontacts::ProjectionBucket::Hidden); +} + +void network_policy_keeps_services_out_of_contacts() +{ + assert(rtnetwork::classify(announce(rtdir::AnnounceAspect::LxmfDelivery)) == + rtnetwork::ProjectionBucket::Hidden); + assert(rtnetwork::classify(announce(rtdir::AnnounceAspect::LxmfPropagation)) == + rtnetwork::ProjectionBucket::MessageRelay); + assert(rtnetwork::classify(announce(rtdir::AnnounceAspect::NomadNetworkNode)) == + rtnetwork::ProjectionBucket::WebOrService); + assert(rtnetwork::classify(announce(rtdir::AnnounceAspect::CallAudio)) == + rtnetwork::ProjectionBucket::TelephonyService); + assert(rtnetwork::classify(announce(rtdir::AnnounceAspect::Unknown)) == + rtnetwork::ProjectionBucket::UnknownService); + + auto invalid = announce(rtdir::AnnounceAspect::NomadNetworkNode); + invalid.valid = false; + assert(rtnetwork::classify(invalid) == + rtnetwork::ProjectionBucket::Hidden); +} + +} // namespace + +int main() +{ + contacts_policy_keeps_contacts_person_shaped(); + network_policy_keeps_services_out_of_contacts(); + return 0; +} diff --git a/modules/core_team/tests/test_team_app_data_poll_order.cpp b/modules/core_team/tests/test_team_app_data_poll_order.cpp index 4a3a0243..300e96fa 100644 --- a/modules/core_team/tests/test_team_app_data_poll_order.cpp +++ b/modules/core_team/tests/test_team_app_data_poll_order.cpp @@ -98,7 +98,7 @@ class FakeChatStore final : public chat::IChatStore } return {}; } - void setUnread(const chat::ConversationId&, int) override {} + bool setUnread(const chat::ConversationId&, int) override { return true; } int getUnread(const chat::ConversationId&) const override { return 0; } void clearConversation(const chat::ConversationId&) override {} void clearAll() override {} diff --git a/modules/ui_mono/src/runtime.cpp b/modules/ui_mono/src/runtime.cpp index cf30bbd0..a3708c6d 100644 --- a/modules/ui_mono/src/runtime.cpp +++ b/modules/ui_mono/src/runtime.cpp @@ -5362,7 +5362,7 @@ void Runtime::enterPage(Page page) { if (app()) { - app()->getChatService().markConversationRead(active_conversation_); + (void)app()->getChatService().markConversationRead(active_conversation_); } rebuildMessages(); message_focus_started_ms_ = nowMs(); diff --git a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp index 4620ff29..91e416f2 100644 --- a/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp +++ b/modules/ui_shared/src/ui/i18n/resource_pack_registry.cpp @@ -89,7 +89,7 @@ constexpr bool kFlashPackStorageEnabled = false; #if defined(ESP_PLATFORM) || defined(ARDUINO_ARCH_ESP32) constexpr bool kAllowSynchronousContentSupplementFontLoad = false; -constexpr bool kAllowDeferredContentSupplementFontLoad = false; +constexpr bool kAllowDeferredContentSupplementFontLoad = true; #else constexpr bool kAllowSynchronousContentSupplementFontLoad = true; constexpr bool kAllowDeferredContentSupplementFontLoad = true; diff --git a/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp b/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp index 46b39566..e97bd4dc 100644 --- a/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp +++ b/modules/ui_shared/src/ui/presentation_sources/runtime_chat_action_sink.cpp @@ -103,7 +103,10 @@ ui::UiActionResult RuntimeChatActionSink::markRead(ui::chat::ConversationId id) return ui::UiActionResult::fail(ui::UiActionFailure::Unsupported); } - chat_service_.markConversationRead(core_id); + if (!chat_service_.markConversationRead(core_id)) + { + return ui::UiActionResult::fail(ui::UiActionFailure::Rejected); + } return ui::UiActionResult::success(); } 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 623fdc45..f18cff5b 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 @@ -4,6 +4,7 @@ #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" #include "platform/ui/reticulum_directory_runtime.h" +#include "platform/ui/reticulum_network_projection_policy.h" #include "platform/ui/reticulum_page_runtime.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" @@ -66,6 +67,7 @@ namespace { namespace rtdir = ::platform::ui::reticulum_directory; +namespace rtnet = ::platform::ui::reticulum_network; namespace rtpage = ::platform::ui::reticulum_page; namespace micron = ::ui::screens::network::micron; @@ -920,14 +922,14 @@ const char* aspect_label(rtdir::AnnounceAspect aspect) case rtdir::AnnounceAspect::LxmfDelivery: return "LXMF"; case rtdir::AnnounceAspect::LxmfPropagation: - return "Prop"; + return "Relay"; case rtdir::AnnounceAspect::CallAudio: return "Call"; case rtdir::AnnounceAspect::NomadNetworkNode: - return "Nomad"; + return "Web"; case rtdir::AnnounceAspect::Unknown: default: - return "Node"; + return "Unknown"; } } @@ -942,12 +944,16 @@ const char* announce_display_label(const rtdir::AnnounceRecord& announce, { return "Anonymous Node"; } + if (announce.aspect == rtdir::AnnounceAspect::LxmfPropagation) + { + return "Message Relay"; + } if (announce.aspect == rtdir::AnnounceAspect::LxmfDelivery || announce.aspect == rtdir::AnnounceAspect::CallAudio) { return "Anonymous Peer"; } - return fallback ? fallback : ""; + return fallback ? fallback : "Unknown Service"; } const char* address_display_label(const rtdir::LxmfAddressRecord& address, @@ -962,7 +968,7 @@ const char* address_display_label(const rtdir::LxmfAddressRecord& address, bool announce_visible_in_directory(const rtdir::AnnounceRecord& announce) { - return announce.valid && announce.aspect == rtdir::AnnounceAspect::NomadNetworkNode; + return rtnet::visible(announce); } std::size_t visible_announce_count() diff --git a/modules/ui_shared/tests/test_chat_presentation_source.cpp b/modules/ui_shared/tests/test_chat_presentation_source.cpp index f3fe55e9..8d15ad87 100644 --- a/modules/ui_shared/tests/test_chat_presentation_source.cpp +++ b/modules/ui_shared/tests/test_chat_presentation_source.cpp @@ -312,9 +312,10 @@ class PagingStore final : public ::chat::IChatStore return {meta}; } - void setUnread(const ::chat::ConversationId&, int unread) override + bool setUnread(const ::chat::ConversationId&, int unread) override { unread_ = unread; + return true; } int getUnread(const ::chat::ConversationId&) const override diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h index d91952b2..54824269 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -10,7 +10,10 @@ #include "chat/infra/mesh_incoming_queue.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/ports/i_mesh_peer_directory.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter_scratch.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_ingestor.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_attempt_ledger.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_notifier.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_planner.h" @@ -25,10 +28,11 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_ping_service.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_client.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_stamp_runtime.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_rx_telemetry.h" #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h" #include "platform/ui/reticulum_page_runtime.h" -#include "sys/ringbuf.h" #include #include @@ -37,7 +41,7 @@ namespace chat::lxmf { -class LxmfAdapter : public IMeshAdapter +class LxmfAdapter : public IMeshAdapter, private runtime::IPeerProjectionSink { public: explicit LxmfAdapter(LoraBoard& board, @@ -110,6 +114,7 @@ class LxmfAdapter : public IMeshAdapter using PendingPropagationUpload = runtime::PendingPropagationUpload; using PropagationSyncStage = runtime::PropagationSyncStage; using PendingNomadPageRequest = runtime::PendingNomadPageRequest; + using RuntimeBudget = runtime::RuntimeBudget; static bool resolveLocalDestinationForAnnounce( void* context, @@ -126,40 +131,12 @@ class LxmfAdapter : public IMeshAdapter static constexpr uint32_t kAnnounceRebroadcastIntervalMs = 60000; static constexpr uint32_t kPeerProjectionScreenIntervalMs = 2000; static constexpr uint32_t kPeerProjectionSleepIntervalMs = 250; - static constexpr std::size_t kPendingPeerProjectionDepth = 24; - static constexpr std::size_t kDeferredDiscoveryDepth = 8; - static constexpr std::size_t kPeerDirectoryHotLoadRecords = 64; static constexpr std::size_t kMaxPendingPingRequests = 4; static constexpr std::size_t kMaxPendingNomadPageRequests = 4; static constexpr std::size_t kNomadPagePathMaxLen = 64; static constexpr uint32_t kNomadPageRequestTtlMs = 90000; static constexpr uint32_t kNomadPageSendRetryMs = 1500; - struct RuntimeBudget - { - uint8_t live_packet_limit = 1; - uint8_t deferred_discovery_limit = 0; - bool allow_public_discovery = false; - bool allow_persistence = false; - bool allow_peer_projection = false; - bool allow_announce_tx = true; - bool allow_propagation_client = false; - bool drop_public_discovery = false; - const char* phase = "screen"; - }; - - struct DeferredDiscoveryPacket - { - uint8_t data[reticulum::kReticulumMtu] = {}; - size_t len = 0; - RxMeta rx_meta{}; - reticulum::interfaces::InterfaceKind interface_kind = - reticulum::interfaces::InterfaceKind::LoRa; - reticulum::interfaces::InterfaceId interface_id = - reticulum::interfaces::kInvalidInterfaceId; - uint8_t packet_hash[reticulum::kFullHashSize] = {}; - }; - struct OutboundLxmfDispatch { bool ok = false; @@ -172,12 +149,8 @@ class LxmfAdapter : public IMeshAdapter reticulum::interfaces::ReticulumInterfaceSet interfaces_; uint32_t network_config_generation_ = 0; - reticulum::interfaces::RxPacket rx_packet_scratch_{}; - uint8_t announce_tx_signed_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t announce_tx_payload_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t announce_tx_packet_scratch_[reticulum::kReticulumMtu] = {}; - sys::RingBuffer deferred_discovery_queue_; - DeferredDiscoveryPacket deferred_discovery_scratch_{}; + runtime::AdapterScratchBuffers scratch_{}; + runtime::DeferredDiscoveryQueue deferred_discovery_; LxmfIdentity identity_; MeshConfig config_{}; static constexpr std::size_t kIncomingQueueDepth = 12; @@ -197,46 +170,10 @@ class LxmfAdapter : public IMeshAdapter runtime::LxmfDeliveryNotifier delivery_notifier_; std::string user_long_name_; std::string user_short_name_; - uint32_t last_announce_ms_ = 0; - uint32_t last_announce_attempt_ms_ = 0; - uint32_t last_lora_discovery_sample_ms_ = 0; - uint32_t last_wifi_discovery_sample_ms_ = 0; - uint32_t last_rx_summary_ms_ = 0; - uint32_t last_announce_rebroadcast_ms_ = 0; - uint32_t rx_summary_packets_ = 0; - uint32_t rx_summary_wifi_skipped_ = 0; - uint32_t rx_summary_duplicates_ = 0; - uint32_t rx_summary_parse_failed_ = 0; - uint32_t rx_summary_deferred_ = 0; - uint32_t rx_summary_deferred_dropped_ = 0; - uint32_t rx_summary_throttled_discovery_ = 0; - uint32_t last_lora_discovery_detail_log_ms_ = 0; - uint32_t suppressed_lora_discovery_detail_logs_ = 0; - uint32_t last_lora_announce_ignore_log_ms_ = 0; - uint32_t suppressed_lora_announce_ignore_logs_ = 0; - std::array pending_peer_projection_nodes_{}; - std::size_t pending_peer_projection_count_ = 0; - std::array peer_directory_load_entries_{}; - uint8_t nomad_page_request_payload_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t nomad_page_wire_payload_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t nomad_page_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t link_request_payload_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t link_request_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t link_request_routed_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t path_request_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t proof_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t routed_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t forward_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t lxmf_tx_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t encrypted_payload_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t link_wire_payload_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t link_packet_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t resource_advertisement_scratch_[reticulum::kReticulumMtu] = {}; - uint8_t resource_hashmap_update_scratch_[reticulum::kReticulumMtu] = {}; + runtime::AnnounceScheduler announce_scheduler_; + runtime::RawRxTelemetry rx_telemetry_; std::size_t link_request_packet_len_ = 0; - uint32_t last_peer_projection_ms_ = 0; uint32_t next_app_packet_id_ = 1; - bool announce_pending_ = true; bool peers_loaded_ = false; RxMeta active_rx_meta_{}; bool has_active_rx_meta_ = false; @@ -390,9 +327,8 @@ class LxmfAdapter : public IMeshAdapter bool favorite) const; PeerInfo* rememberPeerIdentity(const uint8_t combined_pub[reticulum::kCombinedPublicKeySize], const char* display_name = nullptr); - void queuePeerUpdate(const PeerInfo& peer); void pumpPendingPeerUpdates(); - void publishPeerUpdate(const PeerInfo& peer) const; + void publishPeerUpdate(const PeerInfo& peer) override; void loadPersistedPeers(); void loadDirectoryPeers(); uint32_t currentTimestampSeconds() const; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter_scratch.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter_scratch.h new file mode 100644 index 00000000..c923fdd1 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter_scratch.h @@ -0,0 +1,40 @@ +/** + * @file lxmf_adapter_scratch.h + * @brief Long-lived packet scratch buffers for the embedded LXMF adapter. + */ + +#pragma once + +#include "chat/infra/lxmf/lxmf_wire.h" +#include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h" + +#include + +namespace chat::lxmf::runtime +{ + +struct AdapterScratchBuffers +{ + reticulum::interfaces::RxPacket rx_packet{}; + uint8_t announce_tx_signed[reticulum::kReticulumMtu] = {}; + uint8_t announce_tx_payload[reticulum::kReticulumMtu] = {}; + uint8_t announce_tx_packet[reticulum::kReticulumMtu] = {}; + uint8_t nomad_page_request_payload[reticulum::kReticulumMtu] = {}; + uint8_t nomad_page_wire_payload[reticulum::kReticulumMtu] = {}; + uint8_t nomad_page_packet[reticulum::kReticulumMtu] = {}; + uint8_t link_request_payload[reticulum::kReticulumMtu] = {}; + uint8_t link_request_packet[reticulum::kReticulumMtu] = {}; + uint8_t link_request_routed[reticulum::kReticulumMtu] = {}; + uint8_t path_request_packet[reticulum::kReticulumMtu] = {}; + uint8_t proof_packet[reticulum::kReticulumMtu] = {}; + uint8_t routed_packet[reticulum::kReticulumMtu] = {}; + uint8_t forward_packet[reticulum::kReticulumMtu] = {}; + uint8_t lxmf_tx_packet[reticulum::kReticulumMtu] = {}; + uint8_t encrypted_payload[reticulum::kReticulumMtu] = {}; + uint8_t link_wire_payload[reticulum::kReticulumMtu] = {}; + uint8_t link_packet[reticulum::kReticulumMtu] = {}; + uint8_t resource_advertisement[reticulum::kReticulumMtu] = {}; + uint8_t resource_hashmap_update[reticulum::kReticulumMtu] = {}; +}; + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h new file mode 100644 index 00000000..f2d8a8dd --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h @@ -0,0 +1,42 @@ +/** + * @file lxmf_announce_scheduler.h + * @brief Local announce TX scheduling state for the embedded LXMF runtime. + */ + +#pragma once + +#include + +namespace chat::lxmf::runtime +{ + +struct AnnounceScheduleDecision +{ + bool should_send = false; +}; + +class AnnounceScheduler +{ + public: + void resetAfterConfig(uint32_t now_ms, bool anonymous_peer); + bool beginManualBroadcast(bool anonymous_peer); + void markIdentityChanged(); + + AnnounceScheduleDecision next(uint32_t now_ms, + bool anonymous_peer, + uint32_t announce_interval_ms, + uint32_t initial_delay_ms, + uint32_t retry_delay_ms); + + void completeAttempt(uint32_t now_ms, bool any_sent, bool all_complete); + + bool rebroadcastDue(uint32_t now_ms, uint32_t interval_ms); + + private: + uint32_t last_announce_ms_ = 0; + uint32_t last_attempt_ms_ = 0; + uint32_t last_rebroadcast_ms_ = 0; + bool pending_ = true; +}; + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h index f63ba091..1e29770f 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h @@ -12,6 +12,10 @@ #include +#ifndef TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT +#define TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT 0 +#endif + namespace chat::lxmf::call_profile { @@ -29,10 +33,14 @@ inline ::platform::ui::reticulum_call::Codec2Mode runtimeCodec2Mode( ReticulumCallWireProfile wire_profile, uint16_t lxst_profile) { +#if TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT if (wire_profile == ReticulumCallWireProfile::MeshChatCallAudio) { return ::platform::ui::reticulum_call::Codec2Mode::Mode1200; } +#else + (void)wire_profile; +#endif (void)lxst_profile; return ::platform::ui::reticulum_call::Codec2Mode::Mode3200; @@ -41,9 +49,14 @@ inline ::platform::ui::reticulum_call::Codec2Mode runtimeCodec2Mode( inline ::platform::ui::reticulum_call::WireProfile runtimeWireProfile( ReticulumCallWireProfile wire_profile) { +#if TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT return wire_profile == ReticulumCallWireProfile::MeshChatCallAudio ? ::platform::ui::reticulum_call::WireProfile::MeshChatCallAudio : ::platform::ui::reticulum_call::WireProfile::SidebandLxst; +#else + (void)wire_profile; + return ::platform::ui::reticulum_call::WireProfile::SidebandLxst; +#endif } } // namespace chat::lxmf::call_profile diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h new file mode 100644 index 00000000..292769be --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h @@ -0,0 +1,47 @@ +/** + * @file lxmf_deferred_discovery_queue.h + * @brief Deferred public discovery packet queue for the embedded LXMF runtime. + */ + +#pragma once + +#include "chat/infra/lxmf/lxmf_wire.h" +#include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h" +#include "sys/ringbuf.h" + +#include +#include + +namespace chat::lxmf::runtime +{ + +struct DeferredDiscoveryPacket +{ + uint8_t data[reticulum::kReticulumMtu] = {}; + std::size_t len = 0; + RxMeta rx_meta{}; + reticulum::interfaces::InterfaceKind interface_kind = + reticulum::interfaces::InterfaceKind::LoRa; + reticulum::interfaces::InterfaceId interface_id = + reticulum::interfaces::kInvalidInterfaceId; + uint8_t packet_hash[reticulum::kFullHashSize] = {}; +}; + +class DeferredDiscoveryQueue +{ + public: + static constexpr std::size_t kDepth = 8; + + void clear(); + bool contains(const uint8_t packet_hash[reticulum::kFullHashSize]) const; + bool push(const reticulum::interfaces::RxPacket& packet, + const uint8_t packet_hash[reticulum::kFullHashSize], + bool* out_dropped); + bool pop(reticulum::interfaces::RxPacket* out_packet); + + private: + sys::RingBuffer queue_; + DeferredDiscoveryPacket scratch_{}; +}; + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_peer_directory.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_peer_directory.h index f1ecbeb0..9ba5e9bb 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_peer_directory.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_peer_directory.h @@ -8,6 +8,7 @@ #include "chat/ports/i_mesh_peer_directory.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_destination_registry.h" +#include #include #include @@ -42,9 +43,19 @@ struct PeerDirectoryLoadRecentResult ReticulumPeerIdentity reticulumIdentityForPeer(const PeerInfo& peer); +class IPeerProjectionSink +{ + public: + virtual ~IPeerProjectionSink() = default; + virtual void publishPeerUpdate(const PeerInfo& peer) = 0; +}; + class PeerDirectoryService { public: + static constexpr std::size_t kPendingProjectionDepth = 24; + static constexpr std::size_t kHotLoadRecords = 64; + explicit PeerDirectoryService(IMeshPeerDirectory* directory = nullptr); PeerDirectoryService(const PeerDirectoryService&) = delete; PeerDirectoryService& operator=(const PeerDirectoryService&) = delete; @@ -85,8 +96,24 @@ class PeerDirectoryService std::size_t max_loaded_nodes, uint32_t now_s) const; + PeerDirectoryLoadRecentResult loadRecentAndQueue(DestinationRegistry& registry, + uint32_t now_s); + + void queuePeerUpdate(const PeerInfo& peer); + + void pumpQueuedPeerUpdates(DestinationRegistry& registry, + IPeerProjectionSink& sink, + uint32_t now_ms, + bool maintenance_window, + uint32_t sleep_interval_ms, + uint32_t screen_interval_ms); + private: IMeshPeerDirectory* directory_ = nullptr; + std::array pending_projection_nodes_{}; + std::size_t pending_projection_count_ = 0; + std::array hot_load_records_{}; + uint32_t last_projection_ms_ = 0; }; } // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h new file mode 100644 index 00000000..2feb9635 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h @@ -0,0 +1,38 @@ +/** + * @file lxmf_runtime_budget.h + * @brief Runtime scheduling policy for the embedded Reticulum/LXMF adapter. + */ + +#pragma once + +#include + +namespace chat::lxmf::runtime +{ + +struct RuntimeBudget +{ + uint8_t live_packet_limit = 1; + uint8_t deferred_discovery_limit = 0; + bool allow_public_discovery = false; + bool allow_persistence = false; + bool allow_peer_projection = false; + bool allow_announce_tx = true; + bool allow_propagation_client = false; + bool drop_public_discovery = false; + const char* phase = "screen"; +}; + +struct RuntimeBudgetInput +{ + uint8_t max_ingress_packets_per_poll = 4; + uint8_t call_ingress_packets_per_poll = 8; + bool call_realtime_active = false; + bool nomad_request_active = false; + bool screen_sleeping = false; + bool screen_saver_active = false; +}; + +RuntimeBudget makeRuntimeBudget(const RuntimeBudgetInput& input); + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_rx_telemetry.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_rx_telemetry.h new file mode 100644 index 00000000..80c79935 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_rx_telemetry.h @@ -0,0 +1,56 @@ +/** + * @file lxmf_rx_telemetry.h + * @brief RX budget and summary telemetry for the embedded LXMF runtime. + */ + +#pragma once + +#include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h" + +#include + +namespace chat::lxmf::runtime +{ + +class RawRxTelemetry +{ + public: + bool consumeDiscoveryBudget( + reticulum::interfaces::InterfaceKind ingress_interface, + uint32_t now_ms, + uint32_t sample_interval_ms); + + bool shouldLogLoraDiscoveryDetail(uint32_t now_ms, + uint32_t interval_ms, + const char* phase); + + bool shouldLogLoraAnnounceIgnore(uint32_t now_ms, + uint32_t interval_ms); + + void noteSummary(bool wifi_skipped, + bool duplicate, + bool parse_failed, + bool deferred, + bool deferred_dropped, + bool throttled_discovery, + uint32_t now_ms, + uint32_t summary_interval_ms); + + private: + uint32_t last_lora_discovery_sample_ms_ = 0; + uint32_t last_wifi_discovery_sample_ms_ = 0; + uint32_t last_summary_ms_ = 0; + uint32_t packets_ = 0; + uint32_t wifi_skipped_ = 0; + uint32_t duplicates_ = 0; + uint32_t parse_failed_ = 0; + uint32_t deferred_ = 0; + uint32_t deferred_dropped_ = 0; + uint32_t throttled_discovery_ = 0; + uint32_t last_lora_discovery_detail_log_ms_ = 0; + uint32_t suppressed_lora_discovery_detail_logs_ = 0; + uint32_t last_lora_announce_ignore_log_ms_ = 0; + uint32_t suppressed_lora_announce_ignore_logs_ = 0; +}; + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h index a01c0f02..c369aa55 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h @@ -63,7 +63,6 @@ class MtAdapter : public chat::IMeshAdapter bool want_response = false) override; bool pollIncomingData(MeshIncomingData* out) override; bool requestNodeInfo(NodeId dest, bool want_response) override; - bool sendMeshPacket(const meshtastic_MeshPacket& packet); bool startKeyVerification(NodeId node_id) override; bool submitKeyVerificationNumber(NodeId node_id, uint64_t nonce, uint32_t number) override; bool isPkiReady() const override; @@ -217,6 +216,8 @@ class MtAdapter : public chat::IMeshAdapter static constexpr std::size_t kIncomingQueueDepth = 12; static constexpr std::size_t kPendingSendQueueDepth = 8; + static constexpr uint8_t kSendQueueDrainPerTick = 1; + static constexpr uint8_t kLoRaAirTxBudgetPerTick = 1; sys::RingBuffer send_queue_; ::chat::infra::IncomingTextQueue receive_queue_; @@ -228,7 +229,6 @@ class MtAdapter : public chat::IMeshAdapter static constexpr std::size_t kMqttDownlinkWireMaxLen = 255; static constexpr std::size_t kPendingMqttDownlinkTxDepth = 8; static constexpr std::size_t kMqttDownlinkSeenDepth = 24; - static constexpr uint8_t kMqttDownlinkTxDrainPerTick = 1; static constexpr uint8_t kMqttDownlinkTxMaxRetries = 2; static constexpr uint32_t kMqttDownlinkSeenTtlMs = 300000; @@ -343,6 +343,15 @@ class MtAdapter : public chat::IMeshAdapter size_t protocol_action_count_ = 0; bool sendPacket(const PendingSend& pending); + bool sendAppDataNow(ChannelId channel, + uint32_t portnum, + const uint8_t* payload, + size_t len, + NodeId dest, + bool want_ack, + MessageId packet_id, + bool want_response); + bool sendMeshPacket(const meshtastic_MeshPacket& packet); bool sendNodeInfoTo(uint32_t dest, bool want_response, ChannelId channel = ChannelId::PRIMARY); void maybeBroadcastNodeInfo(uint32_t now_ms); @@ -362,7 +371,8 @@ class MtAdapter : public chat::IMeshAdapter meshtastic_Routing_Error reason); bool enqueueSendPacketAction(const runtime::SendPacketEffect& packet); bool popProtocolAction(); - void processProtocolActionQueue(uint32_t now_ms); + bool processProtocolActionQueue(uint32_t now_ms, + uint8_t& tx_budget_remaining); bool executeProtocolAction(const PendingProtocolAction& action); bool resolvePskForChannelHash(uint8_t channel_hash, const uint8_t** out_psk, @@ -384,7 +394,10 @@ class MtAdapter : public chat::IMeshAdapter void trackPendingAck(uint32_t msg_id, uint32_t dest, ChannelId channel, uint8_t channel_hash, const uint8_t* wire_data, size_t wire_size); void clearPendingAck(uint32_t msg_id); - void retryPendingAck(uint32_t msg_id, PendingAckSlot& slot); + bool retryPendingAck(uint32_t msg_id, + PendingAckSlot& slot, + uint32_t now_ms, + uint8_t& tx_budget_remaining); bool initPkiKeys(); void loadPkiNodeKeys(); void savePkiNodeKey(uint32_t node_id, const uint8_t* key, size_t key_len); @@ -469,7 +482,8 @@ class MtAdapter : public chat::IMeshAdapter MessageId msg_id, uint8_t channel_hash, uint32_t now_ms); - void processMqttDownlinkTxQueue(uint32_t now_ms); + bool processMqttDownlinkTxQueue(uint32_t now_ms, + uint8_t& tx_budget_remaining); bool queueMqttProxyPublish(const meshtastic_MeshPacket& packet, const char* channel_id); bool queueMqttProxyPublishFromWire(const uint8_t* wire_data, 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 7b6984c1..d86e987c 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,6 +22,7 @@ class SdStore final : public IChatStore public: static constexpr const char* kDir = "/chat"; static constexpr const char* kIndexFile = "/chat/index.bin"; + static constexpr const char* kReadStateFile = "/chat/read_state.bin"; static constexpr const char* kReticulumLxmfSeenFile = "/chat/lxmf_seen.bin"; static constexpr size_t kMaxMessagesPerConv = 1000; static constexpr size_t kMaxReticulumLxmfSeen = 1024; @@ -43,7 +44,7 @@ class SdStore final : public IChatStore std::vector loadConversationPage(size_t offset, size_t limit, size_t* total) override; - void setUnread(const ConversationId& conv, int unread) override; + bool setUnread(const ConversationId& conv, int unread) override; int getUnread(const ConversationId& conv) const override; void clearConversation(const ConversationId& conv) override; void clearAll() override; @@ -170,6 +171,26 @@ class SdStore final : public IChatStore char preview[kPreviewLen] = {}; } __attribute__((packed)); + struct ReadStateHeader + { + uint32_t magic = 0; + uint16_t version = 0; + uint16_t count = 0; + } __attribute__((packed)); + + struct ReadStateEntry + { + uint8_t protocol = 0; + uint8_t channel = 0; + uint8_t flags = 0; + uint8_t reserved = 0; + uint16_t unread = 0; + uint16_t reserved2 = 0; + uint32_t peer = 0; + uint8_t reticulum_destination_hash[kReticulumPeerHashSize] = {}; + uint8_t reticulum_identity_hash[kReticulumPeerHashSize] = {}; + } __attribute__((packed)); + struct LxmfSeenHeader { uint32_t magic = 0; @@ -183,14 +204,16 @@ class SdStore final : public IChatStore uint8_t hash[kReticulumLxmfHashSize] = {}; } __attribute__((packed)); - static constexpr uint32_t kFileMagic = 0x474F4C43; // "CLOG" - static constexpr uint32_t kIndexMagic = 0x54414843; // "CHAT" - static constexpr uint32_t kLxmfSeenMagic = 0x4E45584C; // "LXEN" + static constexpr uint32_t kFileMagic = 0x474F4C43; // "CLOG" + static constexpr uint32_t kIndexMagic = 0x54414843; // "CHAT" + static constexpr uint32_t kReadStateMagic = 0x44525343; // "CSRD" + static constexpr uint32_t kLxmfSeenMagic = 0x4E45584C; // "LXEN" static constexpr uint16_t kLegacyVersion = 2; static constexpr uint16_t kReticulumIdentityVersion = 3; static constexpr uint16_t kRxOriginVersion = 4; static constexpr uint16_t kFileVersion = 5; static constexpr uint16_t kIndexVersion = 3; + static constexpr uint16_t kReadStateVersion = 1; static constexpr uint16_t kLxmfSeenVersion = 1; bool ensureFs() const; @@ -200,6 +223,18 @@ class SdStore final : public IChatStore bool readIndex(std::vector& entries) const; bool writeIndex(const std::vector& entries) const; bool ensureIndex(std::vector& entries); + bool readReadState(std::vector& entries) const; + bool writeReadState(const std::vector& entries) const; + bool findReadStateEntry(const ConversationId& conv, + std::vector& entries, + size_t* out_idx) const; + bool findReadStateEntry(const ConversationId& conv, + const std::vector& entries, + size_t* out_idx) const; + bool readStateUnreadOnly(const ConversationId& conv, uint16_t* unread) const; + bool readStateUnreadOrLegacy(const ConversationId& conv, uint16_t* unread) const; + bool writeReadStateUnread(const ConversationId& conv, uint16_t unread) const; + bool removeReadStateEntry(const ConversationId& conv) const; bool findIndexEntry(const ConversationId& conv, std::vector& entries, size_t* out_idx) const; @@ -251,6 +286,11 @@ class SdStore final : public IChatStore const ConversationId& conv); static ConversationId conversationFromIndexEntry(const IndexEntry& entry); static ConversationMeta metaFromIndexEntry(const IndexEntry& entry); + static bool readStateEntryHasReticulumIdentity(const ReadStateEntry& entry); + static bool readStateEntryMatchesConversation(const ReadStateEntry& entry, + const ConversationId& conv); + static ReadStateEntry readStateEntryFromConversation(const ConversationId& conv, + uint16_t unread); static bool hasLogSuffix(const char* name); bool ready_ = false; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/notification_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/notification_runtime.h new file mode 100644 index 00000000..5151f90a --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/notification_runtime.h @@ -0,0 +1,28 @@ +/** + * @file notification_runtime.h + * @brief Product notification owner for ESP Arduino builds. + */ + +#pragma once + +#include "app/app_facades.h" +#include "board/BoardBase.h" + +#include + +namespace platform::esp::arduino_common::notification +{ + +enum class AlertKind : uint8_t +{ + Message, + Contact, + Preview, +}; + +bool message_alerts_enabled(); +bool vibration_enabled(); +bool play_alert(BoardBase& board, AlertKind kind); +bool play_alert(app::IAppFacade& app_context, AlertKind kind); + +} // namespace platform::esp::arduino_common::notification 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 9dc71253..e6ea28f8 100644 --- a/platform/esp/arduino_common/src/app_event_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_event_runtime_support.cpp @@ -5,11 +5,10 @@ #include #include "app/app_facades.h" -#include "board/BoardBase.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" #include "platform/esp/arduino_common/app_runtime_support.h" -#include "platform/ui/settings_store.h" +#include "platform/esp/arduino_common/notification_runtime.h" #include "sys/event_bus.h" #include "team/protocol/team_chat.h" #include "ui/chat_ui_runtime.h" @@ -25,12 +24,9 @@ namespace platform::esp::arduino_common namespace { -constexpr const char* kSettingsNs = "settings"; -constexpr const char* kMessageAlertsKey = "chat_message_alerts"; - bool messageAlertsEnabled() { - return platform::ui::settings_store::get_int(kSettingsNs, kMessageAlertsKey, 1) != 0; + return notification::message_alerts_enabled(); } bool isTeamRuntimeEvent(sys::EventType type) @@ -74,16 +70,7 @@ class UiFeedbackChatDeliveryFeedbackPort final void triggerMessageFeedback(app::IAppFacade& app_context) { - BoardBase* board = app_context.getBoard(); - if (!board) - { - return; - } - if (platform::ui::settings_store::get_bool(kSettingsNs, "vibration_enabled", true)) - { - board->vibrator(); - } - board->playMessageTone(); + (void)notification::play_alert(app_context, notification::AlertKind::Message); } std::string resolveContactName(app::IAppFacade& app_context, chat::NodeId node_id) diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index abdfbd87..8d108252 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -17,6 +17,7 @@ #include "platform/esp/arduino_common/app_tasks.h" #include "platform/esp/arduino_common/chat/infra/mesh_mqtt_client_runtime.h" #include "platform/esp/arduino_common/device_identity.h" +#include "platform/esp/arduino_common/notification_runtime.h" #include "platform/esp/arduino_common/reticulum_call_audio_runtime.h" #include "platform/esp/boards/board_runtime.h" #include "platform/ui/gps_runtime.h" @@ -304,18 +305,7 @@ int contactAlertMode() void triggerNodeInfoFeedback(app::IAppFacade& app_context) { - BoardBase* board = app_context.getBoard(); - if (!board) - { - return; - } - - if (platform::ui::settings_store::get_bool(kSettingsNs, "vibration_enabled", true)) - { - board->vibrator(); - } - - board->playMessageTone(); + (void)notification::play_alert(app_context, notification::AlertKind::Contact); } std::string resolveNodeInfoName(app::IAppFacade& app_context, const sys::NodeInfoUpdateEvent& node_event) 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 3798974c..ad6fc78e 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 @@ -1791,8 +1791,8 @@ bool LxmfAdapter::sendPropagationSyncRequest( } wire_payload.resize(wire_payload_len); - std::memset(lxmf_tx_packet_scratch_, 0, sizeof(lxmf_tx_packet_scratch_)); - size_t packet_len = sizeof(lxmf_tx_packet_scratch_); + std::memset(scratch_.lxmf_tx_packet, 0, sizeof(scratch_.lxmf_tx_packet)); + size_t packet_len = sizeof(scratch_.lxmf_tx_packet); if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, reticulum::DestinationType::Link, reticulum::PacketContext::Request, @@ -1800,20 +1800,20 @@ bool LxmfAdapter::sendPropagationSyncRequest( session.link_id, wire_payload.data(), wire_payload.size(), - lxmf_tx_packet_scratch_, + scratch_.lxmf_tx_packet, &packet_len)) { return false; } - reticulum::computeTruncatedPacketHash(lxmf_tx_packet_scratch_, + reticulum::computeTruncatedPacketHash(scratch_.lxmf_tx_packet, packet_len, request_id); sent = session.interface_id != reticulum::interfaces::kInvalidInterfaceId ? interfaces_.sendPacketOn(session.interface_id, - lxmf_tx_packet_scratch_, + scratch_.lxmf_tx_packet, packet_len) - : interfaces_.sendPacket(lxmf_tx_packet_scratch_, packet_len); + : interfaces_.sendPacket(scratch_.lxmf_tx_packet, packet_len); } else { @@ -2282,13 +2282,13 @@ MeshSendResult LxmfAdapter::sendTextToReticulumDestination( return MeshSendResult::fail(MeshOperationFailure::EncodeFailed); } - std::memset(lxmf_tx_packet_scratch_, 0, sizeof(lxmf_tx_packet_scratch_)); - size_t packet_len = sizeof(lxmf_tx_packet_scratch_); + std::memset(scratch_.lxmf_tx_packet, 0, sizeof(scratch_.lxmf_tx_packet)); + size_t packet_len = sizeof(scratch_.lxmf_tx_packet); uint8_t message_hash[reticulum::kFullHashSize] = {}; if (!buildGroupMessagePacket(destination, packed_payload, packed_payload_len, - lxmf_tx_packet_scratch_, + scratch_.lxmf_tx_packet, &packet_len, message_hash)) { @@ -2308,7 +2308,7 @@ MeshSendResult LxmfAdapter::sendTextToReticulumDestination( dest_hash, static_cast(packed_payload_len), static_cast(packet_len)); - const bool ok = routeAndSendPacket(lxmf_tx_packet_scratch_, packet_len, true); + const bool ok = routeAndSendPacket(scratch_.lxmf_tx_packet, packet_len, true); 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, @@ -2491,16 +2491,16 @@ bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, } else { - std::memset(lxmf_tx_packet_scratch_, 0, sizeof(lxmf_tx_packet_scratch_)); - size_t packet_len = sizeof(lxmf_tx_packet_scratch_); + std::memset(scratch_.lxmf_tx_packet, 0, sizeof(scratch_.lxmf_tx_packet)); + size_t packet_len = sizeof(scratch_.lxmf_tx_packet); if (buildSignedMessagePacket(*peer_info, packed_payload, packed_payload_len, - lxmf_tx_packet_scratch_, + scratch_.lxmf_tx_packet, &packet_len, message_hash)) { - ok = routeAndSendPacket(lxmf_tx_packet_scratch_, packet_len, true); + ok = routeAndSendPacket(scratch_.lxmf_tx_packet, packet_len, true); } if (!ok && have_link_payload) @@ -2545,16 +2545,16 @@ bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, (void)sendPathRequest(peer_info); } - std::memset(lxmf_tx_packet_scratch_, 0, sizeof(lxmf_tx_packet_scratch_)); - size_t packet_len = sizeof(lxmf_tx_packet_scratch_); + std::memset(scratch_.lxmf_tx_packet, 0, sizeof(scratch_.lxmf_tx_packet)); + size_t packet_len = sizeof(scratch_.lxmf_tx_packet); uint8_t message_hash[reticulum::kFullHashSize] = {}; if (!buildSignedMessagePacket(peer_info, packed_payload, packed_payload_len, - lxmf_tx_packet_scratch_, + scratch_.lxmf_tx_packet, &packet_len, message_hash) || - !routeAndSendPacket(lxmf_tx_packet_scratch_, + !routeAndSendPacket(scratch_.lxmf_tx_packet, packet_len, true)) { @@ -2609,14 +2609,12 @@ bool LxmfAdapter::requestNodeInfo(NodeId dest, bool want_response) bool LxmfAdapter::broadcastSelfIdentity() { - if (config_.reticulum_anonymous_peer) + if (!announce_scheduler_.beginManualBroadcast(config_.reticulum_anonymous_peer)) { Serial.println("[LXMF][AnnounceTX] skip reason=anonymous_peer trigger=broadcast_self"); - announce_pending_ = false; return false; } - announce_pending_ = true; const bool delivery_ok = sendAnnounce(LocalDestinationKind::Delivery); const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); const bool propagation_service = @@ -2630,12 +2628,10 @@ bool LxmfAdapter::broadcastSelfIdentity() !interfaces_.wifiGatewayConfigured() || !interfaces_.hasReadyWifiGateway() || lastAnnounceTxReachedRequiredInterfaces(call_audio_ok); - if (delivery_ok || propagation_ok || call_audio_ok) - { - last_announce_ms_ = millis(); - } - last_announce_attempt_ms_ = millis(); - announce_pending_ = !(delivery_complete && propagation_complete && call_audio_complete); + announce_scheduler_.completeAttempt( + millis(), + delivery_ok || propagation_ok || call_audio_ok, + delivery_complete && propagation_complete && call_audio_complete); return delivery_ok || propagation_ok || call_audio_ok; } @@ -3229,17 +3225,14 @@ void LxmfAdapter::applyConfig(const MeshConfig& config) config_.reticulum_anonymous_peer ? 1 : 0, static_cast(destination_registry_.size())); } - last_announce_ms_ = millis(); - last_announce_attempt_ms_ = 0; - announce_pending_ = !config_.reticulum_anonymous_peer; + announce_scheduler_.resetAfterConfig(millis(), config_.reticulum_anonymous_peer); } void LxmfAdapter::setUserInfo(const char* long_name, const char* short_name) { user_long_name_ = (long_name && long_name[0] != '\0') ? long_name : ""; user_short_name_ = (short_name && short_name[0] != '\0') ? short_name : ""; - last_announce_attempt_ms_ = 0; - announce_pending_ = true; + announce_scheduler_.markIdentityChanged(); } bool LxmfAdapter::setWifiTransportEnabled(bool enabled) @@ -3278,90 +3271,15 @@ void LxmfAdapter::processSendQueue() LxmfAdapter::RuntimeBudget LxmfAdapter::makeRuntimeBudget() const { - RuntimeBudget budget{}; - if (::platform::ui::reticulum_call::realtime_mode_active()) - { - budget.live_packet_limit = kCallIngressPacketsPerPoll; - budget.deferred_discovery_limit = 0; - budget.allow_public_discovery = false; - budget.allow_persistence = false; - budget.allow_peer_projection = false; - budget.allow_announce_tx = false; - budget.allow_propagation_client = false; - budget.drop_public_discovery = true; - budget.phase = "call"; - return budget; - } - - if (!network_page_client_.empty()) - { - budget.live_packet_limit = kMaxIngressPacketsPerPoll; - budget.deferred_discovery_limit = 0; - budget.allow_public_discovery = false; - budget.allow_persistence = false; - budget.allow_peer_projection = false; - budget.allow_announce_tx = false; - budget.allow_propagation_client = false; - budget.drop_public_discovery = true; - budget.phase = "nomad"; - return budget; - } - - const bool maintenance_window = - screen_runtime::is_sleeping() && !screen_runtime::is_saver_active(); - if (maintenance_window) - { - budget.live_packet_limit = 1; - budget.deferred_discovery_limit = 1; - budget.allow_public_discovery = true; - budget.allow_persistence = false; - budget.allow_peer_projection = false; - budget.allow_announce_tx = false; - budget.allow_propagation_client = false; - budget.phase = "sleep"; - return budget; - } - - if (screen_runtime::is_saver_active()) - { - budget.live_packet_limit = 1; - budget.deferred_discovery_limit = 0; - budget.allow_public_discovery = false; - budget.allow_persistence = false; - budget.allow_peer_projection = false; - budget.allow_announce_tx = false; - budget.allow_propagation_client = false; - budget.drop_public_discovery = true; - budget.phase = "saver"; - return budget; - } - -#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) - // P4 runs the shared mesh task independently of LVGL and has sufficient - // compute for foreground announce verification/projection. Keeping the - // S3 sleep-only discovery policy here leaves an always-lit P4 with an - // eight-packet deferred queue that can never drain into Contacts/Network. - budget.live_packet_limit = kMaxIngressPacketsPerPoll; - budget.deferred_discovery_limit = kMaxIngressPacketsPerPoll; - budget.allow_public_discovery = true; - budget.allow_persistence = true; - budget.allow_peer_projection = true; - budget.allow_announce_tx = true; - budget.allow_propagation_client = true; - budget.phase = "p4_screen"; - return budget; -#endif - - budget.live_packet_limit = kMaxIngressPacketsPerPoll; - budget.deferred_discovery_limit = 0; - budget.allow_public_discovery = false; - budget.allow_persistence = false; - budget.allow_peer_projection = !screen_runtime::is_saver_active(); - budget.allow_announce_tx = true; - budget.allow_propagation_client = true; - budget.drop_public_discovery = true; - budget.phase = "screen"; - return budget; + runtime::RuntimeBudgetInput input{}; + input.max_ingress_packets_per_poll = kMaxIngressPacketsPerPoll; + input.call_ingress_packets_per_poll = kCallIngressPacketsPerPoll; + input.call_realtime_active = + ::platform::ui::reticulum_call::realtime_mode_active(); + input.nomad_request_active = !network_page_client_.empty(); + input.screen_sleeping = screen_runtime::is_sleeping(); + input.screen_saver_active = screen_runtime::is_saver_active(); + return runtime::makeRuntimeBudget(input); } void LxmfAdapter::processRuntime() @@ -3380,7 +3298,7 @@ void LxmfAdapter::processRuntime() }); link_manager_.clear(); path_manager_.clear(); - deferred_discovery_queue_.clear(); + deferred_discovery_.clear(); propagation_client_.resetForNetworkConfig( rtnet::active().propagation.sync_on_start); @@ -3433,10 +3351,10 @@ void LxmfAdapter::processRadioPackets(const RuntimeBudget& budget) { uint8_t polled_packets = 0; while (polled_packets < budget.live_packet_limit && - interfaces_.pollIncomingPacket(&rx_packet_scratch_)) + interfaces_.pollIncomingPacket(&scratch_.rx_packet)) { ++polled_packets; - (void)processOneRadioPacket(rx_packet_scratch_, budget, false); + (void)processOneRadioPacket(scratch_.rx_packet, budget, false); } MeshIncomingData discarded; @@ -3711,46 +3629,19 @@ bool LxmfAdapter::enqueueDeferredDiscoveryPacket( const reticulum::interfaces::RxPacket& packet, const uint8_t packet_hash[reticulum::kFullHashSize]) { - if (!packet_hash || packet.len == 0 || packet.len > reticulum::kReticulumMtu) - { - return false; - } - - deferred_discovery_scratch_ = DeferredDiscoveryPacket{}; - DeferredDiscoveryPacket& deferred = deferred_discovery_scratch_; - memcpy(deferred.data, packet.data, packet.len); - deferred.len = packet.len; - deferred.rx_meta = packet.rx_meta; - deferred.interface_kind = packet.interface_kind; - deferred.interface_id = packet.interface_id; - memcpy(deferred.packet_hash, packet_hash, sizeof(deferred.packet_hash)); - bool dropped = false; - deferred_discovery_queue_.pushDropOldest(deferred, &dropped); + const bool queued = deferred_discovery_.push(packet, packet_hash, &dropped); if (dropped) { noteRxSummary(false, false, false, false, true); } - return true; + return queued; } bool LxmfAdapter::hasDeferredDiscoveryPacket( const uint8_t packet_hash[reticulum::kFullHashSize]) const { - if (!packet_hash) - { - return false; - } - for (std::size_t i = 0; i < deferred_discovery_queue_.size(); ++i) - { - const DeferredDiscoveryPacket* queued = deferred_discovery_queue_.get(i); - if (queued && - hashesEqual(queued->packet_hash, packet_hash, reticulum::kFullHashSize)) - { - return true; - } - } - return false; + return deferred_discovery_.contains(packet_hash); } void LxmfAdapter::processDeferredDiscoveryPackets(const RuntimeBudget& budget) @@ -3762,43 +3653,26 @@ void LxmfAdapter::processDeferredDiscoveryPackets(const RuntimeBudget& budget) uint8_t processed = 0; while (processed < budget.deferred_discovery_limit && - deferred_discovery_queue_.popOldest(&deferred_discovery_scratch_)) + deferred_discovery_.pop(&scratch_.rx_packet)) { - rx_packet_scratch_.len = deferred_discovery_scratch_.len; - memcpy(rx_packet_scratch_.data, - deferred_discovery_scratch_.data, - deferred_discovery_scratch_.len); - rx_packet_scratch_.rx_meta = deferred_discovery_scratch_.rx_meta; - rx_packet_scratch_.interface_kind = deferred_discovery_scratch_.interface_kind; - rx_packet_scratch_.interface_id = deferred_discovery_scratch_.interface_id; - (void)processOneRadioPacket(rx_packet_scratch_, budget, true); + (void)processOneRadioPacket(scratch_.rx_packet, budget, true); ++processed; } } void LxmfAdapter::maybeAnnounce() { - if (config_.reticulum_anonymous_peer) - { - announce_pending_ = false; - last_announce_attempt_ms_ = 0; - return; - } const uint32_t now_ms = millis(); - if (!announce_pending_ && (now_ms - last_announce_ms_) < kAnnounceIntervalMs) + const runtime::AnnounceScheduleDecision decision = + announce_scheduler_.next(now_ms, + config_.reticulum_anonymous_peer, + kAnnounceIntervalMs, + kInitialAnnounceDelayMs, + kPendingAnnounceRetryMs); + if (!decision.should_send) { return; } - if (announce_pending_) - { - const bool first_attempt = last_announce_attempt_ms_ == 0; - const uint32_t wait_ms = first_attempt ? kInitialAnnounceDelayMs : kPendingAnnounceRetryMs; - const uint32_t basis_ms = first_attempt ? last_announce_ms_ : last_announce_attempt_ms_; - if ((now_ms - basis_ms) < wait_ms) - { - return; - } - } const bool delivery_ok = sendAnnounce(LocalDestinationKind::Delivery); const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); @@ -3812,16 +3686,10 @@ void LxmfAdapter::maybeAnnounce() const bool call_audio_complete = !interfaces_.wifiGatewayConfigured() || lastAnnounceTxReachedRequiredInterfaces(call_audio_ok); - last_announce_attempt_ms_ = now_ms; - if (delivery_ok || propagation_ok || call_audio_ok) - { - last_announce_ms_ = now_ms; - } - announce_pending_ = !(delivery_complete && propagation_complete && call_audio_complete); - if (!announce_pending_) - { - last_announce_attempt_ms_ = 0; - } + announce_scheduler_.completeAttempt( + now_ms, + delivery_ok || propagation_ok || call_audio_ok, + delivery_complete && propagation_complete && call_audio_complete); } bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, @@ -3916,7 +3784,7 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, random_hash[8] = static_cast((now_s >> 8) & 0xFFU); random_hash[9] = static_cast(now_s & 0xFFU); - uint8_t* signed_data = announce_tx_signed_scratch_; + uint8_t* signed_data = scratch_.announce_tx_signed; size_t signed_len = 0; memcpy(signed_data + signed_len, destination_hash, reticulum::kTruncatedHashSize); signed_len += reticulum::kTruncatedHashSize; @@ -3939,7 +3807,7 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, return false; } - uint8_t* announce_payload = announce_tx_payload_scratch_; + uint8_t* announce_payload = scratch_.announce_tx_payload; size_t announce_payload_len = 0; memcpy(announce_payload + announce_payload_len, combined_pub, sizeof(combined_pub)); announce_payload_len += sizeof(combined_pub); @@ -3956,7 +3824,7 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, memcpy(announce_payload + announce_payload_len, app_data, app_data_len); announce_payload_len += app_data_len; - uint8_t* packet = announce_tx_packet_scratch_; + uint8_t* packet = scratch_.announce_tx_packet; size_t packet_len = reticulum::kReticulumMtu; if (!reticulum::buildHeader1Packet(reticulum::PacketType::Announce, reticulum::DestinationType::Single, @@ -4163,24 +4031,9 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len !ingest.local_destination && !ingest.contact_announce) { - const uint32_t log_now_ms = millis(); - if (last_lora_announce_ignore_log_ms_ != 0 && - log_now_ms - last_lora_announce_ignore_log_ms_ < - kLoraAnnounceIgnoreDetailLogIntervalMs) - { - ++suppressed_lora_announce_ignore_logs_; - log_announce_detail = false; - } - else - { - if (suppressed_lora_announce_ignore_logs_ != 0) - { - Serial.printf("[LXMF][AnnounceRX] ignored_suppressed iface=lora suppressed=%u\n", - static_cast(suppressed_lora_announce_ignore_logs_)); - suppressed_lora_announce_ignore_logs_ = 0; - } - last_lora_announce_ignore_log_ms_ = log_now_ms; - } + log_announce_detail = rx_telemetry_.shouldLogLoraAnnounceIgnore( + millis(), + kLoraAnnounceIgnoreDetailLogIntervalMs); } if (log_announce_detail) { @@ -4270,7 +4123,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len if (ingress_interface == reticulum::interfaces::InterfaceKind::WifiGateway) { - queuePeerUpdate(peer); + peer_directory_service_.queuePeerUpdate(peer); } else { @@ -4280,7 +4133,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len } else { - queuePeerUpdate(peer); + peer_directory_service_.queuePeerUpdate(peer); } } char peer_ratchet_id[12] = {}; @@ -4590,8 +4443,8 @@ bool LxmfAdapter::handleProofPacket( return false; } - std::memset(forward_packet_scratch_, 0, sizeof(forward_packet_scratch_)); - size_t forward_len = sizeof(forward_packet_scratch_); + std::memset(scratch_.forward_packet, 0, sizeof(scratch_.forward_packet)); + size_t forward_len = sizeof(scratch_.forward_packet); if (!reticulum::buildHeader1Packet(packet.packet_type, packet.destination_type, static_cast(packet.context), @@ -4599,7 +4452,7 @@ bool LxmfAdapter::handleProofPacket( packet.destination_hash, packet.payload, packet.payload_len, - forward_packet_scratch_, + scratch_.forward_packet, &forward_len, packet.hops, reticulum::TransportType::Broadcast)) @@ -4609,7 +4462,7 @@ bool LxmfAdapter::handleProofPacket( reverse->created_ms = 0; return interfaces_.sendPacketOn(reverse->interface_id, - forward_packet_scratch_, + scratch_.forward_packet, forward_len); } @@ -5870,26 +5723,26 @@ bool LxmfAdapter::handleLinkResourceRequest(LinkSession& session, const size_t remaining_hashes = static_cast(resource->part_count) - next_index; const size_t slice_hashes = std::min(segment_capacity, remaining_hashes); - std::memset(resource_hashmap_update_scratch_, + std::memset(scratch_.resource_hashmap_update, 0, - sizeof(resource_hashmap_update_scratch_)); - size_t update_len = sizeof(resource_hashmap_update_scratch_) - + sizeof(scratch_.resource_hashmap_update)); + size_t update_len = sizeof(scratch_.resource_hashmap_update) - reticulum::kFullHashSize; if (encodeResourceHashmapUpdate(segment, resource->hashmap.data() + slice_offset, slice_hashes * kResourceMapHashLen, - resource_hashmap_update_scratch_ + + scratch_.resource_hashmap_update + reticulum::kFullHashSize, &update_len)) { - memcpy(resource_hashmap_update_scratch_, + memcpy(scratch_.resource_hashmap_update, resource->resource_hash, reticulum::kFullHashSize); const size_t wire_len = reticulum::kFullHashSize + update_len; sent_any = sendLinkPacket(session, reticulum::PacketType::Data, reticulum::PacketContext::ResourceHmu, - resource_hashmap_update_scratch_, + scratch_.resource_hashmap_update, wire_len, true) || sent_any; @@ -6568,8 +6421,8 @@ bool LxmfAdapter::sendForwardPlan(const reticulum::ParsedPacket& packet, return false; } - std::memset(forward_packet_scratch_, 0, sizeof(forward_packet_scratch_)); - size_t forward_len = sizeof(forward_packet_scratch_); + std::memset(scratch_.forward_packet, 0, sizeof(scratch_.forward_packet)); + size_t forward_len = sizeof(scratch_.forward_packet); bool built = false; if (plan.header == runtime::PacketForwardHeader::Header1Broadcast) { @@ -6581,7 +6434,7 @@ bool LxmfAdapter::sendForwardPlan(const reticulum::ParsedPacket& packet, packet.destination_hash, packet.payload, packet.payload_len, - forward_packet_scratch_, + scratch_.forward_packet, &forward_len, plan.hops, reticulum::TransportType::Broadcast); @@ -6597,13 +6450,13 @@ bool LxmfAdapter::sendForwardPlan(const reticulum::ParsedPacket& packet, packet.destination_hash, packet.payload, packet.payload_len, - forward_packet_scratch_, + scratch_.forward_packet, &forward_len, plan.hops); } return built && interfaces_.sendPacketOn(plan.interface_id, - forward_packet_scratch_, + scratch_.forward_packet, forward_len); } @@ -6644,8 +6497,8 @@ bool LxmfAdapter::sendProofForPacket(const uint8_t* raw_packet, size_t raw_len) uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; memcpy(destination_hash, packet_hash, sizeof(destination_hash)); - std::memset(proof_packet_scratch_, 0, sizeof(proof_packet_scratch_)); - size_t proof_len = sizeof(proof_packet_scratch_); + std::memset(scratch_.proof_packet, 0, sizeof(scratch_.proof_packet)); + size_t proof_len = sizeof(scratch_.proof_packet); if (!reticulum::buildHeader1Packet(reticulum::PacketType::Proof, reticulum::DestinationType::Single, reticulum::PacketContext::None, @@ -6653,7 +6506,7 @@ bool LxmfAdapter::sendProofForPacket(const uint8_t* raw_packet, size_t raw_len) destination_hash, signature, sizeof(signature), - proof_packet_scratch_, + scratch_.proof_packet, &proof_len)) { return false; @@ -6662,9 +6515,9 @@ bool LxmfAdapter::sendProofForPacket(const uint8_t* raw_packet, size_t raw_len) return active_ingress_interface_id_ != reticulum::interfaces::kInvalidInterfaceId ? interfaces_.sendPacketOn(active_ingress_interface_id_, - proof_packet_scratch_, + scratch_.proof_packet, proof_len) - : interfaces_.sendPacket(proof_packet_scratch_, proof_len); + : interfaces_.sendPacket(scratch_.proof_packet, proof_len); } bool LxmfAdapter::sendPathRequest(PeerInfo& peer) @@ -6690,10 +6543,10 @@ bool LxmfAdapter::sendPathRequest(PeerInfo& peer) uint8_t control_hash[reticulum::kTruncatedHashSize] = {}; pathRequestDestinationHash(control_hash); - std::memset(path_request_packet_scratch_, + std::memset(scratch_.path_request_packet, 0, - sizeof(path_request_packet_scratch_)); - size_t packet_len = sizeof(path_request_packet_scratch_); + sizeof(scratch_.path_request_packet)); + size_t packet_len = sizeof(scratch_.path_request_packet); if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, reticulum::DestinationType::Plain, reticulum::PacketContext::None, @@ -6701,13 +6554,13 @@ bool LxmfAdapter::sendPathRequest(PeerInfo& peer) control_hash, request_payload, sizeof(request_payload), - path_request_packet_scratch_, + scratch_.path_request_packet, &packet_len)) { return false; } - if (!routeAndSendPacket(path_request_packet_scratch_, packet_len, false)) + if (!routeAndSendPacket(scratch_.path_request_packet, packet_len, false)) { return false; } @@ -6743,10 +6596,10 @@ bool LxmfAdapter::sendPathRequestForDestination( uint8_t control_hash[reticulum::kTruncatedHashSize] = {}; pathRequestDestinationHash(control_hash); - std::memset(path_request_packet_scratch_, + std::memset(scratch_.path_request_packet, 0, - sizeof(path_request_packet_scratch_)); - size_t packet_len = sizeof(path_request_packet_scratch_); + sizeof(scratch_.path_request_packet)); + size_t packet_len = sizeof(scratch_.path_request_packet); if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, reticulum::DestinationType::Plain, reticulum::PacketContext::None, @@ -6754,13 +6607,13 @@ bool LxmfAdapter::sendPathRequestForDestination( control_hash, request_payload, sizeof(request_payload), - path_request_packet_scratch_, + scratch_.path_request_packet, &packet_len)) { return false; } - if (!routeAndSendPacket(path_request_packet_scratch_, + if (!routeAndSendPacket(scratch_.path_request_packet, packet_len, false, true)) @@ -6906,27 +6759,27 @@ bool LxmfAdapter::prepareLinkRequest(LinkSession& session) constexpr size_t kRequestPayloadLen = kLinkRequestBaseLen + kLinkSignallingLen; - std::memset(link_request_payload_scratch_, 0, kRequestPayloadLen); - std::memcpy(link_request_payload_scratch_, + std::memset(scratch_.link_request_payload, 0, kRequestPayloadLen); + std::memcpy(scratch_.link_request_payload, session.local_enc_pub, LxmfIdentity::kEncPubKeySize); - std::memcpy(link_request_payload_scratch_ + LxmfIdentity::kEncPubKeySize, + std::memcpy(scratch_.link_request_payload + LxmfIdentity::kEncPubKeySize, session.local_sig_pub, LxmfIdentity::kSigPubKeySize); buildLinkSignallingBytes( reticulum::kReticulumMtu, - link_request_payload_scratch_ + kLinkRequestBaseLen); + scratch_.link_request_payload + kLinkRequestBaseLen); - link_request_packet_len_ = sizeof(link_request_packet_scratch_); + link_request_packet_len_ = sizeof(scratch_.link_request_packet); if (!reticulum::buildHeader1Packet( reticulum::PacketType::LinkRequest, reticulum::DestinationType::Single, reticulum::PacketContext::None, false, session.remote_destination_hash, - link_request_payload_scratch_, + scratch_.link_request_payload, kRequestPayloadLen, - link_request_packet_scratch_, + scratch_.link_request_packet, &link_request_packet_len_)) { link_request_packet_len_ = 0; @@ -6935,10 +6788,10 @@ bool LxmfAdapter::prepareLinkRequest(LinkSession& session) reticulum::ParsedPacket parsed{}; uint8_t prepared_link_id[reticulum::kTruncatedHashSize] = {}; - if (!reticulum::parsePacket(link_request_packet_scratch_, + if (!reticulum::parsePacket(scratch_.link_request_packet, link_request_packet_len_, &parsed) || - !computeLinkIdFromLinkRequest(link_request_packet_scratch_, + !computeLinkIdFromLinkRequest(scratch_.link_request_packet, link_request_packet_len_, parsed, prepared_link_id)) @@ -6972,7 +6825,7 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) } reticulum::ParsedPacket parsed{}; - if (!reticulum::parsePacket(link_request_packet_scratch_, + if (!reticulum::parsePacket(scratch_.link_request_packet, link_request_packet_len_, &parsed) || !parsed.destination_hash) @@ -6984,7 +6837,7 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) return false; } - const uint8_t* tx_packet = link_request_packet_scratch_; + const uint8_t* tx_packet = scratch_.link_request_packet; size_t tx_packet_len = link_request_packet_len_; bool routed = false; const PathEntry* tx_path = @@ -6999,7 +6852,7 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) } if (tx_path && tx_path->hops > 1 && !tx_path->direct) { - tx_packet_len = sizeof(link_request_routed_scratch_); + tx_packet_len = sizeof(scratch_.link_request_routed); if (!reticulum::buildHeader2Packet( parsed.packet_type, parsed.destination_type, @@ -7009,9 +6862,9 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) parsed.destination_hash, parsed.payload, parsed.payload_len, - link_request_routed_scratch_, + scratch_.link_request_routed, &tx_packet_len, - link_request_packet_scratch_[1])) + scratch_.link_request_packet[1])) { Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=route_build raw_len=%u\n", dest_hash, @@ -7019,7 +6872,7 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) static_cast(link_request_packet_len_)); return false; } - tx_packet = link_request_routed_scratch_; + tx_packet = scratch_.link_request_routed; routed = true; } @@ -7311,12 +7164,12 @@ bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, return false; } - std::memset(encrypted_payload_scratch_, 0, sizeof(encrypted_payload_scratch_)); - size_t payload_len = sizeof(encrypted_payload_scratch_); + std::memset(scratch_.encrypted_payload, 0, sizeof(scratch_.encrypted_payload)); + size_t payload_len = sizeof(scratch_.encrypted_payload); if (!encryptForPeer(peer, plaintext, plaintext_len, - encrypted_payload_scratch_, + scratch_.encrypted_payload, &payload_len)) { return false; @@ -7327,7 +7180,7 @@ bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, reticulum::PacketContext::None, false, peer.destination_hash, - encrypted_payload_scratch_, + scratch_.encrypted_payload, payload_len, out_packet, inout_len); @@ -7385,8 +7238,8 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, return send_packet(raw_packet, raw_len, path->interface_id); } - std::memset(routed_packet_scratch_, 0, sizeof(routed_packet_scratch_)); - size_t routed_len = sizeof(routed_packet_scratch_); + std::memset(scratch_.routed_packet, 0, sizeof(scratch_.routed_packet)); + size_t routed_len = sizeof(scratch_.routed_packet); if (!reticulum::buildHeader2Packet(parsed.packet_type, parsed.destination_type, static_cast(parsed.context), @@ -7395,14 +7248,14 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, parsed.destination_hash, parsed.payload, parsed.payload_len, - routed_packet_scratch_, + scratch_.routed_packet, &routed_len, raw_packet[1])) { return false; } - return send_packet(routed_packet_scratch_, routed_len, path->interface_id); + return send_packet(scratch_.routed_packet, routed_len, path->interface_id); } bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, @@ -7425,8 +7278,8 @@ bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, return true; } - std::memset(routed_packet_scratch_, 0, sizeof(routed_packet_scratch_)); - size_t packet_len = sizeof(routed_packet_scratch_); + std::memset(scratch_.routed_packet, 0, sizeof(scratch_.routed_packet)); + size_t packet_len = sizeof(scratch_.routed_packet); if (!reticulum::buildHeader2Packet(reticulum::PacketType::Announce, reticulum::DestinationType::Single, context, @@ -7435,7 +7288,7 @@ bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, parsed.destination_hash, parsed.payload, parsed.payload_len, - routed_packet_scratch_, + scratch_.routed_packet, &packet_len, path.hops)) { @@ -7445,9 +7298,9 @@ bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, return active_ingress_interface_id_ != reticulum::interfaces::kInvalidInterfaceId ? interfaces_.sendPacketOn(active_ingress_interface_id_, - routed_packet_scratch_, + scratch_.routed_packet, packet_len) - : interfaces_.sendPacket(routed_packet_scratch_, packet_len); + : interfaces_.sendPacket(scratch_.routed_packet, packet_len); } bool LxmfAdapter::sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kFullHashSize]) @@ -7566,21 +7419,9 @@ bool LxmfAdapter::shouldLogRxDetail( budget.allow_persistence ? kLoraDiscoverySleepDetailLogIntervalMs : kLoraDiscoveryForegroundDetailLogIntervalMs; - if (last_lora_discovery_detail_log_ms_ != 0 && - now_ms - last_lora_discovery_detail_log_ms_ < interval_ms) - { - ++suppressed_lora_discovery_detail_logs_; - return false; - } - if (suppressed_lora_discovery_detail_logs_ != 0) - { - Serial.printf("[LXMF][RawRX] detail_suppressed iface=lora public_discovery=1 phase=%s suppressed=%u\n", - budget.phase ? budget.phase : "-", - static_cast(suppressed_lora_discovery_detail_logs_)); - suppressed_lora_discovery_detail_logs_ = 0; - } - last_lora_discovery_detail_log_ms_ = now_ms; - return true; + return rx_telemetry_.shouldLogLoraDiscoveryDetail(now_ms, + interval_ms, + budget.phase); } if (!packet.destination_hash) { @@ -7601,18 +7442,9 @@ bool LxmfAdapter::shouldLogRxDetail( bool LxmfAdapter::consumeDiscoveryBudget( reticulum::interfaces::InterfaceKind ingress_interface) { - uint32_t& last_sample_ms = - ingress_interface == reticulum::interfaces::InterfaceKind::WifiGateway - ? last_wifi_discovery_sample_ms_ - : last_lora_discovery_sample_ms_; - const uint32_t now_ms = millis(); - if (last_sample_ms != 0 && - (now_ms - last_sample_ms) < kDiscoverySampleIntervalMs) - { - return false; - } - last_sample_ms = now_ms; - return true; + return rx_telemetry_.consumeDiscoveryBudget(ingress_interface, + millis(), + kDiscoverySampleIntervalMs); } bool LxmfAdapter::isForegroundDiscoveryDestination( @@ -7666,74 +7498,14 @@ void LxmfAdapter::noteRxSummary(bool wifi_skipped, bool deferred_dropped, bool throttled_discovery) { - if (!wifi_skipped && !duplicate && !parse_failed && !deferred && - !deferred_dropped && !throttled_discovery) - { - ++rx_summary_packets_; - } - if (wifi_skipped) - { - ++rx_summary_wifi_skipped_; - } - if (duplicate) - { - ++rx_summary_duplicates_; - } - if (parse_failed) - { - ++rx_summary_parse_failed_; - } - if (deferred) - { - ++rx_summary_deferred_; - } - if (deferred_dropped) - { - ++rx_summary_deferred_dropped_; - } - if (throttled_discovery) - { - ++rx_summary_throttled_discovery_; - } - - const uint32_t now_ms = millis(); - if (last_rx_summary_ms_ == 0) - { - last_rx_summary_ms_ = now_ms; - return; - } - if ((now_ms - last_rx_summary_ms_) < kRxSummaryIntervalMs) - { - return; - } - if (rx_summary_packets_ == 0 && - rx_summary_wifi_skipped_ == 0 && - rx_summary_duplicates_ == 0 && - rx_summary_parse_failed_ == 0 && - rx_summary_deferred_ == 0 && - rx_summary_deferred_dropped_ == 0 && - rx_summary_throttled_discovery_ == 0) - { - last_rx_summary_ms_ = now_ms; - return; - } - - Serial.printf("[LXMF][RawRX] stats packets=%u wifi_skipped=%u duplicate=%u parse_failed=%u deferred=%u deferred_drop=%u throttled_discovery=%u\n", - static_cast(rx_summary_packets_), - static_cast(rx_summary_wifi_skipped_), - static_cast(rx_summary_duplicates_), - static_cast(rx_summary_parse_failed_), - static_cast(rx_summary_deferred_), - static_cast(rx_summary_deferred_dropped_), - static_cast(rx_summary_throttled_discovery_)); - rx_summary_packets_ = 0; - rx_summary_wifi_skipped_ = 0; - rx_summary_duplicates_ = 0; - rx_summary_parse_failed_ = 0; - rx_summary_deferred_ = 0; - rx_summary_deferred_dropped_ = 0; - rx_summary_throttled_discovery_ = 0; - last_rx_summary_ms_ = now_ms; + rx_telemetry_.noteSummary(wifi_skipped, + duplicate, + parse_failed, + deferred, + deferred_dropped, + throttled_discovery, + millis(), + kRxSummaryIntervalMs); } bool LxmfAdapter::shouldRebroadcastAnnounce( @@ -7753,15 +7525,14 @@ bool LxmfAdapter::rebroadcastAnnounce(const PathEntry& path, const reticulum::Pa } const uint32_t now_ms = millis(); - if (last_announce_rebroadcast_ms_ != 0 && - (now_ms - last_announce_rebroadcast_ms_) < kAnnounceRebroadcastIntervalMs) + if (!announce_scheduler_.rebroadcastDue(now_ms, + kAnnounceRebroadcastIntervalMs)) { return false; } - last_announce_rebroadcast_ms_ = now_ms; - std::memset(forward_packet_scratch_, 0, sizeof(forward_packet_scratch_)); - size_t rebroadcast_len = sizeof(forward_packet_scratch_); + std::memset(scratch_.forward_packet, 0, sizeof(scratch_.forward_packet)); + size_t rebroadcast_len = sizeof(scratch_.forward_packet); if (!reticulum::buildHeader2Packet(reticulum::PacketType::Announce, reticulum::DestinationType::Single, reticulum::PacketContext::None, @@ -7770,14 +7541,14 @@ bool LxmfAdapter::rebroadcastAnnounce(const PathEntry& path, const reticulum::Pa packet.destination_hash, packet.payload, packet.payload_len, - forward_packet_scratch_, + scratch_.forward_packet, &rebroadcast_len, packet.hops)) { return false; } - return interfaces_.sendPacket(forward_packet_scratch_, rebroadcast_len); + return interfaces_.sendPacket(scratch_.forward_packet, rebroadcast_len); } void LxmfAdapter::cullTransportState() @@ -8055,23 +7826,23 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, size_t effective_payload_len = payload_len; if (encrypt_payload) { - std::memset(link_wire_payload_scratch_, + std::memset(scratch_.link_wire_payload, 0, - sizeof(link_wire_payload_scratch_)); - effective_payload = link_wire_payload_scratch_; - effective_payload_len = sizeof(link_wire_payload_scratch_); + sizeof(scratch_.link_wire_payload)); + effective_payload = scratch_.link_wire_payload; + effective_payload_len = sizeof(scratch_.link_wire_payload); if (!encryptLinkPayload(session, payload, payload_len, - link_wire_payload_scratch_, + scratch_.link_wire_payload, &effective_payload_len)) { return false; } } - std::memset(link_packet_scratch_, 0, sizeof(link_packet_scratch_)); - size_t packet_len = sizeof(link_packet_scratch_); + std::memset(scratch_.link_packet, 0, sizeof(scratch_.link_packet)); + size_t packet_len = sizeof(scratch_.link_packet); if (!reticulum::buildHeader1Packet(packet_type, reticulum::DestinationType::Link, context, @@ -8079,7 +7850,7 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, session.link_id, effective_payload, effective_payload_len, - link_packet_scratch_, + scratch_.link_packet, &packet_len)) { return false; @@ -8089,7 +7860,7 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, session.interface_id != reticulum::interfaces::kInvalidInterfaceId; const bool ok = has_bound_interface ? interfaces_.sendPacketOn(session.interface_id, - link_packet_scratch_, + scratch_.link_packet, packet_len, session.destination == LocalDestinationKind::CallAudio @@ -8097,18 +7868,18 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, : nullptr, call_admission_control) : (session.destination == LocalDestinationKind::CallAudio - ? interfaces_.sendPacketWifiOnly(link_packet_scratch_, + ? interfaces_.sendPacketWifiOnly(scratch_.link_packet, packet_len, session.link_id, call_admission_control) - : interfaces_.sendPacket(link_packet_scratch_, + : interfaces_.sendPacket(scratch_.link_packet, packet_len)); if (ok) { link_manager_.touchOutbound(session, millis()); if (out_packet_hash) { - reticulum::computePacketHash(link_packet_scratch_, + reticulum::computePacketHash(scratch_.link_packet, packet_len, out_packet_hash); } @@ -8130,62 +7901,62 @@ bool LxmfAdapter::sendNomadPageRequestPacket( std::strlen(request.path), path_hash); - std::memset(nomad_page_request_payload_scratch_, + std::memset(scratch_.nomad_page_request_payload, 0, - sizeof(nomad_page_request_payload_scratch_)); - size_t request_payload_len = sizeof(nomad_page_request_payload_scratch_); + sizeof(scratch_.nomad_page_request_payload)); + size_t request_payload_len = sizeof(scratch_.nomad_page_request_payload); if (!encodeLinkRequestPayload(static_cast(currentTimestampSeconds()), path_hash, nullptr, 0, true, - nomad_page_request_payload_scratch_, + scratch_.nomad_page_request_payload, &request_payload_len) || request_payload_len > session.mdu) { return false; } - std::memset(nomad_page_wire_payload_scratch_, + std::memset(scratch_.nomad_page_wire_payload, 0, - sizeof(nomad_page_wire_payload_scratch_)); - size_t wire_payload_len = sizeof(nomad_page_wire_payload_scratch_); + sizeof(scratch_.nomad_page_wire_payload)); + size_t wire_payload_len = sizeof(scratch_.nomad_page_wire_payload); if (!encryptLinkPayload(session, - nomad_page_request_payload_scratch_, + scratch_.nomad_page_request_payload, request_payload_len, - nomad_page_wire_payload_scratch_, + scratch_.nomad_page_wire_payload, &wire_payload_len)) { return false; } - std::memset(nomad_page_packet_scratch_, + std::memset(scratch_.nomad_page_packet, 0, - sizeof(nomad_page_packet_scratch_)); - size_t packet_len = sizeof(nomad_page_packet_scratch_); + sizeof(scratch_.nomad_page_packet)); + size_t packet_len = sizeof(scratch_.nomad_page_packet); if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, reticulum::DestinationType::Link, reticulum::PacketContext::Request, false, session.link_id, - nomad_page_wire_payload_scratch_, + scratch_.nomad_page_wire_payload, wire_payload_len, - nomad_page_packet_scratch_, + scratch_.nomad_page_packet, &packet_len)) { return false; } uint8_t request_id[reticulum::kTruncatedHashSize] = {}; - reticulum::computeTruncatedPacketHash(nomad_page_packet_scratch_, + reticulum::computeTruncatedPacketHash(scratch_.nomad_page_packet, packet_len, request_id); const bool ok = session.interface_id != reticulum::interfaces::kInvalidInterfaceId ? interfaces_.sendPacketOn(session.interface_id, - nomad_page_packet_scratch_, + scratch_.nomad_page_packet, packet_len) - : interfaces_.sendPacket(nomad_page_packet_scratch_, packet_len); + : interfaces_.sendPacket(scratch_.nomad_page_packet, packet_len); if (!ok) { return false; @@ -8860,10 +8631,10 @@ bool LxmfAdapter::advertiseLinkResource(LinkSession& session, const size_t slice_offset = start_hash * kResourceMapHashLen; const size_t slice_len = slice_hashes * kResourceMapHashLen; - std::memset(resource_advertisement_scratch_, + std::memset(scratch_.resource_advertisement, 0, - sizeof(resource_advertisement_scratch_)); - size_t advertisement_len = sizeof(resource_advertisement_scratch_); + sizeof(scratch_.resource_advertisement)); + size_t advertisement_len = sizeof(scratch_.resource_advertisement); if (encodeResourceAdvertisement(resource.transfer_size, resource.data_size, resource.part_count, @@ -8877,7 +8648,7 @@ bool LxmfAdapter::advertiseLinkResource(LinkSession& session, resource.flags, resource.hashmap.data() + slice_offset, slice_len, - resource_advertisement_scratch_, + scratch_.resource_advertisement, &advertisement_len) && advertisement_len <= session.mdu) { @@ -8885,7 +8656,7 @@ bool LxmfAdapter::advertiseLinkResource(LinkSession& session, return sendLinkPacket(session, reticulum::PacketType::Data, reticulum::PacketContext::ResourceAdv, - resource_advertisement_scratch_, + scratch_.resource_advertisement, advertisement_len, true); } @@ -9819,7 +9590,7 @@ LxmfAdapter::PeerInfo* LxmfAdapter::findOrLoadPeerByNodeId(NodeId node_id) if (result.loaded_from_directory && result.peer) { - queuePeerUpdate(*result.peer); + peer_directory_service_.queuePeerUpdate(*result.peer); Serial.printf("[LXMF][Directory] peer_lookup loaded node=%08lX name=%s\n", static_cast(node_id), result.peer->display_name[0] != '\0' @@ -9852,7 +9623,7 @@ LxmfAdapter::PeerInfo* LxmfAdapter::findOrLoadPeerByDestinationHash( if (result.loaded_from_directory && result.peer) { - queuePeerUpdate(*result.peer); + peer_directory_service_.queuePeerUpdate(*result.peer); char dest[12] = {}; formatHashPrefix(result.peer->destination_hash, dest, sizeof(dest)); Serial.printf("[LXMF][Directory] peer_lookup loaded dest=%s name=%s\n", @@ -9907,65 +9678,20 @@ bool LxmfAdapter::recordPeerInDirectory(const PeerInfo& peer, return true; } -void LxmfAdapter::queuePeerUpdate(const PeerInfo& peer) -{ - if (peer.node_id == 0) - { - return; - } - - for (std::size_t index = 0; index < pending_peer_projection_count_; ++index) - { - if (pending_peer_projection_nodes_[index] == peer.node_id) - { - return; - } - } - - if (pending_peer_projection_count_ >= pending_peer_projection_nodes_.size()) - { - return; - } - - pending_peer_projection_nodes_[pending_peer_projection_count_] = peer.node_id; - ++pending_peer_projection_count_; -} - void LxmfAdapter::pumpPendingPeerUpdates() { - if (pending_peer_projection_count_ == 0) - { - return; - } - const uint32_t now_ms = millis(); const bool maintenance_window = screen_runtime::is_sleeping() && !screen_runtime::is_saver_active(); - const uint32_t interval_ms = maintenance_window - ? kPeerProjectionSleepIntervalMs - : kPeerProjectionScreenIntervalMs; - if (last_peer_projection_ms_ != 0 && - (now_ms - last_peer_projection_ms_) < interval_ms) - { - return; - } - - const NodeId node_id = pending_peer_projection_nodes_[0]; - for (std::size_t index = 1; index < pending_peer_projection_count_; ++index) - { - pending_peer_projection_nodes_[index - 1U] = pending_peer_projection_nodes_[index]; - } - --pending_peer_projection_count_; - last_peer_projection_ms_ = now_ms; - - const PeerInfo* peer = destination_registry_.findByNodeId(node_id); - if (peer) - { - publishPeerUpdate(*peer); - } + peer_directory_service_.pumpQueuedPeerUpdates(destination_registry_, + *this, + now_ms, + maintenance_window, + kPeerProjectionSleepIntervalMs, + kPeerProjectionScreenIntervalMs); } -void LxmfAdapter::publishPeerUpdate(const PeerInfo& peer) const +void LxmfAdapter::publishPeerUpdate(const PeerInfo& peer) { char short_name[10] = {}; snprintf(short_name, sizeof(short_name), "%04lX", @@ -10001,14 +9727,9 @@ void LxmfAdapter::loadDirectoryPeers() return; } - std::array loaded_nodes = {}; const runtime::PeerDirectoryLoadRecentResult result = - peer_directory_service_.loadRecent(destination_registry_, - peer_directory_load_entries_.data(), - peer_directory_load_entries_.size(), - loaded_nodes.data(), - loaded_nodes.size(), - currentTimestampSeconds()); + peer_directory_service_.loadRecentAndQueue(destination_registry_, + currentTimestampSeconds()); if (!result.status.succeeded()) { Serial.printf("[LXMF][Directory] load failed status=%u\n", @@ -10016,17 +9737,6 @@ void LxmfAdapter::loadDirectoryPeers() return; } - const std::size_t queued_count = - result.loaded < loaded_nodes.size() ? result.loaded : loaded_nodes.size(); - for (std::size_t index = 0; index < queued_count; ++index) - { - const PeerInfo* peer = destination_registry_.findByNodeId(loaded_nodes[index]); - if (peer) - { - queuePeerUpdate(*peer); - } - } - if (result.loaded > 0) { Serial.printf("[LXMF][Directory] loaded addresses=%u directory=mesh_peer_directory\n", diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_announce_scheduler.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_announce_scheduler.cpp new file mode 100644 index 00000000..765d8093 --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_announce_scheduler.cpp @@ -0,0 +1,96 @@ +/** + * @file lxmf_announce_scheduler.cpp + * @brief Local announce TX scheduling state for the embedded LXMF runtime. + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_announce_scheduler.h" + +namespace chat::lxmf::runtime +{ + +void AnnounceScheduler::resetAfterConfig(uint32_t now_ms, bool anonymous_peer) +{ + last_announce_ms_ = now_ms; + last_attempt_ms_ = 0; + pending_ = !anonymous_peer; +} + +bool AnnounceScheduler::beginManualBroadcast(bool anonymous_peer) +{ + if (anonymous_peer) + { + pending_ = false; + return false; + } + pending_ = true; + return true; +} + +void AnnounceScheduler::markIdentityChanged() +{ + last_attempt_ms_ = 0; + pending_ = true; +} + +AnnounceScheduleDecision AnnounceScheduler::next(uint32_t now_ms, + bool anonymous_peer, + uint32_t announce_interval_ms, + uint32_t initial_delay_ms, + uint32_t retry_delay_ms) +{ + if (anonymous_peer) + { + pending_ = false; + last_attempt_ms_ = 0; + return {}; + } + + if (!pending_ && (now_ms - last_announce_ms_) < announce_interval_ms) + { + return {}; + } + if (pending_) + { + const bool first_attempt = last_attempt_ms_ == 0; + const uint32_t wait_ms = first_attempt ? initial_delay_ms : retry_delay_ms; + const uint32_t basis_ms = + first_attempt ? last_announce_ms_ : last_attempt_ms_; + if ((now_ms - basis_ms) < wait_ms) + { + return {}; + } + } + + AnnounceScheduleDecision decision{}; + decision.should_send = true; + return decision; +} + +void AnnounceScheduler::completeAttempt(uint32_t now_ms, + bool any_sent, + bool all_complete) +{ + last_attempt_ms_ = now_ms; + if (any_sent) + { + last_announce_ms_ = now_ms; + } + pending_ = !all_complete; + if (!pending_) + { + last_attempt_ms_ = 0; + } +} + +bool AnnounceScheduler::rebroadcastDue(uint32_t now_ms, uint32_t interval_ms) +{ + if (last_rebroadcast_ms_ != 0 && + (now_ms - last_rebroadcast_ms_) < interval_ms) + { + return false; + } + last_rebroadcast_ms_ = now_ms; + return true; +} + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_deferred_discovery_queue.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_deferred_discovery_queue.cpp new file mode 100644 index 00000000..bcd97829 --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_deferred_discovery_queue.cpp @@ -0,0 +1,109 @@ +/** + * @file lxmf_deferred_discovery_queue.cpp + * @brief Deferred public discovery packet queue for the embedded LXMF runtime. + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_deferred_discovery_queue.h" + +#include + +namespace chat::lxmf::runtime +{ +namespace +{ + +bool hashesEqual(const uint8_t* lhs, const uint8_t* rhs, std::size_t len) +{ + if ((!lhs || !rhs) && len != 0) + { + return false; + } + for (std::size_t index = 0; index < len; ++index) + { + if (lhs[index] != rhs[index]) + { + return false; + } + } + return true; +} + +} // namespace + +void DeferredDiscoveryQueue::clear() +{ + queue_.clear(); + scratch_ = DeferredDiscoveryPacket{}; +} + +bool DeferredDiscoveryQueue::contains( + const uint8_t packet_hash[reticulum::kFullHashSize]) const +{ + if (!packet_hash) + { + return false; + } + for (std::size_t index = 0; index < queue_.size(); ++index) + { + const DeferredDiscoveryPacket* queued = queue_.get(index); + if (queued && + hashesEqual(queued->packet_hash, + packet_hash, + reticulum::kFullHashSize)) + { + return true; + } + } + return false; +} + +bool DeferredDiscoveryQueue::push( + const reticulum::interfaces::RxPacket& packet, + const uint8_t packet_hash[reticulum::kFullHashSize], + bool* out_dropped) +{ + if (out_dropped) + { + *out_dropped = false; + } + if (!packet_hash || packet.len == 0 || + packet.len > reticulum::kReticulumMtu) + { + return false; + } + + scratch_ = DeferredDiscoveryPacket{}; + std::memcpy(scratch_.data, packet.data, packet.len); + scratch_.len = packet.len; + scratch_.rx_meta = packet.rx_meta; + scratch_.interface_kind = packet.interface_kind; + scratch_.interface_id = packet.interface_id; + std::memcpy(scratch_.packet_hash, + packet_hash, + sizeof(scratch_.packet_hash)); + + bool dropped = false; + queue_.pushDropOldest(scratch_, &dropped); + if (out_dropped) + { + *out_dropped = dropped; + } + return true; +} + +bool DeferredDiscoveryQueue::pop( + reticulum::interfaces::RxPacket* out_packet) +{ + if (!out_packet || !queue_.popOldest(&scratch_)) + { + return false; + } + out_packet->len = scratch_.len; + std::memcpy(out_packet->data, scratch_.data, scratch_.len); + out_packet->rx_meta = scratch_.rx_meta; + out_packet->interface_kind = scratch_.interface_kind; + out_packet->interface_id = scratch_.interface_id; + return true; +} + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_lxst_telephony_client.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_lxst_telephony_client.cpp index cb033b20..c7a7ae4e 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_lxst_telephony_client.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_lxst_telephony_client.cpp @@ -7,6 +7,10 @@ #include "chat/infra/reticulum/lxst_telephony_wire.h" +#ifndef TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT +#define TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT 0 +#endif + namespace chat::lxmf::runtime { @@ -55,7 +59,12 @@ void LxstTelephonyClient::beginCallerSession( uint16_t profile, uint32_t now_ms) { +#if TRAIL_MATE_ENABLE_MESHCHAT_CALL_AUDIO_COMPAT session.call_wire_profile = wire_profile; +#else + (void)wire_profile; + session.call_wire_profile = ReticulumCallWireProfile::SidebandLxst; +#endif session.call_runtime_started = false; session.lxst_call = reticulum::lxst::call::makeCaller(profile, now_ms); } diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_peer_directory.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_peer_directory.cpp index 6dbaf844..69708352 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_peer_directory.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_peer_directory.cpp @@ -355,4 +355,92 @@ PeerDirectoryLoadRecentResult PeerDirectoryService::loadRecent( return result; } +PeerDirectoryLoadRecentResult PeerDirectoryService::loadRecentAndQueue( + DestinationRegistry& registry, + uint32_t now_s) +{ + std::array loaded_nodes = {}; + PeerDirectoryLoadRecentResult result = loadRecent(registry, + hot_load_records_.data(), + hot_load_records_.size(), + loaded_nodes.data(), + loaded_nodes.size(), + now_s); + if (!result.status.succeeded()) + { + return result; + } + + const std::size_t queued_count = + result.loaded < loaded_nodes.size() ? result.loaded : loaded_nodes.size(); + for (std::size_t index = 0; index < queued_count; ++index) + { + const PeerInfo* peer = registry.findByNodeId(loaded_nodes[index]); + if (peer) + { + queuePeerUpdate(*peer); + } + } + return result; +} + +void PeerDirectoryService::queuePeerUpdate(const PeerInfo& peer) +{ + if (peer.node_id == 0) + { + return; + } + + for (std::size_t index = 0; index < pending_projection_count_; ++index) + { + if (pending_projection_nodes_[index] == peer.node_id) + { + return; + } + } + + if (pending_projection_count_ >= pending_projection_nodes_.size()) + { + return; + } + + pending_projection_nodes_[pending_projection_count_] = peer.node_id; + ++pending_projection_count_; +} + +void PeerDirectoryService::pumpQueuedPeerUpdates(DestinationRegistry& registry, + IPeerProjectionSink& sink, + uint32_t now_ms, + bool maintenance_window, + uint32_t sleep_interval_ms, + uint32_t screen_interval_ms) +{ + if (pending_projection_count_ == 0) + { + return; + } + + const uint32_t interval_ms = + maintenance_window ? sleep_interval_ms : screen_interval_ms; + if (last_projection_ms_ != 0 && + (now_ms - last_projection_ms_) < interval_ms) + { + return; + } + + const NodeId node_id = pending_projection_nodes_[0]; + for (std::size_t index = 1; index < pending_projection_count_; ++index) + { + pending_projection_nodes_[index - 1U] = pending_projection_nodes_[index]; + } + --pending_projection_count_; + last_projection_ms_ = now_ms; + + const PeerInfo* peer = registry.findByNodeId(node_id); + if (peer) + { + sink.publishPeerUpdate(*peer); + } +} + } // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_runtime_budget.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_runtime_budget.cpp new file mode 100644 index 00000000..059ce2de --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_runtime_budget.cpp @@ -0,0 +1,99 @@ +/** + * @file lxmf_runtime_budget.cpp + * @brief Runtime scheduling policy for the embedded Reticulum/LXMF adapter. + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_budget.h" + +namespace chat::lxmf::runtime +{ + +RuntimeBudget makeRuntimeBudget(const RuntimeBudgetInput& input) +{ + RuntimeBudget budget{}; + if (input.call_realtime_active) + { + budget.live_packet_limit = input.call_ingress_packets_per_poll; + budget.deferred_discovery_limit = 0; + budget.allow_public_discovery = false; + budget.allow_persistence = false; + budget.allow_peer_projection = false; + budget.allow_announce_tx = false; + budget.allow_propagation_client = false; + budget.drop_public_discovery = true; + budget.phase = "call"; + return budget; + } + + if (input.nomad_request_active) + { + budget.live_packet_limit = input.max_ingress_packets_per_poll; + budget.deferred_discovery_limit = 0; + budget.allow_public_discovery = false; + budget.allow_persistence = false; + budget.allow_peer_projection = false; + budget.allow_announce_tx = false; + budget.allow_propagation_client = false; + budget.drop_public_discovery = true; + budget.phase = "nomad"; + return budget; + } + + const bool maintenance_window = + input.screen_sleeping && !input.screen_saver_active; + if (maintenance_window) + { + budget.live_packet_limit = 1; + budget.deferred_discovery_limit = 1; + budget.allow_public_discovery = true; + budget.allow_persistence = false; + budget.allow_peer_projection = false; + budget.allow_announce_tx = false; + budget.allow_propagation_client = false; + budget.phase = "sleep"; + return budget; + } + + if (input.screen_saver_active) + { + budget.live_packet_limit = 1; + budget.deferred_discovery_limit = 0; + budget.allow_public_discovery = false; + budget.allow_persistence = false; + budget.allow_peer_projection = false; + budget.allow_announce_tx = false; + budget.allow_propagation_client = false; + budget.drop_public_discovery = true; + budget.phase = "saver"; + return budget; + } + +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // P4 runs the shared mesh task independently of LVGL and has sufficient + // compute for foreground announce verification/projection. Keeping the + // S3 sleep-only discovery policy here leaves an always-lit P4 with an + // eight-packet deferred queue that can never drain into Contacts/Network. + budget.live_packet_limit = input.max_ingress_packets_per_poll; + budget.deferred_discovery_limit = input.max_ingress_packets_per_poll; + budget.allow_public_discovery = true; + budget.allow_persistence = true; + budget.allow_peer_projection = true; + budget.allow_announce_tx = true; + budget.allow_propagation_client = true; + budget.phase = "p4_screen"; + return budget; +#endif + + budget.live_packet_limit = input.max_ingress_packets_per_poll; + budget.deferred_discovery_limit = 0; + budget.allow_public_discovery = false; + budget.allow_persistence = false; + budget.allow_peer_projection = !input.screen_saver_active; + budget.allow_announce_tx = true; + budget.allow_propagation_client = true; + budget.drop_public_discovery = true; + budget.phase = "screen"; + return budget; +} + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_rx_telemetry.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_rx_telemetry.cpp new file mode 100644 index 00000000..a8c25c64 --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_rx_telemetry.cpp @@ -0,0 +1,146 @@ +/** + * @file lxmf_rx_telemetry.cpp + * @brief RX budget and summary telemetry for the embedded LXMF runtime. + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_rx_telemetry.h" + +#include + +namespace chat::lxmf::runtime +{ + +bool RawRxTelemetry::consumeDiscoveryBudget( + reticulum::interfaces::InterfaceKind ingress_interface, + uint32_t now_ms, + uint32_t sample_interval_ms) +{ + uint32_t& last_sample_ms = + ingress_interface == reticulum::interfaces::InterfaceKind::WifiGateway + ? last_wifi_discovery_sample_ms_ + : last_lora_discovery_sample_ms_; + if (last_sample_ms != 0 && + (now_ms - last_sample_ms) < sample_interval_ms) + { + return false; + } + last_sample_ms = now_ms; + return true; +} + +bool RawRxTelemetry::shouldLogLoraDiscoveryDetail(uint32_t now_ms, + uint32_t interval_ms, + const char* phase) +{ + if (last_lora_discovery_detail_log_ms_ != 0 && + now_ms - last_lora_discovery_detail_log_ms_ < interval_ms) + { + ++suppressed_lora_discovery_detail_logs_; + return false; + } + if (suppressed_lora_discovery_detail_logs_ != 0) + { + Serial.printf("[LXMF][RawRX] detail_suppressed iface=lora public_discovery=1 phase=%s suppressed=%u\n", + phase ? phase : "-", + static_cast(suppressed_lora_discovery_detail_logs_)); + suppressed_lora_discovery_detail_logs_ = 0; + } + last_lora_discovery_detail_log_ms_ = now_ms; + return true; +} + +bool RawRxTelemetry::shouldLogLoraAnnounceIgnore(uint32_t now_ms, + uint32_t interval_ms) +{ + if (last_lora_announce_ignore_log_ms_ != 0 && + now_ms - last_lora_announce_ignore_log_ms_ < interval_ms) + { + ++suppressed_lora_announce_ignore_logs_; + return false; + } + if (suppressed_lora_announce_ignore_logs_ != 0) + { + Serial.printf("[LXMF][AnnounceRX] ignored_suppressed iface=lora suppressed=%u\n", + static_cast(suppressed_lora_announce_ignore_logs_)); + suppressed_lora_announce_ignore_logs_ = 0; + } + last_lora_announce_ignore_log_ms_ = now_ms; + return true; +} + +void RawRxTelemetry::noteSummary(bool wifi_skipped, + bool duplicate, + bool parse_failed_event, + bool deferred_event, + bool deferred_dropped_event, + bool throttled_discovery_event, + uint32_t now_ms, + uint32_t summary_interval_ms) +{ + if (!wifi_skipped && !duplicate && !parse_failed_event && + !deferred_event && !deferred_dropped_event && + !throttled_discovery_event) + { + ++packets_; + } + if (wifi_skipped) + { + ++wifi_skipped_; + } + if (duplicate) + { + ++duplicates_; + } + if (parse_failed_event) + { + ++parse_failed_; + } + if (deferred_event) + { + ++deferred_; + } + if (deferred_dropped_event) + { + ++deferred_dropped_; + } + if (throttled_discovery_event) + { + ++throttled_discovery_; + } + + if (last_summary_ms_ == 0) + { + last_summary_ms_ = now_ms; + return; + } + if ((now_ms - last_summary_ms_) < summary_interval_ms) + { + return; + } + if (packets_ == 0 && wifi_skipped_ == 0 && duplicates_ == 0 && + parse_failed_ == 0 && deferred_ == 0 && deferred_dropped_ == 0 && + throttled_discovery_ == 0) + { + last_summary_ms_ = now_ms; + return; + } + + Serial.printf("[LXMF][RawRX] stats packets=%u wifi_skipped=%u duplicate=%u parse_failed=%u deferred=%u deferred_drop=%u throttled_discovery=%u\n", + static_cast(packets_), + static_cast(wifi_skipped_), + static_cast(duplicates_), + static_cast(parse_failed_), + static_cast(deferred_), + static_cast(deferred_dropped_), + static_cast(throttled_discovery_)); + packets_ = 0; + wifi_skipped_ = 0; + duplicates_ = 0; + parse_failed_ = 0; + deferred_ = 0; + deferred_dropped_ = 0; + throttled_discovery_ = 0; + last_summary_ms_ = now_ms; +} + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp index 8ae56a25..39c22dcc 100644 --- a/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/meshtastic/mt_adapter.cpp @@ -465,6 +465,40 @@ bool MtAdapter::sendAppData(ChannelId channel, uint32_t portnum, return false; } + runtime::SendPacketEffect packet{}; + packet.protocol = MeshProtocol::Meshtastic; + packet.channel = channel; + packet.dest = dest; + packet.portnum = portnum; + packet.request_id = (packet_id != 0) ? packet_id : next_packet_id_++; + if (packet_id != 0 && packet_id >= next_packet_id_) + { + next_packet_id_ = packet_id + 1; + if (next_packet_id_ == 0) + { + next_packet_id_ = 1; + } + } + packet.want_ack = want_ack; + packet.want_response = want_response; + if (!packet.payload.assign(payload, len)) + { + return false; + } + return enqueueSendPacketAction(packet); +} + +bool MtAdapter::sendAppDataNow(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest, bool want_ack, + MessageId packet_id, + bool want_response) +{ + if (!ready_ || !config_.tx_enabled) + { + return false; + } + uint32_t now_ms = millis(); if (min_tx_interval_ms_ > 0 && last_tx_ms_ > 0 && (now_ms - last_tx_ms_) < min_tx_interval_ms_) @@ -2685,9 +2719,11 @@ void MtAdapter::processReceivedPacket(const uint8_t* data, size_t size) void MtAdapter::processSendQueue() { uint32_t now = millis(); + uint8_t tx_budget_remaining = kLoRaAirTxBudgetPerTick; maybeBroadcastNodeInfo(now); - processProtocolActionQueue(now); + const bool protocol_tx_queued = + processProtocolActionQueue(now, tx_budget_remaining); for (std::size_t index = 0; index < pending_ack_states_.capacity();) { @@ -2704,7 +2740,11 @@ void MtAdapter::processSendQueue() { if (pending.retransmit_count < MAX_ACK_RETRIES) { - retryPendingAck(msg_id, *slot); + if (retryPendingAck(msg_id, *slot, now, tx_budget_remaining)) + { + ++index; + continue; + } ++index; continue; } @@ -2732,12 +2772,15 @@ void MtAdapter::processSendQueue() ++index; } - if (!send_queue_.empty()) + if (!send_queue_.empty() && tx_budget_remaining > 0) { LORA_LOG("[LORA] TX queue pending=%u\n", (unsigned)send_queue_.size()); } - while (!send_queue_.empty()) + uint8_t drained = 0; + while (!send_queue_.empty() && + drained < kSendQueueDrainPerTick && + tx_budget_remaining > 0) { PendingSend* pending = send_queue_.get(0); if (!pending) @@ -2762,8 +2805,10 @@ void MtAdapter::processSendQueue() { // Success, remove from queue last_tx_ms_ = now; + --tx_budget_remaining; PendingSend discarded{}; send_queue_.popOldest(&discarded); + ++drained; } else { @@ -2795,14 +2840,25 @@ void MtAdapter::processSendQueue() } } - processMqttDownlinkTxQueue(now); + const bool downlink_tx_queued = + processMqttDownlinkTxQueue(now, tx_budget_remaining); + if (protocol_tx_queued || downlink_tx_queued) + { + LORA_LOG("[LORA] airtime tick_budget protocol=%u downlink=%u remaining=%u\n", + protocol_tx_queued ? 1U : 0U, + downlink_tx_queued ? 1U : 0U, + static_cast(tx_budget_remaining)); + } } -void MtAdapter::processMqttDownlinkTxQueue(uint32_t now_ms) +bool MtAdapter::processMqttDownlinkTxQueue(uint32_t now_ms, + uint8_t& tx_budget_remaining) { + bool tx_queued = false; uint8_t drained = 0; while (!mqtt_downlink_tx_queue_.empty() && - drained < kMqttDownlinkTxDrainPerTick) + drained < kSendQueueDrainPerTick && + tx_budget_remaining > 0) { PendingMqttDownlinkTx* pending = mqtt_downlink_tx_queue_.get(0); if (!pending) @@ -2842,6 +2898,8 @@ void MtAdapter::processMqttDownlinkTxQueue(uint32_t now_ms) if (transmitWirePacket(pending->wire.data(), pending->wire_size)) { last_tx_ms_ = now_ms; + --tx_budget_remaining; + tx_queued = true; LORA_LOG("[MQTT][DownlinkTX] sent_to_radio from=%08lX to=%08lX id=%08lX ch=0x%02X len=%u retries=%u age_ms=%lu depth=%u\n", static_cast(pending->from), static_cast(pending->to), @@ -2884,6 +2942,7 @@ void MtAdapter::processMqttDownlinkTxQueue(uint32_t now_ms) static_cast(mqtt_downlink_tx_queue_.size())); break; } + return tx_queued; } bool MtAdapter::sendPacket(const PendingSend& pending) @@ -3562,10 +3621,14 @@ bool MtAdapter::executeProtocolAction(const PendingProtocolAction& action) } } -void MtAdapter::processProtocolActionQueue(uint32_t now_ms) +bool MtAdapter::processProtocolActionQueue(uint32_t now_ms, + uint8_t& tx_budget_remaining) { + bool tx_queued = false; size_t processed = 0; - while (protocol_action_count_ > 0 && processed < protocol_action_queue_.size()) + while (protocol_action_count_ > 0 && + processed < protocol_action_queue_.size() && + tx_budget_remaining > 0) { PendingProtocolAction& action = protocol_action_queue_[protocol_action_head_]; if (min_tx_interval_ms_ > 0 && last_tx_ms_ > 0 && @@ -3584,6 +3647,8 @@ void MtAdapter::processProtocolActionQueue(uint32_t now_ms) setNodeInfoReplyMs(action.peer, action.nodeinfo_reply_ms); } last_tx_ms_ = now_ms; + --tx_budget_remaining; + tx_queued = true; popProtocolAction(); } else @@ -3602,6 +3667,7 @@ void MtAdapter::processProtocolActionQueue(uint32_t now_ms) } ++processed; } + return tx_queued; } bool MtAdapter::sendChannelAppDataViaCore(uint32_t portnum, @@ -3713,10 +3779,22 @@ void MtAdapter::clearPendingAck(uint32_t msg_id) pending_ack_states_.erase(msg_id); } -void MtAdapter::retryPendingAck(uint32_t msg_id, PendingAckSlot& slot) +bool MtAdapter::retryPendingAck(uint32_t msg_id, + PendingAckSlot& slot, + uint32_t now_ms, + uint8_t& tx_budget_remaining) { + if (tx_budget_remaining == 0) + { + return false; + } + if (min_tx_interval_ms_ > 0 && last_tx_ms_ > 0 && + (now_ms - last_tx_ms_) < min_tx_interval_ms_) + { + return false; + } PendingAckState& pending = slot.meta; - pending.last_attempt_ms = millis(); + pending.last_attempt_ms = now_ms; ++pending.retransmit_count; mt_diag_log("[MT][RETX] req=%08lX dest=%08lX try=%u len=%u\n", static_cast(msg_id), @@ -3731,11 +3809,13 @@ void MtAdapter::retryPendingAck(uint32_t msg_id, PendingAckSlot& slot) if (transmitWirePacket(slot.wire.data(), slot.wire_size)) { last_tx_ms_ = pending.last_attempt_ms; - return; + --tx_budget_remaining; + return true; } LORA_LOG("[LORA] TX retry immediate fail req=%08lX try=%u\n", static_cast(msg_id), static_cast(pending.retransmit_count)); + return false; } bool MtAdapter::initPkiKeys() @@ -4594,41 +4674,19 @@ bool MtAdapter::sendKeyVerificationPacket(uint32_t dest, const meshtastic_KeyVer return false; } - auto& tx = tx_scratch_; - auto& data_buf = tx.data; - auto& pki_buf = tx.pki; - auto& wire_buffer = tx.wire; - size_t data_size = data_buf.size(); - if (!encodeAppData(meshtastic_PortNum_KEY_VERIFICATION_APP, - kv_buf, kv_stream.bytes_written, - want_response, data_buf.data(), &data_size)) + runtime::SendPacketEffect packet{}; + packet.protocol = MeshProtocol::Meshtastic; + packet.channel = ChannelId::PRIMARY; + packet.dest = dest; + packet.portnum = meshtastic_PortNum_KEY_VERIFICATION_APP; + packet.request_id = next_packet_id_++; + packet.want_ack = false; + packet.want_response = want_response; + if (!packet.payload.assign(kv_buf, kv_stream.bytes_written)) { return false; } - - size_t pki_len = pki_buf.size(); - MessageId msg_id = next_packet_id_++; - if (!encryptPkiPayload(dest, msg_id, data_buf.data(), data_size, pki_buf.data(), &pki_len)) - { - return false; - } - - size_t wire_size = wire_buffer.size(); - uint8_t hop_limit = config_.hop_limit; - uint8_t channel_hash = 0; - bool want_ack = false; - if (!buildWirePacket(pki_buf.data(), pki_len, node_id_, msg_id, - dest, channel_hash, hop_limit, want_ack, - nullptr, 0, wire_buffer.data(), &wire_size)) - { - return false; - } - - if (transmitWirePacket(wire_buffer.data(), wire_size)) - { - return true; - } - return false; + return enqueueSendPacketAction(packet); } bool MtAdapter::sendRoutingAck(uint32_t dest, uint32_t request_id, uint8_t channel_hash, @@ -4846,14 +4904,14 @@ bool MtAdapter::sendProtocolPacketEffect(const runtime::SendPacketEffect& packet const size_t payload_len = packet.payload.size(); if (packet.response_request_id == 0) { - return sendAppData(packet.channel, - packet.portnum, - payload, - payload_len, - packet.dest, - packet.want_ack, - packet.request_id, - packet.want_response); + return sendAppDataNow(packet.channel, + packet.portnum, + payload, + payload_len, + packet.dest, + packet.want_ack, + packet.request_id, + packet.want_response); } auto& mesh_packet = protocol_effect_packet_scratch_; 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 88fed97e..515e8897 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 @@ -33,11 +33,14 @@ constexpr uint8_t kRecordHasReticulumIdentityFlag = 0x01U; constexpr uint8_t kRecordSourceUnverifiedFlag = 0x02U; constexpr uint8_t kRecordHasReticulumLxmfHashFlag = 0x04U; constexpr uint8_t kIndexHasReticulumIdentityFlag = 0x01U; +constexpr uint8_t kReadStateHasReticulumIdentityFlag = 0x01U; // FileHeader::reserved is a durable unread count once the valid bit is set. constexpr uint16_t kUnreadStateValidMask = 0x8000U; constexpr uint16_t kUnreadStateCountMask = 0x7FFFU; constexpr const char* kTempIndexFile = "/chat/index.tmp"; constexpr const char* kBackupIndexFile = "/chat/index.bak"; +constexpr const char* kTempReadStateFile = "/chat/read_state.tmp"; +constexpr const char* kBackupReadStateFile = "/chat/read_state.bak"; static_assert(SdStore::kMaxMessagesPerConv <= kUnreadStateCountMask, "Unread state must cover a full conversation ring"); @@ -209,11 +212,9 @@ bool SdStore::appendInternal(const ChatMessage& msg) if (already_committed) { uint16_t committed_unread = 0; - if (!decodeUnreadState(header.reserved, &committed_unread)) + if (!readStateUnreadOrLegacy(conv, &committed_unread)) { - committed_unread = static_cast( - std::min(std::max(0, getUnread(conv)), - kUnreadStateCountMask)); + committed_unread = 0; } file.close(); updateIndexForMessage(msg, committed_unread); @@ -222,16 +223,14 @@ bool SdStore::appendInternal(const ChatMessage& msg) } uint16_t unread = 0; - if (!decodeUnreadState(header.reserved, &unread)) + if (!readStateUnreadOrLegacy(conv, &unread)) { - unread = static_cast( - std::min(std::max(0, getUnread(conv)), kUnreadStateCountMask)); + unread = 0; } if (msg.status == MessageStatus::Incoming && unread < kUnreadStateCountMask) { unread = static_cast(unread + 1U); } - header.reserved = encodeUnreadState(unread); const Record rec = recordFromMessage(msg); if (!writeRecord(file, header.head, rec)) @@ -246,6 +245,7 @@ bool SdStore::appendInternal(const ChatMessage& msg) header.count = static_cast(header.count + 1U); } + header.reserved = encodeUnreadState(unread); if (!file.seek(0) || !writeExact(file, &header, sizeof(header))) { file.close(); @@ -258,6 +258,16 @@ bool SdStore::appendInternal(const ChatMessage& msg) } file.close(); + if (!writeReadStateUnread(conv, unread)) + { + CHAT_STORE_LOG("[AppContext] chat unread persist failed stage=read_state unread=%u\n", + static_cast(unread)); + unread_reconcile_pending_ = true; + if (!removeReadStateEntry(conv)) + { + return false; + } + } updateIndexForMessage(msg, unread); if (chat::hasReticulumLxmfMessageHash(msg)) { @@ -383,27 +393,40 @@ std::vector SdStore::loadConversationPage(size_t offset, return list; } -void SdStore::setUnread(const ConversationId& conv, int unread) +bool SdStore::setUnread(const ConversationId& conv, int unread) { - std::vector entries; - if (!ready_ || !ensureIndex(entries)) + if (!ready_) { - return; + return false; } - size_t index = 0; - if (!findIndexEntry(conv, entries, &index)) - { - return; - } const uint16_t unread_count = static_cast( std::min(std::max(0, unread), kUnreadStateCountMask)); + if (!writeReadStateUnread(conv, unread_count)) + { + CHAT_STORE_LOG("[AppContext] chat unread persist failed stage=read_state unread=%u\n", + static_cast(unread_count)); + return false; + } + const bool header_written = writeConversationUnread(conv, unread_count); if (!header_written) { CHAT_STORE_LOG("[AppContext] chat unread persist failed stage=conversation_header unread=%u\n", static_cast(unread_count)); - return; + } + + std::vector entries; + if (!ensureIndex(entries)) + { + unread_reconcile_pending_ = true; + return true; + } + + size_t index = 0; + if (!findIndexEntry(conv, entries, &index)) + { + return true; } entries[index].unread = unread_count; const bool index_written = writeIndex(entries); @@ -414,22 +437,22 @@ void SdStore::setUnread(const ConversationId& conv, int unread) unread_reconcile_pending_ = true; rebuildIndex(); } + return true; } int SdStore::getUnread(const ConversationId& conv) const { - std::vector entries; - if (!ready_ || !readIndex(entries)) + if (!ready_) { return 0; } - size_t index = 0; - if (!findIndexEntry(conv, entries, &index)) + uint16_t unread = 0; + if (readStateUnreadOrLegacy(conv, &unread)) { - return 0; + return unread; } - return entries[index].unread; + return 0; } void SdStore::clearConversation(const ConversationId& conv) @@ -439,6 +462,8 @@ void SdStore::clearConversation(const ConversationId& conv) return; } + (void)removeReadStateEntry(conv); + char path[96]{}; buildConversationPath(conv, path, sizeof(path)); if (storage::sd_exists(path)) @@ -480,6 +505,18 @@ void SdStore::clearAll() { storage::sd_remove(kBackupIndexFile); } + if (storage::sd_exists(kReadStateFile)) + { + storage::sd_remove(kReadStateFile); + } + if (storage::sd_exists(kTempReadStateFile)) + { + storage::sd_remove(kTempReadStateFile); + } + if (storage::sd_exists(kBackupReadStateFile)) + { + storage::sd_remove(kBackupReadStateFile); + } std::vector log_paths; storage::SdRuntimeDir dir; @@ -895,9 +932,14 @@ bool SdStore::reconcileIndexUnread(std::vector& entries) const bool changed = false; for (auto& entry : entries) { + const ConversationId conv = conversationFromIndexEntry(entry); uint16_t durable_unread = 0; - if (readConversationUnread(conversationFromIndexEntry(entry), &durable_unread) && - entry.unread != durable_unread) + if (!readStateUnreadOrLegacy(conv, &durable_unread)) + { + durable_unread = entry.unread; + (void)writeReadStateUnread(conv, durable_unread); + } + if (entry.unread != durable_unread) { entry.unread = durable_unread; changed = true; @@ -1039,6 +1081,232 @@ bool SdStore::writeIndex(const std::vector& entries) const return true; } +bool SdStore::readReadState(std::vector& entries) const +{ + entries.clear(); + if (!ensureFs() || !storage::sd_exists(kReadStateFile)) + { + return false; + } + + storage::SdRuntimeFile file; + if (!file.open(kReadStateFile, "r")) + { + return false; + } + + ReadStateHeader header{}; + if (!readExact(file, &header, sizeof(header)) || + header.magic != kReadStateMagic || + header.version != kReadStateVersion) + { + file.close(); + return false; + } + + entries.reserve(header.count); + bool ok = true; + for (uint16_t index = 0; ok && index < header.count; ++index) + { + ReadStateEntry entry{}; + ok = readExact(file, &entry, sizeof(entry)); + if (ok) + { + entries.push_back(entry); + } + } + file.close(); + if (!ok) + { + entries.clear(); + return false; + } + return true; +} + +bool SdStore::writeReadState(const std::vector& entries) const +{ + if (!ensureDir()) + { + return false; + } + + if (storage::sd_exists(kTempReadStateFile)) + { + storage::sd_remove(kTempReadStateFile); + } + + storage::SdRuntimeFile file; + if (!file.open(kTempReadStateFile, "w")) + { + return false; + } + + ReadStateHeader header{}; + header.magic = kReadStateMagic; + header.version = kReadStateVersion; + header.count = static_cast(std::min(entries.size(), 0xFFFFU)); + bool ok = writeExact(file, &header, sizeof(header)); + for (size_t index = 0; ok && index < header.count; ++index) + { + ok = writeExact(file, &entries[index], sizeof(ReadStateEntry)); + } + file.flush(); + file.close(); + + if (!ok) + { + storage::sd_remove(kTempReadStateFile); + return false; + } + + const bool had_state = storage::sd_exists(kReadStateFile); + if (had_state) + { + if (storage::sd_exists(kBackupReadStateFile) && + !storage::sd_remove(kBackupReadStateFile)) + { + storage::sd_remove(kTempReadStateFile); + return false; + } + if (!storage::sd_rename(kReadStateFile, kBackupReadStateFile)) + { + storage::sd_remove(kTempReadStateFile); + return false; + } + } + if (!storage::sd_rename(kTempReadStateFile, kReadStateFile)) + { + if (had_state && !storage::sd_exists(kReadStateFile)) + { + (void)storage::sd_rename(kBackupReadStateFile, kReadStateFile); + } + storage::sd_remove(kTempReadStateFile); + return false; + } + if (storage::sd_exists(kBackupReadStateFile)) + { + (void)storage::sd_remove(kBackupReadStateFile); + } + return true; +} + +bool SdStore::findReadStateEntry(const ConversationId& conv, + std::vector& entries, + size_t* out_idx) const +{ + return findReadStateEntry( + conv, + static_cast&>(entries), + out_idx); +} + +bool SdStore::findReadStateEntry(const ConversationId& conv, + const std::vector& entries, + size_t* out_idx) const +{ + for (size_t index = 0; index < entries.size(); ++index) + { + if (readStateEntryMatchesConversation(entries[index], conv)) + { + if (out_idx) + { + *out_idx = index; + } + return true; + } + } + return false; +} + +bool SdStore::readStateUnreadOnly(const ConversationId& conv, uint16_t* unread) const +{ + if (!unread) + { + return false; + } + std::vector entries; + if (!readReadState(entries)) + { + return false; + } + size_t index = 0; + if (!findReadStateEntry(conv, entries, &index)) + { + return false; + } + *unread = entries[index].unread; + return true; +} + +bool SdStore::readStateUnreadOrLegacy(const ConversationId& conv, uint16_t* unread) const +{ + if (!unread) + { + return false; + } + if (readStateUnreadOnly(conv, unread)) + { + return true; + } + if (readConversationUnread(conv, unread)) + { + (void)writeReadStateUnread(conv, *unread); + return true; + } + std::vector entries; + size_t index = 0; + if (readIndex(entries) && findIndexEntry(conv, entries, &index)) + { + *unread = entries[index].unread; + (void)writeReadStateUnread(conv, *unread); + return true; + } + return false; +} + +bool SdStore::writeReadStateUnread(const ConversationId& conv, uint16_t unread) const +{ + std::vector entries; + if (!readReadState(entries)) + { + entries.clear(); + } + + size_t index = 0; + if (findReadStateEntry(conv, entries, &index)) + { + entries[index] = readStateEntryFromConversation(conv, unread); + } + else + { + entries.push_back(readStateEntryFromConversation(conv, unread)); + } + return writeReadState(entries); +} + +bool SdStore::removeReadStateEntry(const ConversationId& conv) const +{ + std::vector entries; + if (!readReadState(entries)) + { + return true; + } + const size_t before = entries.size(); + entries.erase(std::remove_if(entries.begin(), + entries.end(), + [&](const ReadStateEntry& entry) + { + return readStateEntryMatchesConversation(entry, conv); + }), + entries.end()); + if (entries.size() == before) + { + return true; + } + return writeReadState(entries); +} + bool SdStore::ensureIndex(std::vector& entries) { bool loaded = readIndex(entries); @@ -1194,7 +1462,7 @@ void SdStore::rebuildIndex() ChatMessage last_msg; bool have_last = false; uint16_t unread = 0; - const bool has_durable_unread = decodeUnreadState(header.reserved, &unread); + const bool has_header_unread = decodeUnreadState(header.reserved, &unread); for (uint16_t index = 0; index < header.count; ++index) { const uint16_t slot = @@ -1206,7 +1474,7 @@ void SdStore::rebuildIndex() continue; } ChatMessage msg = messageFromRecord(rec); - if (!has_durable_unread && + if (!has_header_unread && msg.status == MessageStatus::Incoming && unread < kUnreadStateCountMask) { @@ -1225,6 +1493,13 @@ void SdStore::rebuildIndex() continue; } + const ConversationId conv = conversationIdForMessage(last_msg); + uint16_t ledger_unread = 0; + if (readStateUnreadOrLegacy(conv, &ledger_unread)) + { + unread = ledger_unread; + } + IndexEntry entry{}; entry.protocol = static_cast(last_msg.protocol); entry.channel = static_cast(last_msg.channel); @@ -1248,9 +1523,13 @@ void SdStore::rebuildIndex() std::memcpy(entry.preview, last_msg.text.data(), entry.preview_len); } entries.push_back(entry); - if (!has_durable_unread) + if (!readStateUnreadOnly(conv, &ledger_unread)) { - (void)writeConversationUnread(conversationIdForMessage(last_msg), unread); + (void)writeReadStateUnread(conv, unread); + } + if (!has_header_unread) + { + (void)writeConversationUnread(conv, unread); } } dir.close(); @@ -1880,6 +2159,52 @@ bool SdStore::indexEntryMatchesConversation(const IndexEntry& entry, return entry.peer == conv.peer; } +bool SdStore::readStateEntryHasReticulumIdentity(const ReadStateEntry& entry) +{ + return static_cast(entry.protocol) == MeshProtocol::Reticulum && + (entry.flags & kReadStateHasReticulumIdentityFlag) != 0; +} + +bool SdStore::readStateEntryMatchesConversation(const ReadStateEntry& entry, + const ConversationId& conv) +{ + if (entry.protocol != static_cast(conv.protocol) || + entry.channel != static_cast(conv.channel)) + { + return false; + } + + const bool conv_has_reticulum_key = hasReticulumConversationKey(conv); + const bool entry_has_reticulum_key = readStateEntryHasReticulumIdentity(entry); + if (conv_has_reticulum_key || entry_has_reticulum_key) + { + return conv_has_reticulum_key && entry_has_reticulum_key && + sameReticulumDestination(conv.reticulum_identity, + entry.reticulum_destination_hash); + } + return entry.peer == conv.peer; +} + +SdStore::ReadStateEntry SdStore::readStateEntryFromConversation( + const ConversationId& conv, + uint16_t unread) +{ + ReadStateEntry entry{}; + entry.protocol = static_cast(conv.protocol); + entry.channel = static_cast(conv.channel); + entry.unread = static_cast( + std::min(unread, kUnreadStateCountMask)); + entry.peer = conv.peer; + if (hasReticulumConversationKey(conv)) + { + entry.flags |= kReadStateHasReticulumIdentityFlag; + copyReticulumIdentityToStorage(entry.reticulum_destination_hash, + entry.reticulum_identity_hash, + conv.reticulum_identity); + } + return entry; +} + ConversationId SdStore::conversationFromIndexEntry(const IndexEntry& entry) { ConversationId conv(static_cast(entry.channel), diff --git a/platform/esp/arduino_common/src/notification_runtime.cpp b/platform/esp/arduino_common/src/notification_runtime.cpp new file mode 100644 index 00000000..e4164907 --- /dev/null +++ b/platform/esp/arduino_common/src/notification_runtime.cpp @@ -0,0 +1,58 @@ +/** + * @file notification_runtime.cpp + * @brief Product notification owner for ESP Arduino builds. + */ + +#include "platform/esp/arduino_common/notification_runtime.h" + +#include "platform/ui/settings_store.h" + +#include + +namespace platform::esp::arduino_common::notification +{ +namespace +{ + +constexpr const char* kSettingsNs = "settings"; +constexpr const char* kMessageAlertsKey = "chat_message_alerts"; +constexpr const char* kVibrationEnabledKey = "vibration_enabled"; + +} // namespace + +bool message_alerts_enabled() +{ + return platform::ui::settings_store::get_int(kSettingsNs, + kMessageAlertsKey, + 1) != 0; +} + +bool vibration_enabled() +{ + return platform::ui::settings_store::get_bool(kSettingsNs, + kVibrationEnabledKey, + true); +} + +bool play_alert(BoardBase& board, AlertKind kind) +{ + if (vibration_enabled() && kind != AlertKind::Preview) + { + board.vibrator(); + } + + board.playMessageTone(); + return true; +} + +bool play_alert(app::IAppFacade& app_context, AlertKind kind) +{ + BoardBase* board = app_context.getBoard(); + if (!board) + { + return false; + } + return play_alert(*board, kind); +} + +} // namespace platform::esp::arduino_common::notification diff --git a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp index feb2dbce..0b82880f 100644 --- a/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_device_runtime.cpp @@ -10,6 +10,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "platform/esp/arduino_common/battery_guard.h" +#include "platform/esp/arduino_common/notification_runtime.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" #include "platform/esp/common/build_info.h" #if defined(ARDUINO_T_LORA_PAGER) @@ -175,7 +176,9 @@ void set_message_tone_volume(uint8_t volume_percent) void play_message_tone() { - board.playMessageTone(); + (void)::platform::esp::arduino_common::notification::play_alert( + board, + ::platform::esp::arduino_common::notification::AlertKind::Preview); } bool sd_ready() 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 8795c67c..8793fb69 100644 --- a/platform/linux/common/include/chat/linux_sqlite_chat_store.h +++ b/platform/linux/common/include/chat/linux_sqlite_chat_store.h @@ -27,7 +27,7 @@ class LinuxSqliteChatStore final : public ::chat::IChatStore std::size_t offset, std::size_t limit, std::size_t* total) override; - void setUnread(const ::chat::ConversationId& conv, int unread) override; + bool setUnread(const ::chat::ConversationId& conv, int unread) override; int getUnread(const ::chat::ConversationId& conv) const override; void clearConversation(const ::chat::ConversationId& conv) override; void clearAll() override; 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 1824f47c..48a92406 100644 --- a/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp +++ b/platform/linux/common/src/chat/linux_sqlite_chat_store.cpp @@ -924,14 +924,14 @@ std::vector<::chat::ConversationMeta> LinuxSqliteChatStore::loadConversationPage return list; } -void LinuxSqliteChatStore::setUnread(const ::chat::ConversationId& conv, +bool LinuxSqliteChatStore::setUnread(const ::chat::ConversationId& conv, int unread) { std::lock_guard lock(mutex_); DatabaseHandle handle; if (!handle) { - return; + return false; } sqlite3_stmt* stmt = nullptr; @@ -942,13 +942,15 @@ void LinuxSqliteChatStore::setUnread(const ::chat::ConversationId& conv, "ON CONFLICT(protocol, channel, peer, reticulum_destination_key) " "DO UPDATE SET " "unread=excluded.unread;"; + bool ok = false; if (sqlite3_prepare_v2(handle.db, kSql, -1, &stmt, nullptr) == SQLITE_OK) { - (void)(bindUnreadConversation(stmt, 1, conv) && - sqlite3_bind_int(stmt, 5, std::max(0, unread)) == SQLITE_OK && - sqlite3_step(stmt) == SQLITE_DONE); + ok = bindUnreadConversation(stmt, 1, conv) && + sqlite3_bind_int(stmt, 5, std::max(0, unread)) == SQLITE_OK && + sqlite3_step(stmt) == SQLITE_DONE; } sqlite3_finalize(stmt); + return ok; } int LinuxSqliteChatStore::getUnread( diff --git a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp index dafd5030..dee5ccf2 100644 --- a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp +++ b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp @@ -1251,8 +1251,9 @@ bool UConsoleChatWorkspaceModel::selectConversation( { active_conversation_ = conversation; active_initialized_ = true; - services_.chat().markConversationRead(active_conversation_); - action_status_ = "Conversation selected."; + action_status_ = services_.chat().markConversationRead(active_conversation_) + ? "Conversation selected." + : "Conversation selected. Read state not saved."; return true; } 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 d9ff1776..b0244b84 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 @@ -25,7 +25,7 @@ class InternalFsStore final : public ::chat::IChatStore std::vector<::chat::ConversationMeta> loadConversationPage(size_t offset, size_t limit, size_t* total) override; - void setUnread(const ::chat::ConversationId& conv, int unread) override; + bool setUnread(const ::chat::ConversationId& conv, int unread) override; int getUnread(const ::chat::ConversationId& conv) const override; void clearConversation(const ::chat::ConversationId& conv) override; void clearAll() 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 7a26c991..8fe91233 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 @@ -171,11 +171,12 @@ std::vector<::chat::ConversationMeta> InternalFsStore::loadConversationPage(size list.begin() + static_cast(end)); } -void InternalFsStore::setUnread(const ::chat::ConversationId& conv, int unread) +bool InternalFsStore::setUnread(const ::chat::ConversationId& conv, int unread) { getConversationStorage(conv).unread_count = unread; markDirty(); - maybeSave(); + maybeSave(true); + return !dirty_; } int InternalFsStore::getUnread(const ::chat::ConversationId& conv) const