feat(mesh): Reticulum LoRa hardware gates pass + RNS Resource transfer + image/voice attachments
Phase 0 gates #2/#3 (two-node LXMF-over-LoRa, external Sideband interop) passed on real hardware (.116's flashed Heltec V3 RNode <-> a phone-flashed RNode running Sideband) — RNS announce, encrypted DM round-trip, and contact binding all verified live. Fixed two bugs found in the process: the Reticulum send path wasn't stamping outbound messages as E2E despite LXMF being unconditionally encrypted, and the per-message transport pill collapsed Meshcore/Meshtastic into one generic "lora" color instead of distinguishing the three radio transports. Built on top of that link: a Columba-style image/file send experience — compression-quality presets with a real transfer-time estimate (mesh.transport-advice, now device-throughput-aware), receive-side thumbnail previews + auto-render for already-local attachments, and async voice messages, all reusing the existing ContentRef/ContentInline attachment pipeline. The headline addition is genuine RNS Resource transfer support (daemon-side RNS.Link + RNS.Resource, Rust-side send_resource/resource_recv plumbing, a new "resource-mesh" transport-advice tier) so compressed photos up to 2MB now actually transfer over LoRa for Reticulum peers instead of always falling back to Tor past the small inline-chunk cap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
12e7990b10
commit
f54c853128
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user