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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-30 19:57:01 -04:00
parent 12e7990b10
commit f54c853128
21 changed files with 2696 additions and 114 deletions

View File

@ -48,6 +48,17 @@ impl ApiHandler {
.get("x-blob-filename")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Optional caller-supplied thumbnail (small, base64) — e.g. the mesh
// chat's image-quality picker generates a tiny client-side preview so
// a ContentRef receiver can render something before fetching the full
// blob. Best-effort: a malformed header is just ignored, not fatal.
let thumb_bytes = headers
.get("x-blob-thumb")
.and_then(|v| v.to_str().ok())
.and_then(|b64| {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(b64).ok()
});
let bytes = body.to_vec();
// Uploads through /api/blob come from the node owner's session and
@ -55,7 +66,7 @@ impl ApiHandler {
// pictures, banners). Store them public so `/blob/<cid>` serves
// without a capability check — external Nostr clients fetching a
// kind-0 `picture` URL have no cap and can't get one.
match store.put(&bytes, &mime, filename, None, true).await {
match store.put(&bytes, &mime, filename, thumb_bytes, true).await {
Ok(meta) => {
let exp =
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;

View File

@ -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

View File

@ -63,6 +63,16 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Send pre-encoded binary over a dedicated Reticulum RNS Resource
/// transfer instead of the small inline-chunk path — Reticulum-only, see
/// `MeshRadioDevice::send_resource`. Used for large attachments
/// (compressed photos, voice messages) that exceed the small-message cap
/// but fit a sane LoRa-Resource budget; routing decision is made by the
/// RPC layer (`mesh.transport-advice`'s `"resource-mesh"` tier).
SendResource {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Send PLAIN text as one or more native meshcore DMs to a stock client
/// (e.g. a phone). Long text is split into multiple readable plain messages
/// — never MC-chunked — because stock clients can't reassemble archy's
@ -372,6 +382,7 @@ impl MeshState {
/// 4. Reconnect on disconnect
pub fn spawn_mesh_listener(
state: Arc<MeshState>,
data_dir: std::path::PathBuf,
device_path: Option<String>,
our_did: String,
our_ed_pubkey_hex: String,
@ -380,6 +391,7 @@ pub fn spawn_mesh_listener(
server_name: Option<String>,
lora_region: Option<String>,
channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
@ -395,6 +407,7 @@ pub fn spawn_mesh_listener(
match session::run_mesh_session(
&state,
&data_dir,
device_path.as_deref(),
&our_did,
&our_ed_pubkey_hex,
@ -403,6 +416,7 @@ pub fn spawn_mesh_listener(
server_name.as_deref(),
lora_region.as_deref(),
channel_name.as_deref(),
device_kind,
&mut shutdown,
&mut cmd_rx,
)

View File

@ -1,6 +1,7 @@
//! Mesh session lifecycle: connect, initialize, main loop.
use super::super::meshtastic::MeshtasticDevice;
use super::super::reticulum::ReticulumLink;
use super::super::serial::MeshcoreDevice;
use super::super::types::*;
use super::{
@ -8,6 +9,7 @@ use super::{
SYNC_INTERVAL,
};
use anyhow::{Context, Result};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
@ -16,6 +18,7 @@ use tracing::{debug, error, info, warn};
enum MeshRadioDevice {
Meshcore(MeshcoreDevice),
Meshtastic(MeshtasticDevice),
Reticulum(ReticulumLink),
}
impl MeshRadioDevice {
@ -23,6 +26,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(_) => DeviceType::Meshcore,
Self::Meshtastic(_) => DeviceType::Meshtastic,
Self::Reticulum(_) => DeviceType::Reticulum,
}
}
@ -30,6 +34,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.advert_name.clone(),
Self::Meshtastic(device) => device.advert_name(),
Self::Reticulum(device) => device.advert_name(),
}
}
@ -37,6 +42,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.set_advert_name(name).await,
Self::Meshtastic(device) => device.set_advert_name(name).await,
Self::Reticulum(device) => device.set_advert_name(name).await,
}
}
@ -44,11 +50,14 @@ impl MeshRadioDevice {
/// their own band on the device, so this is a no-op for them; Meshtastic
/// radios ship region-UNSET (RF-silent) and must be set or they never mesh.
/// Returns `Ok(true)` when a region was written (the device reboots to
/// apply, so the caller should restart the session).
/// apply, so the caller should restart the session). No-op for Reticulum:
/// the daemon's RNodeInterface config carries its own LoRa profile, not
/// driven through this firmware-admin path.
async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result<bool> {
match self {
Self::Meshcore(_) => Ok(false),
Self::Meshtastic(device) => device.ensure_lora_region(region).await,
Self::Reticulum(_) => Ok(false),
}
}
@ -56,11 +65,14 @@ impl MeshRadioDevice {
/// other. No-op for meshcore (it joins its channel by name on the device);
/// Meshtastic radios can sit on mismatched channels otherwise and silently
/// drop every packet as undecryptable. Returns `Ok(true)` when a channel was
/// written (device reboots; caller should restart the session).
/// written (device reboots; caller should restart the session). No-op for
/// Reticulum: RNS has no shared-PSK channel concept (see
/// `ReticulumLink::send_channel_text`).
async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
match self {
Self::Meshcore(_) => Ok(false),
Self::Meshtastic(device) => device.ensure_channel(channel_name).await,
Self::Reticulum(_) => Ok(false),
}
}
@ -68,28 +80,33 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.send_self_advert().await,
Self::Meshtastic(device) => device.send_self_advert().await,
Self::Reticulum(device) => device.send_self_advert().await,
}
}
/// Lightweight serial keepalive (Meshtastic only). Keeps the firmware
/// streaming RECEIVED packets to our serial client — without it the radio
/// can mark a quiet client gone and deliver only our own queue-status.
/// Meshcore needs no such ping.
/// Meshcore/Reticulum need no such ping (Reticulum's "serial" traffic is
/// the daemon's own RNS link, not a firmware queue we poll).
async fn send_keepalive(&mut self) -> Result<()> {
match self {
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.send_keepalive().await,
Self::Reticulum(_) => Ok(()),
}
}
/// Actively advertise our identity over the air. Meshcore already does this
/// inside `send_self_advert` (CMD_SEND_SELF_ADVERT), so this is a no-op for
/// it; Meshtastic needs an explicit NodeInfo broadcast or peers never learn
/// about an already-running node.
/// about an already-running node. No-op for Reticulum: its `announce` (via
/// `send_self_advert`) already covers discovery.
async fn send_nodeinfo_advert(&mut self, want_response: bool) -> Result<()> {
match self {
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.send_nodeinfo_broadcast(want_response).await,
Self::Reticulum(_) => Ok(()),
}
}
@ -97,6 +114,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
Self::Reticulum(device) => device.send_channel_text(channel, payload).await,
}
}
@ -104,15 +122,34 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
Self::Reticulum(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
}
}
/// Send `data` over a dedicated RNS Resource transfer instead of the
/// small-payload "content" path — only Reticulum has anything resembling
/// this (a native large-binary transfer protocol over a `RNS.Link`).
/// Meshcore/Meshtastic have no equivalent in our driver; callers must
/// check `device_type() == DeviceType::Reticulum` before reaching for
/// this (see `mesh.transport-advice`'s `"resource-mesh"` tier, which is
/// Reticulum-only), so an Err here means the caller's gating is wrong,
/// not a legitimate no-op.
async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
match self {
Self::Meshcore(_) | Self::Meshtastic(_) => {
anyhow::bail!("Resource transfer is Reticulum-only")
}
Self::Reticulum(device) => device.send_resource(dest_pubkey_prefix, data).await,
}
}
async fn reboot(&mut self, seconds: i64) -> Result<()> {
match self {
// Meshcore has no equivalent local-admin reboot in our driver; the
// RX-deaf recovery this targets is Meshtastic-specific.
// Meshcore/Reticulum have no equivalent local-admin reboot in our
// driver; the RX-deaf recovery this targets is Meshtastic-specific.
Self::Meshcore(_) => Ok(()),
Self::Meshtastic(device) => device.reboot(seconds).await,
Self::Reticulum(_) => Ok(()),
}
}
@ -120,6 +157,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.remove_contact(pubkey).await,
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
Self::Reticulum(device) => device.remove_contact(pubkey).await,
}
}
@ -143,6 +181,11 @@ impl MeshRadioDevice {
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
Self::Reticulum(device) => {
device
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
}
}
@ -150,6 +193,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.get_contacts().await,
Self::Meshtastic(device) => device.get_contacts().await,
Self::Reticulum(device) => device.get_contacts().await,
}
}
@ -157,6 +201,8 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
// RNS does its own pathfinding — no firmware path table to reset.
Self::Reticulum(_) => Ok(()),
}
}
@ -164,6 +210,7 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.sync_messages().await,
Self::Meshtastic(device) => device.sync_messages().await,
Self::Reticulum(device) => device.sync_messages().await,
}
}
@ -171,46 +218,89 @@ impl MeshRadioDevice {
match self {
Self::Meshcore(device) => device.try_recv_frame().await,
Self::Meshtastic(device) => device.try_recv_frame().await,
Self::Reticulum(device) => device.try_recv_frame().await,
}
}
/// PKI-E2E status of the last inbound frame (meshtastic only; meshcore's
/// per-message E2E is derived in the frames decrypt path). Take-and-clear.
/// per-message E2E is derived in the frames decrypt path). Reticulum/LXMF
/// is unconditionally E2E (no plaintext mode), so it always reports true.
/// Take-and-clear.
fn take_rx_encrypted(&mut self) -> bool {
match self {
Self::Meshcore(_) => false,
Self::Meshtastic(device) => device.take_rx_encrypted(),
Self::Reticulum(device) => device.take_rx_encrypted(),
}
}
}
/// Scan all candidate serial ports and open the first supported mesh device found.
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
///
/// `device_kind`, when set, pins the expected firmware (operator-confirmed via
/// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only
/// that one device's probe runs, so a non-matching firmware's init bytes are
/// never injected into the port. `None` keeps the strict
/// Meshcore→Meshtastic→Reticulum probe order.
async fn auto_detect_and_open(
data_dir: &Path,
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
}
for path in &paths {
debug!(path = %path, "Probing for mesh radio device");
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
if device_kind.is_none_or(|k| k == DeviceType::Meshcore) {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
}
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
if device_kind.is_none_or(|k| k == DeviceType::Meshtastic) {
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
}
}
// Tried LAST: the same reflashable board (e.g. Heltec V3) can run
// Meshcore, Meshtastic, or RNode firmware, so each probe must fail
// strictly before the next is attempted. The RNode KISS-detect probe
// is the most expensive (spawns the supervised daemon on a match), so
// it goes after the two cheap firmware-specific handshakes above.
if device_kind.is_none_or(|k| k == DeviceType::Reticulum) {
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
}
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
},
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
}
}
}
anyhow::bail!(
@ -220,7 +310,57 @@ async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)>
)
}
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
async fn open_preferred_path(
path: &str,
data_dir: &Path,
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
device_kind: Option<DeviceType>,
) -> Result<(MeshRadioDevice, DeviceInfo)> {
// Pinned: try only the configured firmware and surface its own error —
// never fall through to (and inject probe bytes into) another firmware's
// handshake on this port.
if let Some(kind) = device_kind {
return match kind {
DeviceType::Meshcore => {
let mut dev = MeshcoreDevice::open(path)
.await
.context("Could not open preferred path as Meshcore")?;
let info = dev
.initialize()
.await
.context("Preferred path is not a working Meshcore device")?;
Ok((MeshRadioDevice::Meshcore(dev), info))
}
DeviceType::Meshtastic => {
let mut dev = MeshtasticDevice::open(path)
.await
.context("Could not open preferred path as Meshtastic")?;
let info = dev
.initialize()
.await
.context("Preferred path is not a working Meshtastic device")?;
Ok((MeshRadioDevice::Meshtastic(dev), info))
}
DeviceType::Reticulum => {
let mut dev = ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open preferred path as Reticulum")?;
let info = dev
.initialize()
.await
.context("Preferred path is not a working Reticulum RNode")?;
Ok((MeshRadioDevice::Reticulum(dev), info))
}
DeviceType::Unknown => anyhow::bail!("device_kind cannot be Unknown"),
};
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
@ -230,10 +370,24 @@ async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshtastic"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshtastic"),
}
match ReticulumLink::open(
path,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
{
Ok(mut dev) => match dev.initialize().await {
Ok(info) => Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => Err(e).context("Preferred path is not a working Reticulum RNode"),
},
Err(e) => Err(e).context("Could not open preferred path as Reticulum"),
}
}
@ -438,9 +592,12 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// surfaced. `radio_contact_blocklist` is retained but unused.
let mut peers = state.peers.write().await;
let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic);
let is_reticulum = matches!(device.device_type(), DeviceType::Reticulum);
for (idx, contact) in contacts.iter().enumerate() {
let contact_id = if is_meshtastic {
meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
} else if is_reticulum {
reticulum_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
} else {
idx as u32
};
@ -464,6 +621,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
// A non-zero path_len means the firmware has a route (direct
// or flood) to this contact — i.e. we can deliver to it.
reachable: contact.path_len != 0,
// E2E capability only grows (once the radio learns a peer's
// PKI key it stays known), so OR with any prior value rather
// than letting a transient contact refresh clear the pill.
pkc_capable: contact.pkc_capable
|| existing.map(|p| p.pkc_capable).unwrap_or(false),
};
peers.insert(contact_id, peer);
}
@ -530,6 +692,17 @@ fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
}
}
/// Stable `u32` contact id derived from a Reticulum contact's `public_key_hex`
/// (hex of the 16-byte RNS destination hash). Delegates to the canonical
/// derivation in `reticulum.rs` so there is exactly one masking rule (must
/// stay below `FEDERATION_CONTACT_ID_BASE`, mod.rs:53) shared with
/// `ReticulumLink::initialize()`'s reported `node_id`.
fn reticulum_contact_id(public_key_hex: &str) -> Option<u32> {
let bytes = hex::decode(public_key_hex).ok()?;
let hash: [u8; 16] = bytes.try_into().ok()?;
Some(super::super::reticulum::reticulum_contact_id_from_hash(&hash))
}
/// Drain any queued messages from the device.
/// Returns `true` if a write/communication error occurred (for failure tracking).
async fn sync_queued_messages(
@ -574,6 +747,7 @@ static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
/// Run a single mesh session (connect, initialize, main loop).
pub(super) async fn run_mesh_session(
state: &Arc<MeshState>,
data_dir: &Path,
preferred_path: Option<&str>,
our_did: &str,
our_ed_pubkey_hex: &str,
@ -582,23 +756,33 @@ pub(super) async fn run_mesh_session(
server_name: Option<&str>,
lora_region: Option<&str>,
channel_name: Option<&str>,
device_kind: Option<DeviceType>,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> {
// Detect device — try preferred path first, fall back to auto-detect
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
match open_preferred_path(path).await {
match open_preferred_path(
path,
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
device_kind,
)
.await
{
Ok((dev, info)) => (path.to_string(), dev, info),
Err(e) => {
warn!(
"Preferred path {} probe failed: {} — trying auto-detect",
path, e
);
auto_detect_and_open().await?
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind)
.await?
}
}
} else {
auto_detect_and_open().await?
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind).await?
};
// Update status
@ -904,6 +1088,29 @@ async fn handle_send_command(
)
.await;
}
MeshCommand::SendResource {
dest_pubkey_prefix,
payload,
} => {
// No MC-chunk framing here — RNS Resources do their own native
// chunked transfer at the link layer, so the payload goes through
// as-is (the receiving daemon hands back the complete blob in one
// `resource_recv` event).
if let Err(e) = device.send_resource(&dest_pubkey_prefix, &payload).await {
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Failed to send Reticulum resource: {}", e
);
} else {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
len = payload.len(),
"Sent Reticulum resource transfer"
);
}
}
MeshCommand::BroadcastChannel { channel, payload } => {
if let Err(e) = device.send_channel_text(channel, &payload).await {
*consecutive_write_failures += 1;

View File

@ -481,6 +481,29 @@ pub struct ReactionPayload {
pub emoji: String,
}
/// `Option<Vec<u8>>` <-> base64 string, for fields that need to survive a JSON
/// round-trip to the frontend readably (plain serde would emit/expect a JSON
/// array of numbers for `Vec<u8>`, which isn't what `data:` URLs want). CBOR
/// wire encoding pays a small (~33%) size tax for this on `thumb_bytes`
/// specifically — negligible given thumbnails are capped at ~60 bytes.
mod base64_opt_bytes {
use base64::{engine::general_purpose::STANDARD, Engine as _};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(bytes) => s.serialize_str(&STANDARD.encode(bytes)),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let opt: Option<String> = Option::deserialize(d)?;
opt.map(|s| STANDARD.decode(&s).map_err(serde::de::Error::custom))
.transpose()
}
}
/// Content/attachment reference: points at a blob held by the sender that
/// recipients fetch out-of-band via `GET {sender_onion}/blob/{cid}?cap=..&exp=..&peer=..`.
/// Thumb bytes (≤60B) may be inlined for immediate display; full blob is lazy.
@ -491,7 +514,7 @@ pub struct ContentRefPayload {
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")]
pub thumb_bytes: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caption: Option<String>,

View File

@ -14,6 +14,7 @@ pub mod message_types;
pub mod outbox;
pub mod protocol;
pub mod ratchet;
pub mod reticulum;
pub mod scheduler;
pub mod serial;
pub mod session;
@ -245,6 +246,9 @@ pub(crate) async fn upsert_federation_peer(
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
reachable: true,
// Off-radio E2E (federation) is handled by the archy-peer path; preserve
// any radio PKI capability learned for a twinned contact.
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
};
peers.insert(contact_id, peer);
// A radio twin of this node (same advert_name, no arch identity yet) can now
@ -377,6 +381,15 @@ pub struct MeshConfig {
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
#[serde(default)]
pub assistant_allowed_contacts: Vec<String>,
/// Pin the expected firmware on `device_path`/auto-detected ports. A
/// reflashable board (e.g. Heltec V3) can run Meshcore, Meshtastic, or
/// RNode firmware, so probe order alone is best-effort — set this when an
/// operator knows which one is plugged in. When `Some`, only that
/// device's probe runs (no other firmware's init bytes are ever injected
/// into the port); `None` keeps today's Meshcore→Meshtastic→Reticulum
/// strict-probe auto-detect.
#[serde(default)]
pub device_kind: Option<types::DeviceType>,
}
fn default_assistant_backend() -> String {
@ -406,6 +419,7 @@ impl Default for MeshConfig {
assistant_trusted_only: true,
assistant_backend: default_assistant_backend(),
assistant_allowed_contacts: Vec::new(),
device_kind: None,
}
}
}
@ -678,6 +692,7 @@ impl MeshService {
let handle = listener::spawn_mesh_listener(
Arc::clone(&self.state),
self.data_dir.clone(),
self.config.device_path.clone(),
self.our_did.clone(),
self.our_ed_pubkey_hex.clone(),
@ -686,6 +701,7 @@ impl MeshService {
self.server_name.clone(),
self.config.lora_region.clone(),
self.config.channel_name.clone(),
self.config.device_kind,
shutdown_rx,
cmd_rx,
);
@ -1205,6 +1221,8 @@ impl MeshService {
);
}
self.send_raw_payload(contact_id, wire).await?;
let device_type = self.state.status.read().await.device_type;
let radio_transport = radio_transport_label(device_type);
Ok(self
.record_sent_typed(
contact_id,
@ -1212,11 +1230,65 @@ impl MeshService {
display_text,
typed_payload,
sender_seq,
Some("lora".to_string()),
Some(radio_transport.to_string()),
// Archy↔archy typed envelopes over LoRa are identity-signed; the
// radio E2E flag (meshtastic PKI / meshcore session) isn't
// threaded to the send side yet, so don't over-claim E2E here.
false,
// threaded to the send side yet, so don't over-claim E2E here —
// except Reticulum/LXMF, which is unconditionally E2E on every
// send regardless of peer/session state (see send_message).
device_type == DeviceType::Reticulum,
)
.await)
}
/// Send a typed envelope over a dedicated Reticulum RNS Resource transfer
/// (`MeshCommand::SendResource`) instead of the small inline-chunk path
/// `send_typed_wire`/`send_raw_payload` uses. Callers (the `mesh.send-content-inline`
/// RPC handler) are responsible for only reaching this when the active
/// device is actually Reticulum and the payload fits the
/// `RETICULUM_RESOURCE_MAX` budget — see `mesh.transport-advice`'s
/// `"resource-mesh"` tier, the single source of truth for that decision.
/// Mirrors `send_typed_wire`'s signature/return shape so RPC call sites
/// can switch between the two paths without restructuring.
pub async fn send_content_resource(
&self,
contact_id: u32,
wire: Vec<u8>,
type_label: &str,
display_text: &str,
typed_payload: Option<serde_json::Value>,
sender_seq: u64,
) -> Result<MeshMessage> {
let status = self.state.status.read().await;
if !status.device_connected {
anyhow::bail!("No mesh device connected");
}
drop(status);
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendResource {
dest_pubkey_prefix: dest_prefix,
payload: wire,
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
let device_type = self.state.status.read().await.device_type;
let radio_transport = radio_transport_label(device_type);
Ok(self
.record_sent_typed(
contact_id,
type_label,
display_text,
typed_payload,
sender_seq,
Some(radio_transport.to_string()),
// Reticulum/LXMF is unconditionally E2E on every send — same
// reasoning as send_message's native-text path. This method
// is Reticulum-only by construction (callers gate on
// device_type before reaching it), so this is never wrong.
true,
)
.await)
}
@ -1512,6 +1584,7 @@ impl MeshService {
let chan_contact_id = u32::MAX - (channel as u32);
let chan_name = format!("Channel {}", channel);
let msg_id = self.state.next_id().await;
let radio_transport = radio_transport_label(self.state.status.read().await.device_type);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Sent,
@ -1523,7 +1596,7 @@ impl MeshService {
// Channel broadcasts use the shared channel PSK, not per-identity
// E2E — so not an E2E message, but it does travel over the radio.
encrypted: false,
transport: Some("lora".to_string()),
transport: Some(radio_transport.to_string()),
message_type: type_label.to_string(),
typed_payload,
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
@ -1557,14 +1630,16 @@ impl MeshService {
// envelope as a message (PRIVATE_APP is opaque app-data; a base64
// envelope overflows one LoRa frame and chunk-fails) — wrapping text
// is exactly what silently broke archy↔archy Meshtastic LoRa.
// • Meshcore archy peer → keep the rich signed typed envelope. Meshcore
// frames are binary-safe (no UTF-8 mangling) and it carries its own
// session E2E + our signature for `!ai` auth / seq reply addressing,
// so the envelope works there and we must not drop it.
// • Meshcore/Reticulum archy peer → keep the rich signed typed envelope.
// Meshcore frames are binary-safe (no UTF-8 mangling) and Reticulum/LXMF
// is binary-safe and high-capacity too; both carry their own transport
// E2E plus our signature for `!ai` auth / seq reply addressing, so the
// envelope works there and we must not drop it.
// • Meshcore stock client → plain text (can't decode our envelope).
// Rich typed messages (invoice/coordinate/reaction/…) always use the
// typed-wire path via `send_typed_wire`; only plain Text is routed here.
let use_typed_envelope = archy && device_type == DeviceType::Meshcore;
let use_typed_envelope =
archy && matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum);
if use_typed_envelope {
// Sign with our archipelago identity so the receiver can authenticate
// us over LoRa (verifies against our bound `arch_pubkey_hex`). `with_seq`
@ -1591,8 +1666,18 @@ impl MeshService {
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
// The firmware PKI-encrypts a directed DM to any peer whose key it knows;
// archy peers always exchange keys, so mark those Sent rows E2E so the
// pill shows immediately. (The receiver independently stamps E2E from the
// radio's `pki_encrypted` flag, so an inbound row is accurate regardless.)
// pill shows immediately. A non-archy stock peer (e.g. 3ccc) can also be
// PKC-capable once we've learned its NodeInfo public key — OR that in too
// so the pill isn't archy-only. (The receiver independently stamps E2E
// from the radio's `pki_encrypted` flag, so an inbound row is accurate
// regardless.)
//
// Reticulum/LXMF has no such conditional: every send is encrypted to the
// destination's identity key by the LXMF router itself, archy peer or
// not — so it's unconditionally E2E rather than gated on `archy`/`pkc_capable`
// (which is a Meshtastic-only concept; Reticulum contacts never set it).
let pkc_capable = self.peer_pkc_capable(contact_id).await;
let encrypted = device_type == DeviceType::Reticulum || archy || pkc_capable;
Ok(self
.record_sent_typed(
contact_id,
@ -1600,8 +1685,8 @@ impl MeshService {
text,
None,
seq,
Some("lora".to_string()),
archy,
Some(radio_transport_label(device_type).to_string()),
encrypted,
)
.await)
}
@ -1622,6 +1707,21 @@ impl MeshService {
.unwrap_or(false)
}
/// Whether `contact_id`'s real radio PKI (Curve25519) key is known, so the
/// firmware delivers a directed DM to it end-to-end encrypted even though
/// it's not an archipelago peer (e.g. stock Meshtastic peer 3ccc). Stamped
/// onto `MeshPeer::pkc_capable` by `refresh_contacts` from the driver's
/// `get_contacts()`.
async fn peer_pkc_capable(&self, contact_id: u32) -> bool {
self.state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.pkc_capable)
.unwrap_or(false)
}
/// Record a Sent MeshMessage for a typed envelope that has already been
/// transmitted by the caller. Used by the RPC layer after sending
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
@ -1695,6 +1795,7 @@ impl MeshService {
let chan_contact_id = u32::MAX - (channel as u32);
let chan_name = format!("Channel {}", channel);
let msg_id = self.state.next_id().await;
let radio_transport = radio_transport_label(self.state.status.read().await.device_type);
let msg = MeshMessage {
id: msg_id,
@ -1706,7 +1807,7 @@ impl MeshService {
delivered: false,
// Plain channel broadcast over the radio (shared PSK, not E2E).
encrypted: false,
transport: Some("lora".to_string()),
transport: Some(radio_transport.to_string()),
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
@ -1991,6 +2092,7 @@ mod tests {
hops: 0,
last_advert: 0,
reachable,
pkc_capable: false,
}
}

View File

@ -0,0 +1,751 @@
// WIP mesh/transport protocol — suppress dead code warnings
#![allow(dead_code)]
//! Reticulum (RNS + LXMF) bridge.
//!
//! Unlike Meshcore/Meshtastic — simple framed-serial protocols driven entirely
//! in-process — Reticulum is a full network stack (identity, announce, multi-hop
//! routing, LXMF store-and-forward) that we run as a **host-supervised Python
//! daemon** (`reticulum-daemon/`, canonical `rns`+`lxmf`, chosen over the sub-1.0
//! Rust port for interop with Sideband/NomadNet/MeshChat — see the plan). This
//! module is the Rust-side half of that bridge: it owns the child process and
//! speaks the daemon's Unix-socket JSON-RPC, while presenting the same method
//! surface `MeshRadioDevice` (listener/session.rs) already calls on
//! `MeshcoreDevice`/`MeshtasticDevice`.
//!
//! Two contract details that are easy to get wrong (see the plan §2b/§2d):
//! 1. The wrapper's `send_text_msg` is handed only a 6-byte prefix, but an RNS
//! destination is 16 bytes — `prefix_to_hash` below is the mandatory
//! resolver, populated from announces/contacts.
//! 2. Inbound LXMF deliveries are translated into the exact same synthetic
//! `InboundFrame` byte layout Meshtastic already produces
//! (`RESP_CONTACT_MSG_V3[_E2E]`), so `frames::handle_frame` needs zero
//! changes to route them.
use super::protocol::{self, InboundFrame, ParsedContact};
use super::types::DeviceInfo;
use anyhow::{Context, Result};
use serde_json::Value;
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use tokio::process::{Child, Command};
use tracing::{debug, info, warn};
/// RNode KISS protocol bytes, verified against the canonical Reticulum source
/// (`RNS/Interfaces/RNodeInterface.py`, `detect()`/`readLoop()`) — NOT guessed.
/// See `docs/RETICULUM-TRANSPORT-PROGRESS.md` for the citation.
const KISS_FEND: u8 = 0xC0;
const KISS_CMD_DETECT: u8 = 0x08;
const KISS_DETECT_REQ: u8 = 0x73;
const KISS_DETECT_RESP: u8 = 0x46;
const KISS_CMD_FW_VERSION: u8 = 0x50;
const KISS_CMD_PLATFORM: u8 = 0x48;
const KISS_CMD_MCU: u8 = 0x49;
const PROBE_BAUD: u32 = 115200;
const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(800);
/// Path to the supervised daemon binary. In production this is the bundled
/// PyInstaller artifact shipped beside `/usr/local/bin/archipelago` (Phase 1
/// packaging); during development it can point at the venv's interpreter
/// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging.
///
/// `archy_ed_pubkey_hex`/`archy_x25519_pubkey_hex` (when known) are embedded
/// by the daemon in its announce app_data as `ARCHY:2:{ed}:{x25519}` — the
/// SAME wire format meshcore/Meshtastic identity adverts use — so a
/// Reticulum-carried identity binds onto the existing Archy contact via the
/// existing `parse_identity_broadcast`/`handle_identity_received` path,
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
fn daemon_command(
socket_path: &Path,
serial_port: &str,
identity_key: &Path,
archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>,
) -> Command {
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
let mut cmd = if Path::new(&bin).exists() {
Command::new(bin)
} else {
// Dev fallback: run the script through its venv interpreter.
let py = std::env::var("ARCHY_RETICULUM_DAEMON_PY")
.unwrap_or_else(|_| "reticulum-daemon/.venv/bin/python".to_string());
let script = std::env::var("ARCHY_RETICULUM_DAEMON_SCRIPT")
.unwrap_or_else(|_| "reticulum-daemon/reticulum_daemon.py".to_string());
let mut c = Command::new(py);
c.arg(script);
c
};
cmd.arg("--identity-key")
.arg(identity_key)
.arg("--socket")
.arg(socket_path)
.arg("--serial-port")
.arg(serial_port);
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex")
.arg(ed)
.arg("--archy-x25519-pubkey-hex")
.arg(x);
}
cmd.kill_on_drop(true)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd
}
/// One peer learned via an RNS announce (LXMF delivery destination).
#[derive(Clone)]
struct ReticulumPeer {
dest_hash: [u8; 16],
display_name: String,
/// Archy ed25519 identity hex, once carried in a verified announce
/// app-data blob. Not yet wired (TODO, lands with the signed-announce
/// work) — present so `get_contacts` has a stable shape to extend into.
arch_pubkey_hex: Option<String>,
reachable: bool,
}
/// Bridge handle to one supervised `reticulum-daemon` instance, one per active
/// Reticulum (RNode) radio. Implements the same method shapes
/// `MeshRadioDevice` calls on `MeshcoreDevice`/`MeshtasticDevice`.
pub struct ReticulumLink {
device_path: String,
socket_path: std::path::PathBuf,
child: Child,
writer: tokio::net::unix::OwnedWriteHalf,
reader: BufReader<tokio::net::unix::OwnedReadHalf>,
dest_hash: [u8; 16],
display_name: Option<String>,
/// Mandatory: the wrapper's `send_text_msg`/inbound frames only carry a
/// 6-byte prefix, but an RNS destination is 16 bytes. Populated from
/// announces and `get_contacts`.
prefix_to_hash: HashMap<[u8; 6], [u8; 16]>,
peers: HashMap<[u8; 16], ReticulumPeer>,
inbound: std::collections::VecDeque<InboundFrame>,
/// Monotonic correlation id for `send_resource` RPC calls — purely for
/// matching `resource_progress`/`resource_sent`/`resource_failed` events
/// back to a log line; sends are fire-and-forget (see `send_resource`).
resource_id_counter: u64,
}
impl ReticulumLink {
/// Cheap probe: send the verified RNode KISS detect sequence over the raw
/// serial port and look for `DETECT_RESP`, WITHOUT spawning the (heavy)
/// daemon. Mirrors the open()/initialize() split Meshcore/Meshtastic use,
/// so a non-RNode port is rejected fast and the daemon is only ever
/// started against a port we've confirmed is an RNode.
///
/// `data_dir` is the same archipelago data directory `NodeIdentity` was
/// loaded from (`{data_dir}/identity/node_key`) — the daemon reads that
/// key file directly to derive its RNS identity (we pass the path, not
/// the key bytes, so it never travels through more hops than necessary).
///
/// `our_ed_pubkey_hex`/`our_x25519_pubkey_hex` are this node's real Archy
/// identity pubkeys (already computed by the caller — same values passed
/// to `run_mesh_session`); forwarded to the daemon so its announces carry
/// a peer-bindable `ARCHY:2:...` identity. Pass `None` to announce with
/// just the plain display name (e.g. a non-archy/dev run).
pub async fn open(
path: &str,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
probe_rnode(path).await.context("RNode KISS detect failed")?;
Self::spawn(path, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await
}
async fn spawn(
path: &str,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
// Keep the RPC socket under the archipelago-owned data dir (not the
// shared system temp dir) so its access is bounded by the same
// permissions as the rest of our state — consistent with the
// "archipelago-owned runtime dir, 0600" security posture.
let runtime_dir = data_dir.join("reticulum");
tokio::fs::create_dir_all(&runtime_dir)
.await
.context("Failed to create reticulum runtime dir")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
}
let socket_path = runtime_dir.join(format!(
"{}.sock",
path.replace(['/', ' '], "_")
));
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
let identity_key = data_dir.join("identity").join("node_key");
if !identity_key.exists() {
anyhow::bail!(
"Archy identity key not found at {} — cannot derive a Reticulum identity",
identity_key.display()
);
}
let mut cmd = daemon_command(
&socket_path,
path,
&identity_key,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
);
let mut child = cmd
.spawn()
.context("Failed to spawn reticulum-daemon — is it installed/packaged?")?;
// Wait for the socket to appear, then for the daemon's "ready" event.
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
let stream = loop {
if tokio::time::Instant::now() > deadline {
let _ = child.start_kill();
anyhow::bail!("reticulum-daemon did not create its RPC socket in time");
}
match UnixStream::connect(&socket_path).await {
Ok(s) => break s,
Err(_) => tokio::time::sleep(Duration::from_millis(150)).await,
}
};
let (read_half, write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut line = String::new();
tokio::time::timeout(Duration::from_secs(10), reader.read_line(&mut line))
.await
.context("Timed out waiting for reticulum-daemon ready event")?
.context("reticulum-daemon RPC connection closed before ready")?;
let ready: Value = serde_json::from_str(line.trim())
.context("reticulum-daemon sent a non-JSON ready line")?;
if ready.get("event").and_then(Value::as_str) != Some("ready") {
anyhow::bail!("reticulum-daemon's first message was not 'ready': {ready}");
}
let dest_hash_hex = ready
.get("dest_hash")
.and_then(Value::as_str)
.context("ready event missing dest_hash")?;
let dest_hash = parse_hash16(dest_hash_hex)?;
let display_name = ready
.get("display_name")
.and_then(Value::as_str)
.map(str::to_string);
info!(
path = %path,
dest_hash = %dest_hash_hex,
"Reticulum daemon ready"
);
Ok(Self {
device_path: path.to_string(),
socket_path,
child,
writer: write_half,
reader,
dest_hash,
display_name,
prefix_to_hash: HashMap::new(),
peers: HashMap::new(),
inbound: std::collections::VecDeque::new(),
resource_id_counter: 0,
})
}
fn next_resource_id(&mut self) -> String {
self.resource_id_counter += 1;
self.resource_id_counter.to_string()
}
/// Handshake is a no-op here — `open()` already waited for the daemon's
/// `ready` event, so by the time `MeshRadioDevice` calls `initialize()`
/// the daemon (and the RNS/LXMF stack inside it) is already up.
pub async fn initialize(&mut self) -> Result<DeviceInfo> {
Ok(DeviceInfo {
firmware_version: "reticulum-daemon".to_string(),
node_id: reticulum_contact_id_from_hash(&self.dest_hash),
max_contacts: u16::MAX,
device_type: super::types::DeviceType::Reticulum,
})
}
pub fn advert_name(&self) -> Option<String> {
self.display_name.clone()
}
pub async fn set_advert_name(&mut self, name: &str) -> Result<()> {
// The daemon's display_name is fixed at spawn time (CLI arg); changing
// it live would require an RPC verb we haven't added. Track locally so
// `advert_name()` reflects the caller's intent even though the
// RNS-visible name doesn't change until the daemon restarts.
self.display_name = Some(name.to_string());
Ok(())
}
pub async fn send_self_advert(&mut self) -> Result<()> {
self.send_rpc(serde_json::json!({"cmd": "announce"})).await
}
/// Reticulum/LXMF has no shared-PSK broadcast channel like Meshcore's
/// channel 0 or Meshtastic's primary channel — it's point-to-point +
/// propagation-node store-and-forward. Treat as unsupported-but-harmless
/// (no-op success) rather than failing the caller, matching the no-op
/// pattern used for other not-applicable operations on this enum arm.
/// Per-network channel support is tracked in the plan's Phase 4.
pub async fn send_channel_text(&mut self, _channel: u8, _payload: &[u8]) -> Result<()> {
debug!("Reticulum has no broadcast-channel concept — ignoring channel send");
Ok(())
}
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
self.send_rpc(serde_json::json!({
"cmd": "send",
"dest_hash": hex::encode(dest_hash),
"content": String::from_utf8_lossy(payload),
"method": "direct",
}))
.await
}
/// Send `data` (typically an already-built typed-envelope wire blob) to a
/// peer over a dedicated RNS Resource transfer instead of the small LXMF
/// "content" path `send_text_msg` uses — for payloads too large for the
/// inline-chunk cap but well within what LoRa can carry over a proper RNS
/// Resource (native chunked transfer with retries, unlike our own
/// MC-chunk scheme). Fire-and-forget, matching `send_text_msg`'s existing
/// semantics (no synchronous delivery confirmation) — `resource_sent`/
/// `resource_failed`/`resource_progress` events are drained and logged by
/// `handle_event`, not awaited here.
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let dest_hash = self
.prefix_to_hash
.get(dest_pubkey_prefix)
.copied()
.with_context(|| {
format!(
"Unknown Reticulum prefix {} — peer hasn't announced yet",
hex::encode(dest_pubkey_prefix)
)
})?;
let req_id = self.next_resource_id();
self.send_rpc(serde_json::json!({
"cmd": "send_resource",
"id": req_id,
"dest_hash": hex::encode(dest_hash),
"data_b64": B64.encode(data),
}))
.await
}
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
// RNS has no firmware-side contact table to prune — peers simply stop
// being reachable when their announce/path ages out.
Ok(())
}
pub async fn add_contact(
&mut self,
_pubkey: &[u8; 32],
_contact_type: u8,
_flags: u8,
_out_path_len: u8,
_name: &str,
_last_advert: u32,
) -> Result<()> {
// No firmware contact table to seed — RNS learns peers from announces
// (handled by drain_events) and from path requests issued on send.
Ok(())
}
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
self.drain_events().await;
Ok(self
.peers
.values()
.map(|p| ParsedContact {
public_key_hex: hex::encode(p.dest_hash),
advert_name: p.display_name.clone(),
last_advert: 0,
// Deliberately not 1 ("friend"/meshcore type), so the
// meshcore-only auto-heal `reset_contact_path` loop in
// `refresh_contacts` (session.rs) skips these — RNS does its
// own pathfinding, there is no firmware path to reset.
contact_type: 2,
path_len: if p.reachable { 1 } else { 0 },
flags: 0,
// RNS/LXMF is unconditionally E2E — see `take_rx_encrypted`.
// This field tracks Meshtastic's per-contact PKC capability,
// which has no Reticulum analogue (always true, tracked
// elsewhere via `take_rx_encrypted`), so leave it false here.
pkc_capable: false,
})
.collect())
}
pub async fn sync_messages(&mut self) -> Result<Vec<InboundFrame>> {
self.drain_events().await;
Ok(self.inbound.drain(..).collect())
}
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
self.drain_events().await;
Ok(self.inbound.pop_front())
}
/// RNS/LXMF links are end-to-end encrypted by default (no plaintext mode),
/// so every inbound delivery is E2E. Unlike Meshtastic, which only
/// sometimes gets PKI delivery, this is unconditionally true.
pub fn take_rx_encrypted(&mut self) -> bool {
true
}
// ── internals ──────────────────────────────────────────────────────
async fn send_rpc(&mut self, req: Value) -> Result<()> {
let mut line = serde_json::to_vec(&req)?;
line.push(b'\n');
self.writer
.write_all(&line)
.await
.context("Reticulum daemon RPC write failed")?;
Ok(())
}
/// Drain any buffered daemon events (non-blocking) and translate them into
/// peer-table updates / synthetic InboundFrames.
async fn drain_events(&mut self) {
loop {
let mut line = String::new();
let read = tokio::time::timeout(
Duration::from_millis(20),
self.reader.read_line(&mut line),
)
.await;
let n = match read {
Ok(Ok(n)) => n,
_ => break, // timeout (no data) or read error — stop draining
};
if n == 0 {
warn!("Reticulum daemon RPC connection closed");
break;
}
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
continue;
};
self.handle_event(ev);
}
}
fn handle_event(&mut self, ev: Value) {
match ev.get("event").and_then(Value::as_str) {
Some("announce") => {
let Some(hash) = ev
.get("dest_hash")
.and_then(Value::as_str)
.and_then(|h| parse_hash16(h).ok())
else {
return;
};
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, hash);
let app_data_text = ev
.get("app_data")
.and_then(Value::as_str)
.and_then(|h| hex::decode(h).ok())
.map(|b| String::from_utf8_lossy(&b).to_string())
.filter(|s| !s.is_empty());
// If the announce app_data is an ARCHY:n: identity blob (see
// daemon_command's doc comment), bind it onto this peer AND
// surface it through the SAME channel-text path
// meshcore/Meshtastic identity adverts use
// (frames::handle_channel_payload -> parse_identity_broadcast
// -> handle_identity_received -> bind_federation_twins), so a
// Reticulum-carried identity merges into the same conversation
// as that node's other-transport twins — zero new bind logic.
let is_identity_blob = app_data_text
.as_deref()
.map(|t| protocol::parse_identity_broadcast(t).is_some())
.unwrap_or(false);
if is_identity_blob {
let text = app_data_text.clone().unwrap();
let mut data = Vec::with_capacity(7 + text.len());
data.push(0); // channel index — unused by the identity path
data.extend_from_slice(&prefix);
data.extend_from_slice(text.as_bytes());
self.inbound.push_back(InboundFrame {
code: protocol::RESP_MESHTASTIC_CHANNEL_TEXT,
data,
bytes_consumed: 0,
});
}
let display_name = app_data_text
.filter(|_| !is_identity_blob)
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4])));
self.peers
.entry(hash)
.and_modify(|p| {
p.display_name = display_name.clone();
p.reachable = true;
})
.or_insert(ReticulumPeer {
dest_hash: hash,
display_name,
arch_pubkey_hex: None,
reachable: true,
});
}
Some("recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
return;
};
let Ok(source_hash) = parse_hash16(source_hex) else {
return;
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
let content = ev
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.as_bytes()
.to_vec();
self.inbound.push_back(build_synthetic_frame(&prefix, &content));
}
Some("resource_recv") => {
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
return;
};
let Ok(source_hash) = parse_hash16(source_hex) else {
return;
};
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
self.prefix_to_hash.insert(prefix, source_hash);
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let Some(data) = ev
.get("data_b64")
.and_then(Value::as_str)
.and_then(|b64| B64.decode(b64).ok())
else {
warn!("resource_recv event with missing/invalid data_b64");
return;
};
// Resources carry the complete typed-envelope wire bytes
// directly (no MC-chunk/base64 textification needed — RNS
// Resources are already a binary-safe whole-blob transfer),
// so this is the same payload shape `decode.rs` already
// accepts for a single-frame (non-chunked) typed envelope.
self.inbound.push_back(build_synthetic_frame(&prefix, &data));
}
Some("resource_progress") => {
debug!(
id = ?ev.get("id"),
transferred = ?ev.get("transferred"),
total = ?ev.get("total"),
"Reticulum resource transfer progress"
);
}
Some("resource_sent") => {
debug!(id = ?ev.get("id"), "Reticulum resource transfer completed");
}
Some("resource_failed") => {
warn!(
id = ?ev.get("id"),
reason = ?ev.get("reason"),
"Reticulum resource transfer failed"
);
}
Some("delivered") | Some("status") | Some("ready") | Some("error") | None => {}
_ => {}
}
}
}
/// Build the synthetic `RESP_CONTACT_MSG_V3_E2E` InboundFrame for an inbound
/// LXMF message, byte-for-byte matching the layout Meshtastic already
/// produces (meshtastic.rs `parse_meshtastic_frame`) so `frames::handle_frame`
/// needs no Reticulum-specific branch:
/// [snr(1)=0][reserved(2)][sender_prefix(6)][path(1)=0xff][type(1)=0][rx_time(4 LE)][payload]
fn build_synthetic_frame(sender_prefix: &[u8; 6], payload: &[u8]) -> InboundFrame {
let mut data = Vec::with_capacity(15 + payload.len());
data.push(0); // SNR unknown (RNS doesn't expose per-packet SNR through LXMF)
data.extend_from_slice(&[0, 0]); // reserved
data.extend_from_slice(sender_prefix);
data.push(0xff); // path: RNS does its own multi-hop routing, not exposed here
data.push(0); // text type
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as u32;
data.extend_from_slice(&now.to_le_bytes());
data.extend_from_slice(payload);
InboundFrame {
// Reticulum/LXMF is always end-to-end encrypted — no plaintext mode.
code: protocol::RESP_CONTACT_MSG_V3_E2E,
data,
bytes_consumed: 0,
}
}
/// Derive a stable `u32` contact id from the 16-byte RNS destination hash,
/// masked to the low (non-federation-synthetic) id space. Sibling to
/// `meshtastic_contact_id` (listener/session.rs). Kept here so `initialize()`
/// can report a `node_id` consistent with what `refresh_contacts` will later
/// assign via the public helper of the same name in session.rs.
pub(crate) fn reticulum_contact_id_from_hash(hash: &[u8; 16]) -> u32 {
let raw = u32::from_le_bytes([hash[0], hash[1], hash[2], hash[3]]);
let masked = raw & 0x7FFF_FFFF;
if masked == 0 {
1
} else {
masked
}
}
fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
let bytes = hex::decode(hex_str).context("invalid hex")?;
bytes
.try_into()
.map_err(|b: Vec<u8>| anyhow::anyhow!("expected 16 bytes, got {}", b.len()))
}
/// Send the verified RNode KISS detect sequence and look for `DETECT_RESP`.
/// Bytes confirmed against the canonical Reticulum source — see the module
/// doc comment and `docs/RETICULUM-TRANSPORT-PROGRESS.md`.
async fn probe_rnode(path: &str) -> Result<()> {
let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD)
.with_context(|| format!("Failed to open {} for Reticulum probe", path))?;
let probe: [u8; 13] = [
KISS_FEND,
KISS_CMD_DETECT,
KISS_DETECT_REQ,
KISS_FEND,
KISS_CMD_FW_VERSION,
0x00,
KISS_FEND,
KISS_CMD_PLATFORM,
0x00,
KISS_FEND,
KISS_CMD_MCU,
0x00,
KISS_FEND,
];
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
.await
.context("RNode probe write timed out")?
.context("RNode probe write failed")?;
let mut buf = [0u8; 256];
let mut seen = Vec::new();
let deadline = tokio::time::Instant::now() + PROBE_READ_TIMEOUT;
while tokio::time::Instant::now() < deadline {
match tokio::time::timeout(Duration::from_millis(150), port.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => {
seen.extend_from_slice(&buf[..n]);
if contains_detect_resp(&seen) {
return Ok(());
}
}
_ => continue,
}
}
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT)
}
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
/// buffer (KISS framing means other command responses may interleave first).
fn contains_detect_resp(buf: &[u8]) -> bool {
buf.windows(3)
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
}
impl Drop for ReticulumLink {
fn drop(&mut self) {
// Best-effort: ask the daemon to shut down cleanly (frees the serial
// port promptly); `kill_on_drop` on the Command is the hard backstop
// if the daemon doesn't exit in time.
let _ = self.child.start_kill();
let _ = std::fs::remove_file(&self.socket_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_resp_found_in_kiss_stream() {
let stream = [0x10, 0x20, KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP, 0x99];
assert!(contains_detect_resp(&stream));
}
/// Hardware gate: `probe_rnode` against a real RNode-flashed board. Not run in
/// CI (no hardware there) — run manually with
/// `ARCHY_RNODE_TEST_PORT=/dev/ttyUSB0 cargo test -p archipelago --lib
/// mesh::reticulum::tests::probe_rnode_detects_real_hardware -- --ignored --nocapture`
/// once an RNode-flashed board is attached. Confirms the byte-for-byte KISS
/// constants documented above (cited from canonical RNS source) actually work
/// against real firmware, not just the unit-tested byte matcher above.
#[tokio::test]
#[ignore = "requires a real RNode-flashed board; set ARCHY_RNODE_TEST_PORT"]
async fn probe_rnode_detects_real_hardware() {
let port = std::env::var("ARCHY_RNODE_TEST_PORT")
.expect("set ARCHY_RNODE_TEST_PORT to the RNode's serial path");
probe_rnode(&port).await.expect("KISS detect probe failed against real hardware");
}
#[test]
fn detect_resp_absent_in_unrelated_stream() {
let stream = [0x10, 0x20, 0x30, 0x40];
assert!(!contains_detect_resp(&stream));
}
#[test]
fn contact_id_masks_high_bit_and_avoids_zero() {
let hash_high_bit = {
let mut h = [0u8; 16];
h[0..4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
h
};
let id = reticulum_contact_id_from_hash(&hash_high_bit);
assert!(id < 0x8000_0000, "must not collide with federation-synthetic space");
assert_ne!(id, 0);
let zero_hash = [0u8; 16];
assert_eq!(reticulum_contact_id_from_hash(&zero_hash), 1);
}
#[test]
fn synthetic_frame_matches_meshtastic_layout() {
let prefix = [1, 2, 3, 4, 5, 6];
let frame = build_synthetic_frame(&prefix, b"hello");
assert_eq!(frame.code, protocol::RESP_CONTACT_MSG_V3_E2E);
// header is 15 bytes before the payload, per the documented layout
assert_eq!(&frame.data[3..9], &prefix);
assert_eq!(frame.data[9], 0xff);
assert_eq!(frame.data[10], 0);
assert_eq!(&frame.data[15..], b"hello");
}
}

View File

@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
pub enum DeviceType {
Meshcore,
Meshtastic,
/// A Reticulum (RNS/LXMF) RNode, bridged via the host-supervised
/// `reticulum-daemon` over its Unix-socket RPC — not driven in-process
/// like the other two. See `mesh/reticulum.rs`.
Reticulum,
Unknown,
}
@ -18,11 +22,25 @@ impl std::fmt::Display for DeviceType {
match self {
Self::Meshcore => write!(f, "meshcore"),
Self::Meshtastic => write!(f, "meshtastic"),
Self::Reticulum => write!(f, "reticulum"),
Self::Unknown => write!(f, "unknown"),
}
}
}
/// The per-message transport pill label for a radio-delivered message: the
/// active device's own name, since one session owns exactly one device.
/// Federation sends/receives are labelled "fips"/"tor" elsewhere — this only
/// covers the radio-class transports.
pub fn radio_transport_label(device_type: DeviceType) -> &'static str {
match device_type {
DeviceType::Meshcore => "meshcore",
DeviceType::Meshtastic => "meshtastic",
DeviceType::Reticulum => "reticulum",
DeviceType::Unknown => "lora",
}
}
/// A peer discovered via mesh radio.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshPeer {
@ -64,6 +82,12 @@ pub struct MeshPeer {
/// contact with no path and no recent advert is shown as unreachable.
#[serde(default)]
pub reachable: bool,
/// Whether DMs to/from this peer are end-to-end (PKI / Curve25519) encrypted.
/// Set for a Meshtastic peer once we know its real NodeInfo public key (the
/// firmware then PKC-encrypts directed DMs), so the send path can show the
/// E2E pill on a Sent DM to a PKC-capable stock peer, not only archy peers.
#[serde(default)]
pub pkc_capable: bool,
}
impl MeshPeer {
@ -239,6 +263,7 @@ mod tests {
hops: 0,
last_advert: 0,
reachable: false,
pkc_capable: false,
}
}

View File

@ -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 03) |
## 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<DeviceType>`** (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<DeviceType>` 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 <path>` 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.

