diff --git a/apps/linux_sim_shell/CMakeLists.txt b/apps/linux_sim_shell/CMakeLists.txt index 45ca09b0..7090d176 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/infra/mesh_protocol_utils.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_read_model.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_event_projector.cpp" @@ -155,6 +156,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/infra/mesh_protocol_utils.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") target_include_directories(trailmate_uconsole_chat_sqlite_store_smoke @@ -174,6 +176,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/infra/mesh_protocol_utils.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") target_include_directories(trailmate_chat_service_smoke @@ -185,6 +188,53 @@ if(BUILD_TESTING) add_test(NAME trailmate_chat_service_smoke COMMAND trailmate_chat_service_smoke) + add_executable(trailmate_chat_delivery_message_projection_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_chat_delivery_message_projection.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_event_projector.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_read_model.cpp") + target_include_directories(trailmate_chat_delivery_message_projection_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_chat_delivery_message_projection_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_chat_delivery_message_projection_smoke + COMMAND trailmate_chat_delivery_message_projection_smoke) + + add_executable(trailmate_chat_delivery_send_result_projection_smoke + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_chat_delivery_send_result_projection.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp") + target_include_directories(trailmate_chat_delivery_send_result_projection_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + target_compile_features(trailmate_chat_delivery_send_result_projection_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_chat_delivery_send_result_projection_smoke + COMMAND trailmate_chat_delivery_send_result_projection_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/infra/mesh_protocol_utils.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/store/ram_store.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_read_model.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_event_projector.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_event_port.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/src/sys/clock.cpp") + target_include_directories(trailmate_chat_delivery_event_projection_adapter_smoke + PRIVATE + "${TRAIL_MATE_REPO_ROOT}/modules/ui_chat_runtime/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include") + target_compile_features(trailmate_chat_delivery_event_projection_adapter_smoke + PRIVATE cxx_std_17) + add_test(NAME trailmate_chat_delivery_event_projection_adapter_smoke + COMMAND trailmate_chat_delivery_event_projection_adapter_smoke) + add_executable(trailmate_node_store_core_smoke "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/tests/test_node_store_core.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/contact_store_core.cpp" @@ -326,6 +376,7 @@ if(BUILD_TESTING) add_executable(trailmate_reticulum_supported_subset_vectors_smoke "${TRAIL_MATE_REPO_ROOT}/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp" + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/reticulum/lxst_call_state_machine.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/reticulum/lxst_telephony_wire.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp") target_include_directories(trailmate_reticulum_supported_subset_vectors_smoke @@ -568,7 +619,8 @@ if(BUILD_TESTING) target_include_directories(trailmate_app_config_defaults_smoke PRIVATE "${TRAIL_MATE_REPO_ROOT}/modules/core_sys/include" - "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include") + "${TRAIL_MATE_REPO_ROOT}/modules/core_chat/include" + "${TRAIL_MATE_REPO_ROOT}/modules/core_gps/include") target_compile_definitions(trailmate_app_config_defaults_smoke PRIVATE TRAIL_MATE_LORA_TX_POWER_MAX_DBM=22) target_compile_features(trailmate_app_config_defaults_smoke @@ -748,6 +800,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/infra/mesh_protocol_utils.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_chat.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_wire.cpp" "${TRAIL_MATE_REPO_ROOT}/modules/core_team/src/protocol/team_waypoint.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 b11c61e1..bd9bb255 100644 --- a/apps/linux_uconsole_gtk/tests/uconsole_chat_dedup_smoke.cpp +++ b/apps/linux_uconsole_gtk/tests/uconsole_chat_dedup_smoke.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,28 @@ class FakeMeshAdapter final : public ::chat::IMeshAdapter incoming_.push_back(incoming); } + void pushIncomingReticulum(::chat::NodeId from, + ::chat::NodeId to, + ::chat::MessageId msg_id, + const std::string& text, + const std::uint8_t lxmf_hash[::chat::kReticulumLxmfHashSize]) + { + ::chat::MeshIncomingText incoming{}; + incoming.channel = ::chat::ChannelId::PRIMARY; + incoming.from = from; + incoming.to = to; + incoming.msg_id = msg_id; + incoming.text = text; + incoming.timestamp = 1; + incoming.hop_limit = 3; + incoming.encrypted = true; + incoming.has_reticulum_lxmf_hash = true; + std::memcpy(incoming.reticulum_lxmf_hash, + lxmf_hash, + sizeof(incoming.reticulum_lxmf_hash)); + incoming_.push_back(incoming); + } + bool sendText(::chat::ChannelId, const std::string&, ::chat::MessageId* out_msg_id, @@ -194,6 +217,28 @@ class FakeChatStore final : public ::chat::IChatStore return false; } + bool hasReticulumLxmfMessageHash(const std::uint8_t* lxmf_hash) const override + { + if (lxmf_hash == nullptr) + { + return false; + } + for (const auto& msg : messages_) + { + if (!::chat::hasReticulumLxmfMessageHash(msg)) + { + continue; + } + if (std::memcmp(msg.reticulum_lxmf_hash, + lxmf_hash, + ::chat::kReticulumLxmfHashSize) == 0) + { + return true; + } + } + return false; + } + private: std::vector<::chat::ChatMessage> messages_{}; int unread_ = 0; @@ -316,5 +361,43 @@ int main() return rc; } + std::uint8_t lxmf_hash[::chat::kReticulumLxmfHashSize] = {}; + for (std::size_t index = 0; index < sizeof(lxmf_hash); ++index) + { + lxmf_hash[index] = static_cast(index + 1U); + } + service.setActiveProtocol(::chat::MeshProtocol::Reticulum); + adapter.pushIncomingReticulum(0x0BADCAFEU, + 0x11121314U, + 0xA0A0U, + "propagated original", + lxmf_hash); + adapter.pushIncomingReticulum(0x0BADCAFEU, + 0x11121314U, + 0xB0B0U, + "propagated duplicate", + lxmf_hash); + service.processIncoming(); + + const ::chat::ConversationId reticulum_conv(::chat::ChannelId::PRIMARY, + 0x0BADCAFEU, + ::chat::MeshProtocol::Reticulum); + messages = store.loadRecent(reticulum_conv, 10); + if (int rc = expect(messages.size() == 1U, + "duplicate Reticulum LXMF hash was stored more than once")) + { + return rc; + } + if (int rc = expect(messages.front().msg_id == 0xA0A0U, + "Reticulum LXMF duplicate replaced the original message")) + { + return rc; + } + if (int rc = expect(messages.front().text == "propagated original", + "Reticulum LXMF duplicate changed stored text")) + { + return rc; + } + return 0; } diff --git a/apps/nrf52_node/src/nrf52_node_app_facade_runtime.cpp b/apps/nrf52_node/src/nrf52_node_app_facade_runtime.cpp index acb64668..e43817e5 100644 --- a/apps/nrf52_node/src/nrf52_node_app_facade_runtime.cpp +++ b/apps/nrf52_node/src/nrf52_node_app_facade_runtime.cpp @@ -880,7 +880,7 @@ void AppFacadeRuntime::dispatchPendingEvents(std::size_t max_events) if (chat_service_ && message) { const bool local_outgoing = message->from == 0; - chat_service_->handleSendResult(result->msg_id, result->success); + chat_service_->handleSendResult(result->msg_id, result->status); if (local_outgoing) { pending_chat_send_result_feedback_ = true; diff --git a/docs/RETICULUM_MODE_USER_GUIDE.md b/docs/RETICULUM_MODE_USER_GUIDE.md index 35b81755..6262c4ad 100644 --- a/docs/RETICULUM_MODE_USER_GUIDE.md +++ b/docs/RETICULUM_MODE_USER_GUIDE.md @@ -70,8 +70,8 @@ unreliable. ## Reticulum Calls On Pager-class hardware with a microphone, speaker, ES8311 audio codec, and -Wi-Fi gateway connectivity, Reticulum mode supports MeshChat-compatible -`call.audio` links. +Wi-Fi gateway connectivity, Reticulum mode supports Sideband-compatible LXST +telephony links on `lxst.telephony`. Calls are Wi-Fi-gateway only. Trail Mate does not place call audio on LoRa, because the LoRa channel is too slow and too precious for realtime voice. The @@ -80,16 +80,16 @@ LXMF/Reticulum identity and the active adapter reports call support. During a call, Trail Mate enters Reticulum call realtime mode: -- direct call audio, LinkIdentify, and hangup packets keep running through the +- LXST call audio, LinkIdentify, signalling, and hangup packets keep running through the Reticulum Wi-Fi gateway - Codec2 and the ES8311 audio path run at the call's realtime cadence -- MQTT, BLE runtime updates, GPS collection, HTTP/downloads, OTA, +- MQTT, GPS collection, HTTP/downloads, OTA, pack/language/image fetches, public discovery, periodic announces, LoRa polling, and non-urgent SD writes are paused or deferred -Incoming calls wake the device into the call overlay even if the screen was -sleeping. The overlay lets you answer, decline, or hang up without entering the -normal app UI first. +Incoming calls wake the device into the Call Page even if the screen was +sleeping. The page lets you answer, decline, adjust speaker volume, or hang up. +The interruption navigator restores the previous page after call cleanup. ## Reticulum Identity @@ -242,7 +242,7 @@ it does not search message body history. In Reticulum Contacts, selecting a peer opens the action menu. `Chat` opens text chat, `Info` shows Reticulum identity/address details, and `Call` starts a -MeshChat-compatible audio call when the peer identity and Wi-Fi gateway path are +Sideband-compatible LXST call when the peer identity and Wi-Fi gateway path are known. If the device needs a path first, it sends a Reticulum path request and shows `Path requested`; retry after the peer's announce/path response arrives. @@ -304,8 +304,9 @@ Each announce line has these fields: destination_hashidentity_hashaspectsourcefirst_seenlast_seenhopspath_responselocaldeliverypropagationdisplay_nameraw_packet_hexapp_data_hex ``` -`aspect` is usually `lxmf.delivery`, `lxmf.propagation`, `call.audio`, or -`unknown`. +`aspect` is usually `lxmf.delivery`, `lxmf.propagation`, `lxst.telephony`, +`nomadnetwork.node`, or `unknown`. Legacy `call.audio` announces may remain in +the diagnostic cache but are not a selectable product call protocol. `source` is usually `runtime_rx` or `path_response`. `raw_packet_hex` stores the verified raw Reticulum announce packet so future tooling can inspect what was actually received. The runtime keeps the file bounded and updates an diff --git a/docs/protocol_runtime_budget_policy.md b/docs/protocol_runtime_budget_policy.md index 80753380..edf5395e 100644 --- a/docs/protocol_runtime_budget_policy.md +++ b/docs/protocol_runtime_budget_policy.md @@ -44,10 +44,9 @@ announces may publish one coalesced contact-store update at a low awake-screen rate so Contacts and Chat can show the sender's display name without waiting for SD or NVS persistence. -## MeshChat-Compatible Discovery Projection +## Reticulum Client Discovery Projection -Trail Mate's Reticulum UI projections follow the same product-facing discovery -split as Reticulum MeshChat: +Trail Mate's Reticulum UI projections follow client-facing object semantics: - Contacts and Chat consume `lxmf.delivery` announces as chat peers. The peer display name is decoded from LXMF announce app data using the upstream shape: @@ -57,10 +56,10 @@ split as Reticulum MeshChat: ignore trailing fields they do not need. - Network consumes `nomadnetwork.node` announces as Nomad nodes. Their display name is decoded from text app data and is separate from LXMF peer naming. -- `lxmf.propagation`, `call.audio`, and unknown announces may be stored for - routing, call, or diagnostic use, but they must not be promoted into Contacts - as ordinary chat peers unless a Trail Mate business flow explicitly needs - them. +- 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 + for routing or diagnostic use, but they are never promoted into Contacts. - Destination and identity hashes remain address/search metadata. They must not be used as the default display name. If a Reticulum peer has no display name, the UI uses `Anonymous Peer`; if a Nomad node has no display name, the UI uses diff --git a/docs/reticulum_client_architecture.md b/docs/reticulum_client_architecture.md new file mode 100644 index 00000000..e736c4ac --- /dev/null +++ b/docs/reticulum_client_architecture.md @@ -0,0 +1,209 @@ +# Reticulum Client Runtime Architecture + +Status: accepted for implementation + +## Product boundary + +Trail Mate is a Reticulum client. It is not a general-purpose Reticulum +transport node, propagation node, or service host. The supported product +capabilities are: + +- LXMF direct delivery and propagation retrieval for person-to-person messages. +- Reticulum path discovery, identity recall, link lifecycle, packet proofs, and + receipts required by those client operations. +- LXST telephony interoperability with Sideband on `lxst.telephony`. +- Nomad/Micron service discovery and browsing in Network. + +MeshChat `call.audio` remains source-level compatibility code for development +and protocol study. It is not registered in the product runtime, is not exposed +in Settings, and is never an automatic fallback for LXST. + +## Semantic objects + +The runtime recognises the following protocol facts. UI pages and persisted +views are projections of these facts; they do not own protocol state. + +| Fact | Owner | Invariant | +| --- | --- | --- | +| Identity | Identity registry | A public identity is accepted only after its hash and signature relationship have been verified. | +| Destination | Destination registry | A destination is keyed by full destination hash and aspect; projected `NodeId` values are never authoritative. | +| Path | Path manager | Freshness, replay rejection, request coalescing, expiry, and selected interface are decided in one place. | +| Link | Link manager | Link establishment, identification, keepalive, resource transfer, and closure have one lifecycle owner. | +| Message | Message ledger | LXMF hash is the durable idempotency key; UI status follows receipt/proof facts, not a successful local send call. | +| Propagation sync | Propagation client | Offers may repeat. A durable seen/ack ledger prevents duplicate user-visible delivery. | +| Call | LXST telephony client | Only one call session may own telephony state and media resources. Unsupported media profiles fail explicitly. | + +## Runtime shape + +The implementation follows ports-and-adapters with state-machine owners and +read projections: + +```text +IMeshAdapter facade + -> Reticulum client coordinator + -> packet router + -> identity/destination registry + -> path manager + -> link manager + -> LXMF delivery client + message ledger + -> propagation client + seen/ack ledger + -> LXST telephony client + -> platform ports + -> Reticulum interfaces + -> peer/directory storage + -> call realtime leases + -> audio backend + -> projections + -> Contacts: people and communicable identities only + -> Network: Nomad/web/service destinations only + -> Chat: conversation/message ledger projection + -> Call Page: current LXST call projection +``` + +`LxmfAdapter` is a compatibility facade and scheduler shell. It may translate +`IMeshAdapter` calls and provide platform ports, but it must not independently +own path, link, message, propagation, call, Contacts, or Network truth. + +## Packet ingress contract + +```text +interface packet + -> parse and packet-hash duplicate gate + -> packet router + -> announce | data | proof | link request | link packet + -> the single owning runtime + -> domain event + -> ledger/projection persistence +``` + +No UI code, notification code, or product setting may parse Reticulum wire +bytes. No packet handler may update a UI projection while bypassing its owning +runtime. + +## Message contract + +- Direct and propagation delivery converge before LXMF envelope validation. +- The full LXMF message hash is the idempotency key across reboot and across + delivery methods. +- A repeated propagation offer may update transport metadata but cannot create + another chat item or unread count. +- `Sent` means queued or transmitted locally. `Delivered` requires a valid + Reticulum/LXMF receipt or proof associated with the same message ledger entry. +- Propagation acknowledgement is emitted only after the delivery has been + durably accepted into the local message ledger. + +## 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. + +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. + +## LXST call state machine + +```text +Idle + outgoing command -> PathResolving -> LinkConnecting + incoming valid LinkRequest -> IncomingIdentifying + +IncomingIdentifying + link active -> send AVAILABLE + valid LinkIdentify -> IncomingRinging + Call Page + ringing lease + invalid/blocked/busy/timeout -> send BUSY or REJECTED -> Closing + +IncomingRinging + accept -> ResourceAcquiring -> send CONNECTING + reject/timeout -> send REJECTED -> Closing + +OutgoingRinging + remote CONNECTING -> MediaPreparing + +ResourceAcquiring / MediaPreparing + exclusive lease + audio open + supported profile -> send/accept ESTABLISHED + any failure -> explicit failure -> Closing + +Active + only matching link audio RX/TX is accepted + hangup/link close/media failure -> Closing + +Closing + keep exclusive lease until LinkClose is sent/observed or timeout completes + -> release media, realtime leases, and Call Page -> Idle +``` + +An incoming telephony LinkRequest may wake the display and navigate to the Call +Page in an identifying state, but it is not shown as an identified caller until +LinkIdentify succeeds. User acceptance cannot report success before the hard +preemption lease and audio session are both ready. + +## Realtime resource contract + +- Incoming identification/ringing owns the Call Page and soft-preempts Wi-Fi. +- On incoming ringing, LoRa and GPS work are paused; BLE has no compiled ESP + product runtime and therefore has no lease to revoke. +- Outgoing call starts at hard preemption because it is an explicit user action. +- Accepted call hard-preempts all non-call Wi-Fi leases. +- A non-preemptible critical operation makes acceptance fail visibly. +- Closing retains call exclusivity until LinkClose completion or bounded timeout. +- UI never revokes hardware or network work directly; it only issues call + commands to the call runtime. + +## Audio contract + +The selected embedded interoperability profile is LXST Bandwidth Low, Codec2 +3200 at 8 kHz mono. Profile selection is explicit: unsupported incoming profile +requests are answered with the supported preference before media starts. + +Capture/encode and receive/decode/playback run on independent clocks. Both are +children of one media session and stop together. Playback has a bounded jitter +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. + +## UI interruption contract + +The call experience is a page, not a modal. A global interruption navigator +temporarily exits and remembers the current app, enters the Call Page as the +active app, blocks ordinary back/menu navigation while a call phase is active, +and restores the prior app or menu when the call returns to Idle. + +The page shows caller identity when known, explicit identifying/connecting/ +active/closing/failure states, answer/reject/hang-up actions, RX/TX health, and +bottom-aligned volume shortcuts. It does not own call or resource state. + +## Migration and deletion rules + +1. Introduce a runtime owner and tests for a fact. +2. Redirect the adapter or UI caller to the owner. +3. Delete the previous state mutation and compatibility branch in the same + stage. +4. Keep `call.audio` code behind an unregistered development adapter; no + product configuration value selects it. +5. A stage is incomplete while two modules can independently transition the + same path, link, message, propagation, or call state. + +## Acceptance matrix + +- Settings contains no call protocol selector and persisted legacy values do + not alter product call routing. +- Product call destination is always `lxst.telephony`. +- A valid incoming LXST link reaches the Call Page and ring path; LinkIdentify + enriches the caller instead of being the first UI trigger. +- Busy or concurrent incoming calls receive a prompt protocol-level failure. +- Accept, reject, remote hangup, local hangup, timeout, media failure, and link + failure all return resources and UI to Idle. +- RX and TX audio use the agreed profile; microphone work cannot stall speaker + cadence. +- 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. +- 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/reticulum_network_config.md b/docs/reticulum_network_config.md new file mode 100644 index 00000000..d4bfa909 --- /dev/null +++ b/docs/reticulum_network_config.md @@ -0,0 +1,113 @@ +# Reticulum Network Configuration + +Trail Mate reads its Reticulum interface and LXMF propagation client setup from: + +```text +/trailmate/reticulum/config.json +``` + +The file is loaded after the SD card becomes available. A valid SD configuration +becomes the active configuration and is cached in NVS as the last-known-good +copy. If the SD card or file is unavailable at the next boot, Trail Mate uses the +cached copy. Legacy Settings values are only used to construct factory defaults +when neither source exists. + +The embedded parser accepts at most 2 KB, five nesting levels, 128 structural +tokens, and 128 bytes per JSON string. Its DOM exists only during boot or an +explicit reload and is released before Reticulum applies the new fixed-size +configuration snapshot. Template and last-known-good serialization reuse the +same fixed 2 KB buffer instead of allocating a second output string. + +Configuration reload is deferred while a Reticulum call owns the realtime +resource lease. The active interfaces are replaced after the call closes. + +## Example + +```json +{ + "schema": "trail-mate.reticulum", + "version": 1, + "interfaces": [ + { + "id": "integrated-lora", + "type": "IntegratedLoRaInterface", + "enabled": true + }, + { + "id": "local-wifi", + "type": "AutoInterface", + "enabled": true, + "group_id": "reticulum", + "discovery_scope": "link", + "discovery_port": 29716, + "data_port": 42671 + }, + { + "id": "primary-tcp", + "type": "TCPClientInterface", + "enabled": true, + "target_host": "vicliu.i234.me", + "target_port": 4242 + }, + { + "id": "backup-tcp", + "type": "TCPClientInterface", + "enabled": false, + "target_host": "backup.example.net", + "target_port": 4242 + } + ], + "lxmf": { + "propagation": { + "enabled": true, + "service_enabled": false, + "delivery_method": "auto", + "propagation_node": "auto", + "sync_on_start": true, + "sync_interval_seconds": 900, + "max_messages_per_sync": 32 + } + } +} +``` + +## Interface Rules + +- `IntegratedLoRaInterface` enables the board's integrated LoRa bearer. Only + one entry is allowed. +- `AutoInterface` implements the official Reticulum IPv6 link-scope discovery + and per-peer UDP interface model. Only one entry is allowed. +- `TCPClientInterface` connects to a Reticulum TCP server or gateway. Up to + three entries can be configured on boards with native Wi-Fi. +- T-Display-P4 uses the C6 companion's single TCP transport and therefore uses + only the first enabled `TCPClientInterface`. It does not expose an IPv6 + AutoInterface through the companion transport. +- Unknown destinations and announces may fan out over ready interfaces. + Learned paths, links, proofs, resources, calls, and Nomad requests remain + bound to the exact ingress interface or learned path interface. + +## LXMF Delivery + +`delivery_method` accepts: + +- `direct`: use opportunistic or direct-link delivery only. +- `propagated`: submit messages to a selected propagation node. +- `auto`: prefer an existing direct link, then opportunistic delivery with a + usable ratchet, then a discovered propagation node, and finally establish a + direct link when no propagation node is available. + +`propagation_node` can be `auto` or a 32-character propagation destination hash. +Automatic selection prefers an active, fresh node with the lowest known hop +count. Trail Mate generates the official LXMF propagation stamp incrementally, +uploads the encrypted message over an identified propagation link, and marks +the message sent to the propagation node only after the link packet or resource +proof is validated. This state does not claim final recipient delivery. + +`service_enabled` is intentionally `false` by default. Enabling it makes this +battery device announce and accept traffic as an LXMF propagation service; +normal message-for-propagation client support does not require it. + +During synchronization Trail Mate requests the remote transient-ID list, +downloads only missing messages addressed to its local LXMF delivery +destination, and acknowledges handled IDs. Seen transient IDs are retained in +the bounded propagation runtime to suppress duplicate delivery. diff --git a/docs/wifi_access_resource_policy.md b/docs/wifi_access_resource_policy.md index 9b9adf16..8c266cb9 100644 --- a/docs/wifi_access_resource_policy.md +++ b/docs/wifi_access_resource_policy.md @@ -105,7 +105,7 @@ Trail Mate is a low-frequency terminal, not a desktop router node. | Foreground HTTP pending | Suspended. No connect, read, write, publish, or downlink bridge budget. | Suspended. Existing gateway socket may be closed by its budget handler. | The pending foreground download owns the next HTTP opportunity while it waits for memory/SD/UI resources. | | HTTP active | MQTT and Reticulum are reduced to minimal messaging budget. | Reduced to minimal gateway budget. | Exactly one HTTP transaction owns the HTTP resource. | | OTA active | Protocol pump budget is suspended. | Protocol pump budget is suspended. | OTA is exclusive. | -| Reticulum call active | Suspended. MQTT sockets must not connect, pump, publish, or consume RX budget. | Exclusive `call.audio` link traffic only. Discovery, public announce ingest, route/path background noise, and non-call link work are deferred. | Denied. No HTTP, OTA, catalog, pack, language, image, or route download may start. | +| Reticulum call active | Suspended. MQTT sockets must not connect, pump, publish, or consume RX budget. | Exclusive traffic for the current LXST telephony Link only. Discovery, public announce ingest, route/path background noise, and non-call link work are deferred. | Denied. No HTTP, OTA, catalog, pack, language, image, or route download may start. | The wake-protected phase shields the path from screen sleep to screen wake to entering the UI. It is intentionally short and prevents reconnect storms, TLS @@ -115,7 +115,7 @@ visible UI stalls. ## Reticulum Call Realtime Mode Reticulum calls are a realtime resource mode, not a Contacts-page feature. The -incoming-call panel is the visible entry point for this mode, but it is not the +Call Page is the visible entry point for this mode, but it is not the owner of Wi-Fi, LoRa, GPS, BLE, or audio resources. Resource ownership is held by runtime leases underneath the call runtime. @@ -123,34 +123,34 @@ The runtime state is: | Call realtime phase | UI ownership | Resource ownership | | --- | --- | --- | -| `IncomingRinging` | Global modal owns focus. The background remains visible through a scrim and is inert. | Soft preempt. New non-call Wi-Fi work is denied. Existing non-call Wi-Fi work reaches its normal poll/revoke point. LoRa, GPS, and BLE runtime work are paused. | -| `AcceptedStarting` | The modal remains focused and shows the call is connecting. | Hard preempt. The current `call.audio` link starts the Reticulum call Wi-Fi exclusive lease. Non-call Wi-Fi leases are revoked. | -| `ActiveCall` | Active call modal owns focus. | Exclusive call lease. Only Reticulum gateway traffic serving the current `call.audio` link may read or write. | -| `ClosingCall` | The modal may show closing or hanging up. | The previous ownership remains in force until `LinkClose` is sent or cleanup finishes. Rejecting an incoming call that never entered hard preempt keeps only the soft preempt while close signalling is attempted. | -| `Idle` | No call modal. | Normal Wi-Fi scheduling resumes and deferred work may be queued again. | +| `IncomingRinging` | The Call Page owns navigation and focus. | Soft preempt. New non-call Wi-Fi work is denied. Existing non-call Wi-Fi work reaches its normal poll/revoke point. LoRa and GPS runtime work are paused. BLE is not compiled into the ESP product runtime. | +| `AcceptedStarting` | The Call Page shows the call is connecting. | Hard preempt. The current LXST telephony Link starts the Reticulum call Wi-Fi exclusive lease. Non-call Wi-Fi leases are revoked. | +| `ActiveCall` | The active Call Page owns navigation and focus. | Exclusive call lease. Only Reticulum gateway traffic serving the current LXST telephony Link may read or write. | +| `ClosingCall` | The Call Page may show closing or hanging up. | The previous ownership remains in force until `LinkClose` is sent or cleanup finishes. Rejecting an incoming call that never entered hard preempt keeps only the soft preempt while close signalling is attempted. | +| `Idle` | The interruption navigator restores the previous page. | Normal Wi-Fi scheduling resumes and deferred work may be queued again. | Outgoing calls enter `AcceptedStarting` immediately. The user explicitly asked to place the call, so the call runtime must acquire the hard Wi-Fi preempt lease before dialing proceeds. -Incoming calls enter `IncomingRinging` first. This phase still pauses LoRa, GPS, -and BLE runtime work so the device is quiet for the user-visible interruption, +Incoming calls enter `IncomingRinging` first. This phase still pauses LoRa and +GPS runtime work so the device is quiet for the user-visible interruption, but it does not revoke already-running Wi-Fi work unless that work reaches a normal lease or budget poll point. If the user declines, the runtime must not escalate to hard Wi-Fi preempt. -When a MeshChat-compatible `call.audio` link is accepted, active, or closing +When a Sideband-compatible `lxst.telephony` Link is accepted, active, or closing after acceptance, the device must protect the audio path first: - Wi-Fi access grants budget only to the Reticulum gateway client, and only for the call link's Reticulum packets. -- LoRa polling, GPS collection, BLE runtime updates, public Reticulum discovery, +- LoRa polling, GPS collection, public Reticulum discovery, periodic announces, MQTT, HTTP, OTA, route/image downloads, language-pack downloads, catalog refreshes, and SD-backed discovery persistence are paused, denied, or coalesced. - Codec2/ES8311 media owns the audio hardware for the call. Other background - hardware users must not start while the call overlay is active. -- Incoming calls may wake the screen and show the call overlay, but they must + hardware users must not start while the Call Page is active. +- Incoming calls may wake the screen and open the Call Page, but they must not trigger unrelated Wi-Fi reconnect storms, SD scans, or UI-heavy refreshes. Wi-Fi users are grouped by preemption safety: diff --git a/modules/chat_presentation_adapters/src/chat_message_mapper.cpp b/modules/chat_presentation_adapters/src/chat_message_mapper.cpp index 4d5e9cd2..7cece556 100644 --- a/modules/chat_presentation_adapters/src/chat_message_mapper.cpp +++ b/modules/chat_presentation_adapters/src/chat_message_mapper.cpp @@ -15,6 +15,8 @@ ui::chat::MessageDeliveryState mapMessageStatus(chat::MessageStatus status) return ui::chat::MessageDeliveryState::Sent; case chat::MessageStatus::Failed: return ui::chat::MessageDeliveryState::Failed; + case chat::MessageStatus::Delivered: + return ui::chat::MessageDeliveryState::Delivered; } return ui::chat::MessageDeliveryState::Unknown; } diff --git a/modules/core_chat/include/chat/delivery/chat_delivery_send_result_projection.h b/modules/core_chat/include/chat/delivery/chat_delivery_send_result_projection.h index 979d4e2b..d057b319 100644 --- a/modules/core_chat/include/chat/delivery/chat_delivery_send_result_projection.h +++ b/modules/core_chat/include/chat/delivery/chat_delivery_send_result_projection.h @@ -11,6 +11,10 @@ ChatDeliveryEvent makeChatSendResultDeliveryEvent(ChatDeliveryRef ref, bool success, SendFailureKind failure, uint32_t timestamp_ms = 0); +ChatDeliveryEvent makeChatSendResultDeliveryEvent(ChatDeliveryRef ref, + DeliveryState state, + SendFailureKind failure, + uint32_t timestamp_ms = 0); ChatDeliveryEvent makeAckTimeoutDeliveryEvent(ChatDeliveryRef ref, uint32_t timestamp_ms = 0); diff --git a/modules/core_chat/include/chat/domain/chat_types.h b/modules/core_chat/include/chat/domain/chat_types.h index 62ebe597..f51b0f2c 100644 --- a/modules/core_chat/include/chat/domain/chat_types.h +++ b/modules/core_chat/include/chat/domain/chat_types.h @@ -367,7 +367,8 @@ enum class MessageStatus Incoming, // Received message Queued, // Queued for sending Sent, // Successfully sent - Failed // Failed to send + Failed, // Failed to send + Delivered // Confirmed by a remote receipt or proof }; /** @@ -387,6 +388,8 @@ struct ChatMessage int32_t geo_lat_e7; int32_t geo_lon_e7; ReticulumPeerIdentity reticulum_identity{}; + bool has_reticulum_lxmf_hash; + uint8_t reticulum_lxmf_hash[kReticulumLxmfHashSize] = {}; bool source_unverified; RxOrigin rx_origin; MessageStatus status; @@ -398,11 +401,20 @@ struct ChatMessage has_geo(false), geo_lat_e7(0), geo_lon_e7(0), + has_reticulum_lxmf_hash(false), source_unverified(false), rx_origin(RxOrigin::Unknown), status(MessageStatus::Incoming) {} }; +inline bool hasReticulumLxmfMessageHash(const ChatMessage& msg) +{ + return msg.protocol == MeshProtocol::Reticulum && + msg.has_reticulum_lxmf_hash && + !isAllZeroKeyBytes(msg.reticulum_lxmf_hash, + kReticulumLxmfHashSize); +} + inline ConversationId conversationIdForMessage(const ChatMessage& msg) { ConversationId id(msg.channel, msg.peer, msg.protocol); @@ -443,10 +455,19 @@ struct MeshIncomingText uint8_t hop_limit; // Remaining hops bool encrypted; // Whether message was encrypted ReticulumPeerIdentity reticulum_identity{}; + bool has_reticulum_lxmf_hash = false; + uint8_t reticulum_lxmf_hash[kReticulumLxmfHashSize] = {}; bool source_unverified = false; RxMeta rx_meta; }; +inline bool hasReticulumLxmfMessageHash(const MeshIncomingText& msg) +{ + return msg.has_reticulum_lxmf_hash && + !isAllZeroKeyBytes(msg.reticulum_lxmf_hash, + kReticulumLxmfHashSize); +} + /** * @brief Incoming non-text mesh payload */ @@ -576,7 +597,6 @@ struct MeshConfig char reticulum_wifi_gateway_host[kReticulumGatewayHostMaxLen + 1]; uint16_t reticulum_wifi_gateway_port; ReticulumInterfacePolicy reticulum_interface_policy; - ReticulumCallWireProfile reticulum_call_wire_profile; bool reticulum_allow_location_requests; ReticulumGroupDestinationConfig reticulum_groups[kReticulumGroupDestinationMaxCount]; @@ -624,7 +644,6 @@ struct MeshConfig reticulum_anonymous_peer(false), reticulum_wifi_gateway_port(4242), reticulum_interface_policy(ReticulumInterfacePolicy::All), - reticulum_call_wire_profile(ReticulumCallWireProfile::SidebandLxst), reticulum_allow_location_requests(false) { strncpy(primary_channel_name, "LongFast", sizeof(primary_channel_name) - 1); @@ -666,7 +685,7 @@ struct MeshConfig { meshcore_channel_slot = normalizeMeshCoreChannelSlot(meshcore_channel_slot); const MeshCoreChannelConfig& active = activeMeshCoreChannel(); - std::strncpy(meshcore_channel_name, active.name, sizeof(meshcore_channel_name) - 1); + std::memcpy(meshcore_channel_name, active.name, sizeof(meshcore_channel_name)); meshcore_channel_name[sizeof(meshcore_channel_name) - 1] = '\0'; std::memset(secondary_key, 0, sizeof(secondary_key)); std::memcpy(secondary_key, active.key, kMeshCoreChannelKeyLen); @@ -678,7 +697,7 @@ struct MeshConfig MeshCoreChannelConfig& active = activeMeshCoreChannel(); if (meshcore_channel_name[0] != '\0') { - std::strncpy(active.name, meshcore_channel_name, sizeof(active.name) - 1); + std::memcpy(active.name, meshcore_channel_name, sizeof(active.name)); active.name[sizeof(active.name) - 1] = '\0'; } if (!isAllZeroKeyBytes(secondary_key, kMeshCoreChannelKeyLen)) diff --git a/modules/core_chat/include/chat/domain/reticulum_identity.h b/modules/core_chat/include/chat/domain/reticulum_identity.h index 51ef7204..e1c9a7ba 100644 --- a/modules/core_chat/include/chat/domain/reticulum_identity.h +++ b/modules/core_chat/include/chat/domain/reticulum_identity.h @@ -13,6 +13,7 @@ namespace chat { constexpr std::size_t kReticulumPeerHashSize = 16; +constexpr std::size_t kReticulumLxmfHashSize = 32; struct ReticulumPeerIdentity { diff --git a/modules/core_chat/include/chat/domain/reticulum_network_config.h b/modules/core_chat/include/chat/domain/reticulum_network_config.h new file mode 100644 index 00000000..8cf98cc1 --- /dev/null +++ b/modules/core_chat/include/chat/domain/reticulum_network_config.h @@ -0,0 +1,70 @@ +/** + * @file reticulum_network_config.h + * @brief Bounded Reticulum interface and LXMF client configuration + */ + +#pragma once + +#include "chat/infra/reticulum/reticulum_wire.h" + +#include +#include + +namespace chat::reticulum +{ + +constexpr std::size_t kMaxNetworkInterfaces = 6; +constexpr std::size_t kMaxTcpClientInterfaces = 3; +constexpr std::size_t kInterfaceIdMaxLen = 23; +constexpr std::size_t kInterfaceHostMaxLen = 63; +constexpr std::size_t kAutoInterfaceGroupMaxLen = 31; + +enum class NetworkInterfaceType : uint8_t +{ + IntegratedLoRa = 0, + Auto = 1, + TcpClient = 2, +}; + +enum class LxmfDeliveryPreference : uint8_t +{ + Direct = 0, + Propagated = 1, + Automatic = 2, +}; + +struct NetworkInterfaceConfig +{ + char id[kInterfaceIdMaxLen + 1] = {}; + NetworkInterfaceType type = NetworkInterfaceType::IntegratedLoRa; + bool enabled = false; + char target_host[kInterfaceHostMaxLen + 1] = {}; + uint16_t target_port = 4242; + char group_id[kAutoInterfaceGroupMaxLen + 1] = "reticulum"; + uint16_t discovery_port = 29716; + uint16_t data_port = 42671; +}; + +struct LxmfPropagationClientConfig +{ + bool enabled = true; + bool service_enabled = false; + LxmfDeliveryPreference delivery = LxmfDeliveryPreference::Automatic; + bool automatic_node = true; + uint8_t node_hash[kTruncatedHashSize] = {}; + bool sync_on_start = true; + uint32_t sync_interval_s = 15U * 60U; + uint8_t max_messages_per_sync = 32; +}; + +struct ReticulumNetworkConfig +{ + static constexpr uint16_t kSchemaVersion = 1; + + uint16_t version = kSchemaVersion; + NetworkInterfaceConfig interfaces[kMaxNetworkInterfaces] = {}; + uint8_t interface_count = 0; + LxmfPropagationClientConfig propagation{}; +}; + +} // namespace chat::reticulum diff --git a/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h b/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h index bf4a6e67..3df77146 100644 --- a/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h +++ b/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h @@ -141,6 +141,20 @@ struct DecodedPropagationGetRequest uint32_t transfer_limit_kb = 0; }; +struct DecodedPropagationAnnounce +{ + bool valid = false; + bool legacy_support = false; + uint32_t timebase_s = 0; + bool node_active = false; + uint32_t transfer_limit_kb = 0; + uint32_t sync_limit_kb = 0; + uint8_t stamp_cost = 0; + uint8_t stamp_cost_flexibility = 0; + uint8_t peering_cost = 0; + std::string display_name; +}; + bool packPeerAnnounceAppData(const char* display_name, bool has_stamp_cost, uint8_t stamp_cost, @@ -237,6 +251,28 @@ bool decodePropagationOfferPayload(const uint8_t* data, size_t len, bool decodePropagationGetRequestPayload(const uint8_t* data, size_t len, DecodedPropagationGetRequest* out_request); +bool encodePropagationGetRequestPayload( + const std::vector>* wants, + const std::vector>* haves, + bool include_transfer_limit, + uint32_t transfer_limit_kb, + uint8_t* out_payload, + size_t* inout_len); + +bool decodePropagationIdListPayload(const uint8_t* data, + size_t len, + std::vector>* out_ids); + +bool decodePropagationMessageListPayload( + const uint8_t* data, + size_t len, + std::vector>* out_messages); + +bool decodePropagationAnnounceAppData( + const uint8_t* data, + size_t len, + DecodedPropagationAnnounce* out_announce); + bool encodePropagationIdListPayload(const std::vector>& ids, uint8_t* out_payload, size_t* inout_len); diff --git a/modules/core_chat/include/chat/infra/mesh_incoming_queue.h b/modules/core_chat/include/chat/infra/mesh_incoming_queue.h index aea9783f..257b3dca 100644 --- a/modules/core_chat/include/chat/infra/mesh_incoming_queue.h +++ b/modules/core_chat/include/chat/infra/mesh_incoming_queue.h @@ -113,6 +113,10 @@ class IncomingTextQueue slot.hop_limit = metadata.hop_limit; slot.encrypted = metadata.encrypted; slot.reticulum_identity = metadata.reticulum_identity; + slot.has_reticulum_lxmf_hash = metadata.has_reticulum_lxmf_hash; + std::memcpy(slot.reticulum_lxmf_hash, + metadata.reticulum_lxmf_hash, + sizeof(slot.reticulum_lxmf_hash)); slot.source_unverified = metadata.source_unverified; slot.rx_meta = metadata.rx_meta; slot.text_len = text_len; @@ -141,6 +145,10 @@ class IncomingTextQueue out->hop_limit = slot.hop_limit; out->encrypted = slot.encrypted; out->reticulum_identity = slot.reticulum_identity; + out->has_reticulum_lxmf_hash = slot.has_reticulum_lxmf_hash; + std::memcpy(out->reticulum_lxmf_hash, + slot.reticulum_lxmf_hash, + sizeof(out->reticulum_lxmf_hash)); out->source_unverified = slot.source_unverified; out->rx_meta = slot.rx_meta; out->text.assign(slot.text.data(), slot.text_len); @@ -165,6 +173,8 @@ class IncomingTextQueue uint8_t hop_limit = 0xFF; bool encrypted = false; ReticulumPeerIdentity reticulum_identity{}; + bool has_reticulum_lxmf_hash = false; + uint8_t reticulum_lxmf_hash[kReticulumLxmfHashSize] = {}; bool source_unverified = false; RxMeta rx_meta{}; std::array text{}; diff --git a/modules/core_chat/include/chat/infra/reticulum/lxst_call_state_machine.h b/modules/core_chat/include/chat/infra/reticulum/lxst_call_state_machine.h new file mode 100644 index 00000000..1fae3d19 --- /dev/null +++ b/modules/core_chat/include/chat/infra/reticulum/lxst_call_state_machine.h @@ -0,0 +1,107 @@ +/** + * @file lxst_call_state_machine.h + * @brief Pure Sideband/LXST telephony session state and transition policy. + */ + +#pragma once + +#include +#include + +namespace chat::reticulum::lxst::call +{ + +enum class Role : uint8_t +{ + Caller = 0, + Callee = 1, +}; + +enum class Phase : uint8_t +{ + Idle = 0, + CallerAwaitingLink, + CallerAwaitingAvailability, + CallerAwaitingRinging, + CallerNegotiating, + CallerConnecting, + CalleeAwaitingLink, + CalleeAwaitingIdentity, + CalleeAwaitingAdmission, + CalleeRinging, + CalleeConnecting, + Active, + Closing, + Closed, +}; + +enum class EventType : uint8_t +{ + LinkActive = 0, + RemoteIdentified, + AdmissionGranted, + AdmissionDenied, + RemoteSignal, + LocalAccepted, + MediaReady, + LocalHangup, + OperationFailed, + Timeout, + LinkClosed, +}; + +struct Event +{ + EventType type = EventType::OperationFailed; + uint16_t signal = 0; + + static Event remoteSignal(uint16_t value) + { + return Event{EventType::RemoteSignal, value}; + } +}; + +enum class Action : uint8_t +{ + SendAvailable = 0, + SendIdentify, + BeginRinging, + SendRinging, + SendPreferredProfile, + SendConnecting, + PrepareMedia, + SendEstablished, + ActivateMedia, + SendBusy, + SendRejected, + CloseLink, +}; + +struct Transition +{ + static constexpr std::size_t kMaxActions = 4; + + bool accepted = false; + Action actions[kMaxActions] = {}; + std::size_t action_count = 0; +}; + +struct State +{ + Role role = Role::Caller; + Phase phase = Phase::Idle; + uint16_t profile = 0; + uint16_t local_status = 0; + uint16_t remote_status = 0; + uint32_t phase_started_ms = 0; + bool media_prepared = false; +}; + +State makeCaller(uint16_t profile, uint32_t now_ms); +State makeCallee(uint16_t profile, uint32_t now_ms); +Transition dispatch(State* state, const Event& event, uint32_t now_ms); +uint32_t phaseTimeoutMs(const State& state); +bool phaseTimedOut(const State& state, uint32_t now_ms); +const char* phaseName(Phase phase); + +} // namespace chat::reticulum::lxst::call 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 bdd413cc..2f410f51 100644 --- a/modules/core_chat/include/chat/infra/store/ram_store.h +++ b/modules/core_chat/include/chat/infra/store/ram_store.h @@ -40,6 +40,7 @@ class RamStore : public IChatStore void clearAll() override; bool updateMessageStatus(MessageId msg_id, MessageStatus status) override; bool getMessage(MessageId msg_id, ChatMessage* out) const override; + bool hasReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const override; private: struct StoredMessageEntry 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 64f5e288..cc62ec19 100644 --- a/modules/core_chat/include/chat/ports/i_chat_store.h +++ b/modules/core_chat/include/chat/ports/i_chat_store.h @@ -26,6 +26,19 @@ class IChatStore */ virtual void append(const ChatMessage& msg) = 0; + /** + * Persist an incoming message and any durable delivery identity it owns. + * + * Stores that cannot fail independently may inherit this implementation. + * Persistent stores should override it and return false unless both the + * message and its deduplication identity are durable. + */ + virtual bool appendIncomingDurably(const ChatMessage& msg) + { + append(msg); + return true; + } + /** * @brief Load recent messages for a conversation * @param conv Conversation ID @@ -125,6 +138,18 @@ class IChatStore */ virtual bool getMessage(MessageId msg_id, ChatMessage* out) const = 0; + /** + * @brief Check whether an LXMF message hash has already been stored. + * + * The default implementation returns false for stores that do not maintain + * Reticulum/LXMF durable identity state. + */ + virtual bool hasReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const + { + (void)lxmf_hash; + return false; + } + /** * @brief Flush pending buffered writes to persistent storage * diff --git a/modules/core_chat/include/chat/ports/i_incoming_delivery_commit_port.h b/modules/core_chat/include/chat/ports/i_incoming_delivery_commit_port.h new file mode 100644 index 00000000..383dda51 --- /dev/null +++ b/modules/core_chat/include/chat/ports/i_incoming_delivery_commit_port.h @@ -0,0 +1,29 @@ +/** + * @file i_incoming_delivery_commit_port.h + * @brief Optional two-phase commit port for durable incoming deliveries. + */ + +#pragma once + +#include "../domain/chat_types.h" + +namespace chat +{ + +class IIncomingDeliveryCommitPort +{ + public: + virtual ~IIncomingDeliveryCommitPort() = default; + + /** + * Complete a previously polled incoming text delivery. + * + * Adapters that need application-level acceptance, such as an LXMF + * propagation client, use this callback to defer remote acknowledgement + * until the message and its durable identity have been committed. + */ + virtual void commitIncomingText(const MeshIncomingText& message, + bool durably_accepted) = 0; +}; + +} // namespace chat diff --git a/modules/core_chat/include/chat/ports/i_mesh_adapter.h b/modules/core_chat/include/chat/ports/i_mesh_adapter.h index 9e2b195a..ae0991c2 100644 --- a/modules/core_chat/include/chat/ports/i_mesh_adapter.h +++ b/modules/core_chat/include/chat/ports/i_mesh_adapter.h @@ -10,6 +10,8 @@ namespace chat { +class IIncomingDeliveryCommitPort; + namespace meshcore { class IMeshCoreBleBackend; @@ -139,6 +141,14 @@ class IMeshAdapter */ virtual bool pollIncomingText(MeshIncomingText* out) = 0; + /** + * Expose the optional two-phase incoming-delivery capability. + */ + virtual IIncomingDeliveryCommitPort* incomingDeliveryCommitPort() + { + return nullptr; + } + /** * @brief Send app payload data (non-text) * @param channel Channel ID @@ -269,7 +279,7 @@ class IMeshAdapter } /** - * @brief Start a MeshChat-compatible Reticulum call.audio Link call. + * @brief Start a Sideband-compatible LXST telephony Link call. */ virtual MeshActionResult startReticulumAudioCall( const ReticulumPeerIdentity& destination) @@ -279,7 +289,7 @@ class IMeshAdapter } /** - * @brief Send a MeshChat-compatible Reticulum Ping Destination probe. + * @brief Send a Reticulum Ping Destination proof probe. * * This is a protocol action: it sends an empty encrypted packet to an * LXMF delivery destination so the remote can answer with a Reticulum diff --git a/modules/core_chat/include/chat/usecase/chat_service.h b/modules/core_chat/include/chat/usecase/chat_service.h index d2b2a0fe..e6869e6f 100644 --- a/modules/core_chat/include/chat/usecase/chat_service.h +++ b/modules/core_chat/include/chat/usecase/chat_service.h @@ -159,6 +159,14 @@ class ChatService */ void handleSendResult(MessageId msg_id, bool ok); + /** + * @brief Apply an exact outbound delivery state. + * + * Only Sent, Delivered, and Failed are accepted. Delivered is terminal; + * an earlier failure may still be superseded by a later valid proof. + */ + void handleSendResult(MessageId msg_id, MessageStatus status); + /** * @brief Get message by ID (for UI send status) */ @@ -192,12 +200,25 @@ class ChatService MessageId msg_id = 0; bool has_reticulum_destination = false; uint8_t reticulum_destination_hash[kReticulumPeerHashSize] = {}; + bool has_reticulum_lxmf_hash = false; + uint8_t reticulum_lxmf_hash[kReticulumLxmfHashSize] = {}; bool operator==(const IncomingIdentity& other) const { if (protocol != other.protocol || - channel != other.channel || - msg_id != other.msg_id) + channel != other.channel) + { + return false; + } + if (protocol == MeshProtocol::Reticulum && + has_reticulum_lxmf_hash && + other.has_reticulum_lxmf_hash) + { + return std::memcmp(reticulum_lxmf_hash, + other.reticulum_lxmf_hash, + kReticulumLxmfHashSize) == 0; + } + if (msg_id != other.msg_id) { return false; } diff --git a/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp b/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp index 9a342645..d4a420f1 100644 --- a/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp +++ b/modules/core_chat/src/delivery/chat_delivery_message_projection.cpp @@ -17,6 +17,8 @@ DeliveryState mapStatus(chat::MessageStatus status) return DeliveryState::Sent; case chat::MessageStatus::Failed: return DeliveryState::Failed; + case chat::MessageStatus::Delivered: + return DeliveryState::Delivered; } return DeliveryState::Unknown; } diff --git a/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp b/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp index 2af0cf30..32828121 100644 --- a/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp +++ b/modules/core_chat/src/delivery/chat_delivery_send_result_projection.cpp @@ -7,18 +7,31 @@ ChatDeliveryEvent makeChatSendResultDeliveryEvent(ChatDeliveryRef ref, bool success, SendFailureKind failure, uint32_t timestamp_ms) +{ + return makeChatSendResultDeliveryEvent( + ref, + success ? DeliveryState::Sent : DeliveryState::Failed, + failure, + timestamp_ms); +} + +ChatDeliveryEvent makeChatSendResultDeliveryEvent(ChatDeliveryRef ref, + DeliveryState state, + SendFailureKind failure, + uint32_t timestamp_ms) { ChatDeliveryEvent event{}; event.ref = ref; event.timestamp_ms = timestamp_ms; - if (success) + if (state == DeliveryState::Sent || state == DeliveryState::Delivered) { - event.state = DeliveryState::Sent; + event.state = state; event.failure = SendFailureKind::None; return event; } - event.state = DeliveryState::Failed; + event.state = state == DeliveryState::Failed ? DeliveryState::Failed + : DeliveryState::Unknown; event.failure = failure == SendFailureKind::None ? SendFailureKind::Unknown : failure; return event; diff --git a/modules/core_chat/src/infra/contact_store_core.cpp b/modules/core_chat/src/infra/contact_store_core.cpp index 9cecfd16..48b79978 100644 --- a/modules/core_chat/src/infra/contact_store_core.cpp +++ b/modules/core_chat/src/infra/contact_store_core.cpp @@ -63,15 +63,15 @@ bool ContactStoreCore::setNickname(uint32_t node_id, const char* nickname) return true; } char previous[sizeof(entry.nickname)] = {}; - strncpy(previous, entry.nickname, sizeof(previous) - 1); - strncpy(entry.nickname, nickname, sizeof(entry.nickname) - 1); - entry.nickname[sizeof(entry.nickname) - 1] = '\0'; + memcpy(previous, entry.nickname, sizeof(previous)); + previous[sizeof(previous) - 1] = '\0'; + const std::size_t nickname_len = strlen(nickname); + memcpy(entry.nickname, nickname, nickname_len + 1U); if (saveEntries()) { return true; } - strncpy(entry.nickname, previous, sizeof(entry.nickname) - 1); - entry.nickname[sizeof(entry.nickname) - 1] = '\0'; + memcpy(entry.nickname, previous, sizeof(entry.nickname)); return false; } } diff --git a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp index 0c30a668..79ddb867 100644 --- a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp +++ b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp @@ -18,6 +18,7 @@ constexpr uint8_t kAppPayloadFlagWantResponse = 0x01; constexpr size_t kAppPayloadHeaderLen = 18; constexpr uint32_t kSidebandSensorLocation = 0x02; constexpr uint32_t kSidebandCommandTelemetryRequest = 0x01; +constexpr size_t kMaxPropagationWireItems = 64; struct Cursor { @@ -316,6 +317,31 @@ bool readFloat64(Cursor& cursor, double* out_value) return true; } +bool readNonNegativeNumber(Cursor& cursor, uint32_t* out_value) +{ + if (!out_value) + { + return false; + } + + uint8_t tag = 0; + if (!peekByte(cursor, &tag)) + { + return false; + } + if (tag == 0xCB) + { + double value = 0.0; + if (!readFloat64(cursor, &value) || value < 0.0 || value > 4294967295.0) + { + return false; + } + *out_value = static_cast(value); + return true; + } + return readUint(cursor, out_value); +} + bool readNil(Cursor& cursor) { uint8_t tag = 0; @@ -523,6 +549,10 @@ bool appendArrayOfBins(const std::vector>& items, size_t out_len, size_t& used) { + if (items.size() > kMaxPropagationWireItems) + { + return false; + } if (!appendArrayHeader(static_cast(items.size()), out, out_len, used)) { return false; @@ -551,6 +581,11 @@ bool readArrayOfBins(Cursor& cursor, std::vector>* out_item { return false; } + if (count > kMaxPropagationWireItems || + count > cursor.len - cursor.pos) + { + return false; + } std::vector> items; items.reserve(count); @@ -1451,7 +1486,8 @@ bool decodePropagationBatch(const uint8_t* data, size_t len, DecodedPropagationBatch decoded{}; if (!readFloat64(cursor, &decoded.remote_timebase) || - !readArrayOfBins(cursor, &decoded.messages)) + !readArrayOfBins(cursor, &decoded.messages) || + cursor.pos != cursor.len) { return false; } @@ -1505,6 +1541,17 @@ bool decodePropagationOfferPayload(const uint8_t* data, size_t len, { return false; } + for (size_t index = 2; index < count; ++index) + { + if (!skipObject(cursor)) + { + return false; + } + } + if (cursor.pos != cursor.len) + { + return false; + } *out_offer = std::move(decoded); return true; @@ -1581,11 +1628,190 @@ bool decodePropagationGetRequestPayload(const uint8_t* data, size_t len, decoded.has_transfer_limit = true; decoded.transfer_limit_kb = limit_kb; } + for (size_t index = 3; index < count; ++index) + { + if (!skipObject(cursor)) + { + return false; + } + } + if (cursor.pos != cursor.len) + { + return false; + } *out_request = std::move(decoded); return true; } +bool encodePropagationGetRequestPayload( + const std::vector>* wants, + const std::vector>* haves, + bool include_transfer_limit, + uint32_t transfer_limit_kb, + uint8_t* out_payload, + size_t* inout_len) +{ + if (!out_payload || !inout_len) + { + return false; + } + + size_t used = 0; + if (!appendArrayHeader(include_transfer_limit ? 3 : 2, + out_payload, + *inout_len, + used)) + { + return false; + } + if (wants) + { + if (!appendArrayOfBins(*wants, out_payload, *inout_len, used)) + { + return false; + } + } + else if (!appendNil(out_payload, *inout_len, used)) + { + return false; + } + if (haves) + { + if (!appendArrayOfBins(*haves, out_payload, *inout_len, used)) + { + return false; + } + } + else if (!appendNil(out_payload, *inout_len, used)) + { + return false; + } + if (include_transfer_limit && + !appendUint(transfer_limit_kb, out_payload, *inout_len, used)) + { + return false; + } + + *inout_len = used; + return true; +} + +bool decodePropagationIdListPayload( + const uint8_t* data, + size_t len, + std::vector>* out_ids) +{ + if (!data || len == 0 || !out_ids) + { + return false; + } + Cursor cursor{data, len, 0}; + std::vector> ids; + if (!readArrayOfBins(cursor, &ids) || cursor.pos != cursor.len) + { + return false; + } + *out_ids = std::move(ids); + return true; +} + +bool decodePropagationMessageListPayload( + const uint8_t* data, + size_t len, + std::vector>* out_messages) +{ + return decodePropagationIdListPayload(data, len, out_messages); +} + +bool decodePropagationAnnounceAppData( + const uint8_t* data, + size_t len, + DecodedPropagationAnnounce* out_announce) +{ + if (!data || len == 0 || !out_announce) + { + return false; + } + + Cursor cursor{data, len, 0}; + size_t count = 0; + if (!readArrayHeader(cursor, &count) || count < 7) + { + return false; + } + + DecodedPropagationAnnounce decoded{}; + uint32_t stamp_cost = 0; + uint32_t stamp_flexibility = 0; + uint32_t peering_cost = 0; + size_t stamp_count = 0; + if (!readBool(cursor, &decoded.legacy_support) || + !readNonNegativeNumber(cursor, &decoded.timebase_s) || + !readBool(cursor, &decoded.node_active) || + !readNonNegativeNumber(cursor, &decoded.transfer_limit_kb) || + !readNonNegativeNumber(cursor, &decoded.sync_limit_kb) || + !readArrayHeader(cursor, &stamp_count) || stamp_count < 3 || + !readUint(cursor, &stamp_cost) || + !readUint(cursor, &stamp_flexibility) || + !readUint(cursor, &peering_cost)) + { + return false; + } + for (size_t index = 3; index < stamp_count; ++index) + { + if (!skipObject(cursor)) + { + return false; + } + } + + size_t metadata_count = 0; + if (!readMapHeader(cursor, &metadata_count)) + { + return false; + } + for (size_t index = 0; index < metadata_count; ++index) + { + uint32_t key = 0; + if (!readUint(cursor, &key)) + { + return false; + } + if (key == 0x01U) + { + std::vector name; + if (!readBinary(cursor, &name)) + { + return false; + } + decoded.display_name.assign( + reinterpret_cast(name.data()), + name.size()); + } + else if (!skipObject(cursor)) + { + return false; + } + } + for (size_t index = 7; index < count; ++index) + { + if (!skipObject(cursor)) + { + return false; + } + } + + decoded.stamp_cost = static_cast(std::min(stamp_cost, 255U)); + decoded.stamp_cost_flexibility = + static_cast(std::min(stamp_flexibility, 255U)); + decoded.peering_cost = + static_cast(std::min(peering_cost, 255U)); + decoded.valid = decoded.node_active && cursor.pos == cursor.len; + *out_announce = std::move(decoded); + return cursor.pos == cursor.len; +} + bool encodePropagationIdListPayload(const std::vector>& ids, uint8_t* out_payload, size_t* inout_len) diff --git a/modules/core_chat/src/infra/reticulum/lxst_call_state_machine.cpp b/modules/core_chat/src/infra/reticulum/lxst_call_state_machine.cpp new file mode 100644 index 00000000..79e067fd --- /dev/null +++ b/modules/core_chat/src/infra/reticulum/lxst_call_state_machine.cpp @@ -0,0 +1,433 @@ +/** + * @file lxst_call_state_machine.cpp + * @brief Pure Sideband/LXST telephony session state and transition policy. + */ + +#include "chat/infra/reticulum/lxst_call_state_machine.h" + +#include "chat/infra/reticulum/lxst_telephony_wire.h" + +namespace chat::reticulum::lxst::call +{ +namespace +{ + +constexpr uint32_t kIdentifyTimeoutMs = 15000; +constexpr uint32_t kRingTimeoutMs = 60000; +constexpr uint32_t kDialTimeoutMs = 70000; +constexpr uint32_t kMediaSetupTimeoutMs = 5000; + +void appendAction(Transition& transition, Action action) +{ + if (transition.action_count < Transition::kMaxActions) + { + transition.actions[transition.action_count++] = action; + } +} + +void enterPhase(State& state, Phase phase, uint32_t now_ms) +{ + state.phase = phase; + state.phase_started_ms = now_ms; +} + +bool callerIsWaiting(const State& state) +{ + return state.phase == Phase::CallerAwaitingAvailability || + state.phase == Phase::CallerAwaitingRinging || + state.phase == Phase::CallerNegotiating || + state.phase == Phase::CallerConnecting; +} + +bool calleeCanNegotiateProfile(const State& state) +{ + return state.phase == Phase::CalleeRinging || + state.phase == Phase::CalleeConnecting || + state.phase == Phase::Active; +} + +Transition closeTransition(State& state, uint32_t now_ms) +{ + Transition transition{}; + transition.accepted = true; + enterPhase(state, Phase::Closing, now_ms); + appendAction(transition, Action::CloseLink); + return transition; +} + +Transition handlePreferredProfile(State& state, + uint16_t requested_profile) +{ + Transition transition{}; + const bool phase_allows_profile = + (state.role == Role::Caller && callerIsWaiting(state)) || + (state.role == Role::Callee && calleeCanNegotiateProfile(state)); + if (!phase_allows_profile) + { + return transition; + } + + transition.accepted = true; + if (requested_profile != state.profile) + { + appendAction(transition, Action::SendPreferredProfile); + } + return transition; +} + +Transition handleCallerSignal(State& state, + uint16_t signal, + uint32_t now_ms) +{ + Transition transition{}; + if (signal == kStatusBusy || signal == kStatusRejected) + { + if (!callerIsWaiting(state)) + { + return transition; + } + state.remote_status = signal; + return closeTransition(state, now_ms); + } + + if (signal == kStatusAvailable) + { + if (state.phase == Phase::CallerAwaitingRinging) + { + transition.accepted = true; + return transition; + } + if (state.phase != Phase::CallerAwaitingAvailability) + { + return transition; + } + state.remote_status = signal; + enterPhase(state, Phase::CallerAwaitingRinging, now_ms); + transition.accepted = true; + appendAction(transition, Action::SendIdentify); + return transition; + } + + if (signal == kStatusRinging) + { + if (state.phase == Phase::CallerNegotiating) + { + transition.accepted = true; + return transition; + } + if (state.phase != Phase::CallerAwaitingRinging) + { + return transition; + } + state.remote_status = signal; + enterPhase(state, Phase::CallerNegotiating, now_ms); + transition.accepted = true; + appendAction(transition, Action::SendPreferredProfile); + return transition; + } + + if (signal == kStatusConnecting) + { + if (state.phase == Phase::CallerConnecting) + { + transition.accepted = true; + return transition; + } + if (state.phase != Phase::CallerNegotiating) + { + return transition; + } + state.remote_status = signal; + enterPhase(state, Phase::CallerConnecting, now_ms); + transition.accepted = true; + appendAction(transition, Action::PrepareMedia); + return transition; + } + + if (signal == kStatusEstablished) + { + if (state.phase == Phase::Active) + { + transition.accepted = true; + return transition; + } + if (state.phase != Phase::CallerNegotiating && + state.phase != Phase::CallerConnecting) + { + return transition; + } + state.remote_status = signal; + transition.accepted = true; + if (state.media_prepared) + { + enterPhase(state, Phase::Active, now_ms); + appendAction(transition, Action::ActivateMedia); + } + else + { + enterPhase(state, Phase::CallerConnecting, now_ms); + appendAction(transition, Action::PrepareMedia); + } + return transition; + } + return transition; +} + +Transition handleCalleeSignal(State& state, + uint16_t signal, + uint32_t now_ms) +{ + Transition transition{}; + + // LXST ignores status signals from the caller until the local user answers. + if (state.phase == Phase::CalleeAwaitingLink || + state.phase == Phase::CalleeAwaitingIdentity || + state.phase == Phase::CalleeAwaitingAdmission || + state.phase == Phase::CalleeRinging) + { + return transition; + } + + if ((signal == kStatusBusy || signal == kStatusRejected) && + (state.phase == Phase::CalleeConnecting || + state.phase == Phase::Active)) + { + state.remote_status = signal; + return closeTransition(state, now_ms); + } + return transition; +} + +} // namespace + +State makeCaller(uint16_t profile, uint32_t now_ms) +{ + State state{}; + state.role = Role::Caller; + state.phase = Phase::CallerAwaitingLink; + state.profile = profile; + state.local_status = kStatusCalling; + state.remote_status = kStatusCalling; + state.phase_started_ms = now_ms; + return state; +} + +State makeCallee(uint16_t profile, uint32_t now_ms) +{ + State state{}; + state.role = Role::Callee; + state.phase = Phase::CalleeAwaitingLink; + state.profile = profile; + state.local_status = kStatusCalling; + state.remote_status = kStatusCalling; + state.phase_started_ms = now_ms; + return state; +} + +Transition dispatch(State* state, const Event& event, uint32_t now_ms) +{ + Transition transition{}; + if (!state) + { + return transition; + } + + if (event.type == EventType::LinkClosed) + { + transition.accepted = true; + enterPhase(*state, Phase::Closed, now_ms); + return transition; + } + if (state->phase == Phase::Closing || state->phase == Phase::Closed) + { + return transition; + } + if (event.type == EventType::LocalHangup || + event.type == EventType::OperationFailed || + event.type == EventType::Timeout) + { + transition.accepted = true; + const bool reject_ringing_call = + event.type == EventType::LocalHangup && + state->role == Role::Callee && + state->phase == Phase::CalleeRinging; + enterPhase(*state, Phase::Closing, now_ms); + if (reject_ringing_call) + { + state->local_status = kStatusRejected; + appendAction(transition, Action::SendRejected); + } + appendAction(transition, Action::CloseLink); + return transition; + } + + if (event.type == EventType::RemoteSignal) + { + if (event.signal >= kPreferredProfile) + { + return handlePreferredProfile(*state, + event.signal - kPreferredProfile); + } + return state->role == Role::Caller + ? handleCallerSignal(*state, event.signal, now_ms) + : handleCalleeSignal(*state, event.signal, now_ms); + } + + if (event.type == EventType::LinkActive) + { + transition.accepted = true; + if (state->role == Role::Caller && + state->phase == Phase::CallerAwaitingLink) + { + enterPhase(*state, Phase::CallerAwaitingAvailability, now_ms); + return transition; + } + if (state->role == Role::Callee && + state->phase == Phase::CalleeAwaitingLink) + { + state->local_status = kStatusAvailable; + enterPhase(*state, Phase::CalleeAwaitingIdentity, now_ms); + appendAction(transition, Action::SendAvailable); + return transition; + } + transition.accepted = false; + return transition; + } + + if (event.type == EventType::RemoteIdentified && + state->role == Role::Callee && + state->phase == Phase::CalleeAwaitingIdentity) + { + transition.accepted = true; + enterPhase(*state, Phase::CalleeAwaitingAdmission, now_ms); + appendAction(transition, Action::BeginRinging); + return transition; + } + + if (event.type == EventType::AdmissionGranted && + state->role == Role::Callee && + state->phase == Phase::CalleeAwaitingAdmission) + { + transition.accepted = true; + state->local_status = kStatusRinging; + enterPhase(*state, Phase::CalleeRinging, now_ms); + appendAction(transition, Action::SendRinging); + return transition; + } + + if (event.type == EventType::AdmissionDenied && + state->role == Role::Callee && + state->phase == Phase::CalleeAwaitingAdmission) + { + transition.accepted = true; + state->local_status = kStatusBusy; + enterPhase(*state, Phase::Closing, now_ms); + appendAction(transition, Action::SendBusy); + appendAction(transition, Action::CloseLink); + return transition; + } + + if (event.type == EventType::LocalAccepted && + state->role == Role::Callee && + state->phase == Phase::CalleeRinging) + { + transition.accepted = true; + state->local_status = kStatusConnecting; + enterPhase(*state, Phase::CalleeConnecting, now_ms); + appendAction(transition, Action::SendConnecting); + appendAction(transition, Action::PrepareMedia); + return transition; + } + + if (event.type == EventType::MediaReady) + { + if (state->phase == Phase::CallerConnecting) + { + transition.accepted = true; + state->media_prepared = true; + if (state->remote_status == kStatusEstablished) + { + enterPhase(*state, Phase::Active, now_ms); + appendAction(transition, Action::ActivateMedia); + } + return transition; + } + if (state->phase == Phase::CalleeConnecting) + { + transition.accepted = true; + state->media_prepared = true; + state->local_status = kStatusEstablished; + enterPhase(*state, Phase::Active, now_ms); + appendAction(transition, Action::SendEstablished); + appendAction(transition, Action::ActivateMedia); + return transition; + } + } + return transition; +} + +uint32_t phaseTimeoutMs(const State& state) +{ + switch (state.phase) + { + case Phase::CallerAwaitingAvailability: + case Phase::CallerAwaitingRinging: + case Phase::CalleeAwaitingIdentity: + return kIdentifyTimeoutMs; + case Phase::CallerNegotiating: + return kDialTimeoutMs; + case Phase::CalleeRinging: + return kRingTimeoutMs; + case Phase::CallerConnecting: + case Phase::CalleeAwaitingAdmission: + case Phase::CalleeConnecting: + return kMediaSetupTimeoutMs; + default: + return 0; + } +} + +bool phaseTimedOut(const State& state, uint32_t now_ms) +{ + const uint32_t timeout_ms = phaseTimeoutMs(state); + return timeout_ms != 0 && + now_ms - state.phase_started_ms >= timeout_ms; +} + +const char* phaseName(Phase phase) +{ + switch (phase) + { + case Phase::Idle: + return "idle"; + case Phase::CallerAwaitingLink: + return "caller_await_link"; + case Phase::CallerAwaitingAvailability: + return "caller_await_available"; + case Phase::CallerAwaitingRinging: + return "caller_await_ringing"; + case Phase::CallerNegotiating: + return "caller_negotiating"; + case Phase::CallerConnecting: + return "caller_connecting"; + case Phase::CalleeAwaitingLink: + return "callee_await_link"; + case Phase::CalleeAwaitingIdentity: + return "callee_await_identity"; + case Phase::CalleeAwaitingAdmission: + return "callee_admission"; + case Phase::CalleeRinging: + return "callee_ringing"; + case Phase::CalleeConnecting: + return "callee_connecting"; + case Phase::Active: + return "active"; + case Phase::Closing: + return "closing"; + case Phase::Closed: + return "closed"; + } + return "unknown"; +} + +} // namespace chat::reticulum::lxst::call diff --git a/modules/core_chat/src/infra/store/ram_store.cpp b/modules/core_chat/src/infra/store/ram_store.cpp index ad766c71..4907943d 100644 --- a/modules/core_chat/src/infra/store/ram_store.cpp +++ b/modules/core_chat/src/infra/store/ram_store.cpp @@ -6,6 +6,7 @@ #include "chat/infra/store/ram_store.h" #include #include +#include #include #include @@ -216,6 +217,34 @@ bool RamStore::getMessage(MessageId msg_id, ChatMessage* out) const return false; } +bool RamStore::hasReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const +{ + if (!lxmf_hash || isAllZeroKeyBytes(lxmf_hash, kReticulumLxmfHashSize)) + { + return false; + } + + for (const auto& pair : conversations_) + { + const ConversationStorage& storage = pair.second; + for (const auto& entry : storage.messages) + { + const ChatMessage& message = entry.message; + if (!chat::hasReticulumLxmfMessageHash(message)) + { + continue; + } + if (std::memcmp(message.reticulum_lxmf_hash, + lxmf_hash, + kReticulumLxmfHashSize) == 0) + { + return true; + } + } + } + return false; +} + void RamStore::evictOldestMessage() { auto oldest_it = conversations_.end(); diff --git a/modules/core_chat/src/usecase/chat_service.cpp b/modules/core_chat/src/usecase/chat_service.cpp index 7060dd50..fb4dcbdc 100644 --- a/modules/core_chat/src/usecase/chat_service.cpp +++ b/modules/core_chat/src/usecase/chat_service.cpp @@ -5,9 +5,11 @@ #include "chat/usecase/chat_service.h" #include "chat/infra/mesh_protocol_utils.h" +#include "chat/ports/i_incoming_delivery_commit_port.h" #include "chat/time_utils.h" #include #include +#include namespace chat { @@ -532,6 +534,10 @@ void ChatService::processIncoming() msg.timestamp = persistable_incoming_timestamp(incoming_text); msg.text = incoming_text.text; msg.reticulum_identity = incoming_text.reticulum_identity; + msg.has_reticulum_lxmf_hash = incoming_text.has_reticulum_lxmf_hash; + std::memcpy(msg.reticulum_lxmf_hash, + incoming_text.reticulum_lxmf_hash, + sizeof(msg.reticulum_lxmf_hash)); msg.source_unverified = incoming_text.source_unverified; msg.rx_origin = incoming_text.rx_meta.origin; msg.status = MessageStatus::Incoming; @@ -544,7 +550,7 @@ void ChatService::processIncoming() format_reticulum_hash_prefix(incoming_identity, incoming_dest_hash, sizeof(incoming_dest_hash)); - CHAT_SERVICE_DIAG_LOG("[ChatService][RX] text protocol=%s msg=%lu from=%08lX to=%08lX peer=%08lX dest=%s len=%u encrypted=%u unverified=%u\n", + CHAT_SERVICE_DIAG_LOG("[ChatService][RX] text protocol=%s msg=%lu from=%08lX to=%08lX peer=%08lX dest=%s len=%u encrypted=%u unverified=%u lxmf_hash=%u\n", protocol_name(active_protocol_), static_cast(msg.msg_id), static_cast(msg.from), @@ -553,7 +559,8 @@ void ChatService::processIncoming() incoming_dest_hash, static_cast(msg.text.size()), incoming_text.encrypted ? 1U : 0U, - incoming_text.source_unverified ? 1U : 0U); + incoming_text.source_unverified ? 1U : 0U, + chat::hasReticulumLxmfMessageHash(msg) ? 1U : 0U); CHAT_SERVICE_LOG("[ChatService] incoming text ch=%u from=%08lX to=%08lX peer=%08lX ts=%lu len=%u\n", static_cast(msg.channel), @@ -565,13 +572,34 @@ void ChatService::processIncoming() if (isDuplicateIncoming(msg)) { - CHAT_SERVICE_LOG("[ChatService] duplicate incoming text ignored ch=%u from=%08lX peer=%08lX id=%08lX\n", - static_cast(msg.channel), - static_cast(msg.from), - static_cast(msg.peer), - static_cast(msg.msg_id)); + CHAT_SERVICE_DIAG_LOG("[ChatService][RX] duplicate incoming text ignored ch=%u from=%08lX peer=%08lX id=%08lX lxmf_hash=%u\n", + static_cast(msg.channel), + static_cast(msg.from), + static_cast(msg.peer), + static_cast(msg.msg_id), + chat::hasReticulumLxmfMessageHash(msg) ? 1U : 0U); + if (IIncomingDeliveryCommitPort* commit_port = + adapter_.incomingDeliveryCommitPort()) + { + commit_port->commitIncomingText(incoming_text, true); + } continue; } + + if (!store_.appendIncomingDurably(msg)) + { + CHAT_SERVICE_DIAG_LOG("[ChatService][RX] durable append failed protocol=%s msg=%lu lxmf_hash=%u\n", + protocol_name(active_protocol_), + static_cast(msg.msg_id), + chat::hasReticulumLxmfMessageHash(msg) ? 1U : 0U); + if (IIncomingDeliveryCommitPort* commit_port = + adapter_.incomingDeliveryCommitPort()) + { + commit_port->commitIncomingText(incoming_text, false); + } + continue; + } + rememberIncoming(msg); if (model_enabled_) @@ -579,7 +607,11 @@ void ChatService::processIncoming() model_.onIncoming(msg); } - store_.append(msg); + if (IIncomingDeliveryCommitPort* commit_port = + adapter_.incomingDeliveryCommitPort()) + { + commit_port->commitIncomingText(incoming_text, true); + } for (auto* observer : incoming_message_observers_) { @@ -626,7 +658,18 @@ void ChatService::processIncoming() bool ChatService::isDuplicateIncoming(const ChatMessage& msg) const { - if (msg.msg_id == 0 || msg.status != MessageStatus::Incoming) + if (msg.status != MessageStatus::Incoming) + { + return false; + } + + if (chat::hasReticulumLxmfMessageHash(msg) && + store_.hasReticulumLxmfMessageHash(msg.reticulum_lxmf_hash)) + { + return true; + } + + if (msg.msg_id == 0) { return false; } @@ -637,6 +680,13 @@ bool ChatService::isDuplicateIncoming(const ChatMessage& msg) const identity.from = msg.from; identity.peer = msg.peer; identity.msg_id = msg.msg_id; + if (chat::hasReticulumLxmfMessageHash(msg)) + { + identity.has_reticulum_lxmf_hash = true; + std::memcpy(identity.reticulum_lxmf_hash, + msg.reticulum_lxmf_hash, + sizeof(identity.reticulum_lxmf_hash)); + } if (msg.protocol == MeshProtocol::Reticulum && hasReticulumDestinationIdentity(msg.reticulum_identity)) { @@ -649,7 +699,8 @@ bool ChatService::isDuplicateIncoming(const ChatMessage& msg) const void ChatService::rememberIncoming(const ChatMessage& msg) { - if (msg.msg_id == 0 || msg.status != MessageStatus::Incoming) + if (msg.status != MessageStatus::Incoming || + (msg.msg_id == 0 && !chat::hasReticulumLxmfMessageHash(msg))) { return; } @@ -660,6 +711,13 @@ void ChatService::rememberIncoming(const ChatMessage& msg) identity.from = msg.from; identity.peer = msg.peer; identity.msg_id = msg.msg_id; + if (chat::hasReticulumLxmfMessageHash(msg)) + { + identity.has_reticulum_lxmf_hash = true; + std::memcpy(identity.reticulum_lxmf_hash, + msg.reticulum_lxmf_hash, + sizeof(identity.reticulum_lxmf_hash)); + } if (msg.protocol == MeshProtocol::Reticulum && hasReticulumDestinationIdentity(msg.reticulum_identity)) { @@ -772,23 +830,50 @@ void ChatService::removeIncomingDataObserver(IncomingDataObserver* observer) } void ChatService::handleSendResult(MessageId msg_id, bool ok) +{ + handleSendResult(msg_id, + ok ? MessageStatus::Sent : MessageStatus::Failed); +} + +void ChatService::handleSendResult(MessageId msg_id, MessageStatus status) { if (msg_id == 0) { return; } + if (status != MessageStatus::Sent && + status != MessageStatus::Delivered && + status != MessageStatus::Failed) + { + return; + } + const ChatMessage* current = getMessage(msg_id); - if (!ok && current && current->status == MessageStatus::Sent) + if (current && current->from != 0) + { + return; + } + if (current && current->status == MessageStatus::Delivered) + { + return; + } + if (current && current->status == MessageStatus::Sent && + status == MessageStatus::Failed) { CHAT_SERVICE_DIAG_LOG("[ChatService][TX] ignore failed result for sent msg=%lu\n", static_cast(msg_id)); return; } + if (current && current->status == MessageStatus::Failed && + status == MessageStatus::Sent) + { + return; + } if (model_enabled_) { - model_.onSendResult(msg_id, ok); + model_.updateMessageStatus(msg_id, status); } - store_.updateMessageStatus(msg_id, ok ? MessageStatus::Sent : MessageStatus::Failed); + store_.updateMessageStatus(msg_id, status); } const ChatMessage* ChatService::getMessage(MessageId msg_id) const diff --git a/modules/core_chat/tests/test_chat_delivery_message_projection.cpp b/modules/core_chat/tests/test_chat_delivery_message_projection.cpp index ad01a196..9376593c 100644 --- a/modules/core_chat/tests/test_chat_delivery_message_projection.cpp +++ b/modules/core_chat/tests/test_chat_delivery_message_projection.cpp @@ -30,6 +30,11 @@ int main() assert(sent.state == chat::delivery::DeliveryState::Sent); assert(sent.failure == chat::delivery::DeliveryFailureKind::None); + const auto delivered = chat::delivery::toDeliveryRecord( + message(chat::MessageStatus::Delivered, 14)); + assert(delivered.state == chat::delivery::DeliveryState::Delivered); + assert(delivered.failure == chat::delivery::DeliveryFailureKind::None); + const auto failed = chat::delivery::toDeliveryRecord(message(chat::MessageStatus::Failed, 12)); assert(failed.state == chat::delivery::DeliveryState::Failed); diff --git a/modules/core_chat/tests/test_chat_delivery_send_result_projection.cpp b/modules/core_chat/tests/test_chat_delivery_send_result_projection.cpp index bbb5eef3..6b1a902d 100644 --- a/modules/core_chat/tests/test_chat_delivery_send_result_projection.cpp +++ b/modules/core_chat/tests/test_chat_delivery_send_result_projection.cpp @@ -19,6 +19,14 @@ int main() assert(event.failure == SendFailureKind::None); assert(event.timestamp_ms == 111); + event = makeChatSendResultDeliveryEvent(ref, + DeliveryState::Delivered, + SendFailureKind::RadioSendFailed, + 112); + assert(event.state == DeliveryState::Delivered); + assert(event.failure == SendFailureKind::None); + assert(event.timestamp_ms == 112); + ref.protocol_id = 11; event = makeChatSendResultDeliveryEvent(ref, false, diff --git a/modules/core_chat/tests/test_chat_service_resend.cpp b/modules/core_chat/tests/test_chat_service_resend.cpp index 180b4116..24139155 100644 --- a/modules/core_chat/tests/test_chat_service_resend.cpp +++ b/modules/core_chat/tests/test_chat_service_resend.cpp @@ -428,6 +428,13 @@ int main() service.handleSendResult(42, false); msg = onlyMessage(service, conv); assert(msg->status == chat::MessageStatus::Sent); + service.handleSendResult(42, chat::MessageStatus::Delivered); + msg = onlyMessage(service, conv); + assert(msg->status == chat::MessageStatus::Delivered); + service.handleSendResult(42, false); + service.handleSendResult(42, true); + msg = onlyMessage(service, conv); + assert(msg->status == chat::MessageStatus::Delivered); mesh.next_send_ok = false; mesh.next_msg_id = 77; diff --git a/modules/core_sys/include/platform/ui/reticulum_call_runtime.h b/modules/core_sys/include/platform/ui/reticulum_call_runtime.h index dba31dd0..92d99280 100644 --- a/modules/core_sys/include/platform/ui/reticulum_call_runtime.h +++ b/modules/core_sys/include/platform/ui/reticulum_call_runtime.h @@ -28,18 +28,13 @@ enum class State : uint8_t enum class RealtimePhase : uint8_t { Idle = 0, + IncomingIdentifying, IncomingRinging, AcceptedStarting, ActiveCall, ClosingCall, }; -enum class WireProfile : uint8_t -{ - SidebandLxst = 0, - MeshChatCallAudio = 1, -}; - enum class Codec2Mode : uint8_t { Mode700C = 0, @@ -48,6 +43,12 @@ enum class Codec2Mode : uint8_t Mode3200 = 3, }; +enum class WireProfile : uint8_t +{ + SidebandLxst = 0, + MeshChatCallAudio = 1, +}; + struct Peer { uint8_t link_id[kHashSize] = {}; @@ -101,7 +102,8 @@ struct MediaHooks struct RealtimeHooks { - bool (*begin_ringing)(const uint8_t link_id[kHashSize]) = nullptr; + bool (*begin_soft_preempt)(const uint8_t link_id[kHashSize]) = nullptr; + bool (*begin_ringing_alert)(const uint8_t link_id[kHashSize]) = nullptr; bool (*begin_exclusive)(const uint8_t link_id[kHashSize]) = nullptr; void (*begin_closing)(const uint8_t link_id[kHashSize], bool keep_exclusive) = nullptr; @@ -115,9 +117,13 @@ void set_speaker_volume(uint8_t volume_percent); void set_wifi_ready(bool ready); bool begin_incoming(const Peer& peer); +bool begin_incoming_identifying(const Peer& peer); +bool mark_incoming_ringing(const uint8_t link_id[kHashSize]); bool begin_outgoing(const Peer& peer); void update_peer(const Peer& peer); void mark_link_active(const uint8_t link_id[kHashSize]); +bool prepare_media(const uint8_t link_id[kHashSize]); +bool mark_call_active(const uint8_t link_id[kHashSize]); void notify_link_closed(const uint8_t link_id[kHashSize]); void notify_media_failed(); void service_ui_runtime(); diff --git a/modules/core_sys/include/platform/ui/reticulum_contact_projection_policy.h b/modules/core_sys/include/platform/ui/reticulum_contact_projection_policy.h new file mode 100644 index 00000000..b2929100 --- /dev/null +++ b/modules/core_sys/include/platform/ui/reticulum_contact_projection_policy.h @@ -0,0 +1,65 @@ +/** + * @file reticulum_contact_projection_policy.h + * @brief Product projection policy for Reticulum contact destinations. + */ + +#pragma once + +#include "platform/ui/reticulum_directory_runtime.h" + +#include + +namespace platform::ui::reticulum_contacts +{ + +enum class ProjectionBucket : uint8_t +{ + Hidden = 0, + Contact = 1, + Announced = 2, + Ignored = 3, +}; + +inline bool has_nonzero_bytes(const uint8_t* bytes, std::size_t len) +{ + if (!bytes || len == 0) + { + return false; + } + for (std::size_t index = 0; index < len; ++index) + { + if (bytes[index] != 0) + { + return true; + } + } + return false; +} + +inline ProjectionBucket classify( + const reticulum_directory::LxmfAddressRecord& record) +{ + if (!record.valid || + !has_nonzero_bytes(record.destination_hash, + sizeof(record.destination_hash)) || + !has_nonzero_bytes(record.identity_hash, + sizeof(record.identity_hash)) || + !has_nonzero_bytes(record.enc_pub, sizeof(record.enc_pub)) || + !has_nonzero_bytes(record.sig_pub, sizeof(record.sig_pub))) + { + return ProjectionBucket::Hidden; + } + if (record.ignored) + { + return ProjectionBucket::Ignored; + } + if (record.favorite || + record.source == reticulum_directory::EntrySource::Manual || + record.source == reticulum_directory::EntrySource::Import) + { + return ProjectionBucket::Contact; + } + return ProjectionBucket::Announced; +} + +} // namespace platform::ui::reticulum_contacts diff --git a/modules/core_sys/include/platform/ui/reticulum_network_config_runtime.h b/modules/core_sys/include/platform/ui/reticulum_network_config_runtime.h new file mode 100644 index 00000000..f0a5a09f --- /dev/null +++ b/modules/core_sys/include/platform/ui/reticulum_network_config_runtime.h @@ -0,0 +1,47 @@ +/** + * @file reticulum_network_config_runtime.h + * @brief Active Reticulum network configuration snapshot + */ + +#pragma once + +#include "chat/domain/chat_types.h" +#include "chat/domain/reticulum_network_config.h" + +#include +#include + +namespace platform::ui::reticulum_network_config +{ + +enum class Source : uint8_t +{ + Defaults = 0, + LastKnownGood = 1, + SdCard = 2, +}; + +struct Status +{ + bool supported = false; + bool sd_present = false; + bool file_present = false; + bool valid = false; + bool reload_deferred = false; + Source source = Source::Defaults; + uint32_t generation = 0; + uint8_t configured_interfaces = 0; + char message[64] = {}; + char detail[96] = {}; +}; + +void initialize(const chat::MeshConfig& legacy_config); +void poll(const chat::MeshConfig& legacy_config); +const chat::reticulum::ReticulumNetworkConfig& active(); +Status status(); +bool reload(const chat::MeshConfig& legacy_config); +bool export_template(const chat::MeshConfig& legacy_config); +const char* config_path(); +const char* source_name(Source source); + +} // namespace platform::ui::reticulum_network_config diff --git a/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp b/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp index f112ac37..f144592a 100644 --- a/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp +++ b/modules/core_sys/src/platform/ui/reticulum_call_runtime.cpp @@ -112,8 +112,8 @@ void apply_peer_locked(const Peer& peer) sizeof(s_state.snapshot.peer_name), peer.display_name); } - s_state.snapshot.wire_profile = peer.wire_profile; s_state.snapshot.codec2_mode = peer.codec2_mode; + s_state.snapshot.wire_profile = peer.wire_profile; s_state.snapshot.updated_ms = now_ms(); } @@ -125,10 +125,16 @@ void stop_media_outside_lock(MediaHooks hooks) } } -bool begin_ringing_outside_lock(RealtimeHooks hooks, - const uint8_t link_id[kHashSize]) +bool begin_soft_preempt_outside_lock(RealtimeHooks hooks, + const uint8_t link_id[kHashSize]) { - return !hooks.begin_ringing || hooks.begin_ringing(link_id); + return !hooks.begin_soft_preempt || hooks.begin_soft_preempt(link_id); +} + +bool begin_ringing_alert_outside_lock(RealtimeHooks hooks, + const uint8_t link_id[kHashSize]) +{ + return !hooks.begin_ringing_alert || hooks.begin_ringing_alert(link_id); } bool begin_exclusive_outside_lock(RealtimeHooks hooks, @@ -188,9 +194,12 @@ bool start_media_if_needed() s_state.snapshot.media_supported = hooks.is_supported ? hooks.is_supported() : false; should_start = s_state.snapshot.accepted && - s_state.snapshot.state == State::Active && s_state.snapshot.link_active && - !s_state.snapshot.media_active; + !s_state.snapshot.media_active && + (s_state.snapshot.realtime_phase == + RealtimePhase::AcceptedStarting || + s_state.snapshot.realtime_phase == + RealtimePhase::ActiveCall); if (should_start) { // Reserve media ownership before the task starts so a newly scheduled @@ -279,7 +288,10 @@ bool enqueue_packet(sys::RingBuffer& queue, return false; } std::lock_guard lock(s_state.mutex); - if (!hashes_equal(link_id, s_state.snapshot.link_id)) + if (!hashes_equal(link_id, s_state.snapshot.link_id) || + !s_state.snapshot.accepted || + !s_state.snapshot.link_active || + !s_state.snapshot.media_active) { return false; } @@ -353,6 +365,16 @@ void set_wifi_ready(bool ready) } bool begin_incoming(const Peer& peer) +{ + if (!begin_incoming_identifying(peer)) + { + return false; + } + update_peer(peer); + return mark_incoming_ringing(peer.link_id); +} + +bool begin_incoming_identifying(const Peer& peer) { if (hash_is_empty(peer.link_id)) { @@ -360,7 +382,7 @@ bool begin_incoming(const Peer& peer) } RealtimeHooks realtime_hooks{}; uint8_t link_id[kHashSize] = {}; - bool start_ringing = false; + bool start_soft_preempt = false; { std::lock_guard lock(s_state.mutex); if (s_state.snapshot.state == State::Active || @@ -373,7 +395,7 @@ bool begin_incoming(const Peer& peer) s_state.snapshot.supported = true; s_state.snapshot.incoming = true; s_state.snapshot.state = State::Incoming; - s_state.snapshot.realtime_phase = RealtimePhase::IncomingRinging; + s_state.snapshot.realtime_phase = RealtimePhase::IncomingIdentifying; s_state.snapshot.media_supported = s_state.hooks.is_supported ? s_state.hooks.is_supported() : false; apply_peer_locked(peer); @@ -383,11 +405,49 @@ bool begin_incoming(const Peer& peer) s_state.hangup_requested = false; s_state.closing_keep_exclusive = false; (void)acquire_sleep_wake_lease_locked(); - start_ringing = true; + start_soft_preempt = true; } - if (start_ringing) + if (start_soft_preempt && + !begin_soft_preempt_outside_lock(realtime_hooks, link_id)) { - (void)begin_ringing_outside_lock(realtime_hooks, link_id); + close_call(State::Failed, true); + return false; + } + return true; +} + +bool mark_incoming_ringing(const uint8_t link_id[kHashSize]) +{ + if (hash_is_empty(link_id)) + { + return false; + } + + RealtimeHooks realtime_hooks{}; + { + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(s_state.snapshot.link_id, link_id) || + s_state.snapshot.state != State::Incoming || + (s_state.snapshot.realtime_phase != + RealtimePhase::IncomingIdentifying && + s_state.snapshot.realtime_phase != + RealtimePhase::IncomingRinging)) + { + return false; + } + if (s_state.snapshot.realtime_phase == RealtimePhase::IncomingRinging) + { + return true; + } + s_state.snapshot.realtime_phase = RealtimePhase::IncomingRinging; + s_state.snapshot.updated_ms = now_ms(); + realtime_hooks = s_state.realtime_hooks; + } + + if (!begin_ringing_alert_outside_lock(realtime_hooks, link_id)) + { + close_call(State::Failed, true); + return false; } return true; } @@ -413,6 +473,7 @@ bool begin_outgoing(const Peer& peer) { return false; } + set_speaker_volume(100); bool started = false; { std::lock_guard lock(s_state.mutex); @@ -472,46 +533,74 @@ void mark_link_active(const uint8_t link_id[kHashSize]) { return; } - bool should_start = false; + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(s_state.snapshot.link_id, link_id)) + { + return; + } + if (s_state.snapshot.state != State::Incoming && + s_state.snapshot.state != State::Outgoing && + s_state.snapshot.state != State::Active) + { + return; + } + s_state.snapshot.link_active = true; + s_state.snapshot.updated_ms = now_ms(); +} + +bool prepare_media(const uint8_t link_id[kHashSize]) +{ + if (hash_is_empty(link_id)) + { + return false; + } { std::lock_guard lock(s_state.mutex); - if (!hashes_equal(s_state.snapshot.link_id, link_id)) + if (!hashes_equal(s_state.snapshot.link_id, link_id) || + !s_state.snapshot.accepted || + !s_state.snapshot.link_active || + (s_state.snapshot.realtime_phase != + RealtimePhase::AcceptedStarting && + s_state.snapshot.realtime_phase != RealtimePhase::ActiveCall)) { - return; + return false; } - if (s_state.snapshot.state != State::Incoming && - s_state.snapshot.state != State::Outgoing && - s_state.snapshot.state != State::Active) + if (s_state.snapshot.media_active) { - return; + return true; } - s_state.snapshot.link_active = true; - if (s_state.snapshot.state == State::Outgoing) - { - s_state.snapshot.accepted = true; - s_state.snapshot.state = State::Active; - s_state.snapshot.realtime_phase = RealtimePhase::ActiveCall; - should_start = true; - } - else if (s_state.snapshot.state == State::Incoming && - s_state.snapshot.accepted) - { - s_state.snapshot.state = State::Active; - s_state.snapshot.realtime_phase = RealtimePhase::ActiveCall; - should_start = true; - } - else if (s_state.snapshot.state == State::Active && - s_state.snapshot.accepted) - { - s_state.snapshot.realtime_phase = RealtimePhase::ActiveCall; - should_start = !s_state.snapshot.media_active; - } - s_state.snapshot.updated_ms = now_ms(); } - if (should_start) + if (!start_media_if_needed()) { - (void)start_media_if_needed(); + return false; } + std::lock_guard lock(s_state.mutex); + return hashes_equal(s_state.snapshot.link_id, link_id) && + s_state.snapshot.accepted && s_state.snapshot.link_active && + s_state.snapshot.media_active; +} + +bool mark_call_active(const uint8_t link_id[kHashSize]) +{ + if (hash_is_empty(link_id)) + { + return false; + } + std::lock_guard lock(s_state.mutex); + if (!hashes_equal(s_state.snapshot.link_id, link_id) || + !s_state.snapshot.accepted || + !s_state.snapshot.link_active || + !s_state.snapshot.media_active || + (s_state.snapshot.state != State::Incoming && + s_state.snapshot.state != State::Outgoing && + s_state.snapshot.state != State::Active)) + { + return false; + } + s_state.snapshot.state = State::Active; + s_state.snapshot.realtime_phase = RealtimePhase::ActiveCall; + s_state.snapshot.updated_ms = now_ms(); + return true; } void notify_link_closed(const uint8_t link_id[kHashSize]) @@ -535,8 +624,7 @@ void notify_media_failed() bool should_close = false; { std::lock_guard lock(s_state.mutex); - should_close = s_state.snapshot.state == State::Active && - s_state.snapshot.accepted && + should_close = s_state.snapshot.accepted && s_state.snapshot.media_active; } if (should_close) @@ -577,8 +665,9 @@ bool accept() uint8_t link_id[kHashSize] = {}; { std::lock_guard lock(s_state.mutex); - if (s_state.snapshot.state != State::Incoming && - s_state.snapshot.state != State::Active) + if (s_state.snapshot.state != State::Incoming || + s_state.snapshot.realtime_phase != + RealtimePhase::IncomingRinging) { return false; } @@ -590,22 +679,22 @@ bool accept() close_call(State::Failed, true); return false; } + set_speaker_volume(100); bool accepted = false; { std::lock_guard lock(s_state.mutex); if (!hashes_equal(s_state.snapshot.link_id, link_id) || - (s_state.snapshot.state != State::Incoming && - s_state.snapshot.state != State::Active)) + s_state.snapshot.state != State::Incoming || + s_state.snapshot.realtime_phase != + RealtimePhase::IncomingRinging) { realtime_hooks = s_state.realtime_hooks; } else { s_state.snapshot.accepted = true; - s_state.snapshot.state = State::Active; s_state.snapshot.realtime_phase = - s_state.snapshot.link_active ? RealtimePhase::ActiveCall - : RealtimePhase::AcceptedStarting; + RealtimePhase::AcceptedStarting; s_state.snapshot.updated_ms = now_ms(); (void)acquire_sleep_wake_lease_locked(); accepted = true; @@ -613,7 +702,7 @@ bool accept() } if (accepted) { - return start_media_if_needed(); + return true; } end_realtime_outside_lock(realtime_hooks, link_id); return false; @@ -670,10 +759,12 @@ RealtimePhase realtime_phase() bool media_should_run() { std::lock_guard lock(s_state.mutex); - return s_state.snapshot.state == State::Active && - s_state.snapshot.accepted && + return s_state.snapshot.accepted && s_state.snapshot.link_active && - s_state.snapshot.media_active; + s_state.snapshot.media_active && + (s_state.snapshot.realtime_phase == + RealtimePhase::AcceptedStarting || + s_state.snapshot.realtime_phase == RealtimePhase::ActiveCall); } bool realtime_mode_active() diff --git a/modules/core_sys/tests/test_app_config_defaults.cpp b/modules/core_sys/tests/test_app_config_defaults.cpp index f992d07b..c2c6810b 100644 --- a/modules/core_sys/tests/test_app_config_defaults.cpp +++ b/modules/core_sys/tests/test_app_config_defaults.cpp @@ -8,8 +8,6 @@ int main() assert(config.mesh_protocol == chat::MeshProtocol::Reticulum); assert(config.reticulumConfig().reticulum_wifi_gateway_enabled); assert(config.reticulumConfig().reticulum_wifi_gateway_port == 4242); - assert(config.reticulumConfig().reticulum_call_wire_profile == - chat::ReticulumCallWireProfile::SidebandLxst); assert(!config.reticulumConfig().reticulum_allow_location_requests); return 0; } diff --git a/modules/core_sys/tests/test_reticulum_call_runtime.cpp b/modules/core_sys/tests/test_reticulum_call_runtime.cpp index c27e1c0a..8d1b0e2f 100644 --- a/modules/core_sys/tests/test_reticulum_call_runtime.cpp +++ b/modules/core_sys/tests/test_reticulum_call_runtime.cpp @@ -17,6 +17,7 @@ struct Harness bool exclusive_ok = true; int media_start_count = 0; int media_stop_count = 0; + int soft_preempt_count = 0; int ringing_count = 0; int exclusive_count = 0; int closing_count = 0; @@ -51,7 +52,13 @@ void media_stop() ++g.media_stop_count; } -bool begin_ringing(const uint8_t*) +bool begin_soft_preempt(const uint8_t*) +{ + ++g.soft_preempt_count; + return true; +} + +bool begin_ringing_alert(const uint8_t*) { ++g.ringing_count; return true; @@ -82,7 +89,8 @@ void configure_hooks() platform::ui::reticulum_call::set_media_hooks(media); platform::ui::reticulum_call::RealtimeHooks realtime{}; - realtime.begin_ringing = begin_ringing; + realtime.begin_soft_preempt = begin_soft_preempt; + realtime.begin_ringing_alert = begin_ringing_alert; realtime.begin_exclusive = begin_exclusive; realtime.begin_closing = begin_closing; realtime.end = end_realtime; @@ -115,6 +123,32 @@ void complete_local_close(const platform::ui::reticulum_call::Peer& peer) platform::ui::reticulum_call::RealtimePhase::Idle); } +void test_identification_precedes_ringing() +{ + const int soft_preempts_before = g.soft_preempt_count; + const int rings_before = g.ringing_count; + const auto peer = make_peer(0x08, true); + assert(platform::ui::reticulum_call::begin_incoming_identifying(peer)); + platform::ui::reticulum_call::service_ui_runtime(); + + auto snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Incoming); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::IncomingIdentifying); + assert(g.soft_preempt_count == soft_preempts_before + 1); + assert(g.ringing_count == rings_before); + assert(!platform::ui::reticulum_call::accept()); + + assert(platform::ui::reticulum_call::mark_incoming_ringing(peer.link_id)); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::IncomingRinging); + assert(g.ringing_count == rings_before + 1); + + platform::ui::reticulum_call::reject(); + complete_local_close(peer); +} + void test_link_active_before_accept() { const auto peer = make_peer(0x10, true); @@ -139,11 +173,23 @@ void test_link_active_before_accept() assert(platform::ui::reticulum_call::accept()); snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Incoming); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::AcceptedStarting); + assert(!snapshot.media_active); + assert(g.media_start_count == 0); + + assert(platform::ui::reticulum_call::prepare_media(peer.link_id)); + snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.media_active); + assert(g.media_start_count == 1); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::AcceptedStarting); + assert(platform::ui::reticulum_call::mark_call_active(peer.link_id)); + snapshot = platform::ui::reticulum_call::snapshot(); assert(snapshot.state == platform::ui::reticulum_call::State::Active); assert(snapshot.realtime_phase == platform::ui::reticulum_call::RealtimePhase::ActiveCall); - assert(snapshot.media_active); - assert(g.media_start_count == 1); platform::ui::reticulum_call::hangup(); assert(g.media_stop_count == 1); @@ -161,7 +207,7 @@ void test_accept_before_link_active() assert(platform::ui::reticulum_call::accept()); auto snapshot = platform::ui::reticulum_call::snapshot(); - assert(snapshot.state == platform::ui::reticulum_call::State::Active); + assert(snapshot.state == platform::ui::reticulum_call::State::Incoming); assert(snapshot.realtime_phase == platform::ui::reticulum_call::RealtimePhase::AcceptedStarting); assert(!snapshot.link_active); @@ -171,6 +217,12 @@ void test_accept_before_link_active() platform::ui::reticulum_call::mark_link_active(peer.link_id); snapshot = platform::ui::reticulum_call::snapshot(); assert(snapshot.link_active); + assert(!snapshot.media_active); + assert(g.media_start_count == starts_before); + + assert(platform::ui::reticulum_call::prepare_media(peer.link_id)); + assert(platform::ui::reticulum_call::mark_call_active(peer.link_id)); + snapshot = platform::ui::reticulum_call::snapshot(); assert(snapshot.media_active); assert(snapshot.realtime_phase == platform::ui::reticulum_call::RealtimePhase::ActiveCall); @@ -207,7 +259,8 @@ void test_media_start_failure_closes_link() platform::ui::reticulum_call::mark_link_active(peer.link_id); g.media_start_ok = false; const int stops_before = g.media_stop_count; - assert(!platform::ui::reticulum_call::accept()); + assert(platform::ui::reticulum_call::accept()); + assert(!platform::ui::reticulum_call::prepare_media(peer.link_id)); const auto snapshot = platform::ui::reticulum_call::snapshot(); assert(snapshot.state == platform::ui::reticulum_call::State::Failed); @@ -247,6 +300,13 @@ void test_outgoing_hard_preempt_waits_for_link() platform::ui::reticulum_call::mark_link_active(peer.link_id); snapshot = platform::ui::reticulum_call::snapshot(); + assert(snapshot.state == platform::ui::reticulum_call::State::Outgoing); + assert(!snapshot.media_active); + assert(snapshot.realtime_phase == + platform::ui::reticulum_call::RealtimePhase::AcceptedStarting); + assert(platform::ui::reticulum_call::prepare_media(peer.link_id)); + assert(platform::ui::reticulum_call::mark_call_active(peer.link_id)); + snapshot = platform::ui::reticulum_call::snapshot(); assert(snapshot.state == platform::ui::reticulum_call::State::Active); assert(snapshot.media_active); assert(g.media_start_count == starts_before + 1); @@ -291,15 +351,16 @@ int main() sys::set_millis_provider(fake_millis); configure_hooks(); + test_identification_precedes_ringing(); test_link_active_before_accept(); test_accept_before_link_active(); test_exclusive_denial_closes_call(); test_media_start_failure_closes_link(); test_outgoing_hard_preempt_waits_for_link(); - assert(g.modal_wake_count == 5); - assert(g.sleep_disable_count == 5); - assert(g.sleep_enable_count == 5); - assert(g.activity_count == 5); + assert(g.modal_wake_count == 6); + assert(g.sleep_disable_count == 6); + assert(g.sleep_enable_count == 6); + assert(g.activity_count == 6); return 0; } diff --git a/modules/product_composition/src/target_profile.cpp b/modules/product_composition/src/target_profile.cpp index 247ed927..f1cc68b7 100644 --- a/modules/product_composition/src/target_profile.cpp +++ b/modules/product_composition/src/target_profile.cpp @@ -29,7 +29,7 @@ constexpr TargetProfile kTargetProfiles[] = { true, true, true, - BleBackend::C6Companion, + BleBackend::None, WirelessCompanionKind::Esp32C6, DisplayOrientationPolicy::SensorLandscapeOnly, }, @@ -54,7 +54,7 @@ constexpr TargetProfile kTargetProfiles[] = { true, true, true, - BleBackend::C6Companion, + BleBackend::None, WirelessCompanionKind::Esp32C6, DisplayOrientationPolicy::SensorLandscapeOnly, }, @@ -79,7 +79,7 @@ constexpr TargetProfile kTargetProfiles[] = { true, true, true, - BleBackend::C6Companion, + BleBackend::None, WirelessCompanionKind::Esp32C6, DisplayOrientationPolicy::SensorLandscapeOnly, }, diff --git a/modules/product_composition/tests/test_target_profile.cpp b/modules/product_composition/tests/test_target_profile.cpp index fd4507fb..53db6ae5 100644 --- a/modules/product_composition/tests/test_target_profile.cpp +++ b/modules/product_composition/tests/test_target_profile.cpp @@ -33,7 +33,7 @@ int main() assert(tab5->has_audio); assert(tab5->has_motion_sensor); assert(tab5->has_wireless_companion); - assert(tab5->ble_backend == product_composition::BleBackend::C6Companion); + assert(tab5->ble_backend == product_composition::BleBackend::None); assert(tab5->wireless_companion == product_composition::WirelessCompanionKind::Esp32C6); assert(tab5->display_orientation_policy == @@ -44,7 +44,7 @@ int main() assert(std::strcmp(tft->board_id, "t_display_p4") == 0); assert(tft->has_motion_sensor); assert(tft->has_wireless_companion); - assert(tft->ble_backend == product_composition::BleBackend::C6Companion); + assert(tft->ble_backend == product_composition::BleBackend::None); assert(tft->status == product_composition::TargetSupportStatus::Active); assert(tft->display_orientation_policy == product_composition::DisplayOrientationPolicy::SensorLandscapeOnly); @@ -54,7 +54,7 @@ int main() assert(std::strcmp(amoled->board_id, "t_display_p4") == 0); assert(amoled->has_motion_sensor); assert(amoled->has_wireless_companion); - assert(amoled->ble_backend == product_composition::BleBackend::C6Companion); + assert(amoled->ble_backend == product_composition::BleBackend::None); assert(amoled->status == product_composition::TargetSupportStatus::Active); assert(amoled->display_orientation_policy == product_composition::DisplayOrientationPolicy::SensorLandscapeOnly); diff --git a/modules/ui_chat_runtime/include/ui_chat_runtime/chat_delivery_event_projection_adapter.h b/modules/ui_chat_runtime/include/ui_chat_runtime/chat_delivery_event_projection_adapter.h index 663ff736..a84b8e63 100644 --- a/modules/ui_chat_runtime/include/ui_chat_runtime/chat_delivery_event_projection_adapter.h +++ b/modules/ui_chat_runtime/include/ui_chat_runtime/chat_delivery_event_projection_adapter.h @@ -8,11 +8,6 @@ namespace chat class ChatService; } -namespace sys -{ -struct ChatSendResultEvent; -} - namespace ui_chat_runtime { @@ -23,12 +18,14 @@ class ChatDeliveryEventProjectionAdapter ::chat::ChatService& chat_service, ::chat::delivery::IChatDeliveryEventPort& delivery_events); - void onChatSendResult(const ::sys::ChatSendResultEvent& event); + void onChatSendResult(::chat::MessageId msg_id, + ::chat::MessageStatus status, + uint32_t timestamp_ms = 0); void onAckTimeout(::chat::MessageId msg_id, uint32_t timestamp_ms = 0); private: bool publishSendResult(::chat::MessageId msg_id, - bool success, + ::chat::delivery::DeliveryState state, ::chat::delivery::SendFailureKind failure, uint32_t timestamp_ms); diff --git a/modules/ui_chat_runtime/src/chat_delivery_event_projection_adapter.cpp b/modules/ui_chat_runtime/src/chat_delivery_event_projection_adapter.cpp index 320112db..8c79007d 100644 --- a/modules/ui_chat_runtime/src/chat_delivery_event_projection_adapter.cpp +++ b/modules/ui_chat_runtime/src/chat_delivery_event_projection_adapter.cpp @@ -3,7 +3,6 @@ #include "chat/delivery/chat_delivery_message_projection.h" #include "chat/delivery/chat_delivery_send_result_projection.h" #include "chat/usecase/chat_service.h" -#include "sys/event_bus.h" namespace ui_chat_runtime { @@ -17,21 +16,33 @@ ChatDeliveryEventProjectionAdapter::ChatDeliveryEventProjectionAdapter( } void ChatDeliveryEventProjectionAdapter::onChatSendResult( - const ::sys::ChatSendResultEvent& event) + ::chat::MessageId msg_id, + ::chat::MessageStatus status, + uint32_t timestamp_ms) { - const ::chat::ChatMessage* message = chat_service_.getMessage(event.msg_id); - if (!event.success && message != nullptr && - message->status == ::chat::MessageStatus::Sent) + if (status != ::chat::MessageStatus::Sent && + status != ::chat::MessageStatus::Delivered && + status != ::chat::MessageStatus::Failed) { return; } - const auto failure = event.success + + const ::chat::ChatMessage* message = chat_service_.getMessage(msg_id); + if (status == ::chat::MessageStatus::Failed && message != nullptr && + (message->status == ::chat::MessageStatus::Sent || + message->status == ::chat::MessageStatus::Delivered)) + { + return; + } + const auto state = status == ::chat::MessageStatus::Delivered + ? ::chat::delivery::DeliveryState::Delivered + : status == ::chat::MessageStatus::Sent + ? ::chat::delivery::DeliveryState::Sent + : ::chat::delivery::DeliveryState::Failed; + const auto failure = status != ::chat::MessageStatus::Failed ? ::chat::delivery::SendFailureKind::None : ::chat::delivery::SendFailureKind::Unknown; - (void)publishSendResult(event.msg_id, - event.success, - failure, - event.timestamp); + (void)publishSendResult(msg_id, state, failure, timestamp_ms); } void ChatDeliveryEventProjectionAdapter::onAckTimeout( @@ -40,14 +51,14 @@ void ChatDeliveryEventProjectionAdapter::onAckTimeout( { (void)publishSendResult( msg_id, - false, + ::chat::delivery::DeliveryState::Failed, ::chat::delivery::SendFailureKind::AckTimeout, timestamp_ms); } bool ChatDeliveryEventProjectionAdapter::publishSendResult( ::chat::MessageId msg_id, - bool success, + ::chat::delivery::DeliveryState state, ::chat::delivery::SendFailureKind failure, uint32_t timestamp_ms) { @@ -65,7 +76,7 @@ bool ChatDeliveryEventProjectionAdapter::publishSendResult( ::chat::delivery::ChatDeliveryEvent event = ::chat::delivery::makeChatSendResultDeliveryEvent( ::chat::delivery::toDeliveryRef(*message), - success, + state, failure, timestamp_ms); delivery_events_.publishDeliveryEvent(event); diff --git a/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp b/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp index 64b3ba3e..04aaba4a 100644 --- a/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp +++ b/modules/ui_chat_runtime/src/chat_page_runtime_event_pump.cpp @@ -99,7 +99,8 @@ void ChatPageRuntimeEventPump::handleChatSendResult( { if (delivery_adapter_ != nullptr) { - delivery_adapter_->onChatSendResult(event); + delivery_adapter_->onChatSendResult( + event.msg_id, event.status, event.timestamp); } if (ui_ != nullptr) { diff --git a/modules/ui_chat_runtime/tests/test_chat_delivery_event_projection_adapter.cpp b/modules/ui_chat_runtime/tests/test_chat_delivery_event_projection_adapter.cpp index 1186dae6..d1d5d6c9 100644 --- a/modules/ui_chat_runtime/tests/test_chat_delivery_event_projection_adapter.cpp +++ b/modules/ui_chat_runtime/tests/test_chat_delivery_event_projection_adapter.cpp @@ -3,7 +3,6 @@ #include "chat/infra/store/ram_store.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/usecase/chat_service.h" -#include "sys/event_bus.h" #include "ui_chat_runtime/chat_delivery_event_projection_adapter.h" #include @@ -69,9 +68,8 @@ int main() assert(sent_id == 700); service.handleSendResult(sent_id, true); - ::sys::ChatSendResultEvent sent_event(sent_id, true); - sent_event.timestamp = 1234; - projection_adapter.onChatSendResult(sent_event); + projection_adapter.onChatSendResult( + sent_id, ::chat::MessageStatus::Sent, 1234); ::chat::delivery::ChatDeliveryRecord record{}; assert(read_model.find(::chat::delivery::ChatDeliveryRef{0, sent_id, 0}, @@ -79,23 +77,29 @@ int main() assert(record.state == ::chat::delivery::DeliveryState::Sent); assert(record.failure == ::chat::delivery::DeliveryFailureKind::None); assert(record.updated_at_ms == 1234); - ::sys::ChatSendResultEvent late_failed_event(sent_id, false); - late_failed_event.timestamp = 1300; - projection_adapter.onChatSendResult(late_failed_event); + service.handleSendResult(sent_id, ::chat::MessageStatus::Delivered); + projection_adapter.onChatSendResult( + sent_id, ::chat::MessageStatus::Delivered, 1250); assert(read_model.find(::chat::delivery::ChatDeliveryRef{0, sent_id, 0}, record)); - assert(record.state == ::chat::delivery::DeliveryState::Sent); + assert(record.state == ::chat::delivery::DeliveryState::Delivered); assert(record.failure == ::chat::delivery::DeliveryFailureKind::None); - assert(record.updated_at_ms == 1234); + assert(record.updated_at_ms == 1250); + projection_adapter.onChatSendResult( + sent_id, ::chat::MessageStatus::Failed, 1300); + assert(read_model.find(::chat::delivery::ChatDeliveryRef{0, sent_id, 0}, + record)); + assert(record.state == ::chat::delivery::DeliveryState::Delivered); + assert(record.failure == ::chat::delivery::DeliveryFailureKind::None); + assert(record.updated_at_ms == 1250); const auto failed_id = service.sendText(::chat::ChannelId::PRIMARY, "fail", 0); assert(failed_id == 701); service.handleSendResult(failed_id, false); - ::sys::ChatSendResultEvent failed_event(failed_id, false); - failed_event.timestamp = 2345; - projection_adapter.onChatSendResult(failed_event); + projection_adapter.onChatSendResult( + failed_id, ::chat::MessageStatus::Failed, 2345); assert(read_model.find(::chat::delivery::ChatDeliveryRef{0, failed_id, 0}, record)); @@ -116,8 +120,8 @@ int main() projection_adapter.onAckTimeout(0, 4567); assert(read_model.size() == 3); - ::sys::ChatSendResultEvent unknown_event(9999, false); - projection_adapter.onChatSendResult(unknown_event); + projection_adapter.onChatSendResult( + 9999, ::chat::MessageStatus::Failed, 0); assert(read_model.size() == 3); return 0; diff --git a/modules/ui_shared/include/ui/app_runtime.h b/modules/ui_shared/include/ui/app_runtime.h index 2726e44d..0dbbfc61 100644 --- a/modules/ui_shared/include/ui/app_runtime.h +++ b/modules/ui_shared/include/ui/app_runtime.h @@ -12,6 +12,9 @@ void ui_clear_active_app(); AppScreen* ui_get_active_app(); void ui_switch_to_app(AppScreen* app, lv_obj_t* parent); void ui_exit_active_app(lv_obj_t* parent); +bool ui_present_interruption_app(AppScreen* app, lv_obj_t* parent); +void ui_dismiss_interruption_app(lv_obj_t* parent); +bool ui_is_interruption_app_active(); void ui_request_exit_to_menu(); void ui_request_rebuild_active_app(); void ui_set_overlay_active(bool active); diff --git a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h index 472295f4..789e14bd 100644 --- a/modules/ui_shared/include/ui/screens/contacts/contacts_state.h +++ b/modules/ui_shared/include/ui/screens/contacts/contacts_state.h @@ -79,8 +79,6 @@ struct ContactsPageState lv_obj_t* prev_btn = nullptr; // Optional pager button lv_obj_t* next_btn = nullptr; // Optional pager button lv_obj_t* back_btn = nullptr; // Bottom-row back button for non-scroll modes - lv_obj_t* virtual_top_spacer = nullptr; - lv_obj_t* virtual_bottom_spacer = nullptr; // Current state ContactsMode current_mode = ContactsMode::Contacts; @@ -88,10 +86,6 @@ struct ContactsPageState int selected_index = -1; // Selected item in list int current_page = 0; // Current page (0-based) size_t total_items = 0; // Total items in current mode - bool virtual_list_active = false; - int list_window_start_index = 0; - int list_window_end_index = 0; - int list_scroll_y = 0; bool rendered_mode_valid = false; ContactsMode rendered_mode = ContactsMode::Contacts; char rendered_search_query[32] = {}; diff --git a/modules/ui_shared/include/ui/screens/settings/settings_state.h b/modules/ui_shared/include/ui/screens/settings/settings_state.h index dba4e0c5..2fde719a 100644 --- a/modules/ui_shared/include/ui/screens/settings/settings_state.h +++ b/modules/ui_shared/include/ui/screens/settings/settings_state.h @@ -78,7 +78,6 @@ enum class SettingId : std::uint16_t RtWifiPort, RtWifiAuto, RtAnonymousPeer, - RtCallWire, RtLocationRequests, NetUsePreset, NetPreset, @@ -254,7 +253,6 @@ struct SettingsData bool rt_wifi_gateway_enabled = true; bool rt_wifi_auto_connect = true; bool rt_anonymous_peer = false; - int rt_call_wire = 0; bool rt_location_requests = false; char rt_display_name[32] = "--"; char rt_identity_hash[36] = "--"; diff --git a/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h b/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h index 4386f8d0..dbdbdb5f 100644 --- a/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h +++ b/modules/ui_shared/include/ui/widgets/reticulum_call_overlay.h @@ -1,6 +1,6 @@ /** * @file reticulum_call_overlay.h - * @brief Top-level Reticulum call answer/hangup overlay. + * @brief Global Reticulum call interruption page runtime. */ #pragma once diff --git a/modules/ui_shared/src/ui/app_runtime.cpp b/modules/ui_shared/src/ui/app_runtime.cpp index ac9e0398..29f5e090 100644 --- a/modules/ui_shared/src/ui/app_runtime.cpp +++ b/modules/ui_shared/src/ui/app_runtime.cpp @@ -26,6 +26,9 @@ AppScreen* s_pending_exit = nullptr; lv_timer_t* s_exit_timer = nullptr; lv_timer_t* s_rebuild_timer = nullptr; bool s_overlay_active = false; +AppScreen* s_interruption_app = nullptr; +AppScreen* s_interrupted_app = nullptr; +bool s_interrupted_was_menu = false; #if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) struct AppEdgeSwipeState @@ -370,6 +373,10 @@ void set_default_group(lv_group_t* group) void menu_show() { + if (s_interruption_app != nullptr) + { + return; + } show_menu_internal(); } @@ -385,6 +392,10 @@ void ui_clear_active_app() void ui_switch_to_app(AppScreen* app, lv_obj_t* parent) { + if (s_interruption_app != nullptr && app != s_interruption_app) + { + return; + } #if defined(ESP_PLATFORM) ESP_LOGI(kTag, "ui_switch_to_app from=%s to=%s parent=%d", @@ -422,6 +433,10 @@ void ui_switch_to_app(AppScreen* app, lv_obj_t* parent) void ui_exit_active_app(lv_obj_t* parent) { + if (s_interruption_app != nullptr) + { + return; + } if (s_active_app) { std::printf("[UI][Lifecycle] app exit name=%s reason=explicit\n", @@ -432,8 +447,84 @@ void ui_exit_active_app(lv_obj_t* parent) ui::menu_runtime::setScene(ui::menu_runtime::Scene::Menu); } +bool ui_present_interruption_app(AppScreen* app, lv_obj_t* parent) +{ + if (!app || !parent) + { + return false; + } + if (s_interruption_app != nullptr) + { + return s_interruption_app == app && s_active_app == app; + } + + const bool exit_to_menu_was_pending = + s_pending_exit != nullptr && s_pending_exit == s_active_app; + s_interrupted_app = s_active_app; + s_interrupted_was_menu = + ui::menu_runtime::currentScene() == ui::menu_runtime::Scene::Menu || + exit_to_menu_was_pending; + if (s_exit_timer) + { + lv_timer_del(s_exit_timer); + s_exit_timer = nullptr; + } + if (s_rebuild_timer) + { + lv_timer_del(s_rebuild_timer); + s_rebuild_timer = nullptr; + } + s_pending_exit = nullptr; + s_interruption_app = app; + ui_set_overlay_active(true); + ui::menu_layout::setMenuVisible(false); + ui_switch_to_app(app, parent); + return s_active_app == app; +} + +void ui_dismiss_interruption_app(lv_obj_t* parent) +{ + AppScreen* interruption = s_interruption_app; + if (!interruption) + { + return; + } + + AppScreen* interrupted = s_interrupted_app; + const bool restore_menu = s_interrupted_was_menu || interrupted == nullptr; + s_interruption_app = nullptr; + s_interrupted_app = nullptr; + s_interrupted_was_menu = false; + ui_set_overlay_active(false); + + if (s_active_app == interruption) + { + std::printf("[UI][Lifecycle] app exit name=%s reason=interruption_complete\n", + interruption->name()); + interruption->exit(parent); + s_active_app = nullptr; + } + + if (!restore_menu && interrupted) + { + ui::menu_layout::setMenuVisible(false); + ui_switch_to_app(interrupted, parent); + return; + } + show_menu_internal(); +} + +bool ui_is_interruption_app_active() +{ + return s_interruption_app != nullptr; +} + void ui_request_exit_to_menu() { + if (s_interruption_app != nullptr) + { + return; + } AppScreen* app = s_active_app; #if defined(ESP_PLATFORM) ESP_LOGI(kTag, "ui_request_exit_to_menu app=%s", app ? app->name() : "(null)"); diff --git a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp index 23754026..0f3e36be 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_conversation_components.cpp @@ -268,6 +268,8 @@ bool model_fits_location_overlay(lv_obj_t* viewport_root, return ::ui::chat::MessageDeliveryState::Sent; case chat::MessageStatus::Failed: return ::ui::chat::MessageDeliveryState::Failed; + case chat::MessageStatus::Delivered: + return ::ui::chat::MessageDeliveryState::Delivered; } return ::ui::chat::MessageDeliveryState::Unknown; } diff --git a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp index 510ba79c..bac95bac 100644 --- a/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp +++ b/modules/ui_shared/src/ui/screens/chat_watch/chat_conversation_components_watch.cpp @@ -28,6 +28,8 @@ constexpr size_t kMaxPrefixedSenderLen = 20; return ::ui::chat::MessageDeliveryState::Sent; case chat::MessageStatus::Failed: return ::ui::chat::MessageDeliveryState::Failed; + case chat::MessageStatus::Delivered: + return ::ui::chat::MessageDeliveryState::Delivered; } return ::ui::chat::MessageDeliveryState::Unknown; } diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp index 54b27014..4f6cf03e 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_components.cpp @@ -1210,23 +1210,6 @@ static chat::MeshActionResult persist_reticulum_contact_peer( return g_contacts_state.chat_service->persistReticulumPeer(identity, favorite); } -static void present_busy_overlay_now() -{ - static bool s_presenting = false; - if (s_presenting) - { - return; - } - s_presenting = true; - if (lv_obj_t* top = lv_layer_top()) - { - lv_obj_invalidate(top); - } - lv_timer_handler(); - lv_refr_now(nullptr); - s_presenting = false; -} - class ScopedReticulumContactSaveOverlay { public: @@ -1239,7 +1222,6 @@ class ScopedReticulumContactSaveOverlay return; } ::ui::widgets::busy_overlay::show("Saving contact...", detail); - present_busy_overlay_now(); } ~ScopedReticulumContactSaveOverlay() @@ -1249,7 +1231,6 @@ class ScopedReticulumContactSaveOverlay return; } ::ui::widgets::busy_overlay::hide(); - present_busy_overlay_now(); } ScopedReticulumContactSaveOverlay(const ScopedReticulumContactSaveOverlay&) = @@ -4463,23 +4444,6 @@ void refresh_ui() } } g_contacts_state.list_items.clear(); - if (g_contacts_state.virtual_top_spacer != nullptr) - { - if (lv_obj_is_valid(g_contacts_state.virtual_top_spacer)) - { - lv_obj_del(g_contacts_state.virtual_top_spacer); - } - g_contacts_state.virtual_top_spacer = nullptr; - } - if (g_contacts_state.virtual_bottom_spacer != nullptr) - { - if (lv_obj_is_valid(g_contacts_state.virtual_bottom_spacer)) - { - lv_obj_del(g_contacts_state.virtual_bottom_spacer); - } - g_contacts_state.virtual_bottom_spacer = nullptr; - } - // Choose list by mode (unchanged) std::vector broadcast_list; std::vector team_list; @@ -4582,7 +4546,6 @@ void refresh_ui() { g_contacts_state.total_items += 1; } - const bool use_virtual_window = false; int target_scroll_y = same_render_context ? saved_scroll_y : 0; if (target_scroll_y < 0) { @@ -4613,12 +4576,6 @@ void refresh_ui() { end_idx = static_cast(current_list->size()); } - g_contacts_state.virtual_list_active = use_virtual_window; - g_contacts_state.list_window_start_index = use_virtual_window ? start_idx : 0; - g_contacts_state.list_window_end_index = - use_virtual_window ? end_idx : static_cast(current_list->size()); - g_contacts_state.list_scroll_y = target_scroll_y; - if (show_reticulum_group_add_item) { chat::contacts::NodeInfo add_node{}; diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp index b65f4b2e..76671060 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_input.cpp @@ -148,13 +148,28 @@ static lv_obj_t* get_list_button(void* /*ctx*/, size_t index) static int get_preferred_list_index(void* /*ctx*/) { const int selected = contacts::ui::g_contacts_state.selected_index; - if (contacts::ui::g_contacts_state.virtual_list_active) + if (selected < 0) { - const int start = contacts::ui::g_contacts_state.list_window_start_index; - const int end = contacts::ui::g_contacts_state.list_window_end_index; - return selected >= start && selected < end ? selected - start : -1; + return -1; } - return selected >= 0 ? selected : -1; + + const auto& items = contacts::ui::g_contacts_state.list_items; + for (size_t i = 0; i < items.size(); ++i) + { + lv_obj_t* item = items[i]; + if (item == nullptr || !lv_obj_is_valid(item)) + { + continue; + } + const intptr_t item_index = + reinterpret_cast(lv_obj_get_user_data(item)); + if (item_index == selected) + { + return static_cast(i); + } + } + + return -1; } static lv_obj_t* get_list_back_button(void* /*ctx*/) diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp index e92bffce..f55b4215 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_layout.cpp @@ -194,7 +194,9 @@ void create_filter_panel(lv_obj_t* parent) style::apply_panel_side(g_contacts_state.filter_panel); g_contacts_state.contacts_btn = create_filter_button(g_contacts_state.filter_panel, "Contacts"); - g_contacts_state.nearby_btn = create_filter_button(g_contacts_state.filter_panel, "Nearby"); + g_contacts_state.nearby_btn = create_filter_button( + g_contacts_state.filter_panel, + reticulum_profile ? "Announced" : "Nearby"); if (reticulum_profile) { diff --git a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp index 03f889c1..eec8642a 100644 --- a/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/contacts/contacts_page_runtime.cpp @@ -7,6 +7,7 @@ #include "chat/infra/reticulum/reticulum_wire.h" #include "chat/usecase/chat_service.h" #include "chat/usecase/contact_service.h" +#include "platform/ui/reticulum_contact_projection_policy.h" #include "platform/ui/reticulum_directory_runtime.h" #include "platform/ui/reticulum_group_config_runtime.h" #include "ui/app_runtime.h" @@ -41,6 +42,7 @@ namespace { using contacts::ui::shell::Host; +namespace rtcontacts = ::platform::ui::reticulum_contacts; namespace rtdir = ::platform::ui::reticulum_directory; constexpr uint32_t kContactsRefreshIntervalMs = 2000; @@ -365,7 +367,7 @@ chat::contacts::NodeInfo node_from_lxmf_address( item.channel = 0xFF; item.is_contact = as_contact || record.favorite; item.protocol = chat::contacts::NodeProtocolType::Reticulum; - item.role = chat::contacts::NodeRoleType::Client; + item.role = chat::contacts::NodeRoleType::Unknown; item.is_ignored = record.ignored; item.has_public_key = true; item.key_manually_verified = record.trusted; @@ -476,18 +478,22 @@ void merge_reticulum_directory_projection() for (std::size_t index = 0; index < count; ++index) { const auto& record = records[index]; - if (!record.valid) + const rtcontacts::ProjectionBucket bucket = + rtcontacts::classify(record); + if (bucket == rtcontacts::ProjectionBucket::Hidden) { continue; } const chat::contacts::NodeInfo item = - node_from_lxmf_address(record, record.favorite); - if (record.favorite) + node_from_lxmf_address( + record, + bucket == rtcontacts::ProjectionBucket::Contact); + if (bucket == rtcontacts::ProjectionBucket::Contact) { upsert_reticulum_projection(g_contacts_state.contacts_list, item, true); } - if (record.ignored) + else if (bucket == rtcontacts::ProjectionBucket::Ignored) { upsert_reticulum_projection(g_contacts_state.ignored_list, item, false); } @@ -652,10 +658,6 @@ void enter(const shell::Host* host, lv_obj_t* parent) g_contacts_state.last_action_mode = ContactsMode::Contacts; g_contacts_state.current_page = 0; g_contacts_state.selected_index = -1; - g_contacts_state.virtual_list_active = false; - g_contacts_state.list_window_start_index = 0; - g_contacts_state.list_window_end_index = 0; - g_contacts_state.list_scroll_y = 0; g_contacts_state.rendered_mode_valid = false; g_contacts_state.rendered_search_query[0] = '\0'; g_contacts_state.contacts_data_signature = 0; 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 22ca23a3..71e2680c 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 @@ -331,7 +331,18 @@ void copy_text(char* out, std::size_t out_len, const char* text) { return; } - std::snprintf(out, out_len, "%s", text ? text : ""); + if (!text) + { + out[0] = '\0'; + return; + } + std::size_t copy_len = std::strlen(text); + if (copy_len >= out_len) + { + copy_len = out_len - 1U; + } + std::memmove(out, text, copy_len); + out[copy_len] = '\0'; } void* network_page_alloc(std::size_t size) @@ -1660,7 +1671,14 @@ void resolve_link_target(const char* target, char* out, std::size_t out_len) char hash_text[kHashTextLen] = {}; if (extract_destination_text(g_state.current_address, hash_text, sizeof(hash_text))) { - std::snprintf(out, out_len, "%s:%s", hash_text, target); + copy_text(out, out_len, hash_text); + std::size_t used = std::strlen(out); + if (used + 1U < out_len) + { + out[used++] = ':'; + out[used] = '\0'; + copy_text(out + used, out_len - used, target); + } return; } } diff --git a/modules/ui_shared/src/ui/screens/settings/settings_channel_actions.cpp b/modules/ui_shared/src/ui/screens/settings/settings_channel_actions.cpp index bde302e8..e67d37b5 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_channel_actions.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_channel_actions.cpp @@ -25,8 +25,13 @@ void copy_bounded(char* out, std::size_t out_len, const char* text) out[0] = '\0'; return; } - std::strncpy(out, text, out_len - 1); - out[out_len - 1] = '\0'; + std::size_t copy_len = std::strlen(text); + if (copy_len >= out_len) + { + copy_len = out_len - 1U; + } + std::memmove(out, text, copy_len); + out[copy_len] = '\0'; } bool parse_hex_char(char c, std::uint8_t& out) diff --git a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp index 1309cedc..e5e55e53 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_page_components.cpp @@ -500,7 +500,9 @@ static void firmware_status_summary(const firmware_update_runtime::Status& statu status.detail[0] != '\0' && std::strcmp(status.detail, message) != 0) { - std::snprintf(out, out_len, "%s: %s", message, status.detail); + copy_bounded(out, out_len, message); + append_bounded(out, out_len, ": "); + append_bounded(out, out_len, status.detail); return; } @@ -1310,8 +1312,6 @@ static void reset_mesh_settings() app_ctx.getConfig().reticulumConfig().reticulum_wifi_auto_connect; g_settings.rt_anonymous_peer = app_ctx.getConfig().reticulumConfig().reticulum_anonymous_peer; - g_settings.rt_call_wire = static_cast( - app_ctx.getConfig().reticulumConfig().reticulum_call_wire_profile); g_settings.rt_location_requests = app_ctx.getConfig().reticulumConfig().reticulum_allow_location_requests; g_settings.mt_mqtt_enabled = app_ctx.getConfig().meshtastic_mqtt_enabled; @@ -1618,8 +1618,6 @@ static void settings_load() g_settings.rt_wifi_gateway_enabled = reticulum_cfg.reticulum_wifi_gateway_enabled; g_settings.rt_wifi_auto_connect = reticulum_cfg.reticulum_wifi_auto_connect; g_settings.rt_anonymous_peer = reticulum_cfg.reticulum_anonymous_peer; - g_settings.rt_call_wire = - static_cast(reticulum_cfg.reticulum_call_wire_profile); g_settings.rt_location_requests = reticulum_cfg.reticulum_allow_location_requests; copy_bounded(g_settings.rt_wifi_gateway_host, @@ -2797,7 +2795,7 @@ static void build_number_options(char* out, size_t used = 0; for (int value = min_value; value <= max_value; ++value) { - char item[8] = {}; + char item[16] = {}; if (digits == 4) { std::snprintf(item, sizeof(item), "%04d", value); @@ -3249,20 +3247,6 @@ static void on_option_clicked(lv_event_t* e) app_ctx.applyMeshConfig(); rebuild_list = true; } - if (id == settings::ui::SettingId::RtCallWire) - { - app::IAppFacade& app_ctx = app::appFacade(); - const int clamped = payload->value == static_cast( - chat::ReticulumCallWireProfile::MeshChatCallAudio) - ? payload->value - : static_cast( - chat::ReticulumCallWireProfile::SidebandLxst); - g_settings.rt_call_wire = clamped; - app_ctx.getConfig().reticulumConfig().reticulum_call_wire_profile = - static_cast(clamped); - app_ctx.saveConfig(); - app_ctx.applyMeshConfig(); - } if (id == settings::ui::SettingId::MeshProtocol) { app::IAppFacade& app_ctx = app::appFacade(); @@ -4410,10 +4394,6 @@ static const settings::ui::SettingOption kReticulumBearerOptions[] = { {"LoRa", static_cast(chat::ReticulumInterfacePolicy::LoRaOnly)}, {"Wi-Fi", static_cast(chat::ReticulumInterfacePolicy::WifiGatewayOnly)}, }; -static const settings::ui::SettingOption kReticulumCallWireOptions[] = { - {"Sideband", static_cast(chat::ReticulumCallWireProfile::SidebandLxst)}, - {"MeshChat", static_cast(chat::ReticulumCallWireProfile::MeshChatCallAudio)}, -}; static const settings::ui::SettingOption kChatContactAlertOptions[] = { {"OFF", kChatContactAlertsNone}, {"Contacts Only", kChatContactAlertsContacts}, @@ -4603,7 +4583,6 @@ static settings::ui::SettingItem kMeshItems[] = { {"Gateway Port", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, g_settings.rt_wifi_gateway_port, sizeof(g_settings.rt_wifi_gateway_port), false, "rt_wifi_port"}, {"Auto Wi-Fi", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_wifi_auto_connect, nullptr, 0, false, "rt_wifi_auto"}, {"Anonymous Peer", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_anonymous_peer, nullptr, 0, false, "rt_anonymous_peer"}, - {"Call Protocol", settings::ui::SettingType::Enum, kReticulumCallWireOptions, sizeof(kReticulumCallWireOptions) / sizeof(kReticulumCallWireOptions[0]), &g_settings.rt_call_wire, nullptr, nullptr, 0, false, "rt_call_wire"}, {"Location Requests", settings::ui::SettingType::Toggle, nullptr, 0, nullptr, &g_settings.rt_location_requests, nullptr, 0, false, "rt_location_requests"}, }; diff --git a/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp b/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp index d6a2ebdb..5276213b 100644 --- a/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp +++ b/modules/ui_shared/src/ui/screens/settings/settings_spec.cpp @@ -70,7 +70,6 @@ constexpr SettingSpec kSpecs[] = { {SettingId::RtWifiPort, "rt_wifi_port", DynamicOptionKind::None}, {SettingId::RtWifiAuto, "rt_wifi_auto", DynamicOptionKind::None}, {SettingId::RtAnonymousPeer, "rt_anonymous_peer", DynamicOptionKind::None}, - {SettingId::RtCallWire, "rt_call_wire", DynamicOptionKind::None}, {SettingId::RtLocationRequests, "rt_location_requests", DynamicOptionKind::None}, {SettingId::NetUsePreset, "net_use_preset", DynamicOptionKind::None}, {SettingId::NetPreset, "net_preset", DynamicOptionKind::None}, @@ -276,7 +275,6 @@ bool is_reticulum_mesh(SettingId id) case SettingId::RtWifiPort: case SettingId::RtWifiAuto: case SettingId::RtAnonymousPeer: - case SettingId::RtCallWire: case SettingId::RtLocationRequests: return true; default: diff --git a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp index 663daad0..67dd0ff7 100644 --- a/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp +++ b/modules/ui_shared/src/ui/widgets/reticulum_call_overlay.cpp @@ -1,6 +1,6 @@ /** * @file reticulum_call_overlay.cpp - * @brief Top-level Reticulum call answer/hangup overlay. + * @brief Global Reticulum call interruption page. */ #include "ui/widgets/reticulum_call_overlay.h" @@ -9,7 +9,7 @@ #include "platform/ui/reticulum_call_runtime.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/font_utils.h" -#include "ui/localization.h" +#include "ui/callback_app_screen.h" #include "ui/page/page_profile.h" #include "ui/runtime/ui_feedback.h" @@ -21,26 +21,32 @@ namespace ui::widgets::reticulum_call_overlay namespace { -constexpr uint32_t kScrim = 0x1C1812; -constexpr uint32_t kPanelBg = 0xFAF0D8; -constexpr uint32_t kAmber = 0xEBA341; -constexpr uint32_t kAmberDark = 0xC98118; +namespace call = ::platform::ui::reticulum_call; + +constexpr uint32_t kPageBg = 0xFAF0D8; +constexpr uint32_t kHeaderBg = 0xEBA341; constexpr uint32_t kLine = 0xE7C98F; constexpr uint32_t kText = 0x6B4A1E; constexpr uint32_t kTextDim = 0x8A6A3A; -constexpr uint32_t kWarn = 0xB94A2C; -constexpr uint32_t kOk = 0x3E7D3E; -constexpr int kEncoderKeyRotateUp = 19; -constexpr int kEncoderKeyRotateDown = 20; +constexpr uint32_t kDanger = 0xB94A2C; +constexpr uint32_t kDangerPressed = 0x95351F; +constexpr uint32_t kAccept = 0x3E7D3E; +constexpr uint32_t kAcceptPressed = 0x2F662F; constexpr uint8_t kVolumeStep = 5; constexpr uint32_t kActivationGuardMs = 400; -constexpr lv_coord_t kFallbackScreenW = 320; -constexpr lv_coord_t kFallbackScreenH = 240; +constexpr int kEncoderKeyRotateUp = 19; +constexpr int kEncoderKeyRotateDown = 20; -struct OverlayState +enum class Action : uint8_t +{ + Answer, + Hangup, + Decline, +}; + +struct PageState { lv_obj_t* root = nullptr; - lv_obj_t* panel = nullptr; lv_obj_t* title = nullptr; lv_obj_t* peer = nullptr; lv_obj_t* status = nullptr; @@ -49,92 +55,13 @@ struct OverlayState lv_obj_t* hangup_btn = nullptr; lv_obj_t* decline_btn = nullptr; lv_group_t* group = nullptr; - lv_group_t* previous_group = nullptr; - AppScreen* owner_app = nullptr; - bool needs_present = false; + bool presented = false; uint32_t activation_armed_ms = 0; - ::platform::ui::reticulum_call::State last_state = - ::platform::ui::reticulum_call::State::Idle; + call::State last_state = call::State::Idle; + call::RealtimePhase last_phase = call::RealtimePhase::Idle; }; -OverlayState s_overlay; - -lv_coord_t display_width() -{ - const lv_coord_t width = lv_display_get_horizontal_resolution(nullptr); - if (width > 0) - { - return width; - } - if (lv_obj_t* screen = lv_scr_act()) - { - const lv_coord_t screen_width = lv_obj_get_width(screen); - if (screen_width > 0) - { - return screen_width; - } - } - return kFallbackScreenW; -} - -lv_coord_t display_height() -{ - const lv_coord_t height = lv_display_get_vertical_resolution(nullptr); - if (height > 0) - { - return height; - } - if (lv_obj_t* screen = lv_scr_act()) - { - const lv_coord_t screen_height = lv_obj_get_height(screen); - if (screen_height > 0) - { - return screen_height; - } - } - return kFallbackScreenH; -} - -lv_coord_t clamp_panel_width(lv_coord_t preferred, lv_coord_t screen_width) -{ - const lv_coord_t max_width = screen_width > 24 ? screen_width - 16 : screen_width; - if (max_width > 0 && preferred > max_width) - { - return max_width; - } - return preferred; -} - -void cover_display(lv_obj_t* obj) -{ - if (!obj || !lv_obj_is_valid(obj)) - { - return; - } - lv_obj_set_pos(obj, 0, 0); - lv_obj_set_size(obj, display_width(), display_height()); -} - -void normalize_top_layer() -{ - if (lv_obj_t* top = lv_layer_top()) - { - cover_display(top); - } -} - -void present_overlay_now() -{ - if (s_overlay.root && lv_obj_is_valid(s_overlay.root)) - { - lv_obj_invalidate(s_overlay.root); - } - if (lv_obj_t* top = lv_layer_top()) - { - lv_obj_invalidate(top); - } - lv_refr_now(nullptr); -} +PageState s_page; const lv_font_t* body_font() { @@ -146,10 +73,10 @@ const lv_font_t* caption_font() return ::ui::page_profile::resolve_caption_font(); } -void apply_label(lv_obj_t* label, - const char* text, - uint32_t color, - const lv_font_t* font) +void set_label(lv_obj_t* label, + const char* text, + uint32_t color, + const lv_font_t* font) { if (!label) { @@ -158,23 +85,12 @@ void apply_label(lv_obj_t* label, const char* value = text ? text : ""; lv_label_set_text(label, value); lv_obj_set_style_text_color(label, lv_color_hex(color), LV_PART_MAIN); - ::ui::fonts::apply_localized_font(label, value, font ? font : body_font()); + ::ui::fonts::apply_localized_font(label, + value, + font ? font : body_font()); } -void style_button(lv_obj_t* btn, uint32_t bg, uint32_t bg_pressed) -{ - lv_obj_set_style_radius(btn, 8, LV_PART_MAIN); - lv_obj_set_style_bg_color(btn, lv_color_hex(bg), LV_PART_MAIN); - lv_obj_set_style_bg_color(btn, lv_color_hex(bg_pressed), LV_STATE_PRESSED); - lv_obj_set_style_bg_color(btn, lv_color_hex(bg_pressed), LV_STATE_FOCUSED); - lv_obj_set_style_bg_color(btn, lv_color_hex(bg_pressed), LV_STATE_FOCUS_KEY); - lv_obj_set_style_border_width(btn, 1, LV_PART_MAIN); - lv_obj_set_style_border_color(btn, lv_color_hex(kLine), LV_PART_MAIN); - lv_obj_set_style_outline_width(btn, 0, LV_STATE_FOCUSED); - lv_obj_set_style_outline_width(btn, 0, LV_STATE_FOCUS_KEY); -} - -void stop_input_event(lv_event_t* event) +void stop_event(lv_event_t* event) { if (!event) { @@ -188,79 +104,14 @@ void stop_input_event(lv_event_t* event) } } -void swallow_event_cb(lv_event_t* event) -{ - stop_input_event(event); -} - -void add_swallow_input_callbacks(lv_obj_t* obj) -{ - if (!obj) - { - return; - } - - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_PRESSED, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_PRESSING, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_PRESS_LOST, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_RELEASED, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_SHORT_CLICKED, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_LONG_PRESSED, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_LONG_PRESSED_REPEAT, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_GESTURE, nullptr); - lv_obj_add_event_cb(obj, swallow_event_cb, LV_EVENT_KEY, nullptr); -} - -bool focus_previous_key(uint32_t key) -{ - return key == LV_KEY_UP || key == LV_KEY_LEFT || key == LV_KEY_PREV || - key == static_cast(kEncoderKeyRotateUp); -} - -bool focus_next_key(uint32_t key) -{ - return key == LV_KEY_DOWN || key == LV_KEY_RIGHT || key == LV_KEY_NEXT || - key == static_cast(kEncoderKeyRotateDown); -} - -bool handle_focus_key(lv_event_t* event) -{ - if (!event || lv_event_get_code(event) != LV_EVENT_KEY || !s_overlay.group) - { - return false; - } - const uint32_t key = lv_event_get_key(event); - if (focus_previous_key(key)) - { - lv_group_focus_prev(s_overlay.group); - lv_group_set_editing(s_overlay.group, false); - stop_input_event(event); - return true; - } - if (focus_next_key(key)) - { - lv_group_focus_next(s_overlay.group); - lv_group_set_editing(s_overlay.group, false); - stop_input_event(event); - return true; - } - return false; -} - void update_shortcut_hint() { - if (!s_overlay.shortcut_hint) - { - return; - } char hint[48] = {}; std::snprintf(hint, sizeof(hint), "+/- Volume %u%%", - static_cast( - ::platform::ui::reticulum_call::speaker_volume())); - apply_label(s_overlay.shortcut_hint, hint, kTextDim, caption_font()); + static_cast(call::speaker_volume())); + set_label(s_page.shortcut_hint, hint, kTextDim, caption_font()); } bool handle_volume_key(lv_event_t* event) @@ -277,9 +128,9 @@ bool handle_volume_key(lv_event_t* event) return false; } - const uint8_t current = ::platform::ui::reticulum_call::speaker_volume(); - const int delta = louder ? static_cast(kVolumeStep) : -static_cast(kVolumeStep); - int next = static_cast(current) + delta; + const int delta = louder ? static_cast(kVolumeStep) + : -static_cast(kVolumeStep); + int next = static_cast(call::speaker_volume()) + delta; if (next < 0) { next = 0; @@ -288,288 +139,194 @@ bool handle_volume_key(lv_event_t* event) { next = 100; } - ::platform::ui::reticulum_call::set_speaker_volume(static_cast(next)); + call::set_speaker_volume(static_cast(next)); update_shortcut_hint(); - stop_input_event(event); + stop_event(event); return true; } -bool visible_action(lv_obj_t* obj, bool incoming) +bool handle_focus_key(lv_event_t* event) { - if (!obj || !lv_obj_is_valid(obj) || lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) + if (!event || lv_event_get_code(event) != LV_EVENT_KEY || !s_page.group) { return false; } - if (incoming) - { - return obj == s_overlay.answer_btn || obj == s_overlay.decline_btn; - } - return obj == s_overlay.hangup_btn; -} - -void focus_default_action(bool incoming, bool force) -{ - if (!s_overlay.group) - { - return; - } - lv_obj_t* focused = lv_group_get_focused(s_overlay.group); - if (!force && visible_action(focused, incoming)) - { - return; - } - lv_obj_t* target = incoming ? s_overlay.answer_btn : s_overlay.hangup_btn; - if (target && lv_obj_is_valid(target) && - !lv_obj_has_flag(target, LV_OBJ_FLAG_HIDDEN)) - { - lv_group_focus_obj(target); - lv_group_set_editing(s_overlay.group, false); - } -} - -lv_obj_t* make_button(lv_obj_t* parent, - const char* text, - uint32_t bg, - uint32_t bg_pressed, - lv_event_cb_t cb) -{ - lv_obj_t* btn = lv_btn_create(parent); - lv_obj_set_size(btn, - ::ui::page_profile::resolve_control_button_min_width(), - ::ui::page_profile::resolve_control_button_height()); - style_button(btn, bg, bg_pressed); - - lv_obj_t* label = lv_label_create(btn); - apply_label(label, text, kText, body_font()); - lv_obj_center(label); - lv_obj_add_event_cb(btn, cb, LV_EVENT_CLICKED, nullptr); - lv_obj_add_event_cb(btn, cb, LV_EVENT_KEY, nullptr); - lv_obj_add_event_cb(btn, swallow_event_cb, LV_EVENT_PRESSED, nullptr); - lv_obj_add_event_cb(btn, swallow_event_cb, LV_EVENT_PRESSING, nullptr); - lv_obj_add_event_cb(btn, swallow_event_cb, LV_EVENT_PRESS_LOST, nullptr); - lv_obj_add_event_cb(btn, swallow_event_cb, LV_EVENT_RELEASED, nullptr); - if (s_overlay.group) - { - lv_group_add_obj(s_overlay.group, btn); - } - return btn; -} - -bool activation_event(lv_event_t* event) -{ - if (!event) - { - return false; - } - if (static_cast(lv_tick_get() - s_overlay.activation_armed_ms) < 0) - { - stop_input_event(event); - return false; - } - if (lv_event_get_code(event) == LV_EVENT_CLICKED) + const uint32_t key = lv_event_get_key(event); + if (key == LV_KEY_UP || key == LV_KEY_LEFT || key == LV_KEY_PREV || + key == static_cast(kEncoderKeyRotateUp)) { + lv_group_focus_prev(s_page.group); + lv_group_set_editing(s_page.group, false); + stop_event(event); return true; } - if (lv_event_get_code(event) != LV_EVENT_KEY) + if (key == LV_KEY_DOWN || key == LV_KEY_RIGHT || key == LV_KEY_NEXT || + key == static_cast(kEncoderKeyRotateDown)) { - return false; + lv_group_focus_next(s_page.group); + lv_group_set_editing(s_page.group, false); + stop_event(event); + return true; } - const uint32_t key = lv_event_get_key(event); - return key == LV_KEY_ENTER; + return false; } -void answer_cb(lv_event_t* event) +void perform_action(Action action) { - if (handle_volume_key(event)) + switch (action) { - return; - } - if (handle_focus_key(event)) - { - return; - } - if (!activation_event(event)) - { - return; - } - stop_input_event(event); - if (!::platform::ui::reticulum_call::accept()) - { - ::ui::feedback::show_notice(::ui::i18n::tr("Call unavailable"), 1800); - } -} - -void hangup_cb(lv_event_t* event) -{ - if (handle_volume_key(event)) - { - return; - } - if (handle_focus_key(event)) - { - return; - } - if (!activation_event(event)) - { - return; - } - stop_input_event(event); - ::platform::ui::reticulum_call::hangup(); -} - -void decline_cb(lv_event_t* event) -{ - if (handle_volume_key(event)) - { - return; - } - if (handle_focus_key(event)) - { - return; - } - if (!activation_event(event)) - { - return; - } - stop_input_event(event); - ::platform::ui::reticulum_call::reject(); -} - -void root_key_cb(lv_event_t* event) -{ - if (!event || lv_event_get_code(event) != LV_EVENT_KEY) - { - return; - } - if (handle_focus_key(event)) - { - return; - } - if (handle_volume_key(event)) - { - return; - } - const uint32_t key = lv_event_get_key(event); - const auto snapshot = ::platform::ui::reticulum_call::snapshot(); - if ((key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) && - snapshot.state != ::platform::ui::reticulum_call::State::Idle) - { - stop_input_event(event); - ::platform::ui::reticulum_call::hangup(); - } -} - -void destroy_overlay() -{ - if (!s_overlay.root && !s_overlay.group) - { - if (ui_is_overlay_active()) + case Action::Answer: + if (!call::accept()) { - ui_set_overlay_active(false); + ::ui::feedback::show_notice("Call unavailable", 1800); } - return; + break; + case Action::Hangup: + call::hangup(); + break; + case Action::Decline: + call::reject(); + break; } - - lv_group_t* group = s_overlay.group; - lv_group_t* previous_group = s_overlay.previous_group; - AppScreen* owner_app = s_overlay.owner_app; - if (s_overlay.root) - { - lv_obj_del(s_overlay.root); - } - s_overlay = OverlayState{}; - - // Page-owned LVGL groups are deleted during page cleanup; only restore within - // the same active app. - const bool overlay_still_owns_input = group && lv_group_get_default() == group; - const bool previous_group_still_owned = - previous_group && owner_app == ui_get_active_app(); - if (overlay_still_owns_input && previous_group_still_owned) - { - set_default_group(previous_group); - } - else if (overlay_still_owns_input) - { - set_default_group(nullptr); - } - if (group) - { - lv_group_del(group); - } - ui_set_overlay_active(false); } -void ensure_overlay() +void action_cb(lv_event_t* event) { - normalize_top_layer(); - if (s_overlay.root && lv_obj_is_valid(s_overlay.root)) + if (!event || handle_volume_key(event) || handle_focus_key(event)) { - ui_set_overlay_active(true); - cover_display(s_overlay.root); - lv_obj_move_foreground(s_overlay.root); return; } - s_overlay.previous_group = lv_group_get_default(); - s_overlay.owner_app = ui_get_active_app(); - s_overlay.group = lv_group_create(); - if (s_overlay.group) + const lv_event_code_t code = lv_event_get_code(event); + if (code == LV_EVENT_KEY) { - set_default_group(s_overlay.group); + const uint32_t key = lv_event_get_key(event); + if (key == LV_KEY_ESC || key == LV_KEY_BACKSPACE) + { + stop_event(event); + call::hangup(); + return; + } + if (key != LV_KEY_ENTER) + { + return; + } + } + else if (code != LV_EVENT_CLICKED) + { + return; } - ui_set_overlay_active(true); - s_overlay.root = lv_obj_create(lv_layer_top()); - lv_obj_remove_style_all(s_overlay.root); - cover_display(s_overlay.root); - lv_obj_set_style_bg_color(s_overlay.root, lv_color_hex(kScrim), LV_PART_MAIN); - lv_obj_set_style_bg_opa(s_overlay.root, LV_OPA_70, LV_PART_MAIN); - lv_obj_set_style_border_width(s_overlay.root, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(s_overlay.root, 0, LV_PART_MAIN); - lv_obj_clear_flag(s_overlay.root, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_event_cb(s_overlay.root, root_key_cb, LV_EVENT_KEY, nullptr); - lv_obj_add_flag(s_overlay.root, LV_OBJ_FLAG_CLICKABLE); - add_swallow_input_callbacks(s_overlay.root); + if (static_cast(lv_tick_get() - s_page.activation_armed_ms) < 0) + { + stop_event(event); + return; + } + stop_event(event); + perform_action(static_cast( + reinterpret_cast(lv_event_get_user_data(event)))); +} - s_overlay.panel = lv_obj_create(s_overlay.root); - const bool dense = ::ui::page_profile::current().dense; - const lv_coord_t screen_w = display_width(); - lv_obj_set_size(s_overlay.panel, - clamp_panel_width(dense ? 220 : 260, screen_w), - LV_SIZE_CONTENT); - lv_obj_align(s_overlay.panel, LV_ALIGN_CENTER, 0, 0); - lv_obj_set_flex_flow(s_overlay.panel, LV_FLEX_FLOW_COLUMN); - lv_obj_set_flex_align(s_overlay.panel, +void style_button(lv_obj_t* button, + uint32_t background, + uint32_t pressed) +{ + lv_obj_set_style_radius(button, 6, LV_PART_MAIN); + lv_obj_set_style_bg_color(button, lv_color_hex(background), LV_PART_MAIN); + lv_obj_set_style_bg_color(button, lv_color_hex(pressed), LV_STATE_PRESSED); + lv_obj_set_style_bg_color(button, lv_color_hex(pressed), LV_STATE_FOCUSED); + lv_obj_set_style_border_width(button, 1, LV_PART_MAIN); + lv_obj_set_style_border_color(button, lv_color_hex(kLine), LV_PART_MAIN); + lv_obj_set_style_outline_width(button, 0, LV_STATE_FOCUSED); +} + +lv_obj_t* create_action_button(lv_obj_t* parent, + const char* text, + Action action, + uint32_t background, + uint32_t pressed) +{ + lv_obj_t* button = lv_btn_create(parent); + lv_obj_set_size(button, + ::ui::page_profile::resolve_control_button_min_width(), + ::ui::page_profile::resolve_control_button_height()); + style_button(button, background, pressed); + lv_obj_add_event_cb(button, + action_cb, + LV_EVENT_ALL, + reinterpret_cast(static_cast(action))); + + lv_obj_t* label = lv_label_create(button); + set_label(label, text, 0xFFFFFF, body_font()); + lv_obj_center(label); + if (s_page.group) + { + lv_group_add_obj(s_page.group, button); + } + return button; +} + +lv_obj_t* app_parent() +{ + if (!main_screen || !lv_obj_is_valid(main_screen) || + lv_obj_get_child_count(main_screen) < 2) + { + return nullptr; + } + return lv_obj_get_child(main_screen, 1); +} + +void enter_page(lv_obj_t* parent) +{ + if (!parent) + { + return; + } + lv_obj_clean(parent); + s_page.group = lv_group_create(); + set_default_group(s_page.group); + + s_page.root = lv_obj_create(parent); + lv_obj_remove_style_all(s_page.root); + lv_obj_set_size(s_page.root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_flex_flow(s_page.root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_page.root, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_color(s_overlay.panel, lv_color_hex(kPanelBg), LV_PART_MAIN); - lv_obj_set_style_bg_opa(s_overlay.panel, LV_OPA_COVER, LV_PART_MAIN); - lv_obj_set_style_border_width(s_overlay.panel, 1, LV_PART_MAIN); - lv_obj_set_style_border_color(s_overlay.panel, lv_color_hex(kLine), LV_PART_MAIN); - lv_obj_set_style_radius(s_overlay.panel, 8, LV_PART_MAIN); - lv_obj_set_style_pad_all(s_overlay.panel, dense ? 8 : 12, LV_PART_MAIN); - lv_obj_set_style_pad_row(s_overlay.panel, dense ? 4 : 6, LV_PART_MAIN); - lv_obj_clear_flag(s_overlay.panel, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(s_overlay.panel, LV_OBJ_FLAG_CLICKABLE); - add_swallow_input_callbacks(s_overlay.panel); + lv_obj_set_style_bg_color(s_page.root, lv_color_hex(kPageBg), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_page.root, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_pad_all(s_page.root, + ::ui::page_profile::current().dense ? 8 : 12, + LV_PART_MAIN); + lv_obj_set_style_pad_row(s_page.root, 8, LV_PART_MAIN); + lv_obj_clear_flag(s_page.root, LV_OBJ_FLAG_SCROLLABLE); - s_overlay.title = lv_label_create(s_overlay.panel); - lv_obj_set_width(s_overlay.title, LV_PCT(100)); - lv_label_set_long_mode(s_overlay.title, LV_LABEL_LONG_DOT); - lv_obj_set_style_text_align(s_overlay.title, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + s_page.title = lv_label_create(s_page.root); + lv_obj_set_width(s_page.title, LV_PCT(100)); + lv_obj_set_style_bg_color(s_page.title, lv_color_hex(kHeaderBg), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_page.title, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_pad_all(s_page.title, 8, LV_PART_MAIN); + lv_obj_set_style_text_align(s_page.title, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - s_overlay.peer = lv_label_create(s_overlay.panel); - lv_obj_set_width(s_overlay.peer, LV_PCT(100)); - lv_label_set_long_mode(s_overlay.peer, LV_LABEL_LONG_DOT); - lv_obj_set_style_text_align(s_overlay.peer, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + s_page.peer = lv_label_create(s_page.root); + lv_obj_set_width(s_page.peer, LV_PCT(100)); + lv_label_set_long_mode(s_page.peer, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(s_page.peer, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - s_overlay.status = lv_label_create(s_overlay.panel); - lv_obj_set_width(s_overlay.status, LV_PCT(100)); - lv_label_set_long_mode(s_overlay.status, LV_LABEL_LONG_WRAP); - lv_obj_set_style_text_align(s_overlay.status, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + s_page.status = lv_label_create(s_page.root); + lv_obj_set_width(s_page.status, LV_PCT(100)); + lv_label_set_long_mode(s_page.status, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(s_page.status, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - lv_obj_t* actions = lv_obj_create(s_overlay.panel); + lv_obj_t* spacer = lv_obj_create(s_page.root); + lv_obj_remove_style_all(spacer); + lv_obj_set_width(spacer, 1); + lv_obj_set_height(spacer, 0); + lv_obj_set_flex_grow(spacer, 1); + + lv_obj_t* actions = lv_obj_create(s_page.root); + lv_obj_remove_style_all(actions); lv_obj_set_width(actions, LV_PCT(100)); lv_obj_set_height(actions, ::ui::page_profile::resolve_control_button_height() + 4); @@ -578,37 +335,67 @@ void ensure_overlay() LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - lv_obj_set_style_bg_opa(actions, LV_OPA_TRANSP, LV_PART_MAIN); - lv_obj_set_style_border_width(actions, 0, LV_PART_MAIN); - lv_obj_set_style_pad_all(actions, 0, LV_PART_MAIN); - lv_obj_clear_flag(actions, LV_OBJ_FLAG_SCROLLABLE); - lv_obj_add_flag(actions, LV_OBJ_FLAG_CLICKABLE); - add_swallow_input_callbacks(actions); - s_overlay.answer_btn = make_button(actions, "Answer", kOk, 0x2F662F, answer_cb); - s_overlay.hangup_btn = make_button(actions, "Hang up", kWarn, 0x95351F, hangup_cb); - s_overlay.decline_btn = make_button(actions, "Decline", kAmber, kAmberDark, decline_cb); + s_page.answer_btn = create_action_button(actions, + "Answer", + Action::Answer, + kAccept, + kAcceptPressed); + s_page.hangup_btn = create_action_button(actions, + "Hang up", + Action::Hangup, + kDanger, + kDangerPressed); + s_page.decline_btn = create_action_button(actions, + "Decline", + Action::Decline, + kHeaderBg, + 0xC98118); - s_overlay.shortcut_hint = lv_label_create(s_overlay.panel); - lv_obj_set_width(s_overlay.shortcut_hint, LV_PCT(100)); - lv_label_set_long_mode(s_overlay.shortcut_hint, LV_LABEL_LONG_DOT); - lv_obj_set_style_text_align(s_overlay.shortcut_hint, + s_page.shortcut_hint = lv_label_create(s_page.root); + lv_obj_set_width(s_page.shortcut_hint, LV_PCT(100)); + lv_obj_set_style_text_align(s_page.shortcut_hint, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); - lv_obj_move_foreground(s_overlay.root); - s_overlay.needs_present = true; - s_overlay.activation_armed_ms = lv_tick_get() + kActivationGuardMs; + s_page.activation_armed_ms = lv_tick_get() + kActivationGuardMs; + update_shortcut_hint(); } +void exit_page(lv_obj_t* parent) +{ + if (s_page.root && lv_obj_is_valid(s_page.root)) + { + lv_obj_del(s_page.root); + } + if (s_page.group) + { + if (lv_group_get_default() == s_page.group) + { + set_default_group(nullptr); + } + lv_group_del(s_page.group); + } + const bool presented = s_page.presented; + s_page = PageState{}; + s_page.presented = presented; + (void)parent; +} + +::ui::CallbackAppScreen s_call_app("reticulum_call", + "Call", + nullptr, + enter_page, + exit_page); + bool hash_has_value(const uint8_t* hash) { if (!hash) { return false; } - for (std::size_t index = 0; index < ::platform::ui::reticulum_call::kHashSize; ++index) + for (std::size_t index = 0; index < call::kHashSize; ++index) { - if (hash[index] != 0U) + if (hash[index] != 0) { return true; } @@ -631,63 +418,60 @@ void format_hash_prefix(const char* label, static_cast(hash[3])); } -void format_peer_name(const ::platform::ui::reticulum_call::Snapshot& snapshot, +void format_peer_name(const call::Snapshot& snapshot, char* out, std::size_t out_len) { - if (!out || out_len == 0) - { - return; - } if (snapshot.peer_name[0] != '\0') { std::snprintf(out, out_len, "%s", snapshot.peer_name); - return; } - if (hash_has_value(snapshot.peer_destination_hash)) + else if (hash_has_value(snapshot.peer_destination_hash)) { format_hash_prefix("Dest", snapshot.peer_destination_hash, out, out_len); - return; } - if (hash_has_value(snapshot.peer_identity_hash)) + else if (hash_has_value(snapshot.peer_identity_hash)) { format_hash_prefix("ID", snapshot.peer_identity_hash, out, out_len); - return; } - if (hash_has_value(snapshot.link_id)) + else { - format_hash_prefix("Link", snapshot.link_id, out, out_len); - return; + std::snprintf(out, out_len, "%s", "Identifying caller"); } - std::snprintf(out, out_len, "%s", "Unknown caller"); } -void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) +void update_page(const call::Snapshot& snapshot) { - ensure_overlay(); - const auto state = snapshot.state; - const auto phase = snapshot.realtime_phase; - const bool incoming = - phase == ::platform::ui::reticulum_call::RealtimePhase::IncomingRinging; - const bool starting = - phase == ::platform::ui::reticulum_call::RealtimePhase::AcceptedStarting; - const bool active = - phase == ::platform::ui::reticulum_call::RealtimePhase::ActiveCall; - const bool closing = - phase == ::platform::ui::reticulum_call::RealtimePhase::ClosingCall; - const bool outgoing = state == ::platform::ui::reticulum_call::State::Outgoing; - const bool state_changed = s_overlay.last_state != state; + if (!s_page.root || !lv_obj_is_valid(s_page.root)) + { + return; + } - apply_label(s_overlay.title, - incoming ? "Incoming call" - : (closing ? "Closing call" - : (outgoing ? "Calling" : "Reticulum call")), - kText, - body_font()); + const bool identifying = + snapshot.realtime_phase == call::RealtimePhase::IncomingIdentifying; + const bool incoming = + snapshot.realtime_phase == call::RealtimePhase::IncomingRinging; + const bool starting = + snapshot.realtime_phase == call::RealtimePhase::AcceptedStarting; + const bool active = + snapshot.realtime_phase == call::RealtimePhase::ActiveCall; + const bool closing = + snapshot.realtime_phase == call::RealtimePhase::ClosingCall; + const bool changed = snapshot.state != s_page.last_state || + snapshot.realtime_phase != s_page.last_phase; + + set_label(s_page.title, + (identifying || incoming) ? "Incoming call" + : (closing ? "Closing call" + : (snapshot.state == call::State::Outgoing + ? "Calling" + : "Reticulum call")), + kText, + body_font()); char peer_name[48] = {}; format_peer_name(snapshot, peer_name, sizeof(peer_name)); - apply_label(s_overlay.peer, peer_name, kText, body_font()); + set_label(s_page.peer, peer_name, kText, body_font()); char status[96] = {}; if (!snapshot.wifi_ready) @@ -698,13 +482,17 @@ void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) { std::snprintf(status, sizeof(status), "%s", "Audio hardware unavailable"); } + else if (identifying) + { + std::snprintf(status, sizeof(status), "%s", "Identifying caller"); + } else if (closing) { std::snprintf(status, sizeof(status), "%s", "Hanging up"); } else if (starting) { - std::snprintf(status, sizeof(status), "%s", "Connecting call.audio"); + std::snprintf(status, sizeof(status), "%s", "Establishing audio"); } else if (active) { @@ -716,57 +504,89 @@ void update_overlay(const ::platform::ui::reticulum_call::Snapshot& snapshot) } else { - std::snprintf(status, sizeof(status), "%s", "Wi-Fi call.audio"); + std::snprintf(status, sizeof(status), "%s", "Secure call over Reticulum"); } - apply_label(s_overlay.status, status, kTextDim, caption_font()); + set_label(s_page.status, status, kTextDim, caption_font()); update_shortcut_hint(); - if (incoming) + if (identifying) { - lv_obj_clear_flag(s_overlay.answer_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(s_overlay.decline_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(s_overlay.hangup_btn, LV_OBJ_FLAG_HIDDEN); - focus_default_action(true, state_changed); + lv_obj_add_flag(s_page.answer_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_page.decline_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_page.hangup_btn, LV_OBJ_FLAG_HIDDEN); + if (changed) + { + lv_group_focus_obj(s_page.decline_btn); + } + } + else if (incoming) + { + lv_obj_clear_flag(s_page.answer_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_page.decline_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_page.hangup_btn, LV_OBJ_FLAG_HIDDEN); + if (changed) + { + lv_group_focus_obj(s_page.answer_btn); + } } else if (closing) { - lv_obj_add_flag(s_overlay.answer_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(s_overlay.decline_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(s_overlay.hangup_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_page.answer_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_page.decline_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_page.hangup_btn, LV_OBJ_FLAG_HIDDEN); } else { - lv_obj_add_flag(s_overlay.answer_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_add_flag(s_overlay.decline_btn, LV_OBJ_FLAG_HIDDEN); - lv_obj_clear_flag(s_overlay.hangup_btn, LV_OBJ_FLAG_HIDDEN); - focus_default_action(false, state_changed); + lv_obj_add_flag(s_page.answer_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(s_page.decline_btn, LV_OBJ_FLAG_HIDDEN); + lv_obj_clear_flag(s_page.hangup_btn, LV_OBJ_FLAG_HIDDEN); + if (changed) + { + lv_group_focus_obj(s_page.hangup_btn); + } } - s_overlay.last_state = state; - if (s_overlay.needs_present || state_changed) + if (s_page.group) { - s_overlay.needs_present = false; - present_overlay_now(); + lv_group_set_editing(s_page.group, false); } + s_page.last_state = snapshot.state; + s_page.last_phase = snapshot.realtime_phase; } } // namespace void tick() { - ::platform::ui::reticulum_call::service_ui_runtime(); - const auto snapshot = ::platform::ui::reticulum_call::snapshot(); - if (snapshot.realtime_phase == - ::platform::ui::reticulum_call::RealtimePhase::Idle) + call::service_ui_runtime(); + const call::Snapshot snapshot = call::snapshot(); + const bool active = snapshot.realtime_phase != call::RealtimePhase::Idle; + + if (active && !s_page.presented) { - destroy_overlay(); + lv_obj_t* parent = app_parent(); + if (parent && ui_present_interruption_app(&s_call_app, parent)) + { + s_page.presented = true; + } + } + + if (!active && s_page.presented) + { + lv_obj_t* parent = app_parent(); + s_page.presented = false; + ui_dismiss_interruption_app(parent); return; } - update_overlay(snapshot); + + if (active) + { + update_page(snapshot); + } } bool visible() { - return s_overlay.root && lv_obj_is_valid(s_overlay.root); + return s_page.presented && s_page.root && lv_obj_is_valid(s_page.root); } } // namespace ui::widgets::reticulum_call_overlay 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 f269b154..a68e7260 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 @@ -11,6 +11,7 @@ #include "chat/ports/i_mesh_adapter.h" #include "chat/ports/i_mesh_peer_directory.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_stamp_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h" #include "platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h" #include "platform/ui/reticulum_page_runtime.h" @@ -45,6 +46,8 @@ class LxmfAdapter : public IMeshAdapter MessageId forced_msg_id, const ReticulumPeerIdentity& destination) override; bool pollIncomingText(MeshIncomingText* out) override; + void commitIncomingText(const MeshIncomingText& message, + bool durably_accepted); bool sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, NodeId dest = 0, bool want_ack = false, @@ -91,6 +94,8 @@ class LxmfAdapter : public IMeshAdapter using PropagationEntry = runtime::PropagationEntry; using PropagationTransientEntry = runtime::PropagationTransientEntry; using PropagationPeerState = runtime::PropagationPeerState; + using PendingPropagationUpload = runtime::PendingPropagationUpload; + using PropagationSyncStage = runtime::PropagationSyncStage; static constexpr uint32_t kAnnounceIntervalMs = 120000; static constexpr uint32_t kInitialAnnounceDelayMs = 1500; @@ -130,6 +135,8 @@ class LxmfAdapter : public IMeshAdapter 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] = {}; }; @@ -165,6 +172,7 @@ class LxmfAdapter : public IMeshAdapter }; reticulum::interfaces::ReticulumInterfaceSet interfaces_; + uint32_t network_config_generation_ = 0; IMeshPeerDirectory* peer_directory_ = nullptr; reticulum::interfaces::RxPacket rx_packet_scratch_{}; uint8_t announce_tx_signed_scratch_[reticulum::kReticulumMtu] = {}; @@ -182,6 +190,8 @@ class LxmfAdapter : public IMeshAdapter runtime::TransportRuntime transport_; runtime::LinkRuntime links_; runtime::PropagationRuntime propagation_; + runtime::PropagationStampRuntime propagation_stamp_; + PeerInfo propagation_peer_scratch_{}; std::string user_long_name_; std::string user_short_name_; uint32_t last_announce_ms_ = 0; @@ -209,6 +219,10 @@ class LxmfAdapter : public IMeshAdapter 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] = {}; + std::size_t link_request_packet_len_ = 0; uint8_t call_wire_scratch_[reticulum::kReticulumMtu] = {}; uint32_t last_peer_projection_ms_ = 0; uint32_t next_app_packet_id_ = 1; @@ -216,6 +230,8 @@ class LxmfAdapter : public IMeshAdapter bool peers_loaded_ = false; RxMeta active_rx_meta_{}; bool has_active_rx_meta_ = false; + reticulum::interfaces::InterfaceId active_ingress_interface_id_ = + reticulum::interfaces::kInvalidInterfaceId; RuntimeBudget makeRuntimeBudget() const; void processRuntime(); @@ -269,6 +285,7 @@ class LxmfAdapter : public IMeshAdapter LinkSession* ensureOutboundLinkSession(PeerInfo& peer, LocalDestinationKind kind, bool* out_started = nullptr); + bool prepareLinkRequest(LinkSession& session); bool sendLinkRequest(LinkSession& session); bool buildSignedMessagePacket(const PeerInfo& peer, const uint8_t* packed_payload, size_t packed_payload_len, @@ -284,6 +301,30 @@ class LxmfAdapter : public IMeshAdapter size_t packed_payload_len, bool track_user_message, OutboundLxmfDispatch* out_dispatch); + bool queuePropagationUpload(PeerInfo& recipient, + const uint8_t* lxmf_message, + size_t lxmf_message_len, + MessageId message_id, + const uint8_t message_hash[reticulum::kFullHashSize], + bool track_user_message, + OutboundLxmfDispatch* out_dispatch); + void processPropagationClient(); + const PropagationPeerState* selectActivePropagationPeer(); + bool preparePropagationPeer(const PropagationPeerState& source, + PeerInfo* out_peer) const; + bool encryptForPeer(const PeerInfo& peer, + const uint8_t* plaintext, + size_t plaintext_len, + uint8_t* out_payload, + size_t* inout_len); + bool queueReadyPropagationUpload(PendingPropagationUpload& upload, + const PropagationPeerState& node); + bool sendPropagationSyncRequest(LinkSession& session, + PropagationSyncStage next_stage, + const std::vector>* wants, + const std::vector>* haves, + bool include_transfer_limit); + void processPropagationSyncResponse(LinkSession& session); bool respondToSidebandTelemetryRequest( PeerInfo& peer, const SidebandTelemetryRequest& request); @@ -296,7 +337,8 @@ class LxmfAdapter : public IMeshAdapter bool sendCachedAnnounceResponse(const PathEntry& path, reticulum::PacketContext context); bool sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kFullHashSize]); - bool shouldProcessWifiIngressPacket(const reticulum::ParsedPacket& packet); + bool shouldProcessWifiIngressPacket(const reticulum::ParsedPacket& packet, + const RuntimeBudget& budget); bool shouldLogRxDetail(const reticulum::ParsedPacket& packet, reticulum::interfaces::InterfaceKind ingress_interface, const RuntimeBudget& budget); @@ -316,6 +358,7 @@ class LxmfAdapter : public IMeshAdapter bool isDuplicatePacket(const uint8_t packet_hash[reticulum::kFullHashSize]); void rememberPacket(const uint8_t packet_hash[reticulum::kFullHashSize]); void rememberReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize], + reticulum::interfaces::InterfaceId interface_id, uint8_t expected_hops); ReverseEntry* findReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize]); PendingPathRequest* findPendingPathRequest( @@ -386,7 +429,8 @@ class LxmfAdapter : public IMeshAdapter reticulum::PacketContext context, const uint8_t* payload, size_t payload_len, bool encrypt_payload, - bool call_admission_control = false); + bool call_admission_control = false, + uint8_t out_packet_hash[reticulum::kFullHashSize] = nullptr); bool sendNomadPageRequestPacket(LinkSession& session, PendingNomadPageRequest& request); MeshActionResult sendReticulumPingToPeer(PeerInfo& peer, @@ -432,6 +476,9 @@ class LxmfAdapter : public IMeshAdapter bool sendLxstSignal(LinkSession& session, uint16_t signal, bool call_admission_control = false); + bool dispatchLxstCallEvent( + LinkSession& session, + const reticulum::lxst::call::Event& event); bool handleLxstPacket(LinkSession& session, const uint8_t* payload, size_t payload_len); @@ -450,14 +497,18 @@ class LxmfAdapter : public IMeshAdapter const uint8_t* raw_packet, size_t raw_len, const reticulum::ParsedPacket& packet); bool acceptVerifiedEnvelope(const uint8_t* plaintext, size_t plaintext_len, - const uint8_t* raw_packet, size_t raw_len); + const uint8_t* raw_packet, size_t raw_len, + uint8_t* out_message_hash = nullptr, + bool* out_awaiting_commit = nullptr); bool acceptVerifiedEnvelopeForDestination( const uint8_t expected_destination_hash[reticulum::kTruncatedHashSize], const ReticulumPeerIdentity& conversation_identity, bool destination_is_group, bool encrypted, const uint8_t* plaintext, size_t plaintext_len, - const uint8_t* raw_packet, size_t raw_len); + const uint8_t* raw_packet, size_t raw_len, + uint8_t* out_message_hash = nullptr, + bool* out_awaiting_commit = nullptr); bool handleLinkResourceAdvertisement(LinkSession& session, const uint8_t* plaintext, size_t plaintext_len); bool handleLinkResourceRequest(LinkSession& session, @@ -475,7 +526,9 @@ class LxmfAdapter : public IMeshAdapter const uint8_t* request_id, size_t request_id_len); bool acceptPropagatedDelivery(const uint8_t* propagated_payload, - size_t propagated_payload_len); + size_t propagated_payload_len, + uint8_t* out_message_hash = nullptr, + bool* out_awaiting_commit = nullptr); bool requestNextResourceWindow(LinkSession& session, LinkResourceTransfer& resource); bool advertiseLinkResource(LinkSession& session, @@ -485,7 +538,8 @@ class LxmfAdapter : public IMeshAdapter const uint8_t* data, size_t len, uint8_t flags, const uint8_t* request_id, - size_t request_id_len); + size_t request_id_len, + uint32_t message_id = 0); bool sendLinkResponse(LinkSession& session, const uint8_t* request_id, size_t request_id_len, 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 new file mode 100644 index 00000000..f63ba091 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h @@ -0,0 +1,49 @@ +/** + * @file lxmf_call_profile.h + * @brief Single embedded LXST media-profile mapping for the Reticulum client + */ + +#pragma once + +#include "chat/domain/chat_types.h" +#include "chat/infra/reticulum/audio_call_wire.h" +#include "chat/infra/reticulum/lxst_telephony_wire.h" +#include "platform/ui/reticulum_call_runtime.h" + +#include + +namespace chat::lxmf::call_profile +{ + +inline constexpr uint16_t kEmbeddedLxstProfile = + reticulum::lxst::kProfileBandwidthLow; + +inline reticulum::audio_call::Codec2Mode audioCodec2Mode( + uint16_t lxst_profile) +{ + (void)lxst_profile; + return reticulum::audio_call::Codec2Mode::Mode3200; +} + +inline ::platform::ui::reticulum_call::Codec2Mode runtimeCodec2Mode( + ReticulumCallWireProfile wire_profile, + uint16_t lxst_profile) +{ + if (wire_profile == ReticulumCallWireProfile::MeshChatCallAudio) + { + return ::platform::ui::reticulum_call::Codec2Mode::Mode1200; + } + + (void)lxst_profile; + return ::platform::ui::reticulum_call::Codec2Mode::Mode3200; +} + +inline ::platform::ui::reticulum_call::WireProfile runtimeWireProfile( + ReticulumCallWireProfile wire_profile) +{ + return wire_profile == ReticulumCallWireProfile::MeshChatCallAudio + ? ::platform::ui::reticulum_call::WireProfile::MeshChatCallAudio + : ::platform::ui::reticulum_call::WireProfile::SidebandLxst; +} + +} // namespace chat::lxmf::call_profile diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h index 3dc19326..532ee129 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h @@ -28,6 +28,8 @@ struct LxmfDeliveryContext NodeId peer_node_id = 0; NodeId local_node_id = 0; MessageId message_id = 0; + bool has_message_hash = false; + uint8_t message_hash[kReticulumLxmfHashSize] = {}; uint32_t timestamp_s = 0; ReticulumPeerIdentity peer_identity{}; ReticulumPeerIdentity conversation_identity{}; diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_runtime.h index c1725132..406c4ff6 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_runtime.h @@ -52,6 +52,12 @@ PropagationPeerState& upsertPropagationPeer( std::size_t max_peers); void markPropagationPeerSeen(PropagationPeerState& peer, uint32_t now_s); +const PropagationPeerState* selectPropagationPeer( + const PropagationRuntime& propagation, + bool automatic, + const uint8_t configured_hash[reticulum::kTruncatedHashSize], + uint32_t now_s, + uint32_t peer_ttl_s); void notePropagationPeerIncomingMessage(PropagationPeerState& peer); void notePropagationPeerServedMessages(PropagationPeerState& peer, uint32_t served_count); @@ -67,6 +73,21 @@ void rememberPropagationTransient( uint32_t now_s, std::size_t max_transients); +bool awaitPropagationDeliveryCommit( + PropagationRuntime& propagation, + const uint8_t transient_id[reticulum::kFullHashSize], + const uint8_t message_hash[reticulum::kFullHashSize], + std::size_t max_pending); +bool commitPropagationDelivery( + PropagationRuntime& propagation, + const uint8_t message_hash[reticulum::kFullHashSize], + bool accepted, + uint32_t now_s, + std::size_t max_transients); +bool propagationDeliveryCommitsResolved(const PropagationRuntime& propagation); +bool propagationDeliveryCommitRejected(const PropagationRuntime& propagation); +void clearPropagationDeliveryCommits(PropagationRuntime& propagation); + bool rememberPropagationEntry( PropagationRuntime& propagation, const uint8_t transient_id[reticulum::kFullHashSize], diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_stamp_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_stamp_runtime.h new file mode 100644 index 00000000..229a294b --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_stamp_runtime.h @@ -0,0 +1,71 @@ +/** + * @file lxmf_propagation_stamp_runtime.h + * @brief Incremental official LXMF propagation stamp generation for ESP + */ + +#pragma once + +#include "chat/infra/reticulum/reticulum_wire.h" + +#include +#include + +#include + +namespace chat::lxmf::runtime +{ + +class PropagationStampRuntime +{ + public: + enum class State : uint8_t + { + Idle = 0, + Expanding = 1, + Searching = 2, + Complete = 3, + Failed = 4, + }; + + PropagationStampRuntime(); + ~PropagationStampRuntime(); + + PropagationStampRuntime(const PropagationStampRuntime&) = delete; + PropagationStampRuntime& operator=(const PropagationStampRuntime&) = delete; + + bool begin(const uint8_t transient_id[reticulum::kFullHashSize], + uint8_t target_cost); + State poll(uint16_t expand_round_budget = 4, + uint16_t search_round_budget = 256); + bool takeStamp(uint8_t out_stamp[reticulum::kFullHashSize]); + void reset(); + + State state() const { return state_; } + uint16_t expandedRounds() const { return expanded_rounds_; } + uint32_t searchRounds() const { return search_rounds_; } + uint8_t targetCost() const { return target_cost_; } + + private: + static constexpr uint16_t kExpandRounds = 1000; + static constexpr std::size_t kRoundBytes = 256; + static constexpr std::size_t kWorkblockBytes = + kExpandRounds * kRoundBytes; + + bool expandOneRound(); + bool prepareSearch(); + bool searchOneRound(); + static bool meetsTarget(const uint8_t hash[reticulum::kFullHashSize], + uint8_t target_cost); + + uint8_t* workblock_ = nullptr; + uint8_t transient_id_[reticulum::kFullHashSize] = {}; + uint8_t stamp_[reticulum::kFullHashSize] = {}; + mbedtls_sha256_context base_hash_{}; + State state_ = State::Idle; + uint16_t expanded_rounds_ = 0; + uint32_t search_rounds_ = 0; + uint8_t target_cost_ = 0; + bool base_hash_ready_ = false; +}; + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h index 76bebb03..b2baccc9 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_runtime_state.h @@ -7,6 +7,7 @@ #include "chat/domain/chat_types.h" #include "chat/infra/lxmf/lxmf_wire.h" +#include "chat/infra/reticulum/lxst_call_state_machine.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" #include @@ -44,6 +45,7 @@ struct PathEntry size_t cached_announce_len = 0; uint8_t announce_random_blobs[kPathRandomBlobHistory][kAnnounceRandomBlobSize] = {}; uint8_t announce_random_blob_count = 0; + uint8_t interface_id = 0; uint8_t hops = 0; uint32_t last_seen_s = 0; uint32_t updated_ms = 0; @@ -60,6 +62,7 @@ struct PacketFilterEntry struct ReverseEntry { uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t interface_id = 0; uint8_t expected_hops = 0; uint32_t created_ms = 0; }; @@ -82,9 +85,21 @@ struct PendingPingReceipt uint32_t created_ms = 0; }; +struct PendingDeliveryReceipt +{ + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t peer_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + MessageId message_id = 0; + uint32_t created_ms = 0; +}; + struct LinkRelayEntry { uint8_t link_id[reticulum::kTruncatedHashSize] = {}; + uint8_t initiator_interface_id = 0; + uint8_t responder_interface_id = 0; uint8_t initiator_hops = 0; uint8_t responder_hops = 0; uint32_t last_seen_ms = 0; @@ -133,6 +148,13 @@ struct DeferredLinkPayload uint8_t resource_flags = 0; }; +struct LinkPacketReceipt +{ + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + uint32_t message_id = 0; + uint32_t created_ms = 0; +}; + struct LinkResourceTransfer { uint8_t resource_hash[reticulum::kFullHashSize] = {}; @@ -155,6 +177,7 @@ struct LinkResourceTransfer uint32_t total_segments = 1; uint32_t created_ms = 0; uint32_t last_activity_ms = 0; + uint32_t message_id = 0; uint8_t flags = 0; bool incoming = true; bool encrypted = false; @@ -201,6 +224,7 @@ struct LinkSession uint32_t keepalive_interval_ms = 15000; uint32_t stale_timeout_ms = 30000; uint32_t last_keepalive_ms = 0; + uint8_t interface_id = 0; uint8_t expected_hops = 0; bool initiator = false; bool local_identity_sent = false; @@ -208,18 +232,15 @@ struct LinkSession bool validated = false; ReticulumCallWireProfile call_wire_profile = ReticulumCallWireProfile::SidebandLxst; - uint16_t lxst_profile = 0x30; - uint16_t lxst_local_status = 0x02; - uint16_t lxst_remote_status = 0x02; - uint32_t call_status_ms = 0; + reticulum::lxst::call::State lxst_call{}; bool call_runtime_started = false; - bool lxst_preferred_sent = false; LocalDestinationKind destination = LocalDestinationKind::Delivery; LinkState state = LinkState::Pending; LinkCloseReason close_reason = LinkCloseReason::None; bool propagation_offer_validated = false; std::vector pending_requests; std::vector deferred_payloads; + std::vector pending_packet_receipts; std::vector incoming_resources; std::vector incoming_resource_assemblies; std::vector outgoing_resources; @@ -241,14 +262,78 @@ struct PropagationTransientEntry bool delivered = false; }; +enum class PropagationUploadState : uint8_t +{ + WaitingNode = 0, + NeedsStamp = 1, + Stamping = 2, + Ready = 3, + QueuedToLink = 4, + Failed = 5, +}; + +struct PendingPropagationUpload +{ + uint8_t node_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t message_hash[reticulum::kFullHashSize] = {}; + uint8_t transient_id[reticulum::kFullHashSize] = {}; + std::vector transient_data; + uint32_t created_ms = 0; + uint32_t message_id = 0; + uint8_t stamp_cost = 0; + PropagationUploadState state = PropagationUploadState::WaitingNode; + bool track_user_message = false; +}; + +enum class PropagationDeliveryCommitState : uint8_t +{ + AwaitingPersistence = 0, + Accepted = 1, + Rejected = 2, +}; + +struct PendingPropagationDelivery +{ + uint8_t transient_id[reticulum::kFullHashSize] = {}; + uint8_t message_hash[reticulum::kFullHashSize] = {}; + PropagationDeliveryCommitState state = + PropagationDeliveryCommitState::AwaitingPersistence; +}; + +enum class PropagationSyncStage : uint8_t +{ + Idle = 0, + NeedList = 1, + Listing = 2, + NeedMessages = 3, + Downloading = 4, + AwaitingPersistence = 5, + NeedAcknowledge = 6, + Acknowledging = 7, + Complete = 8, + Failed = 9, +}; + struct PropagationPeerState { uint8_t propagation_hash[reticulum::kTruncatedHashSize] = {}; uint8_t delivery_hash[reticulum::kTruncatedHashSize] = {}; uint8_t identity_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t enc_pub[LxmfIdentity::kEncPubKeySize] = {}; + uint8_t sig_pub[LxmfIdentity::kSigPubKeySize] = {}; + char display_name[32] = {}; + uint32_t announce_timebase_s = 0; uint32_t last_seen_s = 0; + uint32_t transfer_limit_kb = 0; + uint32_t sync_limit_kb = 0; uint32_t incoming_messages = 0; uint32_t served_messages = 0; + uint8_t hops = 0; + uint8_t stamp_cost = 0; + uint8_t stamp_cost_flexibility = 0; + uint8_t peering_cost = 0; + bool node_active = false; }; struct TransportRuntime @@ -258,6 +343,7 @@ struct TransportRuntime std::vector reverse_table; std::vector pending_path_requests; std::vector pending_ping_receipts; + std::vector pending_delivery_receipts; std::vector link_relays; }; @@ -271,6 +357,18 @@ struct PropagationRuntime std::vector entries; std::vector transients; std::vector peers; + std::vector pending_uploads; + std::vector pending_deliveries; + std::vector> sync_wants; + std::vector> sync_haves; + uint8_t active_node_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t sync_request_id[reticulum::kTruncatedHashSize] = {}; + uint32_t last_sync_s = 0; + uint32_t sync_started_ms = 0; + uint32_t persistence_started_ms = 0; + PropagationSyncStage sync_stage = PropagationSyncStage::Idle; + bool has_active_node = false; + bool initial_sync_pending = true; }; } // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h index 091175f3..d7a4fac5 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_transport_runtime.h @@ -27,6 +27,8 @@ struct TransportRuntimeLimits std::size_t max_pending_ping_receipts = 0; uint32_t path_ttl_ms = 0; uint32_t pending_ping_receipt_ttl_ms = 0; + std::size_t max_pending_delivery_receipts = 0; + uint32_t pending_delivery_receipt_ttl_ms = 0; }; enum class PathAnnounceDecision : uint8_t @@ -63,6 +65,7 @@ void forgetPacket(TransportRuntime& transport, void rememberReversePath(TransportRuntime& transport, const uint8_t proof_hash[reticulum::kTruncatedHashSize], + uint8_t interface_id, uint8_t expected_hops, uint32_t now_ms, std::size_t max_reverse_entries); @@ -96,6 +99,21 @@ void removePendingPingReceipt( TransportRuntime& transport, const uint8_t proof_hash[reticulum::kTruncatedHashSize]); +void notePendingDeliveryReceipt( + TransportRuntime& transport, + const uint8_t packet_hash[reticulum::kFullHashSize], + const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t peer_sig_pub[LxmfIdentity::kSigPubKeySize], + MessageId message_id, + uint32_t now_ms, + std::size_t max_pending_delivery_receipts); +PendingDeliveryReceipt* findPendingDeliveryReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]); +void removePendingDeliveryReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]); + PathEntry& upsertPath(TransportRuntime& transport, const uint8_t destination_hash[reticulum::kTruncatedHashSize], std::size_t max_paths); diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h index ad98085f..0c2e6e55 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/mesh_adapter_router.h @@ -7,6 +7,7 @@ #include "chat/domain/chat_types.h" #include "chat/infra/mesh_adapter_router_core.h" +#include "chat/ports/i_incoming_delivery_commit_port.h" #include "chat/ports/i_mesh_adapter.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -14,7 +15,8 @@ namespace chat { -class MeshAdapterRouter : public IMeshAdapter +class MeshAdapterRouter : public IMeshAdapter, + public IIncomingDeliveryCommitPort { public: MeshAdapterRouter(); @@ -41,6 +43,9 @@ class MeshAdapterRouter : public IMeshAdapter MessageId forced_msg_id, const ReticulumPeerIdentity& destination) override; bool pollIncomingText(MeshIncomingText* out) override; + IIncomingDeliveryCommitPort* incomingDeliveryCommitPort() override; + void commitIncomingText(const MeshIncomingText& message, + bool durably_accepted) override; bool sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, NodeId dest = 0, bool want_ack = false, diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h index fc548dbb..b09cfd0a 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_adapter.h @@ -10,6 +10,7 @@ #pragma once +#include "chat/ports/i_incoming_delivery_commit_port.h" #include "chat/ports/i_mesh_adapter.h" #include "chat/ports/i_mesh_peer_directory.h" @@ -27,7 +28,8 @@ namespace chat::reticulum namespace lxmf = ::chat::lxmf; -class ReticulumAdapter final : public IMeshAdapter +class ReticulumAdapter final : public IMeshAdapter, + public IIncomingDeliveryCommitPort { public: explicit ReticulumAdapter(LoraBoard& board, @@ -49,6 +51,9 @@ class ReticulumAdapter final : public IMeshAdapter MessageId forced_msg_id, const ReticulumPeerIdentity& destination) override; bool pollIncomingText(MeshIncomingText* out) override; + IIncomingDeliveryCommitPort* incomingDeliveryCommitPort() override; + void commitIncomingText(const MeshIncomingText& message, + bool durably_accepted) override; bool sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, NodeId dest = 0, bool want_ack = false, diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h index dc27d6be..1c469f8f 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/reticulum/reticulum_interfaces.h @@ -7,10 +7,12 @@ #include "board/LoraBoard.h" #include "chat/domain/chat_types.h" +#include "chat/domain/reticulum_network_config.h" #include "chat/infra/reticulum/reticulum_wire.h" #include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" #include "sys/ringbuf.h" +#include #include #include @@ -37,14 +39,22 @@ enum class InterfaceKind : uint8_t { LoRa = 0, WifiGateway = 1, + Auto = 2, }; +using InterfaceId = uint8_t; +constexpr InterfaceId kInvalidInterfaceId = 0; +constexpr InterfaceId kLoRaInterfaceId = 1; +constexpr InterfaceId kAutoInterfaceIdBase = 16; +constexpr InterfaceId kTcpClientInterfaceIdBase = 32; + struct RxPacket { uint8_t data[reticulum::kReticulumMtu] = {}; size_t len = 0; RxMeta rx_meta{}; InterfaceKind interface_kind = InterfaceKind::LoRa; + InterfaceId interface_id = kInvalidInterfaceId; }; struct TxResult @@ -55,6 +65,8 @@ struct TxResult bool wifi_required = false; bool wifi_ready = false; bool wifi_ok = false; + InterfaceId sent_interface = kInvalidInterfaceId; + uint8_t sent_count = 0; bool sent() const { return lora_ok || wifi_ok; } bool reachedRequiredInterfaces() const @@ -70,7 +82,7 @@ class LoRaReticulumInterface public: explicit LoRaReticulumInterface(LoraBoard& board); - void applyConfig(const MeshConfig& config); + void applyConfig(const MeshConfig& config, bool enabled); bool isReady() const; bool sendPacket(const uint8_t* data, size_t len); bool pollPacket(RxPacket* out); @@ -90,7 +102,9 @@ class WifiGatewayReticulumInterface public: WifiGatewayReticulumInterface(); - void applyConfig(const MeshConfig& config); + void applyConfig(const reticulum::NetworkInterfaceConfig* config, + bool auto_connect_wifi, + InterfaceId interface_id); void setTransportEnabled(bool enabled); void maintain(); bool isReady() const; @@ -100,6 +114,7 @@ class WifiGatewayReticulumInterface const uint8_t* call_link_id = nullptr, bool call_admission_control = false); bool pollPacket(RxPacket* out); + InterfaceId interfaceId() const { return interface_id_; } const char* host() const { return host_; } uint16_t port() const { return port_; } @@ -124,6 +139,7 @@ class WifiGatewayReticulumInterface bool enabled_ = false; bool transport_enabled_ = true; bool auto_connect_wifi_ = true; + InterfaceId interface_id_ = kInvalidInterfaceId; char host_[kReticulumGatewayHostMaxLen + 1] = {}; uint16_t port_ = 4242; bool socket_online_ = false; @@ -165,22 +181,104 @@ class WifiGatewayReticulumInterface void fillRxMeta(RxMeta* out) const; }; +class AutoReticulumInterface +{ + public: + AutoReticulumInterface(); + + void applyConfig(const reticulum::NetworkInterfaceConfig* config, + bool auto_connect_wifi); + void setTransportEnabled(bool enabled); + void maintain(); + bool isReady() const; + bool isConfigured() const; + bool sendPacket(const uint8_t* data, + size_t len, + const uint8_t* call_link_id = nullptr, + bool call_admission_control = false); + bool sendPacketOn(InterfaceId interface_id, + const uint8_t* data, + size_t len, + const uint8_t* call_link_id = nullptr, + bool call_admission_control = false); + bool pollPacket(RxPacket* out); + bool owns(InterfaceId interface_id) const; + uint8_t peerCount() const; + + private: + struct Peer + { + uint8_t address[16] = {}; + uint32_t scope_id = 0; + uint32_t last_seen_ms = 0; + uint32_t last_reverse_announce_ms = 0; + InterfaceId interface_id = kInvalidInterfaceId; + bool active = false; + }; + + static constexpr size_t kMaxPeers = 6; + static constexpr size_t kRxQueueDepth = 8; + static constexpr uint32_t kAnnounceIntervalMs = 1600; + static constexpr uint32_t kPeerTimeoutMs = 22000; + static constexpr uint32_t kReverseAnnounceIntervalMs = 5200; + + bool enabled_ = false; + bool transport_enabled_ = true; + bool auto_connect_wifi_ = true; + bool sockets_ready_ = false; + char group_id_[reticulum::kAutoInterfaceGroupMaxLen + 1] = "reticulum"; + uint16_t discovery_port_ = 29716; + uint16_t data_port_ = 42671; + int discovery_socket_ = -1; + int unicast_discovery_socket_ = -1; + int data_socket_ = -1; + uint32_t interface_index_ = 0; + uint8_t local_address_[16] = {}; + uint8_t multicast_address_[16] = {}; + uint8_t discovery_token_[reticulum::kFullHashSize] = {}; + uint32_t last_announce_ms_ = 0; + uint32_t last_socket_attempt_ms_ = 0; + uint32_t last_wifi_connect_ms_ = 0; + std::array peers_{}; + RxPacket rx_scratch_{}; + sys::RingBuffer rx_queue_; + + void stop(); + bool ensureSockets(); + void closeSockets(); + void calculateDiscoveryIdentity(); + void sendPeerAnnounce(); + void sendReverseAnnounce(Peer& peer); + void receiveDiscovery(int socket_fd); + void receiveData(); + void cullPeers(uint32_t now_ms); + Peer* upsertPeer(const uint8_t address[16], uint32_t scope_id); + Peer* findPeer(InterfaceId interface_id); +}; + class ReticulumInterfaceSet { public: explicit ReticulumInterfaceSet(LoraBoard& board); - void applyConfig(const MeshConfig& config); + void applyConfig(const MeshConfig& config, + const reticulum::ReticulumNetworkConfig& network_config); void setWifiTransportEnabled(bool enabled); void maintain(); bool hasReadyInterface() const; bool hasReadyWifiGateway() const; bool wifiGatewayConfigured() const; bool sendPacket(const uint8_t* data, size_t len); + bool sendPacketOn(InterfaceId interface_id, + const uint8_t* data, + size_t len, + const uint8_t* call_link_id = nullptr, + bool call_admission_control = false); bool sendPacketWifiOnly(const uint8_t* data, size_t len, const uint8_t* call_link_id = nullptr, - bool call_admission_control = false); + bool call_admission_control = false, + InterfaceId interface_id = kInvalidInterfaceId); bool pollIncomingPacket(RxPacket* out); bool pollLegacyIncomingData(MeshIncomingData* out); void handleRawPacket(const uint8_t* data, size_t size); @@ -192,18 +290,21 @@ class ReticulumInterfaceSet private: LoRaReticulumInterface lora_; - WifiGatewayReticulumInterface wifi_; + AutoReticulumInterface auto_; + std::array + tcp_{}; MeshConfig config_{}; + reticulum::ReticulumNetworkConfig network_config_{}; RxMeta last_rx_meta_{}; TxResult last_tx_result_{}; bool has_last_rx_meta_ = false; + uint8_t tcp_count_ = 0; uint8_t next_poll_index_ = 0; bool shared_lora_rx_suppressed_ = false; - bool loraAllowed() const; - bool wifiAllowed() const; - bool loraSelectedForRuntime() const; - bool wifiSelectedForRuntime() const; + bool loraEnabled() const; + bool hasConfiguredIpInterface() const; void syncSharedLoRaRxGate(); }; 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 864645d9..d952e892 100644 --- a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/store/sd_store.h @@ -22,7 +22,9 @@ class SdStore final : public IChatStore public: static constexpr const char* kDir = "/chat"; static constexpr const char* kIndexFile = "/chat/index.bin"; + static constexpr const char* kReticulumLxmfSeenFile = "/chat/lxmf_seen.bin"; static constexpr size_t kMaxMessagesPerConv = 1000; + static constexpr size_t kMaxReticulumLxmfSeen = 1024; static constexpr size_t kMaxTextLen = 233; static constexpr size_t kPreviewLen = 48; @@ -32,6 +34,7 @@ class SdStore final : public IChatStore bool isReady() const { return ready_; } void append(const ChatMessage& msg) override; + bool appendIncomingDurably(const ChatMessage& msg) override; std::vector loadRecent(const ConversationId& conv, size_t n) override; std::vector loadPageFromLatest(const ConversationId& conv, size_t offset_from_latest, @@ -46,6 +49,7 @@ class SdStore final : public IChatStore void clearAll() override; bool updateMessageStatus(MessageId msg_id, MessageStatus status) override; bool getMessage(MessageId msg_id, ChatMessage* out) const override; + bool hasReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const override; void flush() override; private: @@ -87,6 +91,23 @@ class SdStore final : public IChatStore char text[kMaxTextLen] = {}; } __attribute__((packed)); + struct RecordV4 + { + uint8_t protocol = 0; + uint8_t channel = 0; + uint8_t status = 0; + uint8_t flags = 0; + uint8_t rx_origin = 0; + uint16_t text_len = 0; + uint32_t from = 0; + uint32_t peer = 0; + uint32_t msg_id = 0; + uint32_t timestamp = 0; + uint8_t reticulum_destination_hash[kReticulumPeerHashSize] = {}; + uint8_t reticulum_identity_hash[kReticulumPeerHashSize] = {}; + char text[kMaxTextLen] = {}; + } __attribute__((packed)); + struct Record { uint8_t protocol = 0; @@ -101,6 +122,7 @@ class SdStore final : public IChatStore uint32_t timestamp = 0; uint8_t reticulum_destination_hash[kReticulumPeerHashSize] = {}; uint8_t reticulum_identity_hash[kReticulumPeerHashSize] = {}; + uint8_t reticulum_lxmf_hash[kReticulumLxmfHashSize] = {}; char text[kMaxTextLen] = {}; } __attribute__((packed)); @@ -142,12 +164,28 @@ class SdStore final : public IChatStore char preview[kPreviewLen] = {}; } __attribute__((packed)); - static constexpr uint32_t kFileMagic = 0x474F4C43; // "CLOG" - static constexpr uint32_t kIndexMagic = 0x54414843; // "CHAT" + struct LxmfSeenHeader + { + uint32_t magic = 0; + uint16_t version = 0; + uint16_t head = 0; + uint16_t count = 0; + } __attribute__((packed)); + + struct LxmfSeenRecord + { + 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 uint16_t kLegacyVersion = 2; static constexpr uint16_t kReticulumIdentityVersion = 3; - static constexpr uint16_t kFileVersion = 4; + static constexpr uint16_t kRxOriginVersion = 4; + static constexpr uint16_t kFileVersion = 5; static constexpr uint16_t kIndexVersion = 3; + static constexpr uint16_t kLxmfSeenVersion = 1; bool ensureFs() const; bool ensureDir() const; @@ -177,6 +215,22 @@ class SdStore final : public IChatStore bool writeRecord(::platform::esp::arduino_common::storage::SdRuntimeFile& file, uint16_t slot, const Record& rec) const; + bool initLxmfSeenFile(::platform::esp::arduino_common::storage::SdRuntimeFile& file) const; + bool loadLxmfSeenHeader(::platform::esp::arduino_common::storage::SdRuntimeFile& file, + LxmfSeenHeader& header) const; + bool readLxmfSeenRecord(::platform::esp::arduino_common::storage::SdRuntimeFile& file, + uint16_t slot, + LxmfSeenRecord& rec) const; + bool writeLxmfSeenRecord(::platform::esp::arduino_common::storage::SdRuntimeFile& file, + uint16_t slot, + const LxmfSeenRecord& rec) const; + bool rememberReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const; + bool conversationContainsReticulumLxmfHash( + ::platform::esp::arduino_common::storage::SdRuntimeFile& file, + const FileHeader& header, + const uint8_t* lxmf_hash, + bool* found) const; + bool appendInternal(const ChatMessage& msg); bool openConversationForUpdate(const ConversationId& conv, ::platform::esp::arduino_common::storage::SdRuntimeFile& file, FileHeader& header) const; diff --git a/platform/esp/arduino_common/include/sys/event_bus.h b/platform/esp/arduino_common/include/sys/event_bus.h index d275d252..e9d90dc4 100644 --- a/platform/esp/arduino_common/include/sys/event_bus.h +++ b/platform/esp/arduino_common/include/sys/event_bus.h @@ -19,6 +19,25 @@ namespace sys { +inline void copy_bounded_text(char* out, size_t out_len, const char* text) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!text) + { + return; + } + const size_t max_copy_len = out_len - 1U; + const auto* terminator = static_cast(std::memchr(text, '\0', max_copy_len)); + const size_t copy_len = + terminator ? static_cast(terminator - text) : max_copy_len; + std::memcpy(out, text, copy_len); + out[copy_len] = '\0'; +} + /** * @brief Event types */ @@ -100,9 +119,16 @@ struct ChatSendResultEvent : public Event { uint32_t msg_id; bool success; + chat::MessageStatus status; ChatSendResultEvent(uint32_t id, bool ok) - : Event(EventType::ChatSendResult), msg_id(id), success(ok) {} + : Event(EventType::ChatSendResult), msg_id(id), success(ok), + status(ok ? chat::MessageStatus::Sent : chat::MessageStatus::Failed) {} + + ChatSendResultEvent(uint32_t id, chat::MessageStatus result_status) + : Event(EventType::ChatSendResult), msg_id(id), + success(result_status != chat::MessageStatus::Failed), + status(result_status) {} }; enum class ReticulumPingResult : uint8_t @@ -194,8 +220,7 @@ struct NodeInfoUpdateEvent : public Event { if (sname) { - strncpy(short_name, sname, sizeof(short_name) - 1); - short_name[sizeof(short_name) - 1] = '\0'; + copy_bounded_text(short_name, sizeof(short_name), sname); } else { @@ -203,8 +228,7 @@ struct NodeInfoUpdateEvent : public Event } if (lname) { - strncpy(long_name, lname, sizeof(long_name) - 1); - long_name[sizeof(long_name) - 1] = '\0'; + copy_bounded_text(long_name, sizeof(long_name), lname); } else { @@ -359,8 +383,7 @@ struct KeyVerificationFinalEvent : public Event { if (code) { - strncpy(verification_code, code, sizeof(verification_code) - 1); - verification_code[sizeof(verification_code) - 1] = '\0'; + copy_bounded_text(verification_code, sizeof(verification_code), code); } else { diff --git a/platform/esp/arduino_common/src/app_config_store.cpp b/platform/esp/arduino_common/src/app_config_store.cpp index d8412f9e..9594c0bb 100644 --- a/platform/esp/arduino_common/src/app_config_store.cpp +++ b/platform/esp/arduino_common/src/app_config_store.cpp @@ -67,7 +67,6 @@ constexpr const char* kChatKeyRtWifiHost = "rt_wifi_host"; constexpr const char* kChatKeyRtWifiPort = "rt_wifi_port"; constexpr const char* kChatKeyRtIfacePolicy = "rt_if_policy"; constexpr const char* kChatKeyRtAnonPeer = "rt_anon_peer"; -constexpr const char* kChatKeyRtCallWire = "rt_call_wire"; constexpr const char* kChatKeyRtLocationRequests = "rt_loc_req"; constexpr const char* kGpsKeyInitBaud = "init_baud"; constexpr const char* kGpsKeyInitProbeMs = "init_probe_ms"; @@ -882,7 +881,7 @@ void log_config_summary(const char* phase, const AppConfig& config) bool_label(config.aprs.self_enable), config.aprs.self_callsign, static_cast(config.aprs.node_map_len)); - Serial.printf("[AppCfg][%s][reticulum_if] lora=%s wifi=%s auto_wifi=%s anonymous=%s host=%s port=%u policy=%u call_wire=%u location_requests=%s\n", + Serial.printf("[AppCfg][%s][reticulum_if] lora=%s wifi=%s auto_wifi=%s anonymous=%s host=%s port=%u policy=%u call=sideband_lxst location_requests=%s\n", safe_label(phase), bool_label(config.rnode_config.reticulum_lora_enabled), bool_label(config.rnode_config.reticulum_wifi_gateway_enabled), @@ -894,8 +893,6 @@ void log_config_summary(const char* phase, const AppConfig& config) static_cast(config.rnode_config.reticulum_wifi_gateway_port), static_cast(static_cast( config.rnode_config.reticulum_interface_policy)), - static_cast(static_cast( - config.rnode_config.reticulum_call_wire_profile)), bool_label(config.rnode_config.reticulum_allow_location_requests)); } @@ -1173,15 +1170,6 @@ bool loadAppConfigFromPreferences(AppConfig& config, rnode_config.reticulum_interface_policy = static_cast(reticulum_interface_policy); } - const uint8_t reticulum_call_wire = - get_uchar(kChatKeyRtCallWire, - static_cast(rnode_config.reticulum_call_wire_profile)); - if (reticulum_call_wire <= - static_cast(chat::ReticulumCallWireProfile::MeshChatCallAudio)) - { - rnode_config.reticulum_call_wire_profile = - static_cast(reticulum_call_wire); - } rnode_config.reticulum_allow_location_requests = get_bool(kChatKeyRtLocationRequests, rnode_config.reticulum_allow_location_requests); @@ -1628,8 +1616,6 @@ bool saveAppConfigToPreferences(const AppConfig& config, : 4242); put_uchar(kChatKeyRtIfacePolicy, static_cast(rnode_config.reticulum_interface_policy)); - put_uchar(kChatKeyRtCallWire, - static_cast(rnode_config.reticulum_call_wire_profile)); put_bool(kChatKeyRtLocationRequests, rnode_config.reticulum_allow_location_requests); const char* legacy_reticulum_group_suffixes[] = {"en", "name", "dest"}; diff --git a/platform/esp/arduino_common/src/app_runtime_support.cpp b/platform/esp/arduino_common/src/app_runtime_support.cpp index 82dba022..fe7f8498 100644 --- a/platform/esp/arduino_common/src/app_runtime_support.cpp +++ b/platform/esp/arduino_common/src/app_runtime_support.cpp @@ -185,15 +185,23 @@ bool stopIncomingCallTone() return s_incoming_call_tone_task == nullptr; } -bool callRealtimeBeginRinging(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) +bool callRealtimeBeginSoftPreempt( + const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) { pauseCallRealtimeResources(); - const bool entered = ::platform::ui::wifi_access::enter_call_ringing(link_id); - if (entered && !startIncomingCallTone()) + return ::platform::ui::wifi_access::enter_call_ringing(link_id); +} + +bool callRealtimeBeginRingingAlert( + const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) +{ + (void)link_id; + const bool started = startIncomingCallTone(); + if (!started) { std::printf("[Reticulum][CallTone] start failed\n"); } - return entered; + return started; } bool callRealtimeBeginExclusive(const uint8_t link_id[::platform::ui::reticulum_call::kHashSize]) @@ -240,7 +248,8 @@ void ensureCallRealtimeHooksRegistered() return; } ::platform::ui::reticulum_call::RealtimeHooks hooks{}; - hooks.begin_ringing = callRealtimeBeginRinging; + hooks.begin_soft_preempt = callRealtimeBeginSoftPreempt; + hooks.begin_ringing_alert = callRealtimeBeginRingingAlert; hooks.begin_exclusive = callRealtimeBeginExclusive; hooks.begin_closing = callRealtimeBeginClosing; hooks.end = callRealtimeEnd; @@ -454,7 +463,8 @@ bool dispatchEvent(app::IAppFacade& app_context, sys::Event* event) case sys::EventType::ChatSendResult: { auto* result_event = static_cast(event); - app_context.getChatService().handleSendResult(result_event->msg_id, result_event->success); + app_context.getChatService().handleSendResult(result_event->msg_id, + result_event->status); return false; } case sys::EventType::NodeInfoUpdate: 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 1fb1dba7..c31d1eb1 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 @@ -11,6 +11,7 @@ #include "chat/infra/reticulum/audio_call_wire.h" #include "chat/infra/reticulum/lxst_telephony_wire.h" #include "chat/time_utils.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_link_runtime.h" #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_runtime.h" @@ -22,6 +23,7 @@ #include "platform/ui/gps_runtime.h" #include "platform/ui/reticulum_call_runtime.h" #include "platform/ui/reticulum_directory_runtime.h" +#include "platform/ui/reticulum_network_config_runtime.h" #include "platform/ui/reticulum_page_runtime.h" #include "platform/ui/screen_runtime.h" #include "sys/event_bus.h" @@ -41,6 +43,7 @@ namespace chat::lxmf namespace { namespace rtdir = ::platform::ui::reticulum_directory; +namespace rtnet = ::platform::ui::reticulum_network_config; namespace rtpage = ::platform::ui::reticulum_page; namespace screen_runtime = ::platform::ui::screen; using PageFailureKind = rtpage::RequestProgress::FailureKind; @@ -70,17 +73,16 @@ constexpr uint32_t kLinkKeepaliveMaxMs = 360000; constexpr float kLinkKeepaliveMaxRttS = 1.75f; constexpr uint32_t kLinkKeepaliveTimeoutFactor = 4; constexpr uint32_t kLinkStaleGraceMs = 5000; -constexpr uint32_t kCallIdentifyTimeoutMs = 15000; -constexpr uint32_t kCallRingTimeoutMs = 60000; -constexpr uint32_t kCallDialTimeoutMs = 70000; -constexpr uint32_t kCallMediaSetupTimeoutMs = 5000; constexpr uint32_t kResourceTransferTtlMs = 60000; +constexpr uint32_t kLinkPacketReceiptTtlMs = 60000; constexpr uint32_t kResourceWindowSize = 4; constexpr size_t kMaxPropagationEntries = 64; constexpr size_t kMaxPropagationTransients = 192; constexpr size_t kMaxPropagationPeers = 32; +constexpr size_t kMaxPendingPropagationUploads = 4; constexpr uint32_t kPropagationEntryTtlS = 3UL * 24UL * 60UL * 60UL; constexpr uint32_t kPropagationTransientTtlS = 3UL * 24UL * 60UL * 60UL; +constexpr uint32_t kPropagationUploadTtlMs = 10UL * 60UL * 1000UL; constexpr uint32_t kPropagationTransferLimitKb = 64; constexpr uint32_t kPropagationSyncLimitKb = 64; constexpr uint32_t kLoraDiscoveryForegroundDetailLogIntervalMs = 15000; @@ -104,6 +106,8 @@ constexpr uint32_t kLinkRelayTtlMs = 300000; constexpr uint32_t kPathTtlMs = 7UL * 24UL * 60UL * 60UL * 1000UL; constexpr size_t kMaxPendingPingReceipts = 4; constexpr uint32_t kPendingPingReceiptTtlMs = 30000; +constexpr size_t kMaxPendingDeliveryReceipts = 8; +constexpr uint32_t kPendingDeliveryReceiptTtlMs = 60000; constexpr uint32_t kPendingPingSendRetryMs = 1500; constexpr uint32_t kDirectoryAddressRefreshIntervalS = 300; constexpr uint8_t kMaxTransportHops = 128; @@ -390,7 +394,7 @@ bool appendMsgpackByte(uint8_t value, uint8_t* out, size_t out_len, size_t& used bool appendMsgpackBytes(const uint8_t* data, size_t len, uint8_t* out, size_t out_len, size_t& used) { - if ((!data && len != 0) || !out || (used + len) > out_len) + if ((!data && len != 0) || !out || used > out_len || len > (out_len - used)) { return false; } @@ -558,43 +562,6 @@ void callDestinationHashForIdentity( } } -::platform::ui::reticulum_call::WireProfile callRuntimeWireProfile( - ReticulumCallWireProfile profile) -{ - return profile == ReticulumCallWireProfile::MeshChatCallAudio - ? ::platform::ui::reticulum_call::WireProfile::MeshChatCallAudio - : ::platform::ui::reticulum_call::WireProfile::SidebandLxst; -} - -reticulum::audio_call::Codec2Mode callAudioCodec2Mode(uint16_t lxst_profile) -{ - reticulum::audio_call::Codec2Mode mode = - reticulum::audio_call::Codec2Mode::Mode3200; - (void)reticulum::lxst::profileToCodec2Mode(lxst_profile, &mode); - return mode; -} - -::platform::ui::reticulum_call::Codec2Mode callRuntimeCodec2Mode( - ReticulumCallWireProfile wire_profile, - uint16_t lxst_profile) -{ - if (wire_profile == ReticulumCallWireProfile::MeshChatCallAudio) - { - return ::platform::ui::reticulum_call::Codec2Mode::Mode1200; - } - - switch (callAudioCodec2Mode(lxst_profile)) - { - case reticulum::audio_call::Codec2Mode::Mode700C: - return ::platform::ui::reticulum_call::Codec2Mode::Mode700C; - case reticulum::audio_call::Codec2Mode::Mode1600: - return ::platform::ui::reticulum_call::Codec2Mode::Mode1600; - case reticulum::audio_call::Codec2Mode::Mode3200: - default: - return ::platform::ui::reticulum_call::Codec2Mode::Mode3200; - } -} - void fillRandomBytes(uint8_t* out, size_t len) { if (!out || len == 0) @@ -758,8 +725,12 @@ void copyCString(char* out, size_t out_len, const char* in) return; } - strncpy(out, in, out_len - 1); - out[out_len - 1] = '\0'; + const size_t max_copy_len = out_len - 1U; + const auto* terminator = static_cast(std::memchr(in, '\0', max_copy_len)); + const size_t copy_len = + terminator ? static_cast(terminator - in) : max_copy_len; + memcpy(out, in, copy_len); + out[copy_len] = '\0'; } bool copyTextAppDataDisplayName(const uint8_t* data, @@ -1250,27 +1221,49 @@ bool LxmfAdapter::dispatchLxmfPayload(PeerInfo& peer, findActiveLinkSessionByDestination(peer.destination_hash, LocalDestinationKind::Delivery); const bool use_opportunistic = !active_link && peerHasUsableRatchet(peer); + const auto& propagation_config = rtnet::active().propagation; + const bool propagation_only = + propagation_config.enabled && + propagation_config.delivery == + reticulum::LxmfDeliveryPreference::Propagated; + const bool propagation_automatic = + propagation_config.enabled && + propagation_config.delivery == + reticulum::LxmfDeliveryPreference::Automatic && + !active_link && !use_opportunistic && + selectActivePropagationPeer() != nullptr; + if (propagation_only || propagation_automatic) + { + if (queuePropagationUpload(peer, + lxmf_message.data(), + lxmf_message_len, + out_dispatch->message_id, + out_dispatch->message_hash, + track_user_message, + out_dispatch)) + { + return true; + } + if (propagation_only) + { + out_dispatch->failure = MeshOperationFailure::RadioTxFailed; + return false; + } + } out_dispatch->path = active_link ? "link" : (use_opportunistic ? "opportunistic" : "deferred_link"); if (active_link) { - out_dispatch->ok = lxmf_message_len <= active_link->mdu - ? sendLinkPacket(*active_link, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - lxmf_message.data(), - lxmf_message_len, - true) - : queueOutgoingResource(*active_link, - lxmf_message.data(), - lxmf_message_len, - 0, - nullptr, - 0); - if (out_dispatch->ok) - { - (void)sendLinkIdentify(*active_link); - } + runtime::DeferredLinkPayload deferred{}; + deferred.payload.assign(lxmf_message.data(), + lxmf_message.data() + lxmf_message_len); + deferred.message_id = track_user_message + ? out_dispatch->message_id + : 0; + active_link->deferred_payloads.push_back(std::move(deferred)); + flushDeferredLinkPayloads(*active_link); + out_dispatch->ok = true; + out_dispatch->result_event_deferred = track_user_message; } else if (use_opportunistic) { @@ -1284,6 +1277,19 @@ bool LxmfAdapter::dispatchLxmfPayload(PeerInfo& peer, &packet_len, out_dispatch->message_hash) && routeAndSendPacket(packet.data(), packet_len, true); + if (out_dispatch->ok && track_user_message) + { + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + reticulum::computePacketHash(packet.data(), packet_len, packet_hash); + runtime::notePendingDeliveryReceipt( + transport_, + packet_hash, + peer.destination_hash, + peer.sig_pub, + out_dispatch->message_id, + millis(), + kMaxPendingDeliveryReceipts); + } } if (!active_link && !out_dispatch->ok) @@ -1316,6 +1322,852 @@ bool LxmfAdapter::dispatchLxmfPayload(PeerInfo& peer, return out_dispatch->ok; } +const LxmfAdapter::PropagationPeerState* +LxmfAdapter::selectActivePropagationPeer() +{ + const auto& config = rtnet::active().propagation; + if (!config.enabled) + { + propagation_.has_active_node = false; + std::memset(propagation_.active_node_hash, + 0, + sizeof(propagation_.active_node_hash)); + return nullptr; + } + + const PropagationPeerState* selected = + runtime::selectPropagationPeer(propagation_, + config.automatic_node, + config.node_hash, + currentTimestampSeconds(), + kPropagationEntryTtlS); + if (!selected) + { + propagation_.has_active_node = false; + if (!config.automatic_node) + { + (void)sendPathRequestForDestination(config.node_hash); + } + return nullptr; + } + + const bool changed = + !propagation_.has_active_node || + !hashesEqual(propagation_.active_node_hash, + selected->propagation_hash, + sizeof(propagation_.active_node_hash)); + copyHash(propagation_.active_node_hash, + selected->propagation_hash, + sizeof(propagation_.active_node_hash)); + propagation_.has_active_node = true; + if (changed) + { + char node_hash[12] = {}; + formatHashPrefix(selected->propagation_hash, + node_hash, + sizeof(node_hash)); + Serial.printf("[LXMF][Propagation] active_node dest=%s hops=%u cost=%u mode=%s\n", + node_hash, + static_cast(selected->hops), + static_cast(selected->stamp_cost), + config.automatic_node ? "auto" : "manual"); + propagation_.initial_sync_pending = config.sync_on_start; + if (!config.sync_on_start) + { + propagation_.last_sync_s = currentTimestampSeconds(); + } + propagation_.sync_stage = PropagationSyncStage::Idle; + } + return selected; +} + +bool LxmfAdapter::preparePropagationPeer(const PropagationPeerState& source, + PeerInfo* out_peer) const +{ + if (!out_peer || !source.node_active || + isZeroBytes(source.propagation_hash, + sizeof(source.propagation_hash)) || + isZeroBytes(source.enc_pub, sizeof(source.enc_pub)) || + isZeroBytes(source.sig_pub, sizeof(source.sig_pub))) + { + return false; + } + + *out_peer = PeerInfo{}; + copyHash(out_peer->destination_hash, + source.propagation_hash, + sizeof(out_peer->destination_hash)); + copyHash(out_peer->identity_hash, + source.identity_hash, + sizeof(out_peer->identity_hash)); + memcpy(out_peer->enc_pub, source.enc_pub, sizeof(out_peer->enc_pub)); + memcpy(out_peer->sig_pub, source.sig_pub, sizeof(out_peer->sig_pub)); + out_peer->node_id = + reticulum::nodeIdFromDestinationHash(out_peer->destination_hash); + out_peer->last_seen_s = source.last_seen_s; + std::snprintf(out_peer->display_name, + sizeof(out_peer->display_name), + "%s", + source.display_name); + return true; +} + +bool LxmfAdapter::queuePropagationUpload( + PeerInfo& recipient, + const uint8_t* lxmf_message, + size_t lxmf_message_len, + MessageId message_id, + const uint8_t message_hash[reticulum::kFullHashSize], + bool track_user_message, + OutboundLxmfDispatch* out_dispatch) +{ + if (!lxmf_message || + lxmf_message_len <= reticulum::kTruncatedHashSize || + !message_hash || !out_dispatch || + propagation_.pending_uploads.size() >= + kMaxPendingPropagationUploads) + { + return false; + } + + const size_t encrypted_capacity = + LxmfIdentity::kEncPubKeySize + + reticulum::tokenSizeForPlaintext( + lxmf_message_len - reticulum::kTruncatedHashSize); + std::vector encrypted(encrypted_capacity, 0); + size_t encrypted_len = encrypted.size(); + if (!encryptForPeer(recipient, + lxmf_message + reticulum::kTruncatedHashSize, + lxmf_message_len - reticulum::kTruncatedHashSize, + encrypted.data(), + &encrypted_len)) + { + return false; + } + + PendingPropagationUpload upload{}; + copyHash(upload.destination_hash, + recipient.destination_hash, + sizeof(upload.destination_hash)); + copyHash(upload.message_hash, + message_hash, + sizeof(upload.message_hash)); + upload.transient_data.resize(reticulum::kTruncatedHashSize + encrypted_len); + memcpy(upload.transient_data.data(), + recipient.destination_hash, + reticulum::kTruncatedHashSize); + memcpy(upload.transient_data.data() + reticulum::kTruncatedHashSize, + encrypted.data(), + encrypted_len); + reticulum::fullHash(upload.transient_data.data(), + upload.transient_data.size(), + upload.transient_id); + upload.created_ms = millis(); + upload.message_id = message_id; + upload.track_user_message = track_user_message; + upload.state = runtime::PropagationUploadState::WaitingNode; + + if (const PropagationPeerState* node = selectActivePropagationPeer()) + { + copyHash(upload.node_hash, + node->propagation_hash, + sizeof(upload.node_hash)); + upload.stamp_cost = node->stamp_cost; + upload.state = runtime::PropagationUploadState::NeedsStamp; + } + + propagation_.pending_uploads.push_back(std::move(upload)); + out_dispatch->ok = true; + out_dispatch->result_event_deferred = track_user_message; + out_dispatch->path = "propagation"; + char transient_hash[12] = {}; + formatHashPrefix(propagation_.pending_uploads.back().transient_id, + transient_hash, + sizeof(transient_hash)); + Serial.printf("[LXMF][PropagationTX] queued msg=%lu transient=%s bytes=%u node=%u\n", + static_cast(message_id), + transient_hash, + static_cast( + propagation_.pending_uploads.back().transient_data.size()), + propagation_.pending_uploads.back().state == + runtime::PropagationUploadState::NeedsStamp + ? 1U + : 0U); + return true; +} + +bool LxmfAdapter::queueReadyPropagationUpload( + PendingPropagationUpload& upload, + const PropagationPeerState& node) +{ + if (upload.state != runtime::PropagationUploadState::Ready || + upload.transient_data.empty() || + !preparePropagationPeer(node, &propagation_peer_scratch_)) + { + return false; + } + + bool started = false; + LinkSession* session = ensureOutboundLinkSession(propagation_peer_scratch_, + LocalDestinationKind::Propagation, + &started); + if (!session) + { + return false; + } + + std::vector> messages; + messages.push_back(upload.transient_data); + std::vector batch(upload.transient_data.size() + 32U, 0); + size_t batch_len = batch.size(); + if (!encodePropagationBatch(static_cast(currentTimestampSeconds()), + messages, + batch.data(), + &batch_len)) + { + return false; + } + batch.resize(batch_len); + + runtime::DeferredLinkPayload deferred{}; + deferred.payload = std::move(batch); + deferred.message_id = upload.track_user_message ? upload.message_id : 0; + session->deferred_payloads.push_back(std::move(deferred)); + upload.state = runtime::PropagationUploadState::QueuedToLink; + if (session->state == LinkState::Active) + { + (void)sendLinkIdentify(*session); + flushDeferredLinkPayloads(*session); + } + + char node_hash[12] = {}; + char transient_hash[12] = {}; + formatHashPrefix(node.propagation_hash, node_hash, sizeof(node_hash)); + formatHashPrefix(upload.transient_id, + transient_hash, + sizeof(transient_hash)); + Serial.printf("[LXMF][PropagationTX] link_queue msg=%lu transient=%s node=%s link_state=%u started=%u\n", + static_cast(upload.message_id), + transient_hash, + node_hash, + static_cast(session->state), + started ? 1U : 0U); + return true; +} + +void LxmfAdapter::processPropagationClient() +{ + const auto& config = rtnet::active().propagation; + if (!config.enabled) + { + for (const auto& upload : propagation_.pending_uploads) + { + if (upload.track_user_message && upload.message_id != 0) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(upload.message_id, false), + 0); + } + Serial.printf("[LXMF][PropagationTX] disabled msg=%lu\n", + static_cast(upload.message_id)); + } + propagation_.pending_uploads.clear(); + propagation_.sync_wants.clear(); + propagation_.sync_haves.clear(); + runtime::clearPropagationDeliveryCommits(propagation_); + propagation_.persistence_started_ms = 0; + propagation_.sync_stage = PropagationSyncStage::Idle; + propagation_.has_active_node = false; + propagation_.initial_sync_pending = true; + std::memset(propagation_.active_node_hash, + 0, + sizeof(propagation_.active_node_hash)); + std::memset(propagation_.sync_request_id, + 0, + sizeof(propagation_.sync_request_id)); + for (auto& session : links_.sessions) + { + if (session.destination == LocalDestinationKind::Propagation && + session.state != LinkState::Closed) + { + closeLinkSession(session, LinkCloseReason::LocalClose); + } + } + propagation_stamp_.reset(); + return; + } + + const uint32_t now_ms = millis(); + for (auto& upload : propagation_.pending_uploads) + { + if (upload.state != runtime::PropagationUploadState::Failed && + upload.created_ms != 0 && + (now_ms - upload.created_ms) > kPropagationUploadTtlMs) + { + upload.state = runtime::PropagationUploadState::Failed; + } + } + propagation_.pending_uploads.erase( + std::remove_if( + propagation_.pending_uploads.begin(), + propagation_.pending_uploads.end(), + [](const PendingPropagationUpload& upload) + { + if (upload.state != runtime::PropagationUploadState::Failed) + { + return false; + } + if (upload.track_user_message && upload.message_id != 0) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(upload.message_id, false), + 0); + } + Serial.printf("[LXMF][PropagationTX] failed msg=%lu\n", + static_cast(upload.message_id)); + return true; + }), + propagation_.pending_uploads.end()); + + const PropagationPeerState* node = selectActivePropagationPeer(); + if (!propagation_.pending_uploads.empty()) + { + PendingPropagationUpload& upload = propagation_.pending_uploads.front(); + if (!node) + { + upload.state = runtime::PropagationUploadState::WaitingNode; + propagation_stamp_.reset(); + } + else + { + const bool node_changed = + !hashesEqual(upload.node_hash, + node->propagation_hash, + sizeof(upload.node_hash)) || + upload.stamp_cost != node->stamp_cost; + if (node_changed) + { + propagation_stamp_.reset(); + copyHash(upload.node_hash, + node->propagation_hash, + sizeof(upload.node_hash)); + upload.stamp_cost = node->stamp_cost; + upload.state = runtime::PropagationUploadState::NeedsStamp; + } + else if (upload.state == + runtime::PropagationUploadState::WaitingNode) + { + upload.state = runtime::PropagationUploadState::NeedsStamp; + } + + if (upload.state == runtime::PropagationUploadState::NeedsStamp) + { + if (propagation_stamp_.begin(upload.transient_id, + upload.stamp_cost)) + { + upload.state = runtime::PropagationUploadState::Stamping; + Serial.printf("[LXMF][PropagationTX] stamp_begin msg=%lu cost=%u\n", + static_cast(upload.message_id), + static_cast(upload.stamp_cost)); + } + else + { + upload.state = runtime::PropagationUploadState::Failed; + } + } + + if (upload.state == runtime::PropagationUploadState::Stamping) + { + const auto stamp_state = propagation_stamp_.poll(); + if (stamp_state == + runtime::PropagationStampRuntime::State::Complete) + { + uint8_t stamp[reticulum::kFullHashSize] = {}; + const uint32_t rounds = + propagation_stamp_.searchRounds(); + if (!propagation_stamp_.takeStamp(stamp)) + { + upload.state = + runtime::PropagationUploadState::Failed; + } + else + { + upload.transient_data.insert( + upload.transient_data.end(), + stamp, + stamp + sizeof(stamp)); + upload.state = runtime::PropagationUploadState::Ready; + Serial.printf("[LXMF][PropagationTX] stamp_ready msg=%lu rounds=%lu\n", + static_cast(upload.message_id), + static_cast(rounds)); + } + } + else if (stamp_state == + runtime::PropagationStampRuntime::State::Failed) + { + upload.state = runtime::PropagationUploadState::Failed; + } + else if (stamp_state == + runtime::PropagationStampRuntime::State::Expanding && + propagation_stamp_.expandedRounds() != 0U && + (propagation_stamp_.expandedRounds() % 100U) == 0U) + { + Serial.printf("[LXMF][PropagationTX] stamp_progress msg=%lu expand=%u/1000 search=%lu\n", + static_cast(upload.message_id), + static_cast( + propagation_stamp_.expandedRounds()), + static_cast( + propagation_stamp_.searchRounds())); + } + else if (stamp_state == + runtime::PropagationStampRuntime::State::Searching && + propagation_stamp_.searchRounds() != 0U && + (propagation_stamp_.searchRounds() % 4096U) == 0U) + { + Serial.printf("[LXMF][PropagationTX] stamp_progress msg=%lu expand=%u/1000 search=%lu\n", + static_cast(upload.message_id), + static_cast( + propagation_stamp_.expandedRounds()), + static_cast( + propagation_stamp_.searchRounds())); + } + } + + if (upload.state == runtime::PropagationUploadState::Ready && + queueReadyPropagationUpload(upload, *node)) + { + propagation_.pending_uploads.erase( + propagation_.pending_uploads.begin()); + } + } + } + + if (node) + { + LinkSession* session = + runtime::findOpenLinkSessionByDestination( + links_, + node->propagation_hash, + LocalDestinationKind::Propagation); + const auto& sync_config = rtnet::active().propagation; + const uint32_t now_s = currentTimestampSeconds(); + const bool sync_due = + propagation_.initial_sync_pending || + (sync_config.sync_interval_s != 0 && + (propagation_.last_sync_s == 0 || + now_s < propagation_.last_sync_s || + (now_s - propagation_.last_sync_s) >= + sync_config.sync_interval_s)); + if (!session && sync_due && + preparePropagationPeer(*node, &propagation_peer_scratch_)) + { + session = ensureOutboundLinkSession(propagation_peer_scratch_, + LocalDestinationKind::Propagation, + nullptr); + } + if (session) + { + processPropagationSyncResponse(*session); + } + } +} + +bool LxmfAdapter::sendPropagationSyncRequest( + LinkSession& session, + PropagationSyncStage next_stage, + const std::vector>* wants, + const std::vector>* haves, + bool include_transfer_limit) +{ + if (session.state != LinkState::Active || + session.destination != LocalDestinationKind::Propagation) + { + return false; + } + + const size_t item_count = (wants ? wants->size() : 0U) + + (haves ? haves->size() : 0U); + std::vector packed_data(32U + item_count * 35U, 0); + size_t packed_data_len = packed_data.size(); + if (!encodePropagationGetRequestPayload(wants, + haves, + include_transfer_limit, + kPropagationTransferLimitKb, + packed_data.data(), + &packed_data_len)) + { + return false; + } + packed_data.resize(packed_data_len); + + uint8_t path_hash[reticulum::kTruncatedHashSize] = {}; + runtime::propagationServicePathHash( + runtime::PropagationServicePath::Get, + path_hash); + std::vector request_payload(packed_data.size() + 64U, 0); + size_t request_payload_len = request_payload.size(); + if (!encodeLinkRequestPayload( + static_cast(currentTimestampSeconds()), + path_hash, + packed_data.data(), + packed_data.size(), + false, + request_payload.data(), + &request_payload_len)) + { + return false; + } + request_payload.resize(request_payload_len); + + uint8_t request_id[reticulum::kTruncatedHashSize] = {}; + bool sent = false; + if (request_payload.size() <= session.mdu) + { + std::vector wire_payload( + reticulum::tokenSizeForPlaintext(request_payload.size()), + 0); + size_t wire_payload_len = wire_payload.size(); + if (!encryptLinkPayload(session, + request_payload.data(), + request_payload.size(), + wire_payload.data(), + &wire_payload_len)) + { + return false; + } + wire_payload.resize(wire_payload_len); + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, + reticulum::DestinationType::Link, + reticulum::PacketContext::Request, + false, + session.link_id, + wire_payload.data(), + wire_payload.size(), + packet, + &packet_len)) + { + return false; + } + reticulum::computeTruncatedPacketHash(packet, packet_len, request_id); + sent = session.interface_id != + reticulum::interfaces::kInvalidInterfaceId + ? interfaces_.sendPacketOn(session.interface_id, + packet, + packet_len) + : interfaces_.sendPacket(packet, packet_len); + } + else + { + reticulum::truncatedHash(request_payload.data(), + request_payload.size(), + request_id); + sent = queueOutgoingResource(session, + request_payload.data(), + request_payload.size(), + kResourceFlagRequest, + request_id, + sizeof(request_id)); + } + if (!sent) + { + return false; + } + + LinkPendingRequest pending{}; + pending.request_id.assign(request_id, request_id + sizeof(request_id)); + pending.created_ms = millis(); + pending.awaiting_resource = request_payload.size() > session.mdu; + session.pending_requests.push_back(std::move(pending)); + copyHash(propagation_.sync_request_id, + request_id, + sizeof(propagation_.sync_request_id)); + propagation_.sync_stage = next_stage; + session.last_outbound_ms = millis(); + Serial.printf("[LXMF][PropagationSync] request stage=%u wants=%u haves=%u resource=%u\n", + static_cast(next_stage), + static_cast(wants ? wants->size() : 0U), + static_cast(haves ? haves->size() : 0U), + request_payload.size() > session.mdu ? 1U : 0U); + return true; +} + +void LxmfAdapter::processPropagationSyncResponse(LinkSession& session) +{ + if (session.state != LinkState::Active || + session.destination != LocalDestinationKind::Propagation) + { + return; + } + + const auto& config = rtnet::active().propagation; + const uint32_t now_s = currentTimestampSeconds(); + const bool interval_due = + config.sync_interval_s != 0 && + (propagation_.last_sync_s == 0 || now_s < propagation_.last_sync_s || + (now_s - propagation_.last_sync_s) >= config.sync_interval_s); + if (propagation_.sync_stage == PropagationSyncStage::Idle && + (propagation_.initial_sync_pending || interval_due)) + { + propagation_.sync_stage = PropagationSyncStage::NeedList; + propagation_.sync_started_ms = millis(); + propagation_.persistence_started_ms = 0; + propagation_.sync_wants.clear(); + propagation_.sync_haves.clear(); + runtime::clearPropagationDeliveryCommits(propagation_); + } + + if (propagation_.sync_stage == PropagationSyncStage::NeedList) + { + (void)sendLinkIdentify(session); + if (!sendPropagationSyncRequest(session, + PropagationSyncStage::Listing, + nullptr, + nullptr, + false)) + { + propagation_.sync_stage = PropagationSyncStage::Failed; + } + return; + } + + auto pending = std::find_if( + session.pending_requests.begin(), + session.pending_requests.end(), + [this](const LinkPendingRequest& request) + { + return request.request_id.size() == + sizeof(propagation_.sync_request_id) && + hashesEqual(request.request_id.data(), + propagation_.sync_request_id, + sizeof(propagation_.sync_request_id)); + }); + if ((propagation_.sync_stage == PropagationSyncStage::Listing || + propagation_.sync_stage == PropagationSyncStage::Downloading || + propagation_.sync_stage == PropagationSyncStage::Acknowledging) && + pending != session.pending_requests.end() && + pending->created_ms != 0 && + (millis() - pending->created_ms) > kLinkRequestTtlMs) + { + session.pending_requests.erase(pending); + propagation_.sync_stage = PropagationSyncStage::Failed; + pending = session.pending_requests.end(); + } + + if (propagation_.sync_stage == PropagationSyncStage::Listing && + pending != session.pending_requests.end() && pending->response_ready) + { + std::vector> remote_ids; + const bool decoded = + decodePropagationIdListPayload(pending->response.data(), + pending->response.size(), + &remote_ids); + session.pending_requests.erase(pending); + if (!decoded) + { + propagation_.sync_stage = PropagationSyncStage::Failed; + } + else + { + const size_t max_messages = + std::max(1U, config.max_messages_per_sync); + for (const auto& transient_id : remote_ids) + { + if (transient_id.size() != reticulum::kFullHashSize) + { + continue; + } + if (runtime::hasSeenPropagationTransient( + propagation_, transient_id.data(), nullptr)) + { + propagation_.sync_haves.push_back(transient_id); + } + else if (propagation_.sync_wants.size() < max_messages) + { + propagation_.sync_wants.push_back(transient_id); + } + } + propagation_.sync_stage = propagation_.sync_wants.empty() + ? PropagationSyncStage::NeedAcknowledge + : PropagationSyncStage::NeedMessages; + } + } + + if (propagation_.sync_stage == PropagationSyncStage::NeedMessages) + { + if (!sendPropagationSyncRequest(session, + PropagationSyncStage::Downloading, + &propagation_.sync_wants, + &propagation_.sync_haves, + true)) + { + propagation_.sync_stage = PropagationSyncStage::Failed; + } + return; + } + + if (propagation_.sync_stage == PropagationSyncStage::Downloading && + pending != session.pending_requests.end() && pending->response_ready) + { + std::vector> messages; + const bool decoded = + decodePropagationMessageListPayload(pending->response.data(), + pending->response.size(), + &messages); + session.pending_requests.erase(pending); + if (!decoded) + { + propagation_.sync_stage = PropagationSyncStage::Failed; + } + else + { + bool delivery_commit_registration_failed = false; + uint8_t local_delivery[reticulum::kTruncatedHashSize] = {}; + localDestinationHash(LocalDestinationKind::Delivery, + local_delivery); + for (const auto& message : messages) + { + if (message.size() <= reticulum::kTruncatedHashSize || + !hashesEqual(message.data(), + local_delivery, + sizeof(local_delivery))) + { + continue; + } + uint8_t transient_id[reticulum::kFullHashSize] = {}; + reticulum::fullHash(message.data(), + message.size(), + transient_id); + uint8_t message_hash[reticulum::kFullHashSize] = {}; + bool awaiting_commit = false; + if (acceptPropagatedDelivery( + message.data() + reticulum::kTruncatedHashSize, + message.size() - reticulum::kTruncatedHashSize, + message_hash, + &awaiting_commit)) + { + if (awaiting_commit) + { + if (!runtime::awaitPropagationDeliveryCommit( + propagation_, + transient_id, + message_hash, + std::max(1U, + config.max_messages_per_sync))) + { + delivery_commit_registration_failed = true; + break; + } + } + else + { + runtime::rememberPropagationTransient( + propagation_, + transient_id, + true, + currentTimestampSeconds(), + kMaxPropagationTransients); + propagation_.sync_haves.emplace_back( + transient_id, + transient_id + sizeof(transient_id)); + } + } + } + propagation_.sync_stage = + delivery_commit_registration_failed + ? PropagationSyncStage::Failed + : (propagation_.pending_deliveries.empty() + ? PropagationSyncStage::NeedAcknowledge + : PropagationSyncStage::AwaitingPersistence); + if (propagation_.sync_stage == + PropagationSyncStage::AwaitingPersistence) + { + propagation_.persistence_started_ms = millis(); + } + } + } + + if (propagation_.sync_stage == PropagationSyncStage::AwaitingPersistence) + { + if (propagation_.persistence_started_ms == 0 || + (millis() - propagation_.persistence_started_ms) > + kLinkRequestTtlMs) + { + propagation_.sync_stage = PropagationSyncStage::Failed; + } + else if (runtime::propagationDeliveryCommitsResolved(propagation_)) + { + propagation_.sync_stage = + runtime::propagationDeliveryCommitRejected(propagation_) + ? PropagationSyncStage::Failed + : PropagationSyncStage::NeedAcknowledge; + } + else + { + return; + } + } + + if (propagation_.sync_stage == PropagationSyncStage::NeedAcknowledge) + { + const bool acknowledged = + propagation_.sync_haves.empty() || + sendPropagationSyncRequest(session, + PropagationSyncStage::Acknowledging, + nullptr, + &propagation_.sync_haves, + false); + if (acknowledged) + { + propagation_.sync_stage = propagation_.sync_haves.empty() + ? PropagationSyncStage::Complete + : PropagationSyncStage::Acknowledging; + } + else + { + propagation_.sync_stage = PropagationSyncStage::Failed; + } + if (propagation_.sync_stage == PropagationSyncStage::Acknowledging) + { + return; + } + } + + if (propagation_.sync_stage == PropagationSyncStage::Acknowledging && + pending != session.pending_requests.end() && pending->response_ready) + { + session.pending_requests.erase(pending); + propagation_.sync_stage = PropagationSyncStage::Complete; + } + + if (propagation_.sync_stage == PropagationSyncStage::Complete) + { + propagation_.last_sync_s = now_s; + propagation_.initial_sync_pending = false; + Serial.printf("[LXMF][PropagationSync] complete received=%u acknowledged=%u\n", + static_cast(propagation_.sync_haves.size()), + static_cast(propagation_.sync_haves.size())); + propagation_.sync_wants.clear(); + propagation_.sync_haves.clear(); + runtime::clearPropagationDeliveryCommits(propagation_); + propagation_.persistence_started_ms = 0; + propagation_.sync_stage = PropagationSyncStage::Idle; + } + else if (propagation_.sync_stage == PropagationSyncStage::Failed) + { + Serial.println("[LXMF][PropagationSync] failed"); + propagation_.sync_wants.clear(); + propagation_.sync_haves.clear(); + runtime::clearPropagationDeliveryCommits(propagation_); + propagation_.persistence_started_ms = 0; + propagation_.sync_stage = PropagationSyncStage::Idle; + propagation_.initial_sync_pending = false; + propagation_.last_sync_s = now_s; + } +} + bool LxmfAdapter::respondToSidebandTelemetryRequest( PeerInfo& peer, const SidebandTelemetryRequest& request) @@ -1605,6 +2457,38 @@ bool LxmfAdapter::pollIncomingText(MeshIncomingText* out) return text_receive_queue_.pop(out); } +void LxmfAdapter::commitIncomingText(const MeshIncomingText& message, + bool durably_accepted) +{ + if (!message.has_reticulum_lxmf_hash) + { + return; + } + + if (!runtime::commitPropagationDelivery(propagation_, + message.reticulum_lxmf_hash, + durably_accepted, + currentTimestampSeconds(), + kMaxPropagationTransients)) + { + return; + } + + Serial.printf("[LXMF][PropagationSync] durable_commit msg=%08lX accepted=%u pending=%u\n", + static_cast(message.msg_id), + durably_accepted ? 1U : 0U, + static_cast(propagation_.pending_deliveries.size())); + + if (propagation_.sync_stage == PropagationSyncStage::AwaitingPersistence && + runtime::propagationDeliveryCommitsResolved(propagation_)) + { + propagation_.sync_stage = + runtime::propagationDeliveryCommitRejected(propagation_) + ? PropagationSyncStage::Failed + : PropagationSyncStage::NeedAcknowledge; + } +} + bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, NodeId dest, bool want_ack, @@ -1863,8 +2747,12 @@ bool LxmfAdapter::broadcastSelfIdentity() announce_pending_ = true; const bool delivery_ok = sendAnnounce(LocalDestinationKind::Delivery); const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); - const bool propagation_ok = sendAnnounce(LocalDestinationKind::Propagation); - const bool propagation_complete = lastAnnounceTxReachedRequiredInterfaces(propagation_ok); + const bool propagation_service = + rtnet::active().propagation.service_enabled; + const bool propagation_ok = + propagation_service && sendAnnounce(LocalDestinationKind::Propagation); + const bool propagation_complete = + !propagation_service || lastAnnounceTxReachedRequiredInterfaces(propagation_ok); const bool call_audio_ok = sendAnnounce(LocalDestinationKind::CallAudio); const bool call_audio_complete = !interfaces_.wifiGatewayConfigured() || @@ -1956,8 +2844,8 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( return MeshActionResult::fail(MeshOperationFailure::PeerKeyMissing); } - const ReticulumCallWireProfile wire_profile = - config_.reticulum_call_wire_profile; + constexpr ReticulumCallWireProfile wire_profile = + ReticulumCallWireProfile::SidebandLxst; uint8_t call_destination_hash[reticulum::kTruncatedHashSize] = {}; callDestinationHashForIdentity(remote_identity_hash, wire_profile, @@ -1975,10 +2863,18 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( const PathEntry* path = findPath(call_destination_hash); bool path_requested = false; + bool path_waiting = false; if (!path) { - path_requested = - sendPathRequestForDestination(call_destination_hash); + const PendingPathRequest* pending = + findPendingPathRequest(call_destination_hash); + path_waiting = pending && !pending->resolved; + if (!path_waiting) + { + path_requested = + sendPathRequestForDestination(call_destination_hash); + path_waiting = path_requested; + } } LinkSession& session = runtime::appendLinkSession(links_, kMaxLinkSessions); @@ -1994,10 +2890,9 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( !isZeroBytes(remote_identity_hash, sizeof(remote_identity_hash)); session.validated = false; session.call_wire_profile = wire_profile; - session.lxst_profile = reticulum::lxst::kProfileBandwidthLow; - session.lxst_local_status = reticulum::lxst::kStatusCalling; - session.lxst_remote_status = reticulum::lxst::kStatusCalling; - session.call_status_ms = session.created_ms; + session.lxst_call = reticulum::lxst::call::makeCaller( + call_profile::kEmbeddedLxstProfile, + session.created_ms); session.keepalive_interval_ms = kLinkKeepaliveMaxMs; session.stale_timeout_ms = kLinkKeepaliveMaxMs * 2U; copyHash(session.remote_destination_hash, @@ -2016,10 +2911,10 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( Curve25519::dh1(session.local_enc_pub, session.local_enc_priv); if (isZeroBytes(session.local_enc_priv, sizeof(session.local_enc_priv)) || !generateLinkSigningKey(session.local_sig_pub, session.local_sig_priv) || - !sendLinkRequest(session)) + !prepareLinkRequest(session)) { links_.sessions.pop_back(); - return MeshActionResult::fail(MeshOperationFailure::RadioTxFailed); + return MeshActionResult::fail(MeshOperationFailure::EncodeFailed); } ::platform::ui::reticulum_call::Peer call_peer{}; @@ -2045,20 +2940,35 @@ MeshActionResult LxmfAdapter::startReticulumAudioCall( sizeof(call_peer.identity_hash)); } call_peer.incoming = false; - call_peer.wire_profile = callRuntimeWireProfile(session.call_wire_profile); + call_peer.wire_profile = + call_profile::runtimeWireProfile(session.call_wire_profile); call_peer.codec2_mode = - callRuntimeCodec2Mode(session.call_wire_profile, session.lxst_profile); - ::platform::ui::reticulum_call::update_peer(call_peer); + call_profile::runtimeCodec2Mode(session.call_wire_profile, + session.lxst_call.profile); + if (!::platform::ui::reticulum_call::begin_outgoing(call_peer)) + { + links_.sessions.pop_back(); + return MeshActionResult::fail(MeshOperationFailure::Busy); + } + session.call_runtime_started = true; + + if ((path && !sendLinkRequest(session)) || (!path && !path_waiting)) + { + ::platform::ui::reticulum_call::notify_link_closed(session.link_id); + links_.sessions.pop_back(); + return MeshActionResult::fail(MeshOperationFailure::RadioTxFailed); + } char call_hash[12] = {}; formatHashPrefix(call_destination_hash, call_hash, sizeof(call_hash)); - Serial.printf("[LXMF][CallTX] start dest=%s wire=%u path=%u requested=%u link_pending=1\n", + Serial.printf("[LXMF][CallTX] start dest=%s wire=%u path=%u requested=%u waiting=%u link_pending=1\n", call_hash, static_cast(wire_profile), path ? 1U : 0U, - path_requested ? 1U : 0U); + path_requested ? 1U : 0U, + path_waiting ? 1U : 0U); return MeshActionResult::success(); } @@ -2426,7 +3336,9 @@ void LxmfAdapter::applyConfig(const MeshConfig& config) { loadPersistedPeers(); } - interfaces_.applyConfig(config_); + rtnet::initialize(config_); + interfaces_.applyConfig(config_, rtnet::active()); + network_config_generation_ = rtnet::status().generation; if (identity_.isReady()) { uint8_t delivery_hash[reticulum::kTruncatedHashSize] = {}; @@ -2547,7 +3459,22 @@ LxmfAdapter::RuntimeBudget LxmfAdapter::makeRuntimeBudget() const return budget; } - budget.live_packet_limit = 1; +#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.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; @@ -2559,16 +3486,64 @@ LxmfAdapter::RuntimeBudget LxmfAdapter::makeRuntimeBudget() const void LxmfAdapter::processRuntime() { + rtnet::poll(config_); + const auto network_status = rtnet::status(); + if (network_status.generation != network_config_generation_) + { + for (auto& session : links_.sessions) + { + if (session.state != LinkState::Closed) + { + closeLinkSession(session, LinkCloseReason::Error); + } + } + links_.sessions.clear(); + + transport_.paths.clear(); + transport_.reverse_table.clear(); + transport_.pending_path_requests.clear(); + transport_.link_relays.clear(); + deferred_discovery_queue_.clear(); + + propagation_stamp_.reset(); + for (auto& upload : propagation_.pending_uploads) + { + if (upload.state == runtime::PropagationUploadState::Stamping) + { + upload.state = runtime::PropagationUploadState::NeedsStamp; + } + } + propagation_.sync_wants.clear(); + propagation_.sync_haves.clear(); + propagation_.sync_stage = PropagationSyncStage::Idle; + propagation_.has_active_node = false; + propagation_.initial_sync_pending = + rtnet::active().propagation.sync_on_start; + std::memset(propagation_.active_node_hash, + 0, + sizeof(propagation_.active_node_hash)); + std::memset(propagation_.sync_request_id, + 0, + sizeof(propagation_.sync_request_id)); + + interfaces_.applyConfig(config_, rtnet::active()); + network_config_generation_ = network_status.generation; + Serial.printf("[LXMF][NetworkConfig] generation=%lu source=%s interfaces=%u\n", + static_cast(network_status.generation), + rtnet::source_name(network_status.source), + static_cast(network_status.configured_interfaces)); + } + const RuntimeBudget budget = makeRuntimeBudget(); + processRadioPackets(budget); + pumpPendingPingRequests(); + pumpReticulumAudioCall(); + cullTransportState(); if (::platform::ui::reticulum_call::realtime_mode_active()) { - processRadioPackets(budget); - pumpPendingPingRequests(); - pumpReticulumAudioCall(); return; } - cullTransportState(); const runtime::PropagationRuntimeLimits propagation_limits{ kMaxPropagationEntries, kMaxPropagationTransients, @@ -2580,10 +3555,8 @@ void LxmfAdapter::processRuntime() currentTimestampSeconds(), propagation_limits); - processRadioPackets(budget); - pumpPendingPingRequests(); - pumpReticulumAudioCall(); pumpNomadPageRequests(); + processPropagationClient(); processDeferredDiscoveryPackets(budget); if (budget.allow_peer_projection) @@ -2621,15 +3594,26 @@ bool LxmfAdapter::processOneRadioPacket( const size_t packet_len = rx_packet.len; const auto ingress_interface = rx_packet.interface_kind; const bool ingress_wifi = - ingress_interface == reticulum::interfaces::InterfaceKind::WifiGateway; - const char* iface_label = ingress_wifi ? "wifi" : "lora"; + ingress_interface != reticulum::interfaces::InterfaceKind::LoRa; + const char* iface_label = + ingress_interface == reticulum::interfaces::InterfaceKind::LoRa + ? "lora" + : (ingress_interface == reticulum::interfaces::InterfaceKind::Auto + ? "auto" + : "tcp"); active_rx_meta_ = rx_packet.rx_meta; has_active_rx_meta_ = true; - struct ActiveRxMetaScope + active_ingress_interface_id_ = rx_packet.interface_id; + struct ActiveIngressScope { bool& active; - ~ActiveRxMetaScope() { active = false; } - } active_rx_meta_scope{has_active_rx_meta_}; + reticulum::interfaces::InterfaceId& interface_id; + ~ActiveIngressScope() + { + active = false; + interface_id = reticulum::interfaces::kInvalidInterfaceId; + } + } active_ingress_scope{has_active_rx_meta_, active_ingress_interface_id_}; reticulum::ParsedPacket parsed{}; if (!reticulum::parsePacket(packet, packet_len, &parsed)) @@ -2664,7 +3648,21 @@ bool LxmfAdapter::processOneRadioPacket( hashesEqual(parsed.destination_hash, call_link_id, reticulum::kTruncatedHashSize); - if (!call_link_request && !current_call_link_packet) + const LinkSession* call_session = + has_call_link ? findLinkSession(call_link_id) : nullptr; + const bool current_call_path_response = + ingress_wifi && call_session && call_session->initiator && + call_session->destination == LocalDestinationKind::CallAudio && + call_session->state == LinkState::Pending && + parsed.packet_type == reticulum::PacketType::Announce && + parsed.context == + static_cast(reticulum::PacketContext::PathResponse) && + parsed.destination_hash && + hashesEqual(parsed.destination_hash, + call_session->remote_destination_hash, + reticulum::kTruncatedHashSize); + if (!call_link_request && !current_call_link_packet && + !current_call_path_response) { noteRxSummary(true, false, false); return false; @@ -2740,7 +3738,7 @@ bool LxmfAdapter::processOneRadioPacket( return false; } - if (!deferred_replay && ingress_wifi && !shouldProcessWifiIngressPacket(parsed)) + if (!deferred_replay && ingress_wifi && !shouldProcessWifiIngressPacket(parsed, budget)) { if (budget.phase && std::strcmp(budget.phase, "nomad") == 0 && (packet_len >= 256U || @@ -2821,7 +3819,15 @@ bool LxmfAdapter::shouldDeferDiscoveryPacket( return true; } +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // The P4-specific foreground budget above is the admission decision. Do + // not re-apply the S3 one-announce-per-10-seconds sampler, otherwise the + // gateway ingress rate still permanently outruns the deferred queue. + (void)ingress_interface; + return false; +#else return !consumeDiscoveryBudget(ingress_interface); +#endif } bool LxmfAdapter::isPublicDiscoveryPacket(const reticulum::ParsedPacket& packet) const @@ -2854,6 +3860,7 @@ bool LxmfAdapter::enqueueDeferredDiscoveryPacket( 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; @@ -2901,6 +3908,7 @@ void LxmfAdapter::processDeferredDiscoveryPackets(const RuntimeBudget& budget) 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); ++processed; } @@ -2932,8 +3940,12 @@ void LxmfAdapter::maybeAnnounce() const bool delivery_ok = sendAnnounce(LocalDestinationKind::Delivery); const bool delivery_complete = lastAnnounceTxReachedRequiredInterfaces(delivery_ok); - const bool propagation_ok = sendAnnounce(LocalDestinationKind::Propagation); - const bool propagation_complete = lastAnnounceTxReachedRequiredInterfaces(propagation_ok); + const bool propagation_service = + rtnet::active().propagation.service_enabled; + const bool propagation_ok = + propagation_service && sendAnnounce(LocalDestinationKind::Propagation); + const bool propagation_complete = + !propagation_service || lastAnnounceTxReachedRequiredInterfaces(propagation_ok); const bool call_audio_ok = sendAnnounce(LocalDestinationKind::CallAudio); const bool call_audio_complete = !interfaces_.wifiGatewayConfigured() || @@ -2962,6 +3974,11 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, return false; } const bool call_audio = kind == LocalDestinationKind::CallAudio; + if (kind == LocalDestinationKind::Propagation && + !rtnet::active().propagation.service_enabled) + { + return false; + } if (call_audio && !interfaces_.wifiGatewayConfigured()) { return false; @@ -2985,18 +4002,6 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, if (kind == LocalDestinationKind::CallAudio) { app_data_len = 0; - if (config_.reticulum_call_wire_profile == - ReticulumCallWireProfile::MeshChatCallAudio) - { - const char* display_name = effectiveDisplayName(); - app_data_len = display_name - ? std::min(strlen(display_name), sizeof(app_data)) - : 0; - if (app_data_len != 0) - { - memcpy(app_data, display_name, app_data_len); - } - } app_data_ok = true; } else if (kind == LocalDestinationKind::Propagation) @@ -3031,15 +4036,7 @@ bool LxmfAdapter::sendAnnounce(LocalDestinationKind kind, uint8_t name_hash[reticulum::kNameHashSize] = {}; if (kind == LocalDestinationKind::CallAudio) { - if (config_.reticulum_call_wire_profile == - ReticulumCallWireProfile::MeshChatCallAudio) - { - reticulum::computeNameHash("call", "audio", name_hash); - } - else - { - reticulum::computeNameHash("lxst", "telephony", name_hash); - } + reticulum::computeNameHash("lxst", "telephony", name_hash); } else { @@ -3302,6 +4299,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len announce.random_hash, now_ms, now_s); + path.interface_id = active_ingress_interface_id_; path.direct = (packet.transport_id == nullptr); resolvePendingPathRequest(packet.destination_hash); if (packet.transport_id) @@ -3475,7 +4473,7 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len bool log_announce_detail = ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway || local_destination; - const bool contact_announce = delivery_announce || call_audio_announce; + const bool contact_announce = delivery_announce || lxst_telephony_announce; if (log_announce_detail && ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway && !local_destination && @@ -3529,7 +4527,51 @@ bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len delivery_hash, identity_hash, kMaxPropagationPeers); + DecodedPropagationAnnounce propagation_announce_data{}; + const bool propagation_data_valid = + decodePropagationAnnounceAppData(announce.app_data, + announce.app_data_len, + &propagation_announce_data) && + propagation_announce_data.valid; + propagation_peer.node_active = propagation_data_valid; + propagation_peer.hops = packet.hops; + if (propagation_data_valid) + { + propagation_peer.announce_timebase_s = + propagation_announce_data.timebase_s; + propagation_peer.transfer_limit_kb = + propagation_announce_data.transfer_limit_kb; + propagation_peer.sync_limit_kb = + propagation_announce_data.sync_limit_kb; + propagation_peer.stamp_cost = + propagation_announce_data.stamp_cost; + propagation_peer.stamp_cost_flexibility = + propagation_announce_data.stamp_cost_flexibility; + propagation_peer.peering_cost = + propagation_announce_data.peering_cost; + if (announce.public_key) + { + memcpy(propagation_peer.enc_pub, + announce.public_key, + sizeof(propagation_peer.enc_pub)); + memcpy(propagation_peer.sig_pub, + announce.public_key + sizeof(propagation_peer.enc_pub), + sizeof(propagation_peer.sig_pub)); + } + std::snprintf(propagation_peer.display_name, + sizeof(propagation_peer.display_name), + "%s", + propagation_announce_data.display_name.c_str()); + } runtime::markPropagationPeerSeen(propagation_peer, now_s); + Serial.printf("[LXMF][Propagation] node_seen dest=%s active=%u hops=%u cost=%u transfer_kb=%lu sync_kb=%lu name=\"%s\"\n", + packet_hash_hex, + propagation_peer.node_active ? 1U : 0U, + static_cast(propagation_peer.hops), + static_cast(propagation_peer.stamp_cost), + static_cast(propagation_peer.transfer_limit_kb), + static_cast(propagation_peer.sync_limit_kb), + propagation_peer.display_name); } if (!contact_announce || local_destination) @@ -3835,23 +4877,71 @@ bool LxmfAdapter::handleProofPacket( return maybeForwardLinkPacket(raw_packet, raw_len, packet); } + const auto proof_signature_for_hash = + [&packet](const uint8_t expected_hash[reticulum::kFullHashSize]) + -> const uint8_t* + { + if (packet.payload_len == reticulum::kSignatureSize) + { + return packet.payload; + } + if (packet.payload_len == + (reticulum::kFullHashSize + reticulum::kSignatureSize) && + packet.payload && expected_hash && + hashesEqual(packet.payload, + expected_hash, + reticulum::kFullHashSize)) + { + return packet.payload + reticulum::kFullHashSize; + } + return nullptr; + }; + + if (runtime::PendingDeliveryReceipt* pending = + runtime::findPendingDeliveryReceipt(transport_, + packet.destination_hash)) + { + const uint8_t* signature = + proof_signature_for_hash(pending->packet_hash); + const bool valid = + signature && + LxmfIdentity::verify(pending->peer_sig_pub, + signature, + pending->packet_hash, + sizeof(pending->packet_hash)); + char destination_hash[12] = {}; + formatHashPrefix(pending->destination_hash, + destination_hash, + sizeof(destination_hash)); + if (!valid) + { + Serial.printf("[LXMF][DirectTX] proof_reject reason=invalid_signature msg=%lu dest=%s payload_len=%u\n", + static_cast(pending->message_id), + destination_hash, + static_cast(packet.payload_len)); + return false; + } + + const MessageId message_id = pending->message_id; + const uint32_t elapsed_ms = millis() - pending->created_ms; + runtime::removePendingDeliveryReceipt(transport_, + packet.destination_hash); + Serial.printf("[LXMF][DirectTX] proof_ok msg=%lu representation=opportunistic elapsed_ms=%lu hops=%u\n", + static_cast(message_id), + static_cast(elapsed_ms), + static_cast(packet.hops)); + sys::EventBus::publish( + new sys::ChatSendResultEvent(message_id, + MessageStatus::Delivered), + 0); + return true; + } + if (runtime::PendingPingReceipt* pending = runtime::findPendingPingReceipt(transport_, packet.destination_hash)) { - const uint8_t* signature = nullptr; - if (packet.payload_len == reticulum::kSignatureSize) - { - signature = packet.payload; - } - else if (packet.payload_len == - (reticulum::kFullHashSize + reticulum::kSignatureSize) && - packet.payload && - hashesEqual(packet.payload, - pending->packet_hash, - reticulum::kFullHashSize)) - { - signature = packet.payload + reticulum::kFullHashSize; - } + const uint8_t* signature = + proof_signature_for_hash(pending->packet_hash); const bool valid = signature && @@ -3916,7 +5006,9 @@ bool LxmfAdapter::handleProofPacket( } reverse->created_ms = 0; - return interfaces_.sendPacket(forward_packet, forward_len); + return interfaces_.sendPacketOn(reverse->interface_id, + forward_packet, + forward_len); } bool LxmfAdapter::handleLinkRequestPacket( @@ -3947,7 +5039,7 @@ bool LxmfAdapter::handleLinkRequestPacket( if (local_destination) { if (local_kind == LocalDestinationKind::CallAudio && - ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway) + ingress_interface == reticulum::interfaces::InterfaceKind::LoRa) { return false; } @@ -4029,20 +5121,17 @@ bool LxmfAdapter::handleLinkRequestPacket( session->created_ms = millis(); session->request_ms = session->created_ms; session->last_inbound_ms = session->created_ms; + session->interface_id = active_ingress_interface_id_; session->destination = local_kind; session->initiator = false; session->state = LinkState::Handshake; if (local_kind == LocalDestinationKind::CallAudio) { session->call_wire_profile = - config_.reticulum_call_wire_profile; - session->lxst_profile = - reticulum::lxst::kProfileBandwidthLow; - session->lxst_local_status = - reticulum::lxst::kStatusCalling; - session->lxst_remote_status = - reticulum::lxst::kStatusCalling; - session->call_status_ms = session->created_ms; + ReticulumCallWireProfile::SidebandLxst; + session->lxst_call = reticulum::lxst::call::makeCallee( + call_profile::kEmbeddedLxstProfile, + session->created_ms); } if (!deriveLinkKey(*session)) @@ -4088,32 +5177,24 @@ bool LxmfAdapter::handleLinkRequestPacket( { char link_hash[12] = {}; formatHashPrefix(session->link_id, link_hash, sizeof(link_hash)); - bool ui_started = false; - if (session->call_wire_profile == - ReticulumCallWireProfile::MeshChatCallAudio) - { - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, - session->link_id, - sizeof(call_peer.link_id)); - call_peer.incoming = true; - call_peer.wire_profile = - callRuntimeWireProfile(session->call_wire_profile); - call_peer.codec2_mode = callRuntimeCodec2Mode( - session->call_wire_profile, - session->lxst_profile); - ui_started = - ::platform::ui::reticulum_call::begin_incoming(call_peer); - session->call_runtime_started = ui_started; - } - Serial.printf("[LXMF][CallRX] link_admitted link=%s wire=%u ui=%u await_identify=%u iface=%u\n", + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, + session->link_id, + sizeof(call_peer.link_id)); + call_peer.incoming = true; + call_peer.wire_profile = + call_profile::runtimeWireProfile( + session->call_wire_profile); + call_peer.codec2_mode = call_profile::runtimeCodec2Mode( + session->call_wire_profile, + session->lxst_call.profile); + const bool ui_started = + ::platform::ui::reticulum_call::begin_incoming_identifying( + call_peer); + session->call_runtime_started = ui_started; + Serial.printf("[LXMF][CallRX] link_admitted link=%s wire=sideband_lxst ui=%u await_identify=1 iface=%u\n", link_hash, - static_cast(session->call_wire_profile), ui_started ? 1U : 0U, - session->call_wire_profile == - ReticulumCallWireProfile::SidebandLxst - ? 1U - : 0U, static_cast(ingress_interface)); } } @@ -4135,6 +5216,8 @@ bool LxmfAdapter::handleLinkRequestPacket( if (computeLinkIdFromLinkRequest(raw_packet, raw_len, packet, link_id)) { LinkRelayEntry& relay = upsertLinkRelay(link_id); + relay.initiator_interface_id = active_ingress_interface_id_; + relay.responder_interface_id = path->interface_id; relay.initiator_hops = packet.hops; relay.responder_hops = path->hops; relay.last_seen_ms = millis(); @@ -4159,7 +5242,9 @@ bool LxmfAdapter::handleLinkRequestPacket( return false; } - return interfaces_.sendPacket(forward_packet, forward_len); + return interfaces_.sendPacketOn(path->interface_id, + forward_packet, + forward_len); } uint8_t forward_packet[kMaxPacketLen] = {}; @@ -4179,7 +5264,9 @@ bool LxmfAdapter::handleLinkRequestPacket( return false; } - return interfaces_.sendPacket(forward_packet, forward_len); + return interfaces_.sendPacketOn(path->interface_id, + forward_packet, + forward_len); } bool LxmfAdapter::handlePathRequestPacket(const reticulum::ParsedPacket& packet) @@ -4272,10 +5359,20 @@ bool LxmfAdapter::handleLocalLinkPacket( return false; } if (session->destination == LocalDestinationKind::CallAudio && - ingress_interface != reticulum::interfaces::InterfaceKind::WifiGateway) + ingress_interface == reticulum::interfaces::InterfaceKind::LoRa) { return false; } + if (session->interface_id != reticulum::interfaces::kInvalidInterfaceId && + active_ingress_interface_id_ != reticulum::interfaces::kInvalidInterfaceId && + session->interface_id != active_ingress_interface_id_) + { + return false; + } + if (session->interface_id == reticulum::interfaces::kInvalidInterfaceId) + { + session->interface_id = active_ingress_interface_id_; + } session->last_inbound_ms = millis(); if (packet.packet_type == reticulum::PacketType::Proof) @@ -4320,7 +5417,10 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, { if (session.destination == LocalDestinationKind::Propagation) { - handled = handlePropagationBatch(session, payload_ptr, payload_len); + handled = rtnet::active().propagation.service_enabled && + handlePropagationBatch(session, + payload_ptr, + payload_len); } else if (session.destination == LocalDestinationKind::CallAudio) { @@ -4376,37 +5476,12 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, updateCallRuntimePeer(session, peer); if (!session.initiator && session.call_wire_profile == - ReticulumCallWireProfile::SidebandLxst && - !session.call_runtime_started) + ReticulumCallWireProfile::SidebandLxst) { - const bool ringing_started = - beginIncomingCallRuntime(session, *peer); - const bool ringing_sent = - ringing_started && - sendLxstSignal( - session, - reticulum::lxst::kStatusRinging, - true); - if (!ringing_sent) - { - if (!ringing_started) - { - (void)sendLxstSignal( - session, - reticulum::lxst::kStatusBusy, - true); - } - (void)sendLinkPacket( - session, - reticulum::PacketType::Data, - reticulum::PacketContext::LinkClose, - session.link_id, - sizeof(session.link_id), - true, - true); - closeLinkSession(session, - LinkCloseReason::LocalClose); - } + handled = dispatchLxstCallEvent( + session, + {reticulum::lxst::call::EventType:: + RemoteIdentified}); } } } @@ -4425,7 +5500,8 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, if (session.destination == LocalDestinationKind::Propagation) { - handled = handlePropagationRequest(session, + handled = rtnet::active().propagation.service_enabled && + handlePropagationRequest(session, request, request_id, sizeof(request_id)); @@ -4477,19 +5553,18 @@ bool LxmfAdapter::handleLinkDataPacket(LinkSession& session, session.state = LinkState::Active; if (session.destination == LocalDestinationKind::CallAudio) { + ::platform::ui::reticulum_call::mark_link_active( + session.link_id); if (session.call_wire_profile == ReticulumCallWireProfile::SidebandLxst) { - handled = sendLxstSignal( + handled = dispatchLxstCallEvent( session, - reticulum::lxst::kStatusAvailable, - true); + {reticulum::lxst::call::EventType::LinkActive}); } else { (void)sendLinkIdentify(session); - ::platform::ui::reticulum_call::mark_link_active( - session.link_id); } } else @@ -4631,7 +5706,6 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, uint8_t announce_sig_pub[LxmfIdentity::kSigPubKeySize] = {}; bool announce_identity_known = false; if (!peer_sig_pub && - session.destination == LocalDestinationKind::CallAudio && !isZeroBytes(session.peer_identity_sig_pub, sizeof(session.peer_identity_sig_pub))) { @@ -4842,18 +5916,24 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, if (session.destination == LocalDestinationKind::CallAudio) { updateCallRuntimePeer(session, peer); + ::platform::ui::reticulum_call::mark_link_active( + session.link_id); if (session.call_wire_profile == ReticulumCallWireProfile::MeshChatCallAudio) { (void)sendLinkIdentify(session); - ::platform::ui::reticulum_call::mark_link_active( - session.link_id); } else { - session.call_status_ms = millis(); + (void)dispatchLxstCallEvent( + session, + {reticulum::lxst::call::EventType::LinkActive}); } } + else if (session.destination == LocalDestinationKind::Propagation) + { + (void)sendLinkIdentify(session); + } flushDeferredLinkPayloads(session); return rtt_sent; } @@ -4863,7 +5943,57 @@ bool LxmfAdapter::handleLinkProofPacket(LinkSession& session, return handleLinkResourceProof(session, packet); } - return false; + if (packet.context != static_cast(reticulum::PacketContext::None) || + packet.payload_len != + (reticulum::kFullHashSize + reticulum::kSignatureSize)) + { + return false; + } + + const uint8_t* proved_hash = packet.payload; + auto receipt = std::find_if( + session.pending_packet_receipts.begin(), + session.pending_packet_receipts.end(), + [proved_hash](const runtime::LinkPacketReceipt& candidate) + { + return hashesEqual(candidate.packet_hash, + proved_hash, + reticulum::kFullHashSize); + }); + if (receipt == session.pending_packet_receipts.end()) + { + return false; + } + + const uint8_t* peer_signing_key = + session.initiator ? session.peer_identity_sig_pub + : session.peer_link_sig_pub; + if (isZeroBytes(peer_signing_key, LxmfIdentity::kSigPubKeySize) || + !LxmfIdentity::verify(peer_signing_key, + packet.payload + reticulum::kFullHashSize, + proved_hash, + reticulum::kFullHashSize)) + { + return false; + } + + const uint32_t message_id = receipt->message_id; + session.pending_packet_receipts.erase(receipt); + if (message_id != 0) + { + Serial.printf("[LXMF][%s] proof_ok msg=%lu representation=packet\n", + session.destination == LocalDestinationKind::Propagation + ? "PropagationTX" + : "DirectTX", + static_cast(message_id)); + const MessageStatus status = + session.destination == LocalDestinationKind::Delivery + ? MessageStatus::Delivered + : MessageStatus::Sent; + sys::EventBus::publish(new sys::ChatSendResultEvent(message_id, status), + 0); + } + return true; } bool LxmfAdapter::handleLinkResourceAdvertisement(LinkSession& session, @@ -5577,7 +6707,12 @@ bool LxmfAdapter::handleLinkResourcePart(LinkSession& session, } else if (session.destination == LocalDestinationKind::Propagation) { - (void)handlePropagationBatch(session, payload_data.data(), payload_data.size()); + if (rtnet::active().propagation.service_enabled) + { + (void)handlePropagationBatch(session, + payload_data.data(), + payload_data.size()); + } } return true; @@ -5610,7 +6745,28 @@ bool LxmfAdapter::handleLinkResourceProof(LinkSession& session, return false; } - return runtime::markResourceProofReceived(*resource, expected_proof, millis()); + if (!runtime::markResourceProofReceived(*resource, expected_proof, millis())) + { + return false; + } + + const uint32_t message_id = resource->message_id; + resource->message_id = 0; + if (message_id != 0) + { + Serial.printf("[LXMF][%s] proof_ok msg=%lu representation=resource\n", + session.destination == LocalDestinationKind::Propagation + ? "PropagationTX" + : "DirectTX", + static_cast(message_id)); + const MessageStatus status = + session.destination == LocalDestinationKind::Delivery + ? MessageStatus::Delivered + : MessageStatus::Sent; + sys::EventBus::publish(new sys::ChatSendResultEvent(message_id, status), + 0); + } + return true; } bool LxmfAdapter::handlePropagationBatch(LinkSession& session, @@ -5739,7 +6895,9 @@ bool LxmfAdapter::handlePropagationRequest(LinkSession& session, } bool LxmfAdapter::acceptPropagatedDelivery(const uint8_t* propagated_payload, - size_t propagated_payload_len) + size_t propagated_payload_len, + uint8_t* out_message_hash, + bool* out_awaiting_commit) { if (!propagated_payload || propagated_payload_len <= (reticulum::kEncryptionPublicKeySize + reticulum::kTokenOverhead)) @@ -5791,7 +6949,12 @@ bool LxmfAdapter::acceptPropagatedDelivery(const uint8_t* propagated_payload, plaintext.size()); } - return acceptVerifiedEnvelope(lxmf_message.data(), lxmf_message.size(), nullptr, 0); + return acceptVerifiedEnvelope(lxmf_message.data(), + lxmf_message.size(), + nullptr, + 0, + out_message_hash, + out_awaiting_commit); } bool LxmfAdapter::maybeForwardTransportPacket(const uint8_t* raw_packet, size_t raw_len, @@ -5824,7 +6987,9 @@ bool LxmfAdapter::maybeForwardTransportPacket(const uint8_t* raw_packet, size_t { uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; reticulum::computeTruncatedPacketHash(raw_packet, raw_len, proof_hash); - rememberReversePath(proof_hash, path->hops); + rememberReversePath(proof_hash, + active_ingress_interface_id_, + path->hops); } if (path->hops <= 1 || path->direct) @@ -5846,7 +7011,9 @@ bool LxmfAdapter::maybeForwardTransportPacket(const uint8_t* raw_packet, size_t return false; } - return interfaces_.sendPacket(forward_packet, forward_len); + return interfaces_.sendPacketOn(path->interface_id, + forward_packet, + forward_len); } uint8_t forward_packet[kMaxPacketLen] = {}; @@ -5866,7 +7033,9 @@ bool LxmfAdapter::maybeForwardTransportPacket(const uint8_t* raw_packet, size_t return false; } - return interfaces_.sendPacket(forward_packet, forward_len); + return interfaces_.sendPacketOn(path->interface_id, + forward_packet, + forward_len); } bool LxmfAdapter::maybeForwardLinkPacket(const uint8_t* raw_packet, size_t raw_len, @@ -5891,8 +7060,16 @@ bool LxmfAdapter::maybeForwardLinkPacket(const uint8_t* raw_packet, size_t raw_l return false; } - const bool from_initiator = (packet.hops == relay->initiator_hops); - const bool from_responder = (packet.hops == relay->responder_hops); + const bool from_initiator = + packet.hops == relay->initiator_hops && + (active_ingress_interface_id_ == + reticulum::interfaces::kInvalidInterfaceId || + active_ingress_interface_id_ == relay->initiator_interface_id); + const bool from_responder = + packet.hops == relay->responder_hops && + (active_ingress_interface_id_ == + reticulum::interfaces::kInvalidInterfaceId || + active_ingress_interface_id_ == relay->responder_interface_id); if (!from_initiator && !from_responder) { return false; @@ -5916,7 +7093,12 @@ bool LxmfAdapter::maybeForwardLinkPacket(const uint8_t* raw_packet, size_t raw_l } relay->last_seen_ms = millis(); - return interfaces_.sendPacket(forward_packet, forward_len); + const auto outbound_interface = from_initiator + ? relay->responder_interface_id + : relay->initiator_interface_id; + return interfaces_.sendPacketOn(outbound_interface, + forward_packet, + forward_len); } bool LxmfAdapter::sendProofForPacket(const uint8_t* raw_packet, size_t raw_len) @@ -5953,7 +7135,12 @@ bool LxmfAdapter::sendProofForPacket(const uint8_t* raw_packet, size_t raw_len) return false; } - return routeAndSendPacket(proof_packet, proof_len, false); + return active_ingress_interface_id_ != + reticulum::interfaces::kInvalidInterfaceId + ? interfaces_.sendPacketOn(active_ingress_interface_id_, + proof_packet, + proof_len) + : interfaces_.sendPacket(proof_packet, proof_len); } bool LxmfAdapter::sendPathRequest(PeerInfo& peer) @@ -6206,96 +7393,136 @@ LxmfAdapter::LinkSession* LxmfAdapter::ensureOutboundLinkSession(PeerInfo& peer, return &session; } +bool LxmfAdapter::prepareLinkRequest(LinkSession& session) +{ + if (isZeroBytes(session.remote_destination_hash, + sizeof(session.remote_destination_hash)) || + isZeroBytes(session.local_enc_pub, sizeof(session.local_enc_pub)) || + isZeroBytes(session.local_sig_pub, sizeof(session.local_sig_pub))) + { + return false; + } + + constexpr size_t kRequestPayloadLen = + kLinkRequestBaseLen + kLinkSignallingLen; + std::memset(link_request_payload_scratch_, 0, kRequestPayloadLen); + std::memcpy(link_request_payload_scratch_, + session.local_enc_pub, + LxmfIdentity::kEncPubKeySize); + std::memcpy(link_request_payload_scratch_ + LxmfIdentity::kEncPubKeySize, + session.local_sig_pub, + LxmfIdentity::kSigPubKeySize); + buildLinkSignallingBytes( + reticulum::kReticulumMtu, + link_request_payload_scratch_ + kLinkRequestBaseLen); + + link_request_packet_len_ = sizeof(link_request_packet_scratch_); + if (!reticulum::buildHeader1Packet( + reticulum::PacketType::LinkRequest, + reticulum::DestinationType::Single, + reticulum::PacketContext::None, + false, + session.remote_destination_hash, + link_request_payload_scratch_, + kRequestPayloadLen, + link_request_packet_scratch_, + &link_request_packet_len_)) + { + link_request_packet_len_ = 0; + return false; + } + + reticulum::ParsedPacket parsed{}; + uint8_t prepared_link_id[reticulum::kTruncatedHashSize] = {}; + if (!reticulum::parsePacket(link_request_packet_scratch_, + link_request_packet_len_, + &parsed) || + !computeLinkIdFromLinkRequest(link_request_packet_scratch_, + link_request_packet_len_, + parsed, + prepared_link_id)) + { + link_request_packet_len_ = 0; + return false; + } + if (!isZeroBytes(session.link_id, sizeof(session.link_id)) && + !hashesEqual(session.link_id, + prepared_link_id, + sizeof(prepared_link_id))) + { + link_request_packet_len_ = 0; + return false; + } + copyHash(session.link_id, prepared_link_id, sizeof(session.link_id)); + return true; +} + bool LxmfAdapter::sendLinkRequest(LinkSession& session) { - if (!isReady() || isZeroBytes(session.remote_destination_hash, sizeof(session.remote_destination_hash))) + char dest_hash[12] = {}; + formatHashPrefix(session.remote_destination_hash, dest_hash, sizeof(dest_hash)); + if (!isReady() || !prepareLinkRequest(session)) { - char dest_hash[12] = {}; - formatHashPrefix(session.remote_destination_hash, dest_hash, sizeof(dest_hash)); - Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=not_ready ready=%u\n", + Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=not_ready_or_prepare ready=%u\n", dest_hash, static_cast(session.destination), isReady() ? 1U : 0U); return false; } - char dest_hash[12] = {}; - formatHashPrefix(session.remote_destination_hash, dest_hash, sizeof(dest_hash)); - - uint8_t signalling[kLinkSignallingLen] = {}; - buildLinkSignallingBytes(reticulum::kReticulumMtu, signalling); - - uint8_t request_payload[kLinkRequestBaseLen + kLinkSignallingLen] = {}; - memcpy(request_payload, session.local_enc_pub, LxmfIdentity::kEncPubKeySize); - memcpy(request_payload + LxmfIdentity::kEncPubKeySize, - session.local_sig_pub, - LxmfIdentity::kSigPubKeySize); - memcpy(request_payload + kLinkRequestBaseLen, signalling, sizeof(signalling)); - - uint8_t packet[kMaxPacketLen] = {}; - size_t packet_len = sizeof(packet); - if (!reticulum::buildHeader1Packet(reticulum::PacketType::LinkRequest, - reticulum::DestinationType::Single, - reticulum::PacketContext::None, - false, - session.remote_destination_hash, - request_payload, - sizeof(request_payload), - packet, - &packet_len)) - { - Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=build\n", - dest_hash, - static_cast(session.destination)); - return false; - } - reticulum::ParsedPacket parsed{}; - if (!reticulum::parsePacket(packet, packet_len, &parsed) || !parsed.destination_hash) + if (!reticulum::parsePacket(link_request_packet_scratch_, + link_request_packet_len_, + &parsed) || + !parsed.destination_hash) { Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=parse raw_len=%u\n", dest_hash, static_cast(session.destination), - static_cast(packet_len)); + static_cast(link_request_packet_len_)); return false; } - uint8_t routed_packet[kMaxPacketLen] = {}; - const uint8_t* tx_packet = packet; - size_t tx_packet_len = packet_len; + const uint8_t* tx_packet = link_request_packet_scratch_; + size_t tx_packet_len = link_request_packet_len_; bool routed = false; const PathEntry* tx_path = findPath(parsed.destination_hash); - if (tx_path) + if (tx_path && tx_path->hops > 1 && !tx_path->direct) { - if (tx_path->hops > 1 && !tx_path->direct) + tx_packet_len = sizeof(link_request_routed_scratch_); + if (!reticulum::buildHeader2Packet( + parsed.packet_type, + parsed.destination_type, + static_cast(parsed.context), + parsed.context_flag != 0, + tx_path->next_hop_transport, + parsed.destination_hash, + parsed.payload, + parsed.payload_len, + link_request_routed_scratch_, + &tx_packet_len, + link_request_packet_scratch_[1])) { - tx_packet_len = sizeof(routed_packet); - if (!reticulum::buildHeader2Packet(parsed.packet_type, - parsed.destination_type, - static_cast(parsed.context), - parsed.context_flag != 0, - tx_path->next_hop_transport, - parsed.destination_hash, - parsed.payload, - parsed.payload_len, - routed_packet, - &tx_packet_len, - packet[1])) - { - Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=route_build raw_len=%u\n", - dest_hash, - static_cast(session.destination), - static_cast(packet_len)); - return false; - } - tx_packet = routed_packet; - routed = true; + Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=route_build raw_len=%u\n", + dest_hash, + static_cast(session.destination), + static_cast(link_request_packet_len_)); + return false; } + tx_packet = link_request_routed_scratch_; + routed = true; } reticulum::ParsedPacket tx_parsed{}; + uint8_t transmitted_link_id[reticulum::kTruncatedHashSize] = {}; if (!reticulum::parsePacket(tx_packet, tx_packet_len, &tx_parsed) || - !computeLinkIdFromLinkRequest(tx_packet, tx_packet_len, tx_parsed, session.link_id)) + !computeLinkIdFromLinkRequest(tx_packet, + tx_packet_len, + tx_parsed, + transmitted_link_id) || + !hashesEqual(session.link_id, + transmitted_link_id, + sizeof(transmitted_link_id))) { Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=link_id raw_len=%u routed=%u\n", dest_hash, @@ -6305,37 +7532,29 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) return false; } - session.request_ms = millis(); - const bool wifi_only = session.destination == LocalDestinationKind::CallAudio; - if (wifi_only) + const bool wifi_only = + session.destination == LocalDestinationKind::CallAudio; + if (wifi_only && !session.call_runtime_started) { - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); - copyHash(call_peer.destination_hash, - session.remote_destination_hash, - sizeof(call_peer.destination_hash)); - copyHash(call_peer.identity_hash, - session.remote_identity_hash, - sizeof(call_peer.identity_hash)); - call_peer.incoming = false; - call_peer.wire_profile = - callRuntimeWireProfile(session.call_wire_profile); - call_peer.codec2_mode = callRuntimeCodec2Mode( - session.call_wire_profile, - session.lxst_profile); - if (!::platform::ui::reticulum_call::begin_outgoing(call_peer)) - { - Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=call_realtime_busy\n", - dest_hash, - static_cast(session.destination)); - return false; - } - session.call_runtime_started = true; + Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=call_runtime_not_started\n", + dest_hash, + static_cast(session.destination)); + return false; } - const bool sent = wifi_only ? interfaces_.sendPacketWifiOnly(tx_packet, - tx_packet_len, - session.link_id) - : interfaces_.sendPacket(tx_packet, tx_packet_len); + + session.request_ms = millis(); + session.interface_id = tx_path ? tx_path->interface_id + : reticulum::interfaces::kInvalidInterfaceId; + const bool sent = + session.interface_id != reticulum::interfaces::kInvalidInterfaceId + ? interfaces_.sendPacketOn(session.interface_id, + tx_packet, + tx_packet_len, + wifi_only ? session.link_id : nullptr) + : (wifi_only ? interfaces_.sendPacketWifiOnly(tx_packet, + tx_packet_len, + session.link_id) + : interfaces_.sendPacket(tx_packet, tx_packet_len)); const auto& tx_result = interfaces_.lastTxResult(); if (sent) { @@ -6350,7 +7569,8 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) sizeof(next_hop)); const uint32_t now_s = currentTimestampSeconds(); const uint32_t path_age_s = - (tx_path && tx_path->last_seen_s != 0 && now_s >= tx_path->last_seen_s) + (tx_path && tx_path->last_seen_s != 0 && + now_s >= tx_path->last_seen_s) ? (now_s - tx_path->last_seen_s) : 0; Serial.printf("[LXMF][LinkTX] request_sent dest=%s kind=%u link=%s raw_len=%u routed=%u path_hops=%u path_direct=%u path_age_s=%lu next=%s announce=%u bearer=%s complete=%u\n", @@ -6363,16 +7583,13 @@ bool LxmfAdapter::sendLinkRequest(LinkSession& session) tx_path && tx_path->direct ? 1U : 0U, static_cast(path_age_s), next_hop, - static_cast(tx_path ? tx_path->cached_announce_len : 0U), + static_cast( + tx_path ? tx_path->cached_announce_len : 0U), txBearerName(tx_result), tx_result.reachedRequiredInterfaces() ? 1U : 0U); } else { - if (wifi_only) - { - ::platform::ui::reticulum_call::notify_link_closed(session.link_id); - } Serial.printf("[LXMF][LinkTX] request_fail dest=%s kind=%u reason=send raw_len=%u routed=%u bearer=%s complete=%u\n", dest_hash, static_cast(session.destination), @@ -6501,11 +7718,13 @@ bool LxmfAdapter::buildGroupMessagePacket( inout_len); } -bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, - const uint8_t* plaintext, size_t plaintext_len, - uint8_t* out_packet, size_t* inout_len) +bool LxmfAdapter::encryptForPeer(const PeerInfo& peer, + const uint8_t* plaintext, + size_t plaintext_len, + uint8_t* out_payload, + size_t* inout_len) { - if ((!plaintext && plaintext_len != 0) || !out_packet || !inout_len) + if ((!plaintext && plaintext_len != 0) || !out_payload || !inout_len) { return false; } @@ -6535,22 +7754,26 @@ bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, uint8_t iv[reticulum::kTokenIvSize] = {}; fillRandomBytes(iv, sizeof(iv)); - uint8_t encrypted_token[kMaxPacketLen] = {}; - size_t encrypted_token_len = sizeof(encrypted_token); - if (!reticulum::tokenEncrypt(derived_key, iv, plaintext, plaintext_len, - encrypted_token, &encrypted_token_len)) + const size_t required_len = + sizeof(ephemeral_pub) + reticulum::tokenSizeForPlaintext(plaintext_len); + if (*inout_len < required_len) { + *inout_len = required_len; return false; } - uint8_t payload[kMaxPacketLen] = {}; - const size_t payload_len = sizeof(ephemeral_pub) + encrypted_token_len; - if (payload_len > sizeof(payload)) + size_t encrypted_token_len = *inout_len - sizeof(ephemeral_pub); + if (!reticulum::tokenEncrypt(derived_key, + iv, + plaintext, + plaintext_len, + out_payload + sizeof(ephemeral_pub), + &encrypted_token_len)) { return false; } - memcpy(payload, ephemeral_pub, sizeof(ephemeral_pub)); - memcpy(payload + sizeof(ephemeral_pub), encrypted_token, encrypted_token_len); + memcpy(out_payload, ephemeral_pub, sizeof(ephemeral_pub)); + *inout_len = sizeof(ephemeral_pub) + encrypted_token_len; char dest_hash[12] = {}; char ratchet_id[12] = {}; @@ -6566,6 +7789,29 @@ bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, static_cast(plaintext_len), static_cast(encrypted_token_len)); + return true; +} + +bool LxmfAdapter::buildEncryptedPacketForPeer(const PeerInfo& peer, + const uint8_t* plaintext, size_t plaintext_len, + uint8_t* out_packet, size_t* inout_len) +{ + if ((!plaintext && plaintext_len != 0) || !out_packet || !inout_len) + { + return false; + } + + uint8_t payload[kMaxPacketLen] = {}; + size_t payload_len = sizeof(payload); + if (!encryptForPeer(peer, + plaintext, + plaintext_len, + payload, + &payload_len)) + { + return false; + } + return reticulum::buildHeader1Packet(reticulum::PacketType::Data, reticulum::DestinationType::Single, reticulum::PacketContext::None, @@ -6586,8 +7832,15 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, return false; } - auto send_packet = [&](const uint8_t* data, size_t len) -> bool + auto send_packet = [&](const uint8_t* data, + size_t len, + reticulum::interfaces::InterfaceId interface_id = + reticulum::interfaces::kInvalidInterfaceId) -> bool { + if (interface_id != reticulum::interfaces::kInvalidInterfaceId) + { + return interfaces_.sendPacketOn(interface_id, data, len); + } return wifi_only ? interfaces_.sendPacketWifiOnly(data, len) : interfaces_.sendPacket(data, len); }; @@ -6612,10 +7865,14 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, } const PathEntry* path = findPath(parsed.destination_hash); - if (!path || path->hops <= 1 || path->direct) + if (!path) { return send_packet(raw_packet, raw_len); } + if (path->hops <= 1 || path->direct) + { + return send_packet(raw_packet, raw_len, path->interface_id); + } uint8_t routed_packet[kMaxPacketLen] = {}; size_t routed_len = sizeof(routed_packet); @@ -6634,7 +7891,7 @@ bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, return false; } - return send_packet(routed_packet, routed_len); + return send_packet(routed_packet, routed_len, path->interface_id); } bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, @@ -6669,7 +7926,12 @@ bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, return false; } - return interfaces_.sendPacket(packet, packet_len); + return active_ingress_interface_id_ != + reticulum::interfaces::kInvalidInterfaceId + ? interfaces_.sendPacketOn(active_ingress_interface_id_, + packet, + packet_len) + : interfaces_.sendPacket(packet, packet_len); } bool LxmfAdapter::sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kFullHashSize]) @@ -6687,14 +7949,21 @@ bool LxmfAdapter::sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kF } if (hashesEqual(path.cached_packet_hash, packet_hash, reticulum::kFullHashSize)) { - return interfaces_.sendPacket(path.cached_announce, path.cached_announce_len); + return active_ingress_interface_id_ != + reticulum::interfaces::kInvalidInterfaceId + ? interfaces_.sendPacketOn(active_ingress_interface_id_, + path.cached_announce, + path.cached_announce_len) + : interfaces_.sendPacket(path.cached_announce, + path.cached_announce_len); } } return false; } -bool LxmfAdapter::shouldProcessWifiIngressPacket(const reticulum::ParsedPacket& packet) +bool LxmfAdapter::shouldProcessWifiIngressPacket(const reticulum::ParsedPacket& packet, + const RuntimeBudget& budget) { if (!packet.destination_hash) { @@ -6752,8 +8021,9 @@ bool LxmfAdapter::shouldProcessWifiIngressPacket(const reticulum::ParsedPacket& { return true; } - return screen_runtime::is_sleeping() && - !screen_runtime::is_saver_active(); + return budget.allow_public_discovery || + (screen_runtime::is_sleeping() && + !screen_runtime::is_saver_active()); } return false; @@ -7011,9 +8281,15 @@ void LxmfAdapter::rememberPacket(const uint8_t packet_hash[reticulum::kFullHashS } void LxmfAdapter::rememberReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize], + reticulum::interfaces::InterfaceId interface_id, uint8_t expected_hops) { - runtime::rememberReversePath(transport_, proof_hash, expected_hops, millis(), kMaxReverseEntries); + runtime::rememberReversePath(transport_, + proof_hash, + interface_id, + expected_hops, + millis(), + kMaxReverseEntries); } LxmfAdapter::ReverseEntry* LxmfAdapter::findReversePath( @@ -7072,6 +8348,25 @@ void LxmfAdapter::cullTransportState() 100); } + for (const auto& receipt : transport_.pending_delivery_receipts) + { + if (receipt.created_ms == 0 || + (now_ms - receipt.created_ms) <= + kPendingDeliveryReceiptTtlMs) + { + continue; + } + char destination_hash[12] = {}; + formatHashPrefix(receipt.destination_hash, + destination_hash, + sizeof(destination_hash)); + Serial.printf("[LXMF][DirectTX] proof_timeout msg=%lu dest=%s elapsed_ms=%lu status=sent\n", + static_cast(receipt.message_id), + destination_hash, + static_cast(now_ms - + receipt.created_ms)); + } + const runtime::TransportRuntimeLimits limits{ kMaxPaths, kMaxPacketFilter, @@ -7084,7 +8379,9 @@ void LxmfAdapter::cullTransportState() kLinkRelayTtlMs, kMaxPendingPingReceipts, kPathTtlMs, - kPendingPingReceiptTtlMs}; + kPendingPingReceiptTtlMs, + kMaxPendingDeliveryReceipts, + kPendingDeliveryReceiptTtlMs}; runtime::cullTransportRuntime(transport_, now_ms, limits); cullLinkSessions(); } @@ -7131,15 +8428,7 @@ void LxmfAdapter::localDestinationHash(LocalDestinationKind kind, } else if (kind == LocalDestinationKind::CallAudio) { - if (config_.reticulum_call_wire_profile == - ReticulumCallWireProfile::MeshChatCallAudio) - { - reticulum::computeNameHash("call", "audio", name_hash); - } - else - { - reticulum::computeNameHash("lxst", "telephony", name_hash); - } + reticulum::computeNameHash("lxst", "telephony", name_hash); } else { @@ -7169,7 +8458,8 @@ bool LxmfAdapter::isLocalDestinationHash(const uint8_t hash[reticulum::kTruncate uint8_t propagation_hash[reticulum::kTruncatedHashSize] = {}; localDestinationHash(LocalDestinationKind::Propagation, propagation_hash); - if (hashesEqual(hash, propagation_hash, sizeof(propagation_hash))) + if (rtnet::active().propagation.service_enabled && + hashesEqual(hash, propagation_hash, sizeof(propagation_hash))) { if (out_kind) { @@ -7313,7 +8603,8 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, reticulum::PacketContext context, const uint8_t* payload, size_t payload_len, bool encrypt_payload, - bool call_admission_control) + bool call_admission_control, + uint8_t out_packet_hash[reticulum::kFullHashSize]) { if (!isReady() || (!payload && payload_len != 0)) { @@ -7348,15 +8639,30 @@ bool LxmfAdapter::sendLinkPacket(LinkSession& session, return false; } - const bool ok = session.destination == LocalDestinationKind::CallAudio - ? interfaces_.sendPacketWifiOnly(packet, - packet_len, - session.link_id, - call_admission_control) - : interfaces_.sendPacket(packet, packet_len); + const bool has_bound_interface = + session.interface_id != reticulum::interfaces::kInvalidInterfaceId; + const bool ok = has_bound_interface + ? interfaces_.sendPacketOn(session.interface_id, + packet, + packet_len, + session.destination == + LocalDestinationKind::CallAudio + ? session.link_id + : nullptr, + call_admission_control) + : (session.destination == LocalDestinationKind::CallAudio + ? interfaces_.sendPacketWifiOnly(packet, + packet_len, + session.link_id, + call_admission_control) + : interfaces_.sendPacket(packet, packet_len)); if (ok) { session.last_outbound_ms = millis(); + if (out_packet_hash) + { + reticulum::computePacketHash(packet, packet_len, out_packet_hash); + } } return ok; } @@ -7424,7 +8730,12 @@ bool LxmfAdapter::sendNomadPageRequestPacket( reticulum::computeTruncatedPacketHash(nomad_page_packet_scratch_, packet_len, request.request_id); - const bool ok = interfaces_.sendPacket(nomad_page_packet_scratch_, packet_len); + const bool ok = + session.interface_id != reticulum::interfaces::kInvalidInterfaceId + ? interfaces_.sendPacketOn(session.interface_id, + nomad_page_packet_scratch_, + packet_len) + : interfaces_.sendPacket(nomad_page_packet_scratch_, packet_len); if (!ok) { return false; @@ -8128,9 +9439,14 @@ bool LxmfAdapter::queueOutgoingResource(LinkSession& session, const uint8_t* data, size_t len, uint8_t flags, const uint8_t* request_id, - size_t request_id_len) + size_t request_id_len, + uint32_t message_id) { - if (!data || len == 0 || session.mdu == 0 || (request_id_len != 0 && !request_id)) + if (!data || len == 0 || session.mdu == 0 || (request_id_len != 0 && !request_id) || + len > std::numeric_limits::max() || + len > (std::numeric_limits::max() - kResourceDataPrefixLen) || + len > (std::numeric_limits::max() - sizeof(runtime::LinkResourceTransfer::random_hash)) || + len > (std::numeric_limits::max() - reticulum::kFullHashSize)) { return false; } @@ -8178,6 +9494,7 @@ bool LxmfAdapter::queueOutgoingResource(LinkSession& session, { return false; } + resource.message_id = message_id; bool mapped = false; for (size_t attempt = 0; attempt < 8 && !mapped; ++attempt) @@ -8259,408 +9576,17 @@ bool LxmfAdapter::queueOutgoingResource(LinkSession& session, return true; } -void LxmfAdapter::updateCallRuntimePeer(LinkSession& session, - const PeerInfo* peer) -{ - if (session.destination != LocalDestinationKind::CallAudio) - { - return; - } - - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); - copyHash(call_peer.identity_hash, - session.remote_identity_hash, - sizeof(call_peer.identity_hash)); - if (peer) - { - copyHash(call_peer.destination_hash, - peer->destination_hash, - sizeof(call_peer.destination_hash)); - call_peer.display_name = - peer->display_name[0] != '\0' ? peer->display_name : nullptr; - } - else - { - copyHash(call_peer.destination_hash, - session.remote_destination_hash, - sizeof(call_peer.destination_hash)); - } - call_peer.incoming = !session.initiator; - call_peer.wire_profile = - callRuntimeWireProfile(session.call_wire_profile); - call_peer.codec2_mode = callRuntimeCodec2Mode( - session.call_wire_profile, - session.lxst_profile); - ::platform::ui::reticulum_call::update_peer(call_peer); -} - -bool LxmfAdapter::beginIncomingCallRuntime(LinkSession& session, - const PeerInfo& peer) -{ - if (session.destination != LocalDestinationKind::CallAudio || - session.initiator || - !session.remote_identity_known) - { - return false; - } - if (session.call_runtime_started) - { - return true; - } - - ::platform::ui::reticulum_call::Peer call_peer{}; - copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); - copyHash(call_peer.destination_hash, - peer.destination_hash, - sizeof(call_peer.destination_hash)); - copyHash(call_peer.identity_hash, - peer.identity_hash, - sizeof(call_peer.identity_hash)); - call_peer.display_name = - peer.display_name[0] != '\0' ? peer.display_name : nullptr; - call_peer.incoming = true; - call_peer.wire_profile = - callRuntimeWireProfile(session.call_wire_profile); - call_peer.codec2_mode = callRuntimeCodec2Mode( - session.call_wire_profile, - session.lxst_profile); - session.call_runtime_started = - ::platform::ui::reticulum_call::begin_incoming(call_peer); - - char link_hash[12] = {}; - formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); - Serial.printf("[LXMF][CallRX] identified link=%s wire=%u runtime=%u peer=%s\n", - link_hash, - static_cast(session.call_wire_profile), - session.call_runtime_started ? 1U : 0U, - call_peer.display_name ? call_peer.display_name : ""); - return session.call_runtime_started; -} - -bool LxmfAdapter::sendLxstSignal(LinkSession& session, - uint16_t signal, - bool call_admission_control) -{ - if (session.destination != LocalDestinationKind::CallAudio || - session.call_wire_profile != ReticulumCallWireProfile::SidebandLxst) - { - return false; - } - - size_t payload_len = sizeof(call_wire_scratch_); - if (!reticulum::lxst::encodeSignalling(signal, - call_wire_scratch_, - &payload_len)) - { - return false; - } - const bool sent = sendLinkPacket(session, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - call_wire_scratch_, - payload_len, - true, - call_admission_control); - if (sent) - { - if (signal <= reticulum::lxst::kStatusEstablished) - { - session.lxst_local_status = signal; - session.call_status_ms = millis(); - } - if (signal >= reticulum::lxst::kPreferredProfile) - { - session.lxst_preferred_sent = true; - } - } - - char link_hash[12] = {}; - formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); - Serial.printf("[LXMF][CallTX] lxst_signal link=%s signal=%u ok=%u\n", - link_hash, - static_cast(signal), - sent ? 1U : 0U); - return sent; -} - -bool LxmfAdapter::handleLxstPacket(LinkSession& session, - const uint8_t* payload, - size_t payload_len) -{ - reticulum::lxst::DecodedPacket decoded{}; - if (!reticulum::lxst::decodePacket(payload, payload_len, &decoded)) - { - return false; - } - - bool handled = false; - bool terminate = false; - for (size_t index = 0; index < decoded.signal_count; ++index) - { - const uint16_t signal = decoded.signals[index]; - handled = true; - if (signal >= reticulum::lxst::kPreferredProfile) - { - const uint16_t requested_profile = - signal - reticulum::lxst::kPreferredProfile; - reticulum::audio_call::Codec2Mode requested_mode{}; - const bool supported = reticulum::lxst::profileToCodec2Mode( - requested_profile, - &requested_mode); - const auto call_snapshot = - ::platform::ui::reticulum_call::snapshot(); - if (supported && !call_snapshot.media_active) - { - session.lxst_profile = requested_profile; - updateCallRuntimePeer( - session, - findPeerByIdentityHash(session.remote_identity_hash)); - } - else if (!supported || - requested_profile != session.lxst_profile) - { - (void)sendLxstSignal( - session, - reticulum::lxst::kPreferredProfile + - session.lxst_profile, - true); - } - continue; - } - - session.lxst_remote_status = signal; - session.call_status_ms = millis(); - if (signal == reticulum::lxst::kStatusBusy || - signal == reticulum::lxst::kStatusRejected) - { - terminate = true; - } - else if (signal == reticulum::lxst::kStatusAvailable && - session.initiator) - { - (void)sendLinkIdentify(session); - } - else if (signal == reticulum::lxst::kStatusRinging && - session.initiator) - { - (void)sendLxstSignal( - session, - reticulum::lxst::kPreferredProfile + - session.lxst_profile, - true); - } - else if ((signal == reticulum::lxst::kStatusConnecting || - signal == reticulum::lxst::kStatusEstablished) && - session.initiator) - { - ::platform::ui::reticulum_call::mark_link_active( - session.link_id); - } - } - - const reticulum::audio_call::Codec2Mode expected_mode = - callAudioCodec2Mode(session.lxst_profile); - for (size_t index = 0; index < decoded.frame_count; ++index) - { - const auto& frame = decoded.frames[index]; - if (frame.codec != reticulum::lxst::kCodec2 || - !frame.codec2_mode_valid || - frame.codec2_mode != expected_mode || - !frame.encoded || frame.encoded_len == 0) - { - continue; - } - - size_t normalized_len = sizeof(call_wire_scratch_); - if (reticulum::audio_call::encodePayload(frame.codec2_mode, - frame.encoded, - frame.encoded_len, - call_wire_scratch_, - &normalized_len) && - ::platform::ui::reticulum_call::enqueue_inbound_audio( - session.link_id, - call_wire_scratch_, - normalized_len)) - { - handled = true; - } - } - - if (terminate) - { - (void)sendLinkPacket(session, - reticulum::PacketType::Data, - reticulum::PacketContext::LinkClose, - session.link_id, - sizeof(session.link_id), - true, - true); - closeLinkSession(session, LinkCloseReason::RemoteClose); - } - return handled; -} - -bool LxmfAdapter::sendCallAudioPacket(LinkSession& session, - const uint8_t* payload, - size_t payload_len) -{ - if (session.call_wire_profile == - ReticulumCallWireProfile::MeshChatCallAudio) - { - return sendLinkPacket(session, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - payload, - payload_len, - true); - } - - reticulum::audio_call::DecodedPayload decoded{}; - if (!reticulum::audio_call::decodePayload(payload, - payload_len, - &decoded) || - decoded.mode != callAudioCodec2Mode(session.lxst_profile)) - { - return false; - } - - size_t lxst_len = sizeof(call_wire_scratch_); - if (!reticulum::lxst::encodeCodec2Frames(decoded.mode, - decoded.encoded, - decoded.encoded_len, - call_wire_scratch_, - &lxst_len)) - { - return false; - } - return sendLinkPacket(session, - reticulum::PacketType::Data, - reticulum::PacketContext::None, - call_wire_scratch_, - lxst_len, - true); -} - -void LxmfAdapter::pumpReticulumAudioCall() -{ - ::platform::ui::reticulum_call::set_wifi_ready( - interfaces_.hasReadyWifiGateway()); - - uint8_t hangup_link_id[reticulum::kTruncatedHashSize] = {}; - if (::platform::ui::reticulum_call::consume_hangup_request(hangup_link_id)) - { - if (LinkSession* session = findLinkSession(hangup_link_id)) - { - if (session->destination == LocalDestinationKind::CallAudio) - { - if (session->call_wire_profile == - ReticulumCallWireProfile::SidebandLxst && - !session->initiator && - session->lxst_local_status == - reticulum::lxst::kStatusRinging) - { - (void)sendLxstSignal( - *session, - reticulum::lxst::kStatusRejected, - true); - } - (void)sendLinkPacket(*session, - reticulum::PacketType::Data, - reticulum::PacketContext::LinkClose, - session->link_id, - sizeof(session->link_id), - true, - true); - closeLinkSession(*session, LinkCloseReason::LocalClose); - } - else - { - ::platform::ui::reticulum_call::notify_link_closed(hangup_link_id); - } - } - else - { - ::platform::ui::reticulum_call::notify_link_closed(hangup_link_id); - } - } - - auto call_snapshot = ::platform::ui::reticulum_call::snapshot(); - if (call_snapshot.wire_profile == - ::platform::ui::reticulum_call::WireProfile::SidebandLxst && - call_snapshot.realtime_phase != - ::platform::ui::reticulum_call::RealtimePhase::ClosingCall) - { - LinkSession* session = findLinkSession(call_snapshot.link_id); - if (session && - session->destination == LocalDestinationKind::CallAudio && - session->state == LinkState::Active && - session->call_wire_profile == - ReticulumCallWireProfile::SidebandLxst) - { - if (!session->initiator && - call_snapshot.accepted && - session->lxst_local_status == - reticulum::lxst::kStatusRinging && - sendLxstSignal(*session, - reticulum::lxst::kStatusConnecting, - true)) - { - ::platform::ui::reticulum_call::mark_link_active( - session->link_id); - call_snapshot = - ::platform::ui::reticulum_call::snapshot(); - } - - const bool setup_started = - session->lxst_local_status == - reticulum::lxst::kStatusConnecting || - (session->initiator && - session->lxst_remote_status >= - reticulum::lxst::kStatusConnecting); - if (call_snapshot.media_active && - setup_started && - session->lxst_local_status != - reticulum::lxst::kStatusEstablished) - { - (void)sendLxstSignal( - *session, - reticulum::lxst::kStatusEstablished, - true); - } - } - } - - uint8_t sent_packets = 0; - ::platform::ui::reticulum_call::AudioPacket audio{}; - while (sent_packets < 2 && - ::platform::ui::reticulum_call::dequeue_outbound_audio(&audio)) - { - LinkSession* session = findLinkSession(audio.link_id); - if (!session || - session->destination != LocalDestinationKind::CallAudio || - session->state != LinkState::Active) - { - continue; - } - - if (sendCallAudioPacket(*session, - audio.data, - audio.len)) - { - ::platform::ui::reticulum_call::note_tx_sent(); - ++sent_packets; - } - } -} - void LxmfAdapter::closeLinkSession(LinkSession& session, LinkCloseReason reason) { for (const auto& deferred : session.deferred_payloads) { if (deferred.message_id != 0) { - Serial.printf("[LXMF][DirectTX] deferred_failed msg=%lu reason=link_close close_reason=%u\n", + Serial.printf("[LXMF][%s] deferred_failed msg=%lu reason=link_close close_reason=%u\n", + session.destination == + LocalDestinationKind::Propagation + ? "PropagationTX" + : "DirectTX", static_cast(deferred.message_id), static_cast(reason)); sys::EventBus::publish( @@ -8668,6 +9594,26 @@ void LxmfAdapter::closeLinkSession(LinkSession& session, LinkCloseReason reason) } } + for (const auto& receipt : session.pending_packet_receipts) + { + if (receipt.message_id != 0) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(receipt.message_id, false), 0); + } + } + session.pending_packet_receipts.clear(); + + for (auto& resource : session.outgoing_resources) + { + if (resource.message_id != 0) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(resource.message_id, false), 0); + resource.message_id = 0; + } + } + const bool transitioned = runtime::closeLinkSession(session, reason, millis()); if (!transitioned) { @@ -8676,6 +9622,14 @@ void LxmfAdapter::closeLinkSession(LinkSession& session, LinkCloseReason reason) if (session.destination == LocalDestinationKind::CallAudio) { + if (session.call_wire_profile == + ReticulumCallWireProfile::SidebandLxst) + { + (void)reticulum::lxst::call::dispatch( + &session.lxst_call, + {reticulum::lxst::call::EventType::LinkClosed}, + millis()); + } ::platform::ui::reticulum_call::notify_link_closed(session.link_id); } @@ -8719,12 +9673,26 @@ void LxmfAdapter::flushDeferredLinkPayloads(LinkSession& session) bool sent = false; if (deferred.payload.size() <= session.mdu) { + uint8_t packet_hash[reticulum::kFullHashSize] = {}; sent = sendLinkPacket(session, reticulum::PacketType::Data, reticulum::PacketContext::None, deferred.payload.data(), deferred.payload.size(), - true); + true, + false, + deferred.message_id != 0 ? packet_hash + : nullptr); + if (sent && deferred.message_id != 0) + { + runtime::LinkPacketReceipt receipt{}; + copyHash(receipt.packet_hash, + packet_hash, + sizeof(receipt.packet_hash)); + receipt.message_id = deferred.message_id; + receipt.created_ms = millis(); + session.pending_packet_receipts.push_back(receipt); + } } else { @@ -8733,7 +9701,8 @@ void LxmfAdapter::flushDeferredLinkPayloads(LinkSession& session) deferred.payload.size(), deferred.resource_flags, deferred.request_id.empty() ? nullptr : deferred.request_id.data(), - deferred.request_id.size()); + deferred.request_id.size(), + deferred.message_id); } if (!sent) @@ -8741,6 +9710,14 @@ void LxmfAdapter::flushDeferredLinkPayloads(LinkSession& session) break; } + if (deferred.message_id != 0) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(deferred.message_id, + MessageStatus::Sent), + 0); + } + if (session.destination == LocalDestinationKind::Delivery) { (void)sendLinkIdentify(session); @@ -8748,11 +9725,16 @@ void LxmfAdapter::flushDeferredLinkPayloads(LinkSession& session) if (deferred.message_id != 0) { - Serial.printf("[LXMF][DirectTX] deferred_sent msg=%lu path=link payload_len=%u\n", + Serial.printf("[LXMF][%s] awaiting_proof msg=%lu path=link payload_len=%u representation=%s\n", + session.destination == + LocalDestinationKind::Propagation + ? "PropagationTX" + : "DirectTX", static_cast(deferred.message_id), - static_cast(deferred.payload.size())); - sys::EventBus::publish( - new sys::ChatSendResultEvent(deferred.message_id, true), 0); + static_cast(deferred.payload.size()), + deferred.payload.size() <= session.mdu + ? "packet" + : "resource"); } session.deferred_payloads.erase(session.deferred_payloads.begin()); @@ -8811,86 +9793,92 @@ void LxmfAdapter::cullLinkSessions() for (auto& session : links_.sessions) { - uint32_t call_timeout_ms = 0; if (session.destination == LocalDestinationKind::CallAudio && - session.state == LinkState::Active && - session.call_status_ms != 0) + session.state == LinkState::Active) { if (session.call_wire_profile == - ReticulumCallWireProfile::SidebandLxst) + ReticulumCallWireProfile::SidebandLxst && + reticulum::lxst::call::phaseTimedOut( + session.lxst_call, + now_ms)) { - if (session.initiator) - { - if (session.lxst_remote_status < - reticulum::lxst::kStatusRinging) - { - call_timeout_ms = kCallIdentifyTimeoutMs; - } - else if (session.lxst_remote_status == - reticulum::lxst::kStatusRinging) - { - call_timeout_ms = kCallDialTimeoutMs; - } - else if (session.lxst_remote_status == - reticulum::lxst::kStatusConnecting) - { - call_timeout_ms = kCallMediaSetupTimeoutMs; - } - } - else if (session.lxst_local_status == - reticulum::lxst::kStatusAvailable) - { - call_timeout_ms = kCallIdentifyTimeoutMs; - } - else if (session.lxst_local_status == - reticulum::lxst::kStatusRinging) - { - call_timeout_ms = kCallRingTimeoutMs; - } - else if (session.lxst_local_status == - reticulum::lxst::kStatusConnecting) - { - call_timeout_ms = kCallMediaSetupTimeoutMs; - } + char link_hash[12] = {}; + formatHashPrefix(session.link_id, + link_hash, + sizeof(link_hash)); + Serial.printf("[LXMF][Call] phase_timeout link=%s phase=%s local=%u remote=%u elapsed_ms=%lu\n", + link_hash, + reticulum::lxst::call::phaseName( + session.lxst_call.phase), + static_cast( + session.lxst_call.local_status), + static_cast( + session.lxst_call.remote_status), + static_cast( + now_ms - + session.lxst_call.phase_started_ms)); + (void)dispatchLxstCallEvent( + session, + {reticulum::lxst::call::EventType::Timeout}); + continue; } - else if (!session.initiator && - hashesEqual(call_snapshot.link_id, - session.link_id, - sizeof(session.link_id)) && - call_snapshot.realtime_phase == - ::platform::ui::reticulum_call:: - RealtimePhase::IncomingRinging) + if (session.call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio && + !session.initiator && + hashesEqual(call_snapshot.link_id, + session.link_id, + sizeof(session.link_id)) && + call_snapshot.realtime_phase == + ::platform::ui::reticulum_call:: + RealtimePhase::IncomingRinging && + now_ms - call_snapshot.updated_ms >= 60000) { - call_timeout_ms = kCallRingTimeoutMs; + (void)sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session.link_id, + sizeof(session.link_id), + true, + true); + closeLinkSession(session, LinkCloseReason::Timeout); + continue; } } - if (call_timeout_ms != 0 && - now_ms - session.call_status_ms >= call_timeout_ms) - { - char link_hash[12] = {}; - formatHashPrefix(session.link_id, - link_hash, - sizeof(link_hash)); - Serial.printf("[LXMF][Call] phase_timeout link=%s wire=%u local=%u remote=%u elapsed_ms=%lu\n", - link_hash, - static_cast(session.call_wire_profile), - static_cast(session.lxst_local_status), - static_cast(session.lxst_remote_status), - static_cast( - now_ms - session.call_status_ms)); - (void)sendLinkPacket(session, - reticulum::PacketType::Data, - reticulum::PacketContext::LinkClose, - session.link_id, - sizeof(session.link_id), - true, - true); - closeLinkSession(session, LinkCloseReason::Timeout); - continue; - } - runtime::cullLinkSessionTables(session, now_ms, limits); + session.pending_packet_receipts.erase( + std::remove_if( + session.pending_packet_receipts.begin(), + session.pending_packet_receipts.end(), + [now_ms](const runtime::LinkPacketReceipt& receipt) + { + if (now_ms - receipt.created_ms <= + kLinkPacketReceiptTtlMs) + { + return false; + } + if (receipt.message_id != 0) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(receipt.message_id, + false), + 0); + } + return true; + }), + session.pending_packet_receipts.end()); + for (auto& resource : session.outgoing_resources) + { + if (resource.message_id != 0 && + (resource.last_activity_ms == 0 || + now_ms - resource.last_activity_ms > + kResourceTransferTtlMs)) + { + sys::EventBus::publish( + new sys::ChatSendResultEvent(resource.message_id, false), 0); + resource.message_id = 0; + } + } runtime::cullLinkResources(session, now_ms, resource_limits); const runtime::LinkRuntimeMaintenance maintenance = runtime::advanceLinkSessionLifecycle(session, now_ms, limits); @@ -8960,8 +9948,18 @@ LxmfAdapter::PeerInfo* LxmfAdapter::rememberPeerIdentity( } bool LxmfAdapter::acceptVerifiedEnvelope(const uint8_t* plaintext, size_t plaintext_len, - const uint8_t* raw_packet, size_t raw_len) + const uint8_t* raw_packet, size_t raw_len, + uint8_t* out_message_hash, + bool* out_awaiting_commit) { + if (out_message_hash) + { + std::memset(out_message_hash, 0, reticulum::kFullHashSize); + } + if (out_awaiting_commit) + { + *out_awaiting_commit = false; + } ReticulumPeerIdentity conversation_identity{}; return acceptVerifiedEnvelopeForDestination(identity_.destinationHash(), conversation_identity, @@ -8970,7 +9968,9 @@ bool LxmfAdapter::acceptVerifiedEnvelope(const uint8_t* plaintext, size_t plaint plaintext, plaintext_len, raw_packet, - raw_len); + raw_len, + out_message_hash, + out_awaiting_commit); } bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( @@ -8979,8 +9979,14 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( bool destination_is_group, bool encrypted, const uint8_t* plaintext, size_t plaintext_len, - const uint8_t* raw_packet, size_t raw_len) + const uint8_t* raw_packet, size_t raw_len, + uint8_t* out_message_hash, + bool* out_awaiting_commit) { + if (out_awaiting_commit) + { + *out_awaiting_commit = false; + } char expected_hash[12] = {}; formatHashPrefix(expected_destination_hash, expected_hash, sizeof(expected_hash)); Serial.printf("[LXMF][%sRX] envelope begin dest=%s plaintext_len=%u raw_len=%u encrypted=%u\n", @@ -9069,11 +10075,21 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( static_cast(envelope.packed_payload.size())); return false; } + if (out_message_hash) + { + copyHash(out_message_hash, + message_hash, + reticulum::kFullHashSize); + } PeerInfo* peer = findOrLoadPeerByDestinationHash(envelope.source_hash); runtime::LxmfDeliveryContext delivery_context{}; delivery_context.local_node_id = identity_.nodeId(); delivery_context.message_id = messageIdFromHash(message_hash); + delivery_context.has_message_hash = true; + copyHash(delivery_context.message_hash, + message_hash, + sizeof(delivery_context.message_hash)); delivery_context.timestamp_s = currentTimestampSeconds(); delivery_context.destination_is_group = destination_is_group; delivery_context.encrypted = encrypted; @@ -9288,6 +10304,10 @@ bool LxmfAdapter::acceptVerifiedEnvelopeForDestination( ::chat::infra::IncomingQueuePriority::P0Critical, &report)) { + if (out_awaiting_commit) + { + *out_awaiting_commit = true; + } if (report.dropped_existing) { Serial.printf("[LXMF] RX text queue pressure evicted_prio=%u depth=%u\n", diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter_call.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter_call.cpp new file mode 100644 index 00000000..1c55ce40 --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter_call.cpp @@ -0,0 +1,521 @@ +/** + * @file lxmf_adapter_call.cpp + * @brief LXST call orchestration for the embedded LXMF adapter + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" + +#include "chat/infra/reticulum/audio_call_wire.h" +#include "chat/infra/reticulum/lxst_telephony_wire.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_call_profile.h" +#include "platform/ui/reticulum_call_runtime.h" + +#include +#include + +namespace chat::lxmf +{ +namespace +{ + +void copyHash(uint8_t* out, const uint8_t* in, size_t len) +{ + if (out && in && len != 0) + { + std::memcpy(out, in, len); + } +} + +void formatHashPrefix(const uint8_t* hash, char* out, size_t out_len) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!hash || out_len < 9) + { + std::snprintf(out, out_len, "-"); + return; + } + std::snprintf(out, + out_len, + "%02X%02X%02X%02X", + hash[0], + hash[1], + hash[2], + hash[3]); +} + +} // namespace + +void LxmfAdapter::updateCallRuntimePeer(LinkSession& session, + const PeerInfo* peer) +{ + if (session.destination != LocalDestinationKind::CallAudio) + { + return; + } + + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + copyHash(call_peer.identity_hash, + session.remote_identity_hash, + sizeof(call_peer.identity_hash)); + if (peer) + { + copyHash(call_peer.destination_hash, + peer->destination_hash, + sizeof(call_peer.destination_hash)); + call_peer.display_name = + peer->display_name[0] != '\0' ? peer->display_name : nullptr; + } + else + { + copyHash(call_peer.destination_hash, + session.remote_destination_hash, + sizeof(call_peer.destination_hash)); + } + call_peer.incoming = !session.initiator; + call_peer.wire_profile = + call_profile::runtimeWireProfile(session.call_wire_profile); + call_peer.codec2_mode = call_profile::runtimeCodec2Mode( + session.call_wire_profile, + session.lxst_call.profile); + ::platform::ui::reticulum_call::update_peer(call_peer); +} + +bool LxmfAdapter::beginIncomingCallRuntime(LinkSession& session, + const PeerInfo& peer) +{ + if (session.destination != LocalDestinationKind::CallAudio || + session.initiator || + !session.remote_identity_known) + { + return false; + } + ::platform::ui::reticulum_call::Peer call_peer{}; + copyHash(call_peer.link_id, session.link_id, sizeof(call_peer.link_id)); + copyHash(call_peer.destination_hash, + peer.destination_hash, + sizeof(call_peer.destination_hash)); + copyHash(call_peer.identity_hash, + peer.identity_hash, + sizeof(call_peer.identity_hash)); + call_peer.display_name = + peer.display_name[0] != '\0' ? peer.display_name : nullptr; + call_peer.incoming = true; + call_peer.wire_profile = + call_profile::runtimeWireProfile(session.call_wire_profile); + call_peer.codec2_mode = call_profile::runtimeCodec2Mode( + session.call_wire_profile, + session.lxst_call.profile); + session.call_runtime_started = + ::platform::ui::reticulum_call::begin_incoming(call_peer); + if (session.call_runtime_started && session.state == LinkState::Active) + { + ::platform::ui::reticulum_call::mark_link_active(session.link_id); + } + + char link_hash[12] = {}; + formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); + Serial.printf("[LXMF][CallRX] identified link=%s wire=%u runtime=%u peer=%s\n", + link_hash, + static_cast(session.call_wire_profile), + session.call_runtime_started ? 1U : 0U, + call_peer.display_name ? call_peer.display_name : ""); + return session.call_runtime_started; +} + +bool LxmfAdapter::sendLxstSignal(LinkSession& session, + uint16_t signal, + bool call_admission_control) +{ + if (session.destination != LocalDestinationKind::CallAudio || + session.call_wire_profile != ReticulumCallWireProfile::SidebandLxst) + { + return false; + } + + size_t payload_len = sizeof(call_wire_scratch_); + if (!reticulum::lxst::encodeSignalling(signal, + call_wire_scratch_, + &payload_len)) + { + return false; + } + const bool sent = sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + call_wire_scratch_, + payload_len, + true, + call_admission_control); + + char link_hash[12] = {}; + formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); + Serial.printf("[LXMF][CallTX] lxst_signal link=%s signal=%u ok=%u\n", + link_hash, + static_cast(signal), + sent ? 1U : 0U); + return sent; +} + +bool LxmfAdapter::dispatchLxstCallEvent( + LinkSession& session, + const reticulum::lxst::call::Event& event) +{ + if (session.destination != LocalDestinationKind::CallAudio || + session.call_wire_profile != ReticulumCallWireProfile::SidebandLxst) + { + return false; + } + + const auto previous_phase = session.lxst_call.phase; + const auto transition = reticulum::lxst::call::dispatch( + &session.lxst_call, + event, + millis()); + if (!transition.accepted) + { + return false; + } + + char link_hash[12] = {}; + formatHashPrefix(session.link_id, link_hash, sizeof(link_hash)); + Serial.printf("[LXMF][Call] transition link=%s event=%u phase=%s->%s actions=%u\n", + link_hash, + static_cast(event.type), + reticulum::lxst::call::phaseName(previous_phase), + reticulum::lxst::call::phaseName(session.lxst_call.phase), + static_cast(transition.action_count)); + + bool closes_link = false; + for (size_t index = 0; index < transition.action_count; ++index) + { + if (transition.actions[index] == + reticulum::lxst::call::Action::CloseLink) + { + closes_link = true; + break; + } + } + + for (size_t index = 0; index < transition.action_count; ++index) + { + const auto action = transition.actions[index]; + bool action_ok = true; + switch (action) + { + case reticulum::lxst::call::Action::SendAvailable: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kStatusAvailable, + true); + break; + case reticulum::lxst::call::Action::SendIdentify: + action_ok = sendLinkIdentify(session); + break; + case reticulum::lxst::call::Action::BeginRinging: + { + const PeerInfo* peer = + findPeerByIdentityHash(session.remote_identity_hash); + const bool admitted = + peer && beginIncomingCallRuntime(session, *peer); + return dispatchLxstCallEvent( + session, + {admitted + ? reticulum::lxst::call::EventType::AdmissionGranted + : reticulum::lxst::call::EventType::AdmissionDenied}); + } + case reticulum::lxst::call::Action::SendRinging: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kStatusRinging, + true); + break; + case reticulum::lxst::call::Action::SendPreferredProfile: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kPreferredProfile + + session.lxst_call.profile, + true); + break; + case reticulum::lxst::call::Action::SendConnecting: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kStatusConnecting, + true); + break; + case reticulum::lxst::call::Action::PrepareMedia: + { + const bool media_ready = + ::platform::ui::reticulum_call::prepare_media( + session.link_id); + return dispatchLxstCallEvent( + session, + {media_ready + ? reticulum::lxst::call::EventType::MediaReady + : reticulum::lxst::call::EventType::OperationFailed}); + } + case reticulum::lxst::call::Action::SendEstablished: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kStatusEstablished, + true); + break; + case reticulum::lxst::call::Action::ActivateMedia: + action_ok = + ::platform::ui::reticulum_call::mark_call_active( + session.link_id); + break; + case reticulum::lxst::call::Action::SendBusy: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kStatusBusy, + true); + break; + case reticulum::lxst::call::Action::SendRejected: + action_ok = sendLxstSignal( + session, + reticulum::lxst::kStatusRejected, + true); + break; + case reticulum::lxst::call::Action::CloseLink: + (void)sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session.link_id, + sizeof(session.link_id), + true, + true); + closeLinkSession(session, LinkCloseReason::LocalClose); + return true; + } + + if (!action_ok && !closes_link) + { + (void)dispatchLxstCallEvent( + session, + {reticulum::lxst::call::EventType::OperationFailed}); + return false; + } + } + return true; +} + +bool LxmfAdapter::handleLxstPacket(LinkSession& session, + const uint8_t* payload, + size_t payload_len) +{ + reticulum::lxst::DecodedPacket decoded{}; + if (!reticulum::lxst::decodePacket(payload, payload_len, &decoded)) + { + return false; + } + + bool handled = false; + for (size_t index = 0; index < decoded.signal_count; ++index) + { + const uint16_t signal = decoded.signals[index]; + handled = true; + (void)dispatchLxstCallEvent( + session, + reticulum::lxst::call::Event::remoteSignal(signal)); + if (signal >= reticulum::lxst::kPreferredProfile) + { + updateCallRuntimePeer( + session, + findPeerByIdentityHash(session.remote_identity_hash)); + } + } + + const reticulum::audio_call::Codec2Mode expected_mode = + call_profile::audioCodec2Mode(session.lxst_call.profile); + for (size_t index = 0; index < decoded.frame_count; ++index) + { + const auto& frame = decoded.frames[index]; + if (session.lxst_call.phase != + reticulum::lxst::call::Phase::Active || + frame.codec != reticulum::lxst::kCodec2 || + !frame.codec2_mode_valid || + frame.codec2_mode != expected_mode || + !frame.encoded || frame.encoded_len == 0) + { + continue; + } + + size_t normalized_len = sizeof(call_wire_scratch_); + if (reticulum::audio_call::encodePayload(frame.codec2_mode, + frame.encoded, + frame.encoded_len, + call_wire_scratch_, + &normalized_len) && + ::platform::ui::reticulum_call::enqueue_inbound_audio( + session.link_id, + call_wire_scratch_, + normalized_len)) + { + handled = true; + } + } + return handled; +} + +bool LxmfAdapter::sendCallAudioPacket(LinkSession& session, + const uint8_t* payload, + size_t payload_len) +{ + if (session.call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio) + { + return sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + payload, + payload_len, + true); + } + + reticulum::audio_call::DecodedPayload decoded{}; + if (!reticulum::audio_call::decodePayload(payload, + payload_len, + &decoded) || + decoded.mode != + call_profile::audioCodec2Mode(session.lxst_call.profile)) + { + return false; + } + + size_t lxst_len = sizeof(call_wire_scratch_); + if (!reticulum::lxst::encodeCodec2Frames(decoded.mode, + decoded.encoded, + decoded.encoded_len, + call_wire_scratch_, + &lxst_len)) + { + return false; + } + return sendLinkPacket(session, + reticulum::PacketType::Data, + reticulum::PacketContext::None, + call_wire_scratch_, + lxst_len, + true); +} + +void LxmfAdapter::pumpReticulumAudioCall() +{ + ::platform::ui::reticulum_call::set_wifi_ready( + interfaces_.hasReadyWifiGateway()); + + uint8_t hangup_link_id[reticulum::kTruncatedHashSize] = {}; + if (::platform::ui::reticulum_call::consume_hangup_request(hangup_link_id)) + { + if (LinkSession* session = findLinkSession(hangup_link_id)) + { + if (session->destination == LocalDestinationKind::CallAudio) + { + if (session->call_wire_profile == + ReticulumCallWireProfile::SidebandLxst) + { + (void)dispatchLxstCallEvent( + *session, + {reticulum::lxst::call::EventType::LocalHangup}); + } + else + { + (void)sendLinkPacket( + *session, + reticulum::PacketType::Data, + reticulum::PacketContext::LinkClose, + session->link_id, + sizeof(session->link_id), + true, + true); + closeLinkSession(*session, + LinkCloseReason::LocalClose); + } + } + else + { + ::platform::ui::reticulum_call::notify_link_closed( + hangup_link_id); + } + } + else + { + ::platform::ui::reticulum_call::notify_link_closed(hangup_link_id); + } + } + + auto call_snapshot = ::platform::ui::reticulum_call::snapshot(); + LinkSession* call_session = findLinkSession(call_snapshot.link_id); + if (call_session && + call_session->destination == LocalDestinationKind::CallAudio && + call_session->state == LinkState::Active) + { + ::platform::ui::reticulum_call::mark_link_active( + call_session->link_id); + call_snapshot = ::platform::ui::reticulum_call::snapshot(); + + if (call_snapshot.realtime_phase != + ::platform::ui::reticulum_call::RealtimePhase::ClosingCall) + { + if (call_session->call_wire_profile == + ReticulumCallWireProfile::MeshChatCallAudio && + call_snapshot.accepted && + call_snapshot.realtime_phase == + ::platform::ui::reticulum_call::RealtimePhase:: + AcceptedStarting) + { + if (::platform::ui::reticulum_call::prepare_media( + call_session->link_id)) + { + (void)::platform::ui::reticulum_call::mark_call_active( + call_session->link_id); + } + } + else if (call_session->call_wire_profile == + ReticulumCallWireProfile::SidebandLxst && + !call_session->initiator && + call_snapshot.accepted && + call_session->lxst_call.phase == + reticulum::lxst::call::Phase::CalleeRinging) + { + (void)dispatchLxstCallEvent( + *call_session, + {reticulum::lxst::call::EventType::LocalAccepted}); + } + } + } + + call_snapshot = ::platform::ui::reticulum_call::snapshot(); + if (call_snapshot.realtime_phase != + ::platform::ui::reticulum_call::RealtimePhase::ActiveCall) + { + return; + } + + uint8_t sent_packets = 0; + ::platform::ui::reticulum_call::AudioPacket audio{}; + while (sent_packets < 2 && + ::platform::ui::reticulum_call::dequeue_outbound_audio(&audio)) + { + LinkSession* session = findLinkSession(audio.link_id); + if (!session || + session->destination != LocalDestinationKind::CallAudio || + session->state != LinkState::Active) + { + continue; + } + + if (sendCallAudioPacket(*session, audio.data, audio.len)) + { + ::platform::ui::reticulum_call::note_tx_sent(); + ++sent_packets; + } + } +} + +} // namespace chat::lxmf diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp index a46583cd..638af690 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_delivery_runtime.cpp @@ -5,6 +5,7 @@ #include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_delivery_runtime.h" +#include #include namespace chat::lxmf::runtime @@ -27,6 +28,10 @@ bool materialiseLxmfTextDelivery(const DecodedTextPayload& payload, delivery.incoming.timestamp = context.timestamp_s; delivery.incoming.hop_limit = 0xFF; delivery.incoming.encrypted = context.encrypted; + delivery.incoming.has_reticulum_lxmf_hash = context.has_message_hash; + std::memcpy(delivery.incoming.reticulum_lxmf_hash, + context.message_hash, + sizeof(delivery.incoming.reticulum_lxmf_hash)); delivery.incoming.reticulum_identity = hasReticulumDestinationIdentity(context.conversation_identity) ? context.conversation_identity diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_runtime.cpp index cf419fd5..1206e6b3 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_runtime.cpp @@ -196,6 +196,44 @@ void markPropagationPeerSeen(PropagationPeerState& peer, uint32_t now_s) peer.last_seen_s = now_s; } +const PropagationPeerState* selectPropagationPeer( + const PropagationRuntime& propagation, + bool automatic, + const uint8_t configured_hash[reticulum::kTruncatedHashSize], + uint32_t now_s, + uint32_t peer_ttl_s) +{ + const PropagationPeerState* selected = nullptr; + for (const auto& peer : propagation.peers) + { + const bool expired = + peer.last_seen_s == 0 || now_s < peer.last_seen_s || + (peer_ttl_s != 0 && (now_s - peer.last_seen_s) > peer_ttl_s); + if (expired || !peer.node_active) + { + continue; + } + if (!automatic) + { + if (configured_hash && + hashesEqual(peer.propagation_hash, + configured_hash, + reticulum::kTruncatedHashSize)) + { + return &peer; + } + continue; + } + if (!selected || peer.hops < selected->hops || + (peer.hops == selected->hops && + peer.last_seen_s > selected->last_seen_s)) + { + selected = &peer; + } + } + return selected; +} + void notePropagationPeerIncomingMessage(PropagationPeerState& peer) { peer.incoming_messages += 1; @@ -270,6 +308,129 @@ void rememberPropagationTransient( propagation.transients.push_back(entry); } +bool awaitPropagationDeliveryCommit( + PropagationRuntime& propagation, + const uint8_t transient_id[reticulum::kFullHashSize], + const uint8_t message_hash[reticulum::kFullHashSize], + std::size_t max_pending) +{ + if (!transient_id || !message_hash || max_pending == 0) + { + return false; + } + + for (auto& pending : propagation.pending_deliveries) + { + if (hashesEqual(pending.transient_id, + transient_id, + reticulum::kFullHashSize)) + { + std::memcpy(pending.message_hash, + message_hash, + sizeof(pending.message_hash)); + pending.state = PropagationDeliveryCommitState::AwaitingPersistence; + return true; + } + } + + if (propagation.pending_deliveries.size() >= max_pending) + { + return false; + } + + PendingPropagationDelivery pending{}; + std::memcpy(pending.transient_id, + transient_id, + sizeof(pending.transient_id)); + std::memcpy(pending.message_hash, + message_hash, + sizeof(pending.message_hash)); + propagation.pending_deliveries.push_back(pending); + return true; +} + +bool commitPropagationDelivery( + PropagationRuntime& propagation, + const uint8_t message_hash[reticulum::kFullHashSize], + bool accepted, + uint32_t now_s, + std::size_t max_transients) +{ + if (!message_hash) + { + return false; + } + + bool matched = false; + for (auto& pending : propagation.pending_deliveries) + { + if (!hashesEqual(pending.message_hash, + message_hash, + reticulum::kFullHashSize)) + { + continue; + } + + pending.state = accepted ? PropagationDeliveryCommitState::Accepted + : PropagationDeliveryCommitState::Rejected; + matched = true; + if (accepted) + { + rememberPropagationTransient(propagation, + pending.transient_id, + true, + now_s, + max_transients); + const bool already_have = + std::any_of(propagation.sync_haves.begin(), + propagation.sync_haves.end(), + [&pending](const std::vector& transient_id) + { + return transient_id.size() == + reticulum::kFullHashSize && + hashesEqual(transient_id.data(), + pending.transient_id, + reticulum::kFullHashSize); + }); + if (!already_have) + { + propagation.sync_haves.emplace_back( + pending.transient_id, + pending.transient_id + reticulum::kFullHashSize); + } + } + } + return matched; +} + +bool propagationDeliveryCommitsResolved(const PropagationRuntime& propagation) +{ + return !propagation.pending_deliveries.empty() && + std::none_of(propagation.pending_deliveries.begin(), + propagation.pending_deliveries.end(), + [](const PendingPropagationDelivery& pending) + { + return pending.state == + PropagationDeliveryCommitState::AwaitingPersistence; + }); +} + +bool propagationDeliveryCommitRejected(const PropagationRuntime& propagation) +{ + return std::any_of(propagation.pending_deliveries.begin(), + propagation.pending_deliveries.end(), + [](const PendingPropagationDelivery& pending) + { + return pending.state == + PropagationDeliveryCommitState::Rejected; + }); +} + +void clearPropagationDeliveryCommits(PropagationRuntime& propagation) +{ + propagation.pending_deliveries.clear(); +} + bool rememberPropagationEntry( PropagationRuntime& propagation, const uint8_t transient_id[reticulum::kFullHashSize], diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_stamp_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_stamp_runtime.cpp new file mode 100644 index 00000000..1ef7063e --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_propagation_stamp_runtime.cpp @@ -0,0 +1,248 @@ +/** + * @file lxmf_propagation_stamp_runtime.cpp + * @brief Incremental official LXMF propagation stamp generation for ESP + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_propagation_stamp_runtime.h" +#include "platform/esp/common/mbedtls_sha256_compat.h" + +#include + +#include +#include + +namespace chat::lxmf::runtime +{ +namespace +{ + +std::size_t packRoundNumber(uint16_t value, uint8_t out[3]) +{ + if (value <= 0x7FU) + { + out[0] = static_cast(value); + return 1; + } + if (value <= 0xFFU) + { + out[0] = 0xCC; + out[1] = static_cast(value); + return 2; + } + out[0] = 0xCD; + out[1] = static_cast((value >> 8) & 0xFFU); + out[2] = static_cast(value & 0xFFU); + return 3; +} + +} // namespace + +PropagationStampRuntime::PropagationStampRuntime() +{ + mbedtls_sha256_init(&base_hash_); +} + +PropagationStampRuntime::~PropagationStampRuntime() +{ + reset(); + mbedtls_sha256_free(&base_hash_); +} + +bool PropagationStampRuntime::begin( + const uint8_t transient_id[reticulum::kFullHashSize], + uint8_t target_cost) +{ + if (!transient_id) + { + return false; + } + + reset(); + workblock_ = static_cast( + heap_caps_malloc_prefer(kWorkblockBytes, + 2, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT, + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + if (!workblock_) + { + state_ = State::Failed; + return false; + } + + std::memcpy(transient_id_, transient_id, sizeof(transient_id_)); + target_cost_ = target_cost; + state_ = State::Expanding; + return true; +} + +PropagationStampRuntime::State PropagationStampRuntime::poll( + uint16_t expand_round_budget, + uint16_t search_round_budget) +{ + while (state_ == State::Expanding && expand_round_budget-- != 0) + { + if (!expandOneRound()) + { + state_ = State::Failed; + break; + } + if (expanded_rounds_ == kExpandRounds && !prepareSearch()) + { + state_ = State::Failed; + break; + } + } + + while (state_ == State::Searching && search_round_budget-- != 0) + { + if (!searchOneRound()) + { + state_ = State::Failed; + break; + } + } + return state_; +} + +bool PropagationStampRuntime::takeStamp( + uint8_t out_stamp[reticulum::kFullHashSize]) +{ + if (!out_stamp || state_ != State::Complete) + { + return false; + } + std::memcpy(out_stamp, stamp_, sizeof(stamp_)); + reset(); + return true; +} + +void PropagationStampRuntime::reset() +{ + if (workblock_) + { + heap_caps_free(workblock_); + workblock_ = nullptr; + } + if (base_hash_ready_) + { + mbedtls_sha256_free(&base_hash_); + mbedtls_sha256_init(&base_hash_); + } + std::memset(transient_id_, 0, sizeof(transient_id_)); + std::memset(stamp_, 0, sizeof(stamp_)); + state_ = State::Idle; + expanded_rounds_ = 0; + search_rounds_ = 0; + target_cost_ = 0; + base_hash_ready_ = false; +} + +bool PropagationStampRuntime::expandOneRound() +{ + if (!workblock_ || expanded_rounds_ >= kExpandRounds) + { + return false; + } + + uint8_t round_number[3] = {}; + const std::size_t round_number_len = + packRoundNumber(expanded_rounds_, round_number); + uint8_t salt_material[reticulum::kFullHashSize + sizeof(round_number)] = {}; + std::memcpy(salt_material, transient_id_, sizeof(transient_id_)); + std::memcpy(salt_material + sizeof(transient_id_), + round_number, + round_number_len); + uint8_t salt[reticulum::kFullHashSize] = {}; + reticulum::fullHash(salt_material, + sizeof(transient_id_) + round_number_len, + salt); + + uint8_t* output = + workblock_ + static_cast(expanded_rounds_) * kRoundBytes; + if (!reticulum::hkdfSha256(transient_id_, + sizeof(transient_id_), + salt, + sizeof(salt), + nullptr, + 0, + output, + kRoundBytes)) + { + return false; + } + ++expanded_rounds_; + return true; +} + +bool PropagationStampRuntime::prepareSearch() +{ + if (!workblock_ || expanded_rounds_ != kExpandRounds) + { + return false; + } + if (::platform::esp::common::crypto::sha256_starts(&base_hash_, 0) != 0 || + ::platform::esp::common::crypto::sha256_update( + &base_hash_, workblock_, kWorkblockBytes) != 0) + { + return false; + } + base_hash_ready_ = true; + state_ = State::Searching; + return true; +} + +bool PropagationStampRuntime::searchOneRound() +{ + if (!base_hash_ready_) + { + return false; + } + + esp_fill_random(stamp_, sizeof(stamp_)); + mbedtls_sha256_context attempt; + mbedtls_sha256_init(&attempt); + mbedtls_sha256_clone(&attempt, &base_hash_); + uint8_t hash[reticulum::kFullHashSize] = {}; + const bool hashed = ::platform::esp::common::crypto::sha256_update( + &attempt, stamp_, sizeof(stamp_)) == 0 && + ::platform::esp::common::crypto::sha256_finish( + &attempt, hash) == 0; + mbedtls_sha256_free(&attempt); + if (!hashed) + { + return false; + } + + ++search_rounds_; + if (meetsTarget(hash, target_cost_)) + { + state_ = State::Complete; + } + return true; +} + +bool PropagationStampRuntime::meetsTarget( + const uint8_t hash[reticulum::kFullHashSize], + uint8_t target_cost) +{ + if (!hash || target_cost == 0) + { + return hash != nullptr; + } + uint8_t remaining = target_cost; + for (std::size_t index = 0; + index < reticulum::kFullHashSize && remaining != 0; + ++index) + { + const uint8_t bits = remaining >= 8 ? 8 : remaining; + const uint8_t mask = static_cast(0xFFU << (8U - bits)); + if ((hash[index] & mask) != 0) + { + return false; + } + remaining = static_cast(remaining - bits); + } + return remaining == 0; +} + +} // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp index 9dfc6076..b6de11f8 100644 --- a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_transport_runtime.cpp @@ -216,6 +216,7 @@ void forgetPacket(TransportRuntime& transport, void rememberReversePath(TransportRuntime& transport, const uint8_t proof_hash[reticulum::kTruncatedHashSize], + uint8_t interface_id, uint8_t expected_hops, uint32_t now_ms, std::size_t max_reverse_entries) @@ -229,6 +230,7 @@ void rememberReversePath(TransportRuntime& transport, { if (hashesEqual(entry.proof_hash, proof_hash, sizeof(entry.proof_hash))) { + entry.interface_id = interface_id; entry.expected_hops = expected_hops; entry.created_ms = now_ms; return; @@ -242,6 +244,7 @@ void rememberReversePath(TransportRuntime& transport, ReverseEntry entry{}; copyHash(entry.proof_hash, proof_hash, sizeof(entry.proof_hash)); + entry.interface_id = interface_id; entry.expected_hops = expected_hops; entry.created_ms = now_ms; transport.reverse_table.push_back(entry); @@ -428,6 +431,86 @@ void removePendingPingReceipt( transport.pending_ping_receipts.end()); } +void notePendingDeliveryReceipt( + TransportRuntime& transport, + const uint8_t packet_hash[reticulum::kFullHashSize], + const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t peer_sig_pub[LxmfIdentity::kSigPubKeySize], + MessageId message_id, + uint32_t now_ms, + std::size_t max_pending_delivery_receipts) +{ + if (!packet_hash || !destination_hash || !peer_sig_pub || message_id == 0) + { + return; + } + + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + copyHash(proof_hash, packet_hash, sizeof(proof_hash)); + removePendingDeliveryReceipt(transport, proof_hash); + if (max_pending_delivery_receipts != 0 && + transport.pending_delivery_receipts.size() >= + max_pending_delivery_receipts) + { + transport.pending_delivery_receipts.erase( + transport.pending_delivery_receipts.begin()); + } + + transport.pending_delivery_receipts.push_back(PendingDeliveryReceipt{}); + PendingDeliveryReceipt& receipt = + transport.pending_delivery_receipts.back(); + copyHash(receipt.packet_hash, packet_hash, sizeof(receipt.packet_hash)); + copyHash(receipt.proof_hash, proof_hash, sizeof(receipt.proof_hash)); + copyHash(receipt.destination_hash, + destination_hash, + sizeof(receipt.destination_hash)); + copyHash(receipt.peer_sig_pub, peer_sig_pub, sizeof(receipt.peer_sig_pub)); + receipt.message_id = message_id; + receipt.created_ms = now_ms == 0 ? 1U : now_ms; +} + +PendingDeliveryReceipt* findPendingDeliveryReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]) +{ + if (!proof_hash) + { + return nullptr; + } + for (auto& receipt : transport.pending_delivery_receipts) + { + if (receipt.created_ms != 0 && + hashesEqual(receipt.proof_hash, + proof_hash, + sizeof(receipt.proof_hash))) + { + return &receipt; + } + } + return nullptr; +} + +void removePendingDeliveryReceipt( + TransportRuntime& transport, + const uint8_t proof_hash[reticulum::kTruncatedHashSize]) +{ + if (!proof_hash) + { + return; + } + transport.pending_delivery_receipts.erase( + std::remove_if( + transport.pending_delivery_receipts.begin(), + transport.pending_delivery_receipts.end(), + [proof_hash](const PendingDeliveryReceipt& receipt) + { + return hashesEqual(receipt.proof_hash, + proof_hash, + sizeof(receipt.proof_hash)); + }), + transport.pending_delivery_receipts.end()); +} + PathEntry& upsertPath(TransportRuntime& transport, const uint8_t destination_hash[reticulum::kTruncatedHashSize], std::size_t max_paths) @@ -591,6 +674,32 @@ void cullTransportRuntime(TransportRuntime& transport, transport.pending_ping_receipts.begin(), transport.pending_ping_receipts.begin() + excess); } + + if (limits.pending_delivery_receipt_ttl_ms != 0) + { + transport.pending_delivery_receipts.erase( + std::remove_if( + transport.pending_delivery_receipts.begin(), + transport.pending_delivery_receipts.end(), + [now_ms, &limits](const PendingDeliveryReceipt& receipt) + { + return receipt.created_ms == 0 || + (now_ms - receipt.created_ms) > + limits.pending_delivery_receipt_ttl_ms; + }), + transport.pending_delivery_receipts.end()); + } + if (limits.max_pending_delivery_receipts != 0 && + transport.pending_delivery_receipts.size() > + limits.max_pending_delivery_receipts) + { + const std::size_t excess = + transport.pending_delivery_receipts.size() - + limits.max_pending_delivery_receipts; + transport.pending_delivery_receipts.erase( + transport.pending_delivery_receipts.begin(), + transport.pending_delivery_receipts.begin() + excess); + } } } // namespace chat::lxmf::runtime diff --git a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp index bfff70b3..69c9be47 100644 --- a/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp +++ b/platform/esp/arduino_common/src/chat/infra/mesh_adapter_router.cpp @@ -124,6 +124,29 @@ bool MeshAdapterRouter::pollIncomingText(MeshIncomingText* out) return lock.locked() && core_.pollIncomingText(out); } +IIncomingDeliveryCommitPort* MeshAdapterRouter::incomingDeliveryCommitPort() +{ + return this; +} + +void MeshAdapterRouter::commitIncomingText(const MeshIncomingText& message, + bool durably_accepted) +{ + LockGuard lock(mutex_); + if (!lock.locked()) + { + return; + } + + IMeshAdapter* backend = core_.backendForProtocol(core_.backendProtocol()); + IIncomingDeliveryCommitPort* commit_port = + backend ? backend->incomingDeliveryCommitPort() : nullptr; + if (commit_port) + { + commit_port->commitIncomingText(message, durably_accepted); + } +} + bool MeshAdapterRouter::sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, NodeId dest, bool want_ack, @@ -167,7 +190,14 @@ bool MeshAdapterRouter::submitKeyVerificationNumber(NodeId dest, uint64_t nonce, NodeId MeshAdapterRouter::getNodeId() const { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // The P4 IDF radio runtime can spend a full LoRa airtime inside + // processSendQueue() while holding the router lock. Node identity is a + // presentation read, so do not stall the LVGL task behind that work. + LockGuard lock(mutex_, 0); +#else LockGuard lock(mutex_); +#endif return lock.locked() ? core_.getNodeId() : 0; } @@ -179,7 +209,14 @@ bool MeshAdapterRouter::isPkiReady() const bool MeshAdapterRouter::getReticulumLocalIdentityInfo(ReticulumLocalIdentityInfo* out) const { +#if defined(TRAIL_MATE_ESP_BOARD_T_DISPLAY_P4) + // Settings may be opened while an announce is on air. A temporarily + // unavailable identity snapshot is preferable to blocking every LVGL + // input event until the synchronous transmit finishes. + LockGuard lock(mutex_, 0); +#else LockGuard lock(mutex_); +#endif if (!lock.locked()) { if (out) diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp index 795da7c5..5603d258 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_adapter.cpp @@ -118,6 +118,17 @@ bool ReticulumAdapter::pollIncomingText(MeshIncomingText* out) return service_->pollIncomingText(out); } +IIncomingDeliveryCommitPort* ReticulumAdapter::incomingDeliveryCommitPort() +{ + return this; +} + +void ReticulumAdapter::commitIncomingText(const MeshIncomingText& message, + bool durably_accepted) +{ + service_->commitIncomingText(message, durably_accepted); +} + bool ReticulumAdapter::sendAppData(ChannelId channel, uint32_t portnum, const uint8_t* payload, size_t len, NodeId dest, bool want_ack, diff --git a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp index 5f838f67..020435fe 100644 --- a/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp +++ b/platform/esp/arduino_common/src/chat/infra/reticulum/reticulum_interfaces.cpp @@ -22,7 +22,10 @@ #include #include #include +#include #include +#include +#include namespace chat::reticulum::interfaces { @@ -67,7 +70,10 @@ bool isPriorityRxFrame(const uint8_t* data, size_t len) if (parsed.packet_type == ::chat::reticulum::PacketType::LinkRequest || parsed.packet_type == ::chat::reticulum::PacketType::Proof || - parsed.destination_type == ::chat::reticulum::DestinationType::Link) + parsed.destination_type == ::chat::reticulum::DestinationType::Link || + (parsed.packet_type == ::chat::reticulum::PacketType::Announce && + parsed.context == static_cast( + ::chat::reticulum::PacketContext::PathResponse))) { return true; } @@ -133,10 +139,9 @@ LoRaReticulumInterface::LoRaReticulumInterface(LoraBoard& board) { } -void LoRaReticulumInterface::applyConfig(const MeshConfig& config) +void LoRaReticulumInterface::applyConfig(const MeshConfig& config, bool enabled) { - enabled_ = config.reticulum_lora_enabled && - config.reticulum_interface_policy != ReticulumInterfacePolicy::WifiGatewayOnly; + enabled_ = enabled; if (enabled_) { raw_.applyConfig(config); @@ -175,6 +180,7 @@ bool LoRaReticulumInterface::pollPacket(RxPacket* out) out->len = len; out->interface_kind = InterfaceKind::LoRa; + out->interface_id = kLoRaInterfaceId; fillTimestamp(&out->rx_meta); out->rx_meta.origin = RxOrigin::LoRa; out->rx_meta.direct = true; @@ -214,21 +220,29 @@ float LoRaReticulumInterface::lastRxSnr() const WifiGatewayReticulumInterface::WifiGatewayReticulumInterface() = default; -void WifiGatewayReticulumInterface::applyConfig(const MeshConfig& config) +void WifiGatewayReticulumInterface::applyConfig( + const reticulum::NetworkInterfaceConfig* config, + bool auto_connect_wifi, + InterfaceId interface_id) { - const bool next_enabled = config.reticulum_wifi_gateway_enabled && - config.reticulum_interface_policy != ReticulumInterfacePolicy::LoRaOnly; + const bool next_enabled = config && config->enabled && + config->type == + reticulum::NetworkInterfaceType::TcpClient; char next_host[kReticulumGatewayHostMaxLen + 1] = {}; - copyHost(next_host, sizeof(next_host), config.reticulum_wifi_gateway_host); + copyHost(next_host, + sizeof(next_host), + next_enabled ? config->target_host : nullptr); const uint16_t next_port = - config.reticulum_wifi_gateway_port != 0 ? config.reticulum_wifi_gateway_port : 4242; + next_enabled && config->target_port != 0 ? config->target_port : 4242; const bool changed = next_enabled != enabled_ || + interface_id != interface_id_ || next_port != port_ || std::strcmp(next_host, host_) != 0; enabled_ = next_enabled; - auto_connect_wifi_ = config.reticulum_wifi_auto_connect; + auto_connect_wifi_ = auto_connect_wifi; + interface_id_ = next_enabled ? interface_id : kInvalidInterfaceId; copyHost(host_, sizeof(host_), next_host); port_ = next_port; @@ -242,7 +256,8 @@ void WifiGatewayReticulumInterface::applyConfig(const MeshConfig& config) rx_queue_.clear(); } - Serial.printf("[Reticulum][IF][WiFi] enabled=%s host=%s port=%u auto_wifi=%s available=%s\n", + Serial.printf("[Reticulum][IF][TCP] id=%u enabled=%s host=%s port=%u auto_wifi=%s available=%s\n", + static_cast(interface_id_), boolLabel(enabled_), host_[0] != '\0' ? host_ : "", static_cast(port_), @@ -415,6 +430,7 @@ bool WifiGatewayReticulumInterface::pollPacket(RxPacket* out) out->len = poll_scratch_.len; out->rx_meta = poll_scratch_.rx_meta; out->interface_kind = InterfaceKind::WifiGateway; + out->interface_id = interface_id_; return true; } @@ -914,49 +930,772 @@ void WifiGatewayReticulumInterface::fillRxMeta(RxMeta* out) const out->cr = 0; } +AutoReticulumInterface::AutoReticulumInterface() = default; + +void AutoReticulumInterface::applyConfig( + const reticulum::NetworkInterfaceConfig* config, + bool auto_connect_wifi) +{ + const bool next_enabled = config && config->enabled && + config->type == + reticulum::NetworkInterfaceType::Auto; + const char* next_group = next_enabled ? config->group_id : "reticulum"; + const uint16_t next_discovery_port = + next_enabled && config->discovery_port != 0 ? config->discovery_port : 29716; + const uint16_t next_data_port = + next_enabled && config->data_port != 0 ? config->data_port : 42671; + const bool changed = next_enabled != enabled_ || + std::strcmp(next_group, group_id_) != 0 || + next_discovery_port != discovery_port_ || + next_data_port != data_port_; + + enabled_ = next_enabled; + auto_connect_wifi_ = auto_connect_wifi; + copyHost(group_id_, sizeof(group_id_), next_group); + discovery_port_ = next_discovery_port; + data_port_ = next_data_port; + if (changed) + { + stop(); + last_socket_attempt_ms_ = 0; + last_wifi_connect_ms_ = 0; + rx_queue_.clear(); + } + Serial.printf("[Reticulum][IF][Auto] enabled=%s group=%s discovery=%u data=%u available=%s\n", + boolLabel(enabled_), + group_id_, + static_cast(discovery_port_), + static_cast(data_port_), + boolLabel(TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE != 0)); +} + +void AutoReticulumInterface::setTransportEnabled(bool enabled) +{ + transport_enabled_ = enabled; + if (!transport_enabled_) + { + stop(); + } +} + +void AutoReticulumInterface::maintain() +{ + if (!enabled_ || !transport_enabled_) + { + stop(); + return; + } + + platform::ui::wifi::Status wifi_status = platform::ui::wifi::status(); + const uint32_t now_ms = millis(); + if (!wifi_status.connected && auto_connect_wifi_ && + (last_wifi_connect_ms_ == 0 || + now_ms - last_wifi_connect_ms_ >= 60000U)) + { + last_wifi_connect_ms_ = now_ms; + platform::ui::wifi_access::Request request{}; + request.client = platform::ui::wifi_access::Client::ReticulumGateway; + request.kind = platform::ui::wifi_access::AccessKind::WifiConnect; + request.priority = platform::ui::wifi_access::Priority::Messaging; + request.allow_connect = true; + request.reason = "reticulum_auto_interface"; + (void)platform::ui::wifi_access::ensure_connected(request, nullptr); + wifi_status = platform::ui::wifi::status(); + } + if (!wifi_status.connected) + { + stop(); + return; + } + + if (!sockets_ready_ && !ensureSockets()) + { + return; + } + + receiveDiscovery(discovery_socket_); + receiveDiscovery(unicast_discovery_socket_); + receiveData(); + cullPeers(now_ms); + if (last_announce_ms_ == 0 || now_ms - last_announce_ms_ >= kAnnounceIntervalMs) + { + last_announce_ms_ = now_ms; + sendPeerAnnounce(); + } + for (auto& peer : peers_) + { + if (peer.active && + (peer.last_reverse_announce_ms == 0 || + now_ms - peer.last_reverse_announce_ms >= + kReverseAnnounceIntervalMs)) + { + peer.last_reverse_announce_ms = now_ms; + sendReverseAnnounce(peer); + } + } +} + +bool AutoReticulumInterface::isReady() const +{ + return enabled_ && transport_enabled_ && sockets_ready_ && peerCount() != 0; +} + +bool AutoReticulumInterface::isConfigured() const +{ + return enabled_; +} + +bool AutoReticulumInterface::sendPacket(const uint8_t* data, + size_t len, + const uint8_t* call_link_id, + bool call_admission_control) +{ + if (!data || len == 0 || len > reticulum::kReticulumMtu) + { + return false; + } + maintain(); + bool sent = false; + for (const auto& peer : peers_) + { + if (peer.active) + { + sent = sendPacketOn(peer.interface_id, + data, + len, + call_link_id, + call_admission_control) || + sent; + } + } + return sent; +} + +bool AutoReticulumInterface::sendPacketOn(InterfaceId interface_id, + const uint8_t* data, + size_t len, + const uint8_t* call_link_id, + bool call_admission_control) +{ + if (!data || len == 0 || len > reticulum::kReticulumMtu) + { + return false; + } + Peer* peer = findPeer(interface_id); + if (!peer || data_socket_ < 0) + { + return false; + } + const auto budget = platform::ui::wifi_access::traffic_budget( + platform::ui::wifi_access::Client::ReticulumGateway, + platform::ui::wifi_access::Priority::Messaging, + call_link_id, + call_admission_control + ? platform::ui::wifi_access::AccessKind::ReticulumGatewayCallControl + : (call_link_id + ? platform::ui::wifi_access::AccessKind::ReticulumGatewayCallAudio + : platform::ui::wifi_access::AccessKind::LongLivedSocket)); + if (!budget.allow_write || budget.tx_byte_budget == 0) + { + return false; + } + +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + sockaddr_in6 destination{}; + destination.sin6_family = AF_INET6; + destination.sin6_port = htons(data_port_); + destination.sin6_scope_id = peer->scope_id; + std::memcpy(&destination.sin6_addr, + peer->address, + sizeof(peer->address)); + const int written = lwip_sendto(data_socket_, + data, + len, + 0, + reinterpret_cast(&destination), + sizeof(destination)); + if (written == static_cast(len)) + { + return true; + } + Serial.printf("[Reticulum][IF][Auto][TX] failed id=%u len=%u errno=%d\n", + static_cast(interface_id), + static_cast(len), + errno); +#else + (void)interface_id; +#endif + return false; +} + +bool AutoReticulumInterface::pollPacket(RxPacket* out) +{ + if (!out) + { + return false; + } + maintain(); + return rx_queue_.popOldest(out); +} + +bool AutoReticulumInterface::owns(InterfaceId interface_id) const +{ + return interface_id >= kAutoInterfaceIdBase && + interface_id < kAutoInterfaceIdBase + kMaxPeers; +} + +uint8_t AutoReticulumInterface::peerCount() const +{ + uint8_t count = 0; + for (const auto& peer : peers_) + { + if (peer.active) + { + ++count; + } + } + return count; +} + +void AutoReticulumInterface::stop() +{ + closeSockets(); + for (auto& peer : peers_) + { + peer = Peer{}; + } +} + +bool AutoReticulumInterface::ensureSockets() +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + const uint32_t now_ms = millis(); + if (last_socket_attempt_ms_ != 0 && + now_ms - last_socket_attempt_ms_ < 5000U) + { + return false; + } + last_socket_attempt_ms_ = now_ms; + closeSockets(); + + netif* selected = nullptr; + for (netif* item = netif_list; item != nullptr; item = item->next) + { + if (!netif_is_up(item) || !netif_is_link_up(item) || + !ip6_addr_isvalid(netif_ip6_addr_state(item, 0))) + { + continue; + } + char address[INET6_ADDRSTRLEN] = {}; + if (!ip6addr_ntoa_r(netif_ip6_addr(item, 0), address, sizeof(address)) || + std::strncmp(address, "fe80:", 5) != 0) + { + continue; + } + selected = item; + break; + } + if (!selected) + { + return false; + } + + interface_index_ = netif_get_index(selected); + char local_text[INET6_ADDRSTRLEN] = {}; + if (!ip6addr_ntoa_r(netif_ip6_addr(selected, 0), + local_text, + sizeof(local_text)) || + lwip_inet_pton(AF_INET6, local_text, local_address_) != 1) + { + return false; + } + calculateDiscoveryIdentity(); + + auto open_udp_socket = []() -> int + { + const int socket_fd = lwip_socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (socket_fd >= 0) + { + const int reuse = 1; + lwip_setsockopt(socket_fd, + SOL_SOCKET, + SO_REUSEADDR, + &reuse, + sizeof(reuse)); + const int flags = lwip_fcntl(socket_fd, F_GETFL, 0); + lwip_fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK); + } + return socket_fd; + }; + + discovery_socket_ = open_udp_socket(); + unicast_discovery_socket_ = open_udp_socket(); + data_socket_ = open_udp_socket(); + if (discovery_socket_ < 0 || unicast_discovery_socket_ < 0 || + data_socket_ < 0) + { + closeSockets(); + return false; + } + + sockaddr_in6 discovery_bind{}; + discovery_bind.sin6_family = AF_INET6; + discovery_bind.sin6_port = htons(discovery_port_); + discovery_bind.sin6_addr = in6addr_any; + ipv6_mreq membership{}; + std::memcpy(&membership.ipv6mr_multiaddr, + multicast_address_, + sizeof(multicast_address_)); + membership.ipv6mr_interface = interface_index_; + if (lwip_bind(discovery_socket_, + reinterpret_cast(&discovery_bind), + sizeof(discovery_bind)) != 0 || + lwip_setsockopt(discovery_socket_, + IPPROTO_IPV6, + IPV6_JOIN_GROUP, + &membership, + sizeof(membership)) != 0) + { + closeSockets(); + return false; + } + + sockaddr_in6 unicast_bind{}; + unicast_bind.sin6_family = AF_INET6; + unicast_bind.sin6_port = htons(static_cast(discovery_port_ + 1U)); + unicast_bind.sin6_scope_id = interface_index_; + std::memcpy(&unicast_bind.sin6_addr, + local_address_, + sizeof(local_address_)); + sockaddr_in6 data_bind = unicast_bind; + data_bind.sin6_port = htons(data_port_); + if (lwip_bind(unicast_discovery_socket_, + reinterpret_cast(&unicast_bind), + sizeof(unicast_bind)) != 0 || + lwip_bind(data_socket_, + reinterpret_cast(&data_bind), + sizeof(data_bind)) != 0) + { + closeSockets(); + return false; + } + + sockets_ready_ = true; + last_announce_ms_ = 0; + Serial.printf("[Reticulum][IF][Auto] online ifindex=%u peers=0\n", + static_cast(interface_index_)); + return true; +#else + return false; +#endif +} + +void AutoReticulumInterface::closeSockets() +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + if (discovery_socket_ >= 0) + { + lwip_close(discovery_socket_); + } + if (unicast_discovery_socket_ >= 0) + { + lwip_close(unicast_discovery_socket_); + } + if (data_socket_ >= 0) + { + lwip_close(data_socket_); + } +#endif + discovery_socket_ = -1; + unicast_discovery_socket_ = -1; + data_socket_ = -1; + sockets_ready_ = false; +} + +void AutoReticulumInterface::calculateDiscoveryIdentity() +{ + uint8_t group_hash[reticulum::kFullHashSize] = {}; + reticulum::fullHash(reinterpret_cast(group_id_), + std::strlen(group_id_), + group_hash); + std::memset(multicast_address_, 0, sizeof(multicast_address_)); + multicast_address_[0] = 0xFF; + multicast_address_[1] = 0x12; + for (std::size_t pair = 0; pair < 6; ++pair) + { + multicast_address_[4U + pair * 2U] = group_hash[2U + pair * 2U]; + multicast_address_[5U + pair * 2U] = group_hash[3U + pair * 2U]; + } + +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + char local_text[INET6_ADDRSTRLEN] = {}; + lwip_inet_ntop(AF_INET6, + local_address_, + local_text, + sizeof(local_text)); + std::array + material{}; + const std::size_t group_len = std::strlen(group_id_); + const std::size_t address_len = std::strlen(local_text); + std::memcpy(material.data(), group_id_, group_len); + std::memcpy(material.data() + group_len, local_text, address_len); + reticulum::fullHash(material.data(), + group_len + address_len, + discovery_token_); +#endif +} + +void AutoReticulumInterface::sendPeerAnnounce() +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + if (!sockets_ready_ || data_socket_ < 0) + { + return; + } + sockaddr_in6 destination{}; + destination.sin6_family = AF_INET6; + destination.sin6_port = htons(discovery_port_); + destination.sin6_scope_id = interface_index_; + std::memcpy(&destination.sin6_addr, + multicast_address_, + sizeof(multicast_address_)); + lwip_sendto(data_socket_, + discovery_token_, + sizeof(discovery_token_), + 0, + reinterpret_cast(&destination), + sizeof(destination)); +#endif +} + +void AutoReticulumInterface::sendReverseAnnounce(Peer& peer) +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + if (!peer.active || data_socket_ < 0) + { + return; + } + sockaddr_in6 destination{}; + destination.sin6_family = AF_INET6; + destination.sin6_port = htons(static_cast(discovery_port_ + 1U)); + destination.sin6_scope_id = peer.scope_id; + std::memcpy(&destination.sin6_addr, + peer.address, + sizeof(peer.address)); + lwip_sendto(data_socket_, + discovery_token_, + sizeof(discovery_token_), + 0, + reinterpret_cast(&destination), + sizeof(destination)); +#else + (void)peer; +#endif +} + +void AutoReticulumInterface::receiveDiscovery(int socket_fd) +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + if (socket_fd < 0) + { + return; + } + uint8_t token[reticulum::kFullHashSize] = {}; + for (uint8_t packet = 0; packet < 6; ++packet) + { + sockaddr_in6 source{}; + socklen_t source_len = sizeof(source); + const int read = lwip_recvfrom(socket_fd, + token, + sizeof(token), + 0, + reinterpret_cast(&source), + &source_len); + if (read < 0) + { + break; + } + if (read != static_cast(sizeof(token)) || + std::memcmp(&source.sin6_addr, + local_address_, + sizeof(local_address_)) == 0) + { + continue; + } + + char source_text[INET6_ADDRSTRLEN] = {}; + if (!lwip_inet_ntop(AF_INET6, + &source.sin6_addr, + source_text, + sizeof(source_text))) + { + continue; + } + std::array + material{}; + const std::size_t group_len = std::strlen(group_id_); + const std::size_t address_len = std::strlen(source_text); + std::memcpy(material.data(), group_id_, group_len); + std::memcpy(material.data() + group_len, source_text, address_len); + uint8_t expected[reticulum::kFullHashSize] = {}; + reticulum::fullHash(material.data(), + group_len + address_len, + expected); + if (std::memcmp(expected, token, sizeof(expected)) != 0) + { + continue; + } + (void)upsertPeer(reinterpret_cast(&source.sin6_addr), + source.sin6_scope_id != 0 ? source.sin6_scope_id + : interface_index_); + } +#else + (void)socket_fd; +#endif +} + +void AutoReticulumInterface::receiveData() +{ +#if TRAIL_MATE_RETICULUM_WIFI_CLIENT_AVAILABLE && LWIP_IPV6 + if (data_socket_ < 0) + { + return; + } + for (uint8_t packet = 0; packet < 6; ++packet) + { + sockaddr_in6 source{}; + socklen_t source_len = sizeof(source); + const int read = lwip_recvfrom(data_socket_, + rx_scratch_.data, + sizeof(rx_scratch_.data), + 0, + reinterpret_cast(&source), + &source_len); + if (read < 0) + { + break; + } + Peer* peer = nullptr; + for (auto& candidate : peers_) + { + if (candidate.active && + std::memcmp(candidate.address, + &source.sin6_addr, + sizeof(candidate.address)) == 0) + { + peer = &candidate; + break; + } + } + if (!peer || read <= 0 || + read > static_cast(reticulum::kReticulumMtu)) + { + continue; + } + peer->last_seen_ms = millis(); + rx_scratch_.len = static_cast(read); + rx_scratch_.interface_kind = InterfaceKind::Auto; + rx_scratch_.interface_id = peer->interface_id; + fillTimestamp(&rx_scratch_.rx_meta); + rx_scratch_.rx_meta.origin = RxOrigin::WiFi; + rx_scratch_.rx_meta.direct = true; + bool dropped = false; + rx_queue_.append(rx_scratch_, &dropped); + if (dropped) + { + Serial.printf("[Reticulum][IF][Auto][RX] queue_drop id=%u\n", + static_cast(peer->interface_id)); + } + } +#endif +} + +void AutoReticulumInterface::cullPeers(uint32_t now_ms) +{ + for (auto& peer : peers_) + { + if (peer.active && now_ms - peer.last_seen_ms > kPeerTimeoutMs) + { + Serial.printf("[Reticulum][IF][Auto] peer_timeout id=%u\n", + static_cast(peer.interface_id)); + peer = Peer{}; + } + } +} + +AutoReticulumInterface::Peer* AutoReticulumInterface::upsertPeer( + const uint8_t address[16], + uint32_t scope_id) +{ + if (!address) + { + return nullptr; + } + for (auto& peer : peers_) + { + if (peer.active && + std::memcmp(peer.address, address, sizeof(peer.address)) == 0) + { + peer.last_seen_ms = millis(); + peer.scope_id = scope_id; + return &peer; + } + } + for (std::size_t index = 0; index < peers_.size(); ++index) + { + auto& peer = peers_[index]; + if (!peer.active) + { + std::memcpy(peer.address, address, sizeof(peer.address)); + peer.scope_id = scope_id; + peer.last_seen_ms = millis(); + peer.last_reverse_announce_ms = 0; + peer.interface_id = static_cast(kAutoInterfaceIdBase + index); + peer.active = true; + Serial.printf("[Reticulum][IF][Auto] peer_added id=%u peers=%u\n", + static_cast(peer.interface_id), + static_cast(peerCount())); + return &peer; + } + } + return nullptr; +} + +AutoReticulumInterface::Peer* AutoReticulumInterface::findPeer( + InterfaceId interface_id) +{ + for (auto& peer : peers_) + { + if (peer.active && peer.interface_id == interface_id) + { + return &peer; + } + } + return nullptr; +} + ReticulumInterfaceSet::ReticulumInterfaceSet(LoraBoard& board) : lora_(board) { } -void ReticulumInterfaceSet::applyConfig(const MeshConfig& config) +void ReticulumInterfaceSet::applyConfig( + const MeshConfig& config, + const reticulum::ReticulumNetworkConfig& network_config) { config_ = config; - lora_.applyConfig(config_); - wifi_.applyConfig(config_); + network_config_ = network_config; + + bool lora_configured = false; + const reticulum::NetworkInterfaceConfig* auto_config = nullptr; + tcp_count_ = 0; +#if TRAIL_MATE_RETICULUM_C6_TCP_AVAILABLE + constexpr size_t tcp_capacity = 1; +#else + constexpr size_t tcp_capacity = reticulum::kMaxTcpClientInterfaces; +#endif + const size_t interface_count = std::min( + network_config_.interface_count, + reticulum::kMaxNetworkInterfaces); + for (size_t index = 0; index < interface_count; ++index) + { + const auto& interface_config = network_config_.interfaces[index]; + if (!interface_config.enabled) + { + continue; + } + switch (interface_config.type) + { + case reticulum::NetworkInterfaceType::IntegratedLoRa: + lora_configured = true; + break; + case reticulum::NetworkInterfaceType::Auto: + if (!auto_config) + { + auto_config = &interface_config; + } + break; + case reticulum::NetworkInterfaceType::TcpClient: + if (tcp_count_ < tcp_capacity) + { + tcp_[tcp_count_].applyConfig( + &interface_config, + config_.reticulum_wifi_auto_connect, + static_cast(kTcpClientInterfaceIdBase + + tcp_count_)); + ++tcp_count_; + } + break; + } + } + for (size_t index = tcp_count_; index < tcp_.size(); ++index) + { + tcp_[index].applyConfig(nullptr, + config_.reticulum_wifi_auto_connect, + kInvalidInterfaceId); + } + + lora_.applyConfig(config_, lora_configured); + auto_.applyConfig(auto_config, config_.reticulum_wifi_auto_connect); maintain(); syncSharedLoRaRxGate(); + Serial.printf("[Reticulum][IF] configured total=%u lora=%u auto=%u tcp=%u\n", + static_cast(interface_count), + lora_configured ? 1U : 0U, + auto_config ? 1U : 0U, + static_cast(tcp_count_)); } void ReticulumInterfaceSet::setWifiTransportEnabled(bool enabled) { - wifi_.setTransportEnabled(enabled); + auto_.setTransportEnabled(enabled); + for (auto& interface : tcp_) + { + interface.setTransportEnabled(enabled); + } syncSharedLoRaRxGate(); } void ReticulumInterfaceSet::maintain() { - if (wifiAllowed()) + auto_.maintain(); + for (uint8_t index = 0; index < tcp_count_; ++index) { - wifi_.maintain(); + tcp_[index].maintain(); } syncSharedLoRaRxGate(); } bool ReticulumInterfaceSet::hasReadyInterface() const { - return (loraSelectedForRuntime() && lora_.isReady()) || - (wifiSelectedForRuntime() && wifi_.isReady()); + if (loraEnabled() && lora_.isReady()) + { + return true; + } + return hasReadyWifiGateway(); } bool ReticulumInterfaceSet::hasReadyWifiGateway() const { - return wifiAllowed() && wifi_.isReady(); + if (auto_.isReady()) + { + return true; + } + for (uint8_t index = 0; index < tcp_count_; ++index) + { + if (tcp_[index].isReady()) + { + return true; + } + } + return false; } bool ReticulumInterfaceSet::wifiGatewayConfigured() const { - return wifiAllowed() && wifi_.isConfigured(); + return hasConfiguredIpInterface(); } bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) @@ -968,23 +1707,33 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) } maintain(); + last_tx_result_.lora_required = loraEnabled(); + last_tx_result_.lora_ready = + last_tx_result_.lora_required && lora_.isReady(); + last_tx_result_.wifi_required = hasConfiguredIpInterface(); + last_tx_result_.wifi_ready = hasReadyWifiGateway(); - last_tx_result_.lora_required = loraSelectedForRuntime(); - last_tx_result_.lora_ready = last_tx_result_.lora_required && lora_.isReady(); - last_tx_result_.wifi_required = wifiSelectedForRuntime() && wifi_.isConfigured(); - last_tx_result_.wifi_ready = last_tx_result_.wifi_required && wifi_.isReady(); - - if (last_tx_result_.lora_ready) + if (last_tx_result_.lora_ready && lora_.sendPacket(data, len)) { - last_tx_result_.lora_ok = lora_.sendPacket(data, len); + last_tx_result_.lora_ok = true; + ++last_tx_result_.sent_count; } - if (last_tx_result_.wifi_ready) + if (auto_.isReady() && auto_.sendPacket(data, len)) { - last_tx_result_.wifi_ok = wifi_.sendPacket(data, len); + last_tx_result_.wifi_ok = true; + ++last_tx_result_.sent_count; + } + for (uint8_t index = 0; index < tcp_count_; ++index) + { + if (tcp_[index].isReady() && tcp_[index].sendPacket(data, len)) + { + last_tx_result_.wifi_ok = true; + ++last_tx_result_.sent_count; + } } const bool sent = last_tx_result_.sent(); - Serial.printf("[Reticulum][IF][TX] raw_len=%u bearer=%s lora_req=%u lora_ready=%u lora=%u wifi_req=%u wifi_ready=%u wifi=%u sent=%u complete=%u\n", + Serial.printf("[Reticulum][IF][TX] raw_len=%u bearer=%s lora_req=%u lora_ready=%u lora=%u ip_req=%u ip_ready=%u ip=%u sent_count=%u sent=%u complete=%u\n", static_cast(len), txBearerName(last_tx_result_), last_tx_result_.lora_required ? 1U : 0U, @@ -993,42 +1742,117 @@ bool ReticulumInterfaceSet::sendPacket(const uint8_t* data, size_t len) last_tx_result_.wifi_required ? 1U : 0U, last_tx_result_.wifi_ready ? 1U : 0U, last_tx_result_.wifi_ok ? 1U : 0U, + static_cast(last_tx_result_.sent_count), sent ? 1U : 0U, last_tx_result_.reachedRequiredInterfaces() ? 1U : 0U); return sent; } -bool ReticulumInterfaceSet::sendPacketWifiOnly(const uint8_t* data, - size_t len, - const uint8_t* call_link_id, - bool call_admission_control) +bool ReticulumInterfaceSet::sendPacketOn(InterfaceId interface_id, + const uint8_t* data, + size_t len, + const uint8_t* call_link_id, + bool call_admission_control) { last_tx_result_ = {}; - if (!data || len == 0) + if (!data || len == 0 || interface_id == kInvalidInterfaceId) { return false; } maintain(); - - last_tx_result_.wifi_required = wifiAllowed() && wifi_.isConfigured(); - last_tx_result_.wifi_ready = last_tx_result_.wifi_required && wifi_.isReady(); - if (last_tx_result_.wifi_ready) + bool sent = false; + if (interface_id == kLoRaInterfaceId) { - last_tx_result_.wifi_ok = - wifi_.sendPacket(data, len, call_link_id, call_admission_control); + last_tx_result_.lora_required = true; + last_tx_result_.lora_ready = loraEnabled() && lora_.isReady(); + sent = last_tx_result_.lora_ready && lora_.sendPacket(data, len); + last_tx_result_.lora_ok = sent; + } + else + { + last_tx_result_.wifi_required = true; + if (auto_.owns(interface_id)) + { + last_tx_result_.wifi_ready = auto_.isReady(); + sent = last_tx_result_.wifi_ready && + auto_.sendPacketOn(interface_id, + data, + len, + call_link_id, + call_admission_control); + } + else + { + for (uint8_t index = 0; index < tcp_count_; ++index) + { + if (tcp_[index].interfaceId() != interface_id) + { + continue; + } + last_tx_result_.wifi_ready = tcp_[index].isReady(); + sent = last_tx_result_.wifi_ready && + tcp_[index].sendPacket(data, + len, + call_link_id, + call_admission_control); + break; + } + } + last_tx_result_.wifi_ok = sent; + } + if (sent) + { + last_tx_result_.sent_interface = interface_id; + last_tx_result_.sent_count = 1; + } + return sent; +} + +bool ReticulumInterfaceSet::sendPacketWifiOnly( + const uint8_t* data, + size_t len, + const uint8_t* call_link_id, + bool call_admission_control, + InterfaceId interface_id) +{ + if (interface_id != kInvalidInterfaceId) + { + return sendPacketOn(interface_id, + data, + len, + call_link_id, + call_admission_control); } - const bool sent = last_tx_result_.sent(); - Serial.printf("[Reticulum][IF][TX] raw_len=%u mode=wifi_only bearer=%s wifi_req=%u wifi_ready=%u wifi=%u sent=%u complete=%u\n", - static_cast(len), - txBearerName(last_tx_result_), - last_tx_result_.wifi_required ? 1U : 0U, - last_tx_result_.wifi_ready ? 1U : 0U, - last_tx_result_.wifi_ok ? 1U : 0U, - sent ? 1U : 0U, - last_tx_result_.reachedRequiredInterfaces() ? 1U : 0U); - return sent; + last_tx_result_ = {}; + if (!data || len == 0) + { + return false; + } + maintain(); + last_tx_result_.wifi_required = hasConfiguredIpInterface(); + last_tx_result_.wifi_ready = hasReadyWifiGateway(); + + if (auto_.isReady() && + auto_.sendPacket(data, len, call_link_id, call_admission_control)) + { + last_tx_result_.wifi_ok = true; + ++last_tx_result_.sent_count; + } + for (uint8_t index = 0; index < tcp_count_; ++index) + { + if (tcp_[index].isReady() && + tcp_[index].sendPacket(data, + len, + call_link_id, + call_admission_control)) + { + last_tx_result_.wifi_ok = true; + ++last_tx_result_.sent_count; + } + } + return last_tx_result_.sent(); } bool ReticulumInterfaceSet::pollIncomingPacket(RxPacket* out) @@ -1039,41 +1863,44 @@ bool ReticulumInterfaceSet::pollIncomingPacket(RxPacket* out) } maintain(); - - const bool lora_selected = loraSelectedForRuntime(); - const bool wifi_selected = wifiSelectedForRuntime(); - for (uint8_t i = 0; i < 2; ++i) + const uint8_t source_count = static_cast(2U + tcp_count_); + for (uint8_t offset = 0; offset < source_count; ++offset) { - const uint8_t index = static_cast((next_poll_index_ + i) % 2U); + const uint8_t index = + static_cast((next_poll_index_ + offset) % source_count); bool got = false; if (index == 0) { - got = lora_selected && lora_.pollPacket(out); + got = loraEnabled() && lora_.pollPacket(out); + } + else if (index == 1) + { + got = auto_.pollPacket(out); } else { - got = wifi_selected && wifi_.pollPacket(out); + got = tcp_[index - 2U].pollPacket(out); } if (got) { - next_poll_index_ = static_cast((index + 1U) % 2U); + next_poll_index_ = + static_cast((index + 1U) % source_count); last_rx_meta_ = out->rx_meta; has_last_rx_meta_ = true; return true; } } - return false; } bool ReticulumInterfaceSet::pollLegacyIncomingData(MeshIncomingData* out) { - return loraSelectedForRuntime() && lora_.pollLegacyIncomingData(out); + return loraEnabled() && lora_.pollLegacyIncomingData(out); } void ReticulumInterfaceSet::handleRawPacket(const uint8_t* data, size_t size) { - if (loraSelectedForRuntime()) + if (loraEnabled()) { lora_.handleRawPacket(data, size); } @@ -1102,41 +1929,47 @@ float ReticulumInterfaceSet::lastRxSnr() const return lora_.lastRxSnr(); } -bool ReticulumInterfaceSet::loraAllowed() const +bool ReticulumInterfaceSet::loraEnabled() const { - return config_.reticulum_lora_enabled && - config_.reticulum_interface_policy != ReticulumInterfacePolicy::WifiGatewayOnly; -} - -bool ReticulumInterfaceSet::wifiAllowed() const -{ - return config_.reticulum_wifi_gateway_enabled && - config_.reticulum_interface_policy != ReticulumInterfacePolicy::LoRaOnly; -} - -bool ReticulumInterfaceSet::loraSelectedForRuntime() const -{ - if (!loraAllowed() || ::platform::ui::reticulum_call::resource_preempt_active()) + if (::platform::ui::reticulum_call::resource_preempt_active()) { return false; } - return config_.reticulum_interface_policy != ReticulumInterfacePolicy::All || - !wifi_.isReady(); + const size_t interface_count = std::min( + network_config_.interface_count, + reticulum::kMaxNetworkInterfaces); + for (size_t index = 0; index < interface_count; ++index) + { + const auto& interface_config = network_config_.interfaces[index]; + if (interface_config.enabled && + interface_config.type == + reticulum::NetworkInterfaceType::IntegratedLoRa) + { + return true; + } + } + return false; } -bool ReticulumInterfaceSet::wifiSelectedForRuntime() const +bool ReticulumInterfaceSet::hasConfiguredIpInterface() const { - if (!wifiAllowed()) + if (auto_.isConfigured()) { - return false; + return true; } - return config_.reticulum_interface_policy == ReticulumInterfacePolicy::WifiGatewayOnly || - wifi_.isReady(); + for (uint8_t index = 0; index < tcp_count_; ++index) + { + if (tcp_[index].isConfigured()) + { + return true; + } + } + return false; } void ReticulumInterfaceSet::syncSharedLoRaRxGate() { - const bool suppress = !loraSelectedForRuntime(); + const bool suppress = !loraEnabled(); #if defined(ARDUINO) if (shared_lora_rx_suppressed_ == suppress && app::AppTasks::isRadioReceiveSuppressed() == suppress) 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 e2ff3920..51345879 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 @@ -8,7 +8,9 @@ #include "chat/infra/mesh_protocol_utils.h" #include "platform/esp/arduino_common/storage/sd_card_runtime.h" +#if defined(ARDUINO) #include +#endif #include #include @@ -21,8 +23,15 @@ namespace { namespace storage = ::platform::esp::arduino_common::storage; +#if defined(ARDUINO) +#define CHAT_STORE_LOG(...) Serial.printf(__VA_ARGS__) +#else +#define CHAT_STORE_LOG(...) std::printf(__VA_ARGS__) +#endif + constexpr uint8_t kRecordHasReticulumIdentityFlag = 0x01U; constexpr uint8_t kRecordSourceUnverifiedFlag = 0x02U; +constexpr uint8_t kRecordHasReticulumLxmfHashFlag = 0x04U; constexpr uint8_t kIndexHasReticulumIdentityFlag = 0x01U; // FileHeader::reserved is a durable unread count once the valid bit is set. constexpr uint16_t kUnreadStateValidMask = 0x8000U; @@ -79,6 +88,12 @@ bool sameReticulumDestination(const ReticulumPeerIdentity& identity, return sameReticulumDestinationHash(identity, destination_hash); } +bool validLxmfMessageHash(const uint8_t* lxmf_hash) +{ + return lxmf_hash && + !isAllZeroKeyBytes(lxmf_hash, kReticulumLxmfHashSize); +} + void copyReticulumIdentityToStorage(uint8_t* destination_hash, uint8_t* identity_hash, const ReticulumPeerIdentity& identity) @@ -135,7 +150,7 @@ SdStore::SdStore() ready_ = ensureFs() && ensureDir(); if (!ready_) { - Serial.printf("[AppContext] chat store=SdStore layout=logs ready=0 root=%s\n", kDir); + CHAT_STORE_LOG("[AppContext] chat store=SdStore layout=logs ready=0 root=%s\n", kDir); return; } @@ -150,16 +165,26 @@ SdStore::SdStore() { unread_reconcile_pending_ = !writeIndex(entries); } - Serial.printf("[AppContext] chat store=SdStore layout=logs root=%s index=%u\n", - kDir, - static_cast(entries.size())); + CHAT_STORE_LOG("[AppContext] chat store=SdStore layout=logs root=%s index=%u\n", + kDir, + static_cast(entries.size())); } void SdStore::append(const ChatMessage& msg) +{ + (void)appendInternal(msg); +} + +bool SdStore::appendIncomingDurably(const ChatMessage& msg) +{ + return appendInternal(msg); +} + +bool SdStore::appendInternal(const ChatMessage& msg) { if (!ready_ || !ensureDir()) { - return; + return false; } const ConversationId conv = conversationIdForMessage(msg); @@ -167,7 +192,33 @@ void SdStore::append(const ChatMessage& msg) FileHeader header{}; if (!openConversationForUpdate(conv, file, header)) { - return; + return false; + } + + if (chat::hasReticulumLxmfMessageHash(msg)) + { + bool already_committed = false; + if (!conversationContainsReticulumLxmfHash(file, + header, + msg.reticulum_lxmf_hash, + &already_committed)) + { + file.close(); + return false; + } + if (already_committed) + { + uint16_t committed_unread = 0; + if (!decodeUnreadState(header.reserved, &committed_unread)) + { + committed_unread = static_cast( + std::min(std::max(0, getUnread(conv)), + kUnreadStateCountMask)); + } + file.close(); + updateIndexForMessage(msg, committed_unread); + return rememberReticulumLxmfMessageHash(msg.reticulum_lxmf_hash); + } } uint16_t unread = 0; @@ -186,7 +237,7 @@ void SdStore::append(const ChatMessage& msg) if (!writeRecord(file, header.head, rec)) { file.close(); - return; + return false; } header.head = static_cast((header.head + 1U) % kMaxMessagesPerConv); @@ -198,16 +249,21 @@ void SdStore::append(const ChatMessage& msg) if (!file.seek(0) || !writeExact(file, &header, sizeof(header))) { file.close(); - return; + return false; } if (!file.flush()) { file.close(); - return; + return false; } file.close(); updateIndexForMessage(msg, unread); + if (chat::hasReticulumLxmfMessageHash(msg)) + { + return rememberReticulumLxmfMessageHash(msg.reticulum_lxmf_hash); + } + return true; } std::vector SdStore::loadRecent(const ConversationId& conv, size_t n) @@ -345,16 +401,16 @@ void SdStore::setUnread(const ConversationId& conv, int unread) const bool header_written = writeConversationUnread(conv, unread_count); if (!header_written) { - Serial.printf("[AppContext] chat unread persist failed stage=conversation_header unread=%u\n", - static_cast(unread_count)); + CHAT_STORE_LOG("[AppContext] chat unread persist failed stage=conversation_header unread=%u\n", + static_cast(unread_count)); return; } entries[index].unread = unread_count; const bool index_written = writeIndex(entries); if (!index_written) { - Serial.printf("[AppContext] chat unread persist failed stage=index unread=%u\n", - static_cast(unread_count)); + CHAT_STORE_LOG("[AppContext] chat unread persist failed stage=index unread=%u\n", + static_cast(unread_count)); unread_reconcile_pending_ = true; rebuildIndex(); } @@ -482,7 +538,8 @@ bool SdStore::updateMessageStatus(MessageId msg_id, MessageStatus status) } FileHeader header{}; - if (!loadFileHeader(file, header)) + if (!loadFileHeader(file, header) || + !upgradeConversationFile(file, path, header)) { file.close(); continue; @@ -588,6 +645,46 @@ bool SdStore::getMessage(MessageId msg_id, ChatMessage* out) const return false; } +bool SdStore::hasReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const +{ + if (!ready_ || !validLxmfMessageHash(lxmf_hash)) + { + return false; + } + + storage::SdRuntimeFile file; + if (!file.open(kReticulumLxmfSeenFile, "r")) + { + return false; + } + + LxmfSeenHeader header{}; + if (!loadLxmfSeenHeader(file, header)) + { + file.close(); + return false; + } + + for (uint16_t index = 0; index < header.count; ++index) + { + const uint16_t slot = + static_cast((header.head + kMaxReticulumLxmfSeen - header.count + index) % + kMaxReticulumLxmfSeen); + LxmfSeenRecord rec{}; + if (!readLxmfSeenRecord(file, slot, rec)) + { + continue; + } + if (std::memcmp(rec.hash, lxmf_hash, kReticulumLxmfHashSize) == 0) + { + file.close(); + return true; + } + } + file.close(); + return false; +} + void SdStore::flush() { } @@ -625,8 +722,8 @@ bool SdStore::recoverIndex() const std::vector recovered; if (readIndex(recovered)) { - Serial.printf("[AppContext] chat store=SdStore recovered index source=%s\n", - source); + CHAT_STORE_LOG("[AppContext] chat store=SdStore recovered index source=%s\n", + source); return true; } (void)storage::sd_remove(kIndexFile); @@ -1006,8 +1103,8 @@ void SdStore::rebuildIndex() dir.close(); unread_reconcile_pending_ = !writeIndex(entries); - Serial.printf("[AppContext] chat store=SdStore rebuild index entries=%u\n", - static_cast(entries.size())); + CHAT_STORE_LOG("[AppContext] chat store=SdStore rebuild index entries=%u\n", + static_cast(entries.size())); } bool SdStore::loadFileHeader(storage::SdRuntimeFile& file, FileHeader& header) const @@ -1022,6 +1119,7 @@ bool SdStore::loadFileHeader(storage::SdRuntimeFile& file, FileHeader& header) c } return header.magic == kFileMagic && (header.version == kFileVersion || + header.version == kRxOriginVersion || header.version == kReticulumIdentityVersion || header.version == kLegacyVersion) && header.head < kMaxMessagesPerConv && header.count <= kMaxMessagesPerConv; @@ -1048,7 +1146,8 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, return true; } if ((header.version != kLegacyVersion && - header.version != kReticulumIdentityVersion) || + header.version != kReticulumIdentityVersion && + header.version != kRxOriginVersion) || !path || path[0] == '\0') { return false; @@ -1062,14 +1161,26 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, static_cast((header.head + kMaxMessagesPerConv - header.count + index) % kMaxMessagesPerConv); Record rec{}; - if (readRecord(file, header, slot, rec) && rec.text_len != 0) + if (!readRecord(file, header, slot, rec)) { - records.push_back(rec); + return false; } + records.push_back(rec); } file.close(); - if (!file.open(path, "w+")) + const std::string temp_path = std::string(path) + ".upgrade"; + const std::string backup_path = std::string(path) + ".bak"; + if (storage::sd_exists(temp_path.c_str())) + { + (void)storage::sd_remove(temp_path.c_str()); + } + if (storage::sd_exists(backup_path.c_str()) && + !storage::sd_remove(backup_path.c_str())) + { + return false; + } + if (!file.open(temp_path.c_str(), "w+")) { return false; } @@ -1077,11 +1188,13 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, FileHeader upgraded{}; upgraded.magic = kFileMagic; upgraded.version = kFileVersion; + upgraded.reserved = header.reserved; upgraded.count = static_cast(std::min(records.size(), kMaxMessagesPerConv)); upgraded.head = static_cast(upgraded.count % kMaxMessagesPerConv); if (!file.seek(0) || !writeExact(file, &upgraded, sizeof(upgraded))) { file.close(); + (void)storage::sd_remove(temp_path.c_str()); return false; } for (uint16_t index = 0; index < upgraded.count; ++index) @@ -1089,15 +1202,39 @@ bool SdStore::upgradeConversationFile(storage::SdRuntimeFile& file, if (!writeRecord(file, index, records[index])) { file.close(); + (void)storage::sd_remove(temp_path.c_str()); return false; } } if (!file.flush()) { file.close(); + (void)storage::sd_remove(temp_path.c_str()); return false; } - return loadFileHeader(file, header) && header.version == kFileVersion; + file.close(); + + if (!storage::sd_rename(path, backup_path.c_str())) + { + (void)storage::sd_remove(temp_path.c_str()); + return false; + } + if (!storage::sd_rename(temp_path.c_str(), path)) + { + (void)storage::sd_rename(backup_path.c_str(), path); + (void)storage::sd_remove(temp_path.c_str()); + return false; + } + if (!file.open(path, "r+") || !loadFileHeader(file, header) || + header.version != kFileVersion) + { + file.close(); + (void)storage::sd_remove(path); + (void)storage::sd_rename(backup_path.c_str(), path); + return false; + } + (void)storage::sd_remove(backup_path.c_str()); + return true; } bool SdStore::readRecord(storage::SdRuntimeFile& file, @@ -1118,6 +1255,10 @@ bool SdStore::readRecord(storage::SdRuntimeFile& file, { record_size = sizeof(RecordV3); } + else if (header.version == kRxOriginVersion) + { + record_size = sizeof(RecordV4); + } const uint64_t offset = sizeof(FileHeader) + static_cast(slot) * record_size; if (file.size() < offset + record_size) { @@ -1132,6 +1273,33 @@ bool SdStore::readRecord(storage::SdRuntimeFile& file, return readExact(file, &rec, sizeof(rec)); } + if (header.version == kRxOriginVersion) + { + RecordV4 rec_v4{}; + if (!readExact(file, &rec_v4, sizeof(rec_v4))) + { + return false; + } + rec.protocol = rec_v4.protocol; + rec.channel = rec_v4.channel; + rec.status = rec_v4.status; + rec.flags = rec_v4.flags; + rec.rx_origin = rec_v4.rx_origin; + rec.text_len = rec_v4.text_len; + rec.from = rec_v4.from; + rec.peer = rec_v4.peer; + rec.msg_id = rec_v4.msg_id; + rec.timestamp = rec_v4.timestamp; + std::memcpy(rec.reticulum_destination_hash, + rec_v4.reticulum_destination_hash, + sizeof(rec.reticulum_destination_hash)); + std::memcpy(rec.reticulum_identity_hash, + rec_v4.reticulum_identity_hash, + sizeof(rec.reticulum_identity_hash)); + std::memcpy(rec.text, rec_v4.text, sizeof(rec.text)); + return true; + } + if (header.version == kReticulumIdentityVersion) { RecordV3 rec_v3{}; @@ -1186,6 +1354,154 @@ bool SdStore::writeRecord(storage::SdRuntimeFile& file, uint16_t slot, const Rec return file.seek(offset) && writeExact(file, &rec, sizeof(rec)); } +bool SdStore::initLxmfSeenFile(storage::SdRuntimeFile& file) const +{ + LxmfSeenHeader header{}; + header.magic = kLxmfSeenMagic; + header.version = kLxmfSeenVersion; + if (!file.seek(0) || !writeExact(file, &header, sizeof(header))) + { + return false; + } + return file.flush(); +} + +bool SdStore::loadLxmfSeenHeader(storage::SdRuntimeFile& file, + LxmfSeenHeader& header) const +{ + if (!file.is_open() || file.size() < sizeof(LxmfSeenHeader)) + { + return false; + } + if (!file.seek(0) || !readExact(file, &header, sizeof(header))) + { + return false; + } + return header.magic == kLxmfSeenMagic && + header.version == kLxmfSeenVersion && + header.head < kMaxReticulumLxmfSeen && + header.count <= kMaxReticulumLxmfSeen; +} + +bool SdStore::readLxmfSeenRecord(storage::SdRuntimeFile& file, + uint16_t slot, + LxmfSeenRecord& rec) const +{ + if (slot >= kMaxReticulumLxmfSeen) + { + return false; + } + const uint64_t offset = + sizeof(LxmfSeenHeader) + static_cast(slot) * sizeof(LxmfSeenRecord); + if (file.size() < offset + sizeof(LxmfSeenRecord)) + { + return false; + } + return file.seek(offset) && readExact(file, &rec, sizeof(rec)); +} + +bool SdStore::writeLxmfSeenRecord(storage::SdRuntimeFile& file, + uint16_t slot, + const LxmfSeenRecord& rec) const +{ + if (slot >= kMaxReticulumLxmfSeen) + { + return false; + } + const uint64_t offset = + sizeof(LxmfSeenHeader) + static_cast(slot) * sizeof(LxmfSeenRecord); + return file.seek(offset) && writeExact(file, &rec, sizeof(rec)); +} + +bool SdStore::rememberReticulumLxmfMessageHash(const uint8_t* lxmf_hash) const +{ + if (!ready_ || !validLxmfMessageHash(lxmf_hash) || !ensureDir()) + { + return false; + } + if (hasReticulumLxmfMessageHash(lxmf_hash)) + { + return true; + } + + storage::SdRuntimeFile file; + if (storage::sd_exists(kReticulumLxmfSeenFile)) + { + if (!file.open(kReticulumLxmfSeenFile, "r+")) + { + return false; + } + } + else if (!file.open(kReticulumLxmfSeenFile, "w+")) + { + return false; + } + + LxmfSeenHeader header{}; + if (!loadLxmfSeenHeader(file, header)) + { + if (!initLxmfSeenFile(file) || !loadLxmfSeenHeader(file, header)) + { + file.close(); + return false; + } + } + + LxmfSeenRecord rec{}; + std::memcpy(rec.hash, lxmf_hash, sizeof(rec.hash)); + if (!writeLxmfSeenRecord(file, header.head, rec)) + { + file.close(); + return false; + } + + header.head = static_cast((header.head + 1U) % kMaxReticulumLxmfSeen); + if (header.count < kMaxReticulumLxmfSeen) + { + header.count = static_cast(header.count + 1U); + } + if (!file.seek(0) || !writeExact(file, &header, sizeof(header)) || !file.flush()) + { + file.close(); + return false; + } + file.close(); + return true; +} + +bool SdStore::conversationContainsReticulumLxmfHash( + storage::SdRuntimeFile& file, + const FileHeader& header, + const uint8_t* lxmf_hash, + bool* found) const +{ + if (!file.is_open() || !validLxmfMessageHash(lxmf_hash) || !found) + { + return false; + } + *found = false; + for (uint16_t index = 0; index < header.count; ++index) + { + const uint16_t slot = static_cast( + (header.head + kMaxMessagesPerConv - header.count + index) % + kMaxMessagesPerConv); + Record rec{}; + if (!readRecord(file, header, slot, rec)) + { + return false; + } + if ((rec.flags & kRecordHasReticulumLxmfHashFlag) != 0 && + std::memcmp(rec.reticulum_lxmf_hash, + lxmf_hash, + kReticulumLxmfHashSize) == 0) + { + *found = true; + return true; + } + } + return true; +} + bool SdStore::openConversationForUpdate(const ConversationId& conv, storage::SdRuntimeFile& file, FileHeader& header) const @@ -1193,13 +1509,18 @@ bool SdStore::openConversationForUpdate(const ConversationId& conv, char path[96]{}; buildConversationPath(conv, path, sizeof(path)); - if (storage::sd_exists(path) && file.open(path, "r+")) + if (storage::sd_exists(path)) { + if (!file.open(path, "r+")) + { + return false; + } if (loadFileHeader(file, header) && upgradeConversationFile(file, path, header)) { return true; } file.close(); + return false; } if (!file.open(path, "w+")) @@ -1333,6 +1654,12 @@ ChatMessage SdStore::messageFromRecord(const Record& rec) rec.reticulum_destination_hash, rec.reticulum_identity_hash); } + if ((rec.flags & kRecordHasReticulumLxmfHashFlag) != 0) + { + std::memcpy(msg.reticulum_lxmf_hash, + rec.reticulum_lxmf_hash, + sizeof(msg.reticulum_lxmf_hash)); + } return msg; } @@ -1360,6 +1687,13 @@ SdStore::Record SdStore::recordFromMessage(const ChatMessage& msg) rec.reticulum_identity_hash, msg.reticulum_identity); } + if (chat::hasReticulumLxmfMessageHash(msg)) + { + rec.flags |= kRecordHasReticulumLxmfHashFlag; + std::memcpy(rec.reticulum_lxmf_hash, + msg.reticulum_lxmf_hash, + sizeof(rec.reticulum_lxmf_hash)); + } if (rec.text_len > 0) { std::memcpy(rec.text, msg.text.data(), rec.text_len); diff --git a/platform/esp/arduino_common/src/platform_ui_reticulum_network_config_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_reticulum_network_config_runtime.cpp new file mode 100644 index 00000000..c4c7ae45 --- /dev/null +++ b/platform/esp/arduino_common/src/platform_ui_reticulum_network_config_runtime.cpp @@ -0,0 +1,1266 @@ +#include "platform/ui/reticulum_network_config_runtime.h" + +#include "platform/ui/reticulum_call_runtime.h" + +#if defined(ARDUINO) +#include "platform/esp/arduino_common/storage/sd_card_runtime.h" + +#include +#include +#else +#include "esp_timer.h" +#include "nvs.h" +#include "platform/esp/idf_common/bsp_runtime.h" + +#include +#include +#endif + +#include "cJSON.h" + +#include +#include + +namespace platform::ui::reticulum_network_config +{ + +namespace +{ + +constexpr const char* kConfigDirectory = "/trailmate/reticulum"; +constexpr const char* kConfigPath = "/trailmate/reticulum/config.json"; +constexpr const char* kConfigTempPath = "/trailmate/reticulum/config.tmp"; +constexpr const char* kSchema = "trail-mate.reticulum"; +constexpr const char* kPreferencesNamespace = "rt_net_cfg"; +constexpr const char* kLastKnownGoodKey = "last_good"; +constexpr std::size_t kMaxConfigBytes = 2U * 1024U; +constexpr std::size_t kMaxJsonDepth = 5U; +constexpr std::size_t kMaxJsonStructuralTokens = 128U; +constexpr std::size_t kMaxJsonStringBytes = 128U; +constexpr uint32_t kSdProbeIntervalMs = 5000; + +chat::reticulum::ReticulumNetworkConfig g_active{}; +chat::reticulum::ReticulumNetworkConfig g_parse_scratch{}; +Status g_status{}; +char g_file_buffer[kMaxConfigBytes + 1U] = {}; +bool g_initialized = false; +bool g_sd_checked = false; +bool g_reload_deferred = false; +uint32_t g_last_sd_probe_ms = 0; + +uint32_t uptime_ms() +{ +#if defined(ARDUINO) + return millis(); +#else + return static_cast(esp_timer_get_time() / 1000ULL); +#endif +} + +void copy_bounded(char* out, std::size_t out_len, const char* value) +{ + if (!out || out_len == 0) + { + return; + } + out[0] = '\0'; + if (!value) + { + return; + } + const auto max_copy_len = out_len - 1U; + const auto* terminator = static_cast(std::memchr(value, '\0', max_copy_len)); + const std::size_t copy_len = + terminator ? static_cast(terminator - value) : max_copy_len; + std::memcpy(out, value, copy_len); + out[copy_len] = '\0'; +} + +void reset_network_config( + chat::reticulum::ReticulumNetworkConfig& config) +{ + config.version = + chat::reticulum::ReticulumNetworkConfig::kSchemaVersion; + for (auto& interface_config : config.interfaces) + { + interface_config = chat::reticulum::NetworkInterfaceConfig{}; + } + config.interface_count = 0; + config.propagation = chat::reticulum::LxmfPropagationClientConfig{}; +} + +void set_status(const char* message, const char* detail = nullptr) +{ + copy_bounded(g_status.message, sizeof(g_status.message), message); + copy_bounded(g_status.detail, sizeof(g_status.detail), detail); + g_status.reload_deferred = g_reload_deferred; +} + +bool is_zero_hash(const uint8_t* hash, std::size_t len) +{ + if (!hash) + { + return true; + } + for (std::size_t index = 0; index < len; ++index) + { + if (hash[index] != 0) + { + return false; + } + } + return true; +} + +int hex_nibble(char value) +{ + if (value >= '0' && value <= '9') + { + return value - '0'; + } + if (value >= 'a' && value <= 'f') + { + return value - 'a' + 10; + } + if (value >= 'A' && value <= 'F') + { + return value - 'A' + 10; + } + return -1; +} + +bool parse_hash(const char* text, uint8_t* out, std::size_t out_len) +{ + if (!text || !out || std::strlen(text) != out_len * 2U) + { + return false; + } + for (std::size_t index = 0; index < out_len; ++index) + { + const int high = hex_nibble(text[index * 2U]); + const int low = hex_nibble(text[index * 2U + 1U]); + if (high < 0 || low < 0) + { + return false; + } + out[index] = static_cast((high << 4) | low); + } + return true; +} + +void format_hash(const uint8_t* hash, std::size_t len, char* out, std::size_t out_len) +{ + static constexpr char kHex[] = "0123456789ABCDEF"; + if (!hash || !out || out_len < len * 2U + 1U) + { + return; + } + for (std::size_t index = 0; index < len; ++index) + { + out[index * 2U] = kHex[(hash[index] >> 4U) & 0x0FU]; + out[index * 2U + 1U] = kHex[hash[index] & 0x0FU]; + } + out[len * 2U] = '\0'; +} + +cJSON* object_item(cJSON* object, const char* key) +{ + return cJSON_IsObject(object) + ? cJSON_GetObjectItemCaseSensitive(object, key) + : nullptr; +} + +bool json_bool(cJSON* object, const char* key, bool fallback) +{ + cJSON* item = object_item(object, key); + return cJSON_IsBool(item) ? cJSON_IsTrue(item) : fallback; +} + +int json_int(cJSON* object, const char* key, int fallback) +{ + cJSON* item = object_item(object, key); + return cJSON_IsNumber(item) ? item->valueint : fallback; +} + +const char* json_string(cJSON* object, const char* key) +{ + cJSON* item = object_item(object, key); + return cJSON_IsString(item) ? item->valuestring : nullptr; +} + +bool validate_json_budget(const char* data, + std::size_t len, + char* error, + std::size_t error_len) +{ + if (!data || len == 0) + { + copy_bounded(error, error_len, "Configuration is empty"); + return false; + } + if (len > kMaxConfigBytes) + { + copy_bounded(error, error_len, "Configuration exceeds 2 KB limit"); + return false; + } + + std::size_t depth = 0; + std::size_t structural_tokens = 0; + std::size_t string_bytes = 0; + bool in_string = false; + bool escaped = false; + for (std::size_t index = 0; index < len; ++index) + { + const unsigned char value = static_cast(data[index]); + if (value == '\0') + { + copy_bounded(error, error_len, "Configuration contains a null byte"); + return false; + } + if (in_string) + { + if (escaped) + { + escaped = false; + ++string_bytes; + } + else if (value == '\\') + { + escaped = true; + ++string_bytes; + } + else if (value == '"') + { + in_string = false; + } + else + { + if (value < 0x20U) + { + copy_bounded(error, error_len, "Configuration string contains a control byte"); + return false; + } + ++string_bytes; + } + if (string_bytes > kMaxJsonStringBytes) + { + copy_bounded(error, error_len, "Configuration string exceeds 128 bytes"); + return false; + } + continue; + } + + if (value == '"') + { + in_string = true; + string_bytes = 0; + continue; + } + if (value == '{' || value == '[') + { + ++depth; + ++structural_tokens; + if (depth > kMaxJsonDepth) + { + copy_bounded(error, error_len, "Configuration nesting exceeds 5 levels"); + return false; + } + } + else if (value == '}' || value == ']') + { + if (depth == 0) + { + copy_bounded(error, error_len, "Configuration JSON structure is invalid"); + return false; + } + --depth; + ++structural_tokens; + } + else if (value == ',' || value == ':') + { + ++structural_tokens; + } + + if (structural_tokens > kMaxJsonStructuralTokens) + { + copy_bounded(error, error_len, "Configuration structure exceeds embedded budget"); + return false; + } + } + + if (in_string || escaped || depth != 0) + { + copy_bounded(error, error_len, "Configuration JSON structure is incomplete"); + return false; + } + return true; +} + +bool add_json_string(cJSON* object, const char* key, const char* value) +{ + return object && value && cJSON_AddStringToObject(object, key, value); +} + +bool add_json_number(cJSON* object, const char* key, double value) +{ + return object && cJSON_AddNumberToObject(object, key, value); +} + +bool add_json_bool(cJSON* object, const char* key, bool value) +{ + return object && cJSON_AddBoolToObject(object, key, value); +} + +bool append_default_interface(chat::reticulum::NetworkInterfaceType type, + const char* id, + chat::reticulum::NetworkInterfaceConfig** out) +{ + if (g_parse_scratch.interface_count >= chat::reticulum::kMaxNetworkInterfaces) + { + return false; + } + auto& interface_config = + g_parse_scratch.interfaces[g_parse_scratch.interface_count++]; + interface_config = chat::reticulum::NetworkInterfaceConfig{}; + interface_config.type = type; + interface_config.enabled = true; + interface_config.target_port = 4242; + interface_config.discovery_port = 29716; + interface_config.data_port = 42671; + copy_bounded(interface_config.id, sizeof(interface_config.id), id); + copy_bounded(interface_config.group_id, + sizeof(interface_config.group_id), + "reticulum"); + if (out) + { + *out = &interface_config; + } + return true; +} + +void build_defaults(const chat::MeshConfig& legacy_config) +{ + reset_network_config(g_parse_scratch); + g_parse_scratch.version = + chat::reticulum::ReticulumNetworkConfig::kSchemaVersion; + + const bool allow_lora = legacy_config.reticulum_lora_enabled && + legacy_config.reticulum_interface_policy != + chat::ReticulumInterfacePolicy::WifiGatewayOnly; + const bool allow_ip = legacy_config.reticulum_wifi_gateway_enabled && + legacy_config.reticulum_interface_policy != + chat::ReticulumInterfacePolicy::LoRaOnly; + + if (allow_lora) + { + (void)append_default_interface( + chat::reticulum::NetworkInterfaceType::IntegratedLoRa, + "integrated-lora", + nullptr); + } + if (allow_ip) + { + (void)append_default_interface(chat::reticulum::NetworkInterfaceType::Auto, + "local-wifi", + nullptr); + if (legacy_config.reticulum_wifi_gateway_host[0] != '\0') + { + chat::reticulum::NetworkInterfaceConfig* tcp = nullptr; + if (append_default_interface( + chat::reticulum::NetworkInterfaceType::TcpClient, + "primary-tcp", + &tcp)) + { + copy_bounded(tcp->target_host, + sizeof(tcp->target_host), + legacy_config.reticulum_wifi_gateway_host); + tcp->target_port = legacy_config.reticulum_wifi_gateway_port != 0 + ? legacy_config.reticulum_wifi_gateway_port + : 4242; + } + } + } + + g_parse_scratch.propagation.enabled = true; + g_parse_scratch.propagation.delivery = + chat::reticulum::LxmfDeliveryPreference::Automatic; + g_parse_scratch.propagation.automatic_node = true; + g_parse_scratch.propagation.sync_on_start = true; + g_parse_scratch.propagation.sync_interval_s = 15U * 60U; + g_parse_scratch.propagation.max_messages_per_sync = 32; +} + +bool id_is_unique(const chat::reticulum::ReticulumNetworkConfig& config, + const char* id, + std::size_t count) +{ + for (std::size_t index = 0; index < count; ++index) + { + if (std::strcmp(config.interfaces[index].id, id) == 0) + { + return false; + } + } + return true; +} + +bool parse_interface(cJSON* object, + chat::reticulum::NetworkInterfaceConfig& out, + std::size_t prior_count, + uint8_t& lora_count, + uint8_t& auto_count, + uint8_t& tcp_count, + char* error, + std::size_t error_len) +{ + const char* id = json_string(object, "id"); + const char* type = json_string(object, "type"); + if (!id || id[0] == '\0' || std::strlen(id) > chat::reticulum::kInterfaceIdMaxLen) + { + copy_bounded(error, error_len, "Interface id is missing or too long"); + return false; + } + if (!id_is_unique(g_parse_scratch, id, prior_count)) + { + copy_bounded(error, error_len, "Interface ids must be unique"); + return false; + } + if (!type) + { + copy_bounded(error, error_len, "Interface type is missing"); + return false; + } + + out = chat::reticulum::NetworkInterfaceConfig{}; + copy_bounded(out.id, sizeof(out.id), id); + out.enabled = json_bool(object, "enabled", true); + out.target_port = 4242; + out.discovery_port = 29716; + out.data_port = 42671; + copy_bounded(out.group_id, sizeof(out.group_id), "reticulum"); + + if (std::strcmp(type, "IntegratedLoRaInterface") == 0) + { + out.type = chat::reticulum::NetworkInterfaceType::IntegratedLoRa; + if (++lora_count > 1) + { + copy_bounded(error, error_len, "Only one integrated LoRa interface is supported"); + return false; + } + } + else if (std::strcmp(type, "AutoInterface") == 0) + { + out.type = chat::reticulum::NetworkInterfaceType::Auto; + if (++auto_count > 1) + { + copy_bounded(error, error_len, "Only one AutoInterface is supported"); + return false; + } + const char* group_id = json_string(object, "group_id"); + if (group_id) + { + if (group_id[0] == '\0' || + std::strlen(group_id) > chat::reticulum::kAutoInterfaceGroupMaxLen) + { + copy_bounded(error, error_len, "AutoInterface group_id is invalid"); + return false; + } + copy_bounded(out.group_id, sizeof(out.group_id), group_id); + } + const char* scope = json_string(object, "discovery_scope"); + if (scope && std::strcmp(scope, "link") != 0) + { + copy_bounded(error, error_len, "Embedded AutoInterface supports link scope only"); + return false; + } + const int discovery_port = json_int(object, "discovery_port", 29716); + const int data_port = json_int(object, "data_port", 42671); + if (discovery_port <= 0 || discovery_port >= 65535 || + data_port <= 0 || data_port > 65535) + { + copy_bounded(error, error_len, "AutoInterface port is invalid"); + return false; + } + out.discovery_port = static_cast(discovery_port); + out.data_port = static_cast(data_port); + } + else if (std::strcmp(type, "TCPClientInterface") == 0) + { + out.type = chat::reticulum::NetworkInterfaceType::TcpClient; + if (++tcp_count > chat::reticulum::kMaxTcpClientInterfaces) + { + copy_bounded(error, error_len, "Too many TCPClientInterface entries"); + return false; + } + const char* host = json_string(object, "target_host"); + const int port = json_int(object, "target_port", 4242); + if (!host || host[0] == '\0' || + std::strlen(host) > chat::reticulum::kInterfaceHostMaxLen || + port <= 0 || port > 65535) + { + copy_bounded(error, error_len, "TCPClientInterface endpoint is invalid"); + return false; + } + copy_bounded(out.target_host, sizeof(out.target_host), host); + out.target_port = static_cast(port); + } + else + { + copy_bounded(error, error_len, "Unsupported Interface type"); + return false; + } + + return true; +} + +bool parse_propagation(cJSON* object, + chat::reticulum::LxmfPropagationClientConfig& out, + char* error, + std::size_t error_len) +{ + if (!object) + { + return true; + } + if (!cJSON_IsObject(object)) + { + copy_bounded(error, error_len, "lxmf.propagation must be an object"); + return false; + } + + out.enabled = json_bool(object, "enabled", out.enabled); + out.service_enabled = + json_bool(object, "service_enabled", out.service_enabled); + out.sync_on_start = json_bool(object, "sync_on_start", out.sync_on_start); + const char* delivery = json_string(object, "delivery_method"); + if (delivery) + { + if (std::strcmp(delivery, "direct") == 0) + { + out.delivery = chat::reticulum::LxmfDeliveryPreference::Direct; + } + else if (std::strcmp(delivery, "propagated") == 0) + { + out.delivery = chat::reticulum::LxmfDeliveryPreference::Propagated; + } + else if (std::strcmp(delivery, "auto") == 0) + { + out.delivery = chat::reticulum::LxmfDeliveryPreference::Automatic; + } + else + { + copy_bounded(error, error_len, "Unknown LXMF delivery_method"); + return false; + } + } + + const char* node = json_string(object, "propagation_node"); + if (node && std::strcmp(node, "auto") != 0) + { + if (!parse_hash(node, out.node_hash, sizeof(out.node_hash))) + { + copy_bounded(error, error_len, "propagation_node must be auto or a destination hash"); + return false; + } + out.automatic_node = false; + } + else + { + out.automatic_node = true; + std::memset(out.node_hash, 0, sizeof(out.node_hash)); + } + + const int interval = json_int(object, + "sync_interval_seconds", + static_cast(out.sync_interval_s)); + const int max_messages = json_int(object, + "max_messages_per_sync", + out.max_messages_per_sync); + if (interval < 60 || interval > 24 * 60 * 60 || + max_messages < 1 || max_messages > 64) + { + copy_bounded(error, error_len, "Propagation sync limits are invalid"); + return false; + } + out.sync_interval_s = static_cast(interval); + out.max_messages_per_sync = static_cast(max_messages); + return true; +} + +bool parse_document(const char* data, + std::size_t len, + chat::reticulum::ReticulumNetworkConfig* out, + char* error, + std::size_t error_len) +{ + if (!out) + { + copy_bounded(error, error_len, "Configuration is empty"); + return false; + } + if (!validate_json_budget(data, len, error, error_len)) + { + return false; + } + + const char* parse_end = nullptr; + cJSON* root = cJSON_ParseWithLengthOpts(data, len, &parse_end, false); + while (parse_end && parse_end < data + len && + (*parse_end == ' ' || *parse_end == '\t' || *parse_end == '\r' || + *parse_end == '\n')) + { + ++parse_end; + } + if (!cJSON_IsObject(root) || parse_end != data + len) + { + cJSON_Delete(root); + copy_bounded(error, error_len, "Configuration JSON is invalid"); + return false; + } + + const char* schema = json_string(root, "schema"); + const int version = json_int(root, "version", 0); + if (!schema || std::strcmp(schema, kSchema) != 0 || + version != chat::reticulum::ReticulumNetworkConfig::kSchemaVersion) + { + cJSON_Delete(root); + copy_bounded(error, error_len, "Unsupported Reticulum configuration schema"); + return false; + } + + reset_network_config(g_parse_scratch); + g_parse_scratch.version = static_cast(version); + g_parse_scratch.propagation = {}; + + cJSON* interfaces = object_item(root, "interfaces"); + if (!cJSON_IsArray(interfaces)) + { + cJSON_Delete(root); + copy_bounded(error, error_len, "interfaces must be an array"); + return false; + } + + const int count = cJSON_GetArraySize(interfaces); + if (count <= 0 || count > static_cast(chat::reticulum::kMaxNetworkInterfaces)) + { + cJSON_Delete(root); + copy_bounded(error, error_len, "Interface count is outside the supported range"); + return false; + } + + uint8_t lora_count = 0; + uint8_t auto_count = 0; + uint8_t tcp_count = 0; + uint8_t enabled_count = 0; + for (int index = 0; index < count; ++index) + { + cJSON* item = cJSON_GetArrayItem(interfaces, index); + if (!cJSON_IsObject(item) || + !parse_interface(item, + g_parse_scratch.interfaces[index], + static_cast(index), + lora_count, + auto_count, + tcp_count, + error, + error_len)) + { + cJSON_Delete(root); + return false; + } + if (g_parse_scratch.interfaces[index].enabled) + { + ++enabled_count; + } + } + if (enabled_count == 0) + { + cJSON_Delete(root); + copy_bounded(error, error_len, "At least one Interface must be enabled"); + return false; + } + g_parse_scratch.interface_count = static_cast(count); + + cJSON* lxmf = object_item(root, "lxmf"); + if (lxmf && !cJSON_IsObject(lxmf)) + { + cJSON_Delete(root); + copy_bounded(error, error_len, "lxmf must be an object"); + return false; + } + if (!parse_propagation(object_item(lxmf, "propagation"), + g_parse_scratch.propagation, + error, + error_len)) + { + cJSON_Delete(root); + return false; + } + + cJSON_Delete(root); + *out = g_parse_scratch; + return true; +} + +cJSON* create_document(const chat::reticulum::ReticulumNetworkConfig& config) +{ + cJSON* root = cJSON_CreateObject(); + if (!root) + { + return nullptr; + } + auto fail = [&root]() -> cJSON* + { + cJSON_Delete(root); + return nullptr; + }; + if (!add_json_string(root, "schema", kSchema) || + !add_json_number(root, "version", config.version)) + { + return fail(); + } + cJSON* interfaces = cJSON_AddArrayToObject(root, "interfaces"); + if (!interfaces) + { + return fail(); + } + for (std::size_t index = 0; index < config.interface_count; ++index) + { + const auto& source = config.interfaces[index]; + cJSON* item = cJSON_CreateObject(); + if (!item) + { + return fail(); + } + const char* type = "IntegratedLoRaInterface"; + if (source.type == chat::reticulum::NetworkInterfaceType::Auto) + { + type = "AutoInterface"; + } + else if (source.type == chat::reticulum::NetworkInterfaceType::TcpClient) + { + type = "TCPClientInterface"; + } + bool valid = add_json_string(item, "id", source.id) && + add_json_string(item, "type", type) && + add_json_bool(item, "enabled", source.enabled); + if (source.type == chat::reticulum::NetworkInterfaceType::Auto) + { + valid = valid && add_json_string(item, "group_id", source.group_id) && + add_json_string(item, "discovery_scope", "link") && + add_json_number(item, "discovery_port", source.discovery_port) && + add_json_number(item, "data_port", source.data_port); + } + else if (source.type == chat::reticulum::NetworkInterfaceType::TcpClient) + { + valid = valid && + add_json_string(item, "target_host", source.target_host) && + add_json_number(item, "target_port", source.target_port); + } + if (!valid || !cJSON_AddItemToArray(interfaces, item)) + { + cJSON_Delete(item); + return fail(); + } + } + + cJSON* lxmf = cJSON_AddObjectToObject(root, "lxmf"); + if (!lxmf) + { + return fail(); + } + cJSON* propagation = cJSON_AddObjectToObject(lxmf, "propagation"); + if (!propagation || + !add_json_bool(propagation, "enabled", config.propagation.enabled) || + !add_json_bool(propagation, + "service_enabled", + config.propagation.service_enabled)) + { + return fail(); + } + const char* delivery = "auto"; + if (config.propagation.delivery == chat::reticulum::LxmfDeliveryPreference::Direct) + { + delivery = "direct"; + } + else if (config.propagation.delivery == + chat::reticulum::LxmfDeliveryPreference::Propagated) + { + delivery = "propagated"; + } + if (!add_json_string(propagation, "delivery_method", delivery)) + { + return fail(); + } + if (config.propagation.automatic_node || + is_zero_hash(config.propagation.node_hash, + sizeof(config.propagation.node_hash))) + { + if (!add_json_string(propagation, "propagation_node", "auto")) + { + return fail(); + } + } + else + { + char hash[(chat::reticulum::kTruncatedHashSize * 2U) + 1U] = {}; + format_hash(config.propagation.node_hash, + sizeof(config.propagation.node_hash), + hash, + sizeof(hash)); + if (!add_json_string(propagation, "propagation_node", hash)) + { + return fail(); + } + } + if (!add_json_bool(propagation, + "sync_on_start", + config.propagation.sync_on_start) || + !add_json_number(propagation, + "sync_interval_seconds", + config.propagation.sync_interval_s) || + !add_json_number(propagation, + "max_messages_per_sync", + config.propagation.max_messages_per_sync)) + { + return fail(); + } + return root; +} + +#if !defined(ARDUINO) +void native_path(const char* path, char* out, std::size_t out_len) +{ + const char* mount_point = + ::platform::esp::idf_common::bsp_runtime::sdcard_mount_point(); + std::snprintf(out, + out_len, + "%s%s", + mount_point ? mount_point : "/sdcard", + path ? path : ""); +} +#endif + +bool sd_available() +{ +#if defined(ARDUINO) + return ::platform::esp::arduino_common::storage::sd_card_ready(); +#else + return ::platform::esp::idf_common::bsp_runtime::ensure_sdcard_ready() && + ::platform::esp::idf_common::bsp_runtime::sdcard_ready(); +#endif +} + +bool sd_exists(const char* path) +{ +#if defined(ARDUINO) + return ::platform::esp::arduino_common::storage::sd_exists(path); +#else + char native[192] = {}; + native_path(path, native, sizeof(native)); + struct stat info + { + }; + return stat(native, &info) == 0; +#endif +} + +bool ensure_directory() +{ +#if defined(ARDUINO) + if (!::platform::esp::arduino_common::storage::sd_exists("/trailmate") && + !::platform::esp::arduino_common::storage::sd_mkdir("/trailmate")) + { + return false; + } + return ::platform::esp::arduino_common::storage::sd_exists(kConfigDirectory) || + ::platform::esp::arduino_common::storage::sd_mkdir(kConfigDirectory); +#else + char trailmate[192] = {}; + char directory[192] = {}; + native_path("/trailmate", trailmate, sizeof(trailmate)); + native_path(kConfigDirectory, directory, sizeof(directory)); + return (mkdir(trailmate, 0775) == 0 || errno == EEXIST) && + (mkdir(directory, 0775) == 0 || errno == EEXIST); +#endif +} + +bool read_sd_file(const char* path, std::size_t* out_len) +{ + if (!out_len) + { + return false; + } + *out_len = 0; +#if defined(ARDUINO) + ::platform::esp::arduino_common::storage::SdRuntimeFile file; + if (!file.open(path, "r")) + { + return false; + } + const std::size_t size = file.size(); + if (size == 0 || size > kMaxConfigBytes) + { + file.close(); + return false; + } + const std::size_t read = file.read_bytes(g_file_buffer, size); + file.close(); +#else + char native[192] = {}; + native_path(path, native, sizeof(native)); + std::FILE* file = std::fopen(native, "rb"); + if (!file || std::fseek(file, 0, SEEK_END) != 0) + { + if (file) + { + std::fclose(file); + } + return false; + } + const long file_size = std::ftell(file); + if (file_size <= 0 || file_size > static_cast(kMaxConfigBytes) || + std::fseek(file, 0, SEEK_SET) != 0) + { + std::fclose(file); + return false; + } + const std::size_t size = static_cast(file_size); + const std::size_t read = std::fread(g_file_buffer, 1, size, file); + std::fclose(file); +#endif + if (read != size) + { + return false; + } + g_file_buffer[size] = '\0'; + *out_len = size; + return true; +} + +bool write_sd_file_atomic(const char* text, std::size_t len) +{ + if (!text || len == 0 || len > kMaxConfigBytes || !ensure_directory()) + { + return false; + } +#if defined(ARDUINO) + if (::platform::esp::arduino_common::storage::sd_exists(kConfigTempPath)) + { + ::platform::esp::arduino_common::storage::sd_remove(kConfigTempPath); + } + ::platform::esp::arduino_common::storage::SdRuntimeFile file; + if (!file.open(kConfigTempPath, "w")) + { + return false; + } + const bool wrote = + file.write(reinterpret_cast(text), len) == len; + file.close(); + if (!wrote) + { + ::platform::esp::arduino_common::storage::sd_remove(kConfigTempPath); + return false; + } + if (::platform::esp::arduino_common::storage::sd_exists(kConfigPath)) + { + ::platform::esp::arduino_common::storage::sd_remove(kConfigPath); + } + return ::platform::esp::arduino_common::storage::sd_rename(kConfigTempPath, + kConfigPath); +#else + char path[192] = {}; + char temp_path[192] = {}; + native_path(kConfigPath, path, sizeof(path)); + native_path(kConfigTempPath, temp_path, sizeof(temp_path)); + std::remove(temp_path); + std::FILE* file = std::fopen(temp_path, "wb"); + if (!file) + { + return false; + } + const bool wrote = std::fwrite(text, 1, len, file) == len && + std::fflush(file) == 0; + std::fclose(file); + if (!wrote) + { + std::remove(temp_path); + return false; + } + std::remove(path); + return std::rename(temp_path, path) == 0; +#endif +} + +bool read_last_known_good(std::size_t* out_len) +{ + if (!out_len) + { + return false; + } + *out_len = 0; +#if defined(ARDUINO) + Preferences preferences; + if (!preferences.begin(kPreferencesNamespace, true)) + { + return false; + } + const std::size_t size = preferences.getBytesLength(kLastKnownGoodKey); + const bool valid_size = size > 0 && size <= kMaxConfigBytes; + const std::size_t read = valid_size + ? preferences.getBytes(kLastKnownGoodKey, + g_file_buffer, + size) + : 0; + preferences.end(); +#else + nvs_handle_t handle = 0; + if (nvs_open(kPreferencesNamespace, NVS_READONLY, &handle) != ESP_OK) + { + return false; + } + std::size_t size = 0; + esp_err_t error = nvs_get_blob(handle, kLastKnownGoodKey, nullptr, &size); + const bool valid_size = error == ESP_OK && size > 0 && size <= kMaxConfigBytes; + if (valid_size) + { + error = nvs_get_blob(handle, kLastKnownGoodKey, g_file_buffer, &size); + } + nvs_close(handle); + const std::size_t read = error == ESP_OK && valid_size ? size : 0; +#endif + if (!valid_size || read != size) + { + return false; + } + g_file_buffer[size] = '\0'; + *out_len = size; + return true; +} + +bool write_last_known_good(const char* text, std::size_t len) +{ + if (!text || len == 0 || len > kMaxConfigBytes) + { + return false; + } +#if defined(ARDUINO) + Preferences preferences; + if (!preferences.begin(kPreferencesNamespace, false)) + { + return false; + } + const bool wrote = + preferences.putBytes(kLastKnownGoodKey, text, len) == len; + preferences.end(); + return wrote; +#else + nvs_handle_t handle = 0; + if (nvs_open(kPreferencesNamespace, NVS_READWRITE, &handle) != ESP_OK) + { + return false; + } + const bool wrote = nvs_set_blob(handle, kLastKnownGoodKey, text, len) == ESP_OK && + nvs_commit(handle) == ESP_OK; + nvs_close(handle); + return wrote; +#endif +} + +bool persist_last_known_good(const chat::reticulum::ReticulumNetworkConfig& config) +{ + cJSON* root = create_document(config); + g_file_buffer[0] = '\0'; + const bool printed = root && + cJSON_PrintPreallocated(root, + g_file_buffer, + sizeof(g_file_buffer), + false); + cJSON_Delete(root); + return printed && + write_last_known_good(g_file_buffer, std::strlen(g_file_buffer)); +} + +void activate(const chat::reticulum::ReticulumNetworkConfig& config, + Source source) +{ + g_active = config; + g_status.source = source; + g_status.valid = true; + g_status.configured_interfaces = config.interface_count; + ++g_status.generation; +} + +bool load_sd_config() +{ + g_status.sd_present = sd_available(); + if (!g_status.sd_present) + { + set_status("SD unavailable", kConfigPath); + return false; + } + g_status.file_present = sd_exists(kConfigPath); + if (!g_status.file_present) + { + set_status("Reticulum config not found", kConfigPath); + return false; + } + + std::size_t len = 0; + if (!read_sd_file(kConfigPath, &len)) + { + set_status("Cannot read Reticulum config", kConfigPath); + return false; + } + char error[96] = {}; + if (!parse_document(g_file_buffer, + len, + &g_parse_scratch, + error, + sizeof(error))) + { + set_status("Invalid Reticulum config", error); + return false; + } + + activate(g_parse_scratch, Source::SdCard); + (void)persist_last_known_good(g_active); + set_status("Reticulum config loaded", kConfigPath); + return true; +} + +} // namespace + +void initialize(const chat::MeshConfig& legacy_config) +{ + if (g_initialized) + { + return; + } + g_status = {}; + g_status.supported = true; + build_defaults(legacy_config); + activate(g_parse_scratch, Source::Defaults); + set_status("Using Reticulum defaults", kConfigPath); + + std::size_t len = 0; + char error[96] = {}; + if (read_last_known_good(&len) && + parse_document(g_file_buffer, + len, + &g_parse_scratch, + error, + sizeof(error))) + { + activate(g_parse_scratch, Source::LastKnownGood); + set_status("Using cached Reticulum config", kConfigPath); + } + g_initialized = true; +} + +void poll(const chat::MeshConfig& legacy_config) +{ + initialize(legacy_config); + if (g_reload_deferred && !::platform::ui::reticulum_call::resource_preempt_active()) + { + g_reload_deferred = false; + g_status.reload_deferred = false; + g_sd_checked = true; + (void)load_sd_config(); + return; + } + if (g_sd_checked) + { + return; + } + const uint32_t now_ms = uptime_ms(); + if (g_last_sd_probe_ms != 0 && + now_ms - g_last_sd_probe_ms < kSdProbeIntervalMs) + { + return; + } + g_last_sd_probe_ms = now_ms; + if (sd_available()) + { + if (::platform::ui::reticulum_call::resource_preempt_active()) + { + g_reload_deferred = true; + set_status("Reload deferred until call closes", kConfigPath); + return; + } + g_sd_checked = true; + (void)load_sd_config(); + } +} + +const chat::reticulum::ReticulumNetworkConfig& active() +{ + return g_active; +} + +Status status() +{ + g_status.reload_deferred = g_reload_deferred; + return g_status; +} + +bool reload(const chat::MeshConfig& legacy_config) +{ + initialize(legacy_config); + if (::platform::ui::reticulum_call::resource_preempt_active()) + { + g_reload_deferred = true; + set_status("Reload deferred until call closes", kConfigPath); + return true; + } + g_sd_checked = true; + return load_sd_config(); +} + +bool export_template(const chat::MeshConfig& legacy_config) +{ + initialize(legacy_config); + if (::platform::ui::reticulum_call::resource_preempt_active() || + !sd_available()) + { + return false; + } + cJSON* root = create_document(g_active); + g_file_buffer[0] = '\0'; + const bool printed = root && + cJSON_PrintPreallocated(root, + g_file_buffer, + sizeof(g_file_buffer), + true); + const bool wrote = printed && + write_sd_file_atomic(g_file_buffer, + std::strlen(g_file_buffer)); + cJSON_Delete(root); + if (wrote) + { + g_status.file_present = true; + set_status("Reticulum config exported", kConfigPath); + } + return wrote; +} + +const char* config_path() +{ + return kConfigPath; +} + +const char* source_name(Source source) +{ + switch (source) + { + case Source::SdCard: + return "SD"; + case Source::LastKnownGood: + return "Cached"; + case Source::Defaults: + default: + return "Defaults"; + } +} + +} // namespace platform::ui::reticulum_network_config diff --git a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp index 7fcbf788..687c9a90 100644 --- a/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_wifi_access_runtime.cpp @@ -413,6 +413,19 @@ TrafficBudget base_budget(Client client, return budget; } + if (client == Client::ReticulumGateway) + { + budget.allow_connect = true; + budget.allow_read = true; + budget.allow_write = true; + budget.rx_packet_budget = 0; + budget.tx_packet_budget = 4; + budget.rx_byte_budget = 512; + budget.tx_byte_budget = 2048; + budget.min_read_interval_ms = 0; + return budget; + } + if (phase == ScreenPhase::WakeProtected) { budget.allow_connect = false; @@ -420,9 +433,9 @@ TrafficBudget base_budget(Client client, budget.allow_write = true; budget.rx_packet_budget = client == Client::MeshMqtt ? 1 : 0; budget.tx_packet_budget = 1; - budget.rx_byte_budget = client == Client::ReticulumGateway ? 64 : 512; + budget.rx_byte_budget = 512; budget.tx_byte_budget = 512; - budget.min_read_interval_ms = client == Client::ReticulumGateway ? 500 : 0; + budget.min_read_interval_ms = 0; return budget; } @@ -433,9 +446,9 @@ TrafficBudget base_budget(Client client, budget.allow_write = true; budget.rx_packet_budget = client == Client::MeshMqtt ? 8 : 0; budget.tx_packet_budget = client == Client::MeshMqtt ? 8 : 2; - budget.rx_byte_budget = client == Client::ReticulumGateway ? 256 : 4096; + budget.rx_byte_budget = 4096; budget.tx_byte_budget = 4096; - budget.min_read_interval_ms = client == Client::ReticulumGateway ? 100 : 0; + budget.min_read_interval_ms = 0; return budget; } @@ -444,9 +457,9 @@ TrafficBudget base_budget(Client client, budget.allow_write = true; budget.rx_packet_budget = client == Client::MeshMqtt ? 4 : 0; budget.tx_packet_budget = client == Client::MeshMqtt ? 4 : 1; - budget.rx_byte_budget = client == Client::ReticulumGateway ? 128 : 2048; + budget.rx_byte_budget = 2048; + budget.min_read_interval_ms = 0; budget.tx_byte_budget = 2048; - budget.min_read_interval_ms = client == Client::ReticulumGateway ? 250 : 0; return budget; } @@ -722,8 +735,11 @@ bool ensure_connected(const Request& request, Decision* out_decision) } else { + // ESP-IDF P4 targets delegate Wi-Fi to the C6 companion. A + // successfully queued Connect command completes asynchronously; + // absence of an immediate GOT_IP event is not a failed command. decision = ::platform::ui::wifi::status().connected ? Decision::Granted - : Decision::ConnectFailed; + : Decision::ConnectBackoff; } } diff --git a/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp b/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp index 323acef8..061f5b22 100644 --- a/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp +++ b/platform/esp/arduino_common/src/reticulum_call_audio_runtime.cpp @@ -19,15 +19,18 @@ namespace { constexpr uint8_t kBitsPerSample = 16; -constexpr uint8_t kCodecChannels = 1; +constexpr uint8_t kHardwareChannels = 2; constexpr std::size_t kMaxCodec2SamplesPerFrame = 320; -constexpr uint8_t kDefaultVolume = 78; -constexpr float kDefaultGainDb = 36.0f; +constexpr uint8_t kDefaultVolume = 100; +constexpr float kDefaultGainDb = 24.0f; constexpr auto kAudioOwner = ::boards::tlora_pager::PagerAudioOwner::ReticulumCall; bool s_registered = false; ::boards::tlora_pager::TLoRaPagerBoard* s_board = nullptr; +uint8_t s_call_volume = kDefaultVolume; +alignas(4) int16_t s_capture_i2s[kMaxCodec2SamplesPerFrame * kHardwareChannels] = {}; +alignas(4) int16_t s_playback_i2s[kMaxCodec2SamplesPerFrame * kHardwareChannels] = {}; ::boards::tlora_pager::TLoRaPagerBoard* resolve_board() { @@ -48,11 +51,12 @@ bool media_supported() bool open_audio(uint32_t sample_rate_hz) { + s_call_volume = kDefaultVolume; s_board = resolve_board(); if (!s_board || s_board->openAudioSession(kAudioOwner, kBitsPerSample, - kCodecChannels, + kHardwareChannels, sample_rate_hz, true) != 0) { @@ -60,8 +64,7 @@ bool open_audio(uint32_t sample_rate_hz) return false; } - if (!s_board->audioSetVolume(kAudioOwner, - s_board->getMessageToneVolume()) || + if (!s_board->audioSetVolume(kAudioOwner, s_call_volume) || !s_board->audioSetGain(kAudioOwner, kDefaultGainDb) || !s_board->audioSetMute(kAudioOwner, false) || !s_board->audioSetOutMute(kAudioOwner, false)) @@ -85,13 +88,24 @@ void close_audio() bool read_mono(int16_t* pcm, std::size_t sample_count) { if (!s_board || !pcm || sample_count == 0 || - sample_count > kMaxCodec2SamplesPerFrame || - s_board->audioRead(kAudioOwner, - reinterpret_cast(pcm), - sample_count * sizeof(int16_t)) != 0) + sample_count > kMaxCodec2SamplesPerFrame) { return false; } + + const std::size_t i2s_sample_count = sample_count * kHardwareChannels; + if (s_board->audioRead(kAudioOwner, + reinterpret_cast(s_capture_i2s), + i2s_sample_count * sizeof(int16_t)) != 0) + { + return false; + } + for (std::size_t index = 0; index < sample_count; ++index) + { + const int32_t left = s_capture_i2s[index * kHardwareChannels]; + const int32_t right = s_capture_i2s[index * kHardwareChannels + 1U]; + pcm[index] = static_cast((left + right) / 2); + } return true; } @@ -103,25 +117,25 @@ bool write_mono(const int16_t* pcm, std::size_t sample_count) return false; } + for (std::size_t index = 0; index < sample_count; ++index) + { + s_playback_i2s[index * kHardwareChannels] = pcm[index]; + s_playback_i2s[index * kHardwareChannels + 1U] = pcm[index]; + } return s_board->audioWrite(kAudioOwner, - reinterpret_cast(pcm), - sample_count * sizeof(int16_t)) == 0; + reinterpret_cast(s_playback_i2s), + sample_count * kHardwareChannels * sizeof(int16_t)) == 0; } uint8_t speaker_volume() { - auto* board = resolve_board(); - return board ? board->getMessageToneVolume() : kDefaultVolume; + return s_call_volume; } void set_speaker_volume(uint8_t volume_percent) { const uint8_t volume = volume_percent > 100U ? 100U : volume_percent; - auto* board = resolve_board(); - if (board) - { - board->setMessageToneVolume(volume); - } + s_call_volume = volume; if (s_board) { (void)s_board->audioSetVolume(kAudioOwner, volume); diff --git a/platform/esp/common/include/platform/esp/common/call_acoustic_echo_guard.h b/platform/esp/common/include/platform/esp/common/call_acoustic_echo_guard.h new file mode 100644 index 00000000..159b0ac8 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/call_acoustic_echo_guard.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace platform::esp::common::reticulum_call_audio +{ + +class CallAcousticEchoGuard +{ + public: + explicit CallAcousticEchoGuard(uint32_t sample_rate_hz, + uint16_t render_active_mean_abs = 384, + uint16_t release_hold_ms = 160); + + bool observeRender(const int16_t* pcm, std::size_t sample_count); + bool suppressCapture(int16_t* pcm, std::size_t sample_count) const; + + bool captureSuppressed() const; + uint16_t lastRenderMeanAbs() const; + + private: + uint16_t render_active_mean_abs_ = 0; + uint32_t release_hold_samples_ = 0; + uint32_t remaining_hold_samples_ = 0; + uint16_t last_render_mean_abs_ = 0; +}; + +} // namespace platform::esp::common::reticulum_call_audio diff --git a/platform/esp/common/include/platform/esp/common/mbedtls_sha256_compat.h b/platform/esp/common/include/platform/esp/common/mbedtls_sha256_compat.h new file mode 100644 index 00000000..6a969036 --- /dev/null +++ b/platform/esp/common/include/platform/esp/common/mbedtls_sha256_compat.h @@ -0,0 +1,41 @@ +#pragma once + +#include "mbedtls/sha256.h" +#include "mbedtls/version.h" + +#include +#include + +namespace platform::esp::common::crypto +{ + +inline int sha256_starts(mbedtls_sha256_context* context, int is224) +{ +#if MBEDTLS_VERSION_NUMBER < 0x03000000 + return mbedtls_sha256_starts_ret(context, is224); +#else + return mbedtls_sha256_starts(context, is224); +#endif +} + +inline int sha256_update(mbedtls_sha256_context* context, + const uint8_t* data, + std::size_t len) +{ +#if MBEDTLS_VERSION_NUMBER < 0x03000000 + return mbedtls_sha256_update_ret(context, data, len); +#else + return mbedtls_sha256_update(context, data, len); +#endif +} + +inline int sha256_finish(mbedtls_sha256_context* context, uint8_t output[32]) +{ +#if MBEDTLS_VERSION_NUMBER < 0x03000000 + return mbedtls_sha256_finish_ret(context, output); +#else + return mbedtls_sha256_finish(context, output); +#endif +} + +} // namespace platform::esp::common::crypto diff --git a/platform/esp/common/src/call_acoustic_echo_guard.cpp b/platform/esp/common/src/call_acoustic_echo_guard.cpp new file mode 100644 index 00000000..637af59e --- /dev/null +++ b/platform/esp/common/src/call_acoustic_echo_guard.cpp @@ -0,0 +1,76 @@ +#include "platform/esp/common/call_acoustic_echo_guard.h" + +#include +#include +#include + +namespace platform::esp::common::reticulum_call_audio +{ + +CallAcousticEchoGuard::CallAcousticEchoGuard(uint32_t sample_rate_hz, + uint16_t render_active_mean_abs, + uint16_t release_hold_ms) + : render_active_mean_abs_(render_active_mean_abs) +{ + const uint64_t hold_samples = + (static_cast(sample_rate_hz) * release_hold_ms) / 1000U; + release_hold_samples_ = static_cast( + std::min(hold_samples, std::numeric_limits::max())); +} + +bool CallAcousticEchoGuard::observeRender(const int16_t* pcm, + std::size_t sample_count) +{ + if (!pcm || sample_count == 0) + { + return false; + } + + uint64_t magnitude_sum = 0; + for (std::size_t index = 0; index < sample_count; ++index) + { + const int32_t sample = pcm[index]; + magnitude_sum += static_cast(sample < 0 ? -sample : sample); + } + last_render_mean_abs_ = static_cast( + std::min(magnitude_sum / sample_count, + std::numeric_limits::max())); + + const bool was_suppressed = captureSuppressed(); + if (last_render_mean_abs_ >= render_active_mean_abs_) + { + remaining_hold_samples_ = release_hold_samples_; + } + else if (remaining_hold_samples_ > sample_count) + { + remaining_hold_samples_ -= static_cast(sample_count); + } + else + { + remaining_hold_samples_ = 0; + } + return was_suppressed != captureSuppressed(); +} + +bool CallAcousticEchoGuard::suppressCapture(int16_t* pcm, + std::size_t sample_count) const +{ + if (!captureSuppressed() || !pcm || sample_count == 0) + { + return false; + } + std::memset(pcm, 0, sample_count * sizeof(int16_t)); + return true; +} + +bool CallAcousticEchoGuard::captureSuppressed() const +{ + return remaining_hold_samples_ != 0; +} + +uint16_t CallAcousticEchoGuard::lastRenderMeanAbs() const +{ + return last_render_mean_abs_; +} + +} // namespace platform::esp::common::reticulum_call_audio diff --git a/platform/esp/common/src/reticulum_call_audio_engine.cpp b/platform/esp/common/src/reticulum_call_audio_engine.cpp index 28b2548d..cbef3107 100644 --- a/platform/esp/common/src/reticulum_call_audio_engine.cpp +++ b/platform/esp/common/src/reticulum_call_audio_engine.cpp @@ -1,16 +1,19 @@ #include "platform/esp/common/reticulum_call_audio_engine.h" #include "chat/infra/reticulum/audio_call_wire.h" +#include "platform/esp/common/call_acoustic_echo_guard.h" #include "platform/ui/reticulum_call_runtime.h" #include #include #include +#include #include #include #include #include +#include namespace platform::esp::common::reticulum_call_audio { @@ -22,19 +25,17 @@ using ::chat::reticulum::audio_call::DecodedPayload; constexpr uint32_t kSampleRateHz = 8000; constexpr uint8_t kMaxCodecFramesPerPacket = 13; -constexpr uint8_t kJitterMinFrames = 3; -constexpr uint8_t kJitterMaxFrames = 16; -constexpr float kTxPcmGain = 1.5f; +constexpr uint8_t kJitterStartFrames = 3; +constexpr uint8_t kJitterMaxFrames = 32; +constexpr uint8_t kInboundDrainLimit = 4; +constexpr uint8_t kIoFailureLimit = 25; +constexpr float kTxPcmGain = 1.0f; constexpr float kRxPcmGain = 1.0f; -constexpr uint32_t kTaskStackBytes = 18 * 1024; +constexpr uint32_t kCaptureTaskStackBytes = 20 * 1024; +constexpr uint32_t kPlaybackTaskStackBytes = 20 * 1024; constexpr UBaseType_t kTaskPriority = tskIDLE_PRIORITY + 2; constexpr uint32_t kStopTimeoutMs = 750; -Backend s_backend{}; -TaskHandle_t s_task = nullptr; -volatile bool s_stop_requested = false; -bool s_backend_open = false; - struct CodecSelection { int codec2_mode = CODEC2_MODE_3200; @@ -42,6 +43,29 @@ struct CodecSelection uint32_t target_packet_ms = 200; }; +struct MediaSession +{ + explicit MediaSession(const CodecSelection& selected) + : selection(selected), echo_guard(kSampleRateHz) + { + } + + CodecSelection selection{}; + CallAcousticEchoGuard echo_guard; + SemaphoreHandle_t echo_mutex = nullptr; + TaskHandle_t capture_task = nullptr; + TaskHandle_t playback_task = nullptr; + volatile bool ready = false; + volatile bool stop_requested = false; + bool failure_notified = false; + uint8_t workers_remaining = 0; +}; + +Backend s_backend{}; +bool s_backend_open = false; +MediaSession* s_session = nullptr; +portMUX_TYPE s_session_lock = portMUX_INITIALIZER_UNLOCKED; + CodecSelection codec_selection( const ::platform::ui::reticulum_call::Snapshot& snapshot) { @@ -49,9 +73,9 @@ CodecSelection codec_selection( switch (snapshot.codec2_mode) { case ::platform::ui::reticulum_call::Codec2Mode::Mode700C: - selection.codec2_mode = CODEC2_MODE_700C; - selection.wire_mode = Codec2Mode::Mode700C; - selection.target_packet_ms = 400; + // The embedded Sideband profile is fixed to Codec2 3200. Keeping the + // fallback here prevents the recursive 700C decoder from consuming an + // unbounded ESP task stack if a stale session requests that mode. break; case ::platform::ui::reticulum_call::Codec2Mode::Mode1200: selection.codec2_mode = CODEC2_MODE_1200; @@ -69,11 +93,6 @@ CodecSelection codec_selection( return selection; } -uint32_t now_ms() -{ - return static_cast(pdTICKS_TO_MS(xTaskGetTickCount())); -} - void* audio_alloc(std::size_t bytes) { return heap_caps_malloc_prefer(bytes, @@ -128,6 +147,14 @@ int16_t clamp_sample(int32_t sample) return static_cast(sample); } +MediaSession* active_session() +{ + portENTER_CRITICAL(&s_session_lock); + MediaSession* session = s_session; + portEXIT_CRITICAL(&s_session_lock); + return session; +} + void close_backend() { if (s_backend_open && s_backend.close) @@ -137,258 +164,524 @@ void close_backend() s_backend_open = false; } -void free_buffers(int16_t* pcm_in, - int16_t* pcm_out, - int16_t* silence, - uint8_t* codec_frame, - uint8_t* tx_frames, - uint8_t* rx_frames, - uint8_t* packet_buf) +bool session_should_run(const MediaSession* session) { - audio_free(pcm_in); - audio_free(pcm_out); - audio_free(silence); - audio_free(codec_frame); - audio_free(tx_frames); - audio_free(rx_frames); - audio_free(packet_buf); + return session && !session->stop_requested && + ::platform::ui::reticulum_call::media_should_run(); } -void fail_media_start(CODEC2* codec2_state, - int16_t* pcm_in, - int16_t* pcm_out, - int16_t* silence, - uint8_t* codec_frame, - uint8_t* tx_frames, - uint8_t* rx_frames, - uint8_t* packet_buf) +bool wait_for_session_start(MediaSession* session) { - free_buffers(pcm_in, - pcm_out, - silence, - codec_frame, - tx_frames, - rx_frames, - packet_buf); - if (codec2_state) + while (session && !session->ready && !session->stop_requested) { - codec2_destroy(codec2_state); + vTaskDelay(1); + } + return session_should_run(session); +} + +void request_session_failure(MediaSession* session) +{ + bool notify = false; + portENTER_CRITICAL(&s_session_lock); + if (session && s_session == session && !session->failure_notified) + { + session->failure_notified = true; + session->stop_requested = true; + notify = true; + } + portEXIT_CRITICAL(&s_session_lock); + + if (notify) + { + ::platform::ui::reticulum_call::notify_media_failed(); + } +} + +void worker_finished(MediaSession* session, bool capture_worker) +{ + bool last_worker = false; + const TaskHandle_t current = xTaskGetCurrentTaskHandle(); + portENTER_CRITICAL(&s_session_lock); + if (session) + { + if (capture_worker && session->capture_task == current) + { + session->capture_task = nullptr; + } + else if (!capture_worker && session->playback_task == current) + { + session->playback_task = nullptr; + } + if (session->workers_remaining > 0) + { + --session->workers_remaining; + } + last_worker = session->workers_remaining == 0; + if (last_worker && s_session == session) + { + s_session = nullptr; + } + } + portEXIT_CRITICAL(&s_session_lock); + + if (last_worker) + { + close_backend(); + if (session->echo_mutex) + { + vSemaphoreDelete(session->echo_mutex); + } + session->~MediaSession(); + audio_free(session); } - close_backend(); - s_task = nullptr; - ::platform::ui::reticulum_call::notify_media_failed(); vTaskDelete(nullptr); } -void call_audio_task(void*) +bool suppress_capture(MediaSession* session, + int16_t* pcm, + std::size_t sample_count) { - const auto initial_snapshot = ::platform::ui::reticulum_call::snapshot(); - const CodecSelection selection = codec_selection(initial_snapshot); - CODEC2* codec2_state = codec2_create(selection.codec2_mode); - if (!codec2_state) + if (!session || !session->echo_mutex || + xSemaphoreTake(session->echo_mutex, portMAX_DELAY) != pdTRUE) + { + return false; + } + const bool suppressed = session->echo_guard.suppressCapture(pcm, sample_count); + xSemaphoreGive(session->echo_mutex); + return suppressed; +} + +void observe_render(MediaSession* session, + const int16_t* pcm, + std::size_t sample_count) +{ + if (!session || !session->echo_mutex || + xSemaphoreTake(session->echo_mutex, portMAX_DELAY) != pdTRUE) { - fail_media_start(nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr, nullptr); return; } - codec2_set_lpc_post_filter(codec2_state, 1, 0, 0.8f, 0.2f); + const bool changed = session->echo_guard.observeRender(pcm, sample_count); + const bool suppressed = session->echo_guard.captureSuppressed(); + const uint16_t render_mean = session->echo_guard.lastRenderMeanAbs(); + xSemaphoreGive(session->echo_mutex); - const int samples_per_frame = codec2_samples_per_frame(codec2_state); - const int bytes_per_frame = codec2_bytes_per_frame(codec2_state); - const uint32_t frame_interval_ms = - static_cast((samples_per_frame * 1000U) / kSampleRateHz); - const uint8_t codec_frames_per_packet = static_cast( - std::min(kMaxCodecFramesPerPacket, - std::max(1, - selection.target_packet_ms / - frame_interval_ms))); - - auto* pcm_in = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); - auto* pcm_out = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); - auto* silence = static_cast(audio_alloc(samples_per_frame * sizeof(int16_t))); - auto* codec_frame = static_cast(audio_alloc(bytes_per_frame)); - auto* tx_frames = - static_cast(audio_alloc(bytes_per_frame * kMaxCodecFramesPerPacket)); - auto* rx_frames = - static_cast(audio_alloc(bytes_per_frame * kJitterMaxFrames)); - auto* packet_buf = - static_cast(audio_alloc(::platform::ui::reticulum_call::kAudioPacketMaxLen)); - - if (!pcm_in || !pcm_out || !silence || !codec_frame || !tx_frames || !rx_frames || - !packet_buf) + if (changed) { - fail_media_start(codec2_state, - pcm_in, - pcm_out, - silence, - codec_frame, - tx_frames, - rx_frames, - packet_buf); + std::printf("[Reticulum][CallAudio] echo_guard suppressed=%u render_mean=%u\n", + suppressed ? 1U : 0U, + static_cast(render_mean)); + } +} + +void capture_task(void* context) +{ + auto* session = static_cast(context); + if (!wait_for_session_start(session)) + { + worker_finished(session, true); + return; + } + + CODEC2* encoder = codec2_create(session->selection.codec2_mode); + const int samples_per_frame = encoder ? codec2_samples_per_frame(encoder) : 0; + const int bytes_per_frame = encoder ? codec2_bytes_per_frame(encoder) : 0; + const uint32_t frame_interval_ms = samples_per_frame > 0 + ? static_cast( + (samples_per_frame * 1000U) / + kSampleRateHz) + : 0; + const uint8_t frames_per_packet = frame_interval_ms > 0 + ? static_cast(std::min( + kMaxCodecFramesPerPacket, + std::max( + 1, + session->selection.target_packet_ms / + frame_interval_ms))) + : 0; + + auto* pcm = samples_per_frame > 0 + ? static_cast( + audio_alloc(samples_per_frame * sizeof(int16_t))) + : nullptr; + auto* codec_frame = bytes_per_frame > 0 + ? static_cast(audio_alloc(bytes_per_frame)) + : nullptr; + auto* tx_frames = bytes_per_frame > 0 + ? static_cast(audio_alloc( + bytes_per_frame * kMaxCodecFramesPerPacket)) + : nullptr; + auto* packet = static_cast(audio_alloc( + ::platform::ui::reticulum_call::kAudioPacketMaxLen)); + + if (!encoder || !pcm || !codec_frame || !tx_frames || !packet || + frames_per_packet == 0) + { + audio_free(pcm); + audio_free(codec_frame); + audio_free(tx_frames); + audio_free(packet); + if (encoder) + { + codec2_destroy(encoder); + } + request_session_failure(session); + worker_finished(session, true); + return; + } + + uint8_t tx_frame_count = 0; + uint8_t consecutive_read_failures = 0; + bool stack_reported = false; + while (session_should_run(session)) + { + if (!s_backend.read_mono(pcm, + static_cast(samples_per_frame))) + { + if (++consecutive_read_failures >= kIoFailureLimit) + { + std::printf("[Reticulum][CallAudio] capture failed repeatedly\n"); + request_session_failure(session); + break; + } + vTaskDelay(pdMS_TO_TICKS(2)); + continue; + } + consecutive_read_failures = 0; + + if (!suppress_capture(session, + pcm, + static_cast(samples_per_frame))) + { + for (int index = 0; index < samples_per_frame; ++index) + { + pcm[index] = clamp_sample( + static_cast(pcm[index] * kTxPcmGain)); + } + } + codec2_encode(encoder, codec_frame, pcm); + if (!stack_reported) + { + stack_reported = true; + std::printf("[Reticulum][CallAudio] stack_hwm phase=encode value=%u\n", + static_cast( + uxTaskGetStackHighWaterMark(nullptr))); + } + std::memcpy(tx_frames + (tx_frame_count * bytes_per_frame), + codec_frame, + bytes_per_frame); + ++tx_frame_count; + + if (tx_frame_count >= frames_per_packet) + { + std::size_t packet_len = + ::platform::ui::reticulum_call::kAudioPacketMaxLen; + const auto snapshot = ::platform::ui::reticulum_call::snapshot(); + if (::chat::reticulum::audio_call::encodePayload( + session->selection.wire_mode, + tx_frames, + bytes_per_frame * tx_frame_count, + packet, + &packet_len)) + { + (void)::platform::ui::reticulum_call::enqueue_outbound_audio( + snapshot.link_id, + packet, + packet_len); + } + tx_frame_count = 0; + } + } + + audio_free(pcm); + audio_free(codec_frame); + audio_free(tx_frames); + audio_free(packet); + codec2_destroy(encoder); + worker_finished(session, true); +} + +void playback_task(void* context) +{ + auto* session = static_cast(context); + if (!wait_for_session_start(session)) + { + worker_finished(session, false); + return; + } + + CODEC2* decoder = codec2_create(session->selection.codec2_mode); + if (decoder) + { + codec2_set_lpc_post_filter(decoder, 1, 0, 0.8f, 0.2f); + } + const int samples_per_frame = decoder ? codec2_samples_per_frame(decoder) : 0; + const int bytes_per_frame = decoder ? codec2_bytes_per_frame(decoder) : 0; + const uint32_t frame_interval_ms = samples_per_frame > 0 + ? static_cast( + (samples_per_frame * 1000U) / + kSampleRateHz) + : 0; + + auto* pcm = samples_per_frame > 0 + ? static_cast( + audio_alloc(samples_per_frame * sizeof(int16_t))) + : nullptr; + auto* silence = samples_per_frame > 0 + ? static_cast( + audio_alloc(samples_per_frame * sizeof(int16_t))) + : nullptr; + auto* rx_frames = bytes_per_frame > 0 + ? static_cast(audio_alloc( + bytes_per_frame * kJitterMaxFrames)) + : nullptr; + auto* inbound = static_cast<::platform::ui::reticulum_call::AudioPacket*>( + audio_alloc(sizeof(::platform::ui::reticulum_call::AudioPacket))); + + if (!decoder || !pcm || !silence || !rx_frames || !inbound || + frame_interval_ms == 0) + { + audio_free(pcm); + audio_free(silence); + audio_free(rx_frames); + audio_free(inbound); + if (decoder) + { + codec2_destroy(decoder); + } + request_session_failure(session); + worker_finished(session, false); return; } std::memset(silence, 0, samples_per_frame * sizeof(int16_t)); - uint8_t tx_frame_count = 0; uint8_t rx_frame_count = 0; uint8_t rx_head = 0; uint8_t rx_tail = 0; - uint32_t last_play_ms = now_ms(); + uint8_t consecutive_write_failures = 0; + bool playback_primed = false; + bool stack_reported = false; + TickType_t last_wake = xTaskGetTickCount(); + const TickType_t frame_ticks = + std::max(1, pdMS_TO_TICKS(frame_interval_ms)); - while (!s_stop_requested && ::platform::ui::reticulum_call::media_should_run()) + while (session_should_run(session)) { - const auto snapshot = ::platform::ui::reticulum_call::snapshot(); - - ::platform::ui::reticulum_call::AudioPacket inbound{}; - uint8_t inbound_drains = 0; - while (inbound_drains < 2 && - ::platform::ui::reticulum_call::dequeue_inbound_audio(&inbound)) + uint8_t drains = 0; + while (drains < kInboundDrainLimit && + ::platform::ui::reticulum_call::dequeue_inbound_audio(inbound)) { DecodedPayload decoded{}; - if (::chat::reticulum::audio_call::decodePayload(inbound.data, - inbound.len, + if (::chat::reticulum::audio_call::decodePayload(inbound->data, + inbound->len, &decoded) && - decoded.mode == selection.wire_mode && decoded.encoded && - decoded.encoded_len >= static_cast(bytes_per_frame)) + decoded.mode == session->selection.wire_mode && decoded.encoded && + decoded.encoded_len >= static_cast(bytes_per_frame) && + (decoded.encoded_len % static_cast(bytes_per_frame)) == + 0) { - const std::size_t frames = + const std::size_t frame_count = decoded.encoded_len / static_cast(bytes_per_frame); - for (std::size_t index = 0; index < frames; ++index) + for (std::size_t index = 0; index < frame_count; ++index) { - if (rx_frame_count >= kJitterMaxFrames) + if (rx_frame_count == kJitterMaxFrames) { - break; + rx_head = static_cast( + (rx_head + 1U) % kJitterMaxFrames); + --rx_frame_count; } - uint8_t* dst = rx_frames + (rx_tail * bytes_per_frame); - std::memcpy(dst, + std::memcpy(rx_frames + (rx_tail * bytes_per_frame), decoded.encoded + (index * bytes_per_frame), bytes_per_frame); - rx_tail = static_cast((rx_tail + 1U) % kJitterMaxFrames); + rx_tail = static_cast( + (rx_tail + 1U) % kJitterMaxFrames); ++rx_frame_count; } } - ++inbound_drains; + ++drains; } - if (s_backend.read_mono(pcm_in, static_cast(samples_per_frame))) + vTaskDelayUntil(&last_wake, frame_ticks); + + const int16_t* playback = silence; + if (!playback_primed && rx_frame_count >= kJitterStartFrames) { + playback_primed = true; + } + if (playback_primed && rx_frame_count > 0) + { + const uint8_t* frame = rx_frames + (rx_head * bytes_per_frame); + codec2_decode(decoder, pcm, const_cast(frame)); + if (!stack_reported) + { + stack_reported = true; + std::printf("[Reticulum][CallAudio] stack_hwm phase=decode value=%u\n", + static_cast( + uxTaskGetStackHighWaterMark(nullptr))); + } + rx_head = static_cast((rx_head + 1U) % kJitterMaxFrames); + --rx_frame_count; for (int index = 0; index < samples_per_frame; ++index) { - pcm_in[index] = - clamp_sample(static_cast(pcm_in[index] * kTxPcmGain)); - } - codec2_encode(codec2_state, codec_frame, pcm_in); - std::memcpy(tx_frames + (tx_frame_count * bytes_per_frame), - codec_frame, - bytes_per_frame); - ++tx_frame_count; - - if (tx_frame_count >= codec_frames_per_packet) - { - std::size_t packet_len = - ::platform::ui::reticulum_call::kAudioPacketMaxLen; - if (::chat::reticulum::audio_call::encodePayload( - selection.wire_mode, - tx_frames, - bytes_per_frame * tx_frame_count, - packet_buf, - &packet_len)) - { - (void)::platform::ui::reticulum_call::enqueue_outbound_audio( - snapshot.link_id, - packet_buf, - packet_len); - } - tx_frame_count = 0; + pcm[index] = clamp_sample( + static_cast(pcm[index] * kRxPcmGain)); } + playback = pcm; } - - const uint32_t current_ms = now_ms(); - if (current_ms - last_play_ms >= frame_interval_ms) + else if (playback_primed) { - last_play_ms = current_ms; - const int16_t* playback = silence; - if (rx_frame_count >= kJitterMinFrames) - { - const uint8_t* frame = rx_frames + (rx_head * bytes_per_frame); - codec2_decode(codec2_state, pcm_out, const_cast(frame)); - rx_head = static_cast((rx_head + 1U) % kJitterMaxFrames); - --rx_frame_count; - for (int index = 0; index < samples_per_frame; ++index) - { - pcm_out[index] = - clamp_sample(static_cast(pcm_out[index] * kRxPcmGain)); - } - playback = pcm_out; - } - (void)s_backend.write_mono(playback, - static_cast(samples_per_frame)); + playback_primed = false; } - vTaskDelay(pdMS_TO_TICKS(2)); + observe_render(session, + playback, + static_cast(samples_per_frame)); + if (!s_backend.write_mono(playback, + static_cast(samples_per_frame))) + { + if (++consecutive_write_failures >= kIoFailureLimit) + { + std::printf("[Reticulum][CallAudio] playback failed repeatedly\n"); + request_session_failure(session); + break; + } + } + else + { + consecutive_write_failures = 0; + } } - free_buffers(pcm_in, - pcm_out, - silence, - codec_frame, - tx_frames, - rx_frames, - packet_buf); - codec2_destroy(codec2_state); + audio_free(pcm); + audio_free(silence); + audio_free(rx_frames); + audio_free(inbound); + codec2_destroy(decoder); + worker_finished(session, false); +} + +void destroy_unstarted_session(MediaSession* session) +{ + portENTER_CRITICAL(&s_session_lock); + if (s_session == session) + { + s_session = nullptr; + } + portEXIT_CRITICAL(&s_session_lock); + if (session) + { + if (session->echo_mutex) + { + vSemaphoreDelete(session->echo_mutex); + } + session->~MediaSession(); + audio_free(session); + } close_backend(); - s_stop_requested = false; - s_task = nullptr; - vTaskDelete(nullptr); } bool media_start() { - if (s_task) + if (MediaSession* existing = active_session()) { - return !s_stop_requested; + return !existing->stop_requested; } if (!media_supported() || !s_backend.open(kSampleRateHz)) { return false; } - s_backend_open = true; - s_stop_requested = false; - const BaseType_t rc = xTaskCreate(call_audio_task, - "rt_call_audio", - kTaskStackBytes, - nullptr, - kTaskPriority, - &s_task); - if (rc != pdPASS) - { - close_backend(); - s_task = nullptr; - return false; - } + const auto snapshot = ::platform::ui::reticulum_call::snapshot(); const CodecSelection selection = codec_selection(snapshot); - std::printf("[Reticulum][CallAudio] media started codec2_mode=%d wire=%u\n", - selection.codec2_mode, - static_cast(snapshot.wire_profile)); + void* storage = heap_caps_malloc(sizeof(MediaSession), + MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + auto* session = storage ? new (storage) MediaSession(selection) : nullptr; + if (!session) + { + close_backend(); + return false; + } + session->echo_mutex = xSemaphoreCreateMutex(); + if (!session->echo_mutex) + { + destroy_unstarted_session(session); + return false; + } + + portENTER_CRITICAL(&s_session_lock); + s_session = session; + portEXIT_CRITICAL(&s_session_lock); + + if (xTaskCreate(capture_task, + "rt_call_capture", + kCaptureTaskStackBytes, + session, + kTaskPriority, + &session->capture_task) != pdPASS) + { + destroy_unstarted_session(session); + return false; + } + + if (xTaskCreate(playback_task, + "rt_call_play", + kPlaybackTaskStackBytes, + session, + kTaskPriority, + &session->playback_task) != pdPASS) + { + portENTER_CRITICAL(&s_session_lock); + session->workers_remaining = 1; + session->stop_requested = true; + session->ready = true; + portEXIT_CRITICAL(&s_session_lock); + return false; + } + + portENTER_CRITICAL(&s_session_lock); + session->workers_remaining = 2; + session->ready = true; + portEXIT_CRITICAL(&s_session_lock); + std::printf( + "[Reticulum][CallAudio] media started codec2_mode=%d capture_stack=%u playback_stack=%u\n", + selection.codec2_mode, + static_cast(kCaptureTaskStackBytes), + static_cast(kPlaybackTaskStackBytes)); return true; } void media_stop() { - s_stop_requested = true; - const TaskHandle_t task = s_task; - if (!task || xTaskGetCurrentTaskHandle() == task) + MediaSession* session = nullptr; + TaskHandle_t capture = nullptr; + TaskHandle_t playback = nullptr; + portENTER_CRITICAL(&s_session_lock); + session = s_session; + if (session) + { + session->stop_requested = true; + capture = session->capture_task; + playback = session->playback_task; + } + portEXIT_CRITICAL(&s_session_lock); + + if (!session) + { + return; + } + const TaskHandle_t current = xTaskGetCurrentTaskHandle(); + if (current == capture || current == playback) { return; } const TickType_t started = xTaskGetTickCount(); - while (s_task && pdTICKS_TO_MS(xTaskGetTickCount() - started) < kStopTimeoutMs) + while (active_session() == session && + pdTICKS_TO_MS(xTaskGetTickCount() - started) < kStopTimeoutMs) { vTaskDelay(pdMS_TO_TICKS(5)); } - if (s_task) + if (active_session() == session) { std::printf("[Reticulum][CallAudio] stop timeout\n"); } @@ -398,7 +691,7 @@ void media_stop() bool install_backend(const Backend& backend) { - if (!backend_complete(backend) || s_task || s_backend_open) + if (!backend_complete(backend) || active_session() || s_backend_open) { return false; } diff --git a/platform/esp/common/tests/test_call_acoustic_echo_guard.cpp b/platform/esp/common/tests/test_call_acoustic_echo_guard.cpp new file mode 100644 index 00000000..d558f614 --- /dev/null +++ b/platform/esp/common/tests/test_call_acoustic_echo_guard.cpp @@ -0,0 +1,42 @@ +#include "platform/esp/common/call_acoustic_echo_guard.h" + +#include +#include +#include +#include + +using platform::esp::common::reticulum_call_audio::CallAcousticEchoGuard; + +int main() +{ + constexpr std::size_t kFrameSamples = 160; + std::array quiet{}; + std::array remote_voice{}; + std::array capture{}; + remote_voice.fill(800); + capture.fill(1200); + + CallAcousticEchoGuard guard(8000, 400, 60); + assert(!guard.observeRender(quiet.data(), quiet.size())); + assert(!guard.captureSuppressed()); + + assert(guard.observeRender(remote_voice.data(), remote_voice.size())); + assert(guard.captureSuppressed()); + assert(guard.lastRenderMeanAbs() == 800); + assert(guard.suppressCapture(capture.data(), capture.size())); + assert(std::all_of(capture.begin(), capture.end(), [](int16_t sample) + { return sample == 0; })); + + assert(!guard.observeRender(quiet.data(), quiet.size())); + assert(!guard.observeRender(quiet.data(), quiet.size())); + assert(guard.captureSuppressed()); + assert(guard.observeRender(quiet.data(), quiet.size())); + assert(!guard.captureSuppressed()); + + capture.fill(1200); + assert(!guard.suppressCapture(capture.data(), capture.size())); + assert(std::all_of(capture.begin(), capture.end(), [](int16_t sample) + { return sample == 1200; })); + + return 0; +} diff --git a/platform/linux/common/include/sys/event_bus.h b/platform/linux/common/include/sys/event_bus.h index 0f071c14..9fd6a891 100644 --- a/platform/linux/common/include/sys/event_bus.h +++ b/platform/linux/common/include/sys/event_bus.h @@ -83,9 +83,16 @@ struct ChatSendResultEvent : public Event { uint32_t msg_id; bool success; + chat::MessageStatus status; ChatSendResultEvent(uint32_t id, bool ok) - : Event(EventType::ChatSendResult), msg_id(id), success(ok) {} + : Event(EventType::ChatSendResult), msg_id(id), success(ok), + status(ok ? chat::MessageStatus::Sent : chat::MessageStatus::Failed) {} + + ChatSendResultEvent(uint32_t id, chat::MessageStatus result_status) + : Event(EventType::ChatSendResult), msg_id(id), + success(result_status != chat::MessageStatus::Failed), + status(result_status) {} }; struct ChatUnreadChangedEvent : public Event diff --git a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp index b345db4e..dafd5030 100644 --- a/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp +++ b/platform/linux/uconsole/src/uconsole_chat_workspace_model.cpp @@ -44,6 +44,8 @@ namespace return "sent"; case ::chat::MessageStatus::Failed: return "failed"; + case ::chat::MessageStatus::Delivered: + return "delivered"; } return "unknown"; } diff --git a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/sys/event_bus.h b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/sys/event_bus.h index 2014e93d..88c090fd 100644 --- a/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/sys/event_bus.h +++ b/platform/nrf52/arduino_common/include/platform/nrf52/arduino_common/sys/event_bus.h @@ -31,9 +31,16 @@ struct ChatSendResultEvent : public Event { chat::MessageId msg_id; bool success; + chat::MessageStatus status; ChatSendResultEvent(chat::MessageId id, bool ok) - : Event(EventType::ChatSendResult), msg_id(id), success(ok) {} + : Event(EventType::ChatSendResult), msg_id(id), success(ok), + status(ok ? chat::MessageStatus::Sent : chat::MessageStatus::Failed) {} + + ChatSendResultEvent(chat::MessageId id, chat::MessageStatus result_status) + : Event(EventType::ChatSendResult), msg_id(id), + success(result_status != chat::MessageStatus::Failed), + status(result_status) {} }; struct KeyVerificationNumberRequestEvent : public Event diff --git a/tests/reticulum_conformance/deviations.md b/tests/reticulum_conformance/deviations.md index 85a82fde..f79e5c04 100644 --- a/tests/reticulum_conformance/deviations.md +++ b/tests/reticulum_conformance/deviations.md @@ -34,3 +34,4 @@ Each entry must be classified before it drives code changes. | RCNF-007 | Runtime state ownership | Trail Mate has explicit transport, link, resource, propagation, propagation-service, and delivery runtime helpers for the current embedded Reticulum runtime ownership contract. | | RCNF-008 | Supported-subset golden baseline | Trail Mate has deterministic product-subset golden vectors for the Reticulum/LXMF bytes currently used by supported business paths; canonical Python Reticulum/LXMF cross-check remains a later conformance upgrade. | | RCNF-009 | Wi-Fi gateway final LinkRequest delivery | A Header 2 LinkRequest addressed to a local destination is now accepted as final delivery when it arrives from the configured Wi-Fi gateway, even when the retained transport id belongs to that gateway. Transport-id matching remains required for non-local forwarding and non-gateway ingress. | +| RCNF-010 | LXMF propagation client | The embedded runtime supports official propagation-node announce discovery, propagated message packing and stamping, identified Link packet/Resource upload with proof validation, and bounded `/get` synchronization. Propagation-node service mode remains a separate opt-in capability. | diff --git a/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp b/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp index 2354977f..04831409 100644 --- a/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp +++ b/tests/reticulum_conformance/test_reticulum_runtime_state_contract.cpp @@ -83,6 +83,7 @@ int main() assert(transport.reverse_table.empty()); assert(transport.pending_path_requests.empty()); assert(transport.pending_ping_receipts.empty()); + assert(transport.pending_delivery_receipts.empty()); assert(transport.link_relays.empty()); const TransportRuntimeLimits limits{ @@ -97,7 +98,9 @@ int main() 300000, 4, 1000, - 500}; + 500, + 4, + 750}; const auto destination = filled_hash(0x10); PathEntry& path = upsertPath(transport, destination.data(), limits.max_paths); @@ -161,9 +164,15 @@ int main() assert(!isDuplicatePacket(transport, packet_hash.data())); rememberPacket(transport, packet_hash.data(), 100, limits.max_packet_filter); - rememberReversePath(transport, destination.data(), 3, 200, limits.max_reverse_entries); + rememberReversePath(transport, + destination.data(), + 7, + 3, + 200, + limits.max_reverse_entries); ReverseEntry* reverse = findReversePath(transport, destination.data()); assert(reverse != nullptr); + assert(reverse->interface_id == 7); assert(reverse->expected_hops == 3); notePendingPathRequest(transport, destination.data(), 300, limits.max_pending_path_requests); @@ -192,6 +201,21 @@ int main() assert(same_hash(ping_receipt->destination_hash, destination)); assert(same_hash(ping_receipt->peer_sig_pub, peer_sig_pub)); + notePendingDeliveryReceipt(transport, + packet_hash.data(), + destination.data(), + peer_sig_pub.data(), + 1234, + 350, + limits.max_pending_delivery_receipts); + PendingDeliveryReceipt* delivery_receipt = + findPendingDeliveryReceipt(transport, packet_hash.data()); + assert(delivery_receipt != nullptr); + assert(delivery_receipt->message_id == 1234); + assert(same_hash(delivery_receipt->packet_hash, packet_hash)); + assert(same_hash(delivery_receipt->destination_hash, destination)); + assert(same_hash(delivery_receipt->peer_sig_pub, peer_sig_pub)); + LinkRelayEntry& relay = upsertLinkRelay(transport, destination.data(), limits.max_link_relays); relay.initiator_hops = 1; relay.responder_hops = 2; @@ -204,6 +228,7 @@ int main() assert(transport.reverse_table.empty()); assert(transport.pending_path_requests.empty()); assert(transport.pending_ping_receipts.empty()); + assert(transport.pending_delivery_receipts.empty()); assert(transport.link_relays.empty()); assert(transport.paths.empty()); @@ -1038,6 +1063,11 @@ int main() delivery_context.peer_node_id = 0x01020304; delivery_context.local_node_id = 0x11121314; delivery_context.message_id = 0xAABBCCDD; + delivery_context.has_message_hash = true; + for (std::size_t index = 0; index < sizeof(delivery_context.message_hash); ++index) + { + delivery_context.message_hash[index] = static_cast(0x80U + index); + } delivery_context.timestamp_s = 1234567890; delivery_context.peer_identity = ::chat::makeReticulumPeerIdentity(delivery_hash.data(), identity_hash.data()); @@ -1066,6 +1096,10 @@ int main() assert(text_delivery.incoming.from == delivery_context.peer_node_id); assert(text_delivery.incoming.to == delivery_context.local_node_id); assert(text_delivery.incoming.msg_id == delivery_context.message_id); + assert(text_delivery.incoming.has_reticulum_lxmf_hash); + assert(std::memcmp(text_delivery.incoming.reticulum_lxmf_hash, + delivery_context.message_hash, + sizeof(delivery_context.message_hash)) == 0); assert(text_delivery.incoming.timestamp == delivery_context.timestamp_s); assert(text_delivery.incoming.hop_limit == 0xFF); assert(text_delivery.incoming.encrypted); @@ -1085,6 +1119,10 @@ int main() ::chat::MeshIncomingText popped_text{}; assert(text_queue.pop(&popped_text)); assert(popped_text.text == text_payload.content); + assert(popped_text.has_reticulum_lxmf_hash); + assert(std::memcmp(popped_text.reticulum_lxmf_hash, + delivery_context.message_hash, + sizeof(delivery_context.message_hash)) == 0); assert(!popped_text.source_unverified); assert(::chat::sameReticulumPeerIdentity(popped_text.reticulum_identity, delivery_context.peer_identity)); diff --git a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp index 02a22c81..1fb90077 100644 --- a/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp +++ b/tests/reticulum_conformance/test_reticulum_supported_subset_vectors.cpp @@ -1,4 +1,5 @@ #include "chat/infra/lxmf/lxmf_wire.h" +#include "chat/infra/reticulum/lxst_call_state_machine.h" #include "chat/infra/reticulum/lxst_telephony_wire.h" #include "chat/infra/reticulum/reticulum_wire.h" #include "team/protocol/team_portnum.h" @@ -17,6 +18,7 @@ namespace namespace lxmf = chat::lxmf; namespace lxst = chat::reticulum::lxst; +namespace lxst_call = chat::reticulum::lxst::call; namespace reticulum = chat::reticulum; constexpr const char* kFullHashTrailMate = @@ -124,6 +126,13 @@ constexpr const char* kPropagationMessageList = constexpr const char* kPropagationBatch = "92cb409452000000000092c412101112131415161718191a1b1c1d1e1f0102" "c411202122232425262728292a2b2c2d2e2f03"; +constexpr const char* kOfficialPropagationAnnounce = + "97c2ce6553f100c340cc80930c02128101c408547261696c20504e"; +constexpr const char* kOfficialPropagationGet = + "9391c420000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + "91c420a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babb" + "bcbdbebf40"; +constexpr const char* kOfficialPropagationListRequest = "92c0c0"; uint8_t hexNibble(char value) { @@ -823,6 +832,56 @@ void expectPropagationVectors() assert(decoded_get.has_transfer_limit); assert(decoded_get.transfer_limit_kb == 64); + std::vector> official_wants(1); + std::vector> official_haves(1); + for (uint8_t value = 0; value < reticulum::kFullHashSize; ++value) + { + official_wants.front().push_back(value); + official_haves.front().push_back(static_cast(0xA0U + value)); + } + std::vector official_get = encodeBuffer(128); + std::size_t official_get_len = official_get.size(); + assert(lxmf::encodePropagationGetRequestPayload(&official_wants, + &official_haves, + true, + 64, + official_get.data(), + &official_get_len)); + official_get.resize(official_get_len); + expectBytes(official_get.data(), + official_get.size(), + kOfficialPropagationGet); + + std::vector list_request = encodeBuffer(16); + std::size_t list_request_len = list_request.size(); + assert(lxmf::encodePropagationGetRequestPayload(nullptr, + nullptr, + false, + 0, + list_request.data(), + &list_request_len)); + list_request.resize(list_request_len); + expectBytes(list_request.data(), + list_request.size(), + kOfficialPropagationListRequest); + + const std::vector official_announce = + fromHex(kOfficialPropagationAnnounce); + lxmf::DecodedPropagationAnnounce decoded_announce{}; + assert(lxmf::decodePropagationAnnounceAppData(official_announce.data(), + official_announce.size(), + &decoded_announce)); + assert(decoded_announce.valid); + assert(!decoded_announce.legacy_support); + assert(decoded_announce.node_active); + assert(decoded_announce.timebase_s == 1700000000U); + assert(decoded_announce.transfer_limit_kb == 64U); + assert(decoded_announce.sync_limit_kb == 128U); + assert(decoded_announce.stamp_cost == 12U); + assert(decoded_announce.stamp_cost_flexibility == 2U); + assert(decoded_announce.peering_cost == 18U); + assert(decoded_announce.display_name == "Trail PN"); + std::vector id_list = encodeBuffer(64); std::size_t id_list_len = id_list.size(); assert(lxmf::encodePropagationIdListPayload({decoded_get.wants.front()}, @@ -863,6 +922,16 @@ void expectPropagationVectors() assert(decoded_batch.messages[0] == propagated_message); assert(decoded_batch.messages[1] == second_message); + std::vector trailing_batch = batch; + trailing_batch.push_back(0x00); + assert(!lxmf::decodePropagationBatch(trailing_batch.data(), + trailing_batch.size(), + &decoded_batch)); + const std::vector oversized_list = fromHex("dc0041"); + assert(!lxmf::decodePropagationIdListPayload(oversized_list.data(), + oversized_list.size(), + &decoded_get.wants)); + const std::vector invalid_msgpack = fromHex("91c4"); assert(!lxmf::decodePropagationOfferPayload(invalid_msgpack.data(), invalid_msgpack.size(), @@ -875,6 +944,169 @@ void expectPropagationVectors() &decoded_batch)); } +bool hasAction(const lxst_call::Transition& transition, + lxst_call::Action expected) +{ + for (std::size_t index = 0; index < transition.action_count; ++index) + { + if (transition.actions[index] == expected) + { + return true; + } + } + return false; +} + +void expectLxstCallStateMachine() +{ + uint32_t now_ms = 100; + auto caller = lxst_call::makeCaller(lxst::kProfileBandwidthLow, now_ms); + assert(caller.phase == lxst_call::Phase::CallerAwaitingLink); + + auto transition = lxst_call::dispatch( + &caller, + {lxst_call::EventType::LinkActive}, + ++now_ms); + assert(transition.accepted); + assert(transition.action_count == 0); + assert(caller.phase == lxst_call::Phase::CallerAwaitingAvailability); + + transition = lxst_call::dispatch( + &caller, + lxst_call::Event::remoteSignal(lxst::kStatusAvailable), + ++now_ms); + assert(transition.accepted); + assert(hasAction(transition, lxst_call::Action::SendIdentify)); + assert(caller.phase == lxst_call::Phase::CallerAwaitingRinging); + + const auto duplicate_available = lxst_call::dispatch( + &caller, + lxst_call::Event::remoteSignal(lxst::kStatusAvailable), + ++now_ms); + assert(duplicate_available.accepted); + assert(duplicate_available.action_count == 0); + + transition = lxst_call::dispatch( + &caller, + lxst_call::Event::remoteSignal(lxst::kStatusRinging), + ++now_ms); + assert(hasAction(transition, + lxst_call::Action::SendPreferredProfile)); + assert(caller.phase == lxst_call::Phase::CallerNegotiating); + + transition = lxst_call::dispatch( + &caller, + lxst_call::Event::remoteSignal(lxst::kStatusConnecting), + ++now_ms); + assert(hasAction(transition, lxst_call::Action::PrepareMedia)); + assert(caller.phase == lxst_call::Phase::CallerConnecting); + + transition = lxst_call::dispatch( + &caller, + {lxst_call::EventType::MediaReady}, + ++now_ms); + assert(transition.accepted); + assert(transition.action_count == 0); + assert(caller.media_prepared); + + transition = lxst_call::dispatch( + &caller, + lxst_call::Event::remoteSignal(lxst::kStatusEstablished), + ++now_ms); + assert(hasAction(transition, lxst_call::Action::ActivateMedia)); + assert(caller.phase == lxst_call::Phase::Active); + assert(!lxst_call::dispatch( + &caller, + lxst_call::Event::remoteSignal(lxst::kStatusRinging), + ++now_ms) + .accepted); + + auto callee = lxst_call::makeCallee(lxst::kProfileBandwidthLow, now_ms); + transition = lxst_call::dispatch( + &callee, + {lxst_call::EventType::LinkActive}, + ++now_ms); + assert(hasAction(transition, lxst_call::Action::SendAvailable)); + assert(callee.phase == lxst_call::Phase::CalleeAwaitingIdentity); + + transition = lxst_call::dispatch( + &callee, + {lxst_call::EventType::RemoteIdentified}, + ++now_ms); + assert(hasAction(transition, lxst_call::Action::BeginRinging)); + assert(callee.phase == lxst_call::Phase::CalleeAwaitingAdmission); + + transition = lxst_call::dispatch( + &callee, + {lxst_call::EventType::AdmissionGranted}, + ++now_ms); + assert(hasAction(transition, lxst_call::Action::SendRinging)); + assert(callee.phase == lxst_call::Phase::CalleeRinging); + + assert(!lxst_call::dispatch( + &callee, + lxst_call::Event::remoteSignal(lxst::kStatusAvailable), + ++now_ms) + .accepted); + transition = lxst_call::dispatch( + &callee, + lxst_call::Event::remoteSignal( + lxst::kPreferredProfile + lxst::kProfileBandwidthVeryLow), + ++now_ms); + assert(transition.accepted); + assert(hasAction(transition, + lxst_call::Action::SendPreferredProfile)); + + transition = lxst_call::dispatch( + &callee, + {lxst_call::EventType::LocalAccepted}, + ++now_ms); + assert(hasAction(transition, lxst_call::Action::SendConnecting)); + assert(hasAction(transition, lxst_call::Action::PrepareMedia)); + assert(callee.phase == lxst_call::Phase::CalleeConnecting); + + transition = lxst_call::dispatch( + &callee, + {lxst_call::EventType::MediaReady}, + ++now_ms); + assert(hasAction(transition, lxst_call::Action::SendEstablished)); + assert(hasAction(transition, lxst_call::Action::ActivateMedia)); + assert(callee.phase == lxst_call::Phase::Active); + + auto rejected = lxst_call::makeCallee( + lxst::kProfileBandwidthLow, + now_ms); + (void)lxst_call::dispatch( + &rejected, + {lxst_call::EventType::LinkActive}, + ++now_ms); + (void)lxst_call::dispatch( + &rejected, + {lxst_call::EventType::RemoteIdentified}, + ++now_ms); + (void)lxst_call::dispatch( + &rejected, + {lxst_call::EventType::AdmissionGranted}, + ++now_ms); + transition = lxst_call::dispatch( + &rejected, + {lxst_call::EventType::LocalHangup}, + ++now_ms); + assert(hasAction(transition, lxst_call::Action::SendRejected)); + assert(hasAction(transition, lxst_call::Action::CloseLink)); + assert(rejected.phase == lxst_call::Phase::Closing); + + auto timeout = lxst_call::makeCaller( + lxst::kProfileBandwidthLow, + 0); + (void)lxst_call::dispatch( + &timeout, + {lxst_call::EventType::LinkActive}, + 1); + assert(!lxst_call::phaseTimedOut(timeout, 15000)); + assert(lxst_call::phaseTimedOut(timeout, 15001)); +} + } // namespace int main() @@ -885,5 +1117,6 @@ int main() expectLinkServiceVectors(); expectResourceVectors(); expectPropagationVectors(); + expectLxstCallStateMachine(); return 0; }