Aurora Reticulum TCP interop + mesh/RNode reliability fixes #70

Merged
ai merged 9 commits from worktree-reticulum-tcp-interop into main 2026-07-06 12:07:12 +00:00
13 changed files with 686 additions and 80 deletions

View File

@ -422,6 +422,7 @@ pub fn spawn_mesh_listener(
lora_region: Option<String>,
channel_name: Option<String>,
device_kind: Option<super::types::DeviceType>,
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
@ -456,6 +457,7 @@ pub fn spawn_mesh_listener(
lora_region.as_deref(),
channel_name.as_deref(),
device_kind,
reticulum_tcp.clone(),
&mut shutdown,
&mut cmd_rx,
)

View File

@ -261,7 +261,7 @@ impl MeshRadioDevice {
/// `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.
/// Reticulum→Meshcore→Meshtastic probe order.
async fn auto_detect_and_open(
data_dir: &Path,
our_ed_pubkey_hex: &str,
@ -274,6 +274,34 @@ async fn auto_detect_and_open(
}
for path in &paths {
debug!(path = %path, "Probing for mesh radio device");
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
// spawn behind a cheap (~1s worst case) RNode KISS-detect probe, so a
// failed match here costs about as much as the Meshcore/Meshtastic
// probes below. Trying it first matters in practice: on a real RNode
// board, Meshcore's and Meshtastic's handshake bytes (~5s timeout
// each, ~10.6s combined) sitting on the wire before Reticulum ever
// gets a turn was observed to leave the RNode firmware unresponsive
// by the time its turn came — confirmed on hardware that responds
// correctly to the same KISS probe on a truly fresh port.
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"),
}
}
if device_kind.is_none_or(|k| k == DeviceType::Meshcore) {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
@ -298,30 +326,6 @@ async fn auto_detect_and_open(
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!(
"No supported mesh radio found on {} candidate ports: {:?}",
@ -381,20 +385,10 @@ async fn open_preferred_path(
};
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
match MeshtasticDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshtastic(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshtastic"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshtastic"),
}
// Reticulum first — see the matching comment on auto_detect_and_open:
// its cheap probe_rnode gate fails in ~1s for non-RNode firmware, while
// trying Meshcore/Meshtastic first was observed leaving a real RNode
// board unresponsive by the time Reticulum's turn came.
match ReticulumLink::open(
path,
data_dir,
@ -404,11 +398,69 @@ async fn open_preferred_path(
.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"),
Ok(info) => return Ok((MeshRadioDevice::Reticulum(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not a working Reticulum RNode"),
},
Err(e) => Err(e).context("Could not open preferred path as Reticulum"),
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Reticulum"),
}
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => return Ok((MeshRadioDevice::Meshcore(dev), info)),
Err(e) => debug!(path = %path, error = %e, "Preferred path is not Meshcore"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open preferred path as Meshcore"),
}
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 a working Meshtastic device"),
},
Err(e) => Err(e).context("Could not open preferred path as Meshtastic"),
}
}
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
/// `probe_rnode` KISS-detect gate. This is the radio-less counterpart to
/// `auto_detect_and_open`/`open_preferred_path`'s serial paths; see
/// `types::ReticulumTcpConfig`'s doc comment for scope (dev/verification,
/// loopback-only server bind).
async fn open_reticulum_tcp(
cfg: &ReticulumTcpConfig,
data_dir: &Path,
our_ed_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
let mut dev = match cfg {
ReticulumTcpConfig::Server { bind } => {
ReticulumLink::open_tcp_server(
bind,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP server interface")?
}
ReticulumTcpConfig::Client { connect } => {
ReticulumLink::open_tcp_client(
connect,
data_dir,
Some(our_ed_pubkey_hex),
Some(our_x25519_pubkey_hex),
)
.await
.context("Could not open Reticulum TCP client interface")?
}
};
let info = dev
.initialize()
.await
.context("Reticulum TCP interface failed to initialize")?;
let label = match cfg {
ReticulumTcpConfig::Server { bind } => format!("tcp-server:{bind}"),
ReticulumTcpConfig::Client { connect } => format!("tcp-client:{}", connect.join(",")),
};
Ok((label, MeshRadioDevice::Reticulum(dev), info))
}
/// ASCII marker for the original DM-via-channel format:
@ -627,11 +679,19 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()),
// Preserve any archipelago identity bound by an earlier
// identity advert — NEVER overwrite it with the firmware
// contact key, or a signed `!ai` query from this peer would
// fail authentication after the next contact refresh.
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
// Reticulum carries the archipelago identity in-band with
// the contact snapshot itself (see `ParsedContact::
// arch_pubkey_hex`'s doc comment) — prefer it when this
// refresh's snapshot has one. Otherwise preserve whatever
// was bound by an earlier identity advert (Meshcore/
// Meshtastic's `bind_federation_twins` path): NEVER
// overwrite a known identity with the firmware contact
// key, or a signed `!ai` query from this peer would fail
// authentication after the next contact refresh.
arch_pubkey_hex: contact
.arch_pubkey_hex
.clone()
.or_else(|| existing.and_then(|p| p.arch_pubkey_hex.clone())),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
// Meshtastic-only today (see ParsedContact) — falls back to
// whatever was already known if this refresh's contact
@ -787,11 +847,17 @@ pub(super) async fn run_mesh_session(
lora_region: Option<&str>,
channel_name: Option<&str>,
device_kind: Option<DeviceType>,
reticulum_tcp: Option<ReticulumTcpConfig>,
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 {
// Detect device — TCP Reticulum config (radio-less) takes priority when
// set, otherwise try the preferred serial path, falling back to
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
// for existing serial/RNode deployments where `reticulum_tcp` is None.
let (device_path, mut device, device_info) = if let Some(tcp_cfg) = &reticulum_tcp {
open_reticulum_tcp(tcp_cfg, data_dir, our_ed_pubkey_hex, our_x25519_pubkey_hex).await?
} else if let Some(path) = preferred_path {
match open_preferred_path(
path,
data_dir,

View File

@ -180,6 +180,12 @@ impl MeshtasticDevice {
"Failed to open serial port {} (permission denied? device busy?)",
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
info!(path = %path, baud = BAUD_RATE, "Opened Meshtastic serial port");
Ok(Self {
@ -976,6 +982,7 @@ impl MeshtasticDevice {
snr,
lat,
lon,
arch_pubkey_hex: None,
},
);
}
@ -1048,6 +1055,7 @@ fn packet_to_inbound_frame(
snr: None,
lat: None,
lon: None,
arch_pubkey_hex: None,
});
if packet.rx_rssi.is_some() {
contact.rssi = packet.rx_rssi.map(|v| v as i16);

View File

@ -392,6 +392,13 @@ pub struct MeshConfig {
/// strict-probe auto-detect.
#[serde(default)]
pub device_kind: Option<types::DeviceType>,
/// Optional plain-TCP Reticulum interface — radio-less dev/verification
/// alternative to the serial-RNode path (see `types::ReticulumTcpConfig`
/// doc comment). Not exposed via `mesh.configure`/frontend; hand-edit
/// this file to use it. `None` (default) preserves today's
/// serial-only/auto-detect behavior unchanged.
#[serde(default)]
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
}
fn default_assistant_backend() -> String {
@ -422,6 +429,7 @@ impl Default for MeshConfig {
assistant_backend: default_assistant_backend(),
assistant_allowed_contacts: Vec::new(),
device_kind: None,
reticulum_tcp: None,
}
}
}
@ -704,6 +712,7 @@ impl MeshService {
self.config.lora_region.clone(),
self.config.channel_name.clone(),
self.config.device_kind,
self.config.reticulum_tcp.clone(),
shutdown_rx,
cmd_rx,
);
@ -2223,4 +2232,133 @@ mod tests {
assert!(loaded.enabled);
assert_eq!(loaded.device_path, Some("/dev/ttyUSB0".to_string()));
}
/// End-to-end: `MeshService::start()` spawns a real `reticulum-daemon` in
/// plain-TCP CLIENT mode (no serial RNode, no `probe_rnode`), dials a
/// second stand-alone daemon instance running in TCP SERVER mode (the
/// Aurora-side role — Aurora's `RnsTcpInterface` dials the same way), and
/// reaches `device_connected: true` via the exact `mesh.status`-backing
/// `MeshService::status()` call the RPC layer uses. This is the Rust-side
/// half of the archy<->Aurora TCP interop gate (see
/// docs/RETICULUM-TRANSPORT-PROGRESS.md); the LXMF wire-level proof
/// itself already passed via a scripted RNS/LXMF stand-in (Steps 1-2 of
/// that gate), so this test only needs to prove Rust's spawn/connect
/// path, not re-prove LXMF content delivery.
///
/// Requires `reticulum-daemon/.venv` (`python3 -m venv .venv &&
/// .venv/bin/pip install -r requirements.txt`) — skips (not fails) if
/// absent, since CI doesn't provision a Python/RNS/LXMF environment for
/// the rest of the mesh test suite either.
#[tokio::test]
#[ignore = "spawns real reticulum-daemon subprocesses over loopback TCP; run manually with `cargo test -p archipelago -- --ignored mesh_service_connects_over_reticulum_tcp`"]
async fn mesh_service_connects_over_reticulum_tcp_client() {
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("core/archipelago has two ancestors up to the repo root")
.to_path_buf();
let venv_py = repo_root.join("reticulum-daemon/.venv/bin/python");
let daemon_script = repo_root.join("reticulum-daemon/reticulum_daemon.py");
if !venv_py.exists() {
eprintln!(
"SKIP mesh_service_connects_over_reticulum_tcp_client: {} not found — \
run `python3 -m venv .venv && .venv/bin/pip install -r requirements.txt` \
in reticulum-daemon/",
venv_py.display()
);
return;
}
let root = tempfile::tempdir().unwrap();
// Stand-in "Aurora-side" server daemon: a second, independent
// reticulum-daemon instance in --tcp-listen mode. Any free loopback
// port works; this test doesn't need it fixed, so ask the OS for one.
let bind_addr = {
let sock = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = sock.local_addr().unwrap();
drop(sock); // release it so the daemon can bind — small TOCTOU window, acceptable for a manual/ignored test
addr
};
let server_dir = root.path().join("standin-server");
std::fs::create_dir_all(&server_dir).unwrap();
let server_seed = SigningKey::generate(&mut OsRng);
std::fs::write(server_dir.join("seed.bin"), server_seed.to_bytes()).unwrap();
let mut server = tokio::process::Command::new(&venv_py)
.arg(&daemon_script)
.arg("--identity-key")
.arg(server_dir.join("seed.bin"))
.arg("--socket")
.arg(server_dir.join("server.sock"))
.arg("--rns-config")
.arg(server_dir.join("rns-config"))
.arg("--tcp-listen")
.arg(bind_addr.to_string())
.arg("--display-name")
.arg("standin-server")
.kill_on_drop(true)
.spawn()
.expect("failed to spawn stand-in reticulum-daemon (server)");
// Give the server daemon time to bind before the client dials it.
tokio::time::sleep(Duration::from_secs(2)).await;
// Point ReticulumLink::daemon_command's dev-fallback at our venv —
// these env vars are read fresh on every spawn (mesh/reticulum.rs),
// so setting them here (test-process-global, but this test owns the
// only mesh session in this process) is sufficient.
std::env::set_var("ARCHY_RETICULUM_DAEMON_PY", &venv_py);
std::env::set_var("ARCHY_RETICULUM_DAEMON_SCRIPT", &daemon_script);
// Force the dev fallback even if a packaged binary happens to exist
// on this machine's PATH convention — this test wants exactly the
// freshly-built venv daemon under test.
std::env::set_var("ARCHY_RETICULUM_DAEMON_BIN", "/nonexistent-force-dev-fallback");
let data_dir = root.path().join("node");
std::fs::create_dir_all(data_dir.join("identity")).unwrap();
let node_signing_key = SigningKey::generate(&mut OsRng);
std::fs::write(
data_dir.join("identity").join("node_key"),
node_signing_key.to_bytes(),
)
.unwrap();
let ed_pubkey_hex = hex::encode(node_signing_key.verifying_key().to_bytes());
let did = crate::identity::did_key_from_pubkey_hex(&ed_pubkey_hex).unwrap();
let config = MeshConfig {
enabled: true,
reticulum_tcp: Some(types::ReticulumTcpConfig::Client {
connect: vec![bind_addr.to_string()],
}),
..Default::default()
};
save_config(&data_dir, &config).await.unwrap();
let mut service = MeshService::new(&data_dir, &node_signing_key, &did, &ed_pubkey_hex)
.await
.expect("MeshService::new failed");
service.start().expect("MeshService::start failed");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
let mut last_status = service.status().await;
while tokio::time::Instant::now() < deadline {
last_status = service.status().await;
if last_status.device_connected {
break;
}
tokio::time::sleep(Duration::from_millis(300)).await;
}
let _ = server.start_kill();
assert!(
last_status.device_connected,
"MeshService never reached device_connected: true over Reticulum TCP client \
mode within 20s (last status: {last_status:?})"
);
assert_eq!(last_status.device_type, DeviceType::Reticulum);
}
}

View File

@ -406,6 +406,15 @@ pub struct ParsedContact {
/// contact has shared one.
pub lat: Option<f64>,
pub lon: Option<f64>,
/// Archipelago ed25519 identity hex, when this transport carried it
/// in-band with the contact announce itself (Reticulum only today — the
/// RNS announce's app_data can embed an `ARCHY:n:` identity blob
/// alongside the destination hash in the same event, so there's no
/// ambiguity about which physical peer it belongs to). Meshcore/
/// Meshtastic identity adverts go out on a separate channel and are
/// correlated after the fact by `bind_federation_twins`'s advert_name
/// matching instead, so they always leave this `None`.
pub arch_pubkey_hex: Option<String>,
}
/// Parse RESP_CONTACT (0x03) response.
@ -457,6 +466,7 @@ pub fn parse_contact(data: &[u8]) -> Result<ParsedContact> {
snr: None,
lat: None,
lon: None,
arch_pubkey_hex: None,
})
}

View File

@ -47,7 +47,14 @@ 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);
// 800ms was too tight for real hardware: confirmed on a genuine Heltec V4
// RNode (firmware 1.86, verified via `rnodeconf --info`) that prints extra
// boot/status chatter on the same serial line before answering KISS
// commands — DETECT_RESP measured arriving ~1.05s after the probe write in
// that case. 2.5s leaves comfortable margin without meaningfully slowing
// down detection of non-RNode devices (Meshcore/Meshtastic already budget
// ~5s each).
const PROBE_READ_TIMEOUT: Duration = Duration::from_millis(2500);
/// Prefix marking an LXMF `content` string as base64 of a raw binary
/// typed-envelope payload rather than literal text. LXMF `content` travels
@ -64,6 +71,44 @@ const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:";
/// packaging); during development it can point at the venv's interpreter
/// invoking `reticulum_daemon.py` directly. Overridable for testing/packaging.
///
/// Which Reticulum interface the daemon should bring up. `Serial` is the
/// original (and only, until now) path — a physical RNode over a serial
/// port, gated behind `probe_rnode`'s KISS-detect handshake in `open()`.
/// `TcpServer`/`TcpClient` are the radio-less, dev/verification-only
/// addition (see `types::ReticulumTcpConfig`'s doc comment): they let the
/// daemon speak plain Reticulum TCP — the same transport Aurora's
/// `RnsTcpInterface`/`RnsTcpServerInterface` use by default — without any
/// physical hardware. `TcpServer` is hard-gated to loopback by
/// `open_tcp_server`; `TcpClient` (outbound dial) is unrestricted, the same
/// risk class as Aurora's own hub uplinks.
pub enum ReticulumInterface<'a> {
Serial(&'a str),
TcpServer(&'a str),
TcpClient(&'a [String]),
}
impl ReticulumInterface<'_> {
/// Human-readable label used for both the `device_path` status field and
/// (sanitized) the per-instance RPC socket filename. For `Serial` this is
/// exactly the old bare path — zero behavior change for the existing
/// hardware flow.
fn label(&self) -> String {
match self {
Self::Serial(path) => path.to_string(),
Self::TcpServer(bind) => format!("tcp-server:{bind}"),
Self::TcpClient(targets) => format!("tcp-client:{}", targets.join(",")),
}
}
}
/// Bind-scope guard for TCP server mode — see `ReticulumInterface` doc
/// comment. Mirrors `reticulum_daemon.py`'s `_require_loopback`; enforced
/// here too (defense in depth) since this is the gate an operator/caller
/// actually goes through in Rust.
fn is_loopback_host(host: &str) -> bool {
matches!(host, "127.0.0.1" | "::1" | "localhost")
}
/// `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
@ -72,7 +117,7 @@ const RETICULUM_BINARY_CONTENT_MARKER: &str = "\u{0}b64:";
/// satisfying cross-protocol DM convergence with zero new Rust dispatch code.
fn daemon_command(
socket_path: &Path,
serial_port: &str,
iface: &ReticulumInterface<'_>,
identity_key: &Path,
archy_ed_pubkey_hex: Option<&str>,
archy_x25519_pubkey_hex: Option<&str>,
@ -94,9 +139,20 @@ fn daemon_command(
cmd.arg("--identity-key")
.arg(identity_key)
.arg("--socket")
.arg(socket_path)
.arg("--serial-port")
.arg(serial_port);
.arg(socket_path);
match iface {
ReticulumInterface::Serial(path) => {
cmd.arg("--serial-port").arg(path);
}
ReticulumInterface::TcpServer(bind) => {
cmd.arg("--tcp-listen").arg(bind);
}
ReticulumInterface::TcpClient(targets) => {
for target in *targets {
cmd.arg("--tcp-connect").arg(target);
}
}
}
if let (Some(ed), Some(x)) = (archy_ed_pubkey_hex, archy_x25519_pubkey_hex) {
cmd.arg("--archy-ed-pubkey-hex")
.arg(ed)
@ -120,9 +176,12 @@ fn daemon_command(
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.
/// Archy ed25519 identity hex, once carried in this peer's own announce
/// app-data blob (`ARCHY:n:...`) — see `handle_event`'s "announce" arm.
/// Unlike Meshcore/Meshtastic, this arrives in-band with the same event
/// as the destination hash, so it can be bound directly onto this
/// RNS-hash-keyed peer with no name-matching ambiguity (contrast
/// `bind_federation_twins`, which those two transports rely on instead).
arch_pubkey_hex: Option<String>,
reachable: bool,
}
@ -189,11 +248,62 @@ impl ReticulumLink {
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
Self::spawn(
ReticulumInterface::Serial(path),
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
)
.await
}
/// Bring up a daemon in plain-TCP server mode — no physical RNode, no
/// `probe_rnode` gate. `bind` (`host:port`) must be loopback; see
/// `ReticulumInterface`/`is_loopback_host`.
pub async fn open_tcp_server(
bind: &str,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
anyhow::ensure!(
is_loopback_host(host),
"reticulum TCP server bind must be loopback-only (127.0.0.1/::1/localhost) — \
got {bind}; WAN/LAN exposure needs its own security review"
);
Self::spawn(
ReticulumInterface::TcpServer(bind),
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
)
.await
}
/// Bring up a daemon in plain-TCP client mode, dialing one or more
/// `host:port` targets — no physical RNode, no `probe_rnode` gate.
pub async fn open_tcp_client(
targets: &[String],
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
) -> Result<Self> {
anyhow::ensure!(
!targets.is_empty(),
"reticulum TCP client mode needs at least one target"
);
Self::spawn(
ReticulumInterface::TcpClient(targets),
data_dir,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
)
.await
}
async fn spawn(
path: &str,
iface: ReticulumInterface<'_>,
data_dir: &Path,
our_ed_pubkey_hex: Option<&str>,
our_x25519_pubkey_hex: Option<&str>,
@ -212,9 +322,10 @@ impl ReticulumLink {
let _ = tokio::fs::set_permissions(&runtime_dir, std::fs::Permissions::from_mode(0o700))
.await;
}
let label = iface.label();
let socket_path = runtime_dir.join(format!(
"{}.sock",
path.replace(['/', ' '], "_")
label.replace(['/', ' ', ':', ','], "_")
));
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
@ -229,7 +340,7 @@ impl ReticulumLink {
let mut cmd = daemon_command(
&socket_path,
path,
&iface,
&identity_key,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
@ -274,13 +385,13 @@ impl ReticulumLink {
.map(str::to_string);
info!(
path = %path,
iface = %label,
dest_hash = %dest_hash_hex,
"Reticulum daemon ready"
);
let mut link = Self {
device_path: path.to_string(),
device_path: label,
socket_path,
child,
writer: write_half,
@ -544,6 +655,7 @@ impl ReticulumLink {
snr: None,
lat: None,
lon: None,
arch_pubkey_hex: p.arch_pubkey_hex.clone(),
})
.collect())
}
@ -622,17 +734,21 @@ impl ReticulumLink {
.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
// daemon_command's doc comment), bind the ed25519 hex directly
// onto this RNS-hash-keyed peer below (unambiguous — it came
// in the same event as `hash`) 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
// -> handle_identity_received), so it also lands on that
// transport's federation-twin peer for UI/contact purposes.
// `group_peer_twins` can then collapse the two rows since both
// now carry the same `arch_pubkey_hex`, instead of relying on
// `bind_federation_twins`'s advert_name matching, which never
// matches here — see `display_name` below.
let parsed_identity = app_data_text
.as_deref()
.map(|t| protocol::parse_identity_broadcast(t).is_some())
.unwrap_or(false);
.and_then(protocol::parse_identity_broadcast);
let is_identity_blob = parsed_identity.is_some();
if is_identity_blob {
let text = app_data_text.clone().unwrap();
let mut data = Vec::with_capacity(7 + text.len());
@ -645,6 +761,7 @@ impl ReticulumLink {
bytes_consumed: 0,
});
}
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
let display_name = app_data_text
.filter(|_| !is_identity_blob)
@ -654,11 +771,14 @@ impl ReticulumLink {
.and_modify(|p| {
p.display_name = display_name.clone();
p.reachable = true;
if arch_pubkey_hex.is_some() {
p.arch_pubkey_hex = arch_pubkey_hex.clone();
}
})
.or_insert(ReticulumPeer {
dest_hash: hash,
display_name,
arch_pubkey_hex: None,
arch_pubkey_hex,
reachable: true,
});
self.persist_peers();
@ -904,6 +1024,14 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> {
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))?;
// ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART
// bridge chip) treat a DTR/RTS transition on open as a reset signal, the
// same mechanism esptool uses to force bootloader entry. Deassert both
// and let the board settle before writing the probe, or the reboot eats
// the DETECT_RESP window below.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
let probe: [u8; 13] = [
KISS_FEND,
KISS_CMD_DETECT,

View File

@ -57,6 +57,12 @@ impl MeshcoreDevice {
"Failed to open serial port {} (permission denied? device busy?)",
path
))?;
// See probe_rnode() in reticulum.rs for why: ESP32-S3 native-USB
// boards reset on a DTR/RTS transition, so deassert both and settle
// before the handshake below.
let _ = port.set_dtr(false);
let _ = port.set_rts(false);
tokio::time::sleep(Duration::from_millis(300)).await;
info!(path = %path, baud = BAUD_RATE, "Opened serial port");

View File

@ -28,6 +28,23 @@ impl std::fmt::Display for DeviceType {
}
}
/// Optional plain-TCP Reticulum interface, radio-less alternative to the
/// serial-RNode path. Dev/verification surface for now (e.g. proving
/// interop with Aurora's `RnsTcpInterface`/`RnsTcpServerInterface`, which is
/// its default connectivity mode) — not exposed through `mesh.configure`/the
/// frontend. `Server` is hard-gated to loopback in both the daemon and Rust
/// (`reticulum::is_loopback_host`); WAN/LAN exposure is a separate, deliberate
/// future decision (archy is otherwise Tor-first for inter-node traffic).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum ReticulumTcpConfig {
/// Bind a `TCPServerInterface`. `bind` is `host:port`, host must be
/// loopback (127.0.0.1/::1/localhost).
Server { bind: String },
/// Dial one or more `TCPClientInterface` targets (`host:port`).
Client { connect: Vec<String> },
}
/// 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

View File

@ -25,6 +25,7 @@ owns. Stay out of `meshtastic.rs`/`protocol.rs` to avoid collisions.
| 2c | `MeshConfig.device_kind` reflashable-board pin | ✅ **DONE** this session (was the one open Phase-2 item) |
| 3 | Frontend (~8 label/CSS spots) | ✅ DONE (scoped down — see note below) |
| 4 | Multi-device (run all 3 radios at once) + per-network channels | ⏳ not started (follow-on, after 03) |
| 5 | Aurora interop — optional plain-TCP Reticulum interface (radio-less) | ✅ **DONE + verified 2026-07-03** — see checkpoint below. Real Aurora GUI test still open (manual follow-up). |
## Checkpoint 2026-06-30 (late session — read this first if cut off)
@ -290,3 +291,82 @@ Phase 0 gates #1#3 are now **all passed**. What's left:
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.
## Checkpoint 2026-07-03 — Phase 5: Aurora interop via plain-TCP Reticulum (radio-less)
**Why:** `~/aurora` (a separate Flutter off-grid messenger) already runs real RNS + LXMF
(`LxmfRouter`, comment "interop with Sideband/NomadNet/MeshChat" in `rns_service.dart`), and its
**default** connectivity mode is plain TCP (`RnsTcpInterface`/`RnsTcpServerInterface`), not radio —
it ships a static bootstrap list of public RNS hubs on port 4242. Archy's daemon could previously
only bring up a serial-RNode interface, so it was unreachable by Aurora (or any TCP-based RNS/LXMF
client) at all, and every interop proof was bottlenecked on scarce LoRa hardware. This phase adds
an **optional, additive, loopback-only plain-TCP interface**, proves interop with a scripted
RNS/LXMF stand-in (the same class of proof the Sideband gate already established), and leaves the
serial/RNode path completely unchanged.
**Done, all verified:**
1. `reticulum-daemon/reticulum_daemon.py``_write_rns_config()` gained a third branch
(`--tcp-listen HOST:PORT``TCPServerInterface`, `--tcp-connect HOST:PORT` repeatable →
`TCPClientInterface`), mutually exclusive with `--serial-port`. `--tcp-listen` is hard-gated to
loopback (`_require_loopback`) — archy is otherwise Tor-first for inter-node traffic, so a
WAN/LAN-exposed Reticulum port is a deliberate future decision, not something this phase does
silently. Verified: `--selftest` regression still passes; two daemon processes (server +
client, throwaway identities) reached `connected: true` on both sides via `mesh.status`-daemon
RPC, live `TCPServerInterface`/`TCPClientInterface` visible in `get_interface_stats()`.
2. **Bidirectional LXMF DM gate against a scripted Aurora stand-in** (Python RNS+LXMF client
dialing as a `TCPClientInterface` + running its own `LXMRouter` — a legitimate protocol-level
proxy for Aurora's Dart stack, same wire format): forward (stand-in → archy daemon) and reverse
(archy daemon → stand-in) both delivered with matching content and correct source/dest hashes,
confirmed via the daemon's own `recv`/`delivered` RPC events. Direct TCP analogue of the
already-passed Sideband gate (RF → TCP, Sideband → scripted stand-in).
3. **Rust wiring**, fully additive — the serial/RNode path is byte-for-byte unchanged:
- `mesh/reticulum.rs`: new `ReticulumInterface` enum (`Serial`/`TcpServer`/`TcpClient`) threads
through `daemon_command()`/`spawn()`; `open()` (serial) now just wraps
`ReticulumInterface::Serial` — same `probe_rnode` gate as before. New
`open_tcp_server()`/`open_tcp_client()` associated fns skip `probe_rnode` entirely (the
"spawn without a physical RNode" path); `open_tcp_server` hard-enforces
`is_loopback_host()` (mirrors the Python-side guard).
- `mesh/types.rs`: new `ReticulumTcpConfig` enum (`Server { bind }` / `Client { connect }`).
- `mesh/mod.rs`: `MeshConfig.reticulum_tcp: Option<ReticulumTcpConfig>` (`#[serde(default)]`,
`None` by default — no migration, zero behavior change when unset); threaded into
`start()``spawn_mesh_listener`.
- `listener/mod.rs` / `listener/session.rs`: `reticulum_tcp` param threaded through
`spawn_mesh_listener`/`run_mesh_session`; new leading branch — if set, a new
`open_reticulum_tcp()` helper dispatches to `open_tcp_server`/`open_tcp_client`; otherwise
falls through to the **untouched** existing `preferred_path`/`auto_detect_and_open` logic.
- Deliberately **not** wired into `mesh.configure`/the frontend — dev/verification-only surface
for now (hand-edit `mesh-config.json`), consistent with how narrowly scoped this phase is.
- `cargo check -p archipelago` + `cargo test -p archipelago` (mesh module): **108 passed, 0
failed, 1 ignored** (the pre-existing hardware-gated `probe_rnode_detects_real_hardware`) —
zero regression to the serial/RNode path, provable without any hardware.
4. **End-to-end Rust integration test** (`mesh::tests::mesh_service_connects_over_reticulum_tcp_client`,
`#[ignore]`d — spawns real subprocesses, skipped in the default `cargo test` run the same way
the rest of the mesh suite skips hardware-gated tests): a real `MeshService::start()` spawns the
daemon in TCP **client** mode (no serial probe at all), dials a second stand-alone daemon
instance in TCP **server** mode (the Aurora-side role), and reaches `device_connected: true` /
`device_type: Reticulum` via the exact `MeshService::status()` call the `mesh.status` RPC uses.
Passed in ~2.6s. Run manually: `cargo test -p archipelago -- --ignored
mesh_service_connects_over_reticulum_tcp` (needs `reticulum-daemon/.venv`, see below).
**Environment note:** this session's Rust toolchain drift — system `rustc` (apt, 1.85.0) is too
old for code already on `main` (`u32::is_multiple_of` in `health_monitor.rs`, stabilized upstream
after 1.85); a pre-installed rustup toolchain at
`~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu` (1.96.0) builds clean. Not something this
phase's changes caused — pre-existing, just newly hit. Put that toolchain's `bin/` first on `PATH`
if `cargo check`/`test` reports `E0658 unsigned_is_multiple_of`.
**Explicitly NOT done (out of scope for this phase, see plan non-goals):**
- Real Aurora Flutter GUI verification — this dev sandbox has no `flutter`, no `$DISPLAY`, and no
`reticulum-dart` sibling checked out (Aurora's actual RNS implementation lives in that separate
repo; Aurora's CI clones it fresh at build time). The scripted-stand-in gate above is the
protocol-level substitute. **Manual follow-up**: point a real Aurora build's TCP hub list (or an
ad hoc connect) at an archy node's `--tcp-listen` address and confirm an LXMF DM in the actual
app UI.
- Any non-loopback (LAN/WAN) TCP bind — hard-gated off on purpose; a real "Aurora hub" deployment
needs its own security review given archy's Tor-first posture for inter-node traffic.
- LXMF propagation-node / always-on-hub role for archy (bridging Aurora's offline BLE peers) —
bigger architectural + storage commitment.
- Identity unification between archy's and Aurora's independent Nostr/secp256k1 keys — both
already have separate Nostr identities with no derivation link; out of scope here.
- `mesh.configure` RPC / frontend exposure of `reticulum_tcp` — stays hand-edit-only until/unless
it becomes user-facing.

View File

@ -1,6 +1,7 @@
#!/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.
# Build the PyInstaller single-binaries for the OTA (plan Phase 1 packaging).
# Outputs: dist/archy-reticulum-daemon, dist/archy-rnodeconf — drop both next
# to /usr/local/bin/archipelago.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
@ -9,7 +10,7 @@ if [ ! -d .venv ]; then
fi
.venv/bin/pip install -q -r requirements.txt -r requirements-build.txt
rm -rf build dist archy-reticulum-daemon.spec
rm -rf build dist archy-reticulum-daemon.spec archy-rnodeconf.spec
# --collect-submodules: RNS/LXMF load most of their own internals dynamically
# (interface drivers, transport backends), which PyInstaller's static import
@ -30,3 +31,30 @@ rm -rf build dist archy-reticulum-daemon.spec
reticulum_daemon.py
echo "Built dist/archy-reticulum-daemon ($(du -h dist/archy-reticulum-daemon | cut -f1))"
# archy-rnodeconf: RNS's own official RNode config/diagnostic tool
# (RNS.Utilities.rnodeconf — reads/sets frequency, bandwidth, spreading
# factor, coding rate, TX power on an attached RNode; also verifies firmware
# signatures and can flash/bootstrap a board). Shipped alongside the daemon
# so every node can inspect and reconfigure its own radio without needing a
# full RNS/Python dev environment set up by hand — see the checkpoint in
# docs/RETICULUM-TRANSPORT-PROGRESS.md for the incident (two nodes silently
# running at different spreading factors, invisible without this tool) that
# motivated shipping it as a first-class OS tool rather than an ad hoc script.
RNODECONF_SRC="$(find .venv/lib -maxdepth 5 -path '*/site-packages/RNS/Utilities/rnodeconf.py' -print -quit)"
if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
# --runtime-hook: rnodeconf's own graceful_exit() calls the bare
# exit()/quit() builtins, which only exist in interactive Python (site.py
# injects them) — a frozen app hits NameError right as it tries to quit
# cleanly, after all the real work already succeeded. See
# pyi_rthook_exit_builtins.py.
.venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \
--collect-submodules RNS \
--collect-data RNS \
--runtime-hook pyi_rthook_exit_builtins.py \
-d noarchive \
"$RNODECONF_SRC"
echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))"
else
echo "WARNING: rnodeconf.py not found at $RNODECONF_SRC (RNS version mismatch?) — skipping archy-rnodeconf build" >&2
fi

View File

@ -0,0 +1,18 @@
# PyInstaller runtime hook — see build.sh.
#
# `exit()`/`quit()` aren't part of the language; they're `site.Quitter`
# instances the interactive interpreter injects into builtins at startup
# (site.py). A frozen PyInstaller app never runs that interactive-mode
# init, so any bundled script that calls bare `exit()` (RNS's own
# rnodeconf.py does, in its graceful_exit() cleanup path) hits
# `NameError: name 'exit' is not defined` right as it tries to quit
# cleanly — the real work above it already completed, but the process
# still exits 1, which is a foot-gun for anything scripting off the exit
# code. Pre-define both as sys.exit so that path is a no-op crash-wise.
import builtins
import sys
if not hasattr(builtins, "exit"):
builtins.exit = sys.exit
if not hasattr(builtins, "quit"):
builtins.quit = sys.exit

View File

@ -59,9 +59,33 @@ from archy_rns_identity import lxmf_destination_hash, load_identity
# ─────────────────────────── 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."""
def _require_loopback(host: str) -> None:
"""Bind-scope guard: TCP server mode is dev/verification only for now. WAN/LAN
exposure is a separate future decision needing its own security review archy
is Tor-first for inter-node traffic (see README/CLAUDE.md), and a plain-TCP
Reticulum listener bound beyond loopback would bypass that entirely. Defense in
depth: the Rust side enforces the same rule (reticulum.rs::is_loopback_host),
but this daemon can also be invoked directly, so it re-checks here too."""
if host not in ("127.0.0.1", "::1", "localhost"):
raise SystemExit(
f"--tcp-listen host must be loopback-only (got {host!r}); "
"WAN/LAN bind is out of scope for this daemon build"
)
def _write_rns_config(
configdir: Path,
*,
serial_port: str | None,
lora: dict,
no_radio: bool,
tcp_listen: str | None = None,
tcp_connect: list[str] | None = None,
) -> None:
"""Materialise an RNS config file. RNode interface for real radios; plain-TCP
server/client interface(s) for radio-less dev/verification (e.g. Aurora
interop testing); a loopback-only disabled config for --selftest so the stack
comes up with zero interfaces at all."""
configdir.mkdir(parents=True, exist_ok=True)
cfg = configdir / "config"
if no_radio:
@ -70,7 +94,7 @@ def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, n
" type = AutoInterface\n"
" enabled = no\n"
)
else:
elif serial_port:
interfaces = (
" [[RNode LoRa]]\n"
" type = RNodeInterface\n"
@ -82,6 +106,33 @@ def _write_rns_config(configdir: Path, *, serial_port: str | None, lora: dict, n
f" spreadingfactor = {lora['spreadingfactor']}\n"
f" codingrate = {lora['codingrate']}\n"
)
elif tcp_listen or tcp_connect:
parts = []
if tcp_listen:
host, _, port = tcp_listen.rpartition(":")
_require_loopback(host)
parts.append(
" [[Reticulum TCP Server]]\n"
" type = TCPServerInterface\n"
" enabled = yes\n"
f" listen_ip = {host}\n"
f" listen_port = {port}\n"
)
for i, target in enumerate(tcp_connect or []):
host, _, port = target.rpartition(":")
parts.append(
f" [[Reticulum TCP Client {i}]]\n"
" type = TCPClientInterface\n"
" enabled = yes\n"
f" target_host = {host}\n"
f" target_port = {port}\n"
)
interfaces = "\n".join(parts)
else:
raise SystemExit(
"no interface configured: need --serial-port, --tcp-listen/--tcp-connect, "
"or --no-radio"
)
cfg.write_text(
"[reticulum]\n"
" enable_transport = no\n"
@ -137,6 +188,8 @@ class ReticulumDaemon:
"codingrate": self.args.codingrate,
},
no_radio=self.args.no_radio,
tcp_listen=self.args.tcp_listen,
tcp_connect=self.args.tcp_connect,
)
self.reticulum = RNS.Reticulum(configdir=str(configdir))
self.identity = load_identity(self.seed)
@ -483,6 +536,11 @@ def _parse_args(argv):
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("--tcp-listen", default=None, metavar="HOST:PORT",
help="Bind a plain-TCP Reticulum server interface (dev/verification "
"only; loopback-only, e.g. 127.0.0.1:4242).")
p.add_argument("--tcp-connect", action="append", default=None, metavar="HOST:PORT",
help="Dial a plain-TCP Reticulum client interface (repeatable).")
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 "
@ -530,6 +588,12 @@ def main(argv=None) -> int:
_install_parent_death_signal()
args = _parse_args(argv if argv is not None else sys.argv[1:])
if args.serial_port and (args.tcp_listen or args.tcp_connect):
raise SystemExit(
"--serial-port is mutually exclusive with --tcp-listen/--tcp-connect "
"(one daemon process = one interface)"
)
if args.check:
seed = ReticulumDaemon._read_seed(Path(args.identity_key))
print(lxmf_destination_hash(seed).hex())

View File

@ -257,6 +257,13 @@ ssh $SSH_OPTS "$TARGET_HOST" '
NEED_INSTALL=""
command -v rsync >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL rsync"
command -v python3 >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL python3"
# python3 -m venv exists but cannot bootstrap pip without the matching
# <major>.<minor>-venv package on Debian — needed for reticulum-daemon/
# build.sh (archy-reticulum-daemon / archy-rnodeconf packaging).
if command -v python3 >/dev/null 2>&1 && ! python3 -c "import ensurepip" >/dev/null 2>&1; then
PYVER=$(python3 -c "import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")")
NEED_INSTALL="$NEED_INSTALL python3.${PYVER#*.}-venv"
fi
if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then
echo " Node.js/npm not found — installing..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - 2>&1 | tail -3
@ -585,6 +592,20 @@ else
echo " ⚠️ Rust not installed on target, skipping backend build"
fi
# reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf) — skip with
# --frontend-only. Non-fatal on failure: these are supplementary mesh/radio
# tools, not required for the rest of the deploy to succeed, and build.sh
# handles its own venv/pip setup so a first run here is slower than later ones.
if [ "$FRONTEND_ONLY" = true ]; then
echo " Skipping reticulum-daemon tools build (--frontend-only)"
else
progress "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)"
section_start
ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/reticulum-daemon && ./build.sh 2>&1" | sed 's/^/ /' \
|| echo " ⚠️ reticulum-daemon tools build failed — continuing without updating them"
section_end
fi
if [ "$LIVE" = true ]; then
# Create rollback backup before deploying
@ -608,6 +629,26 @@ if [ "$LIVE" = true ]; then
ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/"
fi
# Deploy reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf) —
# skip with --frontend-only. Non-fatal: archipelago falls back to its dev
# venv path (ARCHY_RETICULUM_DAEMON_PY/_SCRIPT) if the packaged binary
# isn't present, so a missing/failed build here degrades rather than
# breaks mesh. archipelago is already stopped from the backend-binary
# step above, so this is a clean window to swap both binaries.
if [ "$FRONTEND_ONLY" = true ]; then
echo " Skipping reticulum-daemon tools deploy (--frontend-only)"
else
progress "Deploying reticulum-daemon tools"
for tool in archy-reticulum-daemon archy-rnodeconf; do
if ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/reticulum-daemon/dist/$tool ]" 2>/dev/null; then
ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/reticulum-daemon/dist/$tool /usr/local/bin/ && sudo chmod +x /usr/local/bin/$tool"
echo " $tool deployed"
else
echo " ⚠️ $tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
fi
done
fi
# Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build)
progress "Deploying frontend"
ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +"