feat(mesh): Reticulum LoRa hardware gates pass + RNS Resource transfer + image/voice attachments

Phase 0 gates #2/#3 (two-node LXMF-over-LoRa, external Sideband interop) passed
on real hardware (.116's flashed Heltec V3 RNode <-> a phone-flashed RNode running
Sideband) — RNS announce, encrypted DM round-trip, and contact binding all verified
live. Fixed two bugs found in the process: the Reticulum send path wasn't stamping
outbound messages as E2E despite LXMF being unconditionally encrypted, and the
per-message transport pill collapsed Meshcore/Meshtastic into one generic "lora"
color instead of distinguishing the three radio transports.

Built on top of that link: a Columba-style image/file send experience —
compression-quality presets with a real transfer-time estimate (mesh.transport-advice,
now device-throughput-aware), receive-side thumbnail previews + auto-render for
already-local attachments, and async voice messages, all reusing the existing
ContentRef/ContentInline attachment pipeline. The headline addition is genuine RNS
Resource transfer support (daemon-side RNS.Link + RNS.Resource, Rust-side
send_resource/resource_recv plumbing, a new "resource-mesh" transport-advice tier)
so compressed photos up to 2MB now actually transfer over LoRa for Reticulum peers
instead of always falling back to Tor past the small inline-chunk cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-30 19:57:01 -04:00
co-authored by Claude Sonnet 5
parent 12e7990b10
commit f54c853128
21 changed files with 2696 additions and 114 deletions
+12 -1
View File
@@ -48,6 +48,17 @@ impl ApiHandler {
.get("x-blob-filename")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Optional caller-supplied thumbnail (small, base64) — e.g. the mesh
// chat's image-quality picker generates a tiny client-side preview so
// a ContentRef receiver can render something before fetching the full
// blob. Best-effort: a malformed header is just ignored, not fatal.
let thumb_bytes = headers
.get("x-blob-thumb")
.and_then(|v| v.to_str().ok())
.and_then(|b64| {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(b64).ok()
});
let bytes = body.to_vec();
// Uploads through /api/blob come from the node owner's session and
@@ -55,7 +66,7 @@ impl ApiHandler {
// pictures, banners). Store them public so `/blob/<cid>` serves
// without a capability check — external Nostr clients fetching a
// kind-0 `picture` URL have no cap and can't get one.
match store.put(&bytes, &mime, filename, None, true).await {
match store.put(&bytes, &mime, filename, thumb_bytes, true).await {
Ok(meta) => {
let exp =
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
@@ -391,9 +391,24 @@ impl RpcHandler {
// Hard ceiling matching the chunked-send capacity (~20 chunks * 152
// b64 chars after MCIIXXTT framing). Anything larger must go via
// ContentRef over Tor.
// ContentRef over Tor — UNLESS the active device is Reticulum, which
// can carry up to RETICULUM_RESOURCE_MAX directly over LoRa via a
// native RNS Resource transfer (keep this ceiling in sync with
// `mesh.transport-advice`'s `"resource-mesh"` tier, the source of
// truth the frontend consults before ever reaching this size).
const INLINE_HARD_MAX: usize = 2300;
if bytes.len() > INLINE_HARD_MAX {
const RETICULUM_RESOURCE_MAX: usize = 2 * 1024 * 1024;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let device_type = svc.shared_state().status.read().await.device_type;
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
&& device_type == crate::mesh::types::DeviceType::Reticulum
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
anyhow::bail!(
"Payload {} bytes exceeds inline max {} — use mesh.send-content (ContentRef) instead",
bytes.len(),
@@ -414,11 +429,6 @@ impl RpcHandler {
.put(&bytes, &mime, filename.clone(), None, false)
.await?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let content = ContentInlinePayload {
mime: mime.clone(),
filename: filename.clone(),
@@ -447,8 +457,8 @@ impl RpcHandler {
"inline": true,
});
let msg = svc
.send_typed_wire(
let msg = if use_resource_transfer {
svc.send_content_resource(
contact_id,
wire,
"content_ref",
@@ -456,12 +466,24 @@ impl RpcHandler {
Some(typed_json),
seq,
)
.await?;
.await?
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
};
info!(
contact_id,
size = meta.size,
cid = %meta.cid,
via_resource = use_resource_transfer,
"Sent content_inline over mesh"
);
Ok(serde_json::json!({
@@ -492,8 +514,19 @@ impl RpcHandler {
// Knobs — keep in sync with the frontend modal copy.
const MESH_AUTO_MAX: u64 = 1024;
const MESH_HARD_MAX: u64 = 2300;
// Reticulum-only: above the small inline-chunk cap, a real RNS Resource
// transfer can still carry the payload directly over LoRa (native
// chunked transfer with retries) instead of falling back to Tor. Capped
// well under TOR_LARGE_WARN to keep worst-case LoRa transfer time
// bounded — comfortably covers the HIGH image preset (512KB target).
const RETICULUM_RESOURCE_MAX: u64 = 2 * 1024 * 1024;
const TOR_LARGE_WARN: u64 = 5 * 1024 * 1024;
const LORA_BYTES_PER_SEC: u64 = 50;
// Meshcore/Meshtastic effective LoRa throughput after retries/FEC is much
// lower than the raw radio bitrate. Reticulum's RNodeInterface reports its
// real bitrate (e.g. ~3125 bps ≈ 390 B/s observed live), so estimates for it
// would be wildly pessimistic at the generic 50 B/s figure.
const LORA_BYTES_PER_SEC_DEFAULT: u64 = 50;
const LORA_BYTES_PER_SEC_RETICULUM: u64 = 390;
// Resolve peer Tor reachability via federation node list.
let service = self.mesh_service.read().await;
@@ -501,6 +534,12 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let state = svc.shared_state();
let device_type = state.status.read().await.device_type;
let lora_bytes_per_sec = if device_type == crate::mesh::types::DeviceType::Reticulum {
LORA_BYTES_PER_SEC_RETICULUM
} else {
LORA_BYTES_PER_SEC_DEFAULT
};
let (peer_pubkey_hex, peer_did) = {
let peers = state.peers.read().await;
match peers.get(&contact_id) {
@@ -520,8 +559,10 @@ impl RpcHandler {
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let est_seconds = (size.saturating_add(LORA_BYTES_PER_SEC - 1) / LORA_BYTES_PER_SEC).max(1);
let est_seconds =
(size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX {
@@ -530,6 +571,8 @@ impl RpcHandler {
} else {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
("resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer")
} else if size <= TOR_LARGE_WARN {
if has_tor {
("tor-only", "Too large for mesh — Tor only")
@@ -674,18 +717,6 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing cid"))?
.to_string();
let sender_onion = params["sender_onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
.trim_end_matches('/')
.to_string();
let cap_token = params["cap_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
.to_string();
let cap_exp = params["cap_exp"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
let mime_hint = params["mime"]
.as_str()
.unwrap_or("application/octet-stream")
@@ -709,7 +740,12 @@ impl RpcHandler {
};
// Short-circuit if we already hold the blob — still issue a fresh
// self-cap so the UI gets a displayable local URL.
// self-cap so the UI gets a displayable local URL. Checked BEFORE the
// sender_onion/cap_token/cap_exp params are required below: an inline
// ContentInline attachment (mesh.send-content-inline) is written to
// our own BlobStore the moment it's received/sent (dispatch.rs), so
// its typed_payload never carries those fields at all — only a
// ContentRef fetched from a remote peer needs them.
if blob_store.has(&cid).await {
let local_exp = (chrono::Utc::now().timestamp() as u64) + DEFAULT_CAP_TTL_SECS;
let local_cap = blob_store.issue_capability(&cid, &self_pubkey_hex, local_exp);
@@ -725,6 +761,19 @@ impl RpcHandler {
}));
}
let sender_onion = params["sender_onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
.trim_end_matches('/')
.to_string();
let cap_token = params["cap_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
.to_string();
let cap_exp = params["cap_exp"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
// Reach the sender: FIPS preferred when the sender is federated
// and has advertised a FIPS npub, Tor fallback otherwise.
// Cap/exp/peer in the query string match what the sender signed in
+14
View File
@@ -63,6 +63,16 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Send pre-encoded binary over a dedicated Reticulum RNS Resource
/// transfer instead of the small inline-chunk path — Reticulum-only, see
/// `MeshRadioDevice::send_resource`. Used for large attachments
/// (compressed photos, voice messages) that exceed the small-message cap
/// but fit a sane LoRa-Resource budget; routing decision is made by the
/// RPC layer (`mesh.transport-advice`'s `"resource-mesh"` tier).
SendResource {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Send PLAIN text as one or more native meshcore DMs to a stock client
/// (e.g. a phone). Long text is split into multiple readable plain messages
/// — never MC-chunked — because stock clients can't reassemble archy's
@@ -372,6 +382,7 @@ impl MeshState {
/// 4. Reconnect on disconnect
pub fn spawn_mesh_listener(
state: Arc<MeshState>,
data_dir: std::path::PathBuf,
device_path: Option<String>,
our_did: String,
our_ed_pubkey_hex: String,
@@ -380,6 +391,7 @@ pub fn spawn_mesh_listener(
server_name: Option<String>,
lora_region: Option<String>,
channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
@@ -395,6 +407,7 @@ pub fn spawn_mesh_listener(
match session::run_mesh_session(
&state,
&data_dir,
device_path.as_deref(),
&our_did,
&our_ed_pubkey_hex,
@@ -403,6 +416,7 @@ pub fn spawn_mesh_listener(
server_name.as_deref(),
lora_region.as_deref(),
channel_name.as_deref(),
device_kind,
&mut shutdown,
&mut cmd_rx,
)
+240 -33
View File
@@ -1,6 +1,7 @@
//! Mesh session lifecycle: connect, initialize, main loop.
use super::super::meshtastic::MeshtasticDevice;
use super::super::reticulum::ReticulumLink;
use super::super::serial::MeshcoreDevice;
use super::super::types::*;
use super::{
@@ -8,6 +9,7 @@ use super::{
SYNC_INTERVAL,
};
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
@@ -16,6 +18,7 @@ use tracing::{debug, error, info, warn};
enum MeshRadioDevice {
Meshcore(MeshcoreDevice),
Meshtastic(MeshtasticDevice),
Reticulum(ReticulumLink),
}
impl MeshRadioDevice {
@@ -23,6 +26,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(_) => DeviceType::Meshcore,
Self::Meshtastic(_) => DeviceType::Meshtastic,
Self::Reticulum(_) => DeviceType::Reticulum,
}
}
@@ -30,6 +34,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.advert_name.clone(),
Self::Meshtastic(device) => device.advert_name(),
Self::Reticulum(device) => device.advert_name(),
}
}
@@ -37,6 +42,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.set_advert_name(name).await,
Self::Meshtastic(device) => device.set_advert_name(name).await,
Self::Reticulum(device) => device.set_advert_name(name).await,
}
}
@@ -44,11 +50,14 @@ impl MeshRadioDevice {
/// their own band on the device, so this is a no-op for them; Meshtastic
/// radios ship region-UNSET (RF-silent) and must be set or they never mesh.
/// Returns `Ok(true)` when a region was written (the device reboots to
/// apply, so the caller should restart the session).
/// apply, so the caller should restart the session). No-op for Reticulum:
/// the daemon's RNodeInterface config carries its own LoRa profile, not
/// driven through this firmware-admin path.
async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result<bool> {
match self {
Self::Meshcore(_) => Ok(false),
Self::Meshtastic(device) => device.ensure_lora_region(region).await,
Self::Reticulum(_) => Ok(false),
}
}
@@ -56,11 +65,14 @@ impl MeshRadioDevice {
/// other. No-op for meshcore (it joins its channel by name on the device);
/// Meshtastic radios can sit on mismatched channels otherwise and silently
/// drop every packet as undecryptable. Returns `Ok(true)` when a channel was
/// written (device reboots; caller should restart the session).
/// written (device reboots; caller should restart the session). No-op for
/// Reticulum: RNS has no shared-PSK channel concept (see
/// `ReticulumLink::send_channel_text`).
async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
match self {
Self::Meshcore(_) => Ok(false),
Self::Meshtastic(device) => device.ensure_channel(channel_name).await,
Self::Reticulum(_) => Ok(false),
}
}
@@ -68,28 +80,33 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.send_self_advert().await,
Self::Meshtastic(device) => device.send_self_advert().await,
Self::Reticulum(device) => device.send_self_advert().await,
}
}
/// Lightweight serial keepalive (Meshtastic only). Keeps the firmware
/// streaming RECEIVED packets to our serial client — without it the radio
/// can mark a quiet client gone and deliver only our own queue-status.
/// Meshcore needs no such ping.
/// Meshcore/Reticulum need no such ping (Reticulum's "serial" traffic is
/// the daemon's own RNS link, not a firmware queue we poll).
async fn send_keepalive(&mut self) -> Result<()> {
match self {
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.send_keepalive().await,
Self::Reticulum(_) => Ok(()),
}
}
/// Actively advertise our identity over the air. Meshcore already does this
/// inside `send_self_advert` (CMD_SEND_SELF_ADVERT), so this is a no-op for
/// it; Meshtastic needs an explicit NodeInfo broadcast or peers never learn
/// about an already-running node.
/// about an already-running node. No-op for Reticulum: its `announce` (via
/// `send_self_advert`) already covers discovery.
async fn send_nodeinfo_advert(&mut self, want_response: bool) -> Result<()> {
match self {
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.send_nodeinfo_broadcast(want_response).await,
Self::Reticulum(_) => Ok(()),
}
}
@@ -97,6 +114,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
Self::Reticulum(device) => device.send_channel_text(channel, payload).await,
}
}
@@ -104,15 +122,34 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
Self::Reticulum(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
}
}
/// Send `data` over a dedicated RNS Resource transfer instead of the
/// small-payload "content" path — only Reticulum has anything resembling
/// this (a native large-binary transfer protocol over a `RNS.Link`).
/// Meshcore/Meshtastic have no equivalent in our driver; callers must
/// check `device_type() == DeviceType::Reticulum` before reaching for
/// this (see `mesh.transport-advice`'s `"resource-mesh"` tier, which is
/// Reticulum-only), so an Err here means the caller's gating is wrong,
/// not a legitimate no-op.
async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
match self {
Self::Meshcore(_) | Self::Meshtastic(_) => {
anyhow::bail!("Resource transfer is Reticulum-only")
}
Self::Reticulum(device) => device.send_resource(dest_pubkey_prefix, data).await,
}
}
async fn reboot(&mut self, seconds: i64) -> Result<()> {
match self {
// Meshcore has no equivalent local-admin reboot in our driver; the
// RX-deaf recovery this targets is Meshtastic-specific.
// Meshcore/Reticulum have no equivalent local-admin reboot in our
// driver; the RX-deaf recovery this targets is Meshtastic-specific.
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.reboot(seconds).await,
Self::Reticulum(_) => Ok(()),
}
}
@@ -120,6 +157,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.remove_contact(pubkey).await,
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
Self::Reticulum(device) => device.remove_contact(pubkey).await,
}
}
@@ -143,6 +181,11 @@ impl MeshRadioDevice {
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
Self::Reticulum(device) => {
device
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
}
}
@@ -150,6 +193,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.get_contacts().await,
Self::Meshtastic(device) => device.get_contacts().await,
Self::Reticulum(device) => device.get_contacts().await,
}
}
@@ -157,6 +201,8 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
// RNS does its own pathfinding — no firmware path table to reset.
Self::Reticulum(_) => Ok(()),
}
}
@@ -164,6 +210,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.sync_messages().await,
Self::Meshtastic(device) => device.sync_messages().await,
Self::Reticulum(device) => device.sync_messages().await,
}
}
@@ -171,46 +218,89 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.try_recv_frame().await,
Self::Meshtastic(device) => device.try_recv_frame().await,
Self::Reticulum(device) => device.try_recv_frame().await,
}
}
/// PKI-E2E status of the last inbound frame (meshtastic only; meshcore's
/// per-message E2E is derived in the frames decrypt path). Take-and-clear.
/// per-message E2E is derived in the frames decrypt path). Reticulum/LXMF
/// is unconditionally E2E (no plaintext mode), so it always reports true.
/// Take-and-clear.
fn take_rx_encrypted(&mut self) -> bool {
match self {
Self::Meshcore(_) => false,
Self::Meshtastic(device) => device.take_rx_encrypted(),
Self::Reticulum(device) => device.take_rx_encrypted(),
}
}
}
/// Scan all candidate serial ports and open the first supported mesh device found.
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
///
/// `device_kind`, when set, pins the expected firmware (operator-confirmed via
/// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only
/// that one device's probe runs, so a non-matching firmware's init bytes are
/// never injected into the port. `None` keeps the strict
/// Meshcore→Meshtastic→Reticulum probe order.
async fn auto_detect_and_open(
data_dir: &Path,
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
}
for path in &paths {
debug!(path = %path, "Probing for mesh radio device");
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
if device_kind.is_none_or(|k| k == DeviceType::Meshcore) {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
}
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
if device_kind.is_none_or(|k| k == DeviceType::Meshtastic) {
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
}
}
// Tried LAST: the same reflashable board (e.g. Heltec V3) can run
// Meshcore, Meshtastic, or RNode firmware, so each probe must fail
// strictly before the next is attempted. The RNode KISS-detect probe
// is the most expensive (spawns the supervised daemon on a match), so
// it goes after the two cheap firmware-specific handshakes above.
if device_kind.is_none_or(|k| k == DeviceType::Reticulum) {
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
},
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
}
}
}
anyhow::bail!(
@@ -220,7 +310,57 @@ async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)>
)
}
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
async fn open_preferred_path(
path: &str,
data_dir: &Path,
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
) -> Result<(MeshRadioDevice, DeviceInfo)> {
// Pinned: try only the configured firmware and surface its own error —
// never fall through to (and inject probe bytes into) another firmware's
// handshake on this port.
if let Some(kind) = device_kind {
return match kind {
DeviceType::Meshcore => {
let mut dev = MeshcoreDevice::open(path)
.await
.context("Could not open preferred path as Meshcore")?;
let info = dev
.initialize()
.await
.context("Preferred path is not a working Meshcore device")?;
Ok((MeshRadioDevice::Meshcore(dev), info))
}
DeviceType::Meshtastic => {
let mut dev = MeshtasticDevice::open(path)
.await
.context("Could not open preferred path as Meshtastic")?;
let info = dev
.initialize()
.await
.context("Preferred path is not a working Meshtastic device")?;
Ok((MeshRadioDevice::Meshtastic(dev), info))
}
DeviceType::Reticulum => {
let mut dev = ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open preferred path as Reticulum")?;
let info = dev
.initialize()
.await
.context("Preferred path is not a working Reticulum RNode")?;
Ok((MeshRadioDevice::Reticulum(dev), info))
}
DeviceType::Unknown => anyhow::bail!("device_kind cannot be Unknown"),
};
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
@@ -230,10 +370,24 @@ async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshtastic"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshtastic"),
}
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Reticulum RNode"),
},
Err(e) => Err(e).context("Could not open preferred path as Reticulum"),
}
}
@@ -438,9 +592,12 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// surfaced. `radio_contact_blocklist` is retained but unused.
let mut peers = state.peers.write().await;
let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic);
let is_reticulum = matches!(device.device_type(), DeviceType::Reticulum);
for (idx, contact) in contacts.iter().enumerate() {
let contact_id = if is_meshtastic {
meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
} else if is_reticulum {
reticulum_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
} else {
idx as u32
};
@@ -464,6 +621,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// A non-zero path_len means the firmware has a route (direct
// or flood) to this contact — i.e. we can deliver to it.
reachable: contact.path_len != 0,
// E2E capability only grows (once the radio learns a peer's
// PKI key it stays known), so OR with any prior value rather
// than letting a transient contact refresh clear the pill.
pkc_capable: contact.pkc_capable
|| existing.map(|p| p.pkc_capable).unwrap_or(false),
};
peers.insert(contact_id, peer);
}
@@ -530,6 +692,17 @@ fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
}
}
/// Stable `u32` contact id derived from a Reticulum contact's `public_key_hex`
/// (hex of the 16-byte RNS destination hash). Delegates to the canonical
/// derivation in `reticulum.rs` so there is exactly one masking rule (must
/// stay below `FEDERATION_CONTACT_ID_BASE`, mod.rs:53) shared with
/// `ReticulumLink::initialize()`'s reported `node_id`.
fn reticulum_contact_id(public_key_hex: &str) -> Option<u32> {
let bytes = hex::decode(public_key_hex).ok()?;
let hash: [u8; 16] = bytes.try_into().ok()?;
Some(super::super::reticulum::reticulum_contact_id_from_hash(&hash))
}
/// Drain any queued messages from the device.
/// Returns `true` if a write/communication error occurred (for failure tracking).
async fn sync_queued_messages(
@@ -574,6 +747,7 @@ static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
/// Run a single mesh session (connect, initialize, main loop).
pub(super) async fn run_mesh_session(
state: &Arc<MeshState>,
data_dir: &Path,
preferred_path: Option<&str>,
our_did: &str,
our_ed_pubkey_hex: &str,
@@ -582,23 +756,33 @@ pub(super) async fn run_mesh_session(
server_name: Option<&str>,
lora_region: Option<&str>,
channel_name: Option<&str>,
device_kind: Option<DeviceType>,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> {
// Detect device — try preferred path first, fall back to auto-detect
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
match open_preferred_path(path).await {
match open_preferred_path(
path,
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
)
.await
{
Ok((dev, info)) => (path.to_string(), dev, info),
Err(e) => {
warn!(
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open().await?
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind)
.await?
}
}
} else {
auto_detect_and_open().await?
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind).await?
};
// Update status
@@ -904,6 +1088,29 @@ async fn handle_send_command(
)
.await;
}
MeshCommand::SendResource {
dest_pubkey_prefix,
payload,
} => {
// No MC-chunk framing here — RNS Resources do their own native
// chunked transfer at the link layer, so the payload goes through
// as-is (the receiving daemon hands back the complete blob in one
// `resource_recv` event).
if let Err(e) = device.send_resource(&dest_pubkey_prefix, &payload).await {
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Failed to send Reticulum resource: {}", e
);
} else {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
len = payload.len(),
"Sent Reticulum resource transfer"
);
}
}
MeshCommand::BroadcastChannel { channel, payload } => {
if let Err(e) = device.send_channel_text(channel, &payload).await {
*consecutive_write_failures += 1;
+24 -1
View File
@@ -481,6 +481,29 @@ pub struct ReactionPayload {
pub emoji: String,
}
/// `Option<Vec<u8>>` <-> base64 string, for fields that need to survive a JSON
/// round-trip to the frontend readably (plain serde would emit/expect a JSON
/// array of numbers for `Vec<u8>`, which isn't what `data:` URLs want). CBOR
/// wire encoding pays a small (~33%) size tax for this on `thumb_bytes`
/// specifically — negligible given thumbnails are capped at ~60 bytes.
mod base64_opt_bytes {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(bytes) => s.serialize_str(&STANDARD.encode(bytes)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let opt: Option<String> = Option::deserialize(d)?;
opt.map(|s| STANDARD.decode(&s).map_err(serde::de::Error::custom))
.transpose()
}
}
/// Content/attachment reference: points at a blob held by the sender that
/// recipients fetch out-of-band via `GET {sender_onion}/blob/{cid}?cap=..&exp=..&peer=..`.
/// Thumb bytes (≤60B) may be inlined for immediate display; full blob is lazy.
@@ -491,7 +514,7 @@ pub struct ContentRefPayload {
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")]
pub thumb_bytes: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,
+116 -14
View File
@@ -14,6 +14,7 @@ pub mod message_types;
pub mod outbox;
pub mod protocol;
pub mod ratchet;
pub mod reticulum;
pub mod scheduler;
pub mod serial;
pub mod session;
@@ -245,6 +246,9 @@ pub(crate) async fn upsert_federation_peer(
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
reachable: true,
// Off-radio E2E (federation) is handled by the archy-peer path; preserve
// any radio PKI capability learned for a twinned contact.
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
};
peers.insert(contact_id, peer);
// A radio twin of this node (same advert_name, no arch identity yet) can now
@@ -377,6 +381,15 @@ pub struct MeshConfig {
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
#[serde(default)]
pub assistant_allowed_contacts: Vec<String>,
/// Pin the expected firmware on `device_path`/auto-detected ports. A
/// reflashable board (e.g. Heltec V3) can run Meshcore, Meshtastic, or
/// RNode firmware, so probe order alone is best-effort — set this when an
/// operator knows which one is plugged in. When `Some`, only that
/// device's probe runs (no other firmware's init bytes are ever injected
/// into the port); `None` keeps today's Meshcore→Meshtastic→Reticulum
/// strict-probe auto-detect.
#[serde(default)]
pub device_kind: Option<types::DeviceType>,
}
fn default_assistant_backend() -> String {
@@ -406,6 +419,7 @@ impl Default for MeshConfig {
assistant_trusted_only: true,
assistant_backend: default_assistant_backend(),
assistant_allowed_contacts: Vec::new(),
device_kind: None,
}
}
}
@@ -678,6 +692,7 @@ impl MeshService {
let handle = listener::spawn_mesh_listener(
Arc::clone(&self.state),
self.data_dir.clone(),
self.config.device_path.clone(),
self.our_did.clone(),
self.our_ed_pubkey_hex.clone(),
@@ -686,6 +701,7 @@ impl MeshService {
self.server_name.clone(),
self.config.lora_region.clone(),
self.config.channel_name.clone(),
self.config.device_kind,
shutdown_rx,
cmd_rx,
);
@@ -1205,6 +1221,8 @@ impl MeshService {
);
}
self.send_raw_payload(contact_id, wire).await?;
let device_type = self.state.status.read().await.device_type;
let radio_transport = radio_transport_label(device_type);
Ok(self
.record_sent_typed(
contact_id,
@@ -1212,11 +1230,65 @@ impl MeshService {
display_text,
typed_payload,
sender_seq,
Some("lora".to_string()),
Some(radio_transport.to_string()),
// Archy↔archy typed envelopes over LoRa are identity-signed; the
// radio E2E flag (meshtastic PKI / meshcore session) isn't
// threaded to the send side yet, so don't over-claim E2E here.
false,
// threaded to the send side yet, so don't over-claim E2E here
// except Reticulum/LXMF, which is unconditionally E2E on every
// send regardless of peer/session state (see send_message).
device_type == DeviceType::Reticulum,
)
.await)
}
/// Send a typed envelope over a dedicated Reticulum RNS Resource transfer
/// (`MeshCommand::SendResource`) instead of the small inline-chunk path
/// `send_typed_wire`/`send_raw_payload` uses. Callers (the `mesh.send-content-inline`
/// RPC handler) are responsible for only reaching this when the active
/// device is actually Reticulum and the payload fits the
/// `RETICULUM_RESOURCE_MAX` budget — see `mesh.transport-advice`'s
/// `"resource-mesh"` tier, the single source of truth for that decision.
/// Mirrors `send_typed_wire`'s signature/return shape so RPC call sites
/// can switch between the two paths without restructuring.
pub async fn send_content_resource(
&self,
contact_id: u32,
wire: Vec<u8>,
type_label: &str,
display_text: &str,
typed_payload: Option<serde_json::Value>,
sender_seq: u64,
) -> Result<MeshMessage> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected");
}
drop(status);
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendResource {
dest_pubkey_prefix: dest_prefix,
payload: wire,
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let device_type = self.state.status.read().await.device_type;
let radio_transport = radio_transport_label(device_type);
Ok(self
.record_sent_typed(
contact_id,
type_label,
display_text,
typed_payload,
sender_seq,
Some(radio_transport.to_string()),
// Reticulum/LXMF is unconditionally E2E on every send — same
// reasoning as send_message's native-text path. This method
// is Reticulum-only by construction (callers gate on
// device_type before reaching it), so this is never wrong.
true,
)
.await)
}
@@ -1512,6 +1584,7 @@ impl MeshService {
let chan_contact_id = u32::MAX - (channel as u32);
let chan_name = format!("Channel {}", channel);
let msg_id = self.state.next_id().await;
let radio_transport = radio_transport_label(self.state.status.read().await.device_type);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Sent,
@@ -1523,7 +1596,7 @@ impl MeshService {
// Channel broadcasts use the shared channel PSK, not per-identity
// E2E — so not an E2E message, but it does travel over the radio.
encrypted: false,
transport: Some("lora".to_string()),
transport: Some(radio_transport.to_string()),
message_type: type_label.to_string(),
typed_payload,
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
@@ -1557,14 +1630,16 @@ impl MeshService {
// envelope as a message (PRIVATE_APP is opaque app-data; a base64
// envelope overflows one LoRa frame and chunk-fails) — wrapping text
// is exactly what silently broke archy↔archy Meshtastic LoRa.
// • Meshcore archy peer → keep the rich signed typed envelope. Meshcore
// frames are binary-safe (no UTF-8 mangling) and it carries its own
// session E2E + our signature for `!ai` auth / seq reply addressing,
// so the envelope works there and we must not drop it.
// • Meshcore/Reticulum archy peer → keep the rich signed typed envelope.
// Meshcore frames are binary-safe (no UTF-8 mangling) and Reticulum/LXMF
// is binary-safe and high-capacity too; both carry their own transport
// E2E plus our signature for `!ai` auth / seq reply addressing, so the
// envelope works there and we must not drop it.
// • Meshcore stock client → plain text (can't decode our envelope).
// Rich typed messages (invoice/coordinate/reaction/…) always use the
// typed-wire path via `send_typed_wire`; only plain Text is routed here.
let use_typed_envelope = archy && device_type == DeviceType::Meshcore;
let use_typed_envelope =
archy && matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum);
if use_typed_envelope {
// Sign with our archipelago identity so the receiver can authenticate
// us over LoRa (verifies against our bound `arch_pubkey_hex`). `with_seq`
@@ -1591,8 +1666,18 @@ impl MeshService {
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
// The firmware PKI-encrypts a directed DM to any peer whose key it knows;
// archy peers always exchange keys, so mark those Sent rows E2E so the
// pill shows immediately. (The receiver independently stamps E2E from the
// radio's `pki_encrypted` flag, so an inbound row is accurate regardless.)
// pill shows immediately. A non-archy stock peer (e.g. 3ccc) can also be
// PKC-capable once we've learned its NodeInfo public key — OR that in too
// so the pill isn't archy-only. (The receiver independently stamps E2E
// from the radio's `pki_encrypted` flag, so an inbound row is accurate
// regardless.)
//
// Reticulum/LXMF has no such conditional: every send is encrypted to the
// destination's identity key by the LXMF router itself, archy peer or
// not — so it's unconditionally E2E rather than gated on `archy`/`pkc_capable`
// (which is a Meshtastic-only concept; Reticulum contacts never set it).
let pkc_capable = self.peer_pkc_capable(contact_id).await;
let encrypted = device_type == DeviceType::Reticulum || archy || pkc_capable;
Ok(self
.record_sent_typed(
contact_id,
@@ -1600,8 +1685,8 @@ impl MeshService {
text,
None,
seq,
Some("lora".to_string()),
archy,
Some(radio_transport_label(device_type).to_string()),
encrypted,
)
.await)
}
@@ -1622,6 +1707,21 @@ impl MeshService {
.unwrap_or(false)
}
/// Whether `contact_id`'s real radio PKI (Curve25519) key is known, so the
/// firmware delivers a directed DM to it end-to-end encrypted even though
/// it's not an archipelago peer (e.g. stock Meshtastic peer 3ccc). Stamped
/// onto `MeshPeer::pkc_capable` by `refresh_contacts` from the driver's
/// `get_contacts()`.
async fn peer_pkc_capable(&self, contact_id: u32) -> bool {
self.state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.pkc_capable)
.unwrap_or(false)
}
/// Record a Sent MeshMessage for a typed envelope that has already been
/// transmitted by the caller. Used by the RPC layer after sending
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
@@ -1695,6 +1795,7 @@ impl MeshService {
let chan_contact_id = u32::MAX - (channel as u32);
let chan_name = format!("Channel {}", channel);
let msg_id = self.state.next_id().await;
let radio_transport = radio_transport_label(self.state.status.read().await.device_type);
let msg = MeshMessage {
id: msg_id,
@@ -1706,7 +1807,7 @@ impl MeshService {
delivered: false,
// Plain channel broadcast over the radio (shared PSK, not E2E).
encrypted: false,
transport: Some("lora".to_string()),
transport: Some(radio_transport.to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@@ -1991,6 +2092,7 @@ mod tests {
hops: 0,
last_advert: 0,
reachable,
pkc_capable: false,
}
}
+751
View File
@@ -0,0 +1,751 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Reticulum (RNS + LXMF) bridge.
//!
//! Unlike Meshcore/Meshtastic — simple framed-serial protocols driven entirely
//! in-process — Reticulum is a full network stack (identity, announce, multi-hop
//! routing, LXMF store-and-forward) that we run as a **host-supervised Python
//! daemon** (`reticulum-daemon/`, canonical `rns`+`lxmf`, chosen over the sub-1.0
//! Rust port for interop with Sideband/NomadNet/MeshChat — see the plan). This
//! module is the Rust-side half of that bridge: it owns the child process and
//! speaks the daemon's Unix-socket JSON-RPC, while presenting the same method
//! surface `MeshRadioDevice` (listener/session.rs) already calls on
//! `MeshcoreDevice`/`MeshtasticDevice`.
//!
//! Two contract details that are easy to get wrong (see the plan §2b/§2d):
//! 1. The wrapper's `send_text_msg` is handed only a 6-byte prefix, but an RNS
//! destination is 16 bytes — `prefix_to_hash` below is the mandatory
//! resolver, populated from announces/contacts.
//! 2. Inbound LXMF deliveries are translated into the exact same synthetic
//! `InboundFrame` byte layout Meshtastic already produces
//! (`RESP_CONTACT_MSG_V3[_E2E]`), so `frames::handle_frame` needs zero
//! changes to route them.
use super::protocol::{self, InboundFrame, ParsedContact};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::process::{Child, Command};
use tracing::{debug, info, warn};
/// RNode KISS protocol bytes, verified against the canonical Reticulum source
/// (`RNS/Interfaces/RNodeInterface.py`, `detect()`/`readLoop()`) — NOT guessed.
/// See `docs/RETICULUM-TRANSPORT-PROGRESS.md` for the citation.
const KISS_FEND: u8 = 0xC0;
const KISS_CMD_DETECT: u8 = 0x08;
const KISS_DETECT_REQ: u8 = 0x73;
const KISS_DETECT_RESP: u8 = 0x46;
const KISS_CMD_FW_VERSION: u8 = 0x50;
const KISS_CMD_PLATFORM: u8 = 0x48;
const KISS_CMD_MCU: u8 = 0x49;
const PROBE_BAUD: u32 = 115200;
const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800);
/// Path to the supervised daemon binary. In production this is the bundled
/// PyInstaller artifact shipped beside `/usr/local/bin/archipelago` (Phase 1
/// packaging); during development it can point at the venv's interpreter
/// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging.
///
/// `archy_ed_pubkey_hex`/`archy_x25519_pubkey_hex` (when known) are embedded
/// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the
/// SAME wire format meshcore/Meshtastic identity adverts use — so a
/// Reticulum-carried identity binds onto the existing Archy contact via the
/// existing `parse_identity_broadcast`/`handle_identity_received` path,
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
fn daemon_command(
socket_path: &Path,
serial_port: &str,
identity_key: &Path,
archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>,
) -> Command {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
// Dev fallback: run the script through its venv interpreter.
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
let mut c = Command::new(py);
c.arg(script);
c
};
cmd.arg("--identity-key")
.arg(identity_key)
.arg("--socket")
.arg(socket_path)
.arg("--serial-port")
.arg(serial_port);
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex")
.arg(ed)
.arg("--archy-x25519-pubkey-hex")
.arg(x);
}
cmd.kill_on_drop(true)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd
}
/// One peer learned via an RNS announce (LXMF delivery destination).
#[derive(Clone)]
struct ReticulumPeer {
dest_hash: [u8; 16],
display_name: String,
/// Archy ed25519 identity hex, once carried in a verified announce
/// app-data blob. Not yet wired (TODO, lands with the signed-announce
/// work) — present so `get_contacts` has a stable shape to extend into.
arch_pubkey_hex: Option<String>,
reachable: bool,
}
/// Bridge handle to one supervised `reticulum-daemon` instance, one per active
/// Reticulum (RNode) radio. Implements the same method shapes
/// `MeshRadioDevice` calls on `MeshcoreDevice`/`MeshtasticDevice`.
pub struct ReticulumLink {
device_path: String,
socket_path: std::path::PathBuf,
child: Child,
writer: tokio::net::unix::OwnedWriteHalf,
reader: BufReader<tokio::net::unix::OwnedReadHalf>,
dest_hash: [u8; 16],
display_name: Option<String>,
/// Mandatory: the wrapper's `send_text_msg`/inbound frames only carry a
/// 6-byte prefix, but an RNS destination is 16 bytes. Populated from
/// announces and `get_contacts`.
prefix_to_hash: HashMap<[u8; 6], [u8; 16]>,
peers: HashMap<[u8; 16], ReticulumPeer>,
inbound: std::collections::VecDeque<InboundFrame>,
/// Monotonic correlation id for `send_resource` RPC calls — purely for
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
/// back to a log line; sends are fire-and-forget (see `send_resource`).
resource_id_counter: u64,
}
impl ReticulumLink {
/// Cheap probe: send the verified RNode KISS detect sequence over the raw
/// serial port and look for `DETECT_RESP`, WITHOUT spawning the (heavy)
/// daemon. Mirrors the open()/initialize() split Meshcore/Meshtastic use,
/// so a non-RNode port is rejected fast and the daemon is only ever
/// started against a port we've confirmed is an RNode.
///
/// `data_dir` is the same archipelago data directory `NodeIdentity` was
/// loaded from (`{data_dir}/identity/node_key`) — the daemon reads that
/// key file directly to derive its RNS identity (we pass the path, not
/// the key bytes, so it never travels through more hops than necessary).
///
/// `our_ed_pubkey_hex`/`our_x25519_pubkey_hex` are this node's real Archy
/// identity pubkeys (already computed by the caller — same values passed
/// to `run_mesh_session`); forwarded to the daemon so its announces carry
/// a peer-bindable `ARCHY:2:...` identity. Pass `None` to announce with
/// just the plain display name (e.g. a non-archy/dev run).
pub async fn open(
path: &str,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
probe_rnode(path).await.context("RNode KISS detect failed")?;
Self::spawn(path, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await
}
async fn spawn(
path: &str,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
// Keep the RPC socket under the archipelago-owned data dir (not the
// shared system temp dir) so its access is bounded by the same
// permissions as the rest of our state — consistent with the
// "archipelago-owned runtime dir, 0600" security posture.
let runtime_dir = data_dir.join("reticulum");
tokio::fs::create_dir_all(&runtime_dir)
.await
.context("Failed to create reticulum runtime dir")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
}
let socket_path = runtime_dir.join(format!(
"{}.sock",
path.replace(['/', ' '], "_")
));
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
let identity_key = data_dir.join("identity").join("node_key");
if !identity_key.exists() {
anyhow::bail!(
"Archy identity key not found at {} — cannot derive a Reticulum identity",
identity_key.display()
);
}
let mut cmd = daemon_command(
&socket_path,
path,
&identity_key,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
);
let mut child = cmd
.spawn()
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
// Wait for the socket to appear, then for the daemon's "ready" event.
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
let stream = loop {
if tokio::time::Instant::now() > deadline {
let _ = child.start_kill();
anyhow::bail!("reticulum-daemon did not create its RPC socket in time");
}
match UnixStream::connect(&socket_path).await {
Ok(s) => break s,
Err(_) => tokio::time::sleep(Duration::from_millis(150)).await,
}
};
let (read_half, write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut line = String::new();
tokio::time::timeout(Duration::from_secs(10), reader.read_line(&mut line))
.await
.context("Timed out waiting for reticulum-daemon ready event")?
.context("reticulum-daemon RPC connection closed before ready")?;
let ready: Value = serde_json::from_str(line.trim())
.context("reticulum-daemon sent a non-JSON ready line")?;
if ready.get("event").and_then(Value::as_str) != Some("ready") {
anyhow::bail!("reticulum-daemon's first message was not 'ready': {ready}");
}
let dest_hash_hex = ready
.get("dest_hash")
.and_then(Value::as_str)
.context("ready event missing dest_hash")?;
let dest_hash = parse_hash16(dest_hash_hex)?;
let display_name = ready
.get("display_name")
.and_then(Value::as_str)
.map(str::to_string);
info!(
path = %path,
dest_hash = %dest_hash_hex,
"Reticulum daemon ready"
);
Ok(Self {
device_path: path.to_string(),
socket_path,
child,
writer: write_half,
reader,
dest_hash,
display_name,
prefix_to_hash: HashMap::new(),
peers: HashMap::new(),
inbound: std::collections::VecDeque::new(),
resource_id_counter: 0,
})
}
fn next_resource_id(&mut self) -> String {
self.resource_id_counter += 1;
self.resource_id_counter.to_string()
}
/// Handshake is a no-op here — `open()` already waited for the daemon's
/// `ready` event, so by the time `MeshRadioDevice` calls `initialize()`
/// the daemon (and the RNS/LXMF stack inside it) is already up.
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
Ok(DeviceInfo {
firmware_version: "reticulum-daemon".to_string(),
node_id: reticulum_contact_id_from_hash(&self.dest_hash),
max_contacts: u16::MAX,
device_type: super::types::DeviceType::Reticulum,
})
}
pub fn advert_name(&self) -> Option<String> {
self.display_name.clone()
}
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
// The daemon's display_name is fixed at spawn time (CLI arg); changing
// it live would require an RPC verb we haven't added. Track locally so
// `advert_name()` reflects the caller's intent even though the
// RNS-visible name doesn't change until the daemon restarts.
self.display_name = Some(name.to_string());
Ok(())
}
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_rpc(serde_json::json!({"cmd": "announce"})).await
}
/// Reticulum/LXMF has no shared-PSK broadcast channel like Meshcore's
/// channel 0 or Meshtastic's primary channel — it's point-to-point +
/// propagation-node store-and-forward. Treat as unsupported-but-harmless
/// (no-op success) rather than failing the caller, matching the no-op
/// pattern used for other not-applicable operations on this enum arm.
/// Per-network channel support is tracked in the plan's Phase 4.
pub async fn send_channel_text(&mut self, _channel: u8, _payload: &[u8]) -> Result<()> {
debug!("Reticulum has no broadcast-channel concept — ignoring channel send");
Ok(())
}
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
self.send_rpc(serde_json::json!({
"cmd": "send",
"dest_hash": hex::encode(dest_hash),
"content": String::from_utf8_lossy(payload),
"method": "direct",
}))
.await
}
/// Send `data` (typically an already-built typed-envelope wire blob) to a
/// peer over a dedicated RNS Resource transfer instead of the small LXMF
/// "content" path `send_text_msg` uses — for payloads too large for the
/// inline-chunk cap but well within what LoRa can carry over a proper RNS
/// Resource (native chunked transfer with retries, unlike our own
/// MC-chunk scheme). Fire-and-forget, matching `send_text_msg`'s existing
/// semantics (no synchronous delivery confirmation) — `resource_sent`/
/// `resource_failed`/`resource_progress` events are drained and logged by
/// `handle_event`, not awaited here.
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
let req_id = self.next_resource_id();
self.send_rpc(serde_json::json!({
"cmd": "send_resource",
"id": req_id,
"dest_hash": hex::encode(dest_hash),
"data_b64": B64.encode(data),
}))
.await
}
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
// RNS has no firmware-side contact table to prune — peers simply stop
// being reachable when their announce/path ages out.
Ok(())
}
pub async fn add_contact(
&mut self,
_pubkey: &[u8; 32],
_contact_type: u8,
_flags: u8,
_out_path_len: u8,
_name: &str,
_last_advert: u32,
) -> Result<()> {
// No firmware contact table to seed — RNS learns peers from announces
// (handled by drain_events) and from path requests issued on send.
Ok(())
}
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
self.drain_events().await;
Ok(self
.peers
.values()
.map(|p| ParsedContact {
public_key_hex: hex::encode(p.dest_hash),
advert_name: p.display_name.clone(),
last_advert: 0,
// Deliberately not 1 ("friend"/meshcore type), so the
// meshcore-only auto-heal `reset_contact_path` loop in
// `refresh_contacts` (session.rs) skips these — RNS does its
// own pathfinding, there is no firmware path to reset.
contact_type: 2,
path_len: if p.reachable { 1 } else { 0 },
flags: 0,
// RNS/LXMF is unconditionally E2E — see `take_rx_encrypted`.
// This field tracks Meshtastic's per-contact PKC capability,
// which has no Reticulum analogue (always true, tracked
// elsewhere via `take_rx_encrypted`), so leave it false here.
pkc_capable: false,
})
.collect())
}
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
self.drain_events().await;
Ok(self.inbound.drain(..).collect())
}
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
self.drain_events().await;
Ok(self.inbound.pop_front())
}
/// RNS/LXMF links are end-to-end encrypted by default (no plaintext mode),
/// so every inbound delivery is E2E. Unlike Meshtastic, which only
/// sometimes gets PKI delivery, this is unconditionally true.
pub fn take_rx_encrypted(&mut self) -> bool {
true
}
// ── internals ──────────────────────────────────────────────────────
async fn send_rpc(&mut self, req: Value) -> Result<()> {
let mut line = serde_json::to_vec(&req)?;
line.push(b'\n');
self.writer
.write_all(&line)
.await
.context("Reticulum daemon RPC write failed")?;
Ok(())
}
/// Drain any buffered daemon events (non-blocking) and translate them into
/// peer-table updates / synthetic InboundFrames.
async fn drain_events(&mut self) {
loop {
let mut line = String::new();
let read = tokio::time::timeout(
Duration::from_millis(20),
self.reader.read_line(&mut line),
)
.await;
let n = match read {
Ok(Ok(n)) => n,
_ => break, // timeout (no data) or read error — stop draining
};
if n == 0 {
warn!("Reticulum daemon RPC connection closed");
break;
}
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
continue;
};
self.handle_event(ev);
}
}
fn handle_event(&mut self, ev: Value) {
match ev.get("event").and_then(Value::as_str) {
Some("announce") => {
let Some(hash) = ev
.get("dest_hash")
.and_then(Value::as_str)
.and_then(|h| parse_hash16(h).ok())
else {
return;
};
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash);
let app_data_text = ev
.get("app_data")
.and_then(Value::as_str)
.and_then(|h| hex::decode(h).ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
.filter(|s| !s.is_empty());
// If the announce app_data is an ARCHY:n: identity blob (see
// daemon_command's doc comment), bind it onto this peer AND
// surface it through the SAME channel-text path
// meshcore/Meshtastic identity adverts use
// (frames::handle_channel_payload -> parse_identity_broadcast
// -> handle_identity_received -> bind_federation_twins), so a
// Reticulum-carried identity merges into the same conversation
// as that node's other-transport twins — zero new bind logic.
let is_identity_blob = app_data_text
.as_deref()
.map(|t| protocol::parse_identity_broadcast(t).is_some())
.unwrap_or(false);
if is_identity_blob {
let text = app_data_text.clone().unwrap();
let mut data = Vec::with_capacity(7 + text.len());
data.push(0); // channel index — unused by the identity path
data.extend_from_slice(&prefix);
data.extend_from_slice(text.as_bytes());
self.inbound.push_back(InboundFrame {
code: protocol::RESP_MESHTASTIC_CHANNEL_TEXT,
data,
bytes_consumed: 0,
});
}
let display_name = app_data_text
.filter(|_| !is_identity_blob)
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4])));
self.peers
.entry(hash)
.and_modify(|p| {
p.display_name = display_name.clone();
p.reachable = true;
})
.or_insert(ReticulumPeer {
dest_hash: hash,
display_name,
arch_pubkey_hex: None,
reachable: true,
});
}
Some("recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
return;
};
let Ok(source_hash) = parse_hash16(source_hex) else {
return;
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
let content = ev
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.as_bytes()
.to_vec();
self.inbound.push_back(build_synthetic_frame(&prefix, &content));
}
Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
return;
};
let Ok(source_hash) = parse_hash16(source_hex) else {
return;
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let Some(data) = ev
.get("data_b64")
.and_then(Value::as_str)
.and_then(|b64| B64.decode(b64).ok())
else {
warn!("resource_recv event with missing/invalid data_b64");
return;
};
// Resources carry the complete typed-envelope wire bytes
// directly (no MC-chunk/base64 textification needed — RNS
// Resources are already a binary-safe whole-blob transfer),
// so this is the same payload shape `decode.rs` already
// accepts for a single-frame (non-chunked) typed envelope.
self.inbound.push_back(build_synthetic_frame(&prefix, &data));
}
Some("resource_progress") => {
debug!(
id = ?ev.get("id"),
transferred = ?ev.get("transferred"),
total = ?ev.get("total"),
"Reticulum resource transfer progress"
);
}
Some("resource_sent") => {
debug!(id = ?ev.get("id"), "Reticulum resource transfer completed");
}
Some("resource_failed") => {
warn!(
id = ?ev.get("id"),
reason = ?ev.get("reason"),
"Reticulum resource transfer failed"
);
}
Some("delivered") | Some("status") | Some("ready") | Some("error") | None => {}
_ => {}
}
}
}
/// Build the synthetic `RESP_CONTACT_MSG_V3_E2E` InboundFrame for an inbound
/// LXMF message, byte-for-byte matching the layout Meshtastic already
/// produces (meshtastic.rs `parse_meshtastic_frame`) so `frames::handle_frame`
/// needs no Reticulum-specific branch:
/// [snr(1)=0][reserved(2)][sender_prefix(6)][path(1)=0xff][type(1)=0][rx_time(4 LE)][payload]
fn build_synthetic_frame(sender_prefix: &[u8; 6], payload: &[u8]) -> InboundFrame {
let mut data = Vec::with_capacity(15 + payload.len());
data.push(0); // SNR unknown (RNS doesn't expose per-packet SNR through LXMF)
data.extend_from_slice(&[0, 0]); // reserved
data.extend_from_slice(sender_prefix);
data.push(0xff); // path: RNS does its own multi-hop routing, not exposed here
data.push(0); // text type
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32;
data.extend_from_slice(&now.to_le_bytes());
data.extend_from_slice(payload);
InboundFrame {
// Reticulum/LXMF is always end-to-end encrypted — no plaintext mode.
code: protocol::RESP_CONTACT_MSG_V3_E2E,
data,
bytes_consumed: 0,
}
}
/// Derive a stable `u32` contact id from the 16-byte RNS destination hash,
/// masked to the low (non-federation-synthetic) id space. Sibling to
/// `meshtastic_contact_id` (listener/session.rs). Kept here so `initialize()`
/// can report a `node_id` consistent with what `refresh_contacts` will later
/// assign via the public helper of the same name in session.rs.
pub(crate) fn reticulum_contact_id_from_hash(hash: &[u8; 16]) -> u32 {
let raw = u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]);
let masked = raw & 0x7FFF_FFFF;
if masked == 0 {
1
} else {
masked
}
}
fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
let bytes = hex::decode(hex_str).context("invalid hex")?;
bytes
.try_into()
.map_err(|b: Vec<u8>| anyhow::anyhow!("expected 16 bytes, got {}", b.len()))
}
/// Send the verified RNode KISS detect sequence and look for `DETECT_RESP`.
/// Bytes confirmed against the canonical Reticulum source — see the module
/// doc comment and `docs/RETICULUM-TRANSPORT-PROGRESS.md`.
async fn probe_rnode(path: &str) -> Result<()> {
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
let probe: [u8; 13] = [
KISS_FEND,
KISS_CMD_DETECT,
KISS_DETECT_REQ,
KISS_FEND,
KISS_CMD_FW_VERSION,
0x00,
KISS_FEND,
KISS_CMD_PLATFORM,
0x00,
KISS_FEND,
KISS_CMD_MCU,
0x00,
KISS_FEND,
];
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
.await
.context("RNode probe write timed out")?
.context("RNode probe write failed")?;
let mut buf = [0u8; 256];
let mut seen = Vec::new();
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => {
seen.extend_from_slice(&buf[..n]);
if contains_detect_resp(&seen) {
return Ok(());
}
}
_ => continue,
}
}
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT)
}
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
/// buffer (KISS framing means other command responses may interleave first).
fn contains_detect_resp(buf: &[u8]) -> bool {
buf.windows(3)
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
}
impl Drop for ReticulumLink {
fn drop(&mut self) {
// Best-effort: ask the daemon to shut down cleanly (frees the serial
// port promptly); `kill_on_drop` on the Command is the hard backstop
// if the daemon doesn't exit in time.
let _ = self.child.start_kill();
let _ = std::fs::remove_file(&self.socket_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_resp_found_in_kiss_stream() {
let stream = [0x10, 0x20, KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP, 0x99];
assert!(contains_detect_resp(&stream));
}
/// Hardware gate: `probe_rnode` against a real RNode-flashed board. Not run in
/// CI (no hardware there) — run manually with
/// `ARCHY_RNODE_TEST_PORT=/dev/ttyUSB0 cargo test -p archipelago --lib
/// mesh::reticulum::tests::probe_rnode_detects_real_hardware -- --ignored --nocapture`
/// once an RNode-flashed board is attached. Confirms the byte-for-byte KISS
/// constants documented above (cited from canonical RNS source) actually work
/// against real firmware, not just the unit-tested byte matcher above.
#[tokio::test]
#[ignore = "requires a real RNode-flashed board; set ARCHY_RNODE_TEST_PORT"]
async fn probe_rnode_detects_real_hardware() {
let port = std::env::var("ARCHY_RNODE_TEST_PORT")
.expect("set ARCHY_RNODE_TEST_PORT to the RNode's serial path");
probe_rnode(&port).await.expect("KISS detect probe failed against real hardware");
}
#[test]
fn detect_resp_absent_in_unrelated_stream() {
let stream = [0x10, 0x20, 0x30, 0x40];
assert!(!contains_detect_resp(&stream));
}
#[test]
fn contact_id_masks_high_bit_and_avoids_zero() {
let hash_high_bit = {
let mut h = [0u8; 16];
h[0..4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
h
};
let id = reticulum_contact_id_from_hash(&hash_high_bit);
assert!(id < 0x8000_0000, "must not collide with federation-synthetic space");
assert_ne!(id, 0);
let zero_hash = [0u8; 16];
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1);
}
#[test]
fn synthetic_frame_matches_meshtastic_layout() {
let prefix = [1, 2, 3, 4, 5, 6];
let frame = build_synthetic_frame(&prefix, b"hello");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3_E2E);
// header is 15 bytes before the payload, per the documented layout
assert_eq!(&frame.data[3..9], &prefix);
assert_eq!(frame.data[9], 0xff);
assert_eq!(frame.data[10], 0);
assert_eq!(&frame.data[15..], b"hello");
}
}
+25
View File
@@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
pub enum DeviceType {
Meshcore,
Meshtastic,
/// A Reticulum (RNS/LXMF) RNode, bridged via the host-supervised
/// `reticulum-daemon` over its Unix-socket RPC — not driven in-process
/// like the other two. See `mesh/reticulum.rs`.
Reticulum,
Unknown,
}
@@ -18,11 +22,25 @@ impl std::fmt::Display for DeviceType {
match self {
Self::Meshcore => write!(f, "meshcore"),
Self::Meshtastic => write!(f, "meshtastic"),
Self::Reticulum => write!(f, "reticulum"),
Self::Unknown => write!(f, "unknown"),
}
}
}
/// The per-message transport pill label for a radio-delivered message: the
/// active device's own name, since one session owns exactly one device.
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only
/// covers the radio-class transports.
pub fn radio_transport_label(device_type: DeviceType) -> &'static str {
match device_type {
DeviceType::Meshcore => "meshcore",
DeviceType::Meshtastic => "meshtastic",
DeviceType::Reticulum => "reticulum",
DeviceType::Unknown => "lora",
}
}
/// A peer discovered via mesh radio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshPeer {
@@ -64,6 +82,12 @@ pub struct MeshPeer {
/// contact with no path and no recent advert is shown as unreachable.
#[serde(default)]
pub reachable: bool,
/// Whether DMs to/from this peer are end-to-end (PKI / Curve25519) encrypted.
/// Set for a Meshtastic peer once we know its real NodeInfo public key (the
/// firmware then PKC-encrypts directed DMs), so the send path can show the
/// E2E pill on a Sent DM to a PKC-capable stock peer, not only archy peers.
#[serde(default)]
pub pkc_capable: bool,
}
impl MeshPeer {
@@ -239,6 +263,7 @@ mod tests {
hops: 0,
last_advert: 0,
reachable: false,
pkc_capable: false,
}
}