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:
parent
12e7990b10
commit
f54c853128
@ -48,6 +48,17 @@ impl ApiHandler {
|
||||
.get("x-blob-filename")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
// Optional caller-supplied thumbnail (small, base64) — e.g. the mesh
|
||||
// chat's image-quality picker generates a tiny client-side preview so
|
||||
// a ContentRef receiver can render something before fetching the full
|
||||
// blob. Best-effort: a malformed header is just ignored, not fatal.
|
||||
let thumb_bytes = headers
|
||||
.get("x-blob-thumb")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|b64| {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
STANDARD.decode(b64).ok()
|
||||
});
|
||||
|
||||
let bytes = body.to_vec();
|
||||
// Uploads through /api/blob come from the node owner's session and
|
||||
@ -55,7 +66,7 @@ impl ApiHandler {
|
||||
// pictures, banners). Store them public so `/blob/<cid>` serves
|
||||
// without a capability check — external Nostr clients fetching a
|
||||
// kind-0 `picture` URL have no cap and can't get one.
|
||||
match store.put(&bytes, &mime, filename, None, true).await {
|
||||
match store.put(&bytes, &mime, filename, thumb_bytes, true).await {
|
||||
Ok(meta) => {
|
||||
let exp =
|
||||
(chrono::Utc::now().timestamp() as u64) + crate::blobs::DEFAULT_CAP_TTL_SECS;
|
||||
|
||||
@ -391,9 +391,24 @@ impl RpcHandler {
|
||||
|
||||
// Hard ceiling matching the chunked-send capacity (~20 chunks * 152
|
||||
// b64 chars after MCIIXXTT framing). Anything larger must go via
|
||||
// ContentRef over Tor.
|
||||
// ContentRef over Tor — UNLESS the active device is Reticulum, which
|
||||
// can carry up to RETICULUM_RESOURCE_MAX directly over LoRa via a
|
||||
// native RNS Resource transfer (keep this ceiling in sync with
|
||||
// `mesh.transport-advice`'s `"resource-mesh"` tier, the source of
|
||||
// truth the frontend consults before ever reaching this size).
|
||||
const INLINE_HARD_MAX: usize = 2300;
|
||||
if bytes.len() > INLINE_HARD_MAX {
|
||||
const RETICULUM_RESOURCE_MAX: usize = 2 * 1024 * 1024;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let device_type = svc.shared_state().status.read().await.device_type;
|
||||
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
|
||||
&& device_type == crate::mesh::types::DeviceType::Reticulum
|
||||
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
|
||||
|
||||
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
|
||||
anyhow::bail!(
|
||||
"Payload {} bytes exceeds inline max {} — use mesh.send-content (ContentRef) instead",
|
||||
bytes.len(),
|
||||
@ -414,11 +429,6 @@ impl RpcHandler {
|
||||
.put(&bytes, &mime, filename.clone(), None, false)
|
||||
.await?;
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
|
||||
let content = ContentInlinePayload {
|
||||
mime: mime.clone(),
|
||||
filename: filename.clone(),
|
||||
@ -447,8 +457,8 @@ impl RpcHandler {
|
||||
"inline": true,
|
||||
});
|
||||
|
||||
let msg = svc
|
||||
.send_typed_wire(
|
||||
let msg = if use_resource_transfer {
|
||||
svc.send_content_resource(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
@ -456,12 +466,24 @@ impl RpcHandler {
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?;
|
||||
.await?
|
||||
} else {
|
||||
svc.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"content_ref",
|
||||
&display,
|
||||
Some(typed_json),
|
||||
seq,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
info!(
|
||||
contact_id,
|
||||
size = meta.size,
|
||||
cid = %meta.cid,
|
||||
via_resource = use_resource_transfer,
|
||||
"Sent content_inline over mesh"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
@ -492,8 +514,19 @@ impl RpcHandler {
|
||||
// Knobs — keep in sync with the frontend modal copy.
|
||||
const MESH_AUTO_MAX: u64 = 1024;
|
||||
const MESH_HARD_MAX: u64 = 2300;
|
||||
// Reticulum-only: above the small inline-chunk cap, a real RNS Resource
|
||||
// transfer can still carry the payload directly over LoRa (native
|
||||
// chunked transfer with retries) instead of falling back to Tor. Capped
|
||||
// well under TOR_LARGE_WARN to keep worst-case LoRa transfer time
|
||||
// bounded — comfortably covers the HIGH image preset (512KB target).
|
||||
const RETICULUM_RESOURCE_MAX: u64 = 2 * 1024 * 1024;
|
||||
const TOR_LARGE_WARN: u64 = 5 * 1024 * 1024;
|
||||
const LORA_BYTES_PER_SEC: u64 = 50;
|
||||
// Meshcore/Meshtastic effective LoRa throughput after retries/FEC is much
|
||||
// lower than the raw radio bitrate. Reticulum's RNodeInterface reports its
|
||||
// real bitrate (e.g. ~3125 bps ≈ 390 B/s observed live), so estimates for it
|
||||
// would be wildly pessimistic at the generic 50 B/s figure.
|
||||
const LORA_BYTES_PER_SEC_DEFAULT: u64 = 50;
|
||||
const LORA_BYTES_PER_SEC_RETICULUM: u64 = 390;
|
||||
|
||||
// Resolve peer Tor reachability via federation node list.
|
||||
let service = self.mesh_service.read().await;
|
||||
@ -501,6 +534,12 @@ impl RpcHandler {
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let device_type = state.status.read().await.device_type;
|
||||
let lora_bytes_per_sec = if device_type == crate::mesh::types::DeviceType::Reticulum {
|
||||
LORA_BYTES_PER_SEC_RETICULUM
|
||||
} else {
|
||||
LORA_BYTES_PER_SEC_DEFAULT
|
||||
};
|
||||
let (peer_pubkey_hex, peer_did) = {
|
||||
let peers = state.peers.read().await;
|
||||
match peers.get(&contact_id) {
|
||||
@ -520,8 +559,10 @@ impl RpcHandler {
|
||||
.map(|d| nodes.iter().any(|n| &n.did == d))
|
||||
.unwrap_or(false);
|
||||
|
||||
let est_seconds = (size.saturating_add(LORA_BYTES_PER_SEC - 1) / LORA_BYTES_PER_SEC).max(1);
|
||||
let est_seconds =
|
||||
(size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
|
||||
|
||||
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
|
||||
let (tier, reason) = if size <= MESH_AUTO_MAX {
|
||||
("auto-mesh", "Small enough to send inline over mesh")
|
||||
} else if size <= MESH_HARD_MAX {
|
||||
@ -530,6 +571,8 @@ impl RpcHandler {
|
||||
} else {
|
||||
("auto-mesh", "No Tor path — sending inline over mesh")
|
||||
}
|
||||
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
|
||||
("resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer")
|
||||
} else if size <= TOR_LARGE_WARN {
|
||||
if has_tor {
|
||||
("tor-only", "Too large for mesh — Tor only")
|
||||
@ -674,18 +717,6 @@ impl RpcHandler {
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cid"))?
|
||||
.to_string();
|
||||
let sender_onion = params["sender_onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let cap_token = params["cap_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
|
||||
.to_string();
|
||||
let cap_exp = params["cap_exp"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
|
||||
let mime_hint = params["mime"]
|
||||
.as_str()
|
||||
.unwrap_or("application/octet-stream")
|
||||
@ -709,7 +740,12 @@ impl RpcHandler {
|
||||
};
|
||||
|
||||
// Short-circuit if we already hold the blob — still issue a fresh
|
||||
// self-cap so the UI gets a displayable local URL.
|
||||
// self-cap so the UI gets a displayable local URL. Checked BEFORE the
|
||||
// sender_onion/cap_token/cap_exp params are required below: an inline
|
||||
// ContentInline attachment (mesh.send-content-inline) is written to
|
||||
// our own BlobStore the moment it's received/sent (dispatch.rs), so
|
||||
// its typed_payload never carries those fields at all — only a
|
||||
// ContentRef fetched from a remote peer needs them.
|
||||
if blob_store.has(&cid).await {
|
||||
let local_exp = (chrono::Utc::now().timestamp() as u64) + DEFAULT_CAP_TTL_SECS;
|
||||
let local_cap = blob_store.issue_capability(&cid, &self_pubkey_hex, local_exp);
|
||||
@ -725,6 +761,19 @@ impl RpcHandler {
|
||||
}));
|
||||
}
|
||||
|
||||
let sender_onion = params["sender_onion"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
let cap_token = params["cap_token"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
|
||||
.to_string();
|
||||
let cap_exp = params["cap_exp"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
|
||||
|
||||
// Reach the sender: FIPS preferred when the sender is federated
|
||||
// and has advertised a FIPS npub, Tor fallback otherwise.
|
||||
// Cap/exp/peer in the query string match what the sender signed in
|
||||
|
||||
@ -63,6 +63,16 @@ pub enum MeshCommand {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Send pre-encoded binary over a dedicated Reticulum RNS Resource
|
||||
/// transfer instead of the small inline-chunk path — Reticulum-only, see
|
||||
/// `MeshRadioDevice::send_resource`. Used for large attachments
|
||||
/// (compressed photos, voice messages) that exceed the small-message cap
|
||||
/// but fit a sane LoRa-Resource budget; routing decision is made by the
|
||||
/// RPC layer (`mesh.transport-advice`'s `"resource-mesh"` tier).
|
||||
SendResource {
|
||||
dest_pubkey_prefix: [u8; 6],
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// Send PLAIN text as one or more native meshcore DMs to a stock client
|
||||
/// (e.g. a phone). Long text is split into multiple readable plain messages
|
||||
/// — never MC-chunked — because stock clients can't reassemble archy's
|
||||
@ -372,6 +382,7 @@ impl MeshState {
|
||||
/// 4. Reconnect on disconnect
|
||||
pub fn spawn_mesh_listener(
|
||||
state: Arc<MeshState>,
|
||||
data_dir: std::path::PathBuf,
|
||||
device_path: Option<String>,
|
||||
our_did: String,
|
||||
our_ed_pubkey_hex: String,
|
||||
@ -380,6 +391,7 @@ pub fn spawn_mesh_listener(
|
||||
server_name: Option<String>,
|
||||
lora_region: Option<String>,
|
||||
channel_name: Option<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
@ -395,6 +407,7 @@ pub fn spawn_mesh_listener(
|
||||
|
||||
match session::run_mesh_session(
|
||||
&state,
|
||||
&data_dir,
|
||||
device_path.as_deref(),
|
||||
&our_did,
|
||||
&our_ed_pubkey_hex,
|
||||
@ -403,6 +416,7 @@ pub fn spawn_mesh_listener(
|
||||
server_name.as_deref(),
|
||||
lora_region.as_deref(),
|
||||
channel_name.as_deref(),
|
||||
device_kind,
|
||||
&mut shutdown,
|
||||
&mut cmd_rx,
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
//! Mesh session lifecycle: connect, initialize, main loop.
|
||||
|
||||
use super::super::meshtastic::MeshtasticDevice;
|
||||
use super::super::reticulum::ReticulumLink;
|
||||
use super::super::serial::MeshcoreDevice;
|
||||
use super::super::types::*;
|
||||
use super::{
|
||||
@ -8,6 +9,7 @@ use super::{
|
||||
SYNC_INTERVAL,
|
||||
};
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
@ -16,6 +18,7 @@ use tracing::{debug, error, info, warn};
|
||||
enum MeshRadioDevice {
|
||||
Meshcore(MeshcoreDevice),
|
||||
Meshtastic(MeshtasticDevice),
|
||||
Reticulum(ReticulumLink),
|
||||
}
|
||||
|
||||
impl MeshRadioDevice {
|
||||
@ -23,6 +26,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(_) => DeviceType::Meshcore,
|
||||
Self::Meshtastic(_) => DeviceType::Meshtastic,
|
||||
Self::Reticulum(_) => DeviceType::Reticulum,
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,6 +34,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.advert_name.clone(),
|
||||
Self::Meshtastic(device) => device.advert_name(),
|
||||
Self::Reticulum(device) => device.advert_name(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +42,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.set_advert_name(name).await,
|
||||
Self::Meshtastic(device) => device.set_advert_name(name).await,
|
||||
Self::Reticulum(device) => device.set_advert_name(name).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,11 +50,14 @@ impl MeshRadioDevice {
|
||||
/// their own band on the device, so this is a no-op for them; Meshtastic
|
||||
/// radios ship region-UNSET (RF-silent) and must be set or they never mesh.
|
||||
/// Returns `Ok(true)` when a region was written (the device reboots to
|
||||
/// apply, so the caller should restart the session).
|
||||
/// apply, so the caller should restart the session). No-op for Reticulum:
|
||||
/// the daemon's RNodeInterface config carries its own LoRa profile, not
|
||||
/// driven through this firmware-admin path.
|
||||
async fn ensure_lora_region(&mut self, region: Option<&str>) -> Result<bool> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(false),
|
||||
Self::Meshtastic(device) => device.ensure_lora_region(region).await,
|
||||
Self::Reticulum(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,11 +65,14 @@ impl MeshRadioDevice {
|
||||
/// other. No-op for meshcore (it joins its channel by name on the device);
|
||||
/// Meshtastic radios can sit on mismatched channels otherwise and silently
|
||||
/// drop every packet as undecryptable. Returns `Ok(true)` when a channel was
|
||||
/// written (device reboots; caller should restart the session).
|
||||
/// written (device reboots; caller should restart the session). No-op for
|
||||
/// Reticulum: RNS has no shared-PSK channel concept (see
|
||||
/// `ReticulumLink::send_channel_text`).
|
||||
async fn ensure_channel(&mut self, channel_name: Option<&str>) -> Result<bool> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(false),
|
||||
Self::Meshtastic(device) => device.ensure_channel(channel_name).await,
|
||||
Self::Reticulum(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,28 +80,33 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_self_advert().await,
|
||||
Self::Meshtastic(device) => device.send_self_advert().await,
|
||||
Self::Reticulum(device) => device.send_self_advert().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lightweight serial keepalive (Meshtastic only). Keeps the firmware
|
||||
/// streaming RECEIVED packets to our serial client — without it the radio
|
||||
/// can mark a quiet client gone and deliver only our own queue-status.
|
||||
/// Meshcore needs no such ping.
|
||||
/// Meshcore/Reticulum need no such ping (Reticulum's "serial" traffic is
|
||||
/// the daemon's own RNS link, not a firmware queue we poll).
|
||||
async fn send_keepalive(&mut self) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(()),
|
||||
Self::Meshtastic(device) => device.send_keepalive().await,
|
||||
Self::Reticulum(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Actively advertise our identity over the air. Meshcore already does this
|
||||
/// inside `send_self_advert` (CMD_SEND_SELF_ADVERT), so this is a no-op for
|
||||
/// it; Meshtastic needs an explicit NodeInfo broadcast or peers never learn
|
||||
/// about an already-running node.
|
||||
/// about an already-running node. No-op for Reticulum: its `announce` (via
|
||||
/// `send_self_advert`) already covers discovery.
|
||||
async fn send_nodeinfo_advert(&mut self, want_response: bool) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(_) => Ok(()),
|
||||
Self::Meshtastic(device) => device.send_nodeinfo_broadcast(want_response).await,
|
||||
Self::Reticulum(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,6 +114,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_channel_text(channel, payload).await,
|
||||
Self::Meshtastic(device) => device.send_channel_text(channel, payload).await,
|
||||
Self::Reticulum(device) => device.send_channel_text(channel, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,15 +122,34 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
Self::Reticulum(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `data` over a dedicated RNS Resource transfer instead of the
|
||||
/// small-payload "content" path — only Reticulum has anything resembling
|
||||
/// this (a native large-binary transfer protocol over a `RNS.Link`).
|
||||
/// Meshcore/Meshtastic have no equivalent in our driver; callers must
|
||||
/// check `device_type() == DeviceType::Reticulum` before reaching for
|
||||
/// this (see `mesh.transport-advice`'s `"resource-mesh"` tier, which is
|
||||
/// Reticulum-only), so an Err here means the caller's gating is wrong,
|
||||
/// not a legitimate no-op.
|
||||
async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
||||
match self {
|
||||
Self::Meshcore(_) | Self::Meshtastic(_) => {
|
||||
anyhow::bail!("Resource transfer is Reticulum-only")
|
||||
}
|
||||
Self::Reticulum(device) => device.send_resource(dest_pubkey_prefix, data).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn reboot(&mut self, seconds: i64) -> Result<()> {
|
||||
match self {
|
||||
// Meshcore has no equivalent local-admin reboot in our driver; the
|
||||
// RX-deaf recovery this targets is Meshtastic-specific.
|
||||
// Meshcore/Reticulum have no equivalent local-admin reboot in our
|
||||
// driver; the RX-deaf recovery this targets is Meshtastic-specific.
|
||||
Self::Meshcore(_) => Ok(()),
|
||||
Self::Meshtastic(device) => device.reboot(seconds).await,
|
||||
Self::Reticulum(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -120,6 +157,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.remove_contact(pubkey).await,
|
||||
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
|
||||
Self::Reticulum(device) => device.remove_contact(pubkey).await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,6 +181,11 @@ impl MeshRadioDevice {
|
||||
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||
.await
|
||||
}
|
||||
Self::Reticulum(device) => {
|
||||
device
|
||||
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -150,6 +193,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.get_contacts().await,
|
||||
Self::Meshtastic(device) => device.get_contacts().await,
|
||||
Self::Reticulum(device) => device.get_contacts().await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,6 +201,8 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.reset_contact_path(pubkey).await,
|
||||
Self::Meshtastic(device) => device.reset_contact_path(pubkey).await,
|
||||
// RNS does its own pathfinding — no firmware path table to reset.
|
||||
Self::Reticulum(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,6 +210,7 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.sync_messages().await,
|
||||
Self::Meshtastic(device) => device.sync_messages().await,
|
||||
Self::Reticulum(device) => device.sync_messages().await,
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,46 +218,89 @@ impl MeshRadioDevice {
|
||||
match self {
|
||||
Self::Meshcore(device) => device.try_recv_frame().await,
|
||||
Self::Meshtastic(device) => device.try_recv_frame().await,
|
||||
Self::Reticulum(device) => device.try_recv_frame().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// PKI-E2E status of the last inbound frame (meshtastic only; meshcore's
|
||||
/// per-message E2E is derived in the frames decrypt path). Take-and-clear.
|
||||
/// per-message E2E is derived in the frames decrypt path). Reticulum/LXMF
|
||||
/// is unconditionally E2E (no plaintext mode), so it always reports true.
|
||||
/// Take-and-clear.
|
||||
fn take_rx_encrypted(&mut self) -> bool {
|
||||
match self {
|
||||
Self::Meshcore(_) => false,
|
||||
Self::Meshtastic(device) => device.take_rx_encrypted(),
|
||||
Self::Reticulum(device) => device.take_rx_encrypted(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all candidate serial ports and open the first supported mesh device found.
|
||||
async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
///
|
||||
/// `device_kind`, when set, pins the expected firmware (operator-confirmed via
|
||||
/// `MeshConfig.device_kind` — see the plan's §2c reflashable-board note): only
|
||||
/// that one device's probe runs, so a non-matching firmware's init bytes are
|
||||
/// never injected into the port. `None` keeps the strict
|
||||
/// Meshcore→Meshtastic→Reticulum probe order.
|
||||
async fn auto_detect_and_open(
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let paths = super::super::serial::detect_serial_devices().await;
|
||||
if paths.is_empty() {
|
||||
anyhow::bail!("No serial devices found in /dev");
|
||||
}
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
if device_kind.is_none_or(|k| k == DeviceType::Meshcore) {
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshcore(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
|
||||
}
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
if device_kind.is_none_or(|k| k == DeviceType::Meshtastic) {
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, firmware = %info.firmware_version, "Found Meshtastic device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Meshtastic(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Meshtastic device"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open serial port for Meshtastic"),
|
||||
}
|
||||
}
|
||||
// Tried LAST: the same reflashable board (e.g. Heltec V3) can run
|
||||
// Meshcore, Meshtastic, or RNode firmware, so each probe must fail
|
||||
// strictly before the next is attempted. The RNode KISS-detect probe
|
||||
// is the most expensive (spawns the supervised daemon on a match), so
|
||||
// it goes after the two cheap firmware-specific handshakes above.
|
||||
if device_kind.is_none_or(|k| k == DeviceType::Reticulum) {
|
||||
match ReticulumLink::open(
|
||||
path,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => {
|
||||
info!(path = %path, "Found Reticulum (RNode) device via auto-detect");
|
||||
return Ok((path.clone(), MeshRadioDevice::Reticulum(dev), info));
|
||||
}
|
||||
Err(e) => debug!(path = %path, error = %e, "Reticulum daemon failed to initialize"),
|
||||
},
|
||||
Err(e) => debug!(path = %path, error = %e, "Not a Reticulum RNode"),
|
||||
}
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
@ -220,7 +310,57 @@ async fn auto_detect_and_open() -> Result<(String, MeshRadioDevice, DeviceInfo)>
|
||||
)
|
||||
}
|
||||
|
||||
async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
async fn open_preferred_path(
|
||||
path: &str,
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
) -> Result<(MeshRadioDevice, DeviceInfo)> {
|
||||
// Pinned: try only the configured firmware and surface its own error —
|
||||
// never fall through to (and inject probe bytes into) another firmware's
|
||||
// handshake on this port.
|
||||
if let Some(kind) = device_kind {
|
||||
return match kind {
|
||||
DeviceType::Meshcore => {
|
||||
let mut dev = MeshcoreDevice::open(path)
|
||||
.await
|
||||
.context("Could not open preferred path as Meshcore")?;
|
||||
let info = dev
|
||||
.initialize()
|
||||
.await
|
||||
.context("Preferred path is not a working Meshcore device")?;
|
||||
Ok((MeshRadioDevice::Meshcore(dev), info))
|
||||
}
|
||||
DeviceType::Meshtastic => {
|
||||
let mut dev = MeshtasticDevice::open(path)
|
||||
.await
|
||||
.context("Could not open preferred path as Meshtastic")?;
|
||||
let info = dev
|
||||
.initialize()
|
||||
.await
|
||||
.context("Preferred path is not a working Meshtastic device")?;
|
||||
Ok((MeshRadioDevice::Meshtastic(dev), info))
|
||||
}
|
||||
DeviceType::Reticulum => {
|
||||
let mut dev = ReticulumLink::open(
|
||||
path,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
.context("Could not open preferred path as Reticulum")?;
|
||||
let info = dev
|
||||
.initialize()
|
||||
.await
|
||||
.context("Preferred path is not a working Reticulum RNode")?;
|
||||
Ok((MeshRadioDevice::Reticulum(dev), info))
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("device_kind cannot be Unknown"),
|
||||
};
|
||||
}
|
||||
|
||||
match MeshcoreDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
|
||||
@ -230,10 +370,24 @@ async fn open_preferred_path(path: &str) -> Result<(MeshRadioDevice, DeviceInfo)
|
||||
}
|
||||
match MeshtasticDevice::open(path).await {
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not Meshtastic"),
|
||||
Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)),
|
||||
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshtastic"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
|
||||
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshtastic"),
|
||||
}
|
||||
match ReticulumLink::open(
|
||||
path,
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
Ok(info) => Ok((MeshRadioDevice::Reticulum(dev), info)),
|
||||
Err(e) => Err(e).context("Preferred path is not a working Reticulum RNode"),
|
||||
},
|
||||
Err(e) => Err(e).context("Could not open preferred path as Reticulum"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,9 +592,12 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
// surfaced. `radio_contact_blocklist` is retained but unused.
|
||||
let mut peers = state.peers.write().await;
|
||||
let is_meshtastic = matches!(device.device_type(), DeviceType::Meshtastic);
|
||||
let is_reticulum = matches!(device.device_type(), DeviceType::Reticulum);
|
||||
for (idx, contact) in contacts.iter().enumerate() {
|
||||
let contact_id = if is_meshtastic {
|
||||
meshtastic_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
|
||||
} else if is_reticulum {
|
||||
reticulum_contact_id(&contact.public_key_hex).unwrap_or(idx as u32)
|
||||
} else {
|
||||
idx as u32
|
||||
};
|
||||
@ -464,6 +621,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
// A non-zero path_len means the firmware has a route (direct
|
||||
// or flood) to this contact — i.e. we can deliver to it.
|
||||
reachable: contact.path_len != 0,
|
||||
// E2E capability only grows (once the radio learns a peer's
|
||||
// PKI key it stays known), so OR with any prior value rather
|
||||
// than letting a transient contact refresh clear the pill.
|
||||
pkc_capable: contact.pkc_capable
|
||||
|| existing.map(|p| p.pkc_capable).unwrap_or(false),
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
}
|
||||
@ -530,6 +692,17 @@ fn meshtastic_contact_id(public_key_hex: &str) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable `u32` contact id derived from a Reticulum contact's `public_key_hex`
|
||||
/// (hex of the 16-byte RNS destination hash). Delegates to the canonical
|
||||
/// derivation in `reticulum.rs` so there is exactly one masking rule (must
|
||||
/// stay below `FEDERATION_CONTACT_ID_BASE`, mod.rs:53) shared with
|
||||
/// `ReticulumLink::initialize()`'s reported `node_id`.
|
||||
fn reticulum_contact_id(public_key_hex: &str) -> Option<u32> {
|
||||
let bytes = hex::decode(public_key_hex).ok()?;
|
||||
let hash: [u8; 16] = bytes.try_into().ok()?;
|
||||
Some(super::super::reticulum::reticulum_contact_id_from_hash(&hash))
|
||||
}
|
||||
|
||||
/// Drain any queued messages from the device.
|
||||
/// Returns `true` if a write/communication error occurred (for failure tracking).
|
||||
async fn sync_queued_messages(
|
||||
@ -574,6 +747,7 @@ static CHANNEL_PROVISION_ATTEMPTS: std::sync::atomic::AtomicU32 =
|
||||
/// Run a single mesh session (connect, initialize, main loop).
|
||||
pub(super) async fn run_mesh_session(
|
||||
state: &Arc<MeshState>,
|
||||
data_dir: &Path,
|
||||
preferred_path: Option<&str>,
|
||||
our_did: &str,
|
||||
our_ed_pubkey_hex: &str,
|
||||
@ -582,23 +756,33 @@ pub(super) async fn run_mesh_session(
|
||||
server_name: Option<&str>,
|
||||
lora_region: Option<&str>,
|
||||
channel_name: Option<&str>,
|
||||
device_kind: Option<DeviceType>,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> Result<()> {
|
||||
// Detect device — try preferred path first, fall back to auto-detect
|
||||
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
|
||||
match open_preferred_path(path).await {
|
||||
match open_preferred_path(
|
||||
path,
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((dev, info)) => (path.to_string(), dev, info),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open().await?
|
||||
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auto_detect_and_open().await?
|
||||
auto_detect_and_open(data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex, device_kind).await?
|
||||
};
|
||||
|
||||
// Update status
|
||||
@ -904,6 +1088,29 @@ async fn handle_send_command(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
MeshCommand::SendResource {
|
||||
dest_pubkey_prefix,
|
||||
payload,
|
||||
} => {
|
||||
// No MC-chunk framing here — RNS Resources do their own native
|
||||
// chunked transfer at the link layer, so the payload goes through
|
||||
// as-is (the receiving daemon hands back the complete blob in one
|
||||
// `resource_recv` event).
|
||||
if let Err(e) = device.send_resource(&dest_pubkey_prefix, &payload).await {
|
||||
*consecutive_write_failures += 1;
|
||||
warn!(
|
||||
failures = *consecutive_write_failures,
|
||||
"Failed to send Reticulum resource: {}", e
|
||||
);
|
||||
} else {
|
||||
*consecutive_write_failures = 0;
|
||||
info!(
|
||||
dest = %hex::encode(dest_pubkey_prefix),
|
||||
len = payload.len(),
|
||||
"Sent Reticulum resource transfer"
|
||||
);
|
||||
}
|
||||
}
|
||||
MeshCommand::BroadcastChannel { channel, payload } => {
|
||||
if let Err(e) = device.send_channel_text(channel, &payload).await {
|
||||
*consecutive_write_failures += 1;
|
||||
|
||||
@ -481,6 +481,29 @@ pub struct ReactionPayload {
|
||||
pub emoji: String,
|
||||
}
|
||||
|
||||
/// `Option<Vec<u8>>` <-> base64 string, for fields that need to survive a JSON
|
||||
/// round-trip to the frontend readably (plain serde would emit/expect a JSON
|
||||
/// array of numbers for `Vec<u8>`, which isn't what `data:` URLs want). CBOR
|
||||
/// wire encoding pays a small (~33%) size tax for this on `thumb_bytes`
|
||||
/// specifically — negligible given thumbnails are capped at ~60 bytes.
|
||||
mod base64_opt_bytes {
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Deserializer, Serializer};
|
||||
|
||||
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
|
||||
match v {
|
||||
Some(bytes) => s.serialize_str(&STANDARD.encode(bytes)),
|
||||
None => s.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
|
||||
let opt: Option<String> = Option::deserialize(d)?;
|
||||
opt.map(|s| STANDARD.decode(&s).map_err(serde::de::Error::custom))
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
/// Content/attachment reference: points at a blob held by the sender that
|
||||
/// recipients fetch out-of-band via `GET {sender_onion}/blob/{cid}?cap=..&exp=..&peer=..`.
|
||||
/// Thumb bytes (≤60B) may be inlined for immediate display; full blob is lazy.
|
||||
@ -491,7 +514,7 @@ pub struct ContentRefPayload {
|
||||
pub mime: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub filename: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default, skip_serializing_if = "Option::is_none", with = "base64_opt_bytes")]
|
||||
pub thumb_bytes: Option<Vec<u8>>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub caption: Option<String>,
|
||||
|
||||
@ -14,6 +14,7 @@ pub mod message_types;
|
||||
pub mod outbox;
|
||||
pub mod protocol;
|
||||
pub mod ratchet;
|
||||
pub mod reticulum;
|
||||
pub mod scheduler;
|
||||
pub mod serial;
|
||||
pub mod session;
|
||||
@ -245,6 +246,9 @@ pub(crate) async fn upsert_federation_peer(
|
||||
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
|
||||
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
|
||||
reachable: true,
|
||||
// Off-radio E2E (federation) is handled by the archy-peer path; preserve
|
||||
// any radio PKI capability learned for a twinned contact.
|
||||
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
||||
@ -377,6 +381,15 @@ pub struct MeshConfig {
|
||||
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
|
||||
#[serde(default)]
|
||||
pub assistant_allowed_contacts: Vec<String>,
|
||||
/// Pin the expected firmware on `device_path`/auto-detected ports. A
|
||||
/// reflashable board (e.g. Heltec V3) can run Meshcore, Meshtastic, or
|
||||
/// RNode firmware, so probe order alone is best-effort — set this when an
|
||||
/// operator knows which one is plugged in. When `Some`, only that
|
||||
/// device's probe runs (no other firmware's init bytes are ever injected
|
||||
/// into the port); `None` keeps today's Meshcore→Meshtastic→Reticulum
|
||||
/// strict-probe auto-detect.
|
||||
#[serde(default)]
|
||||
pub device_kind: Option<types::DeviceType>,
|
||||
}
|
||||
|
||||
fn default_assistant_backend() -> String {
|
||||
@ -406,6 +419,7 @@ impl Default for MeshConfig {
|
||||
assistant_trusted_only: true,
|
||||
assistant_backend: default_assistant_backend(),
|
||||
assistant_allowed_contacts: Vec::new(),
|
||||
device_kind: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -678,6 +692,7 @@ impl MeshService {
|
||||
|
||||
let handle = listener::spawn_mesh_listener(
|
||||
Arc::clone(&self.state),
|
||||
self.data_dir.clone(),
|
||||
self.config.device_path.clone(),
|
||||
self.our_did.clone(),
|
||||
self.our_ed_pubkey_hex.clone(),
|
||||
@ -686,6 +701,7 @@ impl MeshService {
|
||||
self.server_name.clone(),
|
||||
self.config.lora_region.clone(),
|
||||
self.config.channel_name.clone(),
|
||||
self.config.device_kind,
|
||||
shutdown_rx,
|
||||
cmd_rx,
|
||||
);
|
||||
@ -1205,6 +1221,8 @@ impl MeshService {
|
||||
);
|
||||
}
|
||||
self.send_raw_payload(contact_id, wire).await?;
|
||||
let device_type = self.state.status.read().await.device_type;
|
||||
let radio_transport = radio_transport_label(device_type);
|
||||
Ok(self
|
||||
.record_sent_typed(
|
||||
contact_id,
|
||||
@ -1212,11 +1230,65 @@ impl MeshService {
|
||||
display_text,
|
||||
typed_payload,
|
||||
sender_seq,
|
||||
Some("lora".to_string()),
|
||||
Some(radio_transport.to_string()),
|
||||
// Archy↔archy typed envelopes over LoRa are identity-signed; the
|
||||
// radio E2E flag (meshtastic PKI / meshcore session) isn't
|
||||
// threaded to the send side yet, so don't over-claim E2E here.
|
||||
false,
|
||||
// threaded to the send side yet, so don't over-claim E2E here —
|
||||
// except Reticulum/LXMF, which is unconditionally E2E on every
|
||||
// send regardless of peer/session state (see send_message).
|
||||
device_type == DeviceType::Reticulum,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Send a typed envelope over a dedicated Reticulum RNS Resource transfer
|
||||
/// (`MeshCommand::SendResource`) instead of the small inline-chunk path
|
||||
/// `send_typed_wire`/`send_raw_payload` uses. Callers (the `mesh.send-content-inline`
|
||||
/// RPC handler) are responsible for only reaching this when the active
|
||||
/// device is actually Reticulum and the payload fits the
|
||||
/// `RETICULUM_RESOURCE_MAX` budget — see `mesh.transport-advice`'s
|
||||
/// `"resource-mesh"` tier, the single source of truth for that decision.
|
||||
/// Mirrors `send_typed_wire`'s signature/return shape so RPC call sites
|
||||
/// can switch between the two paths without restructuring.
|
||||
pub async fn send_content_resource(
|
||||
&self,
|
||||
contact_id: u32,
|
||||
wire: Vec<u8>,
|
||||
type_label: &str,
|
||||
display_text: &str,
|
||||
typed_payload: Option<serde_json::Value>,
|
||||
sender_seq: u64,
|
||||
) -> Result<MeshMessage> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||
self.state
|
||||
.send_cmd(listener::MeshCommand::SendResource {
|
||||
dest_pubkey_prefix: dest_prefix,
|
||||
payload: wire,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
|
||||
let device_type = self.state.status.read().await.device_type;
|
||||
let radio_transport = radio_transport_label(device_type);
|
||||
Ok(self
|
||||
.record_sent_typed(
|
||||
contact_id,
|
||||
type_label,
|
||||
display_text,
|
||||
typed_payload,
|
||||
sender_seq,
|
||||
Some(radio_transport.to_string()),
|
||||
// Reticulum/LXMF is unconditionally E2E on every send — same
|
||||
// reasoning as send_message's native-text path. This method
|
||||
// is Reticulum-only by construction (callers gate on
|
||||
// device_type before reaching it), so this is never wrong.
|
||||
true,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
@ -1512,6 +1584,7 @@ impl MeshService {
|
||||
let chan_contact_id = u32::MAX - (channel as u32);
|
||||
let chan_name = format!("Channel {}", channel);
|
||||
let msg_id = self.state.next_id().await;
|
||||
let radio_transport = radio_transport_label(self.state.status.read().await.device_type);
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Sent,
|
||||
@ -1523,7 +1596,7 @@ impl MeshService {
|
||||
// Channel broadcasts use the shared channel PSK, not per-identity
|
||||
// E2E — so not an E2E message, but it does travel over the radio.
|
||||
encrypted: false,
|
||||
transport: Some("lora".to_string()),
|
||||
transport: Some(radio_transport.to_string()),
|
||||
message_type: type_label.to_string(),
|
||||
typed_payload,
|
||||
sender_pubkey: Some(self.our_ed_pubkey_hex.clone()),
|
||||
@ -1557,14 +1630,16 @@ impl MeshService {
|
||||
// envelope as a message (PRIVATE_APP is opaque app-data; a base64
|
||||
// envelope overflows one LoRa frame and chunk-fails) — wrapping text
|
||||
// is exactly what silently broke archy↔archy Meshtastic LoRa.
|
||||
// • Meshcore archy peer → keep the rich signed typed envelope. Meshcore
|
||||
// frames are binary-safe (no UTF-8 mangling) and it carries its own
|
||||
// session E2E + our signature for `!ai` auth / seq reply addressing,
|
||||
// so the envelope works there and we must not drop it.
|
||||
// • Meshcore/Reticulum archy peer → keep the rich signed typed envelope.
|
||||
// Meshcore frames are binary-safe (no UTF-8 mangling) and Reticulum/LXMF
|
||||
// is binary-safe and high-capacity too; both carry their own transport
|
||||
// E2E plus our signature for `!ai` auth / seq reply addressing, so the
|
||||
// envelope works there and we must not drop it.
|
||||
// • Meshcore stock client → plain text (can't decode our envelope).
|
||||
// Rich typed messages (invoice/coordinate/reaction/…) always use the
|
||||
// typed-wire path via `send_typed_wire`; only plain Text is routed here.
|
||||
let use_typed_envelope = archy && device_type == DeviceType::Meshcore;
|
||||
let use_typed_envelope =
|
||||
archy && matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum);
|
||||
if use_typed_envelope {
|
||||
// Sign with our archipelago identity so the receiver can authenticate
|
||||
// us over LoRa (verifies against our bound `arch_pubkey_hex`). `with_seq`
|
||||
@ -1591,8 +1666,18 @@ impl MeshService {
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
// The firmware PKI-encrypts a directed DM to any peer whose key it knows;
|
||||
// archy peers always exchange keys, so mark those Sent rows E2E so the
|
||||
// pill shows immediately. (The receiver independently stamps E2E from the
|
||||
// radio's `pki_encrypted` flag, so an inbound row is accurate regardless.)
|
||||
// pill shows immediately. A non-archy stock peer (e.g. 3ccc) can also be
|
||||
// PKC-capable once we've learned its NodeInfo public key — OR that in too
|
||||
// so the pill isn't archy-only. (The receiver independently stamps E2E
|
||||
// from the radio's `pki_encrypted` flag, so an inbound row is accurate
|
||||
// regardless.)
|
||||
//
|
||||
// Reticulum/LXMF has no such conditional: every send is encrypted to the
|
||||
// destination's identity key by the LXMF router itself, archy peer or
|
||||
// not — so it's unconditionally E2E rather than gated on `archy`/`pkc_capable`
|
||||
// (which is a Meshtastic-only concept; Reticulum contacts never set it).
|
||||
let pkc_capable = self.peer_pkc_capable(contact_id).await;
|
||||
let encrypted = device_type == DeviceType::Reticulum || archy || pkc_capable;
|
||||
Ok(self
|
||||
.record_sent_typed(
|
||||
contact_id,
|
||||
@ -1600,8 +1685,8 @@ impl MeshService {
|
||||
text,
|
||||
None,
|
||||
seq,
|
||||
Some("lora".to_string()),
|
||||
archy,
|
||||
Some(radio_transport_label(device_type).to_string()),
|
||||
encrypted,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
@ -1622,6 +1707,21 @@ impl MeshService {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether `contact_id`'s real radio PKI (Curve25519) key is known, so the
|
||||
/// firmware delivers a directed DM to it end-to-end encrypted even though
|
||||
/// it's not an archipelago peer (e.g. stock Meshtastic peer 3ccc). Stamped
|
||||
/// onto `MeshPeer::pkc_capable` by `refresh_contacts` from the driver's
|
||||
/// `get_contacts()`.
|
||||
async fn peer_pkc_capable(&self, contact_id: u32) -> bool {
|
||||
self.state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&contact_id)
|
||||
.map(|p| p.pkc_capable)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Record a Sent MeshMessage for a typed envelope that has already been
|
||||
/// transmitted by the caller. Used by the RPC layer after sending
|
||||
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
|
||||
@ -1695,6 +1795,7 @@ impl MeshService {
|
||||
let chan_contact_id = u32::MAX - (channel as u32);
|
||||
let chan_name = format!("Channel {}", channel);
|
||||
let msg_id = self.state.next_id().await;
|
||||
let radio_transport = radio_transport_label(self.state.status.read().await.device_type);
|
||||
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
@ -1706,7 +1807,7 @@ impl MeshService {
|
||||
delivered: false,
|
||||
// Plain channel broadcast over the radio (shared PSK, not E2E).
|
||||
encrypted: false,
|
||||
transport: Some("lora".to_string()),
|
||||
transport: Some(radio_transport.to_string()),
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
sender_pubkey: None,
|
||||
@ -1991,6 +2092,7 @@ mod tests {
|
||||
hops: 0,
|
||||
last_advert: 0,
|
||||
reachable,
|
||||
pkc_capable: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
751
core/archipelago/src/mesh/reticulum.rs
Normal file
751
core/archipelago/src/mesh/reticulum.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
292
docs/RETICULUM-TRANSPORT-PROGRESS.md
Normal file
292
docs/RETICULUM-TRANSPORT-PROGRESS.md
Normal 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 0–3) |
|
||||
|
||||
## Checkpoint 2026-06-30 (late session — read this first if cut off)
|
||||
|
||||
This session picked up after Phase 2/3 were already green, and closed out everything that didn't
|
||||
need real RNode hardware:
|
||||
|
||||
1. **Corrected two stale tracker entries** (both were already done, just not reflected here):
|
||||
- The `_announce_app_data` "TODO" was actually already implemented:
|
||||
`reticulum_daemon.py`'s `_announce_app_data()` embeds `ARCHY:2:{ed}:{x25519}` when
|
||||
`--archy-ed-pubkey-hex`/`--archy-x25519-pubkey-hex` are passed, and `reticulum.rs`'s
|
||||
`daemon_command()`/`open()` already forward `our_ed_pubkey_hex`/`our_x25519_pubkey_hex` from
|
||||
`session.rs` (`run_mesh_session` → `auto_detect_and_open`/`open_preferred_path` →
|
||||
`ReticulumLink::open`). Confirmed end-to-end by reading the call chain, not just grepping.
|
||||
- Phase 3 frontend was already done (see prior entry below) — tracker table above said
|
||||
"not started", now corrected.
|
||||
2. **Added `MeshConfig.device_kind: Option<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.
|
||||
@ -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
|
||||
|
||||
115
neode-ui/src/utils/imageCompression.ts
Normal file
115
neode-ui/src/utils/imageCompression.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
10
reticulum-daemon/.gitignore
vendored
Normal 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/
|
||||
66
reticulum-daemon/README.md
Normal file
66
reticulum-daemon/README.md
Normal 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.
|
||||
80
reticulum-daemon/archy_rns_identity.py
Normal file
80
reticulum-daemon/archy_rns_identity.py
Normal 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
32
reticulum-daemon/build.sh
Executable 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))"
|
||||
2
reticulum-daemon/requirements-build.txt
Normal file
2
reticulum-daemon/requirements-build.txt
Normal file
@ -0,0 +1,2 @@
|
||||
# Build-only dependency, not shipped at runtime — see build.sh.
|
||||
pyinstaller==6.21.0
|
||||
4
reticulum-daemon/requirements.txt
Normal file
4
reticulum-daemon/requirements.txt
Normal 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
|
||||
507
reticulum-daemon/reticulum_daemon.py
Normal file
507
reticulum-daemon/reticulum_daemon.py
Normal 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())
|
||||
64
reticulum-daemon/spike_identity.py
Normal file
64
reticulum-daemon/spike_identity.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user