View File

@ -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

View File

@ -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<File> {
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<Uint8Array> {
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<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error('canvas.toBlob failed'))),
'image/jpeg',
quality,
)
})
}

View File

@ -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<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(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<string>()
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<Map<string, string>>(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<ImageCompressionPreset | null> {
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<ImageCompressionPreset | null>((resolve) => {
imageQualityResolve = resolve
})
}
function pickImageQuality(preset: ImageCompressionPreset | null) {
if (imageQualityResolve) {
imageQualityResolve(preset)
imageQualityResolve = null
}
imageQualityChoice.value = null
}
async function resolveFederationOnion(peerName: string): Promise<string | undefined> {
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<string, string> = {
'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<boolean> {
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<Blob>((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"
/>
<audio
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
:src="fetchedUrls.get(msg.typed_payload.cid)"
controls
class="mesh-typed-content-audio"
/>
<a v-else :href="fetchedUrls.get(msg.typed_payload.cid)" target="_blank" class="btn">Open</a>
</template>
<template v-else-if="msg.direction === 'sent'">
<span class="mesh-typed-content-hint">(shared from this node)</span>
</template>
<template v-else>
<img
v-if="msg.typed_payload.thumb_bytes && isImageMime(msg.typed_payload.mime)"
:src="`data:${msg.typed_payload.mime};base64,${msg.typed_payload.thumb_bytes}`"
class="mesh-typed-content-preview mesh-typed-content-thumb"
alt="thumbnail preview"
/>
<button
class="btn"
:disabled="fetchingCids.has(msg.typed_payload.cid)"
@ -1930,6 +2109,20 @@ function isImageMime(mime?: string): boolean {
<span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>📎</span>
</label>
<button
v-if="activeChatPeer"
type="button"
class="glass-button mesh-chat-record-btn"
:class="{ 'is-recording': isRecordingVoice }"
:disabled="attaching"
:title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
@pointerdown.prevent="startVoiceRecording"
@pointerup.prevent="stopVoiceRecording"
@pointerleave="stopVoiceRecordingIfActive"
>
<span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>🎤</span>
</button>
<input
v-model="messageText"
class="mesh-chat-input"
@ -2027,6 +2220,32 @@ function isImageMime(mime?: string): boolean {
</div>
</div>
<!-- Image quality-picker modal: shown before sending an image attachment.
Each preset shows its nominal size target + a transfer-time estimate
from the same mesh.transport-advice RPC the file-attach flow uses. -->
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">🖼 Choose Image Quality</h3>
<p class="mesh-transport-sub">
<strong>{{ imageQualityChoice.file.name }}</strong>
· {{ (imageQualityChoice.file.size / 1024).toFixed(1) }} KB original
</p>
<div class="mesh-transport-options">
<button
v-for="preset in IMAGE_COMPRESSION_PRESETS"
:key="preset.key"
class="mesh-transport-option"
@click="pickImageQuality(preset)"
>
<span class="mesh-transport-icon">🖼</span>
<span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span>
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
</button>
</div>
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
</div>
</div>
</div>
</template>

View File

@ -149,9 +149,12 @@
.mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; }
.mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); }
.mesh-chat-e2e { font-size: 0.55rem; font-weight: 700; color: #4ade80; padding: 0 3px; border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 3px; }
/* Per-message transport pill (Mesh / FIPS / Tor), styled like the E2E badge. */
/* Per-message transport pill (Meshtastic / Meshcore / Reticulum / FIPS / Tor), styled like the E2E badge. */
.mesh-chat-transport { font-size: 0.55rem; font-weight: 700; padding: 0 3px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.85; }
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* Mesh/LoRa — amber */
.mesh-chat-transport.transport-meshtastic { color: #3eb489; } /* Meshtastic — mint */
.mesh-chat-transport.transport-meshcore { color: #fb923c; } /* Meshcore — orange */
.mesh-chat-transport.transport-reticulum { color: #60a5fa; } /* Reticulum — blue */
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* legacy generic Mesh/LoRa (pre-split) — amber */
.mesh-chat-transport.transport-fips { color: #a78bfa; } /* FIPS — violet */
.mesh-chat-transport.transport-tor { color: #818cf8; } /* Tor — indigo */
.mesh-chat-ack { font-size: 0.7rem; color: #3b82f6; }
@ -338,6 +341,9 @@
.mesh-typed-coordinate-link { display: inline-block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
.typed-block_header { border-left: 3px solid #a855f7; }
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
.mesh-typed-content-preview { max-width: 220px; max-height: 220px; border-radius: 10px; display: block; }
.mesh-typed-content-thumb { opacity: 0.85; filter: blur(0.5px); margin-bottom: 6px; }
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
@ -464,6 +470,8 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fb923c; border-radius: 50%; animation: mesh-spin 0.7s linear infinite; vertical-align: middle; }
@keyframes mesh-spin { to { transform: rotate(360deg); } }
.mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; }
.mesh-chat-record-btn.is-recording { background: rgba(239,68,68,0.25); animation: mesh-record-pulse 1.1s ease-in-out infinite; }
@keyframes mesh-record-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } }
.mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); }
.mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; }

10
reticulum-daemon/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.venv/
__pycache__/
*.pyc
dist/
build/
*.spec
# never commit a real identity key or RNS state
*.key
node_key
.archy-reticulum/

View File

@ -0,0 +1,66 @@
# reticulum-daemon
Host-supervised **Reticulum (RNS) + LXMF** bridge for Archipelago's Mesh tab. This is
the Python side of the [Reticulum transport plan](../../.claude/plans/enchanted-strolling-rocket.md):
archipelago spawns one of these per active Reticulum (RNode) radio, it owns the serial
port, and the Rust mesh subsystem drives it over a Unix-socket JSON-RPC.
Why a daemon (not the Rust `reticulum-rs` crate): the canonical Python `rns`/`lxmf`
guarantees interop with Sideband / NomadNet / MeshChat, and lets us derive the RNS
identity from the existing Archy key (proven in `spike_identity.py`).
## Layout
- `archy_rns_identity.py` — derive a **deterministic** RNS `Identity` from the 32-byte
Archy Ed25519 seed (`identity_dir/node_key`) via domain-separated HKDF. The node's
LXMF destination hash is a stable function of the Archy identity.
- `spike_identity.py`**Phase-0 gate #1** (no radio): proves that determinism.
- `reticulum_daemon.py` — the daemon: RNS bring-up, LXMF router, announce handler, and
the Unix-socket RPC. See its module docstring for the wire protocol.
- `requirements.txt` — pinned `rns==1.3.5`, `lxmf==1.0.1` (validated on Python 3.13).
## Dev setup
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
```
## Run the spike / smoke tests (no hardware)
```sh
.venv/bin/python spike_identity.py # gate #1: identity determinism
.venv/bin/python reticulum_daemon.py --check \
--identity-key /path/to/node_key # print this node's dest hash
.venv/bin/python reticulum_daemon.py --selftest \
--identity-key /path/to/node_key # bring up RNS+LXMF, no radio
```
## Run against a real RNode (Phase-0 hardware gate, on .116 / .228)
```sh
.venv/bin/python reticulum_daemon.py \
--identity-key /var/lib/archipelago/identity/node_key \
--serial-port /dev/reticulum-radio \
--socket /run/archy/reticulum.sock \
--display-name "archy-228"
```
Then verify a two-node LXMF DM over LoRa and interop with a stock Sideband/MeshChat
client (Phase-0 gates #2 and #3).
## Packaging (Phase 1)
Ship as a **PyInstaller single binary** in the OTA next to `/usr/local/bin/archipelago`
(no provision-time `pip install`). archipelago supervises it: start on RNode detect,
kill on unplug/disable. The RPC socket and RNS config dir are archipelago-owned, 0600.
```sh
./build.sh # → dist/archy-reticulum-daemon (~16M, fully standalone)
```
`-d noarchive` is required, not optional — see the comment in `build.sh`: RNS computes
`RNS.Interfaces.__all__` via a `glob()` against its own `__file__` directory at import
time, which only works when PyInstaller keeps modules as loose files instead of zipping
them into the binary.
## Status
Phase-0 gate #1 (identity determinism) **passes**, verified in both the dev venv and the
packaged binary (same dest hash). The signed-identity announce (`ARCHY:2:{ed}:{x25519}` in
`_announce_app_data`, via `--archy-ed-pubkey-hex`/`--archy-x25519-pubkey-hex`) is wired and
the Rust side (`reticulum.rs`) already passes the node's real keys through. Packaging is
done and verified standalone. What's left is entirely hardware-dependent: the live LoRa
message path (Phase-0 gates #2/#3) needs a real RNode-flashed board.

View File

@ -0,0 +1,80 @@
"""Derive a stable Reticulum (RNS) Identity from the Archipelago node identity.
Archy's root identity is a single 32-byte Ed25519 seed (``identity_dir/node_key``,
0600 see core/archipelago/src/identity.rs). A Reticulum ``Identity`` is a *pair*
of keypairs: an X25519 (encryption) key and an Ed25519 (signing) key, whose
concatenated 64-byte private blob is ``x25519_priv(32) || ed25519_priv(32)``.
We derive both halves deterministically from the Archy seed with domain-separated
HKDF-SHA256. Properties this gives us:
* **Reproducible** the same Archy node always produces the same RNS destination
hash, so a contact's Reticulum address is stable across reboots / reinstalls and
peers can bind it to the existing Archy contact (no manual re-pairing).
* **Domain-separated** we never reuse the raw Archy signing key inside RNS; each
derived key has its own HKDF ``info`` label. Reusing one private key across two
cryptographic schemes is a footgun we deliberately avoid.
Binding to the Archy DID/Npub is NOT done by key reuse it is carried in the signed
announce app-data (see ``build_announce_app_data``), which peers verify against the
Archy ed25519 identity and then bind onto the contact's stable ``arch_pubkey_hex``.
"""
from __future__ import annotations
import hashlib
import hmac
# HKDF info labels — changing these changes every node's RNS address, so they are
# part of the wire contract. Do not edit without a migration.
_INFO_X25519 = b"archipelago/reticulum/x25519/v1"
_INFO_ED25519 = b"archipelago/reticulum/ed25519/v1"
_HKDF_SALT = b"archipelago-reticulum-identity-v1"
def _hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
"""RFC 5869 HKDF-SHA256 (extract + expand) for one output block (length <= 32)."""
if length > 32:
raise ValueError("this helper only emits up to one SHA-256 block")
prk = hmac.new(_HKDF_SALT, ikm, hashlib.sha256).digest() # extract
okm = hmac.new(prk, info + b"\x01", hashlib.sha256).digest() # expand (T(1))
return okm[:length]
def rns_private_blob(archy_ed25519_seed: bytes) -> bytes:
"""Return the 64-byte RNS private blob (x25519_priv || ed25519_priv).
``archy_ed25519_seed`` is the raw 32 bytes of ``identity_dir/node_key``.
"""
if len(archy_ed25519_seed) != 32:
raise ValueError(f"expected a 32-byte Archy ed25519 seed, got {len(archy_ed25519_seed)}")
x25519_priv = _hkdf_sha256(archy_ed25519_seed, _INFO_X25519, 32)
ed25519_priv = _hkdf_sha256(archy_ed25519_seed, _INFO_ED25519, 32)
return x25519_priv + ed25519_priv
def load_identity(archy_ed25519_seed: bytes):
"""Build an ``RNS.Identity`` deterministically from the Archy seed.
Imported lazily so this module (and the determinism unit test) can be reasoned
about without RNS installed; the daemon imports it after the venv is present.
"""
import RNS # noqa: PLC0415 — lazy by design
blob = rns_private_blob(archy_ed25519_seed)
identity = RNS.Identity(create_keys=False)
identity.load_private_key(blob)
return identity
def lxmf_destination_hash(archy_ed25519_seed: bytes) -> bytes:
"""The 16-byte LXMF *delivery* destination hash for this node's identity.
Uses the static ``Destination.hash`` so we can derive the address without a
running Reticulum/Transport instance (the daemon computes this at startup,
before bringing interfaces up).
"""
import RNS # noqa: PLC0415
identity = load_identity(archy_ed25519_seed)
return RNS.Destination.hash(identity, "lxmf", "delivery")

32
reticulum-daemon/build.sh Executable file
View File

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Build the PyInstaller single-binary for the OTA (plan Phase 1 packaging).
# Output: dist/archy-reticulum-daemon — drop next to /usr/local/bin/archipelago.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
if [ ! -d .venv ]; then
python3 -m venv .venv
fi
.venv/bin/pip install -q -r requirements.txt -r requirements-build.txt
rm -rf build dist archy-reticulum-daemon.spec
# --collect-submodules: RNS/LXMF load most of their own internals dynamically
# (interface drivers, transport backends), which PyInstaller's static import
# analysis can't see from a plain `import RNS`.
#
# -d noarchive is NOT optional: RNS.Interfaces/__init__.py builds its
# `__all__` by glob-ing *.py/*.pyc next to its own `__file__` at import time
# (`from RNS.Interfaces import *` in Reticulum.py relies on that). PyInstaller
# normally 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` at RNS.Reticulum() bring-up.
# noarchive keeps modules as loose .pyc files on disk so the glob still works.
.venv/bin/pyinstaller --onefile --name archy-reticulum-daemon --clean --noconfirm \
--collect-submodules RNS \
--collect-submodules LXMF \
--collect-data RNS \
-d noarchive \
reticulum_daemon.py
echo "Built dist/archy-reticulum-daemon ($(du -h dist/archy-reticulum-daemon | cut -f1))"

View File

@ -0,0 +1,2 @@
# Build-only dependency, not shipped at runtime — see build.sh.
pyinstaller==6.21.0

View File

@ -0,0 +1,4 @@
# Canonical Reticulum stack — pinned for reproducible, hash-verified bundles.
# Validated in the Phase-0 spike: RNS 1.3.5 + LXMF 1.0.1 on Python 3.13.
rns==1.3.5
lxmf==1.0.1

View File

@ -0,0 +1,507 @@
#!/usr/bin/env python3
"""Archipelago Reticulum daemon — host-supervised RNS + LXMF bridge.
archipelago spawns and supervises one of these per active Reticulum (RNode) radio.
It owns the serial port, runs the Reticulum stack + an LXMF router whose identity is
**derived deterministically from the Archy node key**, and exposes a tiny
line-delimited JSON-RPC over a Unix domain socket (0600) for the Rust side to drive.
Security posture (see the plan's "most secure way" section):
* Runs as the same rootless archipelago user; no root, no network control plane.
* RPC is a Unix socket only; the identity key never leaves the host and is never
logged. The daemon executes ONLY the fixed verb set below.
RPC (one JSON object per line, both directions):
in : {"cmd":"send","dest_hash":"<hex16>","content":"","title":"","method":"direct|opportunistic"}
{"cmd":"announce"}
{"cmd":"status"}
{"cmd":"send_resource","id":"<correlation>","dest_hash":"<hex16>","data_b64":""}
{"cmd":"shutdown"}
out: {"event":"ready","dest_hash":"<hex16>","display_name":""}
{"event":"recv","source_hash":"<hex16>","content":"","title":"","fields":{},"app_data":"<hex>","rssi":n,"snr":n,"stamp":t}
{"event":"announce","dest_hash":"<hex16>","app_data":"<hex>"}
{"event":"delivered","dest_hash":"<hex16>","state":"delivered|failed","id":"<hex>"}
{"event":"status","connected":bool,"dest_hash":"<hex16>","interfaces":[]}
{"event":"resource_progress","id":"<correlation>","transferred":n,"total":n}
{"event":"resource_sent","id":"<correlation>"}
{"event":"resource_failed","id":"<correlation>","reason":""}
{"event":"resource_recv","source_hash":"<hex16>","data_b64":""}
``send_resource`` is for large (>~2.3KB) binary payloads that don't fit the small
LXMF-message path it uses RNS's native Resource transfer protocol over a `RNS.Link`
to the peer's *resource* destination (a separate aspect from the LXMF delivery
destination, so it doesn't disturb normal messaging). Built for sending compressed
photos/files/voice-messages directly over LoRa instead of always falling back to Tor
past the small-message size cap. ``resource_recv``'s `data_b64` is the same
CBOR-encoded payload format already used for the small-inline LoRa path, so the Rust
side decodes it identically regardless of which path delivered it.
This is the Phase-1 skeleton: the identity/LXMF wiring and RPC loop are real and
exercised by ``--check`` / ``--selftest`` (no radio). The live LoRa message path is
validated in the Phase-0 hardware gate on .116/.228.
"""
from __future__ import annotations
import argparse
import asyncio
import base64
import json
import os
import signal
import sys
from pathlib import Path
from archy_rns_identity import lxmf_destination_hash, load_identity
# Lazy heavy imports (RNS/LXMF) happen in run(); --check stays import-light.
# ─────────────────────────── RNS config generation ───────────────────────────
def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, no_radio: bool) -> None:
"""Materialise an RNS config file. RNode interface for real radios; a loopback-
only config for --selftest so the stack comes up without hardware or network."""
configdir.mkdir(parents=True, exist_ok=True)
cfg = configdir / "config"
if no_radio:
interfaces = (
" [[Default Interface]]\n"
" type = AutoInterface\n"
" enabled = no\n"
)
else:
interfaces = (
" [[RNode LoRa]]\n"
" type = RNodeInterface\n"
" enabled = yes\n"
f" port = {serial_port}\n"
f" frequency = {lora['frequency']}\n"
f" bandwidth = {lora['bandwidth']}\n"
f" txpower = {lora['txpower']}\n"
f" spreadingfactor = {lora['spreadingfactor']}\n"
f" codingrate = {lora['codingrate']}\n"
)
cfg.write_text(
"[reticulum]\n"
" enable_transport = no\n"
" share_instance = no\n"
" panic_on_interface_error = no\n\n"
"[interfaces]\n" + interfaces
)
os.chmod(cfg, 0o600)
# ─────────────────────────────── the daemon ──────────────────────────────────
class ReticulumDaemon:
def __init__(self, args):
self.args = args
self.seed = self._read_seed(Path(args.identity_key))
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self.clients: set[asyncio.StreamWriter] = set()
self.reticulum = None
self.router = None
self.delivery_destination = None
self.identity = None
self.dest_hash_hex = lxmf_destination_hash(self.seed).hex()
# Resource transfer (large binary payloads over a dedicated Link, separate
# from LXMF delivery — see module docstring). Keyed by the peer's *resource*
# destination hash (bytes), not their LXMF delivery hash.
self.resource_destination = None
self.links: dict[bytes, "RNS.Link"] = {}
self.pending_resource_sends: dict[bytes, list[tuple[bytes, str]]] = {}
@staticmethod
def _read_seed(path: Path) -> bytes:
seed = path.read_bytes()
if len(seed) != 32:
raise SystemExit(f"identity key {path} must be 32 bytes, got {len(seed)}")
return seed
# ---- RNS / LXMF bring-up ----
def bring_up(self):
import RNS
import LXMF
configdir = Path(self.args.rns_config)
_write_rns_config(
configdir,
serial_port=self.args.serial_port,
lora={
"frequency": self.args.frequency,
"bandwidth": self.args.bandwidth,
"txpower": self.args.txpower,
"spreadingfactor": self.args.spreadingfactor,
"codingrate": self.args.codingrate,
},
no_radio=self.args.no_radio,
)
self.reticulum = RNS.Reticulum(configdir=str(configdir))
self.identity = load_identity(self.seed)
storagepath = str(configdir / "lxmf")
Path(storagepath).mkdir(parents=True, exist_ok=True)
self.router = LXMF.LXMRouter(identity=self.identity, storagepath=storagepath)
self.delivery_destination = self.router.register_delivery_identity(
self.identity, display_name=self.args.display_name
)
self.router.register_delivery_callback(self._on_lxmf_delivery)
# Hear other LXMF nodes' announces so we can surface peers + bind contacts.
RNS.Transport.register_announce_handler(_AnnounceHandler(self))
assert self.delivery_destination.hash.hex() == self.dest_hash_hex, (
"derived dest hash diverged from LXMF's — aspect/identity mismatch"
)
# Separate destination/aspect for inbound Resource transfers (large
# binary payloads), so it never collides with LXMF delivery traffic.
# Every peer running this same daemon code can derive our resource
# destination hash from our (already-announced) identity via
# RNS.Destination.hash(identity, "archy", "resource") — see
# _resource_dest_hash_for, used on the sending side.
self.resource_destination = RNS.Destination(
self.identity, RNS.Destination.IN, RNS.Destination.SINGLE,
"archy", "resource",
)
self.resource_destination.set_link_established_callback(
self._on_resource_link_established
)
def announce(self):
if self.delivery_destination is not None:
self.delivery_destination.announce(app_data=self._announce_app_data())
def _announce_app_data(self) -> bytes:
"""Carry the Archy identity so peers bind this RNS destination onto the
existing contact, the same way a meshcore/Meshtastic identity advert does.
Reuses the exact ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` wire format the
Rust side already parses (``protocol::parse_identity_broadcast``) and
binds via ``handle_identity_received``/``bind_federation_twins`` so a
Reticulum-carried identity merges into the SAME conversation as the
meshcore/Meshtastic/federation twins of the same Archy node, satisfying
cross-protocol DM convergence. The keys are the node's real Archipelago
ed25519/x25519 pubkeys (passed in by the Rust side, which already has
them) NOT this daemon's internally-HKDF-derived RNS keys, which exist
only to make the RNS destination hash deterministic and are never
themselves treated as an Archy identity.
Falls back to a plain display-name string (undetected as an identity
blob no `ARCHY:2:` prefix) if the Archy pubkeys weren't supplied, e.g.
a dev/selftest run with no `--archy-ed-pubkey-hex`.
"""
if self.args.archy_ed_pubkey_hex and self.args.archy_x25519_pubkey_hex:
return (
f"ARCHY:2:{self.args.archy_ed_pubkey_hex}:"
f"{self.args.archy_x25519_pubkey_hex}"
).encode("ascii")
return (self.args.display_name or "").encode("utf-8")
# ---- RNS-thread callbacks → asyncio ----
def _on_lxmf_delivery(self, message):
try:
app_data = b""
src = message.source_hash.hex() if message.source_hash else ""
self._emit_threadsafe({
"event": "recv",
"source_hash": src,
"content": message.content_as_string() if hasattr(message, "content_as_string")
else (message.content.decode("utf-8", "replace") if message.content else ""),
"title": message.title_as_string() if hasattr(message, "title_as_string") else "",
"app_data": app_data.hex(),
"stamp": getattr(message, "timestamp", None),
})
except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "delivery", "detail": str(e)})
def _emit_threadsafe(self, obj: dict):
self.loop.call_soon_threadsafe(self._broadcast, obj)
def _broadcast(self, obj: dict):
line = (json.dumps(obj) + "\n").encode("utf-8")
for w in list(self.clients):
try:
w.write(line)
except Exception:
self.clients.discard(w)
# ---- RPC server ----
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
self.clients.add(writer)
writer.write((json.dumps({
"event": "ready", "dest_hash": self.dest_hash_hex,
"display_name": self.args.display_name,
}) + "\n").encode())
try:
async for raw in reader:
line = raw.decode("utf-8", "replace").strip()
if not line:
continue
try:
req = json.loads(line)
except json.JSONDecodeError:
continue
await self._dispatch(req, writer)
finally:
self.clients.discard(writer)
async def _dispatch(self, req: dict, writer: asyncio.StreamWriter):
cmd = req.get("cmd")
if cmd == "send":
self._send(req)
elif cmd == "announce":
self.announce()
elif cmd == "status":
self._broadcast(self._status())
elif cmd == "send_resource":
self._send_resource(req)
elif cmd == "shutdown":
self.loop.stop()
# unknown verbs are ignored by contract
def _status(self) -> dict:
ifaces = []
if self.reticulum is not None:
try:
ifaces = [str(i) for i in self.reticulum.get_interface_stats().get("interfaces", [])]
except Exception:
pass
return {"event": "status", "connected": self.router is not None,
"dest_hash": self.dest_hash_hex, "interfaces": ifaces}
def _send(self, req: dict):
import RNS
import LXMF
try:
dest_hash = bytes.fromhex(req["dest_hash"])
except (KeyError, ValueError):
return
recipient_identity = RNS.Identity.recall(dest_hash)
if recipient_identity is None:
# No path/identity yet — ask the network and drop this attempt; the Rust
# side retries once the peer is reachable (mirrors LoRa "unreachable").
RNS.Transport.request_path(dest_hash)
self._broadcast({"event": "delivered", "dest_hash": req["dest_hash"],
"state": "failed", "id": "", "reason": "no_path"})
return
dest = RNS.Destination(recipient_identity, RNS.Destination.OUT,
RNS.Destination.SINGLE, "lxmf", "delivery")
method = {"direct": LXMF.LXMessage.DIRECT,
"opportunistic": LXMF.LXMessage.OPPORTUNISTIC,
"propagated": LXMF.LXMessage.PROPAGATED}.get(
req.get("method", "direct"), LXMF.LXMessage.DIRECT)
msg = LXMF.LXMessage(dest, self.delivery_destination,
req.get("content", ""), req.get("title", ""),
desired_method=method)
msg.register_delivery_callback(lambda m: self._emit_threadsafe(
{"event": "delivered", "dest_hash": req["dest_hash"], "state": "delivered",
"id": m.hash.hex() if m.hash else ""}))
msg.register_failed_callback(lambda m: self._emit_threadsafe(
{"event": "delivered", "dest_hash": req["dest_hash"], "state": "failed",
"id": m.hash.hex() if m.hash else ""}))
self.router.handle_outbound(msg)
# ---- Resource transfer (large binary payloads over a dedicated Link) ----
def _resource_dest_hash_for(self, lxmf_dest_hash: bytes):
"""Derive a peer's *resource* destination hash from their LXMF delivery
hash. Requires having already recalled their Identity (e.g. via a prior
`_send`/announce) returns None if we haven't heard from them yet."""
import RNS
identity = RNS.Identity.recall(lxmf_dest_hash)
if identity is None:
return None
return RNS.Destination.hash(identity, "archy", "resource")
def _get_or_create_link(self, lxmf_dest_hash: bytes):
import RNS
identity = RNS.Identity.recall(lxmf_dest_hash)
if identity is None:
RNS.Transport.request_path(lxmf_dest_hash)
return None, None
resource_hash = RNS.Destination.hash(identity, "archy", "resource")
link = self.links.get(resource_hash)
if link is not None and link.status != RNS.Link.CLOSED:
return link, resource_hash
out_dest = RNS.Destination(
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
"archy", "resource",
)
link = RNS.Link(out_dest)
link.set_link_closed_callback(
lambda lk: self.links.pop(resource_hash, None)
)
self.links[resource_hash] = link
return link, resource_hash
def _send_resource(self, req: dict):
import RNS
req_id = req.get("id", "")
try:
lxmf_dest_hash = bytes.fromhex(req["dest_hash"])
data = base64.b64decode(req["data_b64"])
except (KeyError, ValueError, TypeError):
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "bad_request"})
return
link, resource_hash = self._get_or_create_link(lxmf_dest_hash)
if link is None:
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "no_path"})
return
self.pending_resource_sends.setdefault(resource_hash, [])
if link.status == RNS.Link.ACTIVE:
self._start_resource(link, data, req_id)
else:
# Link is establishing — queue and flush from the established
# callback. set_link_established_callback only fires once per Link
# object (the cache is reused across sends), so re-set it here to
# make sure THIS send's queue entry gets flushed too.
self.pending_resource_sends[resource_hash].append((data, req_id))
link.set_link_established_callback(
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
)
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
import RNS
pending = self.pending_resource_sends.pop(resource_hash, [])
for data, req_id in pending:
if link.status == RNS.Link.ACTIVE:
self._start_resource(link, data, req_id)
else:
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "link_failed"})
def _start_resource(self, link, data: bytes, req_id: str):
import RNS
def on_progress(resource):
total = resource.get_data_size() if hasattr(resource, "get_data_size") else len(data)
transferred = int(total * resource.get_progress()) if hasattr(resource, "get_progress") else 0
self._emit_threadsafe({"event": "resource_progress", "id": req_id,
"transferred": transferred, "total": total})
def on_concluded(resource):
if resource.status == RNS.Resource.COMPLETE:
self._emit_threadsafe({"event": "resource_sent", "id": req_id})
else:
self._emit_threadsafe({"event": "resource_failed", "id": req_id,
"reason": "transfer_failed"})
RNS.Resource(data, link, callback=on_concluded, progress_callback=on_progress)
def _on_resource_link_established(self, link):
import RNS
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_concluded_callback(self._on_resource_received)
def _on_resource_received(self, resource):
import RNS
try:
if resource.status != RNS.Resource.COMPLETE:
return
identity = resource.link.get_remote_identity()
# Report the peer's LXMF *delivery* hash (not the raw identity hash,
# and not the resource-aspect hash) since that's what the Rust side's
# contact table is keyed on for every other inbound event.
source_hash = (
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
if identity is not None else ""
)
self._emit_threadsafe({
"event": "resource_recv",
"source_hash": source_hash,
"data_b64": base64.b64encode(resource.data).decode("ascii"),
})
except Exception as e: # never let a callback kill the RNS thread
self._emit_threadsafe({"event": "error", "where": "resource_recv",
"detail": str(e)})
async def serve(self):
sock_path = self.args.socket
if os.path.exists(sock_path):
os.unlink(sock_path)
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
os.chmod(sock_path, 0o600)
self.announce()
async with server:
await server.serve_forever()
class _AnnounceHandler:
"""Surfaces every heard LXMF delivery announce to the Rust side."""
aspect_filter = "lxmf.delivery"
def __init__(self, daemon: "ReticulumDaemon"):
self.daemon = daemon
self.receive_path_responses = True
def received_announce(self, destination_hash, announced_identity, app_data):
self.daemon._emit_threadsafe({
"event": "announce",
"dest_hash": destination_hash.hex(),
"app_data": (app_data or b"").hex(),
})
# ─────────────────────────────── entrypoints ─────────────────────────────────
def _parse_args(argv):
p = argparse.ArgumentParser(description="Archipelago Reticulum daemon")
p.add_argument("--identity-key", required=True, help="path to Archy node_key (32-byte ed25519 seed)")
p.add_argument("--socket", default="/tmp/archy-reticulum.sock", help="Unix RPC socket path")
p.add_argument("--rns-config", default=str(Path.home() / ".archy-reticulum"), help="RNS config/storage dir")
p.add_argument("--serial-port", help="RNode serial device, e.g. /dev/reticulum-radio")
p.add_argument("--display-name", default="Archy", help="LXMF display name")
p.add_argument("--archy-ed-pubkey-hex", default=None,
help="Archy ed25519 pubkey hex (64 chars) — embedded in the announce "
"app_data as ARCHY:2:... so peers bind this RNS destination onto "
"the existing Archy contact. Omit for a plain display-name announce.")
p.add_argument("--archy-x25519-pubkey-hex", default=None,
help="Archy x25519 pubkey hex (64 chars), paired with --archy-ed-pubkey-hex.")
# LoRa profile (defaults are EU_868-ish; settled by the Phase-0 spike)
p.add_argument("--frequency", type=int, default=869525000)
p.add_argument("--bandwidth", type=int, default=125000)
p.add_argument("--txpower", type=int, default=17)
p.add_argument("--spreadingfactor", type=int, default=8)
p.add_argument("--codingrate", type=int, default=5)
p.add_argument("--no-radio", action="store_true", help="bring up with no RNode (selftest)")
p.add_argument("--check", action="store_true", help="print derived dest hash and exit (no RNS)")
p.add_argument("--selftest", action="store_true", help="bring up RNS+LXMF with no radio, verify, exit")
return p.parse_args(argv)
def main(argv=None) -> int:
args = _parse_args(argv if argv is not None else sys.argv[1:])
if args.check:
seed = ReticulumDaemon._read_seed(Path(args.identity_key))
print(lxmf_destination_hash(seed).hex())
return 0
daemon = ReticulumDaemon(args)
if args.selftest:
args.no_radio = True
daemon.bring_up()
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
f"display_name={args.display_name!r} lxmf_router=up")
return 0
for sig in (signal.SIGINT, signal.SIGTERM):
daemon.loop.add_signal_handler(sig, daemon.loop.stop)
try:
daemon.bring_up()
daemon.loop.run_until_complete(daemon.serve())
except RuntimeError:
pass # loop.stop() during serve_forever
finally:
if os.path.exists(args.socket):
os.unlink(args.socket)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Phase 0 gate #1 — deterministic RNS identity from the Archy seed (NO radio needed).
Proves the load-bearing assumption behind the whole "derive RNS identity from Archy
keys" decision: a node's Reticulum/LXMF destination hash is a stable, reproducible
function of its 32-byte Archy Ed25519 seed.
Run:
reticulum-daemon/.venv/bin/python reticulum-daemon/spike_identity.py
Exits non-zero (and prints FAIL) if any invariant breaks.
"""
from __future__ import annotations
import sys
from archy_rns_identity import lxmf_destination_hash, rns_private_blob
# Two fixed, non-secret test seeds (32 bytes each). Real seeds come from node_key.
SEED_A = bytes(range(32))
SEED_B = bytes((i * 7 + 3) & 0xFF for i in range(32))
def _hex(b: bytes) -> str:
return b.hex()
def main() -> int:
ok = True
# 1. The 64-byte private blob is deterministic and well-formed.
blob1 = rns_private_blob(SEED_A)
blob2 = rns_private_blob(SEED_A)
if blob1 != blob2 or len(blob1) != 64:
print(f"FAIL: private blob not deterministic/64B (len={len(blob1)})")
ok = False
else:
print(f"ok : private blob deterministic, 64B ({_hex(blob1)[:16]}…)")
# 2. Same seed -> same LXMF destination hash, across two independent builds.
h1 = lxmf_destination_hash(SEED_A)
h2 = lxmf_destination_hash(SEED_A)
if h1 != h2 or len(h1) != 16:
print(f"FAIL: destination hash not stable/16B: {_hex(h1)} vs {_hex(h2)}")
ok = False
else:
print(f"ok : destination hash stable, 16B <{_hex(h1)}>")
# 3. Different seed -> different destination (no accidental collision/constant).
h3 = lxmf_destination_hash(SEED_B)
if h3 == h1:
print(f"FAIL: distinct seeds produced the same destination <{_hex(h3)}>")
ok = False
else:
print(f"ok : distinct seed -> distinct dest <{_hex(h3)}>")
print("\nPASS — RNS identity is deterministic from the Archy seed."
if ok else "\nFAILED — see above.")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())