diff --git a/core/archipelago/src/api/handler/blob.rs b/core/archipelago/src/api/handler/blob.rs index e8a7a528..23b72677 100644 --- a/core/archipelago/src/api/handler/blob.rs +++ b/core/archipelago/src/api/handler/blob.rs @@ -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/` 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; diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 07c2ca81..631407c0 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -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 diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index b53d19a4..e6a6b2f1 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -63,6 +63,16 @@ pub enum MeshCommand { dest_pubkey_prefix: [u8; 6], payload: Vec, }, + /// 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, + }, /// 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, + data_dir: std::path::PathBuf, device_path: Option, our_did: String, our_ed_pubkey_hex: String, @@ -380,6 +391,7 @@ pub fn spawn_mesh_listener( server_name: Option, lora_region: Option, channel_name: Option, + device_kind: Option, shutdown: tokio::sync::watch::Receiver, cmd_rx: mpsc::Receiver, ) -> 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, ) diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 3f96184c..e2ead014 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -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 { 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 { 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, +) -> 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, +) -> 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) // 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) // 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 { } } +/// 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 { + 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, + 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, shutdown: &mut tokio::sync::watch::Receiver, cmd_rx: &mut mpsc::Receiver, ) -> 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; diff --git a/core/archipelago/src/mesh/message_types.rs b/core/archipelago/src/mesh/message_types.rs index 49778858..fde0e21e 100644 --- a/core/archipelago/src/mesh/message_types.rs +++ b/core/archipelago/src/mesh/message_types.rs @@ -481,6 +481,29 @@ pub struct ReactionPayload { pub emoji: String, } +/// `Option>` <-> 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`, 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(v: &Option>, s: S) -> Result { + match v { + Some(bytes) => s.serialize_str(&STANDARD.encode(bytes)), + None => s.serialize_none(), + } + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let opt: Option = 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, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")] pub thumb_bytes: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub caption: Option, diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 9d0a6261..c0b0ee92 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -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, + /// 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, } 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, + type_label: &str, + display_text: &str, + typed_payload: Option, + sender_seq: u64, + ) -> Result { + 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, } } diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs new file mode 100644 index 00000000..5ffc997e --- /dev/null +++ b/core/archipelago/src/mesh/reticulum.rs @@ -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, + 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, + dest_hash: [u8; 16], + display_name: Option, + /// 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, + /// 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 { + 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 { + // 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 { + 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 { + 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> { + 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> { + self.drain_events().await; + Ok(self.inbound.drain(..).collect()) + } + + pub async fn try_recv_frame(&mut self) -> Result> { + 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::(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| 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"); + } +} diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index e34e9df1..5059909e 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -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, } } diff --git a/docs/RETICULUM-TRANSPORT-PROGRESS.md b/docs/RETICULUM-TRANSPORT-PROGRESS.md new file mode 100644 index 00000000..2dd80790 --- /dev/null +++ b/docs/RETICULUM-TRANSPORT-PROGRESS.md @@ -0,0 +1,292 @@ +# Reticulum mesh transport — progress tracker + +Living status doc for the Reticulum (RNS+LXMF) third-transport work. **Update this after every +meaningful step.** If a session is cut off mid-work, read this file first, then the plan, then +resume at "Next up." + +Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer: +`project_reticulum_transport_plan.md` (auto-memory index). + +**Coordination note (2026-06-30):** a separate agent owns concurrent Meshtastic work, scoped to +`mesh/meshtastic.rs` + `mesh/protocol.rs` (see `docs/SESSION-1.8.0-OTA-PROGRESS.md`) and explicitly +avoiding `mesh/listener/session.rs` transport plumbing + `mesh/mod.rs` routing, which this work +owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions. + +## Status at a glance + +| Phase | What | Status | +|---|---|---| +| 0 | Gate #1 — deterministic identity from Archy keys | ✅ **DONE**, verified in venv AND in the PyInstaller binary (same dest hash) | +| 0 | Gate #2 — two-node LXMF-over-LoRa on real hardware | ✅ **PASSED 2026-06-30** — real RF announce + encrypted DM exchanged between .116's Heltec V3 RNode and a phone-flashed second RNode running Sideband | +| 0 | Gate #3 — external Sideband/MeshChat interop | ✅ **PASSED 2026-06-30** — same session as gate #2; Sideband is the stock external client this gate calls for | +| 1 | `reticulum-daemon/` (Python rns+lxmf, Unix-socket RPC) | ✅ scaffolded + tested (no radio); signed-identity announce **also done** (see below) | +| 1 | Packaging — PyInstaller single binary | ✅ **DONE + verified** — `reticulum-daemon/build.sh`, 16M standalone binary, selftest passes run from `/tmp` with no venv on PATH | +| 2 | Rust wiring (`DeviceType`, `MeshRadioDevice`, `ReticulumLink`, stamp sites) | ✅ **`cargo check`/`cargo test -p archipelago` GREEN** (99 mesh tests pass) — still untested on real hardware | +| 2c | `MeshConfig.device_kind` reflashable-board pin | ✅ **DONE** this session (was the one open Phase-2 item) | +| 3 | Frontend (~8 label/CSS spots) | ✅ DONE (scoped down — see note below) | +| 4 | Multi-device (run all 3 radios at once) + per-network channels | ⏳ not started (follow-on, after 0–3) | + +## Checkpoint 2026-06-30 (late session — read this first if cut off) + +This session picked up after Phase 2/3 were already green, and closed out everything that didn't +need real RNode hardware: + +1. **Corrected two stale tracker entries** (both were already done, just not reflected here): + - The `_announce_app_data` "TODO" was actually already implemented: + `reticulum_daemon.py`'s `_announce_app_data()` embeds `ARCHY:2:{ed}:{x25519}` when + `--archy-ed-pubkey-hex`/`--archy-x25519-pubkey-hex` are passed, and `reticulum.rs`'s + `daemon_command()`/`open()` already forward `our_ed_pubkey_hex`/`our_x25519_pubkey_hex` from + `session.rs` (`run_mesh_session` → `auto_detect_and_open`/`open_preferred_path` → + `ReticulumLink::open`). Confirmed end-to-end by reading the call chain, not just grepping. + - Phase 3 frontend was already done (see prior entry below) — tracker table above said + "not started", now corrected. +2. **Added `MeshConfig.device_kind: Option`** (plan §2c, the one explicitly-listed + open Phase-2 item) — `mesh/mod.rs` (field + Default + threaded into `start()`'s + `spawn_mesh_listener` call), `listener/mod.rs` (`spawn_mesh_listener` param → `run_mesh_session` + arg), `listener/session.rs` (`run_mesh_session` param; `auto_detect_and_open` skips + non-matching probes per-path via `device_kind.is_none_or(|k| k == ...)`; + `open_preferred_path` restructured to a `match kind { ... }` that tries **only** the pinned + driver and surfaces its real error, instead of silently falling through to another firmware's + handshake on the same port). `None` (default) preserves today's strict + Meshcore→Meshtastic→Reticulum auto-detect — fully backward compatible, no config migration + needed. `cargo check` + `cargo test -p archipelago` both green after (99 mesh tests, 0 failed). +3. **Built and verified the PyInstaller packaging** (plan's Phase 1 "Packaging" + the file list's + "Ops: release packaging to include the daemon binary" item — previously undone): + - `reticulum-daemon/build.sh` (new) — reproducible build, installs `requirements-build.txt` + (new, `pyinstaller==6.21.0`, build-only/not shipped) into the existing `.venv`, runs + PyInstaller with flags discovered by trial: `--collect-submodules RNS --collect-submodules + LXMF --collect-data RNS -d noarchive`. + - **Non-obvious gotcha, written up in `build.sh`'s comments so it isn't re-discovered:** + `RNS.Interfaces/__init__.py` builds its `__all__` via `glob.glob(os.path.dirname(__file__) + + "/*.py")` at import time (`Reticulum.py` does `from RNS.Interfaces import *`). PyInstaller's + default `--onefile` zips pure-Python modules into an in-binary PYZ archive, so `__file__` + doesn't point at a real directory and the glob comes back empty → `NameError: name + 'Interface' is not defined` the moment `RNS.Reticulum(...)` is constructed. `-d noarchive` + (keep modules as loose `.pyc` files on disk inside the onefile bundle's runtime-extraction + dir) fixes it — confirmed by reproducing the failure first, then fixing it. + - **Verified, not just built:** ran the resulting `dist/archy-reticulum-daemon` binary's + `--check` (dest hash matches the venv-derived `06bb31e16f4f8d46a8ae8eac23a4fd21` for the + test seed) and `--selftest` (full RNS+LXMF bring-up, no radio) **both from `/tmp` with the + binary copied away from the repo and the `.venv` not on `PATH`** — confirms it's genuinely + self-contained, not accidentally still depending on the dev venv. + - `dist/`/`build/`/`*.spec` are already gitignored (`reticulum-daemon/.gitignore`); only + `build.sh` + `requirements-build.txt` are new tracked files. + +**NOT done this session (still genuinely open):** +- Everything hardware-dependent (Phase 0 gates #2/#3, real RNode probe/spawn). The .116 Heltec V3 + reflash mentioned in the prior session's memory was **not** done in this session — no physical + hardware access was exercised, only software. +- `/dev/reticulum-radio` udev symlink (plan §2c) — **deliberately not added**: the existing + `99-mesh-radio.rules` keys on USB vendor/product ID (e.g. CP2102 0x10c4/0xea60), but the whole + point of `device_kind` is that the *same* chip can run any of the three firmwares — a + vendor/product udev rule can't disambiguate them, and a fabricated rule would just be + misleading. Real fix needs either a per-device `ATTRS{serial}==...` rule the operator fills in + once they know their specific board's serial (no such board exists in-repo to template from + yet), or rely on `device_kind` alone (already done, works regardless of `/dev` path naming). + Revisit once a real RNode-flashed board's serial is known. +- PyInstaller binary not yet wired into the release tarball / `scripts/deploy-to-target.sh` (the + daemon binary path is currently resolved via `ARCHY_RETICULUM_DAEMON_BIN` env or the dev venv + fallback in `reticulum.rs`'s `daemon_command()` — production default + `/usr/local/bin/archy-reticulum-daemon` is a real path convention now that `build.sh` produces + exactly that filename, but nothing copies it there yet). Left undone deliberately — wiring + release-tarball plumbing for a binary that's never been run against real RNS network traffic + felt premature; do this once Phase 0 gates #2/#3 pass. + +## Phase 2 — Rust wiring detail (what's done vs left) + +**Done — `cargo check -p archipelago` is GREEN:** +- `core/archipelago/src/mesh/types.rs` — `DeviceType::Reticulum` (+ `Display` arm) + a + `radio_transport_label(DeviceType) -> &'static str` helper (`"reticulum"` vs `"lora"`). +- `core/archipelago/src/mesh/mod.rs` — all 4 outbound stamp sites use + `radio_transport_label(...)`; `use_typed_envelope` (~1571) extended to + `matches!(device_type, Meshcore | Reticulum)`; `data_dir` threaded into + `spawn_mesh_listener(...)` call (was: `MeshService::start()` → `spawn_mesh_listener`). +- `core/archipelago/src/mesh/listener/mod.rs` — `spawn_mesh_listener` takes `data_dir: + PathBuf`, passes `&data_dir` into `run_mesh_session`. +- `core/archipelago/src/mesh/listener/decode.rs:406,639` and `dispatch.rs:79` — all 3 inbound + stamp sites now use `radio_transport_label(state.status.read().await.device_type)`. +- `core/archipelago/src/mesh/listener/session.rs`: + - `MeshRadioDevice` enum has `Reticulum(ReticulumLink)`; all 18 method arms wired (no-ops: + `ensure_lora_region`, `ensure_channel`, `send_keepalive`, `send_nodeinfo_advert`, `reboot`, + `reset_contact_path`; everything else forwards to `ReticulumLink`). + - `auto_detect_and_open(data_dir: &Path)` and `open_preferred_path(path, data_dir: &Path)` + both now try `ReticulumLink::open(path, data_dir)` **last**, after Meshcore/Meshtastic — + cheap raw-serial KISS-detect probe runs first; the daemon only spawns on a confirmed match. + - `reticulum_contact_id()` helper added (delegates to the canonical + `reticulum::reticulum_contact_id_from_hash`, masked `& 0x7FFF_FFFF`, avoids 0). + - `refresh_contacts()` has an `is_reticulum` branch parallel to `is_meshtastic`; `reachable` + flows through `contact.path_len != 0` unchanged (`ReticulumLink::get_contacts()` already + encodes daemon-reported reachability into `path_len`). + - `data_dir: &Path` threaded through `run_mesh_session` → both probe functions. +- `core/archipelago/src/mesh/reticulum.rs` — **created**. `ReticulumLink`: spawns/supervises the + daemon as a child process, Unix-socket RPC client (matches the tested daemon contract), + `prefix_to_hash: HashMap<[u8;6],[u8;16]>` (mandatory per the plan), synthetic + `InboundFrame` builder byte-matching `meshtastic.rs`'s layout, `Drop` impl that kills the + daemon + cleans up the socket. Has unit tests (KISS-detect byte matching, contact-id masking, + synthetic-frame layout) — **passing, see below**. + +**Concurrent-edit note:** a separate in-flight change (not mine) added `MeshPeer.pkc_capable` +and `ParsedContact.pkc_capable` (Meshtastic PKI-capability tracking) while this work was in +progress. Accounted for: `reticulum.rs`'s `ParsedContact` literal sets `pkc_capable: false` +(Reticulum/LXMF is unconditionally E2E via `take_rx_encrypted()`, this field has no analogue); +two incomplete `MeshPeer` literals in `decode.rs` (lines ~330, ~548) were completed with +`pkc_capable: false` to unblock the build for everyone — not reverted, not worked around. + +**Self-review fix applied:** the RPC Unix socket originally lived in the shared system temp +dir; moved to `{data_dir}/reticulum/` (0700) instead — archipelago-owned, not shared `/tmp`, +matching the security posture. Re-confirmed `cargo check -p archipelago` GREEN after the move. + +**NOT yet done:** +- `MeshConfig.device_kind: Option` hint (optional reflashable-board disambiguator, + plan §2c) — not added. Auto-detect ordering (Meshcore→Meshtastic→Reticulum, strict probes) + is the only disambiguator right now. +- Phase 3 frontend — **DONE**, but **smaller scope than originally inventoried**: only + `Mesh.vue`'s `transportLabel()` (per-message field) + `mesh-styles.css` `.transport-reticulum` + + the `mesh.ts` doc comment needed the addition. `transport.ts` `TransportKind`, + `federation/types.ts` `last_transport`, `NodeList.vue` `transportBadge`, and `PeerFiles.vue` + `transportPill` are a COARSER routing-layer category (`mesh`/`lan`/`fips`/`tor`) where + `'mesh'` already covers any radio (meshcore/meshtastic/reticulum) — adding a separate + `'reticulum'` there would be inconsistent with how meshcore/meshtastic are handled. Confirmed + via `vue-tsc --noEmit` (exit 0, zero errors). +- Everything hardware-dependent: real daemon spawn/probe against an actual RNode (the .116 + Heltec V3, once reflashed), two-node LXMF-over-LoRa, the `_announce_app_data` signed-identity + TODO in the daemon (currently carries only the plaintext display name, not a verified Archy + DID/pubkey — needed for `bind_federation_twins`-style auto-binding across protocols). + +## Verified facts to reuse (don't re-derive) + +**RNode KISS-detect handshake** (confirmed against the canonical Reticulum source, not guessed): +``` +constants: FEND=0xC0 FESC=0xDB TFEND=0xDC TFESC=0xDD CMD_DETECT=0x08 DETECT_REQ=0x73 DETECT_RESP=0x46 +probe tx: C0 08 73 C0 50 00 C0 48 00 C0 49 00 C0 (detect + fw_version + platform + mcu queries) +success: response contains byte sequence ... C0 08 46 ... (FEND, CMD_DETECT, DETECT_RESP) +``` +Source: `RNS/Interfaces/RNodeInterface.py` (Liberated Systems mirror), `detect()`/`readLoop()`. + +**Synthetic `InboundFrame` layout** for a 1:1 DM, copied exactly from +`meshtastic.rs:1031-1047` (`ReticulumLink` must build the same shape so `frames::handle_frame` +needs zero changes): +``` +data = [snr(1)=0][reserved(2)=00,00][sender_prefix(6)][path(1)=0xff][type(1)=0][rx_time(4 LE)][payload…] +code = RESP_CONTACT_MSG_V3_E2E if encrypted else RESP_CONTACT_MSG_V3 (RNS/LXMF is always E2E, so always _E2E) +``` +Channel/broadcast equivalent (`RESP_MESHTASTIC_CHANNEL_TEXT`, meshtastic.rs:1019-1028) — N/A for +Reticulum in single-device Phase 2 (LXMF has no shared-channel concept); revisit in Phase 4. + +**`resolve_peer`** (decode.rs:316) matches inbound `sender_prefix` against +`peer.pubkey_hex.starts_with(prefix)` — so as long as `refresh_contacts`/announce-handling +populates `pubkey_hex` = full 16-byte RNS hash hex BEFORE a message arrives (same precondition +meshtastic relies on via its `peer_pubkeys` map), no Reticulum-specific fallback is needed there. + +**`ParsedContact.public_key_hex`** for Reticulum = hex of the 16-byte RNS dest hash (32 hex +chars, NOT 32 bytes) — the `hex::decode(...).len()==32` checks elsewhere (e.g. the auto-heal +`reset_contact_path` loop in `refresh_contacts`) will naturally skip Reticulum contacts since +their key decodes to 16 bytes, not 32. That's fine — no special-casing needed, just don't "fix" +it to be 32 bytes. + +**`data_dir.join("identity").join("node_key")`** is the 32-byte raw Ed25519 seed file — this is +exactly what `reticulum_daemon.py --identity-key ` expects (confirmed against +`identity.rs` `NODE_KEY_FILE`/`load_or_create`). The daemon reads the file itself — Rust should +pass the **path**, not pipe the raw key bytes through more hops than already exist. + +## Hardware update (2026-06-30) + +**.116 has a Heltec V3 available to reflash with RNode firmware.** This unblocks Phase 0 gates +#2/#3 (previously marked blocked — `.198`'s radio is dead, but .116's Heltec V3 is a real path +forward without needing new hardware). Next concrete step once reflashed: run +`reticulum-daemon/reticulum_daemon.py` pointed at the RNode's serial path, confirm `--check` +hash matches `--selftest`, then bring up two instances (.116 + .228, after .228 also gets an +RNode-capable board) for the real two-node LXMF-over-LoRa gate. + +## Daemon contract (already built + tested — Phase 2 codes against this, no changes needed) + +`reticulum-daemon/reticulum_daemon.py`, RPC over Unix socket (0600), one JSON object per line: +- in: `{"cmd":"send","dest_hash":hex16,"content":...}` / `{"cmd":"announce"}` / + `{"cmd":"status"}` / `{"cmd":"shutdown"}` +- out: `{"event":"ready",...}` / `{"event":"recv",...}` / `{"event":"announce",...}` / + `{"event":"delivered",...}` / `{"event":"status",...}` +Verified: `--check` (hash only), `--selftest` (boots real RNS+LXMF, no radio), and a live +socket round-trip (`ready`→`status`→`shutdown`, clean exit) — see `reticulum-daemon/README.md`. + +## Checkpoint 2026-06-30 (hardware session — gates #2/#3 PASSED) + +Picked up after a session pipe-break; the live system (archipelago.service + the spawned +`archy-reticulum-daemon`) had kept running uninterrupted the whole time, so nothing was lost. + +**What happened, in order:** +1. .116's Heltec V3 (CP2102, USB vendor/product `10c4:ea60`, serial `0001`) was reflashed with + RNode firmware and plugged into `/dev/mesh-radio` (generic udev symlink → `ttyUSB0`, not a + per-serial rule). `mesh-config.json` has `device_path: null` — pure auto-detect, no + `device_kind` pin needed. +2. Auto-detect correctly tried Meshcore → Meshtastic → Reticulum and found it: journal shows + `Found Reticulum (RNode) device via auto-detect path=/dev/mesh-radio` — but only **after** + ~4 min of `Failed to spawn reticulum-daemon — is it installed/packaged?` retries, because + `/usr/local/bin/archy-reticulum-daemon` hadn't been copied into place yet from + `reticulum-daemon/dist/` (built via `./build.sh`). Once copied (sha256-verified match to the + `dist/` build), auto-detect succeeded on the very next retry. +3. `mesh.status` RPC confirmed live: `device_type: "reticulum"`, `device_connected: true`, + `dest_hash: 5d146f6e1c9707f89468b5016ed6dfad`. Periodic self-advert (`send_self_advert` → + `{"cmd":"announce"}` → real RNS `Identity.announce()`) firing every ~30s — confirmed this is + **not** the `send_nodeinfo_advert` no-op arm (that one's still legitimately a no-op for + Reticulum; the real announce path is `send_self_advert`, wired correctly). +4. Second RNode flashed onto a phone running **Sideband**. First attempt showed RF energy + (`interference_last_dbm` climbing) but `rxb: 0` — a parameter mismatch, **not** a frequency + problem (energy was detected, just not demodulated). Root cause: Spreading Factor mismatch + in Sideband's manual RNode interface config (frequency display rounds to one decimal so + "869.5" silently passed at first glance — bandwidth/SF/CR are separate fields and SF was + wrong). Once SF was corrected to match (freq `869525000`, BW `125000`, **SF `8`**, CR `5`), + `rxb` went non-zero immediately and a real `{"event":"announce","dest_hash":"1870744d...", + "app_data":"7a617a61"}` (hex for "zaza") arrived over the air. +5. **Gate #2 + gate #3 both passed in the same exchange**: `zaza` shows up as a real, reachable + `mesh.peers` contact; an inbound encrypted LXMF message ("Yoooo") arrived and was correctly + stamped `encrypted: true, transport: "reticulum"`; a reply was sent back and round-tripped. + Sideband is exactly the stock external client gate #3 calls for, so one real RNode-to-RNode + LoRa link covered both gates — no need for a second dedicated archy node. +6. **Two real bugs found from this, both fixed:** + - `record_sent_typed`'s `encrypted` flag was hardcoded `false`/`archy || pkc_capable` on the + Reticulum send path (both the native-text path in `send_message` and the typed-envelope + path in `send_typed_wire`) — correct for Meshcore/Meshtastic (where E2E really is + conditional on PKI/session state not yet threaded through), **wrong** for Reticulum: LXMF + encrypts every send to the destination identity key unconditionally, archy peer or not. + Fixed: both call sites now OR in `device_type == DeviceType::Reticulum`. + - `radio_transport_label()` collapsed Meshcore **and** Meshtastic into one generic `"lora"` + string, so the per-message pill couldn't distinguish them. User asked for 3 distinct pill + colors (Meshtastic mint, Meshcore orange, Reticulum blue) — extended the label fn to + return `"meshtastic"`/`"meshcore"`/`"reticulum"` distinctly, updated `Mesh.vue`'s + `transportLabel()` switch and `mesh-styles.css` (`.transport-meshtastic` `#3eb489`, + `.transport-meshcore` `#fb923c`, `.transport-reticulum` `#60a5fa`; kept `.transport-lora` + `#f59e0b` as a fallback for any already-stored legacy-labelled messages). `cargo check` + + `vue-tsc --noEmit` both green after. + +**NOT yet done:** +- The Rust-side fix above (`encrypted` flag, transport-label split) is built but **not yet + deployed to .116's running binary** — the live daemon/auto-detect verification above was all + against the binary already running before this session's edits. Rebuild + redeploy to see the + fix live. +- `tests/lifecycle/run-gate.sh` not re-run after these mesh changes yet (project convention: + run after backend changes land). +- Multi-device (3 radios at once, Phase 4) and the release-tarball/udev-rule wiring (originally + "Next up" #6 below) are both still untouched. + +## Next up (resume here) + +Phase 0 gates #1–#3 are now **all passed**. What's left: + +1. Rebuild the backend + frontend and redeploy to .116 so the `encrypted`-flag fix and the + 3-way transport-pill color split actually take effect on the live node (currently only + checked in with `cargo check`/`vue-tsc`, not deployed). +2. Re-verify on-device after redeploy: send another Sideband↔archy DM, confirm the Sent bubble + now shows E2E + a blue "Reticulum" pill, and confirm Meshtastic/Meshcore pills (if any + messages exist) render mint/orange instead of the old generic amber "LoRa". +3. Exercise the rest of the plan's "Verification (definition of done)" items: hot-swap + detection (unplug the RNode mid-session, confirm fallback to FIPS/Tor on the same contact; + replug, confirm it picks Reticulum back up), and `device_kind: Some(Reticulum)` pin path + (currently only auto-detect has been exercised on real hardware). +4. Run `tests/lifecycle/run-gate.sh` to confirm no regression from the mesh changes landing. +5. Only after the above: wire `dist/archy-reticulum-daemon` into the release tarball / + `scripts/deploy-to-target.sh` (target path `/usr/local/bin/archy-reticulum-daemon`, matching + `reticulum.rs`'s default) and add a per-serial-number `/dev/reticulum-radio` udev rule now + that a real board's serial number (`0001` on the CP2102, .116's board) is known — though a + second board will likely report the same `0001` stock serial since CP2102 modules commonly + ship with an unprogrammed default, so this may still need a different disambiguator. +6. Phase 4 (run all 3 radios at once) — still not started, follow-on after the above. diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 8a726dc5..eb770eff 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -78,8 +78,9 @@ export interface MeshMessage { timestamp: string delivered: boolean encrypted: boolean - /// How the message traveled: "lora" (mesh radio), "fips", or "tor". - /// Drives the per-message transport pill. Absent until known. + /// How the message traveled: "meshtastic", "meshcore", "reticulum" (radio + /// transports, one per device kind), "fips", or "tor". Drives the + /// per-message transport pill. Absent until known. transport?: string | null message_type?: MeshMessageTypeLabel // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -459,7 +460,7 @@ export const useMeshStore = defineStore('mesh', () => { async function transportAdvice(contactId: number, size: number) { return rpcClient.call<{ - tier: 'auto-mesh' | 'choose' | 'tor-only' | 'impossible' + tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible' est_seconds: number has_tor: boolean reason: string diff --git a/neode-ui/src/utils/imageCompression.ts b/neode-ui/src/utils/imageCompression.ts new file mode 100644 index 00000000..04f1cce1 --- /dev/null +++ b/neode-ui/src/utils/imageCompression.ts @@ -0,0 +1,115 @@ +// Client-side image compression presets for the mesh chat attachment picker, +// mirroring Columba's ImageCompressionPreset (Low/Medium/High/Original) — +// resize + iteratively-quality-reduced JPEG, entirely in the browser so no +// extra round-trip is needed before the existing send pipeline takes over. + +export interface ImageCompressionPreset { + key: 'low' | 'medium' | 'high' | 'original' + displayName: string + description: string + maxDimensionPx: number + targetBytes: number + initialQuality: number + minQuality: number +} + +export const IMAGE_COMPRESSION_PRESETS: ImageCompressionPreset[] = [ + { + key: 'low', + displayName: 'Low', + description: '32KB max — best for LoRa', + maxDimensionPx: 320, + targetBytes: 32 * 1024, + initialQuality: 60, + minQuality: 30, + }, + { + key: 'medium', + displayName: 'Medium', + description: '128KB max — balanced', + maxDimensionPx: 800, + targetBytes: 128 * 1024, + initialQuality: 75, + minQuality: 40, + }, + { + key: 'high', + displayName: 'High', + description: '512KB max — good quality', + maxDimensionPx: 2048, + targetBytes: 512 * 1024, + initialQuality: 90, + minQuality: 50, + }, + { + key: 'original', + displayName: 'Original', + description: 'No compression', + maxDimensionPx: Infinity, + targetBytes: Infinity, + initialQuality: 100, + minQuality: 100, + }, +] + +/** Resize + iteratively shrink JPEG quality until under the preset's target size + * (or `minQuality` is reached, whichever comes first). `original` is a no-op. */ +export async function compressImage(file: File, preset: ImageCompressionPreset): Promise { + if (preset.key === 'original') return file + + const bitmap = await createImageBitmap(file) + const { width, height } = scaledDimensions(bitmap.width, bitmap.height, preset.maxDimensionPx) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Canvas 2D context unavailable') + ctx.drawImage(bitmap, 0, 0, width, height) + bitmap.close() + + let quality = preset.initialQuality / 100 + let blob = await canvasToJpegBlob(canvas, quality) + while (blob.size > preset.targetBytes && quality > preset.minQuality / 100) { + quality = Math.max(quality - 0.1, preset.minQuality / 100) + blob = await canvasToJpegBlob(canvas, quality) + } + + const name = file.name.replace(/\.[^./\\]+$/, '') + '.jpg' + return new File([blob], name, { type: 'image/jpeg', lastModified: Date.now() }) +} + +/** Tiny low-res JPEG (default 64px / low quality) for the `thumb_bytes` field + * on a ContentRef message — shown immediately on receipt, before the full + * image is fetched. */ +export async function makeThumbnail(file: File, maxDimensionPx = 64, quality = 0.4): Promise { + const bitmap = await createImageBitmap(file) + const { width, height } = scaledDimensions(bitmap.width, bitmap.height, maxDimensionPx) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Canvas 2D context unavailable') + ctx.drawImage(bitmap, 0, 0, width, height) + bitmap.close() + + const blob = await canvasToJpegBlob(canvas, quality) + return new Uint8Array(await blob.arrayBuffer()) +} + +function scaledDimensions(width: number, height: number, maxDimensionPx: number): { width: number; height: number } { + if (!Number.isFinite(maxDimensionPx) || (width <= maxDimensionPx && height <= maxDimensionPx)) { + return { width, height } + } + const scale = maxDimensionPx / Math.max(width, height) + return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) } +} + +function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('canvas.toBlob failed'))), + 'image/jpeg', + quality, + ) + }) +} diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index bf561921..a21da110 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -11,6 +11,7 @@ import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue' import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue' import { rpcClient } from '@/api/rpc-client' import { wsClient } from '@/api/websocket' +import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression' import '@/views/mesh/mesh-styles.css' const mesh = useMeshStore() @@ -1120,11 +1121,13 @@ function isEditedMessage(msg: MeshMessage): number | null { function isDeletedMessage(msg: MeshMessage): boolean { return msg.message_type === 'delete' || msg.typed_payload?.deleted === true } -/// Short label for the per-message transport pill (LoRa / FIPS / Tor), or null -/// when the transport isn't known. Covers both meshcore and meshtastic since -/// the field lives on the shared MeshMessage. +/// Short label for the per-message transport pill (Meshtastic / Meshcore / +/// Reticulum / FIPS / Tor), or null when the transport isn't known. function transportLabel(msg: MeshMessage): string | null { switch (msg.transport) { + case 'meshtastic': return 'Meshtastic' + case 'meshcore': return 'Meshcore' + case 'reticulum': return 'Reticulum' case 'lora': return 'LoRa' case 'fips': return 'FIPS' case 'tor': return 'Tor' @@ -1277,6 +1280,34 @@ const attachError = ref(null) const fetchingCids = ref>(new Set()) const fetchedUrls = ref>(new Map()) +// Auto-render attachments whose bytes are already local — an inline +// (mesh.send-content-inline) ContentRef has its bytes written to our +// BlobStore the moment it's sent/received (dispatch.rs), so there's no real +// fetch to wait on; skip the explicit "Download" click for those. Runs +// `immediate: true` so already-loaded history gets the same treatment as +// newly-arriving messages. +const autoFetchedCids = new Set() +watch( + () => chatMessages.value.length, + () => { + for (const msg of chatMessages.value) { + const payload = msg.typed_payload as { cid?: string; inline?: boolean } | undefined + if ( + msg.message_type === 'content_ref' && + payload?.inline && + payload.cid && + !fetchedUrls.value.has(payload.cid) && + !fetchingCids.value.has(payload.cid) && + !autoFetchedCids.has(payload.cid) + ) { + autoFetchedCids.add(payload.cid) + void handleFetchContent(msg.typed_payload as any) + } + } + }, + { immediate: true }, +) + // Transport chooser modal state — populated when advice comes back as // "choose" (size fits both inline-over-mesh AND Tor). User picks a path; // `transportChoiceResolve` finishes the promise started by handleAttachFile. @@ -1297,6 +1328,51 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') { transportChoice.value = null } +// Image quality-picker modal — shown before sending an image attachment. +// Presets skip 'original'-specific compression (handled in compressImage) +// but still show a transfer-time estimate for it, fetched the same way as +// every other preset via the existing mesh.transport-advice RPC. +const imageQualityChoice = ref<{ file: File } | null>(null) +const imageQualityEstimates = ref>(new Map()) +let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null + +function formatEstSeconds(seconds: number): string { + if (seconds < 60) return `~${seconds}s` + return `~${Math.round(seconds / 60)}m` +} + +async function openImageQualityDialog(file: File, peerContactId: number): Promise { + imageQualityChoice.value = { file } + imageQualityEstimates.value = new Map() + // Fire off estimates for all presets in parallel — each preset's nominal + // target size (or the real file size for 'original') against the SAME + // mesh.transport-advice RPC the non-image attach flow already uses. + void Promise.all( + IMAGE_COMPRESSION_PRESETS.map(async (preset) => { + const size = preset.key === 'original' ? file.size : Math.min(preset.targetBytes, file.size) + try { + const advice = await mesh.transportAdvice(peerContactId, size) + const label = + advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds) + imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label) + } catch { + imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?') + } + }), + ) + return new Promise((resolve) => { + imageQualityResolve = resolve + }) +} + +function pickImageQuality(preset: ImageCompressionPreset | null) { + if (imageQualityResolve) { + imageQualityResolve(preset) + imageQualityResolve = null + } + imageQualityChoice.value = null +} + async function resolveFederationOnion(peerName: string): Promise { try { const fed = await rpcClient.federationListNodes() @@ -1326,13 +1402,24 @@ async function sendViaMeshInline(file: File, peerContactId: number) { async function sendViaTorContentRef(file: File, peerContactId: number, peerName: string) { const buf = await file.arrayBuffer() + const headers: Record = { + 'X-Blob-Mime': file.type || 'application/octet-stream', + 'X-Blob-Filename': file.name, + 'Content-Type': 'application/octet-stream', + } + // Tiny thumbnail so the receiver sees a preview immediately instead of + // waiting on an explicit fetch — see the content_ref render branch below. + if (file.type.startsWith('image/')) { + try { + const thumb = await makeThumbnail(file) + headers['X-Blob-Thumb'] = btoa(String.fromCharCode(...thumb)) + } catch { + // Best-effort — a missing thumbnail just means no preview, not a failed send. + } + } const up = await fetch('/api/blob', { method: 'POST', - headers: { - 'X-Blob-Mime': file.type || 'application/octet-stream', - 'X-Blob-Filename': file.name, - 'Content-Type': 'application/octet-stream', - }, + headers, credentials: 'include', body: buf, }) @@ -1342,9 +1429,50 @@ async function sendViaTorContentRef(file: File, peerContactId: number, peerName: await mesh.sendContent(peerContactId, cid, messageText.value.trim() || undefined, peerOnion) } +/** Resolve the best transport for `file` via mesh.transport-advice (prompting + * with the transport-chooser modal for the ambiguous "choose" tier) and send + * it. Shared by the file-attach flow and voice messages — both just need + * "given a File and a peer, get it there". Returns false if the user + * cancelled or the send was rejected as impossible (caller already informed + * via attachError); true on success. */ +async function sendFileViaBestTransport(file: File, peer: MeshPeer): Promise { + const advice = await mesh.transportAdvice(peer.contact_id, file.size) + let transport: 'mesh' | 'tor' | 'cancel' + if (advice.tier === 'auto-mesh' || advice.tier === 'resource-mesh') { + // 'resource-mesh' (Reticulum-only, large-over-LoRa via RNS Resource) is + // routed by the SAME mesh.send-content-inline call as 'auto-mesh' — the + // backend decides internally whether to use the small inline-chunk path + // or a Resource transfer based on size + active device type. + transport = 'mesh' + } else if (advice.tier === 'tor-only') { + transport = 'tor' + } else if (advice.tier === 'impossible') { + attachError.value = `Cannot send: ${advice.reason} (${(file.size / 1024).toFixed(1)} KB)` + return false + } else { + // "choose" — open modal and wait for user to pick + transport = await new Promise<'mesh' | 'tor' | 'cancel'>((resolve) => { + transportChoiceResolve = resolve + transportChoice.value = { + file, + size: file.size, + est_seconds: advice.est_seconds, + has_tor: advice.has_tor, + } + }) + if (transport === 'cancel') return false + } + if (transport === 'mesh') { + await sendViaMeshInline(file, peer.contact_id) + } else { + await sendViaTorContentRef(file, peer.contact_id, peer.advert_name) + } + return true +} + async function handleAttachFile(ev: Event) { const input = ev.target as HTMLInputElement - const file = input.files?.[0] + let file = input.files?.[0] if (!file) return if (!activeChatPeer.value) { attachError.value = 'Pick a peer first' @@ -1355,33 +1483,12 @@ async function handleAttachFile(ev: Event) { attaching.value = true attachError.value = null try { - const advice = await mesh.transportAdvice(peer.contact_id, file.size) - let transport: 'mesh' | 'tor' | 'cancel' - if (advice.tier === 'auto-mesh') { - transport = 'mesh' - } else if (advice.tier === 'tor-only') { - transport = 'tor' - } else if (advice.tier === 'impossible') { - attachError.value = `Cannot send: ${advice.reason} (${(file.size / 1024).toFixed(1)} KB)` - return - } else { - // "choose" — open modal and wait for user to pick - transport = await new Promise<'mesh' | 'tor' | 'cancel'>((resolve) => { - transportChoiceResolve = resolve - transportChoice.value = { - file, - size: file.size, - est_seconds: advice.est_seconds, - has_tor: advice.has_tor, - } - }) - if (transport === 'cancel') return - } - if (transport === 'mesh') { - await sendViaMeshInline(file, peer.contact_id) - } else { - await sendViaTorContentRef(file, peer.contact_id, peer.advert_name) + if (file.type.startsWith('image/')) { + const preset = await openImageQualityDialog(file, peer.contact_id) + if (!preset) return // user cancelled + file = await compressImage(file, preset) } + if (!(await sendFileViaBestTransport(file, peer))) return messageText.value = '' nextTick(() => scrollChatToBottom()) } catch (e) { @@ -1392,6 +1499,66 @@ async function handleAttachFile(ev: Event) { } } +// Voice messages — async/store-and-forward (a recorded clip sent as a normal +// attachment), NOT a live call; reuses sendFileViaBestTransport exactly like +// any other file. Hold-to-record: press the mic button, release to send. +const isRecordingVoice = ref(false) +let voiceRecorder: MediaRecorder | null = null +let voiceRecorderStream: MediaStream | null = null +let voiceChunks: Blob[] = [] + +async function startVoiceRecording() { + if (isRecordingVoice.value || attaching.value || !activeChatPeer.value) return + try { + voiceRecorderStream = await navigator.mediaDevices.getUserMedia({ audio: true }) + } catch (e) { + attachError.value = e instanceof Error ? e.message : 'Microphone access denied' + return + } + voiceChunks = [] + voiceRecorder = new MediaRecorder(voiceRecorderStream, { mimeType: 'audio/webm;codecs=opus' }) + voiceRecorder.ondataavailable = (e) => { + if (e.data.size > 0) voiceChunks.push(e.data) + } + voiceRecorder.start() + isRecordingVoice.value = true +} + +async function stopVoiceRecording() { + if (!isRecordingVoice.value || !voiceRecorder) return + const recorder = voiceRecorder + const stream = voiceRecorderStream + isRecordingVoice.value = false + voiceRecorder = null + voiceRecorderStream = null + const blob = await new Promise((resolve) => { + recorder.onstop = () => resolve(new Blob(voiceChunks, { type: 'audio/webm' })) + recorder.stop() + }) + stream?.getTracks().forEach((t) => t.stop()) + if (blob.size === 0 || !activeChatPeer.value) return + + const peer = activeChatPeer.value + const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' }) + attaching.value = true + attachError.value = null + try { + if (await sendFileViaBestTransport(file, peer)) { + nextTick(() => scrollChatToBottom()) + } + } catch (e) { + attachError.value = e instanceof Error ? e.message : 'Failed to send voice message' + } finally { + attaching.value = false + } +} + +/** pointerleave while still holding (e.g. dragged off the button) — stop and + * send rather than silently discarding the in-progress recording. */ +function stopVoiceRecordingIfActive() { + if (isRecordingVoice.value) void stopVoiceRecording() +} + async function handleFetchContent(payload: { cid: string sender_onion: string @@ -1829,12 +1996,24 @@ function isImageMime(mime?: string): boolean { class="mesh-typed-content-preview" alt="attachment" /> +