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:
co-authored by
Claude Sonnet 5
parent
12e7990b10
commit
f54c853128
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user