diff --git a/apps/esp_pio/include/apps/esp_pio/app_context.h b/apps/esp_pio/include/apps/esp_pio/app_context.h index 1b34094a..28a90d5f 100644 --- a/apps/esp_pio/include/apps/esp_pio/app_context.h +++ b/apps/esp_pio/include/apps/esp_pio/app_context.h @@ -221,6 +221,12 @@ class AppContext final : public IAppBleFacade config_.meshcore_config = chat::MeshConfig(); config_.applyMeshCoreFactoryDefaults(); } + else if (config_.mesh_protocol == chat::MeshProtocol::RNode || + config_.mesh_protocol == chat::MeshProtocol::LXMF) + { + config_.rnode_config = chat::MeshConfig(); + config_.applyRNodeFactoryDefaults(); + } else { config_.meshtastic_config = chat::MeshConfig(); diff --git a/docs/RETICULUM_LXMF_DEVICE_MODE.md b/docs/RETICULUM_LXMF_DEVICE_MODE.md new file mode 100644 index 00000000..5d498c25 --- /dev/null +++ b/docs/RETICULUM_LXMF_DEVICE_MODE.md @@ -0,0 +1,298 @@ +# Reticulum / LXMF Device Mode Plan + +## Why this document exists + +Trail Mate already has two very different things under the "RNode" umbrella: + +- an honest low-level RNode air-layer implementation +- a USB CDC RNode KISS bridge so a real Reticulum host can use the device as a modem + +What it does **not** yet have is a real device-side Reticulum/LXMF stack. + +That distinction matters: + +- `RNode modem` means the device forwards raw LoRa bytes and is controlled by an external Reticulum host +- `Reticulum/LXMF device mode` means the device itself owns identity, announces, destination discovery, packet encryption, message packing and local chat UX + +The goal of this document is to define the end-state architecture and the first honest implementation slice. It intentionally avoids inventing a private "fake LXMF-like" chat envelope. + +## Target outcome + +Trail Mate should support these runtime roles: + +- `Meshtastic`: native device-side chat over Meshtastic +- `MeshCore`: native device-side chat over MeshCore +- `LXMF`: native device-side chat over a Reticulum-compatible subset carried on RNode-style raw LoRa packets +- `RNode Bridge`: host-controlled modem mode for an external Reticulum/LXMF stack + +In other words: + +- `LXMF` is an application/network stack mode +- `RNode Bridge` is a modem/bridge mode + +Today the codebase still couples "active radio backend" and "active user-visible protocol" into one selector. Because of that, the first implementation phase keeps `RNode Bridge` as a separate protocol value instead of fully moving it into the `Data Exchange` page. That split is deferred until the runtime can own more than one radio-facing backend safely. + +## Constraints from the reference implementations + +The local `.tmp/Reticulum` and `.tmp/LXMF` sources make the following points non-negotiable: + +- Reticulum is a full network stack, not just a payload codec +- `Identity` uses two keypairs in practice: + - Curve25519/X25519 for encryption and ECDH + - Ed25519 for signatures +- destination addressing is derived from: + - `name_hash = SHA256(app_name + aspects)[:10]` + - `destination_hash = SHA256(name_hash + identity_hash)[:16]` +- opportunistic single-packet encryption uses: + - ephemeral Curve25519 key exchange + - HKDF-SHA256 derived 64-byte token key + - AES-256-CBC + PKCS7 + - HMAC-SHA256 token authentication +- packet delivery proofs are real protocol behavior, not optional UI sugar +- LXMF wire format is: + - 16 bytes destination hash + - 16 bytes source hash + - 64 bytes signature + - msgpack payload +- LXMF routing above that depends on announces, destination recall, proofs, and later links/resources/propagation + +## Honest phase boundary + +The full Reticulum feature set is too large to land in one pass without high risk. The first device-side implementation therefore targets a **real interoperable subset**: + +- local Reticulum identity storage +- `lxmf.delivery` destination derivation +- announce transmit/receive and signature validation +- peer discovery from direct announces +- opportunistic LXMF text delivery in single packets +- proof generation for received single-packet deliveries +- contact list integration from announces +- device-side unicast text chat to directly discovered peers + +This first slice is intentionally **not** yet: + +- multi-hop transport/path finding +- path request / path response handling +- link establishment +- resource transfer for large LXMF messages +- propagation nodes +- ticket/stamp enforcement +- ratchets +- full shared-instance parity with Python Reticulum + +Even with those limits, the phase-1 result is still a real protocol subset: + +- packet headers are real Reticulum packet headers +- announces are real Reticulum announces +- payload encryption is the real token scheme +- LXMF bodies are real LXMF bodies + +## Proposed architecture + +### 1. Radio carrier layer + +Reuse the current RNode raw air layer: + +- one-byte RNode air header +- packet fragmentation / reassembly +- raw LoRa payload send/receive +- radio parameter application + +This remains the LoRa carrier for both: + +- `RNode Bridge` +- `LXMF` + +### 2. Reticulum core subset + +Add a shared Reticulum wire layer with: + +- constants for MTU, header sizing and hash sizes +- packet encode/decode for header-1 packets +- packet hash calculation +- destination hash helpers +- announce validation helpers +- HKDF + token encryption helpers + +This layer must stay protocol-accurate and not depend on UI concerns. + +### 3. LXMF wire layer + +Add a shared LXMF wire layer with: + +- minimal msgpack encoder/decoder for the LXMF structures used in phase 1 +- peer announce app-data codec for `[display_name, stamp_cost]` +- text-message pack/unpack for `[timestamp, title, content, fields]` +- support for an optional fifth payload element so future stamp support does not break parsing + +### 4. Platform identity layer + +Add an ESP-side identity service that persists: + +- Curve25519 public/private keypair +- Ed25519 public/private keypair +- local delivery destination hash + +This layer should reuse the existing preferences/blob storage style already used by MeshCore identity handling. + +### 5. LXMF adapter layer + +Add a new `LxmfAdapter` that: + +- composes the existing `RNodeAdapter` as its raw carrier +- owns local identity and peer recall tables +- periodically emits announces +- turns valid announces into `NodeInfoUpdateEvent` and `NodeProtocolUpdateEvent` +- sends opportunistic LXMF text packets +- receives and verifies opportunistic LXMF text packets +- emits proofs for received packets +- exposes incoming text to `ChatService` + +### 6. UI / service integration + +Phase 1 UI integration should be minimal and honest: + +- add `LXMF` as a first-class protocol option +- keep `RNode Bridge` explicit for the host-controlled modem role +- allow Contacts and Chat pages to work when `LxmfAdapter` reports text support +- continue showing the existing RNode host-only warnings only for `RNode Bridge` + +## Data model implications + +### Protocol enum + +Add: + +- `MeshProtocol::LXMF` + +Keep: + +- `MeshProtocol::RNode` + +for bridge mode until radio ownership and bridge/runtime selection are separated. + +### Contact identity mapping + +The current app-wide contact/chat model only has a 32-bit `NodeId`, while Reticulum/LXMF identities are addressed by 16-byte destination hashes. + +Phase 1 therefore introduces a stable surrogate: + +- `node_id = lower_32_bits(destination_hash)` + +This is acceptable for a first pass, but it is not collision-proof. The long-term fix is to add a protocol-native peer identifier model to contacts/chat storage. + +### Radio settings + +Phase 1 reuses the existing `rnode_config` as the carrier configuration for `LXMF`. This avoids adding a second identical LoRa profile while the runtime still treats RNode as the Reticulum-compatible carrier. + +## Phase-1 implementation details + +### Identity + +- generate Curve25519 keys with the already available Arduino `Curve25519` library +- generate Ed25519 keys with the existing shared Ed25519 implementation already used by MeshCore helpers +- persist under a dedicated preferences namespace + +### Local destination + +The local delivery destination is: + +- app name: `lxmf` +- aspect: `delivery` +- direction: inbound single destination + +### Announce behavior + +The device should: + +- send an announce shortly after startup/config apply +- re-announce periodically while active +- include `display_name` in LXMF announce app-data +- validate inbound announce signatures +- remember peer public keys and display names + +### Text send + +Phase 1 sends only opportunistic single-packet LXMF messages: + +- empty title +- text content in the LXMF content field +- empty fields map +- no stamps or tickets +- fail fast if the packed message exceeds the single-packet budget + +### Text receive + +On inbound Reticulum data packets: + +- decrypt if the destination hash matches local delivery destination +- reconstruct the full LXMF frame +- validate the LXMF signature if the source identity is known from prior announce +- queue a `MeshIncomingText` +- immediately emit a Reticulum proof packet for the received packet + +## Deferred work after phase 1 + +### Phase 2 current slice + +The current phase-2 implementation extends the phase-1 subset with: + +- outbound `rnstransport.path.request` packets for known LXMF peers +- inbound path-request handling for the local `lxmf.delivery` destination +- `PATH_RESPONSE` announce replies for the local destination +- persisted peer recall so known LXMF peers survive reboot/config reload +- generic Reticulum announce validation and path learning, not only `lxmf.delivery` +- third-party announce cache storage and cache-request replay +- immediate announce rebroadcast as a minimal propagation mechanism +- `HEADER_2` transport packet parsing/building for multi-hop forwarding +- blind forwarding of transported non-local packets based on a local path table +- reverse-path tracking for proof relay +- opaque link-request relay plus link/resource packet relay over learned link IDs + +This is still intentionally narrower than full Reticulum transport parity: + +- no dedicated local `Link` API or destination-owned link termination on device +- no local resource sender/receiver state machine equivalent to Python `RNS.Resource` +- no propagation-node store/forward policy layer +- no shared-instance parity, tunnel handling, or management destinations +- no ratchets, ticket/stamp enforcement, or deep interop test coverage yet + +### Phase 2 + +- path request / path response +- peer/path table persistence +- better peer ID model than 32-bit surrogate + +### Phase 3 + +- Reticulum links +- direct link-based LXMF delivery +- resource transfer for messages larger than the opportunistic limit + +### Phase 4 + +- propagation nodes +- ticket and stamp handling +- ratchets +- deeper parity testing against Python Reticulum/LXMF + +## Code changes planned in this round + +This round should land the following: + +- shared Reticulum packet/token helpers +- shared LXMF wire/msgpack helpers +- ESP-side LXMF identity persistence +- ESP-side `LxmfAdapter` over the current RNode raw carrier +- protocol enum/UI/config updates for `LXMF` +- compile validation on `tlora_pager_sx1262` + +## Non-goal for this round + +This round does **not** claim "complete Reticulum parity". + +It does claim something narrower and honest: + +- Trail Mate gains a real device-side Reticulum/LXMF foundation +- the implementation uses protocol-accurate wire formats +- the shipped feature set is intentionally the opportunistic direct-neighbor subset first diff --git a/modules/core_chat/include/chat/domain/chat_types.h b/modules/core_chat/include/chat/domain/chat_types.h index 934c573e..6ee1dd6a 100644 --- a/modules/core_chat/include/chat/domain/chat_types.h +++ b/modules/core_chat/include/chat/domain/chat_types.h @@ -41,7 +41,9 @@ using MessageId = uint32_t; enum class MeshProtocol : uint8_t { Meshtastic = 1, - MeshCore = 2 + MeshCore = 2, + RNode = 3, + LXMF = 4 }; /** diff --git a/modules/core_chat/include/chat/domain/contact_types.h b/modules/core_chat/include/chat/domain/contact_types.h index 7f8bb88b..e526d0ba 100644 --- a/modules/core_chat/include/chat/domain/contact_types.h +++ b/modules/core_chat/include/chat/domain/contact_types.h @@ -21,7 +21,9 @@ enum class NodeProtocolType : uint8_t { Unknown = 0, Meshtastic = 1, - MeshCore = 2 + MeshCore = 2, + RNode = 3, + LXMF = 4 }; /** diff --git a/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h b/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h new file mode 100644 index 00000000..56bc0140 --- /dev/null +++ b/modules/core_chat/include/chat/infra/lxmf/lxmf_wire.h @@ -0,0 +1,73 @@ +/** + * @file lxmf_wire.h + * @brief Shared LXMF wire helpers for direct text-message subsets + */ + +#pragma once + +#include "chat/infra/reticulum/reticulum_wire.h" + +#include +#include +#include +#include + +namespace chat::lxmf +{ + +struct DecodedMessage +{ + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t source_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t signature[reticulum::kSignatureSize] = {}; + double timestamp = 0.0; + std::string title; + std::string content; + std::vector packed_payload; + bool has_stamp = false; + std::vector stamp; + bool fields_empty = true; +}; + +bool packPeerAnnounceAppData(const char* display_name, + bool has_stamp_cost, + uint8_t stamp_cost, + uint8_t* out_data, + size_t* inout_len); + +bool unpackPeerAnnounceAppData(const uint8_t* data, size_t len, + char* out_display_name, size_t display_name_len, + bool* out_has_stamp_cost, + uint8_t* out_stamp_cost); + +bool encodeTextPayload(double timestamp, + const char* title, + const char* content, + uint8_t* out_payload, + size_t* inout_len); + +void computeMessageHash(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t source_hash[reticulum::kTruncatedHashSize], + const uint8_t* packed_payload, + size_t packed_payload_len, + uint8_t out_hash[reticulum::kFullHashSize]); + +bool buildSignedPart(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t source_hash[reticulum::kTruncatedHashSize], + const uint8_t* packed_payload, + size_t packed_payload_len, + uint8_t* out_signed_part, + size_t* inout_len, + uint8_t out_message_hash[reticulum::kFullHashSize]); + +bool packMessage(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t source_hash[reticulum::kTruncatedHashSize], + const uint8_t signature[reticulum::kSignatureSize], + const uint8_t* packed_payload, + size_t packed_payload_len, + uint8_t* out_message, + size_t* inout_len); + +bool unpackMessage(const uint8_t* data, size_t len, DecodedMessage* out_message); + +} // namespace chat::lxmf diff --git a/modules/core_chat/include/chat/infra/reticulum/reticulum_wire.h b/modules/core_chat/include/chat/infra/reticulum/reticulum_wire.h new file mode 100644 index 00000000..ef65f96e --- /dev/null +++ b/modules/core_chat/include/chat/infra/reticulum/reticulum_wire.h @@ -0,0 +1,168 @@ +/** + * @file reticulum_wire.h + * @brief Shared Reticulum packet and token helpers for device-side subsets + */ + +#pragma once + +#include +#include + +namespace chat::reticulum +{ + +constexpr size_t kFullHashSize = 32; +constexpr size_t kTruncatedHashSize = 16; +constexpr size_t kNameHashSize = 10; +constexpr size_t kEncryptionPublicKeySize = 32; +constexpr size_t kSigningPublicKeySize = 32; +constexpr size_t kCombinedPublicKeySize = kEncryptionPublicKeySize + kSigningPublicKeySize; +constexpr size_t kSignatureSize = 64; +constexpr size_t kPacketHeader1Size = 2 + kTruncatedHashSize + 1; +constexpr size_t kPacketHeader2Size = 2 + kTruncatedHashSize + kTruncatedHashSize + 1; +constexpr size_t kReticulumMtu = 500; +constexpr size_t kTokenIvSize = 16; +constexpr size_t kTokenHmacSize = 32; +constexpr size_t kTokenOverhead = kTokenIvSize + kTokenHmacSize; +constexpr size_t kDerivedTokenKeySize = 64; +constexpr size_t kReticulumMdu = kReticulumMtu - (2 + 1 + (kTruncatedHashSize * 2)); + +enum class PacketType : uint8_t +{ + Data = 0x00, + Announce = 0x01, + LinkRequest = 0x02, + Proof = 0x03 +}; + +enum class DestinationType : uint8_t +{ + Single = 0x00, + Group = 0x01, + Plain = 0x02, + Link = 0x03 +}; + +enum class TransportType : uint8_t +{ + Broadcast = 0x00, + Transport = 0x01, + Relay = 0x02, + Tunnel = 0x03 +}; + +enum class PacketContext : uint8_t +{ + None = 0x00, + Resource = 0x01, + ResourceAdv = 0x02, + ResourceReq = 0x03, + ResourceHmu = 0x04, + ResourcePrf = 0x05, + ResourceIcl = 0x06, + ResourceRcl = 0x07, + CacheRequest = 0x08, + Request = 0x09, + Response = 0x0A, + PathResponse = 0x0B, + Command = 0x0C, + CommandStatus = 0x0D, + Channel = 0x0E, + Keepalive = 0xFA, + LinkIdentify = 0xFB, + LinkClose = 0xFC, + LinkProof = 0xFD, + LrRtt = 0xFE, + LrProof = 0xFF +}; + +struct ParsedPacket +{ + bool valid = false; + uint8_t raw_flags = 0; + uint8_t header_type = 0; + uint8_t hops = 0; + PacketType packet_type = PacketType::Data; + DestinationType destination_type = DestinationType::Single; + TransportType transport_type = TransportType::Broadcast; + uint8_t context = 0; + uint8_t context_flag = 0; + const uint8_t* transport_id = nullptr; + const uint8_t* destination_hash = nullptr; + const uint8_t* payload = nullptr; + size_t payload_len = 0; + size_t header_len = 0; +}; + +struct ParsedAnnounce +{ + bool valid = false; + bool has_ratchet = false; + const uint8_t* public_key = nullptr; + const uint8_t* name_hash = nullptr; + const uint8_t* random_hash = nullptr; + const uint8_t* ratchet = nullptr; + size_t ratchet_len = 0; + const uint8_t* signature = nullptr; + const uint8_t* app_data = nullptr; + size_t app_data_len = 0; +}; + +size_t paddedTokenPlaintextSize(size_t plaintext_len); +size_t tokenSizeForPlaintext(size_t plaintext_len); + +void fullHash(const uint8_t* data, size_t len, uint8_t out_hash[kFullHashSize]); +void truncatedHash(const uint8_t* data, size_t len, uint8_t out_hash[kTruncatedHashSize]); +void computeNameHash(const char* app_name, const char* aspect, + uint8_t out_hash[kNameHashSize]); +void computeIdentityHash(const uint8_t public_key[kCombinedPublicKeySize], + uint8_t out_hash[kTruncatedHashSize]); +void computePlainDestinationHash(const uint8_t name_hash[kNameHashSize], + uint8_t out_hash[kTruncatedHashSize]); +void computeDestinationHash(const uint8_t name_hash[kNameHashSize], + const uint8_t identity_hash[kTruncatedHashSize], + uint8_t out_hash[kTruncatedHashSize]); +void computePacketHash(const uint8_t* raw_packet, size_t len, + uint8_t out_hash[kFullHashSize]); +void computeTruncatedPacketHash(const uint8_t* raw_packet, size_t len, + uint8_t out_hash[kTruncatedHashSize]); +uint32_t nodeIdFromDestinationHash(const uint8_t destination_hash[kTruncatedHashSize]); + +bool parsePacket(const uint8_t* data, size_t len, ParsedPacket* out_packet); +bool buildHeader1Packet(PacketType packet_type, + DestinationType destination_type, + PacketContext context, + bool context_flag, + const uint8_t destination_hash[kTruncatedHashSize], + const uint8_t* payload, size_t payload_len, + uint8_t* out_packet, size_t* inout_len, + uint8_t hops = 0, + TransportType transport_type = TransportType::Broadcast); +bool buildHeader2Packet(PacketType packet_type, + DestinationType destination_type, + PacketContext context, + bool context_flag, + const uint8_t transport_id[kTruncatedHashSize], + const uint8_t destination_hash[kTruncatedHashSize], + const uint8_t* payload, size_t payload_len, + uint8_t* out_packet, size_t* inout_len, + uint8_t hops = 0, + TransportType transport_type = TransportType::Transport); + +bool parseAnnounce(const ParsedPacket& packet, ParsedAnnounce* out_announce); + +bool hkdfSha256(const uint8_t* ikm, size_t ikm_len, + const uint8_t* salt, size_t salt_len, + const uint8_t* info, size_t info_len, + uint8_t* out_key, size_t out_len); + +bool tokenEncrypt(const uint8_t derived_key[kDerivedTokenKeySize], + const uint8_t iv[kTokenIvSize], + const uint8_t* plaintext, size_t plaintext_len, + uint8_t* out_token, size_t* inout_len); + +bool tokenDecrypt(const uint8_t derived_key[kDerivedTokenKeySize], + const uint8_t* token, size_t token_len, + uint8_t* out_plaintext, size_t* inout_len); + +} // namespace chat::reticulum diff --git a/modules/core_chat/include/chat/infra/rnode/rnode_packet_wire.h b/modules/core_chat/include/chat/infra/rnode/rnode_packet_wire.h new file mode 100644 index 00000000..c26a4523 --- /dev/null +++ b/modules/core_chat/include/chat/infra/rnode/rnode_packet_wire.h @@ -0,0 +1,68 @@ +/** + * @file rnode_packet_wire.h + * @brief Shared RNode over-air packet framing helpers + */ + +#pragma once + +#include +#include + +namespace chat +{ +namespace rnode +{ + +constexpr size_t kRNodeHeaderSize = 1; +constexpr size_t kRNodeSingleAirPacketSize = 255; +constexpr size_t kRNodeFragmentPayloadSize = kRNodeSingleAirPacketSize - kRNodeHeaderSize; +constexpr size_t kRNodeMaxPayloadSize = kRNodeFragmentPayloadSize * 2; +constexpr uint8_t kRNodeFlagSplit = 0x01; +constexpr uint8_t kRNodeSeqUnset = 0xFF; + +struct ParsedAirPacket +{ + uint8_t header = 0; + uint8_t sequence = 0; + bool split = false; + const uint8_t* payload = nullptr; + size_t payload_len = 0; +}; + +struct EncodedAirPacketSet +{ + uint8_t first[kRNodeSingleAirPacketSize] = {}; + size_t first_len = 0; + uint8_t second[kRNodeSingleAirPacketSize] = {}; + size_t second_len = 0; + size_t count = 0; + uint8_t header = 0; +}; + +struct ReassemblyState +{ + uint8_t sequence = kRNodeSeqUnset; + size_t buffered_len = 0; + uint8_t buffered[kRNodeMaxPayloadSize] = {}; + + void reset() + { + sequence = kRNodeSeqUnset; + buffered_len = 0; + } +}; + +bool parseAirPacket(const uint8_t* data, size_t len, ParsedAirPacket* out); +bool encodeAirPacketSet(const uint8_t* payload, size_t payload_len, + uint8_t sequence, EncodedAirPacketSet* out); +bool feedAirPacket(ReassemblyState* state, + const uint8_t* data, size_t len, + uint8_t* out_payload, size_t* inout_payload_len, + bool* out_complete = nullptr); + +uint32_t estimateBitrateBps(uint32_t bandwidth_hz, uint8_t spreading_factor, uint8_t coding_rate); +float estimateSymbolTimeMs(uint32_t bandwidth_hz, uint8_t spreading_factor); +uint16_t recommendPreambleSymbols(uint32_t bandwidth_hz, uint8_t spreading_factor, uint8_t coding_rate); + +} // namespace rnode +} // namespace chat diff --git a/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp new file mode 100644 index 00000000..2e9a74fa --- /dev/null +++ b/modules/core_chat/src/infra/lxmf/lxmf_wire.cpp @@ -0,0 +1,671 @@ +/** + * @file lxmf_wire.cpp + * @brief Shared LXMF wire helpers for direct text-message subsets + */ + +#include "chat/infra/lxmf/lxmf_wire.h" + +#include +#include + +namespace chat::lxmf +{ +namespace +{ +struct Cursor +{ + const uint8_t* data = nullptr; + size_t len = 0; + size_t pos = 0; +}; + +bool appendByte(uint8_t value, uint8_t* out, size_t out_len, size_t& used) +{ + if (!out || used >= out_len) + { + return false; + } + out[used++] = value; + return true; +} + +bool appendBytes(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) + { + return false; + } + if (len != 0) + { + memcpy(out + used, data, len); + } + used += len; + return true; +} + +bool appendArrayHeader(uint8_t count, uint8_t* out, size_t out_len, size_t& used) +{ + return appendByte(static_cast(0x90U | (count & 0x0FU)), out, out_len, used); +} + +bool appendMapHeader(uint8_t count, uint8_t* out, size_t out_len, size_t& used) +{ + return appendByte(static_cast(0x80U | (count & 0x0FU)), out, out_len, used); +} + +bool appendNil(uint8_t* out, size_t out_len, size_t& used) +{ + return appendByte(0xC0, out, out_len, used); +} + +bool appendUint(uint32_t value, uint8_t* out, size_t out_len, size_t& used) +{ + if (value <= 0x7FU) + { + return appendByte(static_cast(value), out, out_len, used); + } + if (value <= 0xFFU) + { + return appendByte(0xCC, out, out_len, used) && + appendByte(static_cast(value), out, out_len, used); + } + return false; +} + +bool appendFloat64(double value, uint8_t* out, size_t out_len, size_t& used) +{ + union + { + double d; + uint8_t b[8]; + } bits{}; + bits.d = value; + + if (!appendByte(0xCB, out, out_len, used)) + { + return false; + } + for (int i = 7; i >= 0; --i) + { + if (!appendByte(bits.b[i], out, out_len, used)) + { + return false; + } + } + return true; +} + +bool appendBin(const uint8_t* data, size_t len, uint8_t* out, size_t out_len, size_t& used) +{ + if (len <= 0xFFU) + { + return appendByte(0xC4, out, out_len, used) && + appendByte(static_cast(len), out, out_len, used) && + appendBytes(data, len, out, out_len, used); + } + if (len <= 0xFFFFU) + { + return appendByte(0xC5, out, out_len, used) && + appendByte(static_cast((len >> 8) & 0xFFU), out, out_len, used) && + appendByte(static_cast(len & 0xFFU), out, out_len, used) && + appendBytes(data, len, out, out_len, used); + } + return false; +} + +bool readByte(Cursor& cursor, uint8_t* out) +{ + if (!out || !cursor.data || cursor.pos >= cursor.len) + { + return false; + } + *out = cursor.data[cursor.pos++]; + return true; +} + +bool peekByte(const Cursor& cursor, uint8_t* out) +{ + if (!out || !cursor.data || cursor.pos >= cursor.len) + { + return false; + } + *out = cursor.data[cursor.pos]; + return true; +} + +bool readArrayHeader(Cursor& cursor, size_t* out_count) +{ + uint8_t tag = 0; + if (!readByte(cursor, &tag) || (tag & 0xF0U) != 0x90U || !out_count) + { + return false; + } + *out_count = static_cast(tag & 0x0FU); + return true; +} + +bool readMapHeader(Cursor& cursor, size_t* out_count) +{ + uint8_t tag = 0; + if (!readByte(cursor, &tag) || (tag & 0xF0U) != 0x80U || !out_count) + { + return false; + } + *out_count = static_cast(tag & 0x0FU); + return true; +} + +bool readUint(Cursor& cursor, uint32_t* out_value) +{ + uint8_t tag = 0; + if (!readByte(cursor, &tag) || !out_value) + { + return false; + } + if (tag <= 0x7F) + { + *out_value = tag; + return true; + } + if (tag == 0xCC) + { + uint8_t value = 0; + if (!readByte(cursor, &value)) + { + return false; + } + *out_value = value; + return true; + } + return false; +} + +bool readFloat64(Cursor& cursor, double* out_value) +{ + uint8_t tag = 0; + if (!readByte(cursor, &tag) || tag != 0xCB || !out_value || cursor.pos + 8 > cursor.len) + { + return false; + } + + union + { + double d; + uint8_t b[8]; + } bits{}; + for (int i = 7; i >= 0; --i) + { + bits.b[i] = cursor.data[cursor.pos++]; + } + *out_value = bits.d; + return true; +} + +bool readNil(Cursor& cursor) +{ + uint8_t tag = 0; + return readByte(cursor, &tag) && tag == 0xC0; +} + +bool readBinary(Cursor& cursor, std::vector* out_data) +{ + if (!out_data) + { + return false; + } + + uint8_t tag = 0; + if (!readByte(cursor, &tag)) + { + return false; + } + + size_t len = 0; + if (tag == 0xC4) + { + uint8_t len8 = 0; + if (!readByte(cursor, &len8)) + { + return false; + } + len = len8; + } + else if (tag == 0xC5) + { + uint8_t hi = 0; + uint8_t lo = 0; + if (!readByte(cursor, &hi) || !readByte(cursor, &lo)) + { + return false; + } + len = static_cast((static_cast(hi) << 8) | lo); + } + else if ((tag & 0xE0U) == 0xA0U) + { + len = static_cast(tag & 0x1FU); + } + else if (tag == 0xD9) + { + uint8_t len8 = 0; + if (!readByte(cursor, &len8)) + { + return false; + } + len = len8; + } + else + { + return false; + } + + if (cursor.pos + len > cursor.len) + { + return false; + } + + out_data->assign(cursor.data + cursor.pos, cursor.data + cursor.pos + len); + cursor.pos += len; + return true; +} + +bool skipObject(Cursor& cursor) +{ + uint8_t tag = 0; + if (!peekByte(cursor, &tag)) + { + return false; + } + + if (tag == 0xC0) + { + return readNil(cursor); + } + if (tag == 0xCB) + { + double ignored = 0.0; + return readFloat64(cursor, &ignored); + } + if (tag == 0xCC || tag <= 0x7F) + { + uint32_t ignored = 0; + return readUint(cursor, &ignored); + } + if ((tag & 0xF0U) == 0x80U) + { + size_t count = 0; + if (!readMapHeader(cursor, &count)) + { + return false; + } + for (size_t i = 0; i < count; ++i) + { + if (!skipObject(cursor) || !skipObject(cursor)) + { + return false; + } + } + return true; + } + if ((tag & 0xF0U) == 0x90U) + { + size_t count = 0; + if (!readArrayHeader(cursor, &count)) + { + return false; + } + for (size_t i = 0; i < count; ++i) + { + if (!skipObject(cursor)) + { + return false; + } + } + return true; + } + + std::vector ignored; + return readBinary(cursor, &ignored); +} + +} // namespace + +bool packPeerAnnounceAppData(const char* display_name, + bool has_stamp_cost, + uint8_t stamp_cost, + uint8_t* out_data, + size_t* inout_len) +{ + if (!out_data || !inout_len) + { + return false; + } + + size_t used = 0; + const uint8_t* name_bytes = reinterpret_cast(display_name ? display_name : ""); + const size_t name_len = (display_name != nullptr) ? strlen(display_name) : 0; + + if (!appendArrayHeader(2, out_data, *inout_len, used)) + { + return false; + } + if (name_len == 0) + { + if (!appendNil(out_data, *inout_len, used)) + { + return false; + } + } + else if (!appendBin(name_bytes, name_len, out_data, *inout_len, used)) + { + return false; + } + + if (has_stamp_cost) + { + if (!appendUint(stamp_cost, out_data, *inout_len, used)) + { + return false; + } + } + else if (!appendNil(out_data, *inout_len, used)) + { + return false; + } + + *inout_len = used; + return true; +} + +bool unpackPeerAnnounceAppData(const uint8_t* data, size_t len, + char* out_display_name, size_t display_name_len, + bool* out_has_stamp_cost, + uint8_t* out_stamp_cost) +{ + if (!data || len == 0 || !out_display_name || display_name_len == 0) + { + return false; + } + + out_display_name[0] = '\0'; + if (out_has_stamp_cost) + { + *out_has_stamp_cost = false; + } + if (out_stamp_cost) + { + *out_stamp_cost = 0; + } + + Cursor cursor; + cursor.data = data; + cursor.len = len; + cursor.pos = 0; + size_t count = 0; + if (!readArrayHeader(cursor, &count) || count != 2) + { + return false; + } + + uint8_t next = 0; + if (!peekByte(cursor, &next)) + { + return false; + } + if (next == 0xC0) + { + if (!readNil(cursor)) + { + return false; + } + } + else + { + std::vector name; + if (!readBinary(cursor, &name)) + { + return false; + } + const size_t copy_len = std::min(name.size(), display_name_len - 1); + memcpy(out_display_name, name.data(), copy_len); + out_display_name[copy_len] = '\0'; + } + + if (!peekByte(cursor, &next)) + { + return false; + } + if (next == 0xC0) + { + return readNil(cursor); + } + + uint32_t stamp = 0; + if (!readUint(cursor, &stamp)) + { + return false; + } + if (out_has_stamp_cost) + { + *out_has_stamp_cost = true; + } + if (out_stamp_cost) + { + *out_stamp_cost = static_cast(stamp); + } + return true; +} + +bool encodeTextPayload(double timestamp, + const char* title, + const char* content, + uint8_t* out_payload, + size_t* inout_len) +{ + if (!out_payload || !inout_len) + { + return false; + } + + const uint8_t* title_bytes = reinterpret_cast(title ? title : ""); + const size_t title_len = (title != nullptr) ? strlen(title) : 0; + const uint8_t* content_bytes = reinterpret_cast(content ? content : ""); + const size_t content_len = (content != nullptr) ? strlen(content) : 0; + + size_t used = 0; + if (!appendArrayHeader(4, out_payload, *inout_len, used) || + !appendFloat64(timestamp, out_payload, *inout_len, used) || + !appendBin(title_bytes, title_len, out_payload, *inout_len, used) || + !appendBin(content_bytes, content_len, out_payload, *inout_len, used) || + !appendMapHeader(0, out_payload, *inout_len, used)) + { + return false; + } + + *inout_len = used; + return true; +} + +void computeMessageHash(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t source_hash[reticulum::kTruncatedHashSize], + const uint8_t* packed_payload, + size_t packed_payload_len, + uint8_t out_hash[reticulum::kFullHashSize]) +{ + uint8_t material[reticulum::kReticulumMtu] = {}; + size_t used = 0; + memcpy(material + used, destination_hash, reticulum::kTruncatedHashSize); + used += reticulum::kTruncatedHashSize; + memcpy(material + used, source_hash, reticulum::kTruncatedHashSize); + used += reticulum::kTruncatedHashSize; + if (packed_payload && packed_payload_len != 0) + { + memcpy(material + used, packed_payload, packed_payload_len); + used += packed_payload_len; + } + reticulum::fullHash(material, used, out_hash); +} + +bool buildSignedPart(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t source_hash[reticulum::kTruncatedHashSize], + const uint8_t* packed_payload, + size_t packed_payload_len, + uint8_t* out_signed_part, + size_t* inout_len, + uint8_t out_message_hash[reticulum::kFullHashSize]) +{ + if (!destination_hash || !source_hash || !out_signed_part || !inout_len || !out_message_hash) + { + return false; + } + + computeMessageHash(destination_hash, source_hash, packed_payload, packed_payload_len, out_message_hash); + + const size_t total_len = (reticulum::kTruncatedHashSize * 2) + + packed_payload_len + + reticulum::kFullHashSize; + if (*inout_len < total_len) + { + *inout_len = total_len; + return false; + } + + size_t used = 0; + memcpy(out_signed_part + used, destination_hash, reticulum::kTruncatedHashSize); + used += reticulum::kTruncatedHashSize; + memcpy(out_signed_part + used, source_hash, reticulum::kTruncatedHashSize); + used += reticulum::kTruncatedHashSize; + if (packed_payload && packed_payload_len != 0) + { + memcpy(out_signed_part + used, packed_payload, packed_payload_len); + used += packed_payload_len; + } + memcpy(out_signed_part + used, out_message_hash, reticulum::kFullHashSize); + used += reticulum::kFullHashSize; + + *inout_len = used; + return true; +} + +bool packMessage(const uint8_t destination_hash[reticulum::kTruncatedHashSize], + const uint8_t source_hash[reticulum::kTruncatedHashSize], + const uint8_t signature[reticulum::kSignatureSize], + const uint8_t* packed_payload, + size_t packed_payload_len, + uint8_t* out_message, + size_t* inout_len) +{ + if (!destination_hash || !source_hash || !signature || !out_message || !inout_len) + { + return false; + } + + const size_t total_len = (reticulum::kTruncatedHashSize * 2) + + reticulum::kSignatureSize + + packed_payload_len; + if (*inout_len < total_len) + { + *inout_len = total_len; + return false; + } + + size_t used = 0; + memcpy(out_message + used, destination_hash, reticulum::kTruncatedHashSize); + used += reticulum::kTruncatedHashSize; + memcpy(out_message + used, source_hash, reticulum::kTruncatedHashSize); + used += reticulum::kTruncatedHashSize; + memcpy(out_message + used, signature, reticulum::kSignatureSize); + used += reticulum::kSignatureSize; + if (packed_payload && packed_payload_len != 0) + { + memcpy(out_message + used, packed_payload, packed_payload_len); + used += packed_payload_len; + } + *inout_len = used; + return true; +} + +bool unpackMessage(const uint8_t* data, size_t len, DecodedMessage* out_message) +{ + if (!data || len < ((reticulum::kTruncatedHashSize * 2) + reticulum::kSignatureSize + 4) || !out_message) + { + return false; + } + + DecodedMessage decoded{}; + memcpy(decoded.destination_hash, data, reticulum::kTruncatedHashSize); + memcpy(decoded.source_hash, data + reticulum::kTruncatedHashSize, reticulum::kTruncatedHashSize); + memcpy(decoded.signature, + data + (reticulum::kTruncatedHashSize * 2), + reticulum::kSignatureSize); + + const uint8_t* payload_ptr = data + (reticulum::kTruncatedHashSize * 2) + reticulum::kSignatureSize; + const size_t payload_len = len - ((reticulum::kTruncatedHashSize * 2) + reticulum::kSignatureSize); + Cursor cursor; + cursor.data = payload_ptr; + cursor.len = payload_len; + cursor.pos = 0; + + size_t element_count = 0; + if (!readArrayHeader(cursor, &element_count) || element_count < 4 || element_count > 5) + { + return false; + } + + if (!readFloat64(cursor, &decoded.timestamp)) + { + return false; + } + + std::vector title_bytes; + std::vector content_bytes; + if (!readBinary(cursor, &title_bytes) || !readBinary(cursor, &content_bytes)) + { + return false; + } + decoded.title.assign(title_bytes.begin(), title_bytes.end()); + decoded.content.assign(content_bytes.begin(), content_bytes.end()); + + size_t map_count = 0; + if (!readMapHeader(cursor, &map_count)) + { + return false; + } + decoded.fields_empty = (map_count == 0); + for (size_t i = 0; i < map_count; ++i) + { + if (!skipObject(cursor) || !skipObject(cursor)) + { + return false; + } + } + + if (element_count == 5) + { + uint8_t next = 0; + if (!peekByte(cursor, &next)) + { + return false; + } + if (next == 0xC0) + { + if (!readNil(cursor)) + { + return false; + } + } + else + { + decoded.has_stamp = true; + if (!readBinary(cursor, &decoded.stamp)) + { + return false; + } + } + } + + decoded.packed_payload.assign(payload_ptr, payload_ptr + cursor.pos); + *out_message = std::move(decoded); + return true; +} + +} // namespace chat::lxmf diff --git a/modules/core_chat/src/infra/mesh_protocol_utils.cpp b/modules/core_chat/src/infra/mesh_protocol_utils.cpp index 60c08cd9..9b33e86a 100644 --- a/modules/core_chat/src/infra/mesh_protocol_utils.cpp +++ b/modules/core_chat/src/infra/mesh_protocol_utils.cpp @@ -14,6 +14,8 @@ bool isValidMeshProtocol(MeshProtocol protocol) { case MeshProtocol::Meshtastic: case MeshProtocol::MeshCore: + case MeshProtocol::RNode: + case MeshProtocol::LXMF: return true; default: return false; @@ -37,6 +39,10 @@ const char* meshProtocolName(MeshProtocol protocol) { case MeshProtocol::MeshCore: return "MeshCore"; + case MeshProtocol::RNode: + return "RNode"; + case MeshProtocol::LXMF: + return "LXMF"; case MeshProtocol::Meshtastic: default: return "Meshtastic"; @@ -49,6 +55,10 @@ const char* meshProtocolShortName(MeshProtocol protocol) { case MeshProtocol::MeshCore: return "MC"; + case MeshProtocol::RNode: + return "RN"; + case MeshProtocol::LXMF: + return "LX"; case MeshProtocol::Meshtastic: default: return "MT"; @@ -61,6 +71,10 @@ const char* meshProtocolSlug(MeshProtocol protocol) { case MeshProtocol::MeshCore: return "meshcore"; + case MeshProtocol::RNode: + return "rnode"; + case MeshProtocol::LXMF: + return "lxmf"; case MeshProtocol::Meshtastic: default: return "meshtastic"; diff --git a/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp b/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp new file mode 100644 index 00000000..517ede02 --- /dev/null +++ b/modules/core_chat/src/infra/reticulum/reticulum_wire.cpp @@ -0,0 +1,657 @@ +/** + * @file reticulum_wire.cpp + * @brief Shared Reticulum packet and token helpers for device-side subsets + */ + +#include "chat/infra/reticulum/reticulum_wire.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace chat::reticulum +{ +namespace +{ +constexpr size_t kAesBlockSize = 16; +constexpr size_t kHeader1Size = 2 + kTruncatedHashSize + 1; +constexpr size_t kHeader2Size = 2 + kTruncatedHashSize + kTruncatedHashSize + 1; +constexpr uint8_t kHeaderType1 = 0x00; +constexpr uint8_t kHeaderType2 = 0x01; + +class Aes256CbcCipher +{ + public: + void setKey(const uint8_t* key, size_t len) + { + valid_ = (key != nullptr && len == 32); + if (valid_) + { + aes_.setKey(key, len); + } + } + + bool valid() const + { + return valid_; + } + + void encryptBlock(uint8_t* out, const uint8_t* in) + { + if (!out || !in) + { + return; + } + aes_.encryptBlock(out, in); + } + + void decryptBlock(uint8_t* out, const uint8_t* in) + { + if (!out || !in) + { + return; + } + aes_.decryptBlock(out, in); + } + + private: + AESSmall256 aes_; + bool valid_ = false; +}; + +void hmacSha256(const uint8_t* key, size_t key_len, + const uint8_t* data, size_t data_len, + uint8_t out_hash[kFullHashSize]) +{ + if (!out_hash) + { + return; + } + + SHA256 sha; + sha.resetHMAC(key, key_len); + if (data && data_len != 0) + { + sha.update(data, data_len); + } + sha.finalizeHMAC(key, key_len, out_hash, kFullHashSize); +} + +void xorBlock(uint8_t* dst, const uint8_t* src) +{ + if (!dst || !src) + { + return; + } + for (size_t i = 0; i < kAesBlockSize; ++i) + { + dst[i] ^= src[i]; + } +} + +bool constantTimeEquals(const uint8_t* a, const uint8_t* b, size_t len) +{ + if ((!a || !b) && len != 0) + { + return false; + } + uint8_t diff = 0; + for (size_t i = 0; i < len; ++i) + { + diff |= static_cast(a[i] ^ b[i]); + } + return diff == 0; +} + +size_t pkcs7Pad(const uint8_t* input, size_t input_len, + uint8_t* out, size_t out_len) +{ + const size_t pad_len = kAesBlockSize - (input_len % kAesBlockSize); + const size_t total_len = input_len + ((pad_len == 0) ? kAesBlockSize : pad_len); + if (!out || out_len < total_len) + { + return 0; + } + + if (input && input_len != 0) + { + memcpy(out, input, input_len); + } + const uint8_t applied = static_cast((pad_len == 0) ? kAesBlockSize : pad_len); + for (size_t i = input_len; i < total_len; ++i) + { + out[i] = applied; + } + return total_len; +} + +bool pkcs7Unpad(const uint8_t* input, size_t input_len, + uint8_t* out, size_t* inout_len) +{ + if (!input || input_len == 0 || !out || !inout_len) + { + return false; + } + + const uint8_t pad_len = input[input_len - 1]; + if (pad_len == 0 || pad_len > kAesBlockSize || pad_len > input_len) + { + return false; + } + + for (size_t i = 0; i < pad_len; ++i) + { + if (input[input_len - 1 - i] != pad_len) + { + return false; + } + } + + const size_t plain_len = input_len - pad_len; + if (*inout_len < plain_len) + { + *inout_len = plain_len; + return false; + } + + if (plain_len != 0) + { + memcpy(out, input, plain_len); + } + *inout_len = plain_len; + return true; +} + +void aesCbcEncrypt(const uint8_t* key, size_t key_len, + const uint8_t iv[kTokenIvSize], + const uint8_t* plaintext, size_t plaintext_len, + uint8_t* out_ciphertext) +{ + Aes256CbcCipher cipher; + cipher.setKey(key, key_len); + if (!cipher.valid() || !iv || !out_ciphertext) + { + return; + } + + uint8_t previous[kAesBlockSize] = {}; + memcpy(previous, iv, sizeof(previous)); + + for (size_t offset = 0; offset < plaintext_len; offset += kAesBlockSize) + { + uint8_t block[kAesBlockSize] = {}; + memcpy(block, plaintext + offset, kAesBlockSize); + xorBlock(block, previous); + cipher.encryptBlock(out_ciphertext + offset, block); + memcpy(previous, out_ciphertext + offset, kAesBlockSize); + } +} + +void aesCbcDecrypt(const uint8_t* key, size_t key_len, + const uint8_t iv[kTokenIvSize], + const uint8_t* ciphertext, size_t ciphertext_len, + uint8_t* out_plaintext) +{ + Aes256CbcCipher cipher; + cipher.setKey(key, key_len); + if (!cipher.valid() || !iv || !out_plaintext) + { + return; + } + + uint8_t previous[kAesBlockSize] = {}; + memcpy(previous, iv, sizeof(previous)); + + for (size_t offset = 0; offset < ciphertext_len; offset += kAesBlockSize) + { + uint8_t block[kAesBlockSize] = {}; + cipher.decryptBlock(block, ciphertext + offset); + xorBlock(block, previous); + memcpy(out_plaintext + offset, block, kAesBlockSize); + memcpy(previous, ciphertext + offset, kAesBlockSize); + } +} + +void appendAscii(char* out, size_t out_len, size_t& index, const char* text) +{ + if (!out || out_len == 0 || !text) + { + return; + } + while (*text != '\0' && index + 1 < out_len) + { + out[index++] = *text++; + } + out[index] = '\0'; +} + +} // namespace + +size_t paddedTokenPlaintextSize(size_t plaintext_len) +{ + const size_t remainder = plaintext_len % kAesBlockSize; + return plaintext_len + ((remainder == 0) ? kAesBlockSize : (kAesBlockSize - remainder)); +} + +size_t tokenSizeForPlaintext(size_t plaintext_len) +{ + return kTokenOverhead + paddedTokenPlaintextSize(plaintext_len); +} + +void fullHash(const uint8_t* data, size_t len, uint8_t out_hash[kFullHashSize]) +{ + if (!out_hash) + { + return; + } + + SHA256 sha; + if (data && len != 0) + { + sha.update(data, len); + } + sha.finalize(out_hash, kFullHashSize); +} + +void truncatedHash(const uint8_t* data, size_t len, uint8_t out_hash[kTruncatedHashSize]) +{ + uint8_t hash[kFullHashSize] = {}; + fullHash(data, len, hash); + memcpy(out_hash, hash, kTruncatedHashSize); +} + +void computeNameHash(const char* app_name, const char* aspect, + uint8_t out_hash[kNameHashSize]) +{ + char expanded[64] = {}; + size_t index = 0; + appendAscii(expanded, sizeof(expanded), index, app_name ? app_name : ""); + if (aspect && aspect[0] != '\0' && index + 1 < sizeof(expanded)) + { + expanded[index++] = '.'; + expanded[index] = '\0'; + appendAscii(expanded, sizeof(expanded), index, aspect); + } + + uint8_t full[kFullHashSize] = {}; + fullHash(reinterpret_cast(expanded), strlen(expanded), full); + memcpy(out_hash, full, kNameHashSize); +} + +void computeIdentityHash(const uint8_t public_key[kCombinedPublicKeySize], + uint8_t out_hash[kTruncatedHashSize]) +{ + truncatedHash(public_key, kCombinedPublicKeySize, out_hash); +} + +void computePlainDestinationHash(const uint8_t name_hash[kNameHashSize], + uint8_t out_hash[kTruncatedHashSize]) +{ + truncatedHash(name_hash, kNameHashSize, out_hash); +} + +void computeDestinationHash(const uint8_t name_hash[kNameHashSize], + const uint8_t identity_hash[kTruncatedHashSize], + uint8_t out_hash[kTruncatedHashSize]) +{ + uint8_t material[kNameHashSize + kTruncatedHashSize] = {}; + memcpy(material, name_hash, kNameHashSize); + memcpy(material + kNameHashSize, identity_hash, kTruncatedHashSize); + truncatedHash(material, sizeof(material), out_hash); +} + +void computePacketHash(const uint8_t* raw_packet, size_t len, + uint8_t out_hash[kFullHashSize]) +{ + if (!raw_packet || len < kHeader1Size || !out_hash) + { + if (out_hash) + { + memset(out_hash, 0, kFullHashSize); + } + return; + } + + uint8_t hashable[kReticulumMtu] = {}; + size_t hashable_len = 0; + hashable[hashable_len++] = static_cast(raw_packet[0] & 0x0FU); + + const uint8_t header_type = static_cast((raw_packet[0] >> 6) & 0x01U); + if (header_type == kHeaderType2) + { + if (len < kHeader2Size) + { + memset(out_hash, 0, kFullHashSize); + return; + } + + memcpy(hashable + hashable_len, + raw_packet + 2 + kTruncatedHashSize, + len - (2 + kTruncatedHashSize)); + hashable_len += (len - (2 + kTruncatedHashSize)); + } + else + { + memcpy(hashable + hashable_len, raw_packet + 2, len - 2); + hashable_len += (len - 2); + } + + fullHash(hashable, hashable_len, out_hash); +} + +void computeTruncatedPacketHash(const uint8_t* raw_packet, size_t len, + uint8_t out_hash[kTruncatedHashSize]) +{ + uint8_t full[kFullHashSize] = {}; + computePacketHash(raw_packet, len, full); + memcpy(out_hash, full, kTruncatedHashSize); +} + +uint32_t nodeIdFromDestinationHash(const uint8_t destination_hash[kTruncatedHashSize]) +{ + if (!destination_hash) + { + return 0; + } + return (static_cast(destination_hash[12]) << 24) | + (static_cast(destination_hash[13]) << 16) | + (static_cast(destination_hash[14]) << 8) | + static_cast(destination_hash[15]); +} + +bool parsePacket(const uint8_t* data, size_t len, ParsedPacket* out_packet) +{ + if (!data || len < kHeader1Size || !out_packet) + { + return false; + } + + ParsedPacket parsed{}; + parsed.raw_flags = data[0]; + parsed.hops = data[1]; + + parsed.header_type = static_cast((parsed.raw_flags >> 6) & 0x01U); + if (parsed.header_type != kHeaderType1 && parsed.header_type != kHeaderType2) + { + return false; + } + + parsed.context_flag = static_cast((parsed.raw_flags >> 5) & 0x01U); + parsed.transport_type = static_cast((parsed.raw_flags >> 4) & 0x01U); + parsed.destination_type = static_cast((parsed.raw_flags >> 2) & 0x03U); + parsed.packet_type = static_cast(parsed.raw_flags & 0x03U); + + if (parsed.header_type == kHeaderType2) + { + if (len < kHeader2Size) + { + return false; + } + + parsed.transport_id = data + 2; + parsed.destination_hash = data + 2 + kTruncatedHashSize; + parsed.context = data[2 + (kTruncatedHashSize * 2)]; + parsed.payload = data + kHeader2Size; + parsed.payload_len = len - kHeader2Size; + parsed.header_len = kHeader2Size; + } + else + { + parsed.transport_id = nullptr; + parsed.destination_hash = data + 2; + parsed.context = data[2 + kTruncatedHashSize]; + parsed.payload = data + kHeader1Size; + parsed.payload_len = len - kHeader1Size; + parsed.header_len = kHeader1Size; + } + + parsed.valid = true; + + *out_packet = parsed; + return true; +} + +bool buildHeader1Packet(PacketType packet_type, + DestinationType destination_type, + PacketContext context, + bool context_flag, + const uint8_t destination_hash[kTruncatedHashSize], + const uint8_t* payload, size_t payload_len, + uint8_t* out_packet, size_t* inout_len, + uint8_t hops, + TransportType transport_type) +{ + if (!destination_hash || !out_packet || !inout_len) + { + return false; + } + + const size_t total_len = kHeader1Size + payload_len; + if (*inout_len < total_len || total_len > kReticulumMtu) + { + *inout_len = total_len; + return false; + } + + const uint8_t flags = + static_cast((0U << 6) | + ((context_flag ? 1U : 0U) << 5) | + ((static_cast(transport_type) & 0x01U) << 4) | + ((static_cast(destination_type) & 0x03U) << 2) | + (static_cast(packet_type) & 0x03U)); + + out_packet[0] = flags; + out_packet[1] = hops; + memcpy(out_packet + 2, destination_hash, kTruncatedHashSize); + out_packet[2 + kTruncatedHashSize] = static_cast(context); + if (payload && payload_len != 0) + { + memcpy(out_packet + kHeader1Size, payload, payload_len); + } + *inout_len = total_len; + return true; +} + +bool buildHeader2Packet(PacketType packet_type, + DestinationType destination_type, + PacketContext context, + bool context_flag, + const uint8_t transport_id[kTruncatedHashSize], + const uint8_t destination_hash[kTruncatedHashSize], + const uint8_t* payload, size_t payload_len, + uint8_t* out_packet, size_t* inout_len, + uint8_t hops, + TransportType transport_type) +{ + if (!transport_id || !destination_hash || !out_packet || !inout_len) + { + return false; + } + + const size_t total_len = kHeader2Size + payload_len; + if (*inout_len < total_len || total_len > kReticulumMtu) + { + *inout_len = total_len; + return false; + } + + const uint8_t flags = + static_cast((1U << 6) | + ((context_flag ? 1U : 0U) << 5) | + ((static_cast(transport_type) & 0x01U) << 4) | + ((static_cast(destination_type) & 0x03U) << 2) | + (static_cast(packet_type) & 0x03U)); + + out_packet[0] = flags; + out_packet[1] = hops; + memcpy(out_packet + 2, transport_id, kTruncatedHashSize); + memcpy(out_packet + 2 + kTruncatedHashSize, destination_hash, kTruncatedHashSize); + out_packet[2 + (kTruncatedHashSize * 2)] = static_cast(context); + if (payload && payload_len != 0) + { + memcpy(out_packet + kHeader2Size, payload, payload_len); + } + *inout_len = total_len; + return true; +} + +bool parseAnnounce(const ParsedPacket& packet, ParsedAnnounce* out_announce) +{ + if (!packet.valid || !out_announce || + packet.packet_type != PacketType::Announce || + packet.payload == nullptr || + packet.payload_len < (kCombinedPublicKeySize + kNameHashSize + 10 + kSignatureSize)) + { + return false; + } + + ParsedAnnounce parsed{}; + parsed.valid = true; + parsed.has_ratchet = (packet.context_flag != 0); + parsed.public_key = packet.payload; + parsed.name_hash = packet.payload + kCombinedPublicKeySize; + parsed.random_hash = parsed.name_hash + kNameHashSize; + + if (parsed.has_ratchet) + { + return false; + } + + parsed.signature = parsed.random_hash + 10; + parsed.app_data = parsed.signature + kSignatureSize; + parsed.app_data_len = packet.payload_len - (kCombinedPublicKeySize + kNameHashSize + 10 + kSignatureSize); + + *out_announce = parsed; + return true; +} + +bool hkdfSha256(const uint8_t* ikm, size_t ikm_len, + const uint8_t* salt, size_t salt_len, + const uint8_t* info, size_t info_len, + uint8_t* out_key, size_t out_len) +{ + if (!ikm || ikm_len == 0 || !out_key || out_len == 0) + { + return false; + } + + uint8_t zero_salt[kFullHashSize] = {}; + const uint8_t* actual_salt = (salt && salt_len != 0) ? salt : zero_salt; + const size_t actual_salt_len = (salt && salt_len != 0) ? salt_len : sizeof(zero_salt); + + uint8_t prk[kFullHashSize] = {}; + hmacSha256(actual_salt, actual_salt_len, ikm, ikm_len, prk); + + uint8_t previous[kFullHashSize] = {}; + size_t generated = 0; + uint8_t counter = 1; + size_t previous_len = 0; + + while (generated < out_len) + { + uint8_t block_input[kFullHashSize + 64 + 1] = {}; + size_t block_len = 0; + if (previous_len != 0) + { + memcpy(block_input + block_len, previous, previous_len); + block_len += previous_len; + } + if (info && info_len != 0) + { + memcpy(block_input + block_len, info, info_len); + block_len += info_len; + } + block_input[block_len++] = counter++; + + hmacSha256(prk, sizeof(prk), block_input, block_len, previous); + previous_len = sizeof(previous); + + const size_t remaining = out_len - generated; + const size_t chunk = std::min(remaining, sizeof(previous)); + memcpy(out_key + generated, previous, chunk); + generated += chunk; + } + + return true; +} + +bool tokenEncrypt(const uint8_t derived_key[kDerivedTokenKeySize], + const uint8_t iv[kTokenIvSize], + const uint8_t* plaintext, size_t plaintext_len, + uint8_t* out_token, size_t* inout_len) +{ + if (!derived_key || !iv || !out_token || !inout_len) + { + return false; + } + + const size_t padded_len = paddedTokenPlaintextSize(plaintext_len); + const size_t total_len = tokenSizeForPlaintext(plaintext_len); + if (*inout_len < total_len) + { + *inout_len = total_len; + return false; + } + + uint8_t padded[kReticulumMtu] = {}; + if (padded_len > sizeof(padded)) + { + return false; + } + if (pkcs7Pad(plaintext, plaintext_len, padded, sizeof(padded)) != padded_len) + { + return false; + } + + memcpy(out_token, iv, kTokenIvSize); + aesCbcEncrypt(derived_key + 32, 32, iv, padded, padded_len, out_token + kTokenIvSize); + + uint8_t mac[kFullHashSize] = {}; + hmacSha256(derived_key, 32, out_token, kTokenIvSize + padded_len, mac); + memcpy(out_token + kTokenIvSize + padded_len, mac, sizeof(mac)); + *inout_len = total_len; + return true; +} + +bool tokenDecrypt(const uint8_t derived_key[kDerivedTokenKeySize], + const uint8_t* token, size_t token_len, + uint8_t* out_plaintext, size_t* inout_len) +{ + if (!derived_key || !token || token_len <= kTokenOverhead || !out_plaintext || !inout_len) + { + return false; + } + + const size_t cipher_len = token_len - kTokenOverhead; + if ((cipher_len % kAesBlockSize) != 0) + { + return false; + } + + const uint8_t* iv = token; + const uint8_t* ciphertext = token + kTokenIvSize; + const uint8_t* received_hmac = token + kTokenIvSize + cipher_len; + + uint8_t expected_hmac[kFullHashSize] = {}; + hmacSha256(derived_key, 32, token, kTokenIvSize + cipher_len, expected_hmac); + if (!constantTimeEquals(received_hmac, expected_hmac, sizeof(expected_hmac))) + { + return false; + } + + uint8_t padded[kReticulumMtu] = {}; + if (cipher_len > sizeof(padded)) + { + return false; + } + + aesCbcDecrypt(derived_key + 32, 32, iv, ciphertext, cipher_len, padded); + return pkcs7Unpad(padded, cipher_len, out_plaintext, inout_len); +} + +} // namespace chat::reticulum diff --git a/modules/core_chat/src/infra/rnode/rnode_packet_wire.cpp b/modules/core_chat/src/infra/rnode/rnode_packet_wire.cpp new file mode 100644 index 00000000..c242a328 --- /dev/null +++ b/modules/core_chat/src/infra/rnode/rnode_packet_wire.cpp @@ -0,0 +1,236 @@ +/** + * @file rnode_packet_wire.cpp + * @brief Shared RNode over-air packet framing helpers + */ + +#include "chat/infra/rnode/rnode_packet_wire.h" + +#include +#include +#include + +namespace chat +{ +namespace rnode +{ + +namespace +{ +constexpr float kPreambleSymbolsMin = 18.0f; +constexpr float kPreambleTargetMs = 24.0f; +constexpr float kPreambleFastDeltaMs = 18.0f; +constexpr uint32_t kFastThresholdBps = 30000U; + +template +T clampValue(T value, T min_value, T max_value) +{ + if (value < min_value) + { + return min_value; + } + if (value > max_value) + { + return max_value; + } + return value; +} + +} // namespace + +bool parseAirPacket(const uint8_t* data, size_t len, ParsedAirPacket* out) +{ + if (!data || len <= kRNodeHeaderSize || !out) + { + return false; + } + + out->header = data[0]; + out->sequence = static_cast(data[0] >> 4); + out->split = (data[0] & kRNodeFlagSplit) != 0; + out->payload = data + kRNodeHeaderSize; + out->payload_len = len - kRNodeHeaderSize; + return out->payload_len > 0; +} + +bool encodeAirPacketSet(const uint8_t* payload, size_t payload_len, + uint8_t sequence, EncodedAirPacketSet* out) +{ + if (!payload || payload_len == 0 || payload_len > kRNodeMaxPayloadSize || !out) + { + return false; + } + + const uint8_t base_header = static_cast((sequence & 0x0FU) << 4); + const bool split = payload_len > kRNodeFragmentPayloadSize; + out->header = static_cast(base_header | (split ? kRNodeFlagSplit : 0U)); + out->count = split ? 2U : 1U; + + out->first[0] = out->header; + const size_t first_payload_len = + split ? kRNodeFragmentPayloadSize : payload_len; + memcpy(out->first + kRNodeHeaderSize, payload, first_payload_len); + out->first_len = kRNodeHeaderSize + first_payload_len; + + if (split) + { + const size_t second_payload_len = payload_len - first_payload_len; + out->second[0] = out->header; + memcpy(out->second + kRNodeHeaderSize, payload + first_payload_len, second_payload_len); + out->second_len = kRNodeHeaderSize + second_payload_len; + } + else + { + out->second_len = 0; + } + + return true; +} + +bool feedAirPacket(ReassemblyState* state, + const uint8_t* data, size_t len, + uint8_t* out_payload, size_t* inout_payload_len, + bool* out_complete) +{ + if (out_complete) + { + *out_complete = false; + } + + if (!state || !data || !out_payload || !inout_payload_len) + { + return false; + } + + ParsedAirPacket parsed{}; + if (!parseAirPacket(data, len, &parsed)) + { + state->reset(); + return false; + } + + auto emit_payload = [&](const uint8_t* src, size_t src_len) -> bool + { + if (*inout_payload_len < src_len) + { + *inout_payload_len = src_len; + return false; + } + memcpy(out_payload, src, src_len); + *inout_payload_len = src_len; + if (out_complete) + { + *out_complete = true; + } + return true; + }; + + if (!parsed.split) + { + state->reset(); + return emit_payload(parsed.payload, parsed.payload_len); + } + + if (state->sequence == kRNodeSeqUnset) + { + if (parsed.payload_len > sizeof(state->buffered)) + { + state->reset(); + return false; + } + memcpy(state->buffered, parsed.payload, parsed.payload_len); + state->buffered_len = parsed.payload_len; + state->sequence = parsed.sequence; + return true; + } + + if (state->sequence != parsed.sequence) + { + if (parsed.payload_len > sizeof(state->buffered)) + { + state->reset(); + return false; + } + memcpy(state->buffered, parsed.payload, parsed.payload_len); + state->buffered_len = parsed.payload_len; + state->sequence = parsed.sequence; + return true; + } + + if (state->buffered_len + parsed.payload_len > sizeof(state->buffered)) + { + state->reset(); + return false; + } + + memcpy(state->buffered + state->buffered_len, parsed.payload, parsed.payload_len); + state->buffered_len += parsed.payload_len; + + const size_t complete_len = state->buffered_len; + const bool ok = emit_payload(state->buffered, complete_len); + state->reset(); + return ok; +} + +uint32_t estimateBitrateBps(uint32_t bandwidth_hz, uint8_t spreading_factor, uint8_t coding_rate) +{ + if (bandwidth_hz == 0 || spreading_factor < 5 || spreading_factor > 12 || + coding_rate < 5 || coding_rate > 8) + { + return 0; + } + + const float sf = static_cast(spreading_factor); + const float cr = static_cast(coding_rate); + const float bw_khz = static_cast(bandwidth_hz) / 1000.0f; + const float bitrate = + sf * ((4.0f / cr) / (std::pow(2.0f, sf) / bw_khz)) * 1000.0f; + if (!std::isfinite(bitrate) || bitrate <= 0.0f) + { + return 0; + } + return static_cast(std::lround(bitrate)); +} + +float estimateSymbolTimeMs(uint32_t bandwidth_hz, uint8_t spreading_factor) +{ + if (bandwidth_hz == 0 || spreading_factor < 5 || spreading_factor > 12) + { + return 0.0f; + } + + const float symbol_rate = + static_cast(bandwidth_hz) / std::pow(2.0f, static_cast(spreading_factor)); + if (!std::isfinite(symbol_rate) || symbol_rate <= 0.0f) + { + return 0.0f; + } + return (1.0f / symbol_rate) * 1000.0f; +} + +uint16_t recommendPreambleSymbols(uint32_t bandwidth_hz, uint8_t spreading_factor, uint8_t coding_rate) +{ + const float symbol_time_ms = estimateSymbolTimeMs(bandwidth_hz, spreading_factor); + if (symbol_time_ms <= 0.0f) + { + return static_cast(kPreambleSymbolsMin); + } + + const uint32_t bitrate_bps = estimateBitrateBps(bandwidth_hz, spreading_factor, coding_rate); + float target_ms = kPreambleTargetMs; + if (bitrate_bps > kFastThresholdBps) + { + target_ms -= kPreambleFastDeltaMs; + } + + float preamble_symbols = target_ms / symbol_time_ms; + if (!std::isfinite(preamble_symbols)) + { + preamble_symbols = kPreambleSymbolsMin; + } + + preamble_symbols = std::max(kPreambleSymbolsMin, std::ceil(preamble_symbols)); + return clampValue(static_cast(preamble_symbols), 18U, 255U); +} + +} // namespace rnode +} // namespace chat diff --git a/modules/core_sys/include/app/app_config.h b/modules/core_sys/include/app/app_config.h index 28d3353d..8efdeac8 100644 --- a/modules/core_sys/include/app/app_config.h +++ b/modules/core_sys/include/app/app_config.h @@ -62,6 +62,11 @@ struct AppConfig static constexpr uint8_t kMeshCoreDefaultSf = 11; static constexpr uint8_t kMeshCoreDefaultCr = 5; static constexpr int8_t kMeshCoreDefaultTxPowerDbm = 20; + static constexpr float kRNodeDefaultFreqMHz = 869.525f; + static constexpr float kRNodeDefaultBwKHz = 125.0f; + static constexpr uint8_t kRNodeDefaultSf = 9; + static constexpr uint8_t kRNodeDefaultCr = 5; + static constexpr int8_t kRNodeDefaultTxPowerDbm = 17; static constexpr int8_t kTxPowerMinDbm = -9; #if defined(TRAIL_MATE_LORA_TX_POWER_MAX_DBM) // Board/module capability must be declared per build target. @@ -77,6 +82,7 @@ struct AppConfig chat::ChatPolicy chat_policy; chat::MeshConfig meshtastic_config; chat::MeshConfig meshcore_config; + chat::MeshConfig rnode_config; chat::MeshProtocol mesh_protocol; // Device settings @@ -150,6 +156,8 @@ struct AppConfig meshcore_config = chat::MeshConfig(); applyMeshCoreFactoryDefaults(); + rnode_config = chat::MeshConfig(); + applyRNodeFactoryDefaults(); mesh_protocol = chat::MeshProtocol::Meshtastic; node_name[0] = '\0'; short_name[0] = '\0'; @@ -209,14 +217,46 @@ struct AppConfig meshcore_config.meshcore_channel_name[sizeof(meshcore_config.meshcore_channel_name) - 1] = '\0'; } + void applyRNodeFactoryDefaults() + { + rnode_config.use_preset = false; + rnode_config.bandwidth_khz = kRNodeDefaultBwKHz; + rnode_config.spread_factor = kRNodeDefaultSf; + rnode_config.coding_rate = kRNodeDefaultCr; + rnode_config.tx_power = kRNodeDefaultTxPowerDbm; + rnode_config.tx_enabled = true; + rnode_config.override_duty_cycle = false; + rnode_config.override_frequency_mhz = kRNodeDefaultFreqMHz; + } + chat::MeshConfig& activeMeshConfig() { - return (mesh_protocol == chat::MeshProtocol::MeshCore) ? meshcore_config : meshtastic_config; + switch (mesh_protocol) + { + case chat::MeshProtocol::MeshCore: + return meshcore_config; + case chat::MeshProtocol::LXMF: + case chat::MeshProtocol::RNode: + return rnode_config; + case chat::MeshProtocol::Meshtastic: + default: + return meshtastic_config; + } } const chat::MeshConfig& activeMeshConfig() const { - return (mesh_protocol == chat::MeshProtocol::MeshCore) ? meshcore_config : meshtastic_config; + switch (mesh_protocol) + { + case chat::MeshProtocol::MeshCore: + return meshcore_config; + case chat::MeshProtocol::LXMF: + case chat::MeshProtocol::RNode: + return rnode_config; + case chat::MeshProtocol::Meshtastic: + default: + return meshtastic_config; + } } }; diff --git a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp index 28553e99..33fc7476 100644 --- a/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp +++ b/modules/ui_shared/src/ui/screens/chat/chat_ui_controller.cpp @@ -7,6 +7,7 @@ #include "app/app_config.h" #include "app/app_facade_access.h" #include "chat/infra/mesh_protocol_utils.h" +#include "chat/ports/i_mesh_adapter.h" #include "chat/usecase/contact_service.h" #include "platform/ui/gps_runtime.h" #include "platform/ui/screen_runtime.h" @@ -130,6 +131,36 @@ std::string base_conversation_name(const chat::ConversationId& conv) return buf; } +chat::MeshCapabilities active_mesh_capabilities() +{ + chat::IMeshAdapter* adapter = app::messagingFacade().getMeshAdapter(); + return adapter ? adapter->getCapabilities() : chat::MeshCapabilities{}; +} + +bool supports_local_text_chat() +{ + return active_mesh_capabilities().supports_unicast_text; +} + +bool supports_team_chat() +{ + return active_mesh_capabilities().supports_unicast_appdata; +} + +const char* local_text_chat_unavailable_message() +{ + return (active_mesh_protocol() == chat::MeshProtocol::RNode) + ? "RNode text chat runs on host" + : "Text chat unavailable"; +} + +const char* team_chat_unavailable_message() +{ + return (active_mesh_protocol() == chat::MeshProtocol::RNode) + ? "Team chat unavailable in RNode mode" + : "Team chat unavailable"; +} + chat::ConversationId teamConversationId() { return chat::ConversationId(kTeamChatChannel, 0, active_mesh_protocol()); @@ -597,7 +628,9 @@ void UiController::switchToConversation(chat::ConversationId conv) conversation_->setActionCallback(handle_conversation_action, this); conversation_->setBackCallback(handle_conversation_back, this); } - const bool can_reply = team_conv_active_ || (conv.protocol == active_mesh_protocol()); + const bool can_reply = team_conv_active_ + ? supports_team_chat() + : (conv.protocol == active_mesh_protocol() && supports_local_text_chat()); conversation_->setReplyEnabled(can_reply); if (team_conv_active_) @@ -668,6 +701,16 @@ void UiController::switchToCompose(chat::ConversationId conv) ::ui::SystemNotification::show("Conversation protocol mismatch", 2000); return; } + if (!is_team_conv && !supports_local_text_chat()) + { + ::ui::SystemNotification::show(local_text_chat_unavailable_message(), 2200); + return; + } + if (is_team_conv && !supports_team_chat()) + { + ::ui::SystemNotification::show(team_chat_unavailable_message(), 2200); + return; + } state_ = State::Compose; current_channel_ = conv.channel; @@ -788,6 +831,11 @@ void UiController::handleSendMessage(const std::string& text) { return; } + if (!supports_local_text_chat()) + { + ::ui::SystemNotification::show(local_text_chat_unavailable_message(), 2200); + return; + } service_.sendText(current_channel_, text, current_conv_.peer); } @@ -1873,6 +1921,16 @@ void UiController::handleConversationAction(ChatConversationScreen::ActionIntent ::ui::SystemNotification::show("Reply disabled for this protocol", 2000); return; } + if (!team_conv_active_ && !supports_local_text_chat()) + { + ::ui::SystemNotification::show(local_text_chat_unavailable_message(), 2200); + return; + } + if (team_conv_active_ && !supports_team_chat()) + { + ::ui::SystemNotification::show(team_chat_unavailable_message(), 2200); + return; + } switchToCompose(current_conv_); } } 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 a2da2bd6..ada280e0 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 @@ -316,6 +316,10 @@ static const char* node_protocol_short_label(chat::contacts::NodeProtocolType pr { switch (protocol) { + case chat::contacts::NodeProtocolType::LXMF: + return "LX"; + case chat::contacts::NodeProtocolType::RNode: + return "RN"; case chat::contacts::NodeProtocolType::MeshCore: return "MC"; case chat::contacts::NodeProtocolType::Meshtastic: @@ -333,6 +337,12 @@ static bool node_protocol_to_mesh(chat::contacts::NodeProtocolType protocol, cha } switch (protocol) { + case chat::contacts::NodeProtocolType::LXMF: + *out = chat::MeshProtocol::LXMF; + return true; + case chat::contacts::NodeProtocolType::RNode: + *out = chat::MeshProtocol::RNode; + return true; case chat::contacts::NodeProtocolType::MeshCore: *out = chat::MeshProtocol::MeshCore; return true; @@ -375,9 +385,62 @@ static constexpr DiscoveryActionSpec kDiscoveryActionSpecs[] = { {"Cancel", "Back", DiscoveryActionCommand::Cancel}, }; +static chat::MeshCapabilities active_mesh_capabilities() +{ + chat::IMeshAdapter* adapter = app::messagingFacade().getMeshAdapter(); + return adapter ? adapter->getCapabilities() : chat::MeshCapabilities{}; +} + +static bool supports_local_text_chat() +{ + return active_mesh_capabilities().supports_unicast_text; +} + +static bool supports_team_chat() +{ + return active_mesh_capabilities().supports_unicast_appdata; +} + +static const char* local_text_chat_unavailable_message() +{ + return (active_mesh_protocol() == chat::MeshProtocol::RNode) + ? "RNode text chat runs on host" + : "Text chat unavailable"; +} + +static const char* team_chat_unavailable_message() +{ + return (active_mesh_protocol() == chat::MeshProtocol::RNode) + ? "Team chat unavailable in RNode mode" + : "Team chat unavailable"; +} + +static const char* broadcast_chat_unavailable_message(const BroadcastTargetSpec& spec) +{ + if (spec.protocol == chat::MeshProtocol::Meshtastic) + { + return "MT send uses slot 0/1 only"; + } + if (spec.protocol == chat::MeshProtocol::RNode) + { + return "RNode text chat runs on host"; + } + return "Chat unavailable"; +} + static size_t get_broadcast_target_count() { - return (active_mesh_protocol() == chat::MeshProtocol::Meshtastic) ? 8U : 2U; + switch (active_mesh_protocol()) + { + case chat::MeshProtocol::Meshtastic: + return 8U; + case chat::MeshProtocol::MeshCore: + return 2U; + case chat::MeshProtocol::RNode: + return 1U; + default: + return 0U; + } } static bool get_broadcast_target_spec(int index, BroadcastTargetSpec* out) @@ -403,6 +466,20 @@ static bool get_broadcast_target_spec(int index, BroadcastTargetSpec* out) return true; } + if (active_mesh_protocol() == chat::MeshProtocol::RNode) + { + if (index != 0) + { + return false; + } + out->protocol = chat::MeshProtocol::RNode; + out->channel = chat::ChannelId::PRIMARY; + out->channel_index = 0; + out->enabled = true; + out->chat_supported = false; + return true; + } + switch (index) { case 0: @@ -432,6 +509,10 @@ static std::string format_broadcast_target_label(const BroadcastTargetSpec& spec snprintf(buf, sizeof(buf), "[MT] Slot %u", static_cast(spec.channel_index)); return std::string(buf); } + if (spec.protocol == chat::MeshProtocol::RNode) + { + return "[RN] Modem Bridge"; + } return (spec.channel == chat::ChannelId::SECONDARY) ? "[MC] Secondary" : "[MC] Primary"; } @@ -453,6 +534,10 @@ static std::string format_broadcast_target_status(const BroadcastTargetSpec& spe } return spec.chat_supported ? "Ready" : "Slot"; } + if (spec.protocol == chat::MeshProtocol::RNode) + { + return "Host bridge"; + } return "Ready"; } @@ -736,7 +821,7 @@ static void on_list_item_clicked(lv_event_t* e) BroadcastTargetSpec spec{}; if (get_selected_broadcast_target(&spec, nullptr) && !spec.chat_supported) { - ::ui::SystemNotification::show("MT send uses slot 0/1 only", 2200); + ::ui::SystemNotification::show(broadcast_chat_unavailable_message(spec), 2200); return; } } @@ -1225,7 +1310,7 @@ static void open_chat_compose() } if (!target_spec.chat_supported) { - ::ui::SystemNotification::show("MT send uses slot 0/1 only", 2200); + ::ui::SystemNotification::show(broadcast_chat_unavailable_message(target_spec), 2200); return; } protocol = target_spec.protocol; @@ -1235,6 +1320,11 @@ static void open_chat_compose() } else if (g_contacts_state.current_mode == ContactsMode::Team) { + if (!supports_team_chat()) + { + ::ui::SystemNotification::show(team_chat_unavailable_message(), 2200); + return; + } channel = chat::ChannelId::PRIMARY; peer_id = 0; title = team::ui::g_team_state.team_name.empty() @@ -1243,6 +1333,11 @@ static void open_chat_compose() } else { + if (!supports_local_text_chat()) + { + ::ui::SystemNotification::show(local_text_chat_unavailable_message(), 2200); + return; + } channel = chat::ChannelId::PRIMARY; peer_id = node->node_id; chat::MeshProtocol node_protocol = protocol; @@ -1251,7 +1346,7 @@ static void open_chat_compose() { char buf[64]; snprintf(buf, sizeof(buf), "Switch to %s to chat", - (node_protocol == chat::MeshProtocol::MeshCore) ? "MeshCore" : "Meshtastic"); + chat::infra::meshProtocolName(node_protocol)); ::ui::SystemNotification::show(buf, 2200); return; } @@ -1531,6 +1626,13 @@ static void on_compose_action(chat::ui::ChatComposeScreen::ActionIntent intent, return; } + if (!supports_local_text_chat()) + { + ::ui::SystemNotification::show(local_text_chat_unavailable_message(), 2200); + close_chat_compose(); + return; + } + std::string text = g_contacts_state.compose_screen->getText(); if (!text.empty()) { @@ -2155,7 +2257,9 @@ static void open_action_menu_modal() (g_contacts_state.current_mode == ContactsMode::Contacts || g_contacts_state.current_mode == ContactsMode::Nearby); - int action_count = 2; // Chat + Cancel + const bool allow_chat_action = + (g_contacts_state.current_mode == ContactsMode::Team) ? supports_team_chat() : supports_local_text_chat(); + int action_count = allow_chat_action ? 2 : 1; // Chat + Cancel if (g_contacts_state.current_mode == ContactsMode::Contacts) { action_count += 3; // Edit/Delete/Info @@ -2255,7 +2359,10 @@ static void open_action_menu_modal() } }; - add_action(ActionMenuCommand::Chat, "Chat"); + if (allow_chat_action) + { + add_action(ActionMenuCommand::Chat, "Chat"); + } if (g_contacts_state.current_mode == ContactsMode::Contacts) { add_action(ActionMenuCommand::Edit, "Edit"); @@ -2324,7 +2431,7 @@ void refresh_ui() lv_obj_clear_flag(g_contacts_state.sub_container, LV_OBJ_FLAG_SCROLLABLE); } - bool team_available = is_team_available(); + bool team_available = is_team_available() && supports_team_chat(); const bool meshcore_mode = (active_mesh_protocol() == chat::MeshProtocol::MeshCore); if (g_contacts_state.team_btn) { @@ -2450,7 +2557,11 @@ void refresh_ui() target.display_name = format_broadcast_target_label(spec); target.protocol = (spec.protocol == chat::MeshProtocol::MeshCore) ? chat::contacts::NodeProtocolType::MeshCore - : chat::contacts::NodeProtocolType::Meshtastic; + : ((spec.protocol == chat::MeshProtocol::LXMF) + ? chat::contacts::NodeProtocolType::LXMF + : ((spec.protocol == chat::MeshProtocol::RNode) + ? chat::contacts::NodeProtocolType::RNode + : chat::contacts::NodeProtocolType::Meshtastic)); target.channel = spec.channel_index; broadcast_list.push_back(target); } 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 6210c2eb..a078869c 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 @@ -7,6 +7,7 @@ #include "app/app_config.h" #include "app/app_facade_access.h" #include "chat/domain/chat_types.h" +#include "chat/infra/mesh_protocol_utils.h" #include "chat/infra/meshtastic/mt_region.h" #include "ui/components/info_card.h" #include "ui/components/two_pane_layout.h" @@ -51,7 +52,33 @@ void format_contacts_title(char* out, size_t out_len) snprintf(out, out_len, "Contacts (MeshCore)"); return; } - snprintf(out, out_len, "Contacts"); + if (protocol == chat::MeshProtocol::RNode) + { + const chat::MeshConfig& rnode = app_ctx.getConfig().rnode_config; + if (rnode.override_frequency_mhz > 0.0f) + { + snprintf(out, out_len, "Contacts (RNode - %.3fMHz)", rnode.override_frequency_mhz); + } + else + { + snprintf(out, out_len, "Contacts (RNode)"); + } + return; + } + if (protocol == chat::MeshProtocol::LXMF) + { + const chat::MeshConfig& lxmf = app_ctx.getConfig().rnode_config; + if (lxmf.override_frequency_mhz > 0.0f) + { + snprintf(out, out_len, "Contacts (LXMF - %.3fMHz)", lxmf.override_frequency_mhz); + } + else + { + snprintf(out, out_len, "Contacts (LXMF)"); + } + return; + } + snprintf(out, out_len, "Contacts (%s)", chat::infra::meshProtocolName(protocol)); } } // namespace diff --git a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp index 9a8ef3e4..e4f00eef 100644 --- a/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp +++ b/modules/ui_shared/src/ui/screens/pc_link/pc_link_page_runtime.cpp @@ -2,6 +2,8 @@ #if defined(ARDUINO) || defined(ESP_PLATFORM) +#include "app/app_config.h" +#include "app/app_facade_access.h" #include "platform/ui/hostlink_runtime.h" #include "ui/app_runtime.h" #include "ui/assets/fonts/fonts.h" @@ -37,8 +39,42 @@ void request_exit() ui_request_exit_to_menu(); } +bool use_rnode_bridge() +{ + return app::appFacade().getConfig().mesh_protocol == chat::MeshProtocol::RNode; +} + +const char* page_title() +{ + return use_rnode_bridge() ? "RNode Bridge" : "Data Exchange"; +} + +const char* page_subtitle() +{ + return use_rnode_bridge() ? "USB CDC KISS modem for Reticulum" : "Data Exchange"; +} + const char* status_text(platform::ui::hostlink::LinkState state) { + if (use_rnode_bridge()) + { + switch (state) + { + case platform::ui::hostlink::LinkState::Stopped: + case platform::ui::hostlink::LinkState::Waiting: + return "Waiting for Reticulum host..."; + case platform::ui::hostlink::LinkState::Connected: + case platform::ui::hostlink::LinkState::Handshaking: + return "Host connected, probing modem..."; + case platform::ui::hostlink::LinkState::Ready: + return "RNode modem ready"; + case platform::ui::hostlink::LinkState::Error: + return "Bridge error"; + default: + return "Waiting for Reticulum host..."; + } + } + switch (state) { case platform::ui::hostlink::LinkState::Stopped: @@ -133,7 +169,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_add_event_cb(s_root, root_key_event_cb, LV_EVENT_KEY, nullptr); ::ui::widgets::top_bar_init(s_top_bar, s_root); - ::ui::widgets::top_bar_set_title(s_top_bar, "Data Exchange"); + ::ui::widgets::top_bar_set_title(s_top_bar, page_title()); ::ui::widgets::top_bar_set_back_callback(s_top_bar, on_back, nullptr); if (s_top_bar.back_btn) { @@ -173,7 +209,7 @@ void enter(const shell::Host* host, lv_obj_t* parent) lv_obj_center(stack); lv_obj_t* title = lv_label_create(stack); - lv_label_set_text(title, "Data Exchange"); + lv_label_set_text(title, page_subtitle()); lv_obj_set_style_text_font(title, &lv_font_montserrat_18, 0); s_status_label = lv_label_create(stack); 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 96c93f10..83a7dad7 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 @@ -11,6 +11,7 @@ #include "app/app_facade_access.h" #include "board/BoardBase.h" #include "chat/domain/chat_types.h" +#include "chat/infra/mesh_protocol_utils.h" #include "chat/infra/meshcore/mc_region_presets.h" #include "chat/infra/meshtastic/mt_region.h" #include "meshtastic/config.pb.h" @@ -399,12 +400,31 @@ static bool parse_float_text(const char* text, float* out_value) return true; } +static chat::MeshProtocol selected_protocol() +{ + return static_cast(g_settings.chat_protocol); +} + +static bool is_meshcore_protocol_selected() +{ + return selected_protocol() == chat::MeshProtocol::MeshCore; +} + +static bool is_rnode_protocol_selected() +{ + return selected_protocol() == chat::MeshProtocol::RNode || + selected_protocol() == chat::MeshProtocol::LXMF; +} + static void reset_mesh_settings() { app::IAppFacade& app_ctx = app::appFacade(); app_ctx.getConfig().meshtastic_config = chat::MeshConfig(); app_ctx.getConfig().meshtastic_config.region = app::AppConfig::kDefaultRegionCode; app_ctx.getConfig().meshcore_config = chat::MeshConfig(); + app_ctx.getConfig().applyMeshCoreFactoryDefaults(); + app_ctx.getConfig().rnode_config = chat::MeshConfig(); + app_ctx.getConfig().applyRNodeFactoryDefaults(); strncpy(app_ctx.getConfig().meshcore_config.meshcore_channel_name, "Public", sizeof(app_ctx.getConfig().meshcore_config.meshcore_channel_name) - 1); app_ctx.getConfig().meshcore_config.meshcore_channel_name[sizeof(app_ctx.getConfig().meshcore_config.meshcore_channel_name) - 1] = '\0'; @@ -417,12 +437,9 @@ static void reset_mesh_settings() g_settings.chat_psk[0] = '\0'; g_settings.net_use_preset = app_ctx.getConfig().meshtastic_config.use_preset; g_settings.net_modem_preset = app_ctx.getConfig().meshtastic_config.modem_preset; - g_settings.net_tx_power = app_ctx.getConfig().meshtastic_config.tx_power; + g_settings.net_tx_power = app_ctx.getConfig().activeMeshConfig().tx_power; g_settings.net_hop_limit = app_ctx.getConfig().meshtastic_config.hop_limit; - const chat::MeshConfig& active_cfg = - (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::MeshCore) - ? app_ctx.getConfig().meshcore_config - : app_ctx.getConfig().meshtastic_config; + const chat::MeshConfig& active_cfg = app_ctx.getConfig().activeMeshConfig(); g_settings.net_tx_enabled = active_cfg.tx_enabled; g_settings.net_relay = app_ctx.getConfig().meshtastic_config.enable_relay; g_settings.net_duty_cycle = true; @@ -576,12 +593,20 @@ static void settings_load() const app::AppConfig& cfg = app_ctx.getConfig(); const chat::MeshConfig& mt_cfg = cfg.meshtastic_config; const chat::MeshConfig& mc_cfg = cfg.meshcore_config; + const chat::MeshConfig& rn_cfg = cfg.rnode_config; g_settings.chat_region = mt_cfg.region; g_settings.chat_channel = cfg.chat_channel; - const uint8_t* active_psk = - (cfg.mesh_protocol == chat::MeshProtocol::MeshCore) ? mc_cfg.secondary_key : mt_cfg.secondary_key; - if (is_zero_key(active_psk, sizeof(mt_cfg.secondary_key))) + const uint8_t* active_psk = nullptr; + if (cfg.mesh_protocol == chat::MeshProtocol::MeshCore) + { + active_psk = mc_cfg.secondary_key; + } + else if (cfg.mesh_protocol == chat::MeshProtocol::Meshtastic) + { + active_psk = mt_cfg.secondary_key; + } + if (!active_psk || is_zero_key(active_psk, sizeof(mt_cfg.secondary_key))) { g_settings.chat_psk[0] = '\0'; } @@ -593,24 +618,38 @@ static void settings_load() sizeof(g_settings.chat_psk)); } - g_settings.net_use_preset = mt_cfg.use_preset; - g_settings.net_modem_preset = mt_cfg.modem_preset; - g_settings.net_manual_bw = static_cast(std::lround(mt_cfg.bandwidth_khz)); - g_settings.net_manual_sf = mt_cfg.spread_factor; - g_settings.net_manual_cr = mt_cfg.coding_rate; - int tx_power = mt_cfg.tx_power; + if (cfg.mesh_protocol == chat::MeshProtocol::RNode || + cfg.mesh_protocol == chat::MeshProtocol::LXMF) + { + g_settings.net_use_preset = 0; + g_settings.net_modem_preset = 0; + g_settings.net_manual_bw = static_cast(std::lround(rn_cfg.bandwidth_khz)); + g_settings.net_manual_sf = rn_cfg.spread_factor; + g_settings.net_manual_cr = rn_cfg.coding_rate; + float_to_text(rn_cfg.override_frequency_mhz, g_settings.net_override_freq, + sizeof(g_settings.net_override_freq), 3); + } + else + { + g_settings.net_use_preset = mt_cfg.use_preset; + g_settings.net_modem_preset = mt_cfg.modem_preset; + g_settings.net_manual_bw = static_cast(std::lround(mt_cfg.bandwidth_khz)); + g_settings.net_manual_sf = mt_cfg.spread_factor; + g_settings.net_manual_cr = mt_cfg.coding_rate; + float_to_text(mt_cfg.override_frequency_mhz, g_settings.net_override_freq, + sizeof(g_settings.net_override_freq), 3); + } + + int tx_power = cfg.activeMeshConfig().tx_power; if (tx_power < kNetTxPowerMin) tx_power = kNetTxPowerMin; if (tx_power > kNetTxPowerMax) tx_power = kNetTxPowerMax; g_settings.net_tx_power = tx_power; g_settings.net_hop_limit = mt_cfg.hop_limit; - g_settings.net_tx_enabled = (cfg.mesh_protocol == chat::MeshProtocol::MeshCore) - ? mc_cfg.tx_enabled - : mt_cfg.tx_enabled; + g_settings.net_tx_enabled = cfg.activeMeshConfig().tx_enabled; g_settings.net_override_duty_cycle = mt_cfg.override_duty_cycle; g_settings.net_channel_num = mt_cfg.channel_num; g_settings.net_relay = mt_cfg.enable_relay; float_to_text(mt_cfg.frequency_offset_mhz, g_settings.net_freq_offset, sizeof(g_settings.net_freq_offset), 3); - float_to_text(mt_cfg.override_frequency_mhz, g_settings.net_override_freq, sizeof(g_settings.net_override_freq), 3); g_settings.net_duty_cycle = cfg.net_duty_cycle; g_settings.net_channel_util = cfg.net_channel_util; @@ -900,7 +939,15 @@ static void on_text_save_clicked(lv_event_t* e) modal_close(); return; } - app_ctx.getConfig().meshtastic_config.override_frequency_mhz = value; + if (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::RNode || + app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::LXMF) + { + app_ctx.getConfig().rnode_config.override_frequency_mhz = value; + } + else + { + app_ctx.getConfig().meshtastic_config.override_frequency_mhz = value; + } app_ctx.saveConfig(); app_ctx.applyMeshConfig(); } @@ -1169,10 +1216,18 @@ static void on_option_clicked(lv_event_t* e) if (payload->item->pref_key && strcmp(payload->item->pref_key, "net_bw") == 0) { app::IAppFacade& app_ctx = app::appFacade(); - app_ctx.getConfig().meshtastic_config.bandwidth_khz = static_cast(payload->value); - app_ctx.getConfig().meshtastic_config.use_preset = false; - g_settings.net_use_preset = false; - prefs_put_int("net_use_preset", 0); + if (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::RNode || + app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::LXMF) + { + app_ctx.getConfig().rnode_config.bandwidth_khz = static_cast(payload->value); + } + else + { + app_ctx.getConfig().meshtastic_config.bandwidth_khz = static_cast(payload->value); + app_ctx.getConfig().meshtastic_config.use_preset = false; + g_settings.net_use_preset = false; + prefs_put_int("net_use_preset", 0); + } app_ctx.saveConfig(); app_ctx.applyMeshConfig(); rebuild_list = true; @@ -1180,10 +1235,18 @@ static void on_option_clicked(lv_event_t* e) if (payload->item->pref_key && strcmp(payload->item->pref_key, "net_sf") == 0) { app::IAppFacade& app_ctx = app::appFacade(); - app_ctx.getConfig().meshtastic_config.spread_factor = static_cast(payload->value); - app_ctx.getConfig().meshtastic_config.use_preset = false; - g_settings.net_use_preset = false; - prefs_put_int("net_use_preset", 0); + if (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::RNode || + app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::LXMF) + { + app_ctx.getConfig().rnode_config.spread_factor = static_cast(payload->value); + } + else + { + app_ctx.getConfig().meshtastic_config.spread_factor = static_cast(payload->value); + app_ctx.getConfig().meshtastic_config.use_preset = false; + g_settings.net_use_preset = false; + prefs_put_int("net_use_preset", 0); + } app_ctx.saveConfig(); app_ctx.applyMeshConfig(); rebuild_list = true; @@ -1191,10 +1254,18 @@ static void on_option_clicked(lv_event_t* e) if (payload->item->pref_key && strcmp(payload->item->pref_key, "net_cr") == 0) { app::IAppFacade& app_ctx = app::appFacade(); - app_ctx.getConfig().meshtastic_config.coding_rate = static_cast(payload->value); - app_ctx.getConfig().meshtastic_config.use_preset = false; - g_settings.net_use_preset = false; - prefs_put_int("net_use_preset", 0); + if (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::RNode || + app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::LXMF) + { + app_ctx.getConfig().rnode_config.coding_rate = static_cast(payload->value); + } + else + { + app_ctx.getConfig().meshtastic_config.coding_rate = static_cast(payload->value); + app_ctx.getConfig().meshtastic_config.use_preset = false; + g_settings.net_use_preset = false; + prefs_put_int("net_use_preset", 0); + } app_ctx.saveConfig(); app_ctx.applyMeshConfig(); rebuild_list = true; @@ -1322,7 +1393,15 @@ static void on_option_clicked(lv_event_t* e) if (payload->item->pref_key && strcmp(payload->item->pref_key, "net_tx_power") == 0) { app::IAppFacade& app_ctx = app::appFacade(); - app_ctx.getConfig().meshtastic_config.tx_power = static_cast(payload->value); + if (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::RNode || + app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::LXMF) + { + app_ctx.getConfig().rnode_config.tx_power = static_cast(payload->value); + } + else + { + app_ctx.getConfig().meshtastic_config.tx_power = static_cast(payload->value); + } app_ctx.saveConfig(); app_ctx.applyMeshConfig(); } @@ -1607,6 +1686,8 @@ static const settings::ui::SettingOption kChatChannelOptions[] = { static const settings::ui::SettingOption kChatProtocolOptions[] = { {"Meshtastic", static_cast(chat::MeshProtocol::Meshtastic)}, {"MeshCore", static_cast(chat::MeshProtocol::MeshCore)}, + {"LXMF", static_cast(chat::MeshProtocol::LXMF)}, + {"RNode Bridge", static_cast(chat::MeshProtocol::RNode)}, }; static const settings::ui::SettingOption kNetPresetOptions[] = { @@ -1801,7 +1882,7 @@ static settings::ui::SettingItem kMapItems[] = { static settings::ui::SettingItem kChatItems[] = { {"User Name", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, g_settings.user_name, sizeof(g_settings.user_name), false, "chat_user"}, {"Short Name", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, g_settings.short_name, sizeof(g_settings.short_name), false, "chat_short"}, - {"Protocol", settings::ui::SettingType::Enum, kChatProtocolOptions, 2, &g_settings.chat_protocol, nullptr, nullptr, 0, false, "mesh_protocol"}, + {"Protocol", settings::ui::SettingType::Enum, kChatProtocolOptions, 4, &g_settings.chat_protocol, nullptr, nullptr, 0, false, "mesh_protocol"}, {"Region", settings::ui::SettingType::Enum, kChatRegionOptions, 0, &g_settings.chat_region, nullptr, nullptr, 0, false, "chat_region"}, {"Channel", settings::ui::SettingType::Enum, kChatChannelOptions, 2, &g_settings.chat_channel, nullptr, nullptr, 0, false, "chat_channel"}, {"Channel Key / PSK", settings::ui::SettingType::Text, nullptr, 0, nullptr, nullptr, g_settings.chat_psk, sizeof(g_settings.chat_psk), true, "chat_psk"}, @@ -1954,7 +2035,8 @@ static bool should_show_item(const settings::ui::SettingItem& item) return true; } - const bool meshcore = (g_settings.chat_protocol == static_cast(chat::MeshProtocol::MeshCore)); + const bool meshcore = is_meshcore_protocol_selected(); + const bool rnode = is_rnode_protocol_selected(); // Relay is currently not implemented as real forwarding in Meshtastic path. if (has_pref_key(item, "net_relay")) @@ -1986,6 +2068,38 @@ static bool should_show_item(const settings::ui::SettingItem& item) if (has_pref_key(item, "net_freq_offset")) return false; if (has_pref_key(item, "net_override_freq")) return false; } + else if (rnode) + { + if (has_pref_key(item, "chat_region")) return false; + if (has_pref_key(item, "chat_channel")) return false; + if (has_pref_key(item, "chat_psk")) return false; + if (has_pref_key(item, "privacy_encrypt")) return false; + if (has_pref_key(item, "privacy_pki")) return false; + + if (has_pref_key(item, "net_use_preset")) return false; + if (has_pref_key(item, "net_preset")) return false; + if (has_pref_key(item, "net_hop_limit")) return false; + if (has_pref_key(item, "net_override_duty")) return false; + if (has_pref_key(item, "net_channel_num")) return false; + if (has_pref_key(item, "net_freq_offset")) return false; + if (has_pref_key(item, "net_duty_cycle")) return false; + if (has_pref_key(item, "net_util")) return false; + + if (has_pref_key(item, "mc_region_preset")) return false; + if (has_pref_key(item, "mc_freq")) return false; + if (has_pref_key(item, "mc_bw")) return false; + if (has_pref_key(item, "mc_sf")) return false; + if (has_pref_key(item, "mc_cr")) return false; + if (has_pref_key(item, "mc_tx_power")) return false; + if (has_pref_key(item, "mc_repeat")) return false; + if (has_pref_key(item, "mc_rx_delay")) return false; + if (has_pref_key(item, "mc_airtime")) return false; + if (has_pref_key(item, "mc_flood_max")) return false; + if (has_pref_key(item, "mc_multi_acks")) return false; + if (has_pref_key(item, "mc_channel_slot")) return false; + if (has_pref_key(item, "mc_channel_name")) return false; + if (has_pref_key(item, "mc_channel_key")) return false; + } else { if (has_pref_key(item, "mc_region_preset")) return false; @@ -2149,6 +2263,11 @@ static bool activate_item_widget(settings::ui::ItemWidget& widget) { app_ctx.getConfig().meshcore_config.tx_enabled = *item.bool_value; } + else if (app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::RNode || + app_ctx.getConfig().mesh_protocol == chat::MeshProtocol::LXMF) + { + app_ctx.getConfig().rnode_config.tx_enabled = *item.bool_value; + } else { app_ctx.getConfig().meshtastic_config.tx_enabled = *item.bool_value; 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 new file mode 100644 index 00000000..3ddc89f5 --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h @@ -0,0 +1,158 @@ +/** + * @file lxmf_adapter.h + * @brief Device-side LXMF adapter over the existing RNode raw carrier + */ + +#pragma once + +#include "board/LoraBoard.h" +#include "chat/infra/lxmf/lxmf_wire.h" +#include "chat/ports/i_mesh_adapter.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" +#include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" + +#include +#include + +namespace chat::lxmf +{ + +class LxmfAdapter : public IMeshAdapter +{ + public: + explicit LxmfAdapter(LoraBoard& board); + + MeshCapabilities getCapabilities() const override; + bool sendText(ChannelId channel, const std::string& text, + MessageId* out_msg_id, NodeId peer = 0) override; + bool pollIncomingText(MeshIncomingText* out) override; + bool sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest = 0, bool want_ack = false, + MessageId packet_id = 0, + bool want_response = false) override; + bool pollIncomingData(MeshIncomingData* out) override; + bool requestNodeInfo(NodeId dest, bool want_response) override; + NodeId getNodeId() const override; + void applyConfig(const MeshConfig& config) override; + void setUserInfo(const char* long_name, const char* short_name) override; + bool isReady() const override; + bool pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) override; + void handleRawPacket(const uint8_t* data, size_t size) override; + void setLastRxStats(float rssi, float snr) override; + + private: + struct PeerInfo + { + uint32_t node_id = 0; + uint8_t destination_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 last_seen_s = 0; + uint32_t last_path_request_ms = 0; + }; + + struct PathEntry + { + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + uint8_t next_hop_transport[reticulum::kTruncatedHashSize] = {}; + uint8_t cached_packet_hash[reticulum::kFullHashSize] = {}; + uint8_t cached_announce[reticulum::kReticulumMtu] = {}; + size_t cached_announce_len = 0; + uint8_t hops = 0; + uint32_t last_seen_s = 0; + bool direct = false; + }; + + struct PacketFilterEntry + { + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + uint32_t seen_ms = 0; + }; + + struct ReverseEntry + { + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + uint32_t created_ms = 0; + }; + + struct LinkRelayEntry + { + uint8_t link_id[reticulum::kTruncatedHashSize] = {}; + uint8_t initiator_hops = 0; + uint8_t responder_hops = 0; + uint32_t last_seen_ms = 0; + }; + + static constexpr uint32_t kAnnounceIntervalMs = 120000; + static constexpr uint32_t kInitialAnnounceDelayMs = 1500; + + rnode::RNodeAdapter raw_; + LxmfIdentity identity_; + MeshConfig config_{}; + std::queue text_receive_queue_; + std::vector peers_; + std::vector paths_; + std::vector packet_filter_; + std::vector reverse_table_; + std::vector link_relays_; + std::string user_long_name_; + std::string user_short_name_; + uint32_t last_announce_ms_ = 0; + bool announce_pending_ = true; + bool peers_loaded_ = false; + + void processRadioPackets(); + void maybeAnnounce(); + bool sendAnnounce(reticulum::PacketContext context = reticulum::PacketContext::None); + bool handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet); + bool handleDataPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet); + bool handleProofPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet); + bool handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet); + bool handlePathRequestPacket(const reticulum::ParsedPacket& packet); + bool handleCacheRequestPacket(const reticulum::ParsedPacket& packet); + bool maybeForwardTransportPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet); + bool maybeForwardLinkPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet); + bool sendProofForPacket(const uint8_t* raw_packet, size_t raw_len); + bool sendPathRequest(PeerInfo& peer); + bool shouldRequestPath(const PeerInfo& peer) const; + bool buildEncryptedPacketForPeer(const PeerInfo& peer, + const uint8_t* plaintext, size_t plaintext_len, + uint8_t* out_packet, size_t* inout_len); + bool routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, + bool allow_transport); + bool sendCachedAnnounceResponse(const PathEntry& path, + reticulum::PacketContext context); + bool sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kFullHashSize]); + bool shouldRebroadcastAnnounce(const reticulum::ParsedPacket& packet) const; + bool rebroadcastAnnounce(const PathEntry& path, const reticulum::ParsedPacket& packet); + 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]); + ReverseEntry* findReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize]); + void cullTransportState(); + PathEntry& upsertPath(const uint8_t destination_hash[reticulum::kTruncatedHashSize]); + const PathEntry* findPath(const uint8_t destination_hash[reticulum::kTruncatedHashSize]) const; + LinkRelayEntry& upsertLinkRelay(const uint8_t link_id[reticulum::kTruncatedHashSize]); + LinkRelayEntry* findLinkRelay(const uint8_t link_id[reticulum::kTruncatedHashSize]); + PeerInfo* findPeerByNodeId(NodeId node_id); + const PeerInfo* findPeerByDestinationHash(const uint8_t hash[reticulum::kTruncatedHashSize]) const; + PeerInfo& upsertPeer(const uint8_t destination_hash[reticulum::kTruncatedHashSize]); + void publishPeerUpdate(const PeerInfo& peer) const; + void loadPersistedPeers(); + bool persistPeers() const; + uint32_t currentTimestampSeconds() const; + const char* effectiveDisplayName() const; + static uint32_t messageIdFromHash(const uint8_t hash[reticulum::kFullHashSize]); + static void pathRequestDestinationHash(uint8_t out_hash[reticulum::kTruncatedHashSize]); +}; + +} // namespace chat::lxmf diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h new file mode 100644 index 00000000..a38a071e --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h @@ -0,0 +1,63 @@ +/** + * @file lxmf_identity.h + * @brief Reticulum/LXMF identity persistence for ESP Arduino targets + */ + +#pragma once + +#include "chat/infra/reticulum/reticulum_wire.h" + +#include +#include +#include + +namespace chat::lxmf +{ + +class LxmfIdentity +{ + public: + static constexpr size_t kEncPubKeySize = reticulum::kEncryptionPublicKeySize; + static constexpr size_t kEncPrivKeySize = reticulum::kEncryptionPublicKeySize; + static constexpr size_t kSigPubKeySize = reticulum::kSigningPublicKeySize; + static constexpr size_t kSigPrivKeySize = reticulum::kSignatureSize; + static constexpr size_t kSignatureSize = reticulum::kSignatureSize; + + bool init(); + bool isReady() const { return ready_; } + + const uint8_t* encryptionPublicKey() const { return enc_pub_.data(); } + const uint8_t* signingPublicKey() const { return sig_pub_.data(); } + const uint8_t* identityHash() const { return identity_hash_.data(); } + const uint8_t* destinationHash() const { return destination_hash_.data(); } + uint32_t nodeId() const { return node_id_; } + + void combinedPublicKey(uint8_t out_key[reticulum::kCombinedPublicKeySize]) const; + + bool sign(const uint8_t* message, size_t message_len, + uint8_t out_signature[kSignatureSize]) const; + + static bool verify(const uint8_t sign_pub[kSigPubKeySize], + const uint8_t signature[kSignatureSize], + const uint8_t* message, size_t message_len); + + bool deriveSharedSecret(const uint8_t peer_public_key[kEncPubKeySize], + uint8_t out_secret[kEncPubKeySize]) const; + + private: + bool loadFromPrefs(); + bool saveToPrefs() const; + bool generateAndPersist(); + void recomputeDerivedFields(); + + bool ready_ = false; + uint32_t node_id_ = 0; + std::array enc_pub_ = {}; + std::array enc_priv_ = {}; + std::array sig_pub_ = {}; + std::array sig_priv_ = {}; + std::array identity_hash_ = {}; + std::array destination_hash_ = {}; +}; + +} // namespace chat::lxmf diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h new file mode 100644 index 00000000..c091275a --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h @@ -0,0 +1,71 @@ +/** + * @file rnode_adapter.h + * @brief Minimal RNode raw-payload mesh adapter + */ + +#pragma once + +#include "board/LoraBoard.h" +#include "chat/infra/rnode/rnode_packet_wire.h" +#include "chat/ports/i_mesh_adapter.h" +#include + +namespace chat +{ +namespace rnode +{ + +class RNodeAdapter : public IMeshAdapter +{ + public: + explicit RNodeAdapter(LoraBoard& board); + + MeshCapabilities getCapabilities() const override; + bool sendText(ChannelId channel, const std::string& text, + MessageId* out_msg_id, NodeId peer = 0) override; + bool pollIncomingText(MeshIncomingText* out) override; + bool sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest = 0, bool want_ack = false, + MessageId packet_id = 0, + bool want_response = false) override; + bool pollIncomingData(MeshIncomingData* out) override; + void applyConfig(const MeshConfig& config) override; + void setLastRxStats(float rssi, float snr) override; + bool isReady() const override; + bool pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) override; + void handleRawPacket(const uint8_t* data, size_t size) override; + float lastRxRssi() const { return last_rx_rssi_; } + float lastRxSnr() const { return last_rx_snr_; } + + private: + static constexpr uint8_t kSyncWord = 0x12; + static constexpr uint8_t kCrcLen = 2; + + struct PendingRawPacket + { + uint8_t data[chat::rnode::kRNodeMaxPayloadSize] = {}; + size_t len = 0; + }; + + LoraBoard& board_; + MeshConfig config_; + bool ready_ = false; + float last_rx_rssi_ = 0.0f; + float last_rx_snr_ = 0.0f; + uint32_t radio_freq_hz_ = 0; + uint32_t radio_bw_hz_ = 0; + uint8_t radio_sf_ = 0; + uint8_t radio_cr_ = 0; + uint8_t next_sequence_ = 0; + chat::rnode::ReassemblyState reassembly_; + PendingRawPacket last_raw_packet_; + bool has_pending_raw_packet_ = false; + std::queue app_receive_queue_; + + void startRadioReceive(); + void enqueueIncomingData(const uint8_t* payload, size_t len); +}; + +} // namespace rnode +} // namespace chat diff --git a/platform/esp/arduino_common/include/platform/esp/arduino_common/rnode_kiss/rnode_kiss_service.h b/platform/esp/arduino_common/include/platform/esp/arduino_common/rnode_kiss/rnode_kiss_service.h new file mode 100644 index 00000000..0693c83f --- /dev/null +++ b/platform/esp/arduino_common/include/platform/esp/arduino_common/rnode_kiss/rnode_kiss_service.h @@ -0,0 +1,13 @@ +#pragma once + +#include "hostlink/hostlink_session.h" + +namespace rnode_kiss +{ + +void start(); +void stop(); +bool is_active(); +hostlink::Status get_status(); + +} // namespace rnode_kiss diff --git a/platform/esp/arduino_common/src/app_config_store.cpp b/platform/esp/arduino_common/src/app_config_store.cpp index d9f93904..9de43d92 100644 --- a/platform/esp/arduino_common/src/app_config_store.cpp +++ b/platform/esp/arduino_common/src/app_config_store.cpp @@ -84,6 +84,7 @@ bool loadAppConfigFromPreferences(AppConfig& config, Preferences& prefs) auto& chat_policy = config.chat_policy; auto& meshtastic_config = config.meshtastic_config; auto& meshcore_config = config.meshcore_config; + auto& rnode_config = config.rnode_config; auto& mesh_protocol = config.mesh_protocol; auto& node_name = config.node_name; auto& short_name = config.short_name; @@ -171,6 +172,13 @@ bool loadAppConfigFromPreferences(AppConfig& config, Preferences& prefs) meshcore_config.meshcore_channel_name[sizeof(meshcore_config.meshcore_channel_name) - 1] = '\0'; prefs.getBytes("mc_ch_key", meshcore_config.secondary_key, sizeof(meshcore_config.secondary_key)); + rnode_config.override_frequency_mhz = prefs.getFloat("rn_freq", rnode_config.override_frequency_mhz); + rnode_config.bandwidth_khz = prefs.getFloat("rn_bw", rnode_config.bandwidth_khz); + rnode_config.spread_factor = prefs.getUChar("rn_sf", rnode_config.spread_factor); + rnode_config.coding_rate = prefs.getUChar("rn_cr", rnode_config.coding_rate); + rnode_config.tx_power = prefs.getChar("rn_tx", rnode_config.tx_power); + rnode_config.tx_enabled = prefs.getBool("rn_tx_en", rnode_config.tx_enabled); + uint8_t mesh_protocol_raw = prefs.getUChar("mesh_protocol", 0xFF); if (chat::infra::isValidMeshProtocolValue(mesh_protocol_raw)) { @@ -215,6 +223,7 @@ bool loadAppConfigFromPreferences(AppConfig& config, Preferences& prefs) }; meshtastic_config.tx_power = clamp_tx_power(meshtastic_config.tx_power); meshcore_config.tx_power = clamp_tx_power(meshcore_config.tx_power); + rnode_config.tx_power = clamp_tx_power(rnode_config.tx_power); prefs.end(); @@ -309,6 +318,7 @@ bool saveAppConfigToPreferences(AppConfig& config, Preferences& prefs) auto& chat_policy = config.chat_policy; auto& meshtastic_config = config.meshtastic_config; auto& meshcore_config = config.meshcore_config; + auto& rnode_config = config.rnode_config; auto& mesh_protocol = config.mesh_protocol; auto& node_name = config.node_name; auto& short_name = config.short_name; @@ -387,6 +397,13 @@ bool saveAppConfigToPreferences(AppConfig& config, Preferences& prefs) prefs.putBool("mc_tx_en", meshcore_config.tx_enabled); prefs.putString("mc_ch_name", meshcore_config.meshcore_channel_name); prefs.putBytes("mc_ch_key", meshcore_config.secondary_key, sizeof(meshcore_config.secondary_key)); + + prefs.putFloat("rn_freq", rnode_config.override_frequency_mhz); + prefs.putFloat("rn_bw", rnode_config.bandwidth_khz); + prefs.putUChar("rn_sf", rnode_config.spread_factor); + prefs.putUChar("rn_cr", rnode_config.coding_rate); + prefs.putChar("rn_tx", rnode_config.tx_power); + prefs.putBool("rn_tx_en", rnode_config.tx_enabled); // Remove first so legacy key type mismatches cannot block updating this value. prefs.remove("mesh_protocol"); prefs.putUChar("mesh_protocol", static_cast(mesh_protocol)); diff --git a/platform/esp/arduino_common/src/ble/ble_manager.cpp b/platform/esp/arduino_common/src/ble/ble_manager.cpp index 0c67fd30..d72c2e2b 100644 --- a/platform/esp/arduino_common/src/ble/ble_manager.cpp +++ b/platform/esp/arduino_common/src/ble/ble_manager.cpp @@ -99,6 +99,14 @@ void BleManager::restartService(chat::MeshProtocol protocol) shutdownNimble(); + if (protocol == chat::MeshProtocol::RNode || protocol == chat::MeshProtocol::LXMF) + { + active_protocol_ = protocol; + Serial.printf("[BLE] protocol=%s has no BLE service yet\n", + chat::infra::meshProtocolSlug(active_protocol_)); + return; + } + const std::string device_name = buildDeviceName(protocol); NimBLEDevice::init(device_name); 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 new file mode 100644 index 00000000..ef7c077f --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_adapter.cpp @@ -0,0 +1,1907 @@ +/** + * @file lxmf_adapter.cpp + * @brief Device-side LXMF adapter over the existing RNode raw carrier + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" + +#include "../../internal/blob_store_io.h" +#include "chat/domain/contact_types.h" +#include "chat/time_utils.h" +#include "sys/event_bus.h" + +#include +#include + +#include +#include +#include + +namespace chat::lxmf +{ +namespace +{ +constexpr size_t kMaxPacketLen = reticulum::kReticulumMtu; +constexpr size_t kMaxLxmfMessageLen = reticulum::kReticulumMtu; +constexpr size_t kSignedPartMaxLen = reticulum::kReticulumMtu; +constexpr size_t kMaxTokenPlaintextLen = reticulum::kReticulumMtu; +constexpr size_t kPathRequestTagSize = reticulum::kTruncatedHashSize; +constexpr uint32_t kPathRequestMinIntervalMs = 20000; +constexpr uint32_t kPathRefreshAgeS = 300; +constexpr size_t kMaxPersistedPeers = 64; +constexpr size_t kMaxPaths = 96; +constexpr size_t kMaxPacketFilter = 128; +constexpr size_t kMaxReverseEntries = 64; +constexpr size_t kMaxLinkRelays = 24; +constexpr uint32_t kPacketFilterTtlMs = 30000; +constexpr uint32_t kReverseEntryTtlMs = 60000; +constexpr uint32_t kLinkRelayTtlMs = 300000; +constexpr uint8_t kMaxTransportHops = 128; +constexpr const char* kPeersPrefsNs = "lxmf_peers"; +constexpr const char* kPeersPrefsKey = "peers"; +constexpr const char* kPeersPrefsVer = "ver"; +constexpr const char* kPeersPrefsCrc = "crc"; +constexpr uint8_t kPeersPrefsVersion = 1; + +struct PersistedPeerRecord +{ + uint8_t destination_hash[reticulum::kTruncatedHashSize]; + uint8_t identity_hash[reticulum::kTruncatedHashSize]; + uint8_t enc_pub[LxmfIdentity::kEncPubKeySize]; + uint8_t sig_pub[LxmfIdentity::kSigPubKeySize]; + uint32_t last_seen_s; + char display_name[32]; +}; + +static_assert(sizeof(PersistedPeerRecord) == 132, "Unexpected LXMF peer record size"); + +void fillRandomBytes(uint8_t* out, size_t len) +{ + if (!out || len == 0) + { + return; + } + + size_t offset = 0; + while (offset < len) + { + const uint32_t rnd = static_cast(esp_random()); + const size_t chunk = (len - offset >= sizeof(rnd)) ? sizeof(rnd) : (len - offset); + memcpy(out + offset, &rnd, chunk); + offset += chunk; + } +} + +bool isZeroBytes(const uint8_t* data, size_t len) +{ + if (!data) + { + return true; + } + for (size_t i = 0; i < len; ++i) + { + if (data[i] != 0) + { + return false; + } + } + return true; +} + +bool hashesEqual(const uint8_t* a, const uint8_t* b, size_t len) +{ + if ((!a || !b) && len != 0) + { + return false; + } + for (size_t i = 0; i < len; ++i) + { + if (a[i] != b[i]) + { + return false; + } + } + return true; +} + +void copyHash(uint8_t* out, const uint8_t* in, size_t len) +{ + if (!out || !in || len == 0) + { + return; + } + memcpy(out, in, len); +} + +void copyCString(char* out, size_t out_len, const char* in) +{ + if (!out || out_len == 0) + { + return; + } + + out[0] = '\0'; + if (!in) + { + return; + } + + strncpy(out, in, out_len - 1); + out[out_len - 1] = '\0'; +} + +uint32_t fnv1a32(const uint8_t* data, size_t len) +{ + uint32_t hash = 2166136261UL; + if (!data) + { + return hash; + } + + for (size_t i = 0; i < len; ++i) + { + hash ^= static_cast(data[i]); + hash *= 16777619UL; + } + return hash; +} + +bool isLxmfDeliveryAnnounce(const reticulum::ParsedAnnounce& announce) +{ + if (!announce.valid || !announce.name_hash) + { + return false; + } + + uint8_t expected_name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("lxmf", "delivery", expected_name_hash); + return hashesEqual(expected_name_hash, announce.name_hash, sizeof(expected_name_hash)); +} + +bool computeLinkIdFromLinkRequest(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet, + uint8_t out_hash[reticulum::kTruncatedHashSize]) +{ + if (!raw_packet || raw_len == 0 || !out_hash || + packet.packet_type != reticulum::PacketType::LinkRequest || + !packet.payload) + { + return false; + } + + uint8_t scratch[kMaxPacketLen] = {}; + if (raw_len > sizeof(scratch)) + { + return false; + } + + size_t working_len = raw_len; + memcpy(scratch, raw_packet, raw_len); + + if (packet.payload_len > 64) + { + const size_t trim = packet.payload_len - 64; + if (trim >= working_len) + { + return false; + } + working_len -= trim; + } + + reticulum::computeTruncatedPacketHash(scratch, working_len, out_hash); + return true; +} + +} // namespace + +LxmfAdapter::LxmfAdapter(LoraBoard& board) + : raw_(board) +{ +} + +MeshCapabilities LxmfAdapter::getCapabilities() const +{ + MeshCapabilities caps; + caps.supports_unicast_text = true; + caps.supports_node_info = true; + return caps; +} + +bool LxmfAdapter::sendText(ChannelId channel, const std::string& text, + MessageId* out_msg_id, NodeId peer) +{ + processRadioPackets(); + + if (channel != ChannelId::PRIMARY || text.empty() || peer == 0 || !isReady()) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + PeerInfo* peer_info = findPeerByNodeId(peer); + if (!peer_info) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + if (shouldRequestPath(*peer_info)) + { + (void)sendPathRequest(*peer_info); + } + + uint8_t packed_payload[kMaxLxmfMessageLen] = {}; + size_t packed_payload_len = sizeof(packed_payload); + if (!encodeTextPayload(static_cast(currentTimestampSeconds()), + "", + text.c_str(), + packed_payload, + &packed_payload_len)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + uint8_t signed_part[kSignedPartMaxLen] = {}; + size_t signed_part_len = sizeof(signed_part); + uint8_t message_hash[reticulum::kFullHashSize] = {}; + if (!buildSignedPart(peer_info->destination_hash, + identity_.destinationHash(), + packed_payload, + packed_payload_len, + signed_part, + &signed_part_len, + message_hash)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + uint8_t signature[reticulum::kSignatureSize] = {}; + if (!identity_.sign(signed_part, signed_part_len, signature)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + uint8_t lxmf_message[kMaxLxmfMessageLen] = {}; + size_t lxmf_message_len = sizeof(lxmf_message); + if (!packMessage(peer_info->destination_hash, + identity_.destinationHash(), + signature, + packed_payload, + packed_payload_len, + lxmf_message, + &lxmf_message_len)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!buildEncryptedPacketForPeer(*peer_info, lxmf_message, lxmf_message_len, packet, &packet_len)) + { + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; + } + + const MessageId message_id = messageIdFromHash(message_hash); + const bool ok = routeAndSendPacket(packet, packet_len, true); + if (out_msg_id) + { + *out_msg_id = message_id; + } + sys::EventBus::publish(new sys::ChatSendResultEvent(message_id, ok), 0); + return ok; +} + +bool LxmfAdapter::pollIncomingText(MeshIncomingText* out) +{ + processRadioPackets(); + maybeAnnounce(); + + if (!out || text_receive_queue_.empty()) + { + return false; + } + + *out = std::move(text_receive_queue_.front()); + text_receive_queue_.pop(); + return true; +} + +bool LxmfAdapter::sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest, bool want_ack, + MessageId packet_id, + bool want_response) +{ + (void)channel; + (void)portnum; + (void)payload; + (void)len; + (void)dest; + (void)want_ack; + (void)packet_id; + (void)want_response; + return false; +} + +bool LxmfAdapter::pollIncomingData(MeshIncomingData* out) +{ + (void)out; + processRadioPackets(); + maybeAnnounce(); + return false; +} + +bool LxmfAdapter::requestNodeInfo(NodeId dest, bool want_response) +{ + (void)want_response; + + processRadioPackets(); + if (dest != 0) + { + PeerInfo* peer = findPeerByNodeId(dest); + if (!peer) + { + return false; + } + + if (!shouldRequestPath(*peer)) + { + return true; + } + return sendPathRequest(*peer); + } + + announce_pending_ = true; + return sendAnnounce(); +} + +NodeId LxmfAdapter::getNodeId() const +{ + return identity_.nodeId(); +} + +void LxmfAdapter::applyConfig(const MeshConfig& config) +{ + config_ = config; + if (identity_.init() && !peers_loaded_) + { + loadPersistedPeers(); + } + raw_.applyConfig(config_); + last_announce_ms_ = millis(); + announce_pending_ = true; +} + +void LxmfAdapter::setUserInfo(const char* long_name, const char* short_name) +{ + user_long_name_ = (long_name && long_name[0] != '\0') ? long_name : ""; + user_short_name_ = (short_name && short_name[0] != '\0') ? short_name : ""; + announce_pending_ = true; +} + +bool LxmfAdapter::isReady() const +{ + return identity_.isReady() && raw_.isReady(); +} + +bool LxmfAdapter::pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) +{ + (void)out_data; + (void)out_len; + (void)max_len; + return false; +} + +void LxmfAdapter::handleRawPacket(const uint8_t* data, size_t size) +{ + raw_.handleRawPacket(data, size); +} + +void LxmfAdapter::setLastRxStats(float rssi, float snr) +{ + raw_.setLastRxStats(rssi, snr); +} + +void LxmfAdapter::processRadioPackets() +{ + cullTransportState(); + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = 0; + while (raw_.pollIncomingRawPacket(packet, packet_len, sizeof(packet))) + { + reticulum::ParsedPacket parsed{}; + if (reticulum::parsePacket(packet, packet_len, &parsed)) + { + if (parsed.hops < 0xFF) + { + parsed.hops += 1; + } + + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + reticulum::computePacketHash(packet, packet_len, packet_hash); + if (isDuplicatePacket(packet_hash)) + { + continue; + } + rememberPacket(packet_hash); + + if (parsed.packet_type == reticulum::PacketType::Announce) + { + handleAnnouncePacket(packet, packet_len, parsed); + } + else if (parsed.packet_type == reticulum::PacketType::Proof) + { + handleProofPacket(packet, packet_len, parsed); + } + else if (parsed.packet_type == reticulum::PacketType::LinkRequest) + { + handleLinkRequestPacket(packet, packet_len, parsed); + } + else if (parsed.packet_type == reticulum::PacketType::Data) + { + if (!handlePathRequestPacket(parsed) && + !handleCacheRequestPacket(parsed) && + !maybeForwardLinkPacket(packet, packet_len, parsed) && + !maybeForwardTransportPacket(packet, packet_len, parsed)) + { + handleDataPacket(packet, packet_len, parsed); + } + } + else + { + (void)maybeForwardLinkPacket(packet, packet_len, parsed); + (void)maybeForwardTransportPacket(packet, packet_len, parsed); + } + } + + MeshIncomingData discarded; + while (raw_.pollIncomingData(&discarded)) + { + } + } +} + +void LxmfAdapter::maybeAnnounce() +{ + if (!announce_pending_ && (millis() - last_announce_ms_) < kAnnounceIntervalMs) + { + return; + } + if (announce_pending_ && (millis() - last_announce_ms_) < kInitialAnnounceDelayMs) + { + return; + } + (void)sendAnnounce(); +} + +bool LxmfAdapter::sendAnnounce(reticulum::PacketContext context) +{ + if (!isReady()) + { + return false; + } + + uint8_t app_data[96] = {}; + size_t app_data_len = sizeof(app_data); + if (!packPeerAnnounceAppData(effectiveDisplayName(), + false, + 0, + app_data, + &app_data_len)) + { + return false; + } + + uint8_t combined_pub[reticulum::kCombinedPublicKeySize] = {}; + identity_.combinedPublicKey(combined_pub); + + uint8_t name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("lxmf", "delivery", name_hash); + + uint8_t random_hash[10] = {}; + fillRandomBytes(random_hash, 5); + const uint64_t now_s = currentTimestampSeconds(); + random_hash[5] = static_cast((now_s >> 32) & 0xFFU); + random_hash[6] = static_cast((now_s >> 24) & 0xFFU); + random_hash[7] = static_cast((now_s >> 16) & 0xFFU); + random_hash[8] = static_cast((now_s >> 8) & 0xFFU); + random_hash[9] = static_cast(now_s & 0xFFU); + + uint8_t signed_data[kMaxPacketLen] = {}; + size_t signed_len = 0; + memcpy(signed_data + signed_len, identity_.destinationHash(), reticulum::kTruncatedHashSize); + signed_len += reticulum::kTruncatedHashSize; + memcpy(signed_data + signed_len, combined_pub, sizeof(combined_pub)); + signed_len += sizeof(combined_pub); + memcpy(signed_data + signed_len, name_hash, sizeof(name_hash)); + signed_len += sizeof(name_hash); + memcpy(signed_data + signed_len, random_hash, sizeof(random_hash)); + signed_len += sizeof(random_hash); + memcpy(signed_data + signed_len, app_data, app_data_len); + signed_len += app_data_len; + + uint8_t signature[reticulum::kSignatureSize] = {}; + if (!identity_.sign(signed_data, signed_len, signature)) + { + return false; + } + + uint8_t announce_payload[kMaxPacketLen] = {}; + size_t announce_payload_len = 0; + memcpy(announce_payload + announce_payload_len, combined_pub, sizeof(combined_pub)); + announce_payload_len += sizeof(combined_pub); + memcpy(announce_payload + announce_payload_len, name_hash, sizeof(name_hash)); + announce_payload_len += sizeof(name_hash); + memcpy(announce_payload + announce_payload_len, random_hash, sizeof(random_hash)); + announce_payload_len += sizeof(random_hash); + memcpy(announce_payload + announce_payload_len, signature, sizeof(signature)); + announce_payload_len += sizeof(signature); + memcpy(announce_payload + announce_payload_len, app_data, app_data_len); + announce_payload_len += app_data_len; + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!reticulum::buildHeader1Packet(reticulum::PacketType::Announce, + reticulum::DestinationType::Single, + context, + false, + identity_.destinationHash(), + announce_payload, + announce_payload_len, + packet, + &packet_len)) + { + return false; + } + + if (!routeAndSendPacket(packet, packet_len, false)) + { + return false; + } + + last_announce_ms_ = millis(); + announce_pending_ = false; + return true; +} + +bool LxmfAdapter::handleAnnouncePacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet) +{ + if (!raw_packet || raw_len == 0 || !packet.destination_hash) + { + return false; + } + if (packet.destination_type != reticulum::DestinationType::Single) + { + return false; + } + if (packet.context != static_cast(reticulum::PacketContext::None) && + packet.context != static_cast(reticulum::PacketContext::PathResponse)) + { + return false; + } + + reticulum::ParsedAnnounce announce{}; + if (!reticulum::parseAnnounce(packet, &announce) || !announce.valid) + { + return false; + } + + uint8_t identity_hash[reticulum::kTruncatedHashSize] = {}; + reticulum::computeIdentityHash(announce.public_key, identity_hash); + + uint8_t expected_destination_hash[reticulum::kTruncatedHashSize] = {}; + reticulum::computeDestinationHash(announce.name_hash, identity_hash, expected_destination_hash); + if (!hashesEqual(expected_destination_hash, packet.destination_hash, reticulum::kTruncatedHashSize)) + { + return false; + } + + uint8_t signed_data[kMaxPacketLen] = {}; + size_t signed_len = 0; + memcpy(signed_data + signed_len, packet.destination_hash, reticulum::kTruncatedHashSize); + signed_len += reticulum::kTruncatedHashSize; + memcpy(signed_data + signed_len, announce.public_key, reticulum::kCombinedPublicKeySize); + signed_len += reticulum::kCombinedPublicKeySize; + memcpy(signed_data + signed_len, announce.name_hash, reticulum::kNameHashSize); + signed_len += reticulum::kNameHashSize; + memcpy(signed_data + signed_len, announce.random_hash, 10); + signed_len += 10; + if (announce.app_data_len != 0) + { + memcpy(signed_data + signed_len, announce.app_data, announce.app_data_len); + signed_len += announce.app_data_len; + } + + const uint8_t* sig_pub = announce.public_key + reticulum::kEncryptionPublicKeySize; + if (!LxmfIdentity::verify(sig_pub, announce.signature, signed_data, signed_len)) + { + return false; + } + + PathEntry& path = upsertPath(packet.destination_hash); + const uint32_t now_s = currentTimestampSeconds(); + path.hops = packet.hops; + path.last_seen_s = now_s; + path.direct = (packet.transport_id == nullptr); + if (packet.transport_id) + { + copyHash(path.next_hop_transport, packet.transport_id, sizeof(path.next_hop_transport)); + } + else + { + copyHash(path.next_hop_transport, packet.destination_hash, sizeof(path.next_hop_transport)); + } + + if (raw_len <= sizeof(path.cached_announce)) + { + memcpy(path.cached_announce, raw_packet, raw_len); + path.cached_announce_len = raw_len; + reticulum::computePacketHash(raw_packet, raw_len, path.cached_packet_hash); + } + + if (shouldRebroadcastAnnounce(packet)) + { + (void)rebroadcastAnnounce(path, packet); + } + + if (!isLxmfDeliveryAnnounce(announce) || + hashesEqual(packet.destination_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + return true; + } + + PeerInfo& peer = upsertPeer(packet.destination_hash); + copyHash(peer.identity_hash, identity_hash, sizeof(peer.identity_hash)); + memcpy(peer.enc_pub, announce.public_key, sizeof(peer.enc_pub)); + memcpy(peer.sig_pub, sig_pub, sizeof(peer.sig_pub)); + peer.last_seen_s = now_s; + + char display_name[sizeof(peer.display_name)] = {}; + bool has_stamp_cost = false; + uint8_t stamp_cost = 0; + if (announce.app_data && announce.app_data_len != 0 && + unpackPeerAnnounceAppData(announce.app_data, announce.app_data_len, + display_name, sizeof(display_name), + &has_stamp_cost, &stamp_cost)) + { + (void)has_stamp_cost; + (void)stamp_cost; + copyCString(peer.display_name, sizeof(peer.display_name), display_name); + } + else if (peer.display_name[0] == '\0') + { + snprintf(peer.display_name, sizeof(peer.display_name), + "%08lX", static_cast(peer.node_id)); + } + + (void)persistPeers(); + publishPeerUpdate(peer); + return true; +} + +bool LxmfAdapter::handleDataPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet) +{ + if (!raw_packet || raw_len == 0 || !packet.payload || !packet.destination_hash) + { + return false; + } + if (!hashesEqual(packet.destination_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + if (packet.destination_type != reticulum::DestinationType::Single || + packet.payload_len <= (reticulum::kEncryptionPublicKeySize + reticulum::kTokenOverhead)) + { + return false; + } + + const uint8_t* peer_ephemeral_pub = packet.payload; + const uint8_t* token = packet.payload + reticulum::kEncryptionPublicKeySize; + const size_t token_len = packet.payload_len - reticulum::kEncryptionPublicKeySize; + + uint8_t shared_secret[LxmfIdentity::kEncPubKeySize] = {}; + if (!identity_.deriveSharedSecret(peer_ephemeral_pub, shared_secret)) + { + return false; + } + + uint8_t derived_key[reticulum::kDerivedTokenKeySize] = {}; + if (!reticulum::hkdfSha256(shared_secret, sizeof(shared_secret), + identity_.identityHash(), reticulum::kTruncatedHashSize, + nullptr, 0, + derived_key, sizeof(derived_key))) + { + return false; + } + + uint8_t plaintext[kMaxTokenPlaintextLen] = {}; + size_t plaintext_len = sizeof(plaintext); + if (!reticulum::tokenDecrypt(derived_key, token, token_len, plaintext, &plaintext_len)) + { + return false; + } + + DecodedMessage message{}; + if (!unpackMessage(plaintext, plaintext_len, &message)) + { + return false; + } + if (!hashesEqual(message.destination_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + + const PeerInfo* peer = findPeerByDestinationHash(message.source_hash); + if (!peer) + { + return false; + } + + uint8_t signed_part[kSignedPartMaxLen] = {}; + size_t signed_part_len = sizeof(signed_part); + uint8_t message_hash[reticulum::kFullHashSize] = {}; + if (!buildSignedPart(message.destination_hash, + message.source_hash, + message.packed_payload.data(), + message.packed_payload.size(), + signed_part, + &signed_part_len, + message_hash)) + { + return false; + } + if (!LxmfIdentity::verify(peer->sig_pub, message.signature, signed_part, signed_part_len)) + { + return false; + } + + MeshIncomingText incoming; + incoming.channel = ChannelId::PRIMARY; + incoming.from = peer->node_id; + incoming.to = identity_.nodeId(); + incoming.msg_id = messageIdFromHash(message_hash); + incoming.timestamp = currentTimestampSeconds(); + incoming.text = message.content; + incoming.hop_limit = 0xFF; + incoming.encrypted = true; + incoming.rx_meta.rx_timestamp_ms = millis(); + incoming.rx_meta.rx_timestamp_s = currentTimestampSeconds(); + incoming.rx_meta.time_source = is_valid_epoch(incoming.rx_meta.rx_timestamp_s) + ? RxTimeSource::DeviceUtc + : RxTimeSource::Uptime; + incoming.rx_meta.origin = RxOrigin::Mesh; + incoming.rx_meta.direct = true; + incoming.rx_meta.from_is = false; + incoming.rx_meta.rssi_dbm_x10 = static_cast(lround(raw_.lastRxRssi() * 10.0f)); + incoming.rx_meta.snr_db_x10 = static_cast(lround(raw_.lastRxSnr() * 10.0f)); + + text_receive_queue_.push(std::move(incoming)); + (void)sendProofForPacket(raw_packet, raw_len); + return true; +} + +bool LxmfAdapter::handleProofPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet) +{ + (void)raw_len; + if (!raw_packet || !packet.destination_hash || packet.packet_type != reticulum::PacketType::Proof) + { + return false; + } + + if (packet.context == static_cast(reticulum::PacketContext::LrProof) || + packet.destination_type == reticulum::DestinationType::Link) + { + return maybeForwardLinkPacket(raw_packet, raw_len, packet); + } + + ReverseEntry* reverse = findReversePath(packet.destination_hash); + if (!reverse) + { + return false; + } + + uint8_t forward_packet[kMaxPacketLen] = {}; + size_t forward_len = sizeof(forward_packet); + if (!reticulum::buildHeader1Packet(packet.packet_type, + packet.destination_type, + static_cast(packet.context), + packet.context_flag != 0, + packet.destination_hash, + packet.payload, + packet.payload_len, + forward_packet, + &forward_len, + packet.hops, + reticulum::TransportType::Broadcast)) + { + return false; + } + + reverse->created_ms = 0; + return raw_.sendAppData(ChannelId::PRIMARY, 0, forward_packet, forward_len); +} + +bool LxmfAdapter::handleLinkRequestPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet) +{ + if (!raw_packet || raw_len == 0 || !packet.destination_hash || + packet.packet_type != reticulum::PacketType::LinkRequest) + { + return false; + } + + if (packet.transport_id && + !hashesEqual(packet.transport_id, identity_.identityHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + + if (hashesEqual(packet.destination_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + + const PathEntry* path = findPath(packet.destination_hash); + if (!path) + { + return false; + } + + uint8_t link_id[reticulum::kTruncatedHashSize] = {}; + if (computeLinkIdFromLinkRequest(raw_packet, raw_len, packet, link_id)) + { + LinkRelayEntry& relay = upsertLinkRelay(link_id); + relay.initiator_hops = packet.hops; + relay.responder_hops = path->hops; + relay.last_seen_ms = millis(); + } + + if (path->hops <= 1 || path->direct) + { + uint8_t forward_packet[kMaxPacketLen] = {}; + size_t forward_len = sizeof(forward_packet); + if (!reticulum::buildHeader1Packet(packet.packet_type, + packet.destination_type, + static_cast(packet.context), + packet.context_flag != 0, + packet.destination_hash, + packet.payload, + packet.payload_len, + forward_packet, + &forward_len, + packet.hops, + reticulum::TransportType::Broadcast)) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, forward_packet, forward_len); + } + + uint8_t forward_packet[kMaxPacketLen] = {}; + size_t forward_len = sizeof(forward_packet); + if (!reticulum::buildHeader2Packet(packet.packet_type, + packet.destination_type, + static_cast(packet.context), + packet.context_flag != 0, + path->next_hop_transport, + packet.destination_hash, + packet.payload, + packet.payload_len, + forward_packet, + &forward_len, + packet.hops)) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, forward_packet, forward_len); +} + +bool LxmfAdapter::handlePathRequestPacket(const reticulum::ParsedPacket& packet) +{ + if (!packet.valid || + packet.packet_type != reticulum::PacketType::Data || + packet.destination_type != reticulum::DestinationType::Plain || + !packet.destination_hash || + !packet.payload || + packet.payload_len <= reticulum::kTruncatedHashSize) + { + return false; + } + + uint8_t control_hash[reticulum::kTruncatedHashSize] = {}; + pathRequestDestinationHash(control_hash); + if (!hashesEqual(packet.destination_hash, control_hash, sizeof(control_hash))) + { + return false; + } + + const uint8_t* requested_hash = packet.payload; + const uint8_t* tag = nullptr; + size_t tag_len = 0; + + if (packet.payload_len > (reticulum::kTruncatedHashSize * 2)) + { + tag = packet.payload + (reticulum::kTruncatedHashSize * 2); + tag_len = packet.payload_len - (reticulum::kTruncatedHashSize * 2); + } + else + { + tag = packet.payload + reticulum::kTruncatedHashSize; + tag_len = packet.payload_len - reticulum::kTruncatedHashSize; + } + + if (tag_len > kPathRequestTagSize) + { + tag_len = kPathRequestTagSize; + } + if (tag_len == 0) + { + return true; + } + + if (hashesEqual(requested_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + (void)tag; + return sendAnnounce(reticulum::PacketContext::PathResponse); + } + + const PathEntry* path = findPath(requested_hash); + if (!path || path->cached_announce_len == 0) + { + return true; + } + + return sendCachedAnnounceResponse(*path, reticulum::PacketContext::PathResponse); +} + +bool LxmfAdapter::handleCacheRequestPacket(const reticulum::ParsedPacket& packet) +{ + if (!packet.valid || + packet.packet_type != reticulum::PacketType::Data || + packet.context != static_cast(reticulum::PacketContext::CacheRequest) || + !packet.payload || + packet.payload_len != reticulum::kFullHashSize) + { + return false; + } + + return sendCachedPacketReplay(packet.payload); +} + +bool LxmfAdapter::maybeForwardTransportPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet) +{ + if (!raw_packet || raw_len == 0 || !packet.destination_hash) + { + return false; + } + + if (!packet.transport_id || + !hashesEqual(packet.transport_id, identity_.identityHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + + if (hashesEqual(packet.destination_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + + const PathEntry* path = findPath(packet.destination_hash); + if (!path) + { + return false; + } + + if (packet.packet_type != reticulum::PacketType::Proof && + packet.packet_type != reticulum::PacketType::Announce) + { + uint8_t proof_hash[reticulum::kTruncatedHashSize] = {}; + reticulum::computeTruncatedPacketHash(raw_packet, raw_len, proof_hash); + rememberReversePath(proof_hash); + } + + if (path->hops <= 1 || path->direct) + { + uint8_t forward_packet[kMaxPacketLen] = {}; + size_t forward_len = sizeof(forward_packet); + if (!reticulum::buildHeader1Packet(packet.packet_type, + packet.destination_type, + static_cast(packet.context), + packet.context_flag != 0, + packet.destination_hash, + packet.payload, + packet.payload_len, + forward_packet, + &forward_len, + packet.hops, + reticulum::TransportType::Broadcast)) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, forward_packet, forward_len); + } + + uint8_t forward_packet[kMaxPacketLen] = {}; + size_t forward_len = sizeof(forward_packet); + if (!reticulum::buildHeader2Packet(packet.packet_type, + packet.destination_type, + static_cast(packet.context), + packet.context_flag != 0, + path->next_hop_transport, + packet.destination_hash, + packet.payload, + packet.payload_len, + forward_packet, + &forward_len, + packet.hops)) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, forward_packet, forward_len); +} + +bool LxmfAdapter::maybeForwardLinkPacket(const uint8_t* raw_packet, size_t raw_len, + const reticulum::ParsedPacket& packet) +{ + (void)raw_packet; + (void)raw_len; + + if (!packet.destination_hash) + { + return false; + } + + if (packet.destination_type != reticulum::DestinationType::Link) + { + return false; + } + + LinkRelayEntry* relay = findLinkRelay(packet.destination_hash); + if (!relay) + { + return false; + } + + const bool from_initiator = (packet.hops == relay->initiator_hops); + const bool from_responder = (packet.hops == relay->responder_hops); + if (!from_initiator && !from_responder) + { + return false; + } + + uint8_t forward_packet[kMaxPacketLen] = {}; + size_t forward_len = sizeof(forward_packet); + if (!reticulum::buildHeader1Packet(packet.packet_type, + packet.destination_type, + static_cast(packet.context), + packet.context_flag != 0, + packet.destination_hash, + packet.payload, + packet.payload_len, + forward_packet, + &forward_len, + packet.hops, + reticulum::TransportType::Broadcast)) + { + return false; + } + + relay->last_seen_ms = millis(); + return raw_.sendAppData(ChannelId::PRIMARY, 0, forward_packet, forward_len); +} + +bool LxmfAdapter::sendProofForPacket(const uint8_t* raw_packet, size_t raw_len) +{ + if (!raw_packet || raw_len == 0 || !isReady()) + { + return false; + } + + uint8_t packet_hash[reticulum::kFullHashSize] = {}; + reticulum::computePacketHash(raw_packet, raw_len, packet_hash); + + uint8_t signature[reticulum::kSignatureSize] = {}; + if (!identity_.sign(packet_hash, sizeof(packet_hash), signature)) + { + return false; + } + + uint8_t destination_hash[reticulum::kTruncatedHashSize] = {}; + memcpy(destination_hash, packet_hash, sizeof(destination_hash)); + + uint8_t proof_packet[kMaxPacketLen] = {}; + size_t proof_len = sizeof(proof_packet); + if (!reticulum::buildHeader1Packet(reticulum::PacketType::Proof, + reticulum::DestinationType::Single, + reticulum::PacketContext::None, + false, + destination_hash, + signature, + sizeof(signature), + proof_packet, + &proof_len)) + { + return false; + } + + return routeAndSendPacket(proof_packet, proof_len, false); +} + +bool LxmfAdapter::sendPathRequest(PeerInfo& peer) +{ + if (!isReady() || isZeroBytes(peer.destination_hash, sizeof(peer.destination_hash))) + { + return false; + } + + const uint32_t now_ms = millis(); + if (peer.last_path_request_ms != 0 && + (now_ms - peer.last_path_request_ms) < kPathRequestMinIntervalMs) + { + return false; + } + + uint8_t request_payload[reticulum::kTruncatedHashSize + kPathRequestTagSize] = {}; + memcpy(request_payload, peer.destination_hash, reticulum::kTruncatedHashSize); + fillRandomBytes(request_payload + reticulum::kTruncatedHashSize, kPathRequestTagSize); + + uint8_t control_hash[reticulum::kTruncatedHashSize] = {}; + pathRequestDestinationHash(control_hash); + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!reticulum::buildHeader1Packet(reticulum::PacketType::Data, + reticulum::DestinationType::Plain, + reticulum::PacketContext::None, + false, + control_hash, + request_payload, + sizeof(request_payload), + packet, + &packet_len)) + { + return false; + } + + if (!routeAndSendPacket(packet, packet_len, false)) + { + return false; + } + + peer.last_path_request_ms = now_ms; + return true; +} + +bool LxmfAdapter::shouldRequestPath(const PeerInfo& peer) const +{ + const uint32_t now_ms = millis(); + if (peer.last_path_request_ms != 0 && + (now_ms - peer.last_path_request_ms) < kPathRequestMinIntervalMs) + { + return false; + } + + if (peer.last_seen_s == 0) + { + return true; + } + + const uint32_t now_s = currentTimestampSeconds(); + if (now_s < peer.last_seen_s) + { + return true; + } + + return (now_s - peer.last_seen_s) >= kPathRefreshAgeS; +} + +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 ephemeral_pub[LxmfIdentity::kEncPubKeySize] = {}; + uint8_t ephemeral_priv[LxmfIdentity::kEncPrivKeySize] = {}; + Curve25519::dh1(ephemeral_pub, ephemeral_priv); + + uint8_t shared_secret[LxmfIdentity::kEncPubKeySize] = {}; + memcpy(shared_secret, peer.enc_pub, sizeof(shared_secret)); + if (!Curve25519::dh2(shared_secret, ephemeral_priv)) + { + return false; + } + + uint8_t derived_key[reticulum::kDerivedTokenKeySize] = {}; + if (!reticulum::hkdfSha256(shared_secret, sizeof(shared_secret), + peer.identity_hash, sizeof(peer.identity_hash), + nullptr, 0, + derived_key, sizeof(derived_key))) + { + return false; + } + + 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)) + { + return false; + } + + uint8_t payload[kMaxPacketLen] = {}; + const size_t payload_len = sizeof(ephemeral_pub) + encrypted_token_len; + if (payload_len > sizeof(payload)) + { + return false; + } + memcpy(payload, ephemeral_pub, sizeof(ephemeral_pub)); + memcpy(payload + sizeof(ephemeral_pub), encrypted_token, encrypted_token_len); + + return reticulum::buildHeader1Packet(reticulum::PacketType::Data, + reticulum::DestinationType::Single, + reticulum::PacketContext::None, + false, + peer.destination_hash, + payload, + payload_len, + out_packet, + inout_len); +} + +bool LxmfAdapter::routeAndSendPacket(const uint8_t* raw_packet, size_t raw_len, + bool allow_transport) +{ + if (!raw_packet || raw_len == 0) + { + return false; + } + + if (!allow_transport) + { + return raw_.sendAppData(ChannelId::PRIMARY, 0, raw_packet, raw_len); + } + + reticulum::ParsedPacket parsed{}; + if (!reticulum::parsePacket(raw_packet, raw_len, &parsed) || !parsed.destination_hash) + { + return raw_.sendAppData(ChannelId::PRIMARY, 0, raw_packet, raw_len); + } + + if (parsed.packet_type == reticulum::PacketType::Announce || + parsed.packet_type == reticulum::PacketType::Proof || + parsed.destination_type == reticulum::DestinationType::Plain || + parsed.destination_type == reticulum::DestinationType::Group) + { + return raw_.sendAppData(ChannelId::PRIMARY, 0, raw_packet, raw_len); + } + + const PathEntry* path = findPath(parsed.destination_hash); + if (!path || path->hops <= 1 || path->direct) + { + return raw_.sendAppData(ChannelId::PRIMARY, 0, raw_packet, raw_len); + } + + uint8_t routed_packet[kMaxPacketLen] = {}; + size_t routed_len = sizeof(routed_packet); + if (!reticulum::buildHeader2Packet(parsed.packet_type, + parsed.destination_type, + static_cast(parsed.context), + parsed.context_flag != 0, + path->next_hop_transport, + parsed.destination_hash, + parsed.payload, + parsed.payload_len, + routed_packet, + &routed_len, + raw_packet[1])) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, routed_packet, routed_len); +} + +bool LxmfAdapter::sendCachedAnnounceResponse(const PathEntry& path, + reticulum::PacketContext context) +{ + if (path.cached_announce_len == 0) + { + return false; + } + + reticulum::ParsedPacket parsed{}; + if (!reticulum::parsePacket(path.cached_announce, path.cached_announce_len, &parsed) || + parsed.packet_type != reticulum::PacketType::Announce) + { + return false; + } + + uint8_t packet[kMaxPacketLen] = {}; + size_t packet_len = sizeof(packet); + if (!reticulum::buildHeader2Packet(reticulum::PacketType::Announce, + reticulum::DestinationType::Single, + context, + parsed.context_flag != 0, + identity_.identityHash(), + parsed.destination_hash, + parsed.payload, + parsed.payload_len, + packet, + &packet_len, + path.hops)) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, packet, packet_len); +} + +bool LxmfAdapter::sendCachedPacketReplay(const uint8_t packet_hash[reticulum::kFullHashSize]) +{ + if (!packet_hash) + { + return false; + } + + for (const auto& path : paths_) + { + if (path.cached_announce_len == 0) + { + continue; + } + if (hashesEqual(path.cached_packet_hash, packet_hash, reticulum::kFullHashSize)) + { + return raw_.sendAppData(ChannelId::PRIMARY, 0, path.cached_announce, path.cached_announce_len); + } + } + + return false; +} + +bool LxmfAdapter::shouldRebroadcastAnnounce(const reticulum::ParsedPacket& packet) const +{ + if (!packet.destination_hash) + { + return false; + } + if (packet.context == static_cast(reticulum::PacketContext::PathResponse)) + { + return false; + } + if (packet.hops >= kMaxTransportHops) + { + return false; + } + if (hashesEqual(packet.destination_hash, identity_.destinationHash(), reticulum::kTruncatedHashSize)) + { + return false; + } + return true; +} + +bool LxmfAdapter::rebroadcastAnnounce(const PathEntry& path, const reticulum::ParsedPacket& packet) +{ + if (!packet.destination_hash || path.cached_announce_len == 0) + { + return false; + } + + uint8_t rebroadcast[kMaxPacketLen] = {}; + size_t rebroadcast_len = sizeof(rebroadcast); + if (!reticulum::buildHeader2Packet(reticulum::PacketType::Announce, + reticulum::DestinationType::Single, + reticulum::PacketContext::None, + packet.context_flag != 0, + identity_.identityHash(), + packet.destination_hash, + packet.payload, + packet.payload_len, + rebroadcast, + &rebroadcast_len, + packet.hops)) + { + return false; + } + + return raw_.sendAppData(ChannelId::PRIMARY, 0, rebroadcast, rebroadcast_len); +} + +bool LxmfAdapter::isDuplicatePacket(const uint8_t packet_hash[reticulum::kFullHashSize]) +{ + if (!packet_hash) + { + return false; + } + for (const auto& entry : packet_filter_) + { + if (entry.seen_ms != 0 && + hashesEqual(entry.packet_hash, packet_hash, reticulum::kFullHashSize)) + { + return true; + } + } + return false; +} + +void LxmfAdapter::rememberPacket(const uint8_t packet_hash[reticulum::kFullHashSize]) +{ + if (!packet_hash) + { + return; + } + + if (packet_filter_.size() >= kMaxPacketFilter) + { + packet_filter_.erase(packet_filter_.begin()); + } + + PacketFilterEntry entry{}; + copyHash(entry.packet_hash, packet_hash, sizeof(entry.packet_hash)); + entry.seen_ms = millis(); + packet_filter_.push_back(entry); +} + +void LxmfAdapter::rememberReversePath(const uint8_t proof_hash[reticulum::kTruncatedHashSize]) +{ + if (!proof_hash) + { + return; + } + + for (auto& entry : reverse_table_) + { + if (hashesEqual(entry.proof_hash, proof_hash, sizeof(entry.proof_hash))) + { + entry.created_ms = millis(); + return; + } + } + + if (reverse_table_.size() >= kMaxReverseEntries) + { + reverse_table_.erase(reverse_table_.begin()); + } + + ReverseEntry entry{}; + copyHash(entry.proof_hash, proof_hash, sizeof(entry.proof_hash)); + entry.created_ms = millis(); + reverse_table_.push_back(entry); +} + +LxmfAdapter::ReverseEntry* LxmfAdapter::findReversePath( + const uint8_t proof_hash[reticulum::kTruncatedHashSize]) +{ + if (!proof_hash) + { + return nullptr; + } + for (auto& entry : reverse_table_) + { + if (entry.created_ms != 0 && + hashesEqual(entry.proof_hash, proof_hash, sizeof(entry.proof_hash))) + { + return &entry; + } + } + return nullptr; +} + +void LxmfAdapter::cullTransportState() +{ + const uint32_t now_ms = millis(); + + packet_filter_.erase( + std::remove_if(packet_filter_.begin(), packet_filter_.end(), + [now_ms](const PacketFilterEntry& entry) + { + return entry.seen_ms == 0 || (now_ms - entry.seen_ms) > kPacketFilterTtlMs; + }), + packet_filter_.end()); + + reverse_table_.erase( + std::remove_if(reverse_table_.begin(), reverse_table_.end(), + [now_ms](const ReverseEntry& entry) + { + return entry.created_ms == 0 || (now_ms - entry.created_ms) > kReverseEntryTtlMs; + }), + reverse_table_.end()); + + link_relays_.erase( + std::remove_if(link_relays_.begin(), link_relays_.end(), + [now_ms](const LinkRelayEntry& entry) + { + return entry.last_seen_ms == 0 || (now_ms - entry.last_seen_ms) > kLinkRelayTtlMs; + }), + link_relays_.end()); +} + +LxmfAdapter::PathEntry& LxmfAdapter::upsertPath( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + for (auto& path : paths_) + { + if (hashesEqual(path.destination_hash, destination_hash, reticulum::kTruncatedHashSize)) + { + return path; + } + } + + if (paths_.size() >= kMaxPaths) + { + paths_.erase(paths_.begin()); + } + + paths_.push_back(PathEntry{}); + PathEntry& path = paths_.back(); + copyHash(path.destination_hash, destination_hash, sizeof(path.destination_hash)); + return path; +} + +const LxmfAdapter::PathEntry* LxmfAdapter::findPath( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) const +{ + if (!destination_hash) + { + return nullptr; + } + for (const auto& path : paths_) + { + if (hashesEqual(path.destination_hash, destination_hash, sizeof(path.destination_hash))) + { + return &path; + } + } + return nullptr; +} + +LxmfAdapter::LinkRelayEntry& LxmfAdapter::upsertLinkRelay( + const uint8_t link_id[reticulum::kTruncatedHashSize]) +{ + for (auto& relay : link_relays_) + { + if (hashesEqual(relay.link_id, link_id, sizeof(relay.link_id))) + { + return relay; + } + } + + if (link_relays_.size() >= kMaxLinkRelays) + { + link_relays_.erase(link_relays_.begin()); + } + + link_relays_.push_back(LinkRelayEntry{}); + LinkRelayEntry& relay = link_relays_.back(); + copyHash(relay.link_id, link_id, sizeof(relay.link_id)); + return relay; +} + +LxmfAdapter::LinkRelayEntry* LxmfAdapter::findLinkRelay( + const uint8_t link_id[reticulum::kTruncatedHashSize]) +{ + if (!link_id) + { + return nullptr; + } + for (auto& relay : link_relays_) + { + if (hashesEqual(relay.link_id, link_id, sizeof(relay.link_id))) + { + return &relay; + } + } + return nullptr; +} + +LxmfAdapter::PeerInfo* LxmfAdapter::findPeerByNodeId(NodeId node_id) +{ + for (auto& peer : peers_) + { + if (peer.node_id == node_id) + { + return &peer; + } + } + return nullptr; +} + +const LxmfAdapter::PeerInfo* LxmfAdapter::findPeerByDestinationHash( + const uint8_t hash[reticulum::kTruncatedHashSize]) const +{ + if (!hash) + { + return nullptr; + } + for (const auto& peer : peers_) + { + if (hashesEqual(peer.destination_hash, hash, reticulum::kTruncatedHashSize)) + { + return &peer; + } + } + return nullptr; +} + +LxmfAdapter::PeerInfo& LxmfAdapter::upsertPeer( + const uint8_t destination_hash[reticulum::kTruncatedHashSize]) +{ + for (auto& peer : peers_) + { + if (hashesEqual(peer.destination_hash, destination_hash, reticulum::kTruncatedHashSize)) + { + return peer; + } + } + + peers_.push_back(PeerInfo{}); + PeerInfo& peer = peers_.back(); + copyHash(peer.destination_hash, destination_hash, reticulum::kTruncatedHashSize); + peer.node_id = reticulum::nodeIdFromDestinationHash(destination_hash); + return peer; +} + +void LxmfAdapter::publishPeerUpdate(const PeerInfo& peer) const +{ + char short_name[10] = {}; + snprintf(short_name, sizeof(short_name), "%04lX", + static_cast(peer.node_id & 0xFFFFUL)); + + sys::EventBus::publish(new sys::NodeProtocolUpdateEvent( + peer.node_id, + peer.last_seen_s, + static_cast(chat::contacts::NodeProtocolType::LXMF)), 0); + + sys::EventBus::publish(new sys::NodeInfoUpdateEvent( + peer.node_id, + short_name, + peer.display_name[0] != '\0' ? peer.display_name : short_name, + raw_.lastRxSnr(), + raw_.lastRxRssi(), + peer.last_seen_s, + static_cast(chat::contacts::NodeProtocolType::LXMF), + static_cast(chat::contacts::NodeRoleType::Client), + 0, + 0, + 0xFF), 0); +} + +void LxmfAdapter::loadPersistedPeers() +{ + peers_loaded_ = true; + + std::vector blob; + chat::infra::PreferencesBlobMetadata meta; + if (!chat::infra::loadRawBlobFromPreferencesWithMetadata(kPeersPrefsNs, + kPeersPrefsKey, + kPeersPrefsVer, + kPeersPrefsCrc, + blob, + &meta)) + { + return; + } + + if (meta.len == 0) + { + if (meta.has_version || meta.has_crc) + { + chat::infra::clearPreferencesKeys(kPeersPrefsNs, + kPeersPrefsVer, + kPeersPrefsCrc); + } + return; + } + + const bool valid_blob = + (meta.len == blob.size()) && + (meta.len % sizeof(PersistedPeerRecord) == 0) && + meta.has_version && + (meta.version == kPeersPrefsVersion) && + meta.has_crc && + (meta.crc == fnv1a32(blob.data(), blob.size())); + + if (!valid_blob) + { + chat::infra::clearPreferencesKeys(kPeersPrefsNs, + kPeersPrefsKey, + kPeersPrefsVer, + kPeersPrefsCrc); + return; + } + + const size_t record_count = std::min(blob.size() / sizeof(PersistedPeerRecord), kMaxPersistedPeers); + for (size_t i = 0; i < record_count; ++i) + { + PersistedPeerRecord record{}; + memcpy(&record, blob.data() + (i * sizeof(PersistedPeerRecord)), sizeof(record)); + + if (isZeroBytes(record.destination_hash, sizeof(record.destination_hash)) || + isZeroBytes(record.identity_hash, sizeof(record.identity_hash)) || + isZeroBytes(record.enc_pub, sizeof(record.enc_pub)) || + isZeroBytes(record.sig_pub, sizeof(record.sig_pub))) + { + continue; + } + + PeerInfo& peer = upsertPeer(record.destination_hash); + copyHash(peer.identity_hash, record.identity_hash, sizeof(peer.identity_hash)); + memcpy(peer.enc_pub, record.enc_pub, sizeof(peer.enc_pub)); + memcpy(peer.sig_pub, record.sig_pub, sizeof(peer.sig_pub)); + peer.last_seen_s = record.last_seen_s; + peer.last_path_request_ms = 0; + copyCString(peer.display_name, sizeof(peer.display_name), record.display_name); + + if (peer.display_name[0] == '\0') + { + snprintf(peer.display_name, sizeof(peer.display_name), + "%08lX", static_cast(peer.node_id)); + } + + publishPeerUpdate(peer); + } +} + +bool LxmfAdapter::persistPeers() const +{ + std::vector ordered; + ordered.reserve(peers_.size()); + for (const auto& peer : peers_) + { + if (isZeroBytes(peer.destination_hash, sizeof(peer.destination_hash)) || + isZeroBytes(peer.identity_hash, sizeof(peer.identity_hash)) || + isZeroBytes(peer.enc_pub, sizeof(peer.enc_pub)) || + isZeroBytes(peer.sig_pub, sizeof(peer.sig_pub))) + { + continue; + } + + ordered.push_back(&peer); + } + + std::sort(ordered.begin(), ordered.end(), + [](const PeerInfo* a, const PeerInfo* b) + { + if (a->last_seen_s != b->last_seen_s) + { + return a->last_seen_s > b->last_seen_s; + } + return a->node_id < b->node_id; + }); + + if (ordered.size() > kMaxPersistedPeers) + { + ordered.resize(kMaxPersistedPeers); + } + + if (ordered.empty()) + { + return chat::infra::saveRawBlobToPreferencesWithMetadata(kPeersPrefsNs, + kPeersPrefsKey, + kPeersPrefsVer, + kPeersPrefsCrc, + nullptr, + 0, + nullptr, + true); + } + + std::vector blob(ordered.size() * sizeof(PersistedPeerRecord)); + for (size_t i = 0; i < ordered.size(); ++i) + { + PersistedPeerRecord record{}; + copyHash(record.destination_hash, + ordered[i]->destination_hash, + sizeof(record.destination_hash)); + copyHash(record.identity_hash, + ordered[i]->identity_hash, + sizeof(record.identity_hash)); + memcpy(record.enc_pub, ordered[i]->enc_pub, sizeof(record.enc_pub)); + memcpy(record.sig_pub, ordered[i]->sig_pub, sizeof(record.sig_pub)); + record.last_seen_s = ordered[i]->last_seen_s; + copyCString(record.display_name, sizeof(record.display_name), ordered[i]->display_name); + memcpy(blob.data() + (i * sizeof(PersistedPeerRecord)), &record, sizeof(record)); + } + + chat::infra::PreferencesBlobMetadata meta; + meta.len = blob.size(); + meta.has_version = true; + meta.version = kPeersPrefsVersion; + meta.has_crc = true; + meta.crc = fnv1a32(blob.data(), blob.size()); + + return chat::infra::saveRawBlobToPreferencesWithMetadata(kPeersPrefsNs, + kPeersPrefsKey, + kPeersPrefsVer, + kPeersPrefsCrc, + blob.data(), + blob.size(), + &meta, + true); +} + +uint32_t LxmfAdapter::currentTimestampSeconds() const +{ + const uint32_t epoch_s = now_epoch_seconds(); + if (is_valid_epoch(epoch_s)) + { + return epoch_s; + } + return millis() / 1000U; +} + +const char* LxmfAdapter::effectiveDisplayName() const +{ + if (!user_long_name_.empty()) + { + return user_long_name_.c_str(); + } + if (!user_short_name_.empty()) + { + return user_short_name_.c_str(); + } + return nullptr; +} + +uint32_t LxmfAdapter::messageIdFromHash(const uint8_t hash[reticulum::kFullHashSize]) +{ + return (static_cast(hash[28]) << 24) | + (static_cast(hash[29]) << 16) | + (static_cast(hash[30]) << 8) | + static_cast(hash[31]); +} + +void LxmfAdapter::pathRequestDestinationHash(uint8_t out_hash[reticulum::kTruncatedHashSize]) +{ + if (!out_hash) + { + return; + } + + uint8_t name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("rnstransport", "path.request", name_hash); + reticulum::computePlainDestinationHash(name_hash, out_hash); +} + +} // namespace chat::lxmf diff --git a/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp new file mode 100644 index 00000000..5d87521e --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/lxmf/lxmf_identity.cpp @@ -0,0 +1,235 @@ +/** + * @file lxmf_identity.cpp + * @brief Reticulum/LXMF identity persistence for ESP Arduino targets + */ + +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_identity.h" + +#include "../../internal/blob_store_io.h" +#include "chat/infra/meshcore/crypto/ed25519/ed_25519.h" + +#include +#include +#include + +#include +#include + +namespace chat::lxmf +{ +namespace +{ +constexpr const char* kPrefsNs = "lxmf_ident"; +constexpr const char* kEncPubKey = "enc_pub"; +constexpr const char* kEncPrivKey = "enc_priv"; +constexpr const char* kSigPubKey = "sig_pub"; +constexpr const char* kSigPrivKey = "sig_priv"; + +void fillRandomBytes(uint8_t* out, size_t len) +{ + if (!out || len == 0) + { + return; + } + + size_t offset = 0; + while (offset < len) + { + const uint32_t rnd = static_cast(esp_random()); + const size_t chunk = (len - offset >= sizeof(rnd)) ? sizeof(rnd) : (len - offset); + memcpy(out + offset, &rnd, chunk); + offset += chunk; + } +} + +bool isAllZero(const uint8_t* data, size_t len) +{ + if (!data) + { + return true; + } + for (size_t i = 0; i < len; ++i) + { + if (data[i] != 0) + { + return false; + } + } + return true; +} + +} // namespace + +bool LxmfIdentity::init() +{ + if (ready_) + { + return true; + } + if (loadFromPrefs()) + { + ready_ = true; + return true; + } + return generateAndPersist(); +} + +void LxmfIdentity::combinedPublicKey(uint8_t out_key[reticulum::kCombinedPublicKeySize]) const +{ + if (!out_key) + { + return; + } + memcpy(out_key, enc_pub_.data(), enc_pub_.size()); + memcpy(out_key + enc_pub_.size(), sig_pub_.data(), sig_pub_.size()); +} + +bool LxmfIdentity::sign(const uint8_t* message, size_t message_len, + uint8_t out_signature[kSignatureSize]) const +{ + if (!ready_ || !message || !out_signature) + { + return false; + } + + ed25519_sign(out_signature, message, message_len, sig_pub_.data(), sig_priv_.data()); + return true; +} + +bool LxmfIdentity::verify(const uint8_t sign_pub[kSigPubKeySize], + const uint8_t signature[kSignatureSize], + const uint8_t* message, size_t message_len) +{ + if (!sign_pub || !signature || !message) + { + return false; + } + return ed25519_verify(signature, message, message_len, sign_pub) != 0; +} + +bool LxmfIdentity::deriveSharedSecret(const uint8_t peer_public_key[kEncPubKeySize], + uint8_t out_secret[kEncPubKeySize]) const +{ + if (!ready_ || !peer_public_key || !out_secret) + { + return false; + } + + memcpy(out_secret, peer_public_key, kEncPubKeySize); + uint8_t local_priv[kEncPrivKeySize] = {}; + memcpy(local_priv, enc_priv_.data(), sizeof(local_priv)); + return Curve25519::dh2(out_secret, local_priv); +} + +bool LxmfIdentity::loadFromPrefs() +{ + std::vector blob; + if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kEncPubKey, blob) || + blob.size() != enc_pub_.size()) + { + return false; + } + memcpy(enc_pub_.data(), blob.data(), enc_pub_.size()); + + if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kEncPrivKey, blob) || + blob.size() != enc_priv_.size()) + { + return false; + } + memcpy(enc_priv_.data(), blob.data(), enc_priv_.size()); + + if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kSigPubKey, blob) || + blob.size() != sig_pub_.size()) + { + return false; + } + memcpy(sig_pub_.data(), blob.data(), sig_pub_.size()); + + if (!chat::infra::loadRawBlobFromPreferences(kPrefsNs, kSigPrivKey, blob) || + blob.size() != sig_priv_.size()) + { + return false; + } + memcpy(sig_priv_.data(), blob.data(), sig_priv_.size()); + + if (isAllZero(enc_pub_.data(), enc_pub_.size()) || + isAllZero(enc_priv_.data(), enc_priv_.size()) || + isAllZero(sig_pub_.data(), sig_pub_.size()) || + isAllZero(sig_priv_.data(), sig_priv_.size())) + { + return false; + } + + uint8_t derived_sig_pub[kSigPubKeySize] = {}; + ed25519_derive_pub(derived_sig_pub, sig_priv_.data()); + if (memcmp(derived_sig_pub, sig_pub_.data(), sizeof(derived_sig_pub)) != 0) + { + memcpy(sig_pub_.data(), derived_sig_pub, sizeof(derived_sig_pub)); + saveToPrefs(); + } + + recomputeDerivedFields(); + return true; +} + +bool LxmfIdentity::saveToPrefs() const +{ + const bool enc_pub_ok = chat::infra::saveRawBlobToPreferences( + kPrefsNs, kEncPubKey, enc_pub_.data(), enc_pub_.size()); + const bool enc_priv_ok = chat::infra::saveRawBlobToPreferences( + kPrefsNs, kEncPrivKey, enc_priv_.data(), enc_priv_.size()); + const bool sig_pub_ok = chat::infra::saveRawBlobToPreferences( + kPrefsNs, kSigPubKey, sig_pub_.data(), sig_pub_.size()); + const bool sig_priv_ok = chat::infra::saveRawBlobToPreferences( + kPrefsNs, kSigPrivKey, sig_priv_.data(), sig_priv_.size()); + return enc_pub_ok && enc_priv_ok && sig_pub_ok && sig_priv_ok; +} + +bool LxmfIdentity::generateAndPersist() +{ + RNG.begin("trail-mate-lxmf"); + + for (size_t attempt = 0; attempt < 16; ++attempt) + { + memset(enc_pub_.data(), 0, enc_pub_.size()); + memset(enc_priv_.data(), 0, enc_priv_.size()); + Curve25519::dh1(enc_pub_.data(), enc_priv_.data()); + if (!isAllZero(enc_priv_.data(), enc_priv_.size())) + { + break; + } + } + + if (isAllZero(enc_priv_.data(), enc_priv_.size())) + { + return false; + } + + uint8_t seed[32] = {}; + fillRandomBytes(seed, sizeof(seed)); + ed25519_create_keypair(sig_pub_.data(), sig_priv_.data(), seed); + memset(seed, 0, sizeof(seed)); + + if (isAllZero(sig_priv_.data(), sig_priv_.size()) || isAllZero(sig_pub_.data(), sig_pub_.size())) + { + return false; + } + + recomputeDerivedFields(); + ready_ = saveToPrefs(); + return ready_; +} + +void LxmfIdentity::recomputeDerivedFields() +{ + uint8_t combined[reticulum::kCombinedPublicKeySize] = {}; + combinedPublicKey(combined); + reticulum::computeIdentityHash(combined, identity_hash_.data()); + + uint8_t name_hash[reticulum::kNameHashSize] = {}; + reticulum::computeNameHash("lxmf", "delivery", name_hash); + reticulum::computeDestinationHash(name_hash, identity_hash_.data(), destination_hash_.data()); + node_id_ = reticulum::nodeIdFromDestinationHash(destination_hash_.data()); +} + +} // namespace chat::lxmf diff --git a/platform/esp/arduino_common/src/chat/infra/protocol_factory.cpp b/platform/esp/arduino_common/src/chat/infra/protocol_factory.cpp index 67e51340..dad01229 100644 --- a/platform/esp/arduino_common/src/chat/infra/protocol_factory.cpp +++ b/platform/esp/arduino_common/src/chat/infra/protocol_factory.cpp @@ -5,8 +5,10 @@ #include "platform/esp/arduino_common/chat/infra/protocol_factory.h" #include "board/LoraBoard.h" +#include "platform/esp/arduino_common/chat/infra/lxmf/lxmf_adapter.h" #include "platform/esp/arduino_common/chat/infra/meshcore/meshcore_adapter.h" #include "platform/esp/arduino_common/chat/infra/meshtastic/mt_adapter.h" +#include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" namespace chat { @@ -18,6 +20,10 @@ std::unique_ptr ProtocolFactory::createAdapter(MeshProtocol protoc { case MeshProtocol::MeshCore: return std::unique_ptr(new chat::meshcore::MeshCoreAdapter(board)); + case MeshProtocol::LXMF: + return std::unique_ptr(new chat::lxmf::LxmfAdapter(board)); + case MeshProtocol::RNode: + return std::unique_ptr(new chat::rnode::RNodeAdapter(board)); case MeshProtocol::Meshtastic: default: return std::unique_ptr(new chat::meshtastic::MtAdapter(board)); diff --git a/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp b/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp new file mode 100644 index 00000000..7edb80dd --- /dev/null +++ b/platform/esp/arduino_common/src/chat/infra/rnode/rnode_adapter.cpp @@ -0,0 +1,261 @@ +/** + * @file rnode_adapter.cpp + * @brief Minimal RNode raw-payload mesh adapter + */ + +#include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" + +#include "chat/time_utils.h" +#include +#include +#include +#include +#include + +namespace chat +{ +namespace rnode +{ + +namespace +{ +constexpr float kDefaultFrequencyMHz = 869.525f; +constexpr float kDefaultBandwidthKHz = 125.0f; +constexpr uint8_t kDefaultSpreadingFactor = 9; +constexpr uint8_t kDefaultCodingRate = 5; +constexpr int8_t kDefaultTxPowerDbm = 17; + +template +T clampValue(T value, T min_value, T max_value) +{ + if (value < min_value) + { + return min_value; + } + if (value > max_value) + { + return max_value; + } + return value; +} + +} // namespace + +RNodeAdapter::RNodeAdapter(LoraBoard& board) + : board_(board) +{ +} + +MeshCapabilities RNodeAdapter::getCapabilities() const +{ + return MeshCapabilities{}; +} + +bool RNodeAdapter::sendText(ChannelId channel, const std::string& text, + MessageId* out_msg_id, NodeId peer) +{ + (void)channel; + (void)text; + (void)peer; + if (out_msg_id) + { + *out_msg_id = 0; + } + return false; +} + +bool RNodeAdapter::pollIncomingText(MeshIncomingText* out) +{ + (void)out; + return false; +} + +bool RNodeAdapter::sendAppData(ChannelId channel, uint32_t portnum, + const uint8_t* payload, size_t len, + NodeId dest, bool want_ack, + MessageId packet_id, + bool want_response) +{ + (void)channel; + (void)dest; + (void)want_ack; + (void)want_response; + + // RNode air payloads are raw Reticulum/TNC bytes. We reserve port 0 + // for pass-through raw payload transmission and reject higher-level + // app-data semantics until a Reticulum-compatible upper layer exists. + if (!payload || len == 0 || portnum != 0 || !ready_ || !board_.isRadioOnline()) + { + return false; + } + + EncodedAirPacketSet air_packets{}; + const uint8_t sequence = static_cast(((packet_id != 0 ? packet_id : next_sequence_) & 0x0FU)); + next_sequence_ = static_cast((sequence + 1U) & 0x0FU); + if (!encodeAirPacketSet(payload, len, sequence, &air_packets)) + { + return false; + } + + const int first_state = board_.transmitRadio(air_packets.first, air_packets.first_len); + if (first_state != RADIOLIB_ERR_NONE) + { + startRadioReceive(); + return false; + } + + if (air_packets.count > 1U) + { + const int second_state = board_.transmitRadio(air_packets.second, air_packets.second_len); + if (second_state != RADIOLIB_ERR_NONE) + { + startRadioReceive(); + return false; + } + } + + startRadioReceive(); + return true; +} + +bool RNodeAdapter::pollIncomingData(MeshIncomingData* out) +{ + if (!out || app_receive_queue_.empty()) + { + return false; + } + + *out = std::move(app_receive_queue_.front()); + app_receive_queue_.pop(); + return true; +} + +void RNodeAdapter::applyConfig(const MeshConfig& config) +{ + config_ = config; + + const float freq_mhz = + (config_.override_frequency_mhz > 0.0f) ? config_.override_frequency_mhz : kDefaultFrequencyMHz; + const float bw_khz = + (config_.bandwidth_khz > 0.0f) ? config_.bandwidth_khz : kDefaultBandwidthKHz; + const uint8_t sf = + clampValue(config_.spread_factor != 0 ? config_.spread_factor : kDefaultSpreadingFactor, 5U, 12U); + const uint8_t cr = + clampValue(config_.coding_rate != 0 ? config_.coding_rate : kDefaultCodingRate, 5U, 8U); + const int8_t tx_power = + clampValue(config_.tx_power != 0 ? config_.tx_power : kDefaultTxPowerDbm, -9, 22); + + radio_freq_hz_ = static_cast(std::lround(freq_mhz * 1000000.0f)); + radio_bw_hz_ = static_cast(std::lround(bw_khz * 1000.0f)); + radio_sf_ = sf; + radio_cr_ = cr; + + const uint16_t preamble = + chat::rnode::recommendPreambleSymbols(radio_bw_hz_, radio_sf_, radio_cr_); + + board_.configureLoraRadio(freq_mhz, bw_khz, sf, cr, tx_power, preamble, kSyncWord, kCrcLen); + ready_ = true; + startRadioReceive(); +} + +void RNodeAdapter::setLastRxStats(float rssi, float snr) +{ + last_rx_rssi_ = rssi; + last_rx_snr_ = snr; +} + +bool RNodeAdapter::isReady() const +{ + return ready_ && board_.isRadioOnline(); +} + +bool RNodeAdapter::pollIncomingRawPacket(uint8_t* out_data, size_t& out_len, size_t max_len) +{ + if (!has_pending_raw_packet_ || !out_data || max_len == 0) + { + return false; + } + + const size_t copy_len = std::min(last_raw_packet_.len, max_len); + memcpy(out_data, last_raw_packet_.data, copy_len); + out_len = copy_len; + has_pending_raw_packet_ = false; + return true; +} + +void RNodeAdapter::handleRawPacket(const uint8_t* data, size_t size) +{ + if (!data || size == 0) + { + return; + } + + uint8_t payload[chat::rnode::kRNodeMaxPayloadSize] = {}; + size_t payload_len = sizeof(payload); + bool complete = false; + if (!feedAirPacket(&reassembly_, data, size, payload, &payload_len, &complete) || !complete) + { + return; + } + + memcpy(last_raw_packet_.data, payload, payload_len); + last_raw_packet_.len = payload_len; + has_pending_raw_packet_ = true; + enqueueIncomingData(payload, payload_len); +} + +void RNodeAdapter::startRadioReceive() +{ + if (!board_.isRadioOnline()) + { + return; + } + (void)board_.startRadioReceive(); +} + +void RNodeAdapter::enqueueIncomingData(const uint8_t* payload, size_t len) +{ + if (!payload || len == 0) + { + return; + } + + MeshIncomingData incoming; + incoming.portnum = 0; + incoming.from = 0; + incoming.to = 0; + incoming.packet_id = now_message_timestamp(); + incoming.request_id = 0; + incoming.channel = ChannelId::PRIMARY; + incoming.channel_hash = 0xFF; + incoming.hop_limit = 0xFF; + incoming.want_response = false; + incoming.payload.assign(payload, payload + len); + + incoming.rx_meta.rx_timestamp_ms = millis(); + const uint32_t epoch_s = now_epoch_seconds(); + if (is_valid_epoch(epoch_s)) + { + incoming.rx_meta.rx_timestamp_s = epoch_s; + incoming.rx_meta.time_source = RxTimeSource::DeviceUtc; + } + else + { + incoming.rx_meta.rx_timestamp_s = incoming.rx_meta.rx_timestamp_ms / 1000U; + incoming.rx_meta.time_source = RxTimeSource::Uptime; + } + incoming.rx_meta.origin = RxOrigin::Mesh; + incoming.rx_meta.direct = true; + incoming.rx_meta.from_is = false; + incoming.rx_meta.rssi_dbm_x10 = static_cast(std::lround(last_rx_rssi_ * 10.0f)); + incoming.rx_meta.snr_db_x10 = static_cast(std::lround(last_rx_snr_ * 10.0f)); + incoming.rx_meta.freq_hz = radio_freq_hz_; + incoming.rx_meta.bw_hz = radio_bw_hz_; + incoming.rx_meta.sf = radio_sf_; + incoming.rx_meta.cr = radio_cr_; + + app_receive_queue_.push(std::move(incoming)); +} + +} // namespace rnode +} // namespace chat diff --git a/platform/esp/arduino_common/src/platform_ui_hostlink_runtime.cpp b/platform/esp/arduino_common/src/platform_ui_hostlink_runtime.cpp index 941da9a5..ba2289ea 100644 --- a/platform/esp/arduino_common/src/platform_ui_hostlink_runtime.cpp +++ b/platform/esp/arduino_common/src/platform_ui_hostlink_runtime.cpp @@ -1,9 +1,22 @@ #include "platform/ui/hostlink_runtime.h" +#include "app/app_config.h" #include "platform/esp/arduino_common/hostlink/hostlink_service.h" +#include "platform/esp/arduino_common/rnode_kiss/rnode_kiss_service.h" + +#include "app/app_facade_access.h" namespace platform::ui::hostlink { +namespace +{ + +bool use_rnode_bridge() +{ + return app::appFacade().getConfig().mesh_protocol == chat::MeshProtocol::RNode; +} + +} // namespace bool is_supported() { @@ -12,21 +25,39 @@ bool is_supported() void start() { + if (use_rnode_bridge()) + { + ::rnode_kiss::start(); + return; + } ::hostlink::start(); } void stop() { + if (use_rnode_bridge()) + { + ::rnode_kiss::stop(); + return; + } ::hostlink::stop(); } bool is_active() { + if (use_rnode_bridge()) + { + return ::rnode_kiss::is_active(); + } return ::hostlink::is_active(); } Status get_status() { + if (use_rnode_bridge()) + { + return ::rnode_kiss::get_status(); + } return ::hostlink::get_status(); } diff --git a/platform/esp/arduino_common/src/rnode_kiss/rnode_kiss_service.cpp b/platform/esp/arduino_common/src/rnode_kiss/rnode_kiss_service.cpp new file mode 100644 index 00000000..e710fc4a --- /dev/null +++ b/platform/esp/arduino_common/src/rnode_kiss/rnode_kiss_service.cpp @@ -0,0 +1,642 @@ +#include "platform/esp/arduino_common/rnode_kiss/rnode_kiss_service.h" + +#include "app/app_config.h" +#include "app/app_facade_access.h" +#include "app/app_facades.h" +#include "hostlink/hostlink_session.h" +#include "platform/esp/arduino_common/chat/infra/rnode/rnode_adapter.h" +#include "usb/usb_cdc_transport.h" + +#include "esp_system.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include +#include +#include + +namespace rnode_kiss +{ +namespace +{ +constexpr uint8_t kFend = 0xC0; +constexpr uint8_t kFesc = 0xDB; +constexpr uint8_t kTfend = 0xDC; +constexpr uint8_t kTfesc = 0xDD; + +constexpr uint8_t kCmdData = 0x00; +constexpr uint8_t kCmdFrequency = 0x01; +constexpr uint8_t kCmdBandwidth = 0x02; +constexpr uint8_t kCmdTxPower = 0x03; +constexpr uint8_t kCmdSf = 0x04; +constexpr uint8_t kCmdCr = 0x05; +constexpr uint8_t kCmdRadioState = 0x06; +constexpr uint8_t kCmdDetect = 0x08; +constexpr uint8_t kCmdLeave = 0x0A; +constexpr uint8_t kCmdStALock = 0x0B; +constexpr uint8_t kCmdLtALock = 0x0C; +constexpr uint8_t kCmdReady = 0x0F; +constexpr uint8_t kCmdStatRx = 0x21; +constexpr uint8_t kCmdStatTx = 0x22; +constexpr uint8_t kCmdStatRssi = 0x23; +constexpr uint8_t kCmdStatSnr = 0x24; +constexpr uint8_t kCmdRandom = 0x40; +constexpr uint8_t kCmdFbExt = 0x41; +constexpr uint8_t kCmdBoard = 0x47; +constexpr uint8_t kCmdPlatform = 0x48; +constexpr uint8_t kCmdMcu = 0x49; +constexpr uint8_t kCmdFwVersion = 0x50; +constexpr uint8_t kCmdError = 0x90; + +constexpr uint8_t kDetectReq = 0x73; +constexpr uint8_t kDetectResp = 0x46; + +constexpr uint8_t kRadioStateOff = 0x00; +constexpr uint8_t kRadioStateOn = 0x01; +constexpr uint8_t kRadioStateAsk = 0xFF; + +constexpr uint8_t kPlatformEsp32 = 0x80; +constexpr uint8_t kMcuEsp32 = 0x81; +constexpr uint8_t kBoardGenericEsp32 = 0x35; + +constexpr uint8_t kErrorTxFailed = 0x02; +constexpr uint8_t kFwVersionMajor = 1; +constexpr uint8_t kFwVersionMinor = 52; +constexpr uint32_t kTaskPollMs = 20; +constexpr size_t kMaxFrameSize = 600; +constexpr size_t kUsbReadChunk = 96; + +struct KissParser +{ + bool in_frame = false; + bool escape = false; + uint8_t buffer[kMaxFrameSize] = {}; + size_t length = 0; + + void reset() + { + in_frame = false; + escape = false; + length = 0; + } +}; + +TaskHandle_t s_task = nullptr; +volatile bool s_stop = false; +hostlink::SessionRuntime s_session{}; +uint8_t s_radio_state = kRadioStateOn; +uint16_t s_short_airtime_limit = 0; +uint16_t s_long_airtime_limit = 0; +bool s_host_seen = false; +uint32_t s_radio_rx_count = 0; +uint32_t s_radio_tx_count = 0; + +void set_state(hostlink::LinkState state) +{ + hostlink::set_link_state(s_session, state); +} + +chat::rnode::RNodeAdapter* get_backend() +{ + chat::IMeshAdapter* mesh = app::messagingFacade().getMeshAdapter(); + if (!mesh) + { + return nullptr; + } + + chat::IMeshAdapter* backend = mesh->backendForProtocol(chat::MeshProtocol::RNode); + return backend ? static_cast(backend) : nullptr; +} + +bool apply_live_config() +{ + chat::rnode::RNodeAdapter* backend = get_backend(); + if (!backend) + { + return false; + } + + backend->applyConfig(app::appFacade().getConfig().rnode_config); + return true; +} + +void note_rx() +{ + hostlink::note_rx(s_session); +} + +void note_tx() +{ + hostlink::note_tx(s_session); +} + +void note_error(uint32_t code) +{ + hostlink::note_error(s_session, code); + set_state(hostlink::LinkState::Error); +} + +void write_escaped(std::vector& frame, const uint8_t* data, size_t len) +{ + if (!data || len == 0) + { + return; + } + + for (size_t i = 0; i < len; ++i) + { + const uint8_t byte = data[i]; + if (byte == kFend) + { + frame.push_back(kFesc); + frame.push_back(kTfend); + } + else if (byte == kFesc) + { + frame.push_back(kFesc); + frame.push_back(kTfesc); + } + else + { + frame.push_back(byte); + } + } +} + +bool send_frame(uint8_t command, const uint8_t* payload, size_t len) +{ + if (!usb_cdc::get_status().started) + { + return false; + } + + std::vector frame; + frame.reserve(len + 4); + frame.push_back(kFend); + frame.push_back(command); + write_escaped(frame, payload, len); + frame.push_back(kFend); + + if (usb_cdc::write(frame.data(), frame.size()) != frame.size()) + { + note_error(kErrorTxFailed); + return false; + } + + note_tx(); + return true; +} + +bool send_u32(uint8_t command, uint32_t value) +{ + const uint8_t payload[4] = { + static_cast((value >> 24) & 0xFF), + static_cast((value >> 16) & 0xFF), + static_cast((value >> 8) & 0xFF), + static_cast(value & 0xFF), + }; + return send_frame(command, payload, sizeof(payload)); +} + +bool send_u16(uint8_t command, uint16_t value) +{ + const uint8_t payload[2] = { + static_cast((value >> 8) & 0xFF), + static_cast(value & 0xFF), + }; + return send_frame(command, payload, sizeof(payload)); +} + +bool send_u8(uint8_t command, uint8_t value) +{ + return send_frame(command, &value, 1); +} + +int clamp_int(int value, int min_value, int max_value) +{ + if (value < min_value) + { + return min_value; + } + if (value > max_value) + { + return max_value; + } + return value; +} + +void send_ready() +{ + const uint8_t ready = 0x01; + (void)send_frame(kCmdReady, &ready, 1); +} + +void reply_current_config(uint8_t command) +{ + const chat::MeshConfig& cfg = app::appFacade().getConfig().rnode_config; + switch (command) + { + case kCmdFrequency: + (void)send_u32(kCmdFrequency, + static_cast(std::lround(cfg.override_frequency_mhz * 1000000.0f))); + break; + case kCmdBandwidth: + (void)send_u32(kCmdBandwidth, + static_cast(std::lround(cfg.bandwidth_khz * 1000.0f))); + break; + case kCmdTxPower: + (void)send_u8(kCmdTxPower, static_cast(cfg.tx_power)); + break; + case kCmdSf: + (void)send_u8(kCmdSf, cfg.spread_factor); + break; + case kCmdCr: + (void)send_u8(kCmdCr, cfg.coding_rate); + break; + case kCmdRadioState: + (void)send_u8(kCmdRadioState, s_radio_state); + break; + case kCmdStALock: + (void)send_u16(kCmdStALock, s_short_airtime_limit); + break; + case kCmdLtALock: + (void)send_u16(kCmdLtALock, s_long_airtime_limit); + break; + case kCmdFwVersion: + { + const uint8_t version[2] = {kFwVersionMajor, kFwVersionMinor}; + (void)send_frame(kCmdFwVersion, version, sizeof(version)); + break; + } + case kCmdPlatform: + (void)send_u8(kCmdPlatform, kPlatformEsp32); + break; + case kCmdMcu: + (void)send_u8(kCmdMcu, kMcuEsp32); + break; + case kCmdBoard: + (void)send_u8(kCmdBoard, kBoardGenericEsp32); + break; + case kCmdStatRx: + (void)send_u32(kCmdStatRx, s_radio_rx_count); + break; + case kCmdStatTx: + (void)send_u32(kCmdStatTx, s_radio_tx_count); + break; + case kCmdRandom: + (void)send_u8(kCmdRandom, static_cast(esp_random() & 0xFF)); + break; + default: + break; + } +} + +uint32_t decode_u32(const uint8_t* payload, size_t len) +{ + if (!payload || len < 4) + { + return 0; + } + return (static_cast(payload[0]) << 24) | + (static_cast(payload[1]) << 16) | + (static_cast(payload[2]) << 8) | + static_cast(payload[3]); +} + +void send_last_rx_stats(chat::rnode::RNodeAdapter& backend) +{ + const float rssi = backend.lastRxRssi(); + const float snr = backend.lastRxSnr(); + + int rssi_encoded = static_cast(std::lround(rssi + 157.0f)); + rssi_encoded = clamp_int(rssi_encoded, 0, 255); + + int snr_encoded = static_cast(std::lround(snr * 4.0f)); + snr_encoded = clamp_int(snr_encoded, -128, 127); + + (void)send_u8(kCmdStatRssi, static_cast(rssi_encoded)); + (void)send_u8(kCmdStatSnr, static_cast(static_cast(snr_encoded))); +} + +void process_command(uint8_t command, const uint8_t* payload, size_t len) +{ + s_host_seen = true; + if (s_session.status.state != hostlink::LinkState::Error) + { + set_state(hostlink::LinkState::Ready); + } + note_rx(); + + app::IAppFacade& app_ctx = app::appFacade(); + chat::MeshConfig& cfg = app_ctx.getConfig().rnode_config; + chat::rnode::RNodeAdapter* backend = get_backend(); + + switch (command) + { + case kCmdDetect: + if (len == 0 || payload[0] == kDetectReq) + { + (void)send_u8(kCmdDetect, kDetectResp); + } + break; + case kCmdFwVersion: + case kCmdPlatform: + case kCmdMcu: + case kCmdBoard: + case kCmdStatRx: + case kCmdStatTx: + case kCmdRandom: + reply_current_config(command); + break; + case kCmdFrequency: + if (len >= 4) + { + cfg.override_frequency_mhz = static_cast(decode_u32(payload, len)) / 1000000.0f; + if (s_radio_state == kRadioStateOn) + { + (void)apply_live_config(); + } + } + reply_current_config(kCmdFrequency); + break; + case kCmdBandwidth: + if (len >= 4) + { + cfg.bandwidth_khz = static_cast(decode_u32(payload, len)) / 1000.0f; + if (s_radio_state == kRadioStateOn) + { + (void)apply_live_config(); + } + } + reply_current_config(kCmdBandwidth); + break; + case kCmdTxPower: + if (len >= 1) + { + cfg.tx_power = static_cast(payload[0]); + if (s_radio_state == kRadioStateOn) + { + (void)apply_live_config(); + } + } + reply_current_config(kCmdTxPower); + break; + case kCmdSf: + if (len >= 1) + { + cfg.spread_factor = payload[0]; + if (s_radio_state == kRadioStateOn) + { + (void)apply_live_config(); + } + } + reply_current_config(kCmdSf); + break; + case kCmdCr: + if (len >= 1) + { + cfg.coding_rate = payload[0]; + if (s_radio_state == kRadioStateOn) + { + (void)apply_live_config(); + } + } + reply_current_config(kCmdCr); + break; + case kCmdStALock: + if (len >= 2) + { + s_short_airtime_limit = static_cast((payload[0] << 8) | payload[1]); + } + reply_current_config(kCmdStALock); + break; + case kCmdLtALock: + if (len >= 2) + { + s_long_airtime_limit = static_cast((payload[0] << 8) | payload[1]); + } + reply_current_config(kCmdLtALock); + break; + case kCmdRadioState: + if (len >= 1 && payload[0] != kRadioStateAsk) + { + s_radio_state = (payload[0] == kRadioStateOff) ? kRadioStateOff : kRadioStateOn; + if (s_radio_state == kRadioStateOn) + { + (void)apply_live_config(); + } + } + reply_current_config(kCmdRadioState); + send_ready(); + break; + case kCmdFbExt: + send_ready(); + break; + case kCmdLeave: + s_host_seen = false; + if (s_session.status.state != hostlink::LinkState::Error) + { + set_state(hostlink::LinkState::Connected); + } + break; + case kCmdData: + if (s_radio_state != kRadioStateOn || !backend || !payload || len == 0) + { + (void)send_u8(kCmdError, kErrorTxFailed); + break; + } + if (backend->sendAppData(chat::ChannelId::PRIMARY, 0, payload, len)) + { + s_radio_tx_count++; + (void)send_u32(kCmdStatTx, s_radio_tx_count); + send_ready(); + } + else + { + (void)send_u8(kCmdError, kErrorTxFailed); + } + break; + default: + break; + } +} + +void feed_parser(KissParser& parser, uint8_t byte) +{ + if (byte == kFend) + { + if (parser.in_frame && parser.length > 0) + { + const uint8_t command = parser.buffer[0]; + const uint8_t* payload = (parser.length > 1) ? &parser.buffer[1] : nullptr; + const size_t payload_len = (parser.length > 1) ? (parser.length - 1) : 0; + process_command(command, payload, payload_len); + } + + parser.in_frame = true; + parser.escape = false; + parser.length = 0; + return; + } + + if (!parser.in_frame) + { + return; + } + + if (parser.escape) + { + if (byte == kTfend) + { + byte = kFend; + } + else if (byte == kTfesc) + { + byte = kFesc; + } + parser.escape = false; + } + else if (byte == kFesc) + { + parser.escape = true; + return; + } + + if (parser.length < sizeof(parser.buffer)) + { + parser.buffer[parser.length++] = byte; + } +} + +void pump_host_rx(KissParser& parser) +{ + uint8_t buffer[kUsbReadChunk] = {}; + const size_t len = usb_cdc::read(buffer, sizeof(buffer)); + for (size_t i = 0; i < len; ++i) + { + feed_parser(parser, buffer[i]); + } +} + +void pump_radio_rx() +{ + if (s_radio_state != kRadioStateOn) + { + return; + } + + chat::rnode::RNodeAdapter* backend = get_backend(); + if (!backend) + { + return; + } + + uint8_t packet[chat::rnode::kRNodeMaxPayloadSize] = {}; + size_t packet_len = 0; + if (!backend->pollIncomingRawPacket(packet, packet_len, sizeof(packet)) || packet_len == 0) + { + return; + } + + send_last_rx_stats(*backend); + if (send_frame(kCmdData, packet, packet_len)) + { + s_radio_rx_count++; + (void)send_u32(kCmdStatRx, s_radio_rx_count); + } +} + +void reset_runtime() +{ + hostlink::reset_session(s_session, 0); + s_radio_state = kRadioStateOn; + s_short_airtime_limit = 0; + s_long_airtime_limit = 0; + s_host_seen = false; + s_radio_rx_count = 0; + s_radio_tx_count = 0; +} + +void rnode_task(void* /*arg*/) +{ + KissParser parser{}; + reset_runtime(); + set_state(hostlink::LinkState::Waiting); + (void)usb_cdc::start(); + + while (!s_stop) + { + if (!usb_cdc::is_connected()) + { + parser.reset(); + s_host_seen = false; + if (s_session.status.state != hostlink::LinkState::Waiting) + { + set_state(hostlink::LinkState::Waiting); + } + vTaskDelay(pdMS_TO_TICKS(kTaskPollMs)); + continue; + } + + if (!s_host_seen && s_session.status.state != hostlink::LinkState::Connected) + { + set_state(hostlink::LinkState::Connected); + } + + pump_host_rx(parser); + pump_radio_rx(); + vTaskDelay(pdMS_TO_TICKS(kTaskPollMs)); + } + + hostlink::stop_session(s_session); + usb_cdc::stop(); + s_task = nullptr; + vTaskDelete(nullptr); +} + +} // namespace + +void start() +{ + if (s_task != nullptr) + { + return; + } + + s_stop = false; + xTaskCreate(rnode_task, "rnode_kiss", 6 * 1024, nullptr, 5, &s_task); +} + +void stop() +{ + if (s_task == nullptr) + { + return; + } + + s_stop = true; + for (int attempts = 0; attempts < 25 && s_task != nullptr; ++attempts) + { + vTaskDelay(pdMS_TO_TICKS(kTaskPollMs)); + } + + if (s_task != nullptr) + { + vTaskDelete(s_task); + s_task = nullptr; + hostlink::stop_session(s_session); + usb_cdc::stop(); + } +} + +bool is_active() +{ + return s_task != nullptr; +} + +hostlink::Status get_status() +{ + return s_session.status; +} + +} // namespace rnode_kiss