WIP: rescue misbranched LXMF and RNode work

This commit is contained in:
liu weikai
2026-04-17 10:03:08 +08:00
parent 86f71bf705
commit d636eb6398
29 changed files with 6048 additions and 50 deletions
@@ -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();
+298
View File
@@ -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
@@ -41,7 +41,9 @@ using MessageId = uint32_t;
enum class MeshProtocol : uint8_t
{
Meshtastic = 1,
MeshCore = 2
MeshCore = 2,
RNode = 3,
LXMF = 4
};
/**
@@ -21,7 +21,9 @@ enum class NodeProtocolType : uint8_t
{
Unknown = 0,
Meshtastic = 1,
MeshCore = 2
MeshCore = 2,
RNode = 3,
LXMF = 4
};
/**
@@ -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 <cstddef>
#include <cstdint>
#include <string>
#include <vector>
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<uint8_t> packed_payload;
bool has_stamp = false;
std::vector<uint8_t> 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
@@ -0,0 +1,168 @@
/**
* @file reticulum_wire.h
* @brief Shared Reticulum packet and token helpers for device-side subsets
*/
#pragma once
#include <cstddef>
#include <cstdint>
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
@@ -0,0 +1,68 @@
/**
* @file rnode_packet_wire.h
* @brief Shared RNode over-air packet framing helpers
*/
#pragma once
#include <cstddef>
#include <cstdint>
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
@@ -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 <algorithm>
#include <cstring>
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<uint8_t>(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<uint8_t>(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<uint8_t>(value), out, out_len, used);
}
if (value <= 0xFFU)
{
return appendByte(0xCC, out, out_len, used) &&
appendByte(static_cast<uint8_t>(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<uint8_t>(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<uint8_t>((len >> 8) & 0xFFU), out, out_len, used) &&
appendByte(static_cast<uint8_t>(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<size_t>(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<size_t>(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<uint8_t>* 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<size_t>((static_cast<uint16_t>(hi) << 8) | lo);
}
else if ((tag & 0xE0U) == 0xA0U)
{
len = static_cast<size_t>(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<uint8_t> 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<const uint8_t*>(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<uint8_t> 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<uint8_t>(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<const uint8_t*>(title ? title : "");
const size_t title_len = (title != nullptr) ? strlen(title) : 0;
const uint8_t* content_bytes = reinterpret_cast<const uint8_t*>(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<uint8_t> title_bytes;
std::vector<uint8_t> 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
@@ -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";
@@ -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 <AES.h>
#include <Crypto.h>
#include <SHA256.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstring>
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<uint8_t>(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<uint8_t>((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<const uint8_t*>(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<uint8_t>(raw_packet[0] & 0x0FU);
const uint8_t header_type = static_cast<uint8_t>((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<uint32_t>(destination_hash[12]) << 24) |
(static_cast<uint32_t>(destination_hash[13]) << 16) |
(static_cast<uint32_t>(destination_hash[14]) << 8) |
static_cast<uint32_t>(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<uint8_t>((parsed.raw_flags >> 6) & 0x01U);
if (parsed.header_type != kHeaderType1 && parsed.header_type != kHeaderType2)
{
return false;
}
parsed.context_flag = static_cast<uint8_t>((parsed.raw_flags >> 5) & 0x01U);
parsed.transport_type = static_cast<TransportType>((parsed.raw_flags >> 4) & 0x01U);
parsed.destination_type = static_cast<DestinationType>((parsed.raw_flags >> 2) & 0x03U);
parsed.packet_type = static_cast<PacketType>(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<uint8_t>((0U << 6) |
((context_flag ? 1U : 0U) << 5) |
((static_cast<uint8_t>(transport_type) & 0x01U) << 4) |
((static_cast<uint8_t>(destination_type) & 0x03U) << 2) |
(static_cast<uint8_t>(packet_type) & 0x03U));
out_packet[0] = flags;
out_packet[1] = hops;
memcpy(out_packet + 2, destination_hash, kTruncatedHashSize);
out_packet[2 + kTruncatedHashSize] = static_cast<uint8_t>(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<uint8_t>((1U << 6) |
((context_flag ? 1U : 0U) << 5) |
((static_cast<uint8_t>(transport_type) & 0x01U) << 4) |
((static_cast<uint8_t>(destination_type) & 0x03U) << 2) |
(static_cast<uint8_t>(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<uint8_t>(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
@@ -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 <algorithm>
#include <cmath>
#include <cstring>
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 <typename T>
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<uint8_t>(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<uint8_t>((sequence & 0x0FU) << 4);
const bool split = payload_len > kRNodeFragmentPayloadSize;
out->header = static_cast<uint8_t>(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<float>(spreading_factor);
const float cr = static_cast<float>(coding_rate);
const float bw_khz = static_cast<float>(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<uint32_t>(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<float>(bandwidth_hz) / std::pow(2.0f, static_cast<float>(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<uint16_t>(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<uint16_t>(static_cast<uint16_t>(preamble_symbols), 18U, 255U);
}
} // namespace rnode
} // namespace chat
+42 -2
View File
@@ -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;
}
}
};
@@ -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_);
}
}
@@ -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<unsigned>(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);
}
@@ -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
@@ -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);
@@ -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<chat::MeshProtocol>(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<int>(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<int>(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<int>(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<float>(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<float>(payload->value);
}
else
{
app_ctx.getConfig().meshtastic_config.bandwidth_khz = static_cast<float>(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<uint8_t>(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<uint8_t>(payload->value);
}
else
{
app_ctx.getConfig().meshtastic_config.spread_factor = static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(payload->value);
}
else
{
app_ctx.getConfig().meshtastic_config.coding_rate = static_cast<uint8_t>(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<int8_t>(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<int8_t>(payload->value);
}
else
{
app_ctx.getConfig().meshtastic_config.tx_power = static_cast<int8_t>(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<int>(chat::MeshProtocol::Meshtastic)},
{"MeshCore", static_cast<int>(chat::MeshProtocol::MeshCore)},
{"LXMF", static_cast<int>(chat::MeshProtocol::LXMF)},
{"RNode Bridge", static_cast<int>(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<int>(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;
@@ -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 <queue>
#include <vector>
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<MeshIncomingText> text_receive_queue_;
std::vector<PeerInfo> peers_;
std::vector<PathEntry> paths_;
std::vector<PacketFilterEntry> packet_filter_;
std::vector<ReverseEntry> reverse_table_;
std::vector<LinkRelayEntry> 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
@@ -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 <array>
#include <cstddef>
#include <cstdint>
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<uint8_t, kEncPubKeySize> enc_pub_ = {};
std::array<uint8_t, kEncPrivKeySize> enc_priv_ = {};
std::array<uint8_t, kSigPubKeySize> sig_pub_ = {};
std::array<uint8_t, kSigPrivKeySize> sig_priv_ = {};
std::array<uint8_t, reticulum::kTruncatedHashSize> identity_hash_ = {};
std::array<uint8_t, reticulum::kTruncatedHashSize> destination_hash_ = {};
};
} // namespace chat::lxmf
@@ -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 <queue>
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<MeshIncomingData> app_receive_queue_;
void startRadioReceive();
void enqueueIncomingData(const uint8_t* payload, size_t len);
};
} // namespace rnode
} // namespace chat
@@ -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
@@ -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<uint8_t>(mesh_protocol));
@@ -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);
File diff suppressed because it is too large Load Diff
@@ -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 <Arduino.h>
#include <Curve25519.h>
#include <RNG.h>
#include <cstring>
#include <vector>
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<uint32_t>(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<uint8_t> 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
@@ -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<IMeshAdapter> ProtocolFactory::createAdapter(MeshProtocol protoc
{
case MeshProtocol::MeshCore:
return std::unique_ptr<IMeshAdapter>(new chat::meshcore::MeshCoreAdapter(board));
case MeshProtocol::LXMF:
return std::unique_ptr<IMeshAdapter>(new chat::lxmf::LxmfAdapter(board));
case MeshProtocol::RNode:
return std::unique_ptr<IMeshAdapter>(new chat::rnode::RNodeAdapter(board));
case MeshProtocol::Meshtastic:
default:
return std::unique_ptr<IMeshAdapter>(new chat::meshtastic::MtAdapter(board));
@@ -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 <Arduino.h>
#include <RadioLib.h>
#include <algorithm>
#include <cmath>
#include <cstring>
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 <typename T>
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<uint8_t>(((packet_id != 0 ? packet_id : next_sequence_) & 0x0FU));
next_sequence_ = static_cast<uint8_t>((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<uint8_t>(config_.spread_factor != 0 ? config_.spread_factor : kDefaultSpreadingFactor, 5U, 12U);
const uint8_t cr =
clampValue<uint8_t>(config_.coding_rate != 0 ? config_.coding_rate : kDefaultCodingRate, 5U, 8U);
const int8_t tx_power =
clampValue<int8_t>(config_.tx_power != 0 ? config_.tx_power : kDefaultTxPowerDbm, -9, 22);
radio_freq_hz_ = static_cast<uint32_t>(std::lround(freq_mhz * 1000000.0f));
radio_bw_hz_ = static_cast<uint32_t>(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<int16_t>(std::lround(last_rx_rssi_ * 10.0f));
incoming.rx_meta.snr_db_x10 = static_cast<int16_t>(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
@@ -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();
}
@@ -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 <algorithm>
#include <cmath>
#include <vector>
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<chat::rnode::RNodeAdapter*>(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<uint8_t>& 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<uint8_t> 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<uint8_t>((value >> 24) & 0xFF),
static_cast<uint8_t>((value >> 16) & 0xFF),
static_cast<uint8_t>((value >> 8) & 0xFF),
static_cast<uint8_t>(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<uint8_t>((value >> 8) & 0xFF),
static_cast<uint8_t>(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<uint32_t>(std::lround(cfg.override_frequency_mhz * 1000000.0f)));
break;
case kCmdBandwidth:
(void)send_u32(kCmdBandwidth,
static_cast<uint32_t>(std::lround(cfg.bandwidth_khz * 1000.0f)));
break;
case kCmdTxPower:
(void)send_u8(kCmdTxPower, static_cast<uint8_t>(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<uint8_t>(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<uint32_t>(payload[0]) << 24) |
(static_cast<uint32_t>(payload[1]) << 16) |
(static_cast<uint32_t>(payload[2]) << 8) |
static_cast<uint32_t>(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<int>(std::lround(rssi + 157.0f));
rssi_encoded = clamp_int(rssi_encoded, 0, 255);
int snr_encoded = static_cast<int>(std::lround(snr * 4.0f));
snr_encoded = clamp_int(snr_encoded, -128, 127);
(void)send_u8(kCmdStatRssi, static_cast<uint8_t>(rssi_encoded));
(void)send_u8(kCmdStatSnr, static_cast<uint8_t>(static_cast<int8_t>(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<float>(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<float>(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<int8_t>(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<uint16_t>((payload[0] << 8) | payload[1]);
}
reply_current_config(kCmdStALock);
break;
case kCmdLtALock:
if (len >= 2)
{
s_long_airtime_limit = static_cast<uint16_t>((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