Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1f4bf0e3 | ||
|
|
79c3cc5947 | ||
|
|
c4f24f3efa | ||
|
|
0acfba40db | ||
|
|
3f76b4960a | ||
|
|
a8c4694c36 | ||
|
|
537c52d11c | ||
|
|
2971623145 | ||
|
|
dcb0618012 | ||
|
|
8403f2233e | ||
|
|
078f3b3619 | ||
|
|
4222a8507c | ||
|
|
5799c37111 | ||
|
|
b14af20d1a | ||
|
|
7547d03166 | ||
|
|
d6019e47a5 | ||
|
|
0ca8f25b1b | ||
|
|
76d14c3bf9 | ||
|
|
a1cb83dfb2 | ||
|
|
ff532465cf | ||
|
|
39e88529b3 | ||
|
|
992bf636e0 | ||
|
|
beff5dd577 | ||
|
|
7d31ca5d65 |
Generated
+50
@@ -84,6 +84,15 @@ version = "1.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
@@ -161,6 +170,7 @@ dependencies = [
|
||||
"uuid",
|
||||
"zbase32",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1175,6 +1185,17 @@ version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@@ -6895,8 +6916,37 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
@@ -108,6 +108,10 @@ bytes = "1"
|
||||
# Mesh networking (Meshcore serial protocol over USB LoRa radios)
|
||||
serial2-tokio = "0.1"
|
||||
|
||||
# LoRa radio firmware flashing: Meshtastic ships per-board images inside a
|
||||
# per-platform release zip (see mesh/flash.rs).
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
# Double Ratchet key derivation (Phase 3: encrypted mesh messaging)
|
||||
hkdf = "0.12.4"
|
||||
|
||||
|
||||
@@ -391,7 +391,12 @@ impl RpcHandler {
|
||||
// Mesh networking (Meshcore LoRa)
|
||||
"mesh.status" => self.handle_mesh_status().await,
|
||||
"mesh.probe-device" => self.handle_mesh_probe_device(params).await,
|
||||
"mesh.flash-list-firmware" => self.handle_mesh_flash_list_firmware(params).await,
|
||||
"mesh.flash-device" => self.handle_mesh_flash_device(params).await,
|
||||
"mesh.flash-status" => self.handle_mesh_flash_status().await,
|
||||
"mesh.flash-cancel" => self.handle_mesh_flash_cancel().await,
|
||||
"mesh.peers" => self.handle_mesh_peers().await,
|
||||
"mesh.refresh" => self.handle_mesh_refresh().await,
|
||||
"mesh.messages" => self.handle_mesh_messages(params).await,
|
||||
"mesh.debug-dump" => self.handle_mesh_debug_dump().await,
|
||||
"mesh.send" => self.handle_mesh_send(params).await,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use crate::mesh::flash::{self, FlashBoard, FlashJobStatus};
|
||||
use crate::mesh::types::DeviceType;
|
||||
use anyhow::Result;
|
||||
|
||||
fn parse_family(s: &str) -> Result<DeviceType> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"meshcore" => Ok(DeviceType::Meshcore),
|
||||
"meshtastic" => Ok(DeviceType::Meshtastic),
|
||||
"reticulum" | "rnode" => Ok(DeviceType::Reticulum),
|
||||
other => anyhow::bail!("Unknown firmware family: {other} (expected meshcore|meshtastic|reticulum)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_board(s: &str) -> Result<FlashBoard> {
|
||||
match s.trim().to_lowercase().as_str() {
|
||||
"heltec-v3" | "heltec_v3" | "heltecv3" => Ok(FlashBoard::HeltecV3),
|
||||
"heltec-v4" | "heltec_v4" | "heltecv4" => Ok(FlashBoard::HeltecV4),
|
||||
other => anyhow::bail!("Unknown board: {other} (expected heltec-v3|heltec-v4)"),
|
||||
}
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
/// mesh.flash-list-firmware — resolve the available firmware version(s)
|
||||
/// for a given family. v1 only ever surfaces "latest".
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_list_firmware(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let family = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("family"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||
let family = parse_family(family)?;
|
||||
let versions = flash::list_firmware(family).await?;
|
||||
Ok(serde_json::json!({ "versions": versions }))
|
||||
}
|
||||
|
||||
/// mesh.flash-device — erase and reflash a detected LoRa radio with the
|
||||
/// latest firmware for the given family, defaulting to a full chip
|
||||
/// erase before write. `board` is optional: if the port's USB vid:pid
|
||||
/// unambiguously resolves to a known board, that's used; otherwise the
|
||||
/// caller must supply it explicitly (see `flash::resolve_flash_board`'s
|
||||
/// doc comment on why we refuse to guess).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_device(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let path = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("path"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing path"))?
|
||||
.to_string();
|
||||
let family = params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("family"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing family"))?;
|
||||
let family = parse_family(family)?;
|
||||
|
||||
let detected = mesh::detect_devices().await;
|
||||
anyhow::ensure!(
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
|
||||
let board = match params
|
||||
.as_ref()
|
||||
.and_then(|p| p.get("board"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
Some(explicit) => parse_board(explicit)?,
|
||||
None => {
|
||||
let info = mesh::detect_devices_info()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|d| d.path == path);
|
||||
info.as_ref()
|
||||
.and_then(flash::resolve_flash_board)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not auto-detect the board on {path} — specify board explicitly"
|
||||
)
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
flash::start_flash_job(
|
||||
&self.flash_job,
|
||||
&self.mesh_service_arc(),
|
||||
self.config.data_dir.clone(),
|
||||
path,
|
||||
board,
|
||||
family,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(serde_json::json!({ "started": true }))
|
||||
}
|
||||
|
||||
/// mesh.flash-status — poll the current (or most recent) flash job.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_status(&self) -> Result<serde_json::Value> {
|
||||
let job = self.flash_job.read().await;
|
||||
match job.as_ref() {
|
||||
Some(j) => {
|
||||
let status: FlashJobStatus = j.snapshot().await;
|
||||
let mut value = serde_json::to_value(&status)?;
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
obj.insert("active".into(), (!status.done).into());
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
None => Ok(serde_json::json!({ "active": false })),
|
||||
}
|
||||
}
|
||||
|
||||
/// mesh.flash-cancel — best-effort; only honored before erase/write has
|
||||
/// started (see `FlashJob::cancel`'s doc comment).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_flash_cancel(&self) -> Result<serde_json::Value> {
|
||||
let job = self.flash_job.read().await;
|
||||
match job.as_ref() {
|
||||
Some(j) => {
|
||||
j.cancel().await?;
|
||||
Ok(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
None => anyhow::bail!("No flash job in progress"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::super::RpcHandler;
|
||||
use crate::mesh;
|
||||
use anyhow::Result;
|
||||
use std::sync::Arc;
|
||||
use tracing::info;
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -131,7 +132,14 @@ impl RpcHandler {
|
||||
config.broadcast_identity = broadcast;
|
||||
}
|
||||
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
|
||||
config.advert_name = Some(name.to_string());
|
||||
// Empty clears the custom mesh name (falls back to the server
|
||||
// name) — without this, a name could be set but never unset.
|
||||
let trimmed = name.trim();
|
||||
config.advert_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(announce) = params
|
||||
.get("announce_block_headers")
|
||||
@@ -202,10 +210,24 @@ impl RpcHandler {
|
||||
|
||||
mesh::save_config(&self.config.data_dir, &config).await?;
|
||||
|
||||
// If we have a running service, update its config
|
||||
let mut service = self.mesh_service.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
svc.configure(config.clone()).await?;
|
||||
// Apply to the running service in the background: configure() may
|
||||
// stop+start the listener (config changes now restart the session so
|
||||
// they actually take effect), and that can take seconds when the old
|
||||
// session is mid-probe. Holding the service write-lock for that long
|
||||
// inside this handler stalled every concurrent mesh.status/mesh.peers
|
||||
// poll behind it — the UI froze and nginx surfaced 502s. The config is
|
||||
// already persisted above; the UI observes progress via mesh.status.
|
||||
{
|
||||
let service_arc = Arc::clone(&self.mesh_service);
|
||||
let config_for_apply = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut service = service_arc.write().await;
|
||||
if let Some(svc) = service.as_mut() {
|
||||
if let Err(e) = svc.configure(config_for_apply).await {
|
||||
tracing::error!("Applying mesh config to running service failed: {e:#}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
info!("Mesh config updated");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
mod assistant;
|
||||
mod bitcoin_ops;
|
||||
mod flash;
|
||||
mod messaging;
|
||||
mod safety;
|
||||
mod status;
|
||||
|
||||
@@ -101,15 +101,61 @@ impl RpcHandler {
|
||||
detected.iter().any(|d| d == &path),
|
||||
"{path} is not a detected mesh-radio candidate port"
|
||||
);
|
||||
let service = self.mesh_service.read().await;
|
||||
let probe = match service.as_ref() {
|
||||
Some(svc) => svc.probe_device(&path).await?,
|
||||
// No mesh service yet (radio never enabled) — probe directly.
|
||||
None => mesh::listener::probe_device(&path).await?,
|
||||
};
|
||||
// Refuse to probe while a firmware flash is in flight. Confirmed
|
||||
// live 2026-07-23: esptool ("multiple access on port?") and
|
||||
// rnodeconf (OSError Errno 71 Protocol error on an RTS ioctl) both
|
||||
// failed with symptoms consistent with a second process holding the
|
||||
// same serial fd — the flash subprocess runs for minutes outside
|
||||
// our own async runtime, so nothing previously stopped a concurrent
|
||||
// `mesh.probe-device` call (e.g. the hot-swap modal's own re-probe)
|
||||
// from opening the identical port at the same time and corrupting
|
||||
// both operations' handshakes.
|
||||
if let Some(job) = self.flash_job.read().await.as_ref() {
|
||||
anyhow::ensure!(
|
||||
job.snapshot().await.done,
|
||||
"A firmware flash is in progress — refusing to probe the serial port until it finishes"
|
||||
);
|
||||
}
|
||||
// Only hold the mesh_service lock long enough for the quick
|
||||
// active-path guard check — NEVER across the actual probe, which
|
||||
// can take 15-60s across its internal collision retries. Confirmed
|
||||
// live 2026-07-23: holding this read lock for the full probe starved
|
||||
// a concurrent firmware-flash job's MeshService::stop() (which needs
|
||||
// the write lock) well past its own bounded timeout, surfacing as
|
||||
// "Mesh listener did not release the serial port" even though
|
||||
// stop() itself was fast.
|
||||
{
|
||||
let service = self.mesh_service.read().await;
|
||||
if let Some(svc) = service.as_ref() {
|
||||
svc.ensure_probe_allowed(&path).await?;
|
||||
}
|
||||
}
|
||||
let probe = mesh::listener::probe_device(&path).await?;
|
||||
Ok(serde_json::to_value(probe)?)
|
||||
}
|
||||
|
||||
/// mesh.refresh — Actively refresh discovery state: re-query the radio's
|
||||
/// contact table and re-announce ourselves so quiet-but-alive neighbours
|
||||
/// answer. This is what the UI's Refresh button calls — before it existed
|
||||
/// the button only re-read server caches and never touched the radio.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_refresh(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let Some(svc) = service.as_ref() else {
|
||||
return Ok(serde_json::json!({ "refreshed": false, "device_connected": false }));
|
||||
};
|
||||
let status = svc.status().await;
|
||||
if status.device_connected {
|
||||
let state = svc.shared_state();
|
||||
let _ = state
|
||||
.send_cmd(crate::mesh::listener::MeshCommand::RefreshContacts)
|
||||
.await;
|
||||
}
|
||||
Ok(serde_json::json!({
|
||||
"refreshed": status.device_connected,
|
||||
"device_connected": status.device_connected,
|
||||
}))
|
||||
}
|
||||
|
||||
/// mesh.peers — List discovered mesh peers.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
|
||||
@@ -89,6 +89,9 @@ pub struct RpcHandler {
|
||||
endpoint_rate_limiter: EndpointRateLimiter,
|
||||
response_cache: ResponseCache,
|
||||
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
|
||||
/// LoRa radio firmware-flash job state, sibling to `mesh_service` — one
|
||||
/// job at a time, since flashing needs exclusive access to the port.
|
||||
flash_job: crate::mesh::flash::FlashJobHandle,
|
||||
transport_router: Arc<tokio::sync::RwLock<Option<Arc<crate::transport::TransportRouter>>>>,
|
||||
/// Shared content-addressed blob store. Set by ApiHandler after construction
|
||||
/// so mesh.send-content / mesh.fetch-content RPCs can reach it without a
|
||||
@@ -160,6 +163,7 @@ impl RpcHandler {
|
||||
endpoint_rate_limiter,
|
||||
response_cache: ResponseCache::new(5),
|
||||
mesh_service: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
flash_job: crate::mesh::flash::new_job_handle(),
|
||||
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
|
||||
|
||||
@@ -61,6 +61,28 @@ impl RpcHandler {
|
||||
|
||||
info!("Server name updated to: {}", name);
|
||||
|
||||
// Propagate to the mesh: the listener advertises the server name (when
|
||||
// no explicit mesh advert_name overrides it), but it was only read at
|
||||
// process startup — a rename never reached the radio/RNS until the
|
||||
// next full restart. Push it into the service and bounce the listener
|
||||
// in the background (the restart re-probes the radio, which can take
|
||||
// seconds — don't block the rename response on it).
|
||||
{
|
||||
let mesh_arc = self.mesh_service_arc();
|
||||
let name_for_mesh = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut guard = mesh_arc.write().await;
|
||||
if let Some(svc) = guard.as_mut() {
|
||||
svc.set_server_name(Some(name_for_mesh));
|
||||
if svc.config().advert_name.is_none() {
|
||||
if let Err(e) = svc.restart_listener_if_running().await {
|
||||
warn!("Mesh listener restart after rename failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Push the new name to federation peers in background
|
||||
let data_dir = self.config.data_dir.clone();
|
||||
let state_manager = self.state_manager.clone();
|
||||
|
||||
@@ -0,0 +1,975 @@
|
||||
// WIP mesh/transport protocol — suppress dead code warnings
|
||||
#![allow(dead_code)]
|
||||
//! Firmware flashing for LoRa mesh radios — Heltec V3/V4 in v1, across all
|
||||
//! three firmware families the mesh module already knows how to detect (see
|
||||
//! `mesh::types::DeviceType`). Firmware is always fetched from upstream at
|
||||
//! flash time (never bundled/pinned in the repo), and every flash defaults
|
||||
//! to a full chip erase before write.
|
||||
//!
|
||||
//! MeshCore and Meshtastic are flashed the same way: download a released
|
||||
//! image, `esptool erase_flash`, then `esptool write_flash 0x0 <image>`.
|
||||
//! Reticulum/RNode is different: `archy-rnodeconf --autoinstall` owns the
|
||||
//! whole fetch+erase+flash+EEPROM-bootstrap sequence itself (confirmed live
|
||||
//! via `archy-rnodeconf --help` — there is no raw esptool path exposed for
|
||||
//! this family, so we deliberately don't resolve a firmware URL ourselves
|
||||
//! for Reticulum; rnodeconf already knows how).
|
||||
|
||||
use super::serial::DetectedDeviceInfo;
|
||||
use super::types::DeviceType;
|
||||
use super::MeshService;
|
||||
use anyhow::{Context, Result};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Boards supported for v1. Both are ESP32-S3 (a single `--chip esp32s3`
|
||||
/// esptool target covers both), but ship different USB identities and
|
||||
/// different per-board firmware assets upstream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashBoard {
|
||||
HeltecV3,
|
||||
HeltecV4,
|
||||
}
|
||||
|
||||
impl FlashBoard {
|
||||
/// Meshtastic's board id (matches the release manifest's `board` field
|
||||
/// and its per-board asset naming, e.g. `firmware-heltec-v3-<ver>.factory.bin`).
|
||||
fn meshtastic_id(self) -> &'static str {
|
||||
match self {
|
||||
Self::HeltecV3 => "heltec-v3",
|
||||
Self::HeltecV4 => "heltec-v4",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a detected USB vid:pid to a known flashable board, using the same
|
||||
/// table as `image-recipe/configs/99-mesh-radio.rules`. CP2102 (10c4:ea60)
|
||||
/// is confirmed there as Heltec V3's USB-UART bridge chip, and is safe to
|
||||
/// auto-match since that vid:pid is bridge-chip-specific.
|
||||
///
|
||||
/// Heltec V4 is NOT auto-matchable and deliberately has no entry here: it
|
||||
/// was confirmed live (real hardware, 2026-07-23) to use the ESP32-S3's
|
||||
/// built-in native-USB JTAG/serial peripheral, reporting vid:pid 303a:1001
|
||||
/// with product string "USB JTAG/serial debug unit" — that descriptor is
|
||||
/// baked into the chip's ROM and is IDENTICAL across every ESP32-S3 board
|
||||
/// with native USB enabled, not just Heltec V4. Adding `303a:1001 =>
|
||||
/// HeltecV4` here would silently misidentify any other native-USB ESP32-S3
|
||||
/// board (a T3-S3, a bare devkit, etc.) as a V4 and risk writing the wrong
|
||||
/// board's image. Callers (the RPC layer / frontend) must let the user pick
|
||||
/// the board manually whenever this returns `None`.
|
||||
pub fn resolve_flash_board(info: &DetectedDeviceInfo) -> Option<FlashBoard> {
|
||||
match (info.vid.as_deref(), info.pid.as_deref()) {
|
||||
(Some("10c4"), Some("ea60")) => Some(FlashBoard::HeltecV3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FlashStage {
|
||||
Downloading,
|
||||
Erasing,
|
||||
Writing,
|
||||
Autoinstalling,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FlashJobStatus {
|
||||
pub board: FlashBoard,
|
||||
pub family: DeviceType,
|
||||
pub path: String,
|
||||
pub stage: FlashStage,
|
||||
pub percent: Option<u8>,
|
||||
pub log_tail: Vec<String>,
|
||||
pub done: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
const LOG_TAIL_MAX: usize = 200;
|
||||
|
||||
/// How long to wait after a successful flash before resuming the mesh
|
||||
/// listener, so the board finishes its own post-flash boot/reset before we
|
||||
/// start opening the port (which itself toggles DTR/RTS) again.
|
||||
const POST_FLASH_SETTLE_DELAY: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Absolute ceiling on a whole flash job (download + erase + write, or
|
||||
/// autoinstall), regardless of what it's doing internally. Last-resort
|
||||
/// safety net so a hang anywhere can't wedge the single-flash-job guard
|
||||
/// forever — generous enough to never trigger on a legitimately slow
|
||||
/// multi-hundred-MB transfer.
|
||||
const MAX_JOB_DURATION: std::time::Duration = std::time::Duration::from_secs(15 * 60);
|
||||
|
||||
/// How long to wait for MeshService::stop() to release the serial port
|
||||
/// before giving up. Confirmed live 2026-07-23: the listener's own
|
||||
/// reconnect/multi-candidate-probe loop doesn't check its shutdown signal
|
||||
/// between candidates, so stop() can take a while (or, if the loop is
|
||||
/// wedged, never return) — 20s comfortably covers a normal handshake-probe
|
||||
/// cycle without leaving a flash request hanging indefinitely if the
|
||||
/// listener genuinely won't let go.
|
||||
const STOP_LISTENER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// How long to keep retrying the port-free check before giving up.
|
||||
const PORT_FREE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Confirm nothing else has `path` open by actually opening (and immediately
|
||||
/// closing) it ourselves. Retries across the timeout since a just-stopped
|
||||
/// listener's fd can take a moment to actually release even after `stop()`
|
||||
/// returns (task abort is a request, not an instant guarantee the OS-level
|
||||
/// resource is gone yet).
|
||||
async fn wait_for_port_free(path: &str) -> Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + PORT_FREE_TIMEOUT;
|
||||
let mut last_err = None;
|
||||
loop {
|
||||
match serial2_tokio::SerialPort::open(path, 115200) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => last_err = Some(e),
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"{path} is still held open by something else after {}s (last error: {}) — refusing to start the flasher against a contended port",
|
||||
PORT_FREE_TIMEOUT.as_secs(),
|
||||
last_err.map(|e| e.to_string()).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Live state for the one flash job that can run at a time. A single global
|
||||
/// slot is sufficient because flashing needs exclusive serial access to the
|
||||
/// one port being flashed — there is no meaningful concept of two concurrent
|
||||
/// flash jobs on this node.
|
||||
pub struct FlashJob {
|
||||
status: RwLock<FlashJobStatus>,
|
||||
/// Set once the background task is spawned. Only used while `stage` is
|
||||
/// still `Downloading` — an interrupted erase/write can leave the chip
|
||||
/// in a worse state than either finished or unstarted, so cancellation
|
||||
/// is refused once erase begins (see `cancel()`).
|
||||
abort_handle: RwLock<Option<tokio::task::AbortHandle>>,
|
||||
}
|
||||
|
||||
impl FlashJob {
|
||||
fn new(board: FlashBoard, family: DeviceType, path: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
abort_handle: RwLock::new(None),
|
||||
status: RwLock::new(FlashJobStatus {
|
||||
board,
|
||||
family,
|
||||
path,
|
||||
stage: FlashStage::Downloading,
|
||||
percent: None,
|
||||
log_tail: Vec::new(),
|
||||
done: false,
|
||||
error: None,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> FlashJobStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn set_stage(&self, stage: FlashStage) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = stage;
|
||||
s.percent = None;
|
||||
}
|
||||
|
||||
async fn set_percent(&self, percent: u8) {
|
||||
self.status.write().await.percent = Some(percent.min(100));
|
||||
}
|
||||
|
||||
async fn push_log(&self, line: impl Into<String>) {
|
||||
let mut s = self.status.write().await;
|
||||
s.log_tail.push(line.into());
|
||||
let overflow = s.log_tail.len().saturating_sub(LOG_TAIL_MAX);
|
||||
if overflow > 0 {
|
||||
s.log_tail.drain(0..overflow);
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail(&self, err: &anyhow::Error) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = FlashStage::Failed;
|
||||
s.error = Some(format!("{err:#}"));
|
||||
s.done = true;
|
||||
}
|
||||
|
||||
async fn finish(&self) {
|
||||
let mut s = self.status.write().await;
|
||||
s.stage = FlashStage::Done;
|
||||
s.done = true;
|
||||
}
|
||||
|
||||
/// Best-effort cancel: only honored before erase/write/autoinstall has
|
||||
/// started (i.e. still in `Downloading`). Once a stage that touches the
|
||||
/// chip begins, this refuses — interrupting an erase or write can leave
|
||||
/// the flash in a state worse than either finished or unstarted.
|
||||
pub async fn cancel(&self) -> Result<()> {
|
||||
let mut s = self.status.write().await;
|
||||
if s.done {
|
||||
anyhow::bail!("Flash job already finished");
|
||||
}
|
||||
if s.stage != FlashStage::Downloading {
|
||||
anyhow::bail!(
|
||||
"Cannot cancel once {:?} has started — let it finish or fail on its own",
|
||||
s.stage
|
||||
);
|
||||
}
|
||||
if let Some(handle) = self.abort_handle.write().await.take() {
|
||||
handle.abort();
|
||||
}
|
||||
s.stage = FlashStage::Failed;
|
||||
s.error = Some("Cancelled by user".to_string());
|
||||
s.done = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared handle held by `RpcHandler`, sibling to `mesh_service`.
|
||||
pub type FlashJobHandle = Arc<RwLock<Option<Arc<FlashJob>>>>;
|
||||
|
||||
pub fn new_job_handle() -> FlashJobHandle {
|
||||
Arc::new(RwLock::new(None))
|
||||
}
|
||||
|
||||
fn firmware_cache_dir(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("mesh").join("firmware-cache")
|
||||
}
|
||||
|
||||
/// No blanket `.timeout()` here on purpose: reqwest's request timeout covers
|
||||
/// the *entire* request including streaming the response body, which would
|
||||
/// kill a legitimate large download partway through (Meshtastic's esp32s3
|
||||
/// zip is ~170MB) — not just a hung connection. `download_to_file` instead
|
||||
/// applies a per-chunk stall timeout, and metadata calls (small JSON
|
||||
/// responses) get their own short timeout at the call site.
|
||||
fn github_client() -> Result<reqwest::Client> {
|
||||
reqwest::Client::builder()
|
||||
.user_agent("archipelago-mesh-flash")
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.context("Failed to build HTTP client")
|
||||
}
|
||||
|
||||
/// Applied per-chunk while streaming a firmware download — if the transfer
|
||||
/// stalls (no bytes for this long) it's treated as a failure, but a slow
|
||||
/// download that's still making progress is never killed just for taking a
|
||||
/// while.
|
||||
const DOWNLOAD_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
/// Applied to metadata calls (GitHub release JSON) — these are small
|
||||
/// responses with no reason to ever take this long.
|
||||
const METADATA_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||
|
||||
/// Resolve what firmware is available for a board+family. v1 only ever
|
||||
/// offers "latest" — MeshCore/Meshtastic latest GitHub release, or, for
|
||||
/// Reticulum, "latest" meaning "whatever archy-rnodeconf --autoinstall
|
||||
/// resolves on its own" (it does its own version checking upstream).
|
||||
pub async fn list_firmware(family: DeviceType) -> Result<Vec<String>> {
|
||||
match family {
|
||||
DeviceType::Reticulum => Ok(vec!["latest".to_string()]),
|
||||
DeviceType::Meshtastic => {
|
||||
let client = github_client()?;
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching Meshtastic release list")?
|
||||
.error_for_status()
|
||||
.context("Meshtastic releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing Meshtastic release JSON")?;
|
||||
Ok(vec![release.tag_name])
|
||||
}
|
||||
DeviceType::Meshcore => {
|
||||
let client = github_client()?;
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching MeshCore release list")?
|
||||
.error_for_status()
|
||||
.context("MeshCore releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing MeshCore release JSON")?;
|
||||
Ok(vec![release.tag_name])
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before listing versions"),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct GithubRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GithubAsset>,
|
||||
}
|
||||
|
||||
/// Start a flash job in the background. Returns as soon as the job has been
|
||||
/// registered and the listener released — callers poll `FlashJobHandle` via
|
||||
/// `mesh.flash-status` for progress. Only one job may be in flight at a time.
|
||||
pub async fn start_flash_job(
|
||||
handle: &FlashJobHandle,
|
||||
mesh_service: &Arc<RwLock<Option<MeshService>>>,
|
||||
data_dir: PathBuf,
|
||||
path: String,
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
) -> Result<()> {
|
||||
{
|
||||
let existing = handle.read().await;
|
||||
if let Some(job) = existing.as_ref() {
|
||||
if !job.snapshot().await.done {
|
||||
anyhow::bail!("A firmware flash is already in progress on this node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let job = FlashJob::new(board, family, path.clone());
|
||||
*handle.write().await = Some(Arc::clone(&job));
|
||||
|
||||
let bg_job = Arc::clone(&job);
|
||||
let bg_service = Arc::clone(mesh_service);
|
||||
let task = tokio::spawn(async move {
|
||||
// esptool/archy-rnodeconf need exclusive serial access — release
|
||||
// the listener's hold on the port before touching it. This USED
|
||||
// TO run synchronously in start_flash_job before the job was even
|
||||
// spawned, blocking the RPC call itself on s.stop().await — a real
|
||||
// 2026-07-23 incident: the mesh listener was mid a multi-candidate
|
||||
// reconnect/probe sequence that doesn't check its shutdown signal
|
||||
// between candidates, so stop() never returned. The HTTP request
|
||||
// timed out client-side ("Operation failed"), while the job
|
||||
// (already inserted into `handle`) was permanently wedged — nothing
|
||||
// had been spawned yet to ever mark it done, so every later flash
|
||||
// attempt failed with "already in progress" until a full restart.
|
||||
// Now this runs inside the spawned task with its own bounded
|
||||
// timeout, so the RPC call always returns immediately regardless,
|
||||
// and a slow-to-stop listener fails the job cleanly instead of
|
||||
// hanging everything downstream of it forever.
|
||||
let stop_result = tokio::time::timeout(STOP_LISTENER_TIMEOUT, async {
|
||||
let mut svc = bg_service.write().await;
|
||||
if let Some(s) = svc.as_mut() {
|
||||
s.stop().await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
if stop_result.is_err() {
|
||||
let err = anyhow::anyhow!(
|
||||
"Mesh listener did not release the serial port within {}s — it may still be mid a reconnect attempt. Try again once mesh.status shows the device idle, or restart the archipelago service if this persists.",
|
||||
STOP_LISTENER_TIMEOUT.as_secs()
|
||||
);
|
||||
bg_job.push_log(format!("ERROR: {err:#}")).await;
|
||||
bg_job.fail(&err).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Belt-and-suspenders port-free check. `stop()` above should have
|
||||
// fully released the port, but esptool/rnodeconf run as external
|
||||
// subprocesses for minutes outside our own async runtime — if
|
||||
// ANYTHING else still has it open (a racing probe, a not-yet-dropped
|
||||
// fd from an aborted task, anything we haven't anticipated), handing
|
||||
// the port to the flasher anyway risks exactly the corruption
|
||||
// confirmed live 2026-07-23: esptool's "device disconnected or
|
||||
// multiple access on port?" and rnodeconf's raw `OSError: [Errno 71]
|
||||
// Protocol error` on an RTS ioctl are both textbook two-openers-on-
|
||||
// one-fd symptoms. Verify by actually opening it ourselves — cheap,
|
||||
// and definitive — before ever starting the flasher.
|
||||
if let Err(e) = wait_for_port_free(&path).await {
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(&e).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Outer ceiling on top of run_flash's own internal timeouts —
|
||||
// belt-and-suspenders so that no future hang (network, subprocess,
|
||||
// anything) can ever wedge the single-flash-job guard permanently
|
||||
// again the way a stuck download did on 2026-07-23 (every
|
||||
// subsequent mesh.flash-device call failed with "already in
|
||||
// progress" until the service was restarted). Generous enough that
|
||||
// a legitimately slow multi-hundred-MB transfer still completes.
|
||||
let result = match tokio::time::timeout(
|
||||
MAX_JOB_DURATION,
|
||||
run_flash(board, family, &data_dir, &path, &bg_job),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(inner) => inner,
|
||||
Err(_) => Err(anyhow::anyhow!(
|
||||
"Flash job exceeded the {}-minute ceiling — aborted",
|
||||
MAX_JOB_DURATION.as_secs() / 60
|
||||
)),
|
||||
};
|
||||
let succeeded = result.is_ok();
|
||||
|
||||
match &result {
|
||||
Ok(()) => {
|
||||
bg_job.push_log("Flash completed successfully".to_string()).await;
|
||||
bg_job.finish().await;
|
||||
info!(path = %path, board = ?board, family = %family, "LoRa firmware flash succeeded");
|
||||
}
|
||||
Err(e) => {
|
||||
// {:#} (alternate Display) walks the full anyhow context
|
||||
// chain — plain {} / %e only prints the outermost .context()
|
||||
// message, which made a real 2026-07-23 esptool failure
|
||||
// undiagnosable from journalctl alone (just "esptool
|
||||
// erase_flash failed", no actual esptool stderr).
|
||||
warn!(path = %path, error = %format!("{e:#}"), "LoRa firmware flash failed");
|
||||
bg_job.push_log(format!("ERROR: {e:#}")).await;
|
||||
bg_job.fail(e).await;
|
||||
}
|
||||
}
|
||||
|
||||
// The board's firmware may now differ from whatever was pinned
|
||||
// before — clear the pin either way so a later reconnect's strict
|
||||
// auto-detect order picks up reality instead of getting wedged
|
||||
// trying the old protocol first.
|
||||
if let Ok(mut config) = super::load_config(&data_dir).await {
|
||||
config.device_kind = None;
|
||||
if let Err(e) = super::save_config(&data_dir, &config).await {
|
||||
warn!(error = %e, "Failed to clear device_kind pin after flash");
|
||||
}
|
||||
}
|
||||
|
||||
if !succeeded {
|
||||
// Deliberately do NOT auto-restart the listener here. A failed
|
||||
// flash means we can't vouch for the board's state — reopening
|
||||
// the port immediately (esptool/rnodeconf's own reset sequence
|
||||
// plus our open() toggling DTR/RTS again right after) risks
|
||||
// hammering a marginal device with reconnect attempts. Confirmed
|
||||
// live 2026-07-23: exactly this sequence left a real Heltec V3
|
||||
// boot-looping for 5+ minutes after a failed flash. Leave mesh
|
||||
// stopped; the user reconnects explicitly via the hot-swap
|
||||
// modal/Mesh page once they've confirmed the board is alive.
|
||||
warn!(
|
||||
path = %path,
|
||||
"Leaving mesh listener stopped after failed flash — reconnect manually once the board is confirmed responsive"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// On success, give the board a moment to finish booting after the
|
||||
// flash tool's own reset sequence before we start hammering it with
|
||||
// connection attempts — same reasoning as above, just the
|
||||
// lower-risk (successful-flash) side of it.
|
||||
tokio::time::sleep(POST_FLASH_SETTLE_DELAY).await;
|
||||
|
||||
let mut svc = bg_service.write().await;
|
||||
if let Some(s) = svc.as_mut() {
|
||||
match super::load_config(&data_dir).await {
|
||||
Ok(config) => {
|
||||
// Only resume if mesh is actually still enabled per the
|
||||
// CURRENT persisted config — confirmed live 2026-07-23:
|
||||
// unconditionally forcing a restart here, regardless of
|
||||
// `enabled`, overrode a user's own concurrent "disable
|
||||
// mesh" toggle and left the listener running while
|
||||
// config said disabled. That inconsistent state is what
|
||||
// made a later legitimate "Keep As Is" click (which
|
||||
// correctly tries to start on a false→true transition)
|
||||
// fail with "already running" — the listener had already
|
||||
// been force-started behind the config's back.
|
||||
let should_run = config.enabled;
|
||||
if let Err(e) = s.configure(config).await {
|
||||
warn!(error = %e, "Failed to resume mesh listener after flash");
|
||||
}
|
||||
if should_run {
|
||||
if let Err(e) = s.start() {
|
||||
warn!(error = %e, "Failed to restart mesh listener after flash");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "Failed to load mesh config after flash"),
|
||||
}
|
||||
}
|
||||
});
|
||||
*job.abort_handle.write().await = Some(task.abort_handle());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_flash(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
path: &str,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
match family {
|
||||
DeviceType::Meshtastic | DeviceType::Meshcore => {
|
||||
let image = fetch_esptool_image(board, family, data_dir, job).await?;
|
||||
esptool_erase_and_write(path, &image, job).await
|
||||
}
|
||||
DeviceType::Reticulum => {
|
||||
let lora_region = super::load_config(data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|c| c.lora_region);
|
||||
rnodeconf_autoinstall(path, board, lora_region.as_deref(), job).await
|
||||
}
|
||||
DeviceType::Unknown => anyhow::bail!("Pick a firmware family before flashing"),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── MeshCore / Meshtastic: esptool ─────────────────────────────────────
|
||||
|
||||
async fn fetch_esptool_image(
|
||||
board: FlashBoard,
|
||||
family: DeviceType,
|
||||
data_dir: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let cache = firmware_cache_dir(data_dir);
|
||||
tokio::fs::create_dir_all(&cache)
|
||||
.await
|
||||
.context("Creating firmware cache dir")?;
|
||||
let client = github_client()?;
|
||||
|
||||
match family {
|
||||
DeviceType::Meshtastic => fetch_meshtastic_image(&client, board, &cache, job).await,
|
||||
DeviceType::Meshcore => fetch_meshcore_image(&client, board, &cache, job).await,
|
||||
_ => anyhow::bail!("{family} is not flashed via esptool"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_meshtastic_image(
|
||||
client: &reqwest::Client,
|
||||
board: FlashBoard,
|
||||
cache: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshtastic/firmware/releases/latest")
|
||||
.timeout(METADATA_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching Meshtastic release list")?
|
||||
.error_for_status()
|
||||
.context("Meshtastic releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing Meshtastic release JSON")?;
|
||||
|
||||
// Meshtastic bundles all esp32s3 boards' images inside one per-platform
|
||||
// zip rather than shipping per-board top-level assets — both Heltec V3
|
||||
// and V4 are esp32s3, so this is the right zip for both (confirmed live
|
||||
// against v2.7.26.54e0d8d).
|
||||
let zip_asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.starts_with("firmware-esp32s3-") && a.name.ends_with(".zip"))
|
||||
.ok_or_else(|| anyhow::anyhow!("No esp32s3 firmware zip in latest Meshtastic release"))?;
|
||||
|
||||
let version = zip_asset
|
||||
.name
|
||||
.strip_prefix("firmware-esp32s3-")
|
||||
.and_then(|s| s.strip_suffix(".zip"))
|
||||
.ok_or_else(|| anyhow::anyhow!("Unexpected Meshtastic asset name: {}", zip_asset.name))?
|
||||
.to_string();
|
||||
|
||||
let zip_path = cache.join(&zip_asset.name);
|
||||
if tokio::fs::metadata(&zip_path).await.is_err() {
|
||||
download_to_file(client, &zip_asset.browser_download_url, &zip_path, job).await?;
|
||||
} else {
|
||||
job.push_log(format!("Using cached {}", zip_asset.name)).await;
|
||||
}
|
||||
|
||||
// "*.factory.bin" is Meshtastic's full merged image (bootloader +
|
||||
// partition table + app) meant to be written at offset 0x0 on a freshly
|
||||
// erased chip — confirmed by inspecting the real zip's contents, as
|
||||
// opposed to the plain "*.bin" OTA-update image which assumes an
|
||||
// existing bootloader/partition table already on the chip.
|
||||
let entry_name = format!(
|
||||
"firmware-{}-{}.factory.bin",
|
||||
board.meshtastic_id(),
|
||||
version
|
||||
);
|
||||
let out_path = cache.join(&entry_name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
return Ok(out_path);
|
||||
}
|
||||
|
||||
job.push_log(format!(
|
||||
"Extracting {entry_name} from {}",
|
||||
zip_asset.name
|
||||
))
|
||||
.await;
|
||||
let zip_path_owned = zip_path.clone();
|
||||
let entry_name_owned = entry_name.clone();
|
||||
let out_path_owned = out_path.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let file = std::fs::File::open(&zip_path_owned).context("Opening downloaded firmware zip")?;
|
||||
let mut archive = zip::ZipArchive::new(file).context("Reading firmware zip")?;
|
||||
let mut entry = archive
|
||||
.by_name(&entry_name_owned)
|
||||
.with_context(|| format!("{entry_name_owned} not found in firmware zip"))?;
|
||||
let mut out =
|
||||
std::fs::File::create(&out_path_owned).context("Creating extracted firmware file")?;
|
||||
std::io::copy(&mut entry, &mut out).context("Extracting firmware image")?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.context("Firmware extraction task panicked")??;
|
||||
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn fetch_meshcore_image(
|
||||
client: &reqwest::Client,
|
||||
board: FlashBoard,
|
||||
cache: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<PathBuf> {
|
||||
let release: GithubRelease = client
|
||||
.get("https://api.github.com/repos/meshcore-dev/MeshCore/releases/latest")
|
||||
.timeout(METADATA_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("Fetching MeshCore release list")?
|
||||
.error_for_status()
|
||||
.context("MeshCore releases API error")?
|
||||
.json()
|
||||
.await
|
||||
.context("Parsing MeshCore release JSON")?;
|
||||
|
||||
// Upstream's casing differs between boards (Heltec_v3_... vs
|
||||
// heltec_v4_...) — match case-insensitively on the exact per-board
|
||||
// substring so V4 isn't accidentally matched by "heltec_v4_tft_..."
|
||||
// variants (there's a "_tft_" in between, so a straight substring match
|
||||
// on "heltec_v4_companion_radio_usb" is already safe).
|
||||
let needle = match board {
|
||||
FlashBoard::HeltecV3 => "heltec_v3_companion_radio_usb",
|
||||
FlashBoard::HeltecV4 => "heltec_v4_companion_radio_usb",
|
||||
};
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| {
|
||||
let lower = a.name.to_lowercase();
|
||||
lower.contains(needle) && lower.ends_with("-merged.bin")
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("No matching MeshCore image in release {}", release.tag_name)
|
||||
})?;
|
||||
|
||||
let out_path = cache.join(&asset.name);
|
||||
if tokio::fs::metadata(&out_path).await.is_ok() {
|
||||
job.push_log(format!("Using cached {}", asset.name)).await;
|
||||
return Ok(out_path);
|
||||
}
|
||||
download_to_file(client, &asset.browser_download_url, &out_path, job).await?;
|
||||
Ok(out_path)
|
||||
}
|
||||
|
||||
async fn download_to_file(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
dest: &Path,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
job.set_stage(FlashStage::Downloading).await;
|
||||
// Bound only the wait for the response to *start* (headers) — NOT a
|
||||
// request-level `.timeout()`, which would cap the whole body transfer
|
||||
// again (the bug this replaced: a blanket 30s client timeout killed
|
||||
// large downloads mid-stream). If the server never responds at all,
|
||||
// this is what stops the job from hanging forever; the per-chunk stall
|
||||
// timeout below is what guards the body once streaming starts. Without
|
||||
// this, a server that accepts the TCP connection but never sends
|
||||
// headers back hangs this call indefinitely — confirmed live
|
||||
// 2026-07-23: a stuck `.send()` here wedged the single-flash-job guard
|
||||
// for good, permanently blocking every subsequent flash attempt with
|
||||
// "already in progress" until the service was restarted.
|
||||
let resp = tokio::time::timeout(METADATA_TIMEOUT, client.get(url).send())
|
||||
.await
|
||||
.context("Firmware download server did not respond")?
|
||||
.context("Starting firmware download")?
|
||||
.error_for_status()
|
||||
.context("Firmware download returned an error status")?;
|
||||
let total = resp.content_length();
|
||||
let tmp = dest.with_extension("part");
|
||||
let mut file = tokio::fs::File::create(&tmp)
|
||||
.await
|
||||
.context("Creating firmware download file")?;
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
use futures_util::StreamExt;
|
||||
loop {
|
||||
let next = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next())
|
||||
.await
|
||||
.context("Firmware download stalled")?;
|
||||
let Some(chunk) = next else { break };
|
||||
let chunk = chunk.context("Reading firmware download stream")?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.context("Writing firmware download")?;
|
||||
downloaded += chunk.len() as u64;
|
||||
if let Some(total) = total {
|
||||
if total > 0 {
|
||||
job.set_percent(((downloaded.saturating_mul(100)) / total) as u8)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
file.flush().await.ok();
|
||||
tokio::fs::rename(&tmp, dest)
|
||||
.await
|
||||
.context("Finalizing firmware download")?;
|
||||
job.push_log(format!(
|
||||
"Downloaded {} ({downloaded} bytes)",
|
||||
dest.display()
|
||||
))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Both Heltec V3 and V4 are ESP32-S3 boards.
|
||||
const ESPTOOL_CHIP: &str = "esp32s3";
|
||||
|
||||
/// esptool's auto-reset-into-bootloader handshake (toggling DTR/RTS in a
|
||||
/// specific timed pattern) is well-known to be flaky on some CP2102/CH340
|
||||
/// board+adapter combinations — esptool's own docs recommend retrying at a
|
||||
/// lower baud rate when this happens. Rather than fail the whole job on the
|
||||
/// first hiccup, retry once at a conservative baud before giving up.
|
||||
const ESPTOOL_FALLBACK_BAUD: &str = "115200";
|
||||
|
||||
/// `write_flash --erase-all` erases the whole chip before writing, in one
|
||||
/// esptool invocation. This needs the esp32s3 stub flasher loaded (see
|
||||
/// esptool_global_args' doc comment) — without it, --erase-all hits the
|
||||
/// exact same ROM limitation a standalone `erase_flash` does ("ESP32-S3 ROM
|
||||
/// does not support function erase_flash", confirmed live 2026-07-23), since
|
||||
/// esptool's --erase-all is implemented as the same full-chip-erase command,
|
||||
/// not a per-sector loop.
|
||||
async fn esptool_erase_and_write(path: &str, image: &Path, job: &Arc<FlashJob>) -> Result<()> {
|
||||
job.set_stage(FlashStage::Writing).await;
|
||||
let image_str = image.to_string_lossy().to_string();
|
||||
esptool_with_retry(
|
||||
path,
|
||||
&["write_flash", "--erase-all", "0x0", &image_str],
|
||||
job,
|
||||
)
|
||||
.await
|
||||
.context("esptool write_flash failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// esptool's global flags (--chip/--port/--baud) MUST precede the subcommand
|
||||
/// token (erase_flash/write_flash/...) — confirmed live 2026-07-23:
|
||||
/// appending `--baud 115200` after the subcommand on the retry path
|
||||
/// produced "esptool: error: unrecognized arguments: --baud 115200" every
|
||||
/// time, so the fallback-baud retry never actually got a chance to run.
|
||||
/// Building global args separately from subcommand args keeps this correct
|
||||
/// by construction instead of relying on call-site ordering.
|
||||
///
|
||||
/// Normal stub-loader mode (no --no-stub) needs the esp32s3 stub flasher
|
||||
/// blob at /usr/lib/python3/dist-packages/esptool/targets/stub_flasher/
|
||||
/// stub_flasher_32s3.json — Debian's `esptool` package (4.7.0+dfsg-0.1)
|
||||
/// ships without it (stripped for DFSG compliance: the prebuilt blob has no
|
||||
/// buildable-from-source path Debian could verify), so scripts/self-update.sh
|
||||
/// fetches the exact same file from the matching upstream esptool release
|
||||
/// tag and installs it alongside the apt package (see the esptool install
|
||||
/// step there). --no-stub (talk directly to the ROM bootloader, skip the
|
||||
/// stub) was tried first and works for connecting, but the ROM bootloader
|
||||
/// doesn't implement a full-chip-erase opcode at all — only the stub does —
|
||||
/// so --no-stub broke our "always erase before write" default outright
|
||||
/// rather than just being slower. Restoring the real stub file is the
|
||||
/// correct fix, not routing around its absence.
|
||||
fn esptool_global_args<'a>(path: &'a str, baud: Option<&'a str>) -> Vec<&'a str> {
|
||||
let mut args = vec!["--chip", ESPTOOL_CHIP, "--port", path];
|
||||
if let Some(b) = baud {
|
||||
args.push("--baud");
|
||||
args.push(b);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
async fn esptool_with_retry(path: &str, subcommand: &[&str], job: &Arc<FlashJob>) -> Result<()> {
|
||||
let mut cmd = Command::new("esptool");
|
||||
cmd.args(esptool_global_args(path, None));
|
||||
cmd.args(subcommand);
|
||||
match run_streamed(cmd, None, job).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(first_err) => {
|
||||
job.push_log(format!(
|
||||
"First attempt failed ({first_err:#}); retrying once at {ESPTOOL_FALLBACK_BAUD} baud"
|
||||
))
|
||||
.await;
|
||||
let mut retry = Command::new("esptool");
|
||||
retry.args(esptool_global_args(path, Some(ESPTOOL_FALLBACK_BAUD)));
|
||||
retry.args(subcommand);
|
||||
run_streamed(retry, None, job)
|
||||
.await
|
||||
.context(format!("retry also failed (first attempt: {first_err:#})"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reticulum/RNode: archy-rnodeconf ───────────────────────────────────
|
||||
|
||||
fn rnodeconf_bin() -> String {
|
||||
std::env::var("ARCHY_RNODECONF_BIN")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/archy-rnodeconf".to_string())
|
||||
}
|
||||
|
||||
/// `--autoinstall`'s "which board is this" step is interactive by design —
|
||||
/// confirmed live against a real Heltec V4 (2026-07-23): even with a board
|
||||
/// given on the command line, rnodeconf can't always tell V3 from V4 apart
|
||||
/// (their bootstrap-time USB identity is often generic, same root cause as
|
||||
/// `resolve_flash_board`'s doc comment), so it always asks. The full prompt
|
||||
/// sequence observed for a Heltec board that already has *some* RNode
|
||||
/// firmware installed (the common case — a truly blank chip likely skips
|
||||
/// straight to the same "Device Selection" menu):
|
||||
/// 1. numbered device-type menu → answer with the menu number
|
||||
/// 2. "Hit enter to continue" → answer with a blank line
|
||||
/// 3. numbered band menu → answer with the menu number
|
||||
/// 4. "Is the above correct? [y/N]" → answer "y"
|
||||
/// Feeding all four answers up front (rather than watching stdout for each
|
||||
/// prompt text) works because the menu is always asked in this fixed order
|
||||
/// for every board that needs (re)provisioning — verified by driving it
|
||||
/// through an unprovisioned real V4 end-to-end (erase → flash → EEPROM
|
||||
/// bootstrap → "Device signature validated" on the next probe).
|
||||
fn rnodeconf_device_menu_number(board: FlashBoard) -> &'static str {
|
||||
match board {
|
||||
FlashBoard::HeltecV3 => "8",
|
||||
FlashBoard::HeltecV4 => "9",
|
||||
}
|
||||
}
|
||||
|
||||
/// rnodeconf's band choice is a coarse RF-frontend bootstrap parameter
|
||||
/// (868/915/923 MHz), not the final operating frequency — that's still
|
||||
/// configured later via the daemon's interface config, same as today. This
|
||||
/// is a best-effort mapping from the node's persisted Meshtastic-style
|
||||
/// region code (see `mesh::meshtastic::region_name_to_code`) down to
|
||||
/// rnodeconf's 3-way menu; regions with no exact 868/923 match fall back to
|
||||
/// 915 MHz as the broadest-compatibility default.
|
||||
fn rnodeconf_band_menu_number(lora_region: Option<&str>) -> &'static str {
|
||||
match lora_region.map(|s| s.trim().to_uppercase()) {
|
||||
Some(r) if r.contains("868") => "1",
|
||||
Some(r) if r.contains("923") => "3",
|
||||
_ => "2",
|
||||
}
|
||||
}
|
||||
|
||||
/// `--autoinstall` fetches, erases, flashes, and bootstraps the EEPROM for
|
||||
/// a detected board as one atomic step (confirmed via `archy-rnodeconf
|
||||
/// --help` AND a real end-to-end flash on real hardware) — this is the
|
||||
/// RNode-side equivalent of our "always erase before write" default, since
|
||||
/// autoinstall doesn't try to preserve any existing on-device state.
|
||||
async fn rnodeconf_autoinstall(
|
||||
path: &str,
|
||||
board: FlashBoard,
|
||||
lora_region: Option<&str>,
|
||||
job: &Arc<FlashJob>,
|
||||
) -> Result<()> {
|
||||
job.set_stage(FlashStage::Autoinstalling).await;
|
||||
let bin = rnodeconf_bin();
|
||||
let mut cmd = if Path::new(&bin).exists() {
|
||||
Command::new(bin)
|
||||
} else {
|
||||
// Dev fallback if only a plain venv/system rnodeconf is on PATH.
|
||||
Command::new("rnodeconf")
|
||||
};
|
||||
cmd.args(["--autoinstall", path]);
|
||||
let stdin = format!(
|
||||
"{}\n\n{}\ny\n",
|
||||
rnodeconf_device_menu_number(board),
|
||||
rnodeconf_band_menu_number(lora_region)
|
||||
);
|
||||
run_streamed(cmd, Some(stdin.into_bytes()), job)
|
||||
.await
|
||||
.context("archy-rnodeconf --autoinstall failed")
|
||||
}
|
||||
|
||||
// ─── Subprocess streaming ────────────────────────────────────────────────
|
||||
|
||||
fn percent_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"\((\d{1,3})\s*%\)").expect("valid regex"))
|
||||
}
|
||||
|
||||
async fn run_streamed(mut cmd: Command, stdin: Option<Vec<u8>>, job: &Arc<FlashJob>) -> Result<()> {
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
if stdin.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
// Deliberately NOT kill_on_drop: an interrupted erase/write can leave
|
||||
// the chip in a worse state than either finished or unstarted (see the
|
||||
// cancellation-safety note in mesh flashing docs). The job is expected
|
||||
// to run to completion or fail on its own.
|
||||
let mut child = cmd.spawn().context("Failed to start subprocess")?;
|
||||
|
||||
if let Some(bytes) = stdin {
|
||||
if let Some(mut child_stdin) = child.stdin.take() {
|
||||
child_stdin
|
||||
.write_all(&bytes)
|
||||
.await
|
||||
.context("Writing to subprocess stdin")?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let job = Arc::clone(job);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(cap) = percent_regex().captures(&line) {
|
||||
if let Ok(pct) = cap[1].parse::<u8>() {
|
||||
job.set_percent(pct).await;
|
||||
}
|
||||
}
|
||||
job.push_log(line).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let job = Arc::clone(job);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
job.push_log(line).await;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
let status = child.wait().await.context("Waiting for subprocess")?;
|
||||
for t in tasks {
|
||||
let _ = t.await;
|
||||
}
|
||||
if !status.success() {
|
||||
// Exit status alone isn't diagnosable — the actual esptool/rnodeconf
|
||||
// stderr (already captured into job.log_tail by the reader tasks
|
||||
// above) is what actually explains a failure. Confirmed live
|
||||
// 2026-07-23: a bare "Command exited with exit status: 1" told us
|
||||
// nothing when esptool's real error was sitting in the log tail the
|
||||
// whole time, only visible via the UI's live poll, not journald.
|
||||
let tail: Vec<String> = job
|
||||
.snapshot()
|
||||
.await
|
||||
.log_tail
|
||||
.iter()
|
||||
.rev()
|
||||
.take(10)
|
||||
.rev()
|
||||
.cloned()
|
||||
.collect();
|
||||
anyhow::bail!("Command exited with {status}\n{}", tail.join("\n"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -578,7 +578,7 @@ pub(super) async fn handle_identity_received(
|
||||
.insert(contact_id, shared_secret);
|
||||
|
||||
// Update peer record
|
||||
let peer = MeshPeer {
|
||||
let mut peer = MeshPeer {
|
||||
contact_id,
|
||||
// .get(): a malformed DID shorter than the "did:key:" prefix must
|
||||
// not panic the listener on a radio-supplied string.
|
||||
@@ -607,6 +607,24 @@ pub(super) async fn handle_identity_received(
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
let is_new = !peers.contains_key(&contact_id);
|
||||
if let Some(existing) = peers.get(&contact_id) {
|
||||
// This id is shared with the federation-seeded row for the same
|
||||
// node (that's the point — identity adverts MERGE, not duplicate).
|
||||
// The wholesale insert below must not stomp the federation row's
|
||||
// real node name with our synthetic "Archy-…" placeholder — with
|
||||
// Reticulum re-emitting identity adverts every announce tick,
|
||||
// that renamed every federated contact once a minute. Same for a
|
||||
// known position: keep it rather than nulling it out.
|
||||
if !existing.advert_name.trim().is_empty()
|
||||
&& !existing.advert_name.starts_with("Archy-")
|
||||
{
|
||||
peer.advert_name = existing.advert_name.clone();
|
||||
}
|
||||
if peer.lat.is_none() {
|
||||
peer.lat = existing.lat;
|
||||
peer.lon = existing.lon;
|
||||
}
|
||||
}
|
||||
peers.insert(contact_id, peer.clone());
|
||||
is_new
|
||||
};
|
||||
|
||||
@@ -557,6 +557,10 @@ pub fn spawn_mesh_listener(
|
||||
let mut shutdown = shutdown;
|
||||
let mut cmd_rx = cmd_rx;
|
||||
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
||||
// Mutable so a successful auto-detect can pin the firmware kind for
|
||||
// the rest of this listener's lifetime — see the pin-on-first-success
|
||||
// block below for why.
|
||||
let mut device_kind = device_kind;
|
||||
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
||||
// builds a fresh device struct (contacts/current_region/etc. all
|
||||
// start empty), so per-device session state is naturally isolated
|
||||
@@ -628,6 +632,45 @@ pub fn spawn_mesh_listener(
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the firmware kind after the first successful auto-detect.
|
||||
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
|
||||
// after clearing a stale pin), EVERY reconnect re-runs the full
|
||||
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
|
||||
// candidate past the first does its own open() with the DTR/RTS
|
||||
// reset both boards need, so a device correctly identified as
|
||||
// Meshtastic still gets reset once for the failed Meshcore
|
||||
// attempt before Meshtastic's own open() resets it again. That
|
||||
// doubled the reset count on every single reconnect indefinitely,
|
||||
// not just during initial detection. Once auto-detect has
|
||||
// identified the device this listener is actually talking to,
|
||||
// there's no reason to keep guessing on subsequent reconnects —
|
||||
// pin it, both in this task's own loop (takes effect
|
||||
// immediately) and on disk (survives a service restart). A
|
||||
// genuine hot-swap to different firmware is still handled: the
|
||||
// setup modal's `mesh.probe-device` always re-probes unpinned,
|
||||
// and the flash flow already clears this pin on its own.
|
||||
if device_kind.is_none() {
|
||||
let detected = state.status.read().await.device_type;
|
||||
if detected != super::types::DeviceType::Unknown {
|
||||
device_kind = Some(detected);
|
||||
match super::load_config(&data_dir).await {
|
||||
Ok(mut cfg) if cfg.device_kind.is_none() => {
|
||||
cfg.device_kind = Some(detected);
|
||||
if let Err(e) = super::save_config(&data_dir, &cfg).await {
|
||||
warn!("Failed to persist auto-detected device_kind: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
kind = %detected,
|
||||
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to disconnected. device_type/firmware_version are
|
||||
// reset too — they were previously left holding the LAST radio's
|
||||
// identity, so after a hot-swap the UI showed the old firmware
|
||||
|
||||
@@ -270,6 +270,7 @@ async fn auto_detect_and_open(
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
skip_path: Option<&str>,
|
||||
advert_name: Option<&str>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let mut paths = super::super::serial::detect_serial_devices().await;
|
||||
// When falling back from a just-failed preferred path, don't probe that
|
||||
@@ -287,6 +288,7 @@ async fn auto_detect_and_open(
|
||||
None => "No serial devices found in /dev".to_string(),
|
||||
});
|
||||
}
|
||||
info!(candidates = ?paths, "Auto-detect candidate ports for this attempt");
|
||||
for path in &paths {
|
||||
debug!(path = %path, "Probing for mesh radio device");
|
||||
// Tried FIRST: `ReticulumLink::open()` gates its expensive daemon
|
||||
@@ -304,6 +306,7 @@ async fn auto_detect_and_open(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -465,6 +468,7 @@ async fn open_preferred_path(
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
device_kind: Option<DeviceType>,
|
||||
advert_name: Option<&str>,
|
||||
) -> 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
|
||||
@@ -497,6 +501,7 @@ async fn open_preferred_path(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
.context("Could not open preferred path as Reticulum")?;
|
||||
@@ -510,40 +515,21 @@ async fn open_preferred_path(
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut dev) => match dev.initialize().await {
|
||||
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) => 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"),
|
||||
}
|
||||
// Unpinned: don't probe this path ourselves at all. Confirmed live
|
||||
// 2026-07-23 — this function used to run its own Reticulum→Meshcore→
|
||||
// Meshtastic sequence here, and the caller (run_mesh_session) falls
|
||||
// back to `auto_detect_and_open` on any error, which scans every
|
||||
// candidate path (this one included) with the exact same three-protocol
|
||||
// sequence. With a single physical radio — the overwhelmingly common
|
||||
// case — `path` here IS the one candidate `auto_detect_and_open` is
|
||||
// about to try, so every unpinned reconnect was resetting the board via
|
||||
// Reticulum/Meshcore/Meshtastic's DTR/RTS toggle TWICE: once here, once
|
||||
// again moments later in auto-detect. Bailing immediately (no port
|
||||
// access at all) means auto-detect's single pass is the only one that
|
||||
// ever touches the port when nothing is pinned yet. (auto_detect_and_open
|
||||
// carries the advert_name threading from main, so nothing is lost.)
|
||||
let _ = advert_name;
|
||||
anyhow::bail!("No device_kind pin — deferring to auto-detect for {path}")
|
||||
}
|
||||
|
||||
/// Bring up a Reticulum daemon over plain TCP — no physical RNode, no
|
||||
@@ -556,6 +542,7 @@ async fn open_reticulum_tcp(
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: &str,
|
||||
our_x25519_pubkey_hex: &str,
|
||||
advert_name: Option<&str>,
|
||||
) -> Result<(String, MeshRadioDevice, DeviceInfo)> {
|
||||
let mut dev = match cfg {
|
||||
ReticulumTcpConfig::Server { bind } => ReticulumLink::open_tcp_server(
|
||||
@@ -563,6 +550,7 @@ async fn open_reticulum_tcp(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP server interface")?,
|
||||
@@ -571,6 +559,7 @@ async fn open_reticulum_tcp(
|
||||
data_dir,
|
||||
Some(our_ed_pubkey_hex),
|
||||
Some(our_x25519_pubkey_hex),
|
||||
advert_name,
|
||||
)
|
||||
.await
|
||||
.context("Could not open Reticulum TCP client interface")?,
|
||||
@@ -987,49 +976,93 @@ pub(super) async fn run_mesh_session(
|
||||
// auto-detect. TCP mode is additive/dev-only; it never changes behavior
|
||||
// for existing serial/RNode deployments where `reticulum_tcp` is None.
|
||||
//
|
||||
// The name we present on the mesh: the operator's configured mesh name /
|
||||
// server name, falling back to a DID fragment. Computed BEFORE the open
|
||||
// sequence because Reticulum needs it at daemon-spawn time — the RNS
|
||||
// announce carries it from the very first announce. (Meshcore/Meshtastic
|
||||
// still receive it via set_advert_name after connect, below.)
|
||||
let desired_advert_name: String = match server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars.
|
||||
Some(name) => name.chars().take(20).collect(),
|
||||
None => format!(
|
||||
"Archy-{}",
|
||||
our_did.chars().skip(8).take(8).collect::<String>()
|
||||
),
|
||||
};
|
||||
|
||||
// The whole open sequence runs under PORT_OPEN_LOCK so an RPC probe
|
||||
// can't interleave its own handshakes on the same tty (see the lock's
|
||||
// doc comment). Held only until the device is opened, then released.
|
||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
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,
|
||||
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(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(path),
|
||||
)
|
||||
.await?
|
||||
//
|
||||
// The sequence is raced against the shutdown signal: probes/handshakes
|
||||
// can take 10s+, and without this a stop() issued mid-probe (config
|
||||
// change, disable, rename) always burned the full listener-shutdown
|
||||
// timeout and ended in a hard abort — observed live on archi-dev-box
|
||||
// 2026-07-28. Dropping the open future mid-probe is safe: it holds no
|
||||
// session state yet and the port guard/serial handle close with it.
|
||||
let open_fut = async {
|
||||
let port_guard = PORT_OPEN_LOCK.lock().await;
|
||||
let result = if let Some(tcp_cfg) = &reticulum_tcp {
|
||||
open_reticulum_tcp(
|
||||
tcp_cfg,
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
} else if let Some(path) = preferred_path {
|
||||
match open_preferred_path(
|
||||
path,
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((dev, info)) => Ok((path.to_string(), dev, info)),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Preferred path {} probe failed: {} — trying auto-detect",
|
||||
path, e
|
||||
);
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
Some(path),
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
None,
|
||||
Some(&desired_advert_name),
|
||||
)
|
||||
.await
|
||||
};
|
||||
drop(port_guard);
|
||||
result
|
||||
};
|
||||
let (device_path, mut device, device_info) = tokio::select! {
|
||||
res = open_fut => res?,
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() {
|
||||
info!("Shutdown requested during device open — ending session");
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!("shutdown signal changed during device open");
|
||||
}
|
||||
} else {
|
||||
auto_detect_and_open(
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
device_kind,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
drop(port_guard);
|
||||
|
||||
// Update status
|
||||
{
|
||||
@@ -1176,19 +1209,14 @@ pub(super) async fn run_mesh_session(
|
||||
}
|
||||
}
|
||||
|
||||
// Set advert name to the server's human-readable name (e.g. "ThinkPad"),
|
||||
// falling back to the DID fragment if no name is configured. Skipped in
|
||||
// keep-as-is mode — the radio keeps the name it came with (already
|
||||
// reflected in status from the connect handshake).
|
||||
if manage_radio {
|
||||
let advert_name = if let Some(name) = server_name {
|
||||
// Meshcore firmware limits advert names — truncate to 20 chars
|
||||
name.chars().take(20).collect::<String>()
|
||||
} else {
|
||||
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
|
||||
format!("Archy-{}", short_did)
|
||||
};
|
||||
if let Err(e) = device.set_advert_name(&advert_name).await {
|
||||
// Set advert name to the configured mesh/server name (computed above).
|
||||
// Skipped in keep-as-is mode for radio-held names — the radio keeps the
|
||||
// name it came with (already reflected in status from the connect
|
||||
// handshake). Reticulum is exempt from keep-as-is: its display name
|
||||
// lives in OUR daemon (the RNode holds no name), so "keep as is" has
|
||||
// nothing to preserve and an unnamed node would be anonymous on RNS.
|
||||
if manage_radio || matches!(device, MeshRadioDevice::Reticulum(_)) {
|
||||
if let Err(e) = device.set_advert_name(&desired_advert_name).await {
|
||||
warn!("Failed to set advert name: {}", e);
|
||||
} else {
|
||||
// Reflect the post-set name in MeshStatus too so the UI can filter
|
||||
@@ -1196,7 +1224,7 @@ pub(super) async fn run_mesh_session(
|
||||
// still carries whatever pre-set name the firmware reported and the
|
||||
// self-filter never matches.
|
||||
let mut status = state.status.write().await;
|
||||
status.self_advert_name = Some(advert_name.clone());
|
||||
status.self_advert_name = Some(desired_advert_name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1489,6 +1517,15 @@ async fn handle_send_command(
|
||||
} else {
|
||||
*consecutive_write_failures = 0;
|
||||
}
|
||||
// The self-advert alone is a no-op for discovery on Meshtastic
|
||||
// (heartbeat + time carry no identity) — the NodeInfo broadcast
|
||||
// is what makes peers learn/refresh us. want_response=true so
|
||||
// neighbours answer with their own NodeInfo: the user pressed
|
||||
// Broadcast to be seen AND to see who's out there. No-op on
|
||||
// Meshcore/Reticulum, whose self-advert already carries identity.
|
||||
if let Err(e) = device.send_nodeinfo_advert(true).await {
|
||||
warn!("Failed to send NodeInfo advert: {}", e);
|
||||
}
|
||||
}
|
||||
MeshCommand::RebootRadio { seconds } => {
|
||||
if let Err(e) = device.reboot(seconds).await {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod alerts;
|
||||
pub mod bitcoin_relay;
|
||||
pub mod crypto;
|
||||
pub mod flash;
|
||||
pub mod listener;
|
||||
pub mod meshtastic;
|
||||
pub mod message_types;
|
||||
@@ -477,6 +478,31 @@ impl Default for MeshConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a mesh config file has ever been written for this node — lets the
|
||||
/// boot path distinguish "operator explicitly disabled mesh" (file exists,
|
||||
/// enabled=false) from "never configured" (no file), which is the only case
|
||||
/// radio auto-enable should touch.
|
||||
pub fn config_file_exists(data_dir: &Path) -> bool {
|
||||
data_dir.join(MESH_CONFIG_FILE).exists()
|
||||
}
|
||||
|
||||
/// True when `new` differs from `old` in any field a running mesh session
|
||||
/// captured by value at spawn (device path/kind, advert name, region, PHY
|
||||
/// params, channel, manage_radio, TCP interface) — i.e. when applying `new`
|
||||
/// to a live service requires a listener restart. Fields the session reads
|
||||
/// live from shared state (broadcast flags, assistant settings, steganography
|
||||
/// mode, …) deliberately don't trigger a restart.
|
||||
fn session_config_changed(old: &MeshConfig, new: &MeshConfig) -> bool {
|
||||
old.device_path != new.device_path
|
||||
|| old.device_kind != new.device_kind
|
||||
|| old.advert_name != new.advert_name
|
||||
|| old.lora_region != new.lora_region
|
||||
|| old.lora_radio_params != new.lora_radio_params
|
||||
|| old.channel_name != new.channel_name
|
||||
|| old.manage_radio != new.manage_radio
|
||||
|| old.reticulum_tcp != new.reticulum_tcp
|
||||
}
|
||||
|
||||
pub async fn load_config(data_dir: &Path) -> Result<MeshConfig> {
|
||||
let path = data_dir.join(MESH_CONFIG_FILE);
|
||||
if !path.exists() {
|
||||
@@ -742,10 +768,18 @@ impl MeshService {
|
||||
self.server_name = name;
|
||||
}
|
||||
|
||||
/// Start the background mesh listener.
|
||||
/// Start the background mesh listener. Idempotent: if the listener is
|
||||
/// already running, this is a harmless no-op rather than an error —
|
||||
/// confirmed live 2026-07-23, a real race between the flash job's own
|
||||
/// post-flash restart and a concurrent user "Keep As Is" click (both
|
||||
/// legitimately trying to ensure the listener is running) surfaced this
|
||||
/// as a user-facing "Mesh listener already running" RPC error. Ensuring
|
||||
/// the listener is running is the intent every caller actually has;
|
||||
/// whichever caller's start() happens to win the race, the other
|
||||
/// finding it already satisfied is success, not failure.
|
||||
pub fn start(&mut self) -> Result<()> {
|
||||
if self.listener_handle.is_some() {
|
||||
anyhow::bail!("Mesh listener already running");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
@@ -764,7 +798,11 @@ impl MeshService {
|
||||
self.our_ed_pubkey_hex.clone(),
|
||||
self.our_x25519_secret,
|
||||
self.our_x25519_pubkey_hex.clone(),
|
||||
self.server_name.clone(),
|
||||
// The mesh-page "Name on the mesh" (config.advert_name) wins over
|
||||
// the server name — it existed as write-only config with no reader
|
||||
// until this line, which is why renaming on the Mesh page never
|
||||
// changed anything on the air.
|
||||
self.config.advert_name.clone().or_else(|| self.server_name.clone()),
|
||||
self.config.lora_region.clone(),
|
||||
self.config.lora_radio_params,
|
||||
self.config.channel_name.clone(),
|
||||
@@ -1055,13 +1093,12 @@ impl MeshService {
|
||||
self.state.peers.read().await.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
||||
/// it — powers the hot-swap "device detected" modal's current-details
|
||||
/// view. Refuses to probe the port the live session currently occupies
|
||||
/// (the probe would steal the serial port from under the session); a
|
||||
/// Refuse to probe the port the live session currently occupies (the
|
||||
/// probe would steal the serial port from under the session); a
|
||||
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||
/// with the reconnect loop (whichever loses just retries).
|
||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
||||
/// with the reconnect loop (whichever loses just retries). Split out from
|
||||
/// the actual probe on purpose — see `probe_device`'s doc comment.
|
||||
pub async fn ensure_probe_allowed(&self, path: &str) -> Result<()> {
|
||||
let status = self.state.status.read().await;
|
||||
if status.device_connected {
|
||||
if let Some(active) = status.device_path.as_deref() {
|
||||
@@ -1076,8 +1113,7 @@ impl MeshService {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(status);
|
||||
listener::probe_device(path).await
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get message history.
|
||||
@@ -1124,7 +1160,32 @@ impl MeshService {
|
||||
let peer = peers
|
||||
.get(&contact_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
|
||||
let pubkey_hex = peer
|
||||
// Cross-transport twin resolution: callers frequently hold the
|
||||
// FEDERATION twin's contact_id (the UI's merged conversation row),
|
||||
// whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
|
||||
// routing key. Sending a Reticulum resource with that prefix fails
|
||||
// with "Unknown Reticulum prefix" (observed live 2026-07-28,
|
||||
// image-over-LoRa to a merged contact). Route via the radio twin —
|
||||
// same arch identity, radio-range id — whose pubkey_hex is the
|
||||
// actual over-the-air routing key (RNS dest hash / firmware key).
|
||||
let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
|
||||
peer.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.and_then(|arch| {
|
||||
peers.values().find(|p| {
|
||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||
&& p.arch_pubkey_hex.as_deref() == Some(arch)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Peer is federation-only (no radio twin) — not reachable over the radio"
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
peer
|
||||
};
|
||||
let pubkey_hex = radio_peer
|
||||
.pubkey_hex
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
|
||||
@@ -1262,12 +1323,44 @@ impl MeshService {
|
||||
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
// Transport policy: LoRa first when it can actually carry the message,
|
||||
// then FIPS, then Tor. A federation-synthetic id (what the UI's merged
|
||||
// conversation holds) used to ALWAYS take the federation path, even
|
||||
// when the very same node was sitting one LoRa hop away — so chats
|
||||
// between two radio-equipped nodes silently rode FIPS/Tor. If the
|
||||
// federation contact has a REACHABLE radio twin (same archipelago
|
||||
// identity, radio-range id) and the payload fits the radio, skip the
|
||||
// federation branch: the fall-through LoRa path twin-resolves the
|
||||
// routing key via peer_dest_prefix.
|
||||
let device_connected = self.state.status.read().await.device_connected;
|
||||
let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && {
|
||||
let peers = self.state.peers.read().await;
|
||||
peers
|
||||
.get(&contact_id)
|
||||
.and_then(|p| p.arch_pubkey_hex.clone())
|
||||
.map(|arch| {
|
||||
peers.values().any(|p| {
|
||||
p.contact_id < FEDERATION_CONTACT_ID_BASE
|
||||
&& p.reachable
|
||||
&& p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let mesh_only_mode = load_config(&self.data_dir)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|cfg| cfg.mesh_only_mode)
|
||||
.unwrap_or(false);
|
||||
if radio_twin_reachable && !mesh_only_mode {
|
||||
tracing::info!(
|
||||
contact_id,
|
||||
bytes = wire.len(),
|
||||
"Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
|
||||
);
|
||||
}
|
||||
if !mesh_only_mode
|
||||
&& !radio_twin_reachable
|
||||
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
|
||||
{
|
||||
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
|
||||
@@ -2091,6 +2184,7 @@ impl MeshService {
|
||||
save_config(&self.data_dir, &config).await?;
|
||||
|
||||
let was_enabled = self.config.enabled;
|
||||
let needs_session_restart = session_config_changed(&self.config, &config);
|
||||
self.config = config.clone();
|
||||
|
||||
// Update the status to reflect new config
|
||||
@@ -2115,11 +2209,31 @@ impl MeshService {
|
||||
status.firmware_version = None;
|
||||
status.self_node_id = None;
|
||||
status.peer_count = 0;
|
||||
} else if config.enabled && was_enabled && needs_session_restart {
|
||||
info!("Mesh session config changed — restarting listener to apply");
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The service's current (last-applied) config.
|
||||
pub fn config(&self) -> &MeshConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Restart the listener (if running) so it picks up out-of-band state a
|
||||
/// spawn captured by value — currently the server name pushed by
|
||||
/// `server.set-name`.
|
||||
pub async fn restart_listener_if_running(&mut self) -> Result<()> {
|
||||
if self.listener_handle.is_some() {
|
||||
self.stop().await;
|
||||
self.start()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a reference to shared state (for RPC handlers).
|
||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||
Arc::clone(&self.state)
|
||||
@@ -2241,6 +2355,46 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn session_config_change_detection() {
|
||||
let base = MeshConfig::default();
|
||||
|
||||
// Same config → no restart.
|
||||
assert!(!session_config_changed(&base, &base.clone()));
|
||||
|
||||
// Every session-captured field individually triggers a restart.
|
||||
let mut c = base.clone();
|
||||
c.device_kind = Some(types::DeviceType::Reticulum);
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.device_path = Some("/dev/ttyUSB0".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.advert_name = Some("RNode Shaza".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.manage_radio = !base.manage_radio;
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.lora_region = Some("EU_868".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
let mut c = base.clone();
|
||||
c.channel_name = Some("private-net".into());
|
||||
assert!(session_config_changed(&base, &c));
|
||||
|
||||
// Live-read fields must NOT force a session restart.
|
||||
let mut c = base.clone();
|
||||
c.broadcast_identity = !base.broadcast_identity;
|
||||
c.announce_block_headers = !base.announce_block_headers;
|
||||
c.assistant_enabled = !base.assistant_enabled;
|
||||
assert!(!session_config_changed(&base, &c));
|
||||
}
|
||||
|
||||
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id,
|
||||
|
||||
@@ -121,6 +121,7 @@ fn daemon_command(
|
||||
identity_key: &Path,
|
||||
archy_ed_pubkey_hex: Option<&str>,
|
||||
archy_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Command {
|
||||
let bin = std::env::var("ARCHY_RETICULUM_DAEMON_BIN")
|
||||
.unwrap_or_else(|_| "/usr/local/bin/archy-reticulum-daemon".to_string());
|
||||
@@ -159,6 +160,15 @@ fn daemon_command(
|
||||
.arg("--archy-x25519-pubkey-hex")
|
||||
.arg(x);
|
||||
}
|
||||
// The RNS-visible display name (what Sideband/NomadNet/other archy nodes
|
||||
// show for us). Without this the daemon falls back to its argparse
|
||||
// default and every archy node announces the same anonymous name.
|
||||
if let Some(name) = display_name {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() {
|
||||
cmd.arg("--display-name").arg(name);
|
||||
}
|
||||
}
|
||||
// Run the daemon as its own process-group leader. The packaged binary is
|
||||
// a PyInstaller one-file bootloader that forks the real Python process;
|
||||
// making it a group leader lets shutdown signal the WHOLE group so the
|
||||
@@ -207,6 +217,10 @@ struct ReticulumPeer {
|
||||
/// `bind_federation_twins`, which those two transports rely on instead).
|
||||
arch_pubkey_hex: Option<String>,
|
||||
reachable: bool,
|
||||
/// Unix time of the last announce heard from this peer over the air.
|
||||
/// In-memory only (a persisted value would be stale by definition) —
|
||||
/// `0` after a restart until the peer re-announces.
|
||||
last_advert_at: u64,
|
||||
}
|
||||
|
||||
/// On-disk shape of `ReticulumPeer` — `[u8; 16]` can't be a JSON object key,
|
||||
@@ -245,6 +259,11 @@ pub struct ReticulumLink {
|
||||
/// 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,
|
||||
/// Set when the daemon's RPC socket closes or its process exits. Once
|
||||
/// true, `try_recv_frame` returns an error so the session loop tears
|
||||
/// down and the outer reconnect loop respawns the daemon — without this
|
||||
/// a dead daemon was invisible until the 30-minute RX-stall watchdog.
|
||||
daemon_gone: bool,
|
||||
}
|
||||
|
||||
impl ReticulumLink {
|
||||
@@ -269,6 +288,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
probe_rnode(path)
|
||||
.await
|
||||
@@ -278,6 +298,7 @@ impl ReticulumLink {
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -290,6 +311,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
let host = bind.rsplit_once(':').map(|(h, _)| h).unwrap_or(bind);
|
||||
anyhow::ensure!(
|
||||
@@ -302,6 +324,7 @@ impl ReticulumLink {
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -313,6 +336,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
anyhow::ensure!(
|
||||
!targets.is_empty(),
|
||||
@@ -323,6 +347,7 @@ impl ReticulumLink {
|
||||
data_dir,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -332,6 +357,7 @@ impl ReticulumLink {
|
||||
data_dir: &Path,
|
||||
our_ed_pubkey_hex: Option<&str>,
|
||||
our_x25519_pubkey_hex: Option<&str>,
|
||||
display_name: 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
|
||||
@@ -379,6 +405,7 @@ impl ReticulumLink {
|
||||
&identity_key,
|
||||
our_ed_pubkey_hex,
|
||||
our_x25519_pubkey_hex,
|
||||
display_name,
|
||||
);
|
||||
cmd.env("TMPDIR", &tmp_dir);
|
||||
let child = cmd
|
||||
@@ -450,6 +477,7 @@ impl ReticulumLink {
|
||||
peers_file: runtime_dir.join("peers.json"),
|
||||
inbound: std::collections::VecDeque::new(),
|
||||
resource_id_counter: 0,
|
||||
daemon_gone: false,
|
||||
};
|
||||
link.load_persisted_peers();
|
||||
Ok(link)
|
||||
@@ -472,15 +500,25 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, hash);
|
||||
// Heal names persisted by pre-2026-07-28 builds, which could
|
||||
// store a raw `ARCHY:…` identity blob as the display name (seen
|
||||
// live on archi-dev-box). Blob-only announces assert no name, so
|
||||
// nothing would ever overwrite it — swap in the placeholder.
|
||||
let display_name = if p.display_name.starts_with("ARCHY:") {
|
||||
format!("Reticulum {}", hex::encode(&hash[..4]))
|
||||
} else {
|
||||
p.display_name
|
||||
};
|
||||
self.peers.insert(
|
||||
hash,
|
||||
ReticulumPeer {
|
||||
dest_hash: hash,
|
||||
display_name: p.display_name,
|
||||
display_name,
|
||||
arch_pubkey_hex: p.arch_pubkey_hex,
|
||||
// Reachability is a live property, not a persisted fact —
|
||||
// start conservative and let the first real event refresh it.
|
||||
reachable: false,
|
||||
last_advert_at: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -533,10 +571,12 @@ impl ReticulumLink {
|
||||
}
|
||||
|
||||
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.
|
||||
// Live rename: the daemon's `set_name` verb updates the LXMF delivery
|
||||
// destination's display_name and re-announces, so peers pick the new
|
||||
// name up on their next announce receipt. Also tracked locally so
|
||||
// `advert_name()` reflects it immediately.
|
||||
self.send_rpc(serde_json::json!({"cmd": "set_name", "name": name}))
|
||||
.await?;
|
||||
self.display_name = Some(name.to_string());
|
||||
Ok(())
|
||||
}
|
||||
@@ -685,7 +725,7 @@ impl ReticulumLink {
|
||||
.map(|p| ParsedContact {
|
||||
public_key_hex: hex::encode(p.dest_hash),
|
||||
advert_name: p.display_name.clone(),
|
||||
last_advert: 0,
|
||||
last_advert: p.last_advert_at as u32,
|
||||
// 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
|
||||
@@ -718,6 +758,12 @@ impl ReticulumLink {
|
||||
|
||||
pub async fn try_recv_frame(&mut self) -> Result<Option<InboundFrame>> {
|
||||
self.drain_events().await;
|
||||
if self.daemon_gone {
|
||||
// Surface the dead daemon as a hard error so run_mesh_session
|
||||
// bails and the outer reconnect loop respawns it, instead of
|
||||
// idling on an empty queue until the RX-stall watchdog fires.
|
||||
anyhow::bail!("reticulum-daemon is gone (process exited or RPC socket closed)");
|
||||
}
|
||||
Ok(self.inbound.pop_front())
|
||||
}
|
||||
|
||||
@@ -743,6 +789,15 @@ impl ReticulumLink {
|
||||
/// Drain any buffered daemon events (non-blocking) and translate them into
|
||||
/// peer-table updates / synthetic InboundFrames.
|
||||
async fn drain_events(&mut self) {
|
||||
// A daemon that died without closing the socket cleanly (SIGKILL,
|
||||
// OOM) leaves the socket readable-with-EOF or just silent — poll the
|
||||
// child's exit status too so death is never mistaken for quiet.
|
||||
if !self.daemon_gone {
|
||||
if let Ok(Some(status)) = self.child.try_wait() {
|
||||
warn!(%status, "reticulum-daemon process exited");
|
||||
self.daemon_gone = true;
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let read =
|
||||
@@ -750,10 +805,16 @@ impl ReticulumLink {
|
||||
.await;
|
||||
let n = match read {
|
||||
Ok(Ok(n)) => n,
|
||||
_ => break, // timeout (no data) or read error — stop draining
|
||||
Ok(Err(e)) => {
|
||||
warn!("Reticulum daemon RPC read failed: {}", e);
|
||||
self.daemon_gone = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => break, // timeout — no data buffered
|
||||
};
|
||||
if n == 0 {
|
||||
warn!("Reticulum daemon RPC connection closed");
|
||||
self.daemon_gone = true;
|
||||
break;
|
||||
}
|
||||
let Ok(ev) = serde_json::from_str::<Value>(line.trim()) else {
|
||||
@@ -775,6 +836,23 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, hash);
|
||||
// Current daemons decode the LXMF announce app_data themselves
|
||||
// and hand us clean fields: `display_name` (LXMF-standard
|
||||
// msgpack name, Sideband-interoperable) and `archy_blob` (the
|
||||
// `ARCHY:n:` identity string, carried as an extra msgpack list
|
||||
// element stock clients ignore). The raw `app_data` text path
|
||||
// below remains for announces from pre-upgrade archy nodes,
|
||||
// whose app_data was EITHER the blob OR a bare-utf8 name.
|
||||
let explicit_name = ev
|
||||
.get("display_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let explicit_blob = ev
|
||||
.get("archy_blob")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.filter(|s| !s.is_empty());
|
||||
let app_data_text = ev
|
||||
.get("app_data")
|
||||
.and_then(Value::as_str)
|
||||
@@ -794,12 +872,17 @@ impl ReticulumLink {
|
||||
// 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
|
||||
let legacy_identity = app_data_text
|
||||
.as_deref()
|
||||
.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 is_legacy_blob = legacy_identity.is_some();
|
||||
let identity_blob_text = explicit_blob.or_else(|| {
|
||||
app_data_text.clone().filter(|_| is_legacy_blob)
|
||||
});
|
||||
let parsed_identity = identity_blob_text
|
||||
.as_deref()
|
||||
.and_then(protocol::parse_identity_broadcast);
|
||||
if let Some(text) = identity_blob_text.as_deref().filter(|_| parsed_identity.is_some()) {
|
||||
let mut data = Vec::with_capacity(7 + text.len());
|
||||
data.push(0); // channel index — unused by the identity path
|
||||
data.extend_from_slice(&prefix);
|
||||
@@ -812,23 +895,31 @@ impl ReticulumLink {
|
||||
}
|
||||
let arch_pubkey_hex = parsed_identity.map(|(_did, ed_pubkey, _x25519)| ed_pubkey);
|
||||
|
||||
let display_name = app_data_text
|
||||
.filter(|_| !is_identity_blob)
|
||||
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4])));
|
||||
let announced_name =
|
||||
pick_announced_name(explicit_name, app_data_text, is_legacy_blob);
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
self.peers
|
||||
.entry(hash)
|
||||
.and_modify(|p| {
|
||||
p.display_name = display_name.clone();
|
||||
if let Some(name) = announced_name.clone() {
|
||||
p.display_name = name;
|
||||
}
|
||||
p.reachable = true;
|
||||
p.last_advert_at = now;
|
||||
if arch_pubkey_hex.is_some() {
|
||||
p.arch_pubkey_hex = arch_pubkey_hex.clone();
|
||||
}
|
||||
})
|
||||
.or_insert(ReticulumPeer {
|
||||
.or_insert_with(|| ReticulumPeer {
|
||||
dest_hash: hash,
|
||||
display_name,
|
||||
display_name: announced_name
|
||||
.unwrap_or_else(|| format!("Reticulum {}", hex::encode(&hash[..4]))),
|
||||
arch_pubkey_hex,
|
||||
reachable: true,
|
||||
last_advert_at: now,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
@@ -844,16 +935,24 @@ impl ReticulumLink {
|
||||
// A peer that messages us without ever announcing still needs
|
||||
// to survive a restart — give it a placeholder name (the real
|
||||
// one, if any, arrives via a later "announce" and overwrites
|
||||
// this) so its routing entry alone doesn't get lost.
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||
{
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
});
|
||||
self.persist_peers();
|
||||
// this) so its routing entry alone doesn't get lost. An
|
||||
// existing entry is proof of life too: mark it reachable so a
|
||||
// restart-restored (reachable=false) peer that DMs us doesn't
|
||||
// stay red-dotted until its next announce.
|
||||
match self.peers.entry(source_hash) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
last_advert_at: 0,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.get_mut().reachable = true;
|
||||
}
|
||||
}
|
||||
|
||||
// A stock LXMF client (Sideband/NomadNet — not an archy peer)
|
||||
@@ -932,15 +1031,20 @@ impl ReticulumLink {
|
||||
};
|
||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||
self.prefix_to_hash.insert(prefix, source_hash);
|
||||
if let std::collections::hash_map::Entry::Vacant(e) = self.peers.entry(source_hash)
|
||||
{
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
});
|
||||
self.persist_peers();
|
||||
match self.peers.entry(source_hash) {
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(ReticulumPeer {
|
||||
dest_hash: source_hash,
|
||||
display_name: format!("Reticulum {}", hex::encode(&source_hash[..4])),
|
||||
arch_pubkey_hex: None,
|
||||
reachable: true,
|
||||
last_advert_at: 0,
|
||||
});
|
||||
self.persist_peers();
|
||||
}
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.get_mut().reachable = true;
|
||||
}
|
||||
}
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let Some(data) = ev
|
||||
@@ -1089,8 +1193,10 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
||||
// 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.
|
||||
// before writing the probe. Boards behind a USB-UART bridge (CP2102 on
|
||||
// the Heltec V3) get the reset pulse from the open() itself, before we
|
||||
// can deassert anything — that case is handled by the boot-settle retry
|
||||
// below.
|
||||
let _ = port.set_dtr(false);
|
||||
let _ = port.set_rts(false);
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
@@ -1109,26 +1215,81 @@ pub(crate) async fn probe_rnode(path: &str) -> Result<()> {
|
||||
0x00,
|
||||
KISS_FEND,
|
||||
];
|
||||
// Attempt 1: probe immediately. A board that did NOT reset on open (it
|
||||
// was already up — e.g. a re-probe of a running RNode) answers in well
|
||||
// under a second, so the fast path stays fast.
|
||||
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 {
|
||||
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No DETECT_RESP. If the open() power-cycled the board (verified live on
|
||||
// a Heltec V3 RNode behind a CP2102: the ESP32 spends ~2.5-3s in boot
|
||||
// ROM + app init and silently eats anything written meanwhile, so the
|
||||
// first probe lands in the void), wait for its boot chatter to go quiet
|
||||
// and probe once more with a fresh response window.
|
||||
const BOOT_QUIET_WINDOW: Duration = Duration::from_millis(800);
|
||||
const BOOT_SETTLE_MAX: Duration = Duration::from_secs(6);
|
||||
let settle_deadline = tokio::time::Instant::now() + BOOT_SETTLE_MAX;
|
||||
let mut last_data = tokio::time::Instant::now();
|
||||
while tokio::time::Instant::now() < settle_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]);
|
||||
// A late DETECT_RESP to the first write still counts.
|
||||
if contains_detect_resp(&seen) {
|
||||
return Ok(());
|
||||
}
|
||||
last_data = tokio::time::Instant::now();
|
||||
}
|
||||
_ => {
|
||||
if last_data.elapsed() >= BOOT_QUIET_WINDOW {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::timeout(Duration::from_millis(500), port.write_all(&probe))
|
||||
.await
|
||||
.context("RNode probe rewrite timed out")?
|
||||
.context("RNode probe rewrite failed")?;
|
||||
seen.clear();
|
||||
if await_detect_resp(&port, &mut buf, &mut seen, PROBE_READ_TIMEOUT).await {
|
||||
return Ok(());
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No RNode DETECT_RESP within {:?} (incl. post-boot-settle retry)",
|
||||
PROBE_READ_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
/// Read from `port` for up to `window`, accumulating into `seen`; true once
|
||||
/// the KISS DETECT_RESP sequence shows up anywhere in the stream.
|
||||
async fn await_detect_resp(
|
||||
port: &serial2_tokio::SerialPort,
|
||||
buf: &mut [u8],
|
||||
seen: &mut Vec<u8>,
|
||||
window: Duration,
|
||||
) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + window;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(150), port.read(buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => {
|
||||
seen.extend_from_slice(&buf[..n]);
|
||||
if contains_detect_resp(seen) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
anyhow::bail!("No RNode DETECT_RESP within {:?}", PROBE_READ_TIMEOUT)
|
||||
false
|
||||
}
|
||||
|
||||
/// Look for the `[FEND, CMD_DETECT, DETECT_RESP]` sequence anywhere in the
|
||||
@@ -1138,6 +1299,33 @@ fn contains_detect_resp(buf: &[u8]) -> bool {
|
||||
.any(|w| w == [KISS_FEND, KISS_CMD_DETECT, KISS_DETECT_RESP])
|
||||
}
|
||||
|
||||
/// The display name an announce actually asserted, if any.
|
||||
///
|
||||
/// Precedence: the daemon-decoded LXMF display name (`display_name` event
|
||||
/// field), then — legacy peers only — bare-utf8 app_data that wasn't an
|
||||
/// identity blob. The bare-utf8 fallback must actually look like text:
|
||||
/// lossy-decoded msgpack (a new-format announce whose name the daemon failed
|
||||
/// to decode) is full of U+FFFD/control chars and would otherwise become a
|
||||
/// mojibake display name. `None` (e.g. a blob-only legacy announce) means
|
||||
/// "no name asserted" and must NOT stomp a previously-learned name.
|
||||
fn pick_announced_name(
|
||||
explicit_name: Option<String>,
|
||||
app_data_text: Option<String>,
|
||||
is_legacy_blob: bool,
|
||||
) -> Option<String> {
|
||||
explicit_name
|
||||
// A legacy blob-only announce utf8-decodes cleanly, so LXMF's
|
||||
// display_name_from_app_data hands the daemon the ENTIRE `ARCHY:…`
|
||||
// string as a "name" — seen live from a pre-upgrade Framework PT.
|
||||
// An identity blob is never a display name.
|
||||
.filter(|s| !s.starts_with("ARCHY:"))
|
||||
.or_else(|| {
|
||||
app_data_text
|
||||
.filter(|_| !is_legacy_blob)
|
||||
.filter(|s| !s.chars().any(|c| c.is_control() || c == '\u{FFFD}'))
|
||||
})
|
||||
}
|
||||
|
||||
impl Drop for ReticulumLink {
|
||||
fn drop(&mut self) {
|
||||
// Group-wide SIGTERM with a delayed SIGKILL backstop (`terminate_group`).
|
||||
@@ -1154,6 +1342,37 @@ impl Drop for ReticulumLink {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn announced_name_precedence() {
|
||||
// Daemon-decoded LXMF name always wins.
|
||||
assert_eq!(
|
||||
pick_announced_name(
|
||||
Some("RNode Shaza".into()),
|
||||
Some("ARCHY:2:aa:bb".into()),
|
||||
true
|
||||
),
|
||||
Some("RNode Shaza".to_string())
|
||||
);
|
||||
// Legacy bare-utf8 name (old daemon, no explicit field).
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("zaza".into()), false),
|
||||
Some("zaza".to_string())
|
||||
);
|
||||
// Legacy blob-only announce asserts NO name (must not stomp).
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("ARCHY:2:aa:bb".into()), true),
|
||||
None
|
||||
);
|
||||
// Lossy-decoded msgpack must not become a mojibake name.
|
||||
assert_eq!(
|
||||
pick_announced_name(None, Some("\u{FFFD}\u{FFFD}Shaza\u{FFFD}".into()), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(pick_announced_name(None, Some("has\u{1}ctl".into()), false), None);
|
||||
// Nothing at all.
|
||||
assert_eq!(pick_announced_name(None, None, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_resp_found_in_kiss_stream() {
|
||||
let stream = [
|
||||
|
||||
@@ -557,29 +557,39 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
||||
/// Scan for serial devices that could be Meshcore radios.
|
||||
/// Returns paths to existing serial device files.
|
||||
///
|
||||
/// Candidates are deduplicated by canonical path: /dev/mesh-radio is a udev
|
||||
/// symlink to a ttyUSB*/ttyACM* node that is ALSO in the candidate list, so
|
||||
/// without this one physical board shows up (and gets probed, and gets its
|
||||
/// DTR/RTS reset toggled) twice per cycle. The first candidate wins, which
|
||||
/// keeps the stable /dev/mesh-radio name when the symlink exists.
|
||||
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
|
||||
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
|
||||
/// primary radio currently enumerates as, so both names always pointed at
|
||||
/// the same candidate list entry and both passed this scan — confirmed live
|
||||
/// 2026-07-23, this made an already-connected, working radio (connected via
|
||||
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
|
||||
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
|
||||
/// hot-swap UI's active-session guard compares path strings, so it didn't
|
||||
/// recognize the two aliases as the same port, showed the "device detected"
|
||||
/// modal for a radio that was already set up, and probing it there opened
|
||||
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
|
||||
/// with — a continuous, UI-driven reset loop that only ran while that view
|
||||
/// was open (matches the reported "stops when I leave, resumes when I come
|
||||
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
|
||||
/// dedup and is what's reported when both alias and target are present.
|
||||
/// (Independently re-discovered and fixed on main 2026-07-26 — both sides
|
||||
/// of the 2026-07-28 merge carried an equivalent implementation.)
|
||||
pub async fn detect_serial_devices() -> Vec<String> {
|
||||
let mut devices = Vec::new();
|
||||
let mut seen_canonical: Vec<std::path::PathBuf> = Vec::new();
|
||||
let mut seen_real_paths = std::collections::HashSet::new();
|
||||
for path in SERIAL_CANDIDATES {
|
||||
if tokio::fs::metadata(path).await.is_ok() {
|
||||
if likely_non_mesh_serial_device(path) {
|
||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||
continue;
|
||||
}
|
||||
let canonical = tokio::fs::canonicalize(path)
|
||||
let real_path = tokio::fs::canonicalize(path)
|
||||
.await
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(path));
|
||||
if seen_canonical.contains(&canonical) {
|
||||
debug!(path = %path, canonical = %canonical.display(),
|
||||
"Skipping alias of an already-detected serial device");
|
||||
if !seen_real_paths.insert(real_path.clone()) {
|
||||
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
|
||||
continue;
|
||||
}
|
||||
seen_canonical.push(canonical);
|
||||
devices.push(path.to_string());
|
||||
}
|
||||
}
|
||||
@@ -610,12 +620,23 @@ pub async fn detect_serial_devices_info() -> Vec<DetectedDeviceInfo> {
|
||||
let mut out = Vec::new();
|
||||
for path in detect_serial_devices().await {
|
||||
let usb = usb_info_for_tty(&path).await;
|
||||
let plugged_at = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs());
|
||||
// Birth time (btime), falling back to inode-change time (ctime) —
|
||||
// NOT mtime: a tty node's mtime bumps on every open()/write, so with
|
||||
// mtime here each probe/session open minted a "new" plugged_at, the
|
||||
// UI's (path, plugged_at) dismissal key never matched again, and the
|
||||
// setup modal re-fired forever on a device that never left the port
|
||||
// (observed live on archi-dev-box 2026-07-28). btime/ctime only
|
||||
// change when udev (re)creates/chowns the node — i.e. on real plugs.
|
||||
let plugged_at = tokio::fs::metadata(&path).await.ok().and_then(|m| {
|
||||
m.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.or_else(|| {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
u64::try_from(m.ctime()).ok()
|
||||
})
|
||||
});
|
||||
out.push(DetectedDeviceInfo {
|
||||
path,
|
||||
vid: usb.0,
|
||||
|
||||
@@ -294,8 +294,13 @@ impl Server {
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
// Auto-enable mesh if a radio is detected and no config exists yet
|
||||
if !mesh_config.enabled {
|
||||
// Auto-enable mesh if a radio is detected and no config exists
|
||||
// yet. Only on a genuinely missing config: an existing file
|
||||
// with enabled=false is an explicit operator decision (e.g.
|
||||
// via mesh.configure) and force-re-enabling it on every boot
|
||||
// made "disable mesh" impossible on any node with a radio
|
||||
// plugged in.
|
||||
if !mesh_config.enabled && !crate::mesh::config_file_exists(&data_dir) {
|
||||
let devices = crate::mesh::detect_devices().await;
|
||||
if !devices.is_empty() {
|
||||
info!("📡 Auto-detected mesh radio: {:?} — enabling mesh", devices);
|
||||
@@ -1322,11 +1327,46 @@ async fn accept_loop(
|
||||
}
|
||||
};
|
||||
let handler = handler.clone();
|
||||
let permit = active_connections.clone().acquire_owned().await;
|
||||
// NEVER park the accept loop on the connection budget.
|
||||
// `acquire_owned().await` here froze accept() entirely when
|
||||
// permits drained — and permits drained because half-open
|
||||
// clients and hung upstreams held them forever (the .228
|
||||
// session-flapping / CLOSE-WAIT `inode: 0` signature). Shed
|
||||
// load instead: accept, answer 503, close.
|
||||
let permit = match active_connections.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"{} connection budget exhausted — shedding {}",
|
||||
local_addr, peer_addr
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stream = stream;
|
||||
let _ = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
stream.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
|
||||
),
|
||||
)
|
||||
.await;
|
||||
let _ = stream.shutdown().await;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
// Set when a request carries an Upgrade header (websocket):
|
||||
// upgraded connections are legitimately long-lived and are
|
||||
// exempt from the non-upgraded connection deadline below.
|
||||
let upgraded = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let upgraded_flag = upgraded.clone();
|
||||
let service = service_fn(move |mut req: hyper::Request<hyper::Body>| {
|
||||
let handler = handler.clone();
|
||||
if req.headers().contains_key(hyper::header::UPGRADE) {
|
||||
upgraded_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
async move {
|
||||
// Record the TCP peer so rate limiting only trusts
|
||||
// forwarded headers on loopback (nginx) connections.
|
||||
@@ -1345,13 +1385,48 @@ async fn accept_loop(
|
||||
.map_err(|e| std::io::Error::other(format!("{}", e)))
|
||||
}
|
||||
});
|
||||
if let Err(e) = Http::new()
|
||||
// header_read_timeout: a client that connects and never
|
||||
// sends a request (slowloris / half-open) is dropped
|
||||
// instead of holding a permit until the heat death of the
|
||||
// node. Long RPCs are safe — the clock only covers header
|
||||
// read.
|
||||
let conn = Http::new()
|
||||
.http1_keep_alive(false)
|
||||
.http1_header_read_timeout(std::time::Duration::from_secs(30))
|
||||
.serve_connection(stream, service)
|
||||
.with_upgrades()
|
||||
.await
|
||||
{
|
||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||
.with_upgrades();
|
||||
tokio::pin!(conn);
|
||||
// Deadline watchdog for NON-upgraded connections. With
|
||||
// keep-alive off a plain connection serves one exchange;
|
||||
// 15 min bounds even the slowest legitimate RPC/stream
|
||||
// while guaranteeing a hung upstream can't hold a permit
|
||||
// forever. Upgraded (websocket) connections are exempt.
|
||||
const NON_UPGRADED_DEADLINE: std::time::Duration =
|
||||
std::time::Duration::from_secs(900);
|
||||
let started = std::time::Instant::now();
|
||||
let watchdog = async {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
if !upgraded.load(std::sync::atomic::Ordering::Relaxed)
|
||||
&& started.elapsed() >= NON_UPGRADED_DEADLINE
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
r = &mut conn => {
|
||||
if let Err(e) = r {
|
||||
error!("Error serving connection from {}: {}", peer_addr, e);
|
||||
}
|
||||
}
|
||||
_ = watchdog => {
|
||||
warn!(
|
||||
"connection from {} exceeded {}s without completing or upgrading — dropping",
|
||||
peer_addr,
|
||||
NON_UPGRADED_DEADLINE.as_secs()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,33 @@ Full plan: `.claude/plans/enchanted-strolling-rocket.md`. Memory pointer:
|
||||
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.
|
||||
|
||||
## Checkpoint 2026-07-28 — RNode connect + names FIXED, live-verified E2E (read this first)
|
||||
|
||||
The fleet reflash back to RNode firmware exposed a stack of bugs that made Reticulum
|
||||
unusable on CP2102-bridged boards (Heltec V3 etc.) and left every archy node nameless on
|
||||
RNS. All fixed in `a8c4694c` (backend) + `3f76b496` (UI), live-verified on archi-dev-box
|
||||
and archy-x250-dev with a real RNode-to-RNode LXMF message (`transport: "reticulum"` in
|
||||
mesh-messages) plus a cross-transport reply:
|
||||
|
||||
1. **probe_rnode boot race** — serial open pulses DTR/RTS via the USB-UART bridge → ESP32
|
||||
power-cycles → KISS DETECT written 300ms later is eaten during ~2.5-3s of boot. Fix:
|
||||
immediate probe (fast path) + drain-until-quiet boot settle + second DETECT window.
|
||||
2. **configure() was a no-op on a running listener** (only enable/disable restarted it) —
|
||||
the setup modal's apply/keep-as-is and every rename did nothing until process restart.
|
||||
3. **Name propagation** — `config.advert_name` had no reader; `server.set-name` never
|
||||
reached mesh; daemon display name fixed at spawn to the "Archy" default; the ARCHY:2
|
||||
announce blob REPLACED the LXMF name. Now: announces carry msgpack
|
||||
`[name, stamp_cost, sf, ARCHY-blob]` (Sideband-compatible, blob invisible to stock
|
||||
clients), daemon has a `set_name` verb, renames bounce the session live.
|
||||
4. **Daemon-death detection** (was invisible up to the 30-min RX-stall watchdog),
|
||||
**modal re-trigger loop** (plugged_at used tty mtime → bumps on every open; now
|
||||
btime/ctime), **ARCHY:2 federation-name clobber**, **mesh.refresh RPC** (Refresh button
|
||||
now actually re-queries the radio), **Meshtastic mesh.broadcast now sends NodeInfo**.
|
||||
|
||||
Still open here: legacy-format peers (old fleet builds) show as `Reticulum <hex4>` until
|
||||
they OTA; RNode RF params still daemon-hardcoded (EU-868 869.525/125k/SF8/CR5); Phase 4
|
||||
multi-radio; duty-cycle guard.
|
||||
|
||||
## Status at a glance
|
||||
|
||||
| Phase | What | Status |
|
||||
|
||||
@@ -110,6 +110,61 @@ lands].
|
||||
2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic).
|
||||
3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves.
|
||||
|
||||
## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E)
|
||||
|
||||
Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell
|
||||
tested on real hardware vs. code-reviewed only as this is run.
|
||||
|
||||
1. ❑ From the hot-swap modal's step 1 (device already probed), press
|
||||
**Flash Firmware…** → new step shows firmware-family + board pickers and
|
||||
the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until
|
||||
family, board, AND the checkbox are all set.
|
||||
2. ❑ Confirm what's currently on the test stick via the existing probe
|
||||
BEFORE flashing it — don't flash the only known-good device without a
|
||||
fallback board on hand.
|
||||
3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run;
|
||||
only exercise a primary/in-use stick once the flow is proven safe.
|
||||
4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log
|
||||
tail update live, ends at "Flash complete".
|
||||
5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from
|
||||
the esp32s3 release zip.
|
||||
6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path
|
||||
completes (no raw esptool erase/write step for this family — see
|
||||
`mesh/flash.rs` doc comment).
|
||||
7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware:
|
||||
V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid
|
||||
303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific)
|
||||
— so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable
|
||||
by vid:pid. Board auto-detect should fail closed for it every time
|
||||
(manual board selection required, "couldn't confirm automatically"
|
||||
warning shown) — this is expected steady-state behavior, not a gap to
|
||||
close later.
|
||||
8. ❑ After a successful flash, the modal automatically re-probes and shows
|
||||
the NEW firmware's badge/details — same as unplugging and replugging
|
||||
(Section E item 3), but without physically touching the cable.
|
||||
9. ❑ Deliberately test a failure path once (disconnect the board mid-write,
|
||||
or point at a bad cached asset) — confirm the error surfaces in the
|
||||
progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware
|
||||
flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/
|
||||
rnodeconf command) actually get the board back to a flashable state.
|
||||
10. ❑ Cancel button only appears (and only works) while still in the
|
||||
"Downloading firmware…" stage — once erasing/writing starts, no cancel
|
||||
affordance is offered.
|
||||
11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash
|
||||
(e.g. kill network access mid-download to force a failure), confirm the
|
||||
mesh listener does NOT auto-resume — `journalctl -u archipelago` should
|
||||
show a single `Leaving mesh listener stopped after failed flash` line
|
||||
and then go quiet for that device, not a repeating `mesh::serial:
|
||||
Opened serial port... Starting Meshcore handshake` cycle every few
|
||||
seconds. Reconnect manually via the hot-swap modal afterward and confirm
|
||||
it connects normally (the board itself should be untouched — the
|
||||
download fails before esptool/rnodeconf ever runs).
|
||||
12. ❑ Separately, force a device to flap connected/disconnected a few times
|
||||
in under 20s each (e.g. a marginal USB connection) and confirm
|
||||
`reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...)
|
||||
rather than resetting to 5s on every attempt — see
|
||||
`STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`.
|
||||
|
||||
---
|
||||
|
||||
After this passes: fold the batch + other agent's work into the next release
|
||||
|
||||
@@ -443,6 +443,86 @@ free -h
|
||||
- Check Nginx WebSocket proxy config: `/etc/nginx/sites-available/archipelago` must include `proxy_set_header Upgrade $http_upgrade`
|
||||
- If on WiFi, try wired Ethernet for more stable connectivity
|
||||
|
||||
### 21. LoRa radio firmware flash failed / board unresponsive
|
||||
|
||||
**Symptoms**: The "Erase & Flash Now" flow in the mesh hot-swap modal reports
|
||||
an error, or the radio no longer enumerates as a serial device after a flash
|
||||
attempt.
|
||||
|
||||
**Diagnosis**:
|
||||
```bash
|
||||
# Poll the flash job's last-known stage/error directly
|
||||
curl -s http://localhost:5678/rpc/v1 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"method":"mesh.flash-status","params":{}}'
|
||||
|
||||
# Confirm the board is still enumerating at all
|
||||
ls -la /dev/ttyUSB* /dev/ttyACM* /dev/mesh-radio 2>&1
|
||||
|
||||
# esptool/rnodeconf binaries present?
|
||||
which esptool; ls -la /usr/local/bin/archy-rnodeconf
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
- A failure during `erasing`/`writing` (MeshCore/Meshtastic) or
|
||||
`autoinstalling` (Reticulum) can leave the chip erased or half-written —
|
||||
this is expected risk of the "always erase first" default, not a bug.
|
||||
- Heltec V3/V4 boards can be forced back into bootloader mode manually: hold
|
||||
**BOOT**, tap **RST**, then release **BOOT** — this puts the chip in a
|
||||
state esptool can always talk to, regardless of what firmware (if any) is
|
||||
currently on it.
|
||||
- With the board in bootloader mode, a manual recovery flash can be run
|
||||
directly over SSH without the UI:
|
||||
```bash
|
||||
esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash
|
||||
esptool --chip esp32s3 --port /dev/ttyACM0 write_flash 0x0 <known-good-image.bin>
|
||||
```
|
||||
- For Reticulum/RNode boards, the equivalent manual recovery is
|
||||
`archy-rnodeconf /dev/ttyACM0 --autoinstall` (or `/usr/local/bin/archy-rnodeconf`
|
||||
if it's not on `PATH`) — it re-runs the same fetch+erase+flash+bootstrap
|
||||
sequence the UI triggers.
|
||||
- If `esptool`/`archy-rnodeconf` are missing entirely, they should have been
|
||||
installed by the last `self-update.sh` run — check
|
||||
`sudo journalctl -u archipelago-update` for install failures, or install
|
||||
`esptool` via `sudo apt-get install esptool` directly.
|
||||
- Once a fresh image is confirmed written, unplug/replug the radio (or wait
|
||||
for the next detection poll) — the hot-swap modal re-probes automatically
|
||||
and shows whatever firmware is actually on the board now.
|
||||
|
||||
**Known incident (2026-07-23) — reconnect storm / device boot-loop after a
|
||||
failed flash**: a real Heltec V3 got stuck cycling "connect → partial
|
||||
handshake → drop" every 5-15s for 5+ minutes after a `mesh.flash-device`
|
||||
attempt failed with `Reading firmware download stream`. Root cause was two
|
||||
compounding issues, both now fixed:
|
||||
1. `spawn_mesh_listener`'s reconnect backoff (`core/archipelago/src/mesh/listener/mod.rs`)
|
||||
reset to its 5s minimum any time the prior session had been `device_connected`
|
||||
at all, even for under a second — so a device that connects-then-drops
|
||||
repeatedly never actually backed off. Every retry's `open()` toggles
|
||||
DTR/RTS, which resets many ESP32 boards' MCU (native-USB *and*
|
||||
CP2102/CH340 auto-reset-circuit boards), so the aggressive retries were
|
||||
themselves *causing* the boot loop, not just observing one. Fixed by only
|
||||
resetting backoff when a session ran for at least `STABLE_SESSION_THRESHOLD`
|
||||
(20s) — see that constant's doc comment.
|
||||
2. `mesh::flash::start_flash_job`'s post-completion handler auto-resumed the
|
||||
listener unconditionally, even after a *failed* flash, immediately
|
||||
re-entering the reconnect loop above with no cooldown. Fixed: on failure
|
||||
the listener is now deliberately left stopped (reconnect manually via the
|
||||
UI once the board is confirmed alive); on success there's a 5s settle
|
||||
delay before resuming, so the board finishes booting from the flash
|
||||
tool's own reset before Archipelago starts probing it again.
|
||||
3. Separately, the download itself was failing because `mesh::flash`'s HTTP
|
||||
client had a blanket 30s request timeout that covered the *entire*
|
||||
download (including streaming a 170MB Meshtastic zip), not just
|
||||
connection setup — fixed with a per-chunk stall timeout instead of a
|
||||
fixed total-transfer cap.
|
||||
|
||||
If this symptom recurs (rapid repeating `mesh::serial: Opened serial
|
||||
port... Starting Meshcore handshake` lines in `journalctl -u archipelago`
|
||||
without a `LoRa firmware flash` job in progress), it's a NEW instance of the
|
||||
same class of bug, not the one above — check whether backoff is actually
|
||||
escalating (`Mesh session error: ... (retry in Xs)` — X should grow past 5s
|
||||
within a few cycles) before assuming it's flashing-related.
|
||||
|
||||
---
|
||||
|
||||
## General Maintenance
|
||||
|
||||
@@ -365,6 +365,11 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
|
||||
ca-certificates \
|
||||
openssl \
|
||||
chrony \
|
||||
iputils-ping \
|
||||
esptool \
|
||||
python3-venv \
|
||||
binutils \
|
||||
libpython3.13 \
|
||||
locales \
|
||||
console-setup \
|
||||
keyboard-configuration \
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
|
||||
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Set Recommended' : 'Flash Firmware'"
|
||||
max-width="max-w-lg"
|
||||
content-class="max-h-[90vh] overflow-y-auto"
|
||||
@close="dismiss"
|
||||
@@ -35,9 +35,17 @@
|
||||
|
||||
<!-- What's currently flashed / configured on it -->
|
||||
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
|
||||
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
|
||||
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
|
||||
Reading what's on the radio…
|
||||
<div v-if="probing" class="py-1">
|
||||
<div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
|
||||
<span>{{ probeStage }}</span>
|
||||
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
|
||||
</div>
|
||||
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
|
||||
:style="{ width: probeProgress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else-if="probe">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -94,21 +102,123 @@
|
||||
:disabled="!!connecting"
|
||||
@click="step = 2"
|
||||
>
|
||||
Set Up with Archipelago Settings
|
||||
Set Recommended
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-white/40 text-[11px] mt-3">
|
||||
"Keep As Is" uses the radio exactly as it is — nothing on it is changed,
|
||||
and you can hot-swap radios any time.
|
||||
</p>
|
||||
<button
|
||||
class="w-full text-center text-white/40 hover:text-white/70 text-[11px] mt-3 underline underline-offset-2"
|
||||
:disabled="!!connecting"
|
||||
@click="openFlashStep"
|
||||
>
|
||||
Flash Firmware…
|
||||
</button>
|
||||
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: erase + reflash — destructive, opt-in only -->
|
||||
<div v-else-if="step === 'flash'">
|
||||
<!-- Once a job exists (started via startFlash), ALWAYS show the
|
||||
progress/result view below — including on failure. The old
|
||||
condition (`!active && stage !== 'done'`) was also true for a
|
||||
FAILED job (active:false, stage:'failed'), which silently sent
|
||||
the user back to this picker instead of showing the error. -->
|
||||
<template v-if="!flashJob">
|
||||
<p class="text-white/60 text-xs mb-3">
|
||||
Downloads the latest firmware from upstream and writes it to
|
||||
<span class="font-mono text-orange-300">{{ devicePath }}</span>.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-white/80 mb-1">Firmware family</label>
|
||||
<select v-model="flashFamily" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">Choose…</option>
|
||||
<option value="meshcore">MeshCore</option>
|
||||
<option value="meshtastic">Meshtastic</option>
|
||||
<option value="reticulum">Reticulum RNode</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-white/80 mb-1">Board</label>
|
||||
<select v-model="flashBoard" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
|
||||
<option value="">Choose…</option>
|
||||
<option value="heltec-v3">Heltec LoRa 32 V3</option>
|
||||
<option value="heltec-v4">Heltec LoRa 32 V4</option>
|
||||
</select>
|
||||
<p v-if="!boardAutoDetected" class="text-[11px] text-amber-400/80 mt-1">
|
||||
Couldn't confirm the board automatically — double check before flashing.
|
||||
Flashing the wrong board's image can brick it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-red-500/10 border border-red-500/30 p-3 mt-4">
|
||||
<label class="flex items-start gap-2 text-xs text-red-300">
|
||||
<input type="checkbox" v-model="flashConfirmed" class="mt-0.5" />
|
||||
<span>
|
||||
This <strong>erases the entire chip</strong>, including any existing
|
||||
keys, identity, and contacts. This cannot be undone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
|
||||
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-red-500/80 hover:bg-red-500 text-white disabled:opacity-50"
|
||||
:disabled="!flashFamily || !flashBoard || !flashConfirmed || starting"
|
||||
@click="startFlash"
|
||||
>
|
||||
{{ starting ? 'Starting…' : 'Erase & Flash Now' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Progress -->
|
||||
<template v-else>
|
||||
<div class="text-center py-2">
|
||||
<p class="text-white text-sm font-medium">{{ flashStageLabel }}</p>
|
||||
<div class="mt-3 h-2 rounded-full bg-white/10 overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-orange-400 transition-all"
|
||||
:style="{ width: (flashJob?.percent ?? (flashJob?.stage === 'done' ? 100 : 8)) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<p v-if="flashJob?.error" class="text-xs text-red-400 mt-3">{{ flashJob.error }}</p>
|
||||
</div>
|
||||
<div class="mt-3 rounded-xl bg-black/30 border border-white/10 p-2 h-32 overflow-y-auto font-mono text-[10px] text-white/50 leading-relaxed">
|
||||
<div v-for="(line, i) in (flashJob?.log_tail ?? []).slice(-40)" :key="i">{{ line }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
v-if="flashJob?.stage === 'downloading' && flashJob?.active"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm"
|
||||
@click="cancelFlash"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
v-if="!flashJob?.active"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="closeFlashStep"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: our latest parameters, shown before anything is written -->
|
||||
<div v-else>
|
||||
<p class="text-white/60 text-xs mb-3">
|
||||
These are the latest Archipelago settings — nothing is written to the
|
||||
radio until you confirm.
|
||||
These are the recommended Archipelago settings — nothing is written to
|
||||
the radio until you confirm.
|
||||
</p>
|
||||
|
||||
<!-- Summary of what will be applied -->
|
||||
@@ -190,7 +300,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh'
|
||||
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams, type FlashFirmwareFamily, type FlashBoard, type FlashJobStatus } from '@/stores/mesh'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
|
||||
@@ -199,13 +309,37 @@ const mesh = useMeshStore()
|
||||
const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const step = ref<1 | 2 | 'flash'>(1)
|
||||
const connecting = ref<false | 'keep' | 'setup'>(false)
|
||||
const error = ref('')
|
||||
const probing = ref(false)
|
||||
const probe = ref<MeshDeviceProbe | null>(null)
|
||||
const probeError = ref('')
|
||||
|
||||
// Time-driven probe progress: the probe RPC is a single opaque call that can
|
||||
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
|
||||
// advances on a clock toward 92% and snaps to 100% when the result lands.
|
||||
const probeProgress = ref(0)
|
||||
const probeStage = ref('Waiting for the radio to boot…')
|
||||
let probeTicker: ReturnType<typeof setInterval> | null = null
|
||||
function startProbeProgress() {
|
||||
stopProbeProgress()
|
||||
probeProgress.value = 0
|
||||
probeStage.value = 'Waiting for the radio to boot…'
|
||||
const startedAt = Date.now()
|
||||
probeTicker = setInterval(() => {
|
||||
const elapsed = (Date.now() - startedAt) / 1000
|
||||
// ~92% at 30s, decelerating — never looks stuck, never lies "done".
|
||||
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
|
||||
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
|
||||
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
|
||||
}, 400)
|
||||
}
|
||||
function stopProbeProgress(done = false) {
|
||||
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
|
||||
if (done) probeProgress.value = 100
|
||||
}
|
||||
|
||||
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
||||
const show = computed(() => !!devicePath.value)
|
||||
const imageFailed = ref(false)
|
||||
@@ -264,7 +398,10 @@ const rfPreset = computed(() => {
|
||||
|
||||
// (Re)probe + (re)apply presets each time a new device surfaces the modal
|
||||
watch([show, devicePath], async ([visible]) => {
|
||||
if (!visible) return
|
||||
if (!visible) {
|
||||
stopFlashPoll()
|
||||
return
|
||||
}
|
||||
step.value = 1
|
||||
error.value = ''
|
||||
imageFailed.value = false
|
||||
@@ -275,6 +412,7 @@ watch([show, devicePath], async ([visible]) => {
|
||||
probe.value = null
|
||||
probeError.value = ''
|
||||
probing.value = true
|
||||
startProbeProgress()
|
||||
const path = devicePath.value
|
||||
try {
|
||||
const res = await mesh.probeDevice(path)
|
||||
@@ -284,6 +422,7 @@ watch([show, devicePath], async ([visible]) => {
|
||||
probeError.value = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
} finally {
|
||||
stopProbeProgress(true)
|
||||
if (devicePath.value === path) probing.value = false
|
||||
}
|
||||
}, { immediate: false })
|
||||
@@ -349,6 +488,118 @@ async function applySetup() {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Step 3: erase + reflash ─────────────────────────────────────────────
|
||||
const flashFamily = ref<FlashFirmwareFamily | ''>('')
|
||||
const flashBoard = ref<FlashBoard | ''>('')
|
||||
const flashConfirmed = ref(false)
|
||||
const starting = ref(false)
|
||||
const flashJob = ref<FlashJobStatus | null>(null)
|
||||
let flashPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const detectedInfo = computed(() =>
|
||||
mesh.status?.detected_device_info?.find(d => d.path === devicePath.value)
|
||||
)
|
||||
|
||||
// Mirrors mesh::flash::resolve_flash_board (core/archipelago/src/mesh/flash.rs)
|
||||
// exactly — matching on the display label was wrong: a Heltec V3's CP2102
|
||||
// bridge chip reports "CP2102 USB to UART Bridge Controller" in its USB
|
||||
// strings, not "Heltec", so meshDeviceImages.ts falls back to a generic
|
||||
// "LoRa radio (CP2102 serial)" label that never matched /v3/i, showing the
|
||||
// "couldn't confirm automatically" warning even though the backend CAN
|
||||
// safely auto-detect V3 via vid:pid. Heltec V4 deliberately has no entry
|
||||
// here, same reasoning as the backend: its vid:pid (303a:1001) is the
|
||||
// ESP32-S3's generic native-USB descriptor, not V4-specific, so it can't be
|
||||
// safely auto-matched and always requires manual selection.
|
||||
const resolvedFlashBoard = computed<FlashBoard | ''>(() => {
|
||||
const info = detectedInfo.value
|
||||
if (info?.vid?.toLowerCase() === '10c4' && info?.pid?.toLowerCase() === 'ea60') return 'heltec-v3'
|
||||
return ''
|
||||
})
|
||||
|
||||
const boardAutoDetected = computed(() => !!resolvedFlashBoard.value)
|
||||
|
||||
const flashStageLabel = computed(() => {
|
||||
switch (flashJob.value?.stage) {
|
||||
case 'downloading': return 'Downloading firmware…'
|
||||
case 'erasing': return 'Erasing chip…'
|
||||
case 'writing': return 'Writing firmware…'
|
||||
case 'autoinstalling': return 'Installing (rnodeconf)…'
|
||||
case 'done': return 'Flash complete'
|
||||
case 'failed': return 'Flash failed'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
function openFlashStep() {
|
||||
flashFamily.value = (probe.value?.kind as FlashFirmwareFamily) ?? ''
|
||||
flashBoard.value = resolvedFlashBoard.value
|
||||
flashConfirmed.value = false
|
||||
flashJob.value = null
|
||||
error.value = ''
|
||||
step.value = 'flash'
|
||||
}
|
||||
|
||||
function stopFlashPoll() {
|
||||
if (flashPollTimer) {
|
||||
clearInterval(flashPollTimer)
|
||||
flashPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function pollFlashStatus() {
|
||||
try {
|
||||
const status = await mesh.flashStatus()
|
||||
flashJob.value = status
|
||||
if (!status.active) {
|
||||
stopFlashPoll()
|
||||
if (status.done && !status.error) {
|
||||
// Mirrors the unplug/replug hot-swap flow: re-probe so the details
|
||||
// card reflects whatever firmware is actually on the board now.
|
||||
const path = devicePath.value
|
||||
probing.value = true
|
||||
try {
|
||||
probe.value = await mesh.probeDevice(path)
|
||||
} catch {
|
||||
probe.value = null
|
||||
} finally {
|
||||
probing.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
stopFlashPoll()
|
||||
}
|
||||
}
|
||||
|
||||
async function startFlash() {
|
||||
if (!flashFamily.value || !flashBoard.value || !flashConfirmed.value) return
|
||||
starting.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await mesh.flashDevice(devicePath.value, flashFamily.value, flashBoard.value)
|
||||
flashJob.value = { active: true, stage: 'downloading', log_tail: [] }
|
||||
stopFlashPoll()
|
||||
flashPollTimer = setInterval(pollFlashStatus, 1500)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start flashing'
|
||||
} finally {
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelFlash() {
|
||||
try {
|
||||
await mesh.flashCancel()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to cancel'
|
||||
}
|
||||
}
|
||||
|
||||
function closeFlashStep() {
|
||||
stopFlashPoll()
|
||||
step.value = 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -57,6 +57,23 @@ export interface MeshDeviceProbe {
|
||||
max_contacts: number | null
|
||||
}
|
||||
|
||||
export type FlashFirmwareFamily = 'meshcore' | 'meshtastic' | 'reticulum'
|
||||
export type FlashBoard = 'heltec-v3' | 'heltec-v4'
|
||||
export type FlashStage = 'downloading' | 'erasing' | 'writing' | 'autoinstalling' | 'done' | 'failed'
|
||||
|
||||
/** Live progress for the one flash job that can run at a time. */
|
||||
export interface FlashJobStatus {
|
||||
active: boolean
|
||||
board?: FlashBoard
|
||||
family?: FlashFirmwareFamily
|
||||
path?: string
|
||||
stage?: FlashStage
|
||||
percent?: number | null
|
||||
log_tail?: string[]
|
||||
done?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
/** Params accepted by mesh.configure (superset of the status fields). */
|
||||
export interface MeshConfigureParams {
|
||||
enabled?: boolean
|
||||
@@ -274,12 +291,16 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const res = await rpcClient.call<MeshStatus>({ method: 'mesh.status' })
|
||||
status.value = res
|
||||
trackDetectedDevices(res)
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
||||
// Don't clobber a user-action error (broadcast/configure/send) — this
|
||||
// runs on a 5s poll, and the old `error.value = null` on entry meant
|
||||
// any real error banner survived at most one poll tick.
|
||||
if (!error.value) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to fetch mesh status'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -358,6 +379,32 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
|
||||
})
|
||||
}
|
||||
/** Available firmware version(s) for a family — v1 only ever returns
|
||||
* ["latest"], since firmware is always fetched from upstream at flash
|
||||
* time rather than pinned/bundled. */
|
||||
async function flashListFirmware(family: FlashFirmwareFamily): Promise<string[]> {
|
||||
const res = await rpcClient.call<{ versions: string[] }>({
|
||||
method: 'mesh.flash-list-firmware',
|
||||
params: { family },
|
||||
})
|
||||
return res.versions
|
||||
}
|
||||
/** Erase + reflash a detected radio. `board` is optional — omit it to let
|
||||
* the backend auto-resolve from the port's USB vid:pid; if that fails
|
||||
* (e.g. Heltec V4 not yet in the vid:pid table), it errors and the UI
|
||||
* must ask the user to pick the board explicitly. Always erases first. */
|
||||
async function flashDevice(path: string, family: FlashFirmwareFamily, board?: FlashBoard): Promise<void> {
|
||||
await rpcClient.call({
|
||||
method: 'mesh.flash-device',
|
||||
params: board ? { path, family, board } : { path, family },
|
||||
})
|
||||
}
|
||||
async function flashStatus(): Promise<FlashJobStatus> {
|
||||
return rpcClient.call<FlashJobStatus>({ method: 'mesh.flash-status' })
|
||||
}
|
||||
async function flashCancel(): Promise<void> {
|
||||
await rpcClient.call({ method: 'mesh.flash-cancel' })
|
||||
}
|
||||
let globalDetectTimer: ReturnType<typeof setInterval> | null = null
|
||||
/** App-wide light poll so the detected-device modal works on every page
|
||||
* (the Mesh view's own 5s poll takes over while it is mounted). */
|
||||
@@ -975,6 +1022,18 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
|
||||
}
|
||||
|
||||
/** Ask the backend to actively re-query the radio's contact table (and by
|
||||
* extension re-drain daemon events for Reticulum) — the server-side half
|
||||
* of the Refresh button; refreshAll() alone only re-reads caches. */
|
||||
async function refreshRadio(): Promise<boolean> {
|
||||
try {
|
||||
const res = await rpcClient.call<{ refreshed: boolean }>({ method: 'mesh.refresh' })
|
||||
return !!res.refreshed
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
peers,
|
||||
@@ -994,6 +1053,10 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
undismissedDetectedDevices,
|
||||
dismissDetectedDevice,
|
||||
probeDevice,
|
||||
flashListFirmware,
|
||||
flashDevice,
|
||||
flashStatus,
|
||||
flashCancel,
|
||||
startGlobalDetection,
|
||||
fetchPeers,
|
||||
fetchMessages,
|
||||
@@ -1002,6 +1065,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
broadcastIdentity,
|
||||
configure,
|
||||
refreshAll,
|
||||
refreshRadio,
|
||||
markChatRead,
|
||||
clearViewingChat,
|
||||
sendInvoice,
|
||||
|
||||
@@ -38,6 +38,8 @@ const activeChatChannel = ref<{ index: number; name: string } | null>(null)
|
||||
const messageText = ref('')
|
||||
const sendError = ref('')
|
||||
const broadcasting = ref(false)
|
||||
const broadcastResult = ref<string | null>(null) // 'ok' | error message
|
||||
const refreshing = ref(false)
|
||||
const configuring = ref(false)
|
||||
const connectingDevice = ref<string | null>(null)
|
||||
// Device-detected onboarding now lives in the global MeshDeviceSetupModal (App.vue).
|
||||
@@ -383,12 +385,22 @@ onMounted(async () => {
|
||||
archPollInterval = setInterval(loadArchMessages, 15000)
|
||||
}
|
||||
if (!pollInterval) {
|
||||
let tick = 0
|
||||
pollInterval = setInterval(() => {
|
||||
mesh.fetchStatus()
|
||||
mesh.fetchPeers()
|
||||
mesh.fetchMessages()
|
||||
mesh.fetchDeadmanStatus()
|
||||
mesh.fetchBlockHeaders()
|
||||
// Contacts/aliases, federation nodes and the outbox badge previously
|
||||
// loaded ONCE at mount and went permanently stale — new federation
|
||||
// peers or renames never appeared without a full page reload. Every
|
||||
// 6th tick (~30s) keeps them fresh without adding per-5s load.
|
||||
if (++tick % 6 === 0) {
|
||||
void refreshContacts()
|
||||
void refreshFederationNodes()
|
||||
void refreshOutboxCount()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
@@ -1021,7 +1033,37 @@ function onChatWheel(e: WheelEvent) {
|
||||
|
||||
async function handleBroadcast() {
|
||||
broadcasting.value = true
|
||||
try { await mesh.broadcastIdentity() } finally { broadcasting.value = false }
|
||||
broadcastResult.value = null
|
||||
try {
|
||||
await mesh.broadcastIdentity()
|
||||
broadcastResult.value = 'ok'
|
||||
} catch (e) {
|
||||
broadcastResult.value = e instanceof Error ? e.message : 'Broadcast failed'
|
||||
} finally {
|
||||
broadcasting.value = false
|
||||
setTimeout(() => { broadcastResult.value = null }, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
if (refreshing.value) return
|
||||
refreshing.value = true
|
||||
try {
|
||||
// Backend first: re-query the radio's contact table (mesh.refresh), then
|
||||
// re-read EVERYTHING the list is built from — peers, contacts/aliases,
|
||||
// federation nodes, outbox — not just the mesh caches.
|
||||
await Promise.allSettled([
|
||||
mesh.refreshRadio(),
|
||||
mesh.refreshAll(),
|
||||
refreshContacts(),
|
||||
refreshFederationNodes(),
|
||||
refreshOutboxCount(),
|
||||
])
|
||||
// Radio contact refresh is async on the backend — pick up its result.
|
||||
await mesh.fetchPeers()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleEnabled() {
|
||||
@@ -1830,8 +1872,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button class="glass-button mesh-action-btn" :disabled="configuring" @click="handleToggleEnabled">
|
||||
{{ mesh.status?.enabled ? 'Disable' : 'Enable' }}
|
||||
</button>
|
||||
<button class="glass-button mesh-action-btn" :disabled="!mesh.status?.device_connected || broadcasting" @click="handleBroadcast">
|
||||
{{ broadcasting ? 'Sending...' : 'Broadcast' }}
|
||||
<button
|
||||
class="glass-button mesh-action-btn"
|
||||
:class="broadcastResult === 'ok' ? 'mesh-action-ok' : ''"
|
||||
:disabled="!mesh.status?.device_connected || broadcasting"
|
||||
:title="broadcastResult && broadcastResult !== 'ok' ? broadcastResult : 'Announce this node so nearby radios learn about it'"
|
||||
@click="handleBroadcast"
|
||||
>
|
||||
{{ broadcasting ? 'Sending…' : broadcastResult === 'ok' ? 'Sent ✓' : broadcastResult ? 'Failed ✕' : 'Broadcast' }}
|
||||
</button>
|
||||
<button
|
||||
class="glass-button mesh-action-btn"
|
||||
@@ -1841,7 +1889,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
>
|
||||
{{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }}
|
||||
</button>
|
||||
<button class="glass-button mesh-action-btn" @click="mesh.refreshAll()">Refresh</button>
|
||||
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh">
|
||||
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
|
||||
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Peers list -->
|
||||
@@ -1871,7 +1922,10 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
>×</button>
|
||||
</div>
|
||||
|
||||
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||
<!-- Only claim "no peers" when the MERGED list (radio + federation)
|
||||
is truly empty — with no radio attached the federation rows and
|
||||
the two channel rows must still render. -->
|
||||
<div v-if="displayedPeers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
|
||||
No peers discovered yet.
|
||||
</div>
|
||||
|
||||
@@ -2140,8 +2194,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
<button
|
||||
class="mesh-typed-content-download-btn"
|
||||
title="Download"
|
||||
aria-label="Download image"
|
||||
@click="downloadAttachment(msg.typed_payload as any)"
|
||||
>⬇</button>
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<audio
|
||||
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
|
||||
@@ -2165,10 +2225,19 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
|
||||
@click="openMeshLightbox(msg.typed_payload as any)"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
class="mesh-typed-content-fetch-btn"
|
||||
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
||||
@click="handleFetchContent(msg.typed_payload as any)"
|
||||
>
|
||||
<span
|
||||
v-if="fetchingCids.has(msg.typed_payload.cid)"
|
||||
class="mesh-refresh-spinner"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
{{ fetchingCids.has(msg.typed_payload.cid) ? 'Fetching…' : 'Download' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -142,12 +142,15 @@ async function saveSettings() {
|
||||
lora_region: form.value.region,
|
||||
device_kind: form.value.deviceKind,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||
// Always sent: an empty string CLEARS the custom mesh name (backend
|
||||
// maps "" -> None -> fall back to the server name). The old omit-when-
|
||||
// empty made clearing impossible once a name was ever set.
|
||||
advert_name: form.value.name.trim(),
|
||||
broadcast_identity: form.value.broadcastIdentity,
|
||||
...(rfParams ? { lora_radio_params: rfParams } : {}),
|
||||
})
|
||||
saveDone.value = true
|
||||
setTimeout(() => { saveDone.value = false }, 3000)
|
||||
setTimeout(() => { saveDone.value = false }, 5000)
|
||||
} catch (e) {
|
||||
saveError.value = e instanceof Error ? e.message : 'Failed to save mesh settings'
|
||||
} finally {
|
||||
@@ -286,7 +289,7 @@ async function saveSettings() {
|
||||
>
|
||||
{{ saving ? 'Saving…' : 'Save Settings' }}
|
||||
</button>
|
||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applies on next radio session</span>
|
||||
<span v-if="saveDone" class="text-xs text-green-400">Saved — applying to the radio now…</span>
|
||||
<span v-if="saveError" class="text-xs text-red-400">{{ saveError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,19 @@
|
||||
.mesh-offgrid-active { border-color: rgba(251, 146, 60, 0.4) !important; color: #fb923c !important; }
|
||||
.mesh-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.mesh-action-btn { flex: 1; padding: 8px 0; font-size: 0.8rem; }
|
||||
.mesh-action-ok { color: #34d399; border-color: rgba(52, 211, 153, 0.4); }
|
||||
.mesh-refresh-spinner {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-right: 4px;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgba(251, 146, 60, 0.7);
|
||||
border-top-color: transparent;
|
||||
animation: mesh-refresh-spin 0.8s linear infinite;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
@keyframes mesh-refresh-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-peers-card { padding: 14px; flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.mesh-peers-card .mesh-section-title { margin-bottom: 10px; flex-shrink: 0; }
|
||||
.mesh-peer-list { display: flex; flex-direction: column; gap: 4px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
@@ -361,13 +374,35 @@
|
||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
|
||||
.mesh-typed-content-download-btn {
|
||||
position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem;
|
||||
position: absolute; bottom: 8px; right: 8px;
|
||||
width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
|
||||
border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
|
||||
background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||
.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
|
||||
.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
|
||||
.mesh-typed-content-download-btn:active { transform: scale(0.96); }
|
||||
/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
|
||||
replaced collapsed to its text width inside the narrow mobile bubble and
|
||||
looked squashed — this is a full-width glass pill in the house style. */
|
||||
.mesh-typed-content-fetch-btn {
|
||||
display: flex; align-items: center; justify-content: center; gap: 7px;
|
||||
width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
|
||||
border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
|
||||
background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
|
||||
font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
|
||||
.mesh-typed-content-fetch-btn:hover:not(:disabled) {
|
||||
background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
|
||||
}
|
||||
.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.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); }
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
import { safeClipboardWrite } from '@/views/web5/utils'
|
||||
import FipsSeedAnchorsCard from './FipsSeedAnchorsCard.vue'
|
||||
|
||||
@@ -136,7 +137,14 @@ interface FipsStatus {
|
||||
anchor_connected?: boolean
|
||||
}
|
||||
|
||||
const status = ref<FipsStatus>({
|
||||
// Shares `server.fips-summary` with the Local Network card's FIPS row, so
|
||||
// both paint from the same cache instantly on revisit and never disagree.
|
||||
const statusRes = useCachedResource<FipsStatus>({
|
||||
key: 'server.fips-summary',
|
||||
ttlMs: 15_000,
|
||||
fetcher: (signal) => rpcClient.call<FipsStatus>({ method: 'fips.status', signal, dedup: true, maxRetries: 1 }),
|
||||
})
|
||||
const status = computed<FipsStatus>(() => statusRes.data.value ?? {
|
||||
installed: false,
|
||||
version: null,
|
||||
service_state: 'unknown',
|
||||
@@ -199,18 +207,11 @@ function flash(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.status' })
|
||||
} catch (e) {
|
||||
if (import.meta.env.DEV) console.warn('fips.status failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function installAndActivate() {
|
||||
installing.value = true
|
||||
try {
|
||||
status.value = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||
const next = await rpcClient.call<FipsStatus>({ method: 'fips.install' })
|
||||
statusRes.optimistic(() => next) // confirmed server state, not a guess
|
||||
flash('FIPS started')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@@ -235,7 +236,7 @@ async function reconnectAnchor() {
|
||||
}>({ method: 'fips.reconnect', timeout: 60_000 })
|
||||
// Update the card with the post-reconnect status returned by the
|
||||
// backend — avoids an extra status fetch race.
|
||||
status.value = { ...status.value, ...res.after }
|
||||
statusRes.optimistic((cur) => ({ ...(cur ?? status.value), ...res.after }))
|
||||
if (res.recovered) {
|
||||
flash('Anchor reconnected.')
|
||||
} else if (res.likely_cause === 'connected') {
|
||||
@@ -258,8 +259,10 @@ async function reconnectAnchor() {
|
||||
// stuck showing whatever anchor state existed at mount time forever.
|
||||
let statusInterval: ReturnType<typeof setInterval> | null = null
|
||||
onMounted(() => {
|
||||
loadStatus()
|
||||
statusInterval = setInterval(loadStatus, 15000)
|
||||
statusInterval = setInterval(() => {
|
||||
if (document.hidden) return
|
||||
void statusRes.refresh()
|
||||
}, 15000)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
if (statusInterval) clearInterval(statusInterval)
|
||||
|
||||
@@ -88,8 +88,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useCachedResource } from '@/composables/useCachedResource'
|
||||
|
||||
defineProps<{ closable?: boolean }>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
@@ -107,7 +108,16 @@ interface ApplyResult {
|
||||
message: string
|
||||
}
|
||||
|
||||
const anchors = ref<SeedAnchor[]>([])
|
||||
const anchorsRes = useCachedResource<SeedAnchor[]>({
|
||||
key: 'server.fips-seed-anchors',
|
||||
fetcher: async (signal) => {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({
|
||||
method: 'fips.list-seed-anchors', signal, dedup: true, maxRetries: 1,
|
||||
})
|
||||
return res.seed_anchors
|
||||
},
|
||||
})
|
||||
const anchors = computed(() => anchorsRes.data.value ?? [])
|
||||
const adding = ref(false)
|
||||
const applying = ref(false)
|
||||
const statusMessage = ref('')
|
||||
@@ -126,15 +136,6 @@ function flash(msg: string, isError = false) {
|
||||
setTimeout(() => { statusMessage.value = '' }, 6000)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ seed_anchors: SeedAnchor[] }>({ method: 'fips.list-seed-anchors' })
|
||||
anchors.value = res.seed_anchors
|
||||
} catch (e: unknown) {
|
||||
if (import.meta.env.DEV) console.warn('fips.list-seed-anchors failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addAnchor() {
|
||||
if (!draft.npub.trim() || !draft.address.trim()) return
|
||||
adding.value = true
|
||||
@@ -148,7 +149,7 @@ async function addAnchor() {
|
||||
label: draft.label.trim(),
|
||||
},
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
anchorsRes.optimistic(() => res.seed_anchors) // authoritative post-add list
|
||||
draft.npub = ''
|
||||
draft.address = ''
|
||||
draft.label = ''
|
||||
@@ -168,7 +169,7 @@ async function removeAnchor(npub: string) {
|
||||
method: 'fips.remove-seed-anchor',
|
||||
params: { npub },
|
||||
})
|
||||
anchors.value = res.seed_anchors
|
||||
anchorsRes.optimistic(() => res.seed_anchors)
|
||||
flash('Anchor removed.')
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
@@ -189,6 +190,4 @@ async function applyAll() {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -47,11 +47,16 @@ if [ -n "$RNODECONF_SRC" ] && [ -f "$RNODECONF_SRC" ]; then
|
||||
# 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.
|
||||
# pyi_rthook_exit_builtins.py. A second hook fixes rnodeconf's board-flash
|
||||
# step, which shells out to a bundled esptool.py via `sys.executable` —
|
||||
# under a frozen binary that's the binary itself, not a real interpreter,
|
||||
# so the flash subprocess call breaks. See
|
||||
# pyi_rthook_fix_flasher_executable.py.
|
||||
.venv/bin/pyinstaller --onefile --name archy-rnodeconf --clean --noconfirm \
|
||||
--collect-submodules RNS \
|
||||
--collect-data RNS \
|
||||
--runtime-hook pyi_rthook_exit_builtins.py \
|
||||
--runtime-hook pyi_rthook_fix_flasher_executable.py \
|
||||
-d noarchive \
|
||||
"$RNODECONF_SRC"
|
||||
echo "Built dist/archy-rnodeconf ($(du -h dist/archy-rnodeconf | cut -f1))"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# PyInstaller runtime hook — see build.sh.
|
||||
#
|
||||
# rnodeconf's own board-flashing code shells out to a bundled esptool.py as
|
||||
# `[sys.executable, flasher_path, "--chip", ..., "write_flash", ...]` (RNS's
|
||||
# rnodeconf.py, ~line 2794 as of RNS 1.3.5). That's correct for a normal
|
||||
# `python rnodeconf.py` invocation, but under a frozen PyInstaller binary
|
||||
# `sys.executable` is the frozen binary itself, not a real interpreter — so
|
||||
# the "subprocess" just re-invokes archy-rnodeconf's OWN argparse CLI with
|
||||
# esptool-shaped flags, which it doesn't recognize, and the flash step fails
|
||||
# immediately with "unrecognized arguments: --chip ...". Confirmed live
|
||||
# against a real Heltec V4 (2026-07-23): device selection, band selection,
|
||||
# and firmware download all worked; only the final `write_flash` subprocess
|
||||
# call broke this way.
|
||||
#
|
||||
# Fix: point sys.executable at a real Python interpreter that has rnodeconf's
|
||||
# own runtime deps available (esptool.py only needs pyserial, which RNS
|
||||
# already depends on) before any of rnodeconf's code runs. Prefer the build
|
||||
# venv this exact binary was frozen from — see build.sh — falling back to a
|
||||
# bare `python3` on PATH if that venv isn't present on this node.
|
||||
import os
|
||||
import sys
|
||||
|
||||
if getattr(sys, "frozen", False):
|
||||
_candidates = [
|
||||
os.environ.get("ARCHY_RNODECONF_PYTHON", ""),
|
||||
os.path.join(os.path.dirname(os.path.abspath(sys.argv[0])), "..", "reticulum-daemon", ".venv", "bin", "python3"),
|
||||
os.path.expanduser("~/archy/reticulum-daemon/.venv/bin/python3"),
|
||||
]
|
||||
for _candidate in _candidates:
|
||||
if _candidate and os.path.isfile(_candidate):
|
||||
sys.executable = _candidate
|
||||
break
|
||||
else:
|
||||
sys.executable = "python3"
|
||||
@@ -14,12 +14,13 @@ Security posture (see the plan's "most secure way" section):
|
||||
RPC (one JSON object per line, both directions):
|
||||
in : {"cmd":"send","dest_hash":"<hex16>","content":"…","title":"…","method":"direct|opportunistic"}
|
||||
{"cmd":"announce"}
|
||||
{"cmd":"set_name","name":"…"}
|
||||
{"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":"announce","dest_hash":"<hex16>","app_data":"<hex>","display_name":"…"|null,"archy_blob":"ARCHY:2:…"|null}
|
||||
{"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}
|
||||
@@ -227,31 +228,47 @@ class ReticulumDaemon:
|
||||
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`.
|
||||
"""
|
||||
def _archy_identity_blob(self):
|
||||
"""The ``ARCHY:2:{ed25519_hex}:{x25519_hex}`` identity string the Rust
|
||||
side parses (``protocol::parse_identity_broadcast``) and binds via
|
||||
``handle_identity_received`` — so a Reticulum-carried identity merges
|
||||
into the SAME conversation as the meshcore/Meshtastic/federation twins
|
||||
of the same Archy node. The keys are the node's real Archipelago
|
||||
pubkeys (passed in by the Rust side) — NOT this daemon's
|
||||
internally-HKDF-derived RNS keys, which exist only to make the RNS
|
||||
destination hash deterministic. ``None`` when the pubkeys weren't
|
||||
supplied (dev/selftest run)."""
|
||||
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")
|
||||
return None
|
||||
|
||||
def _announce_app_data(self) -> bytes:
|
||||
"""LXMF-standard announce app_data — msgpack ``[display_name,
|
||||
stamp_cost, supported_functionality]`` via the router, so Sideband/
|
||||
NomadNet/MeshChat (and upgraded archy nodes) all see our real display
|
||||
name — with the Archy identity blob appended as an EXTRA list element.
|
||||
Stock clients only read the elements they know ([0]/[1]), so the blob
|
||||
rides along invisibly instead of replacing the name the way the old
|
||||
blob-only app_data did (which left every archy node nameless on RNS).
|
||||
"""
|
||||
import RNS.vendor.umsgpack as msgpack
|
||||
app_data = self.router.get_announce_app_data(self.delivery_destination.hash)
|
||||
blob = self._archy_identity_blob()
|
||||
if blob is None:
|
||||
return app_data
|
||||
try:
|
||||
peer_data = msgpack.unpackb(app_data)
|
||||
if not isinstance(peer_data, list):
|
||||
raise ValueError("unexpected announce app_data shape")
|
||||
peer_data.append(blob)
|
||||
return msgpack.packb(peer_data)
|
||||
except Exception:
|
||||
# Never let announce formatting kill announcing entirely — fall
|
||||
# back to the legacy blob-only format (identity binding > name).
|
||||
return blob
|
||||
|
||||
# ---- RNS-thread callbacks → asyncio ----
|
||||
def _on_lxmf_delivery(self, message):
|
||||
@@ -328,6 +345,15 @@ class ReticulumDaemon:
|
||||
self._send(req)
|
||||
elif cmd == "announce":
|
||||
self.announce()
|
||||
elif cmd == "set_name":
|
||||
# Live rename: update the LXMF delivery destination's display name
|
||||
# (what get_announce_app_data reads) and re-announce immediately so
|
||||
# peers learn the new name without waiting for the next advert tick.
|
||||
name = (req.get("name") or "").strip()
|
||||
if name and self.delivery_destination is not None:
|
||||
self.args.display_name = name
|
||||
self.delivery_destination.display_name = name
|
||||
self.announce()
|
||||
elif cmd == "status":
|
||||
self._broadcast(self._status())
|
||||
elif cmd == "send_resource":
|
||||
@@ -521,10 +547,41 @@ class _AnnounceHandler:
|
||||
self.receive_path_responses = True
|
||||
|
||||
def received_announce(self, destination_hash, announced_identity, app_data):
|
||||
# Decode what we can here (both the LXMF-standard display name and our
|
||||
# appended ARCHY identity blob — see _announce_app_data) so the Rust
|
||||
# side gets clean typed fields instead of re-implementing msgpack.
|
||||
display_name = None
|
||||
archy_blob = None
|
||||
raw = app_data or b""
|
||||
try:
|
||||
import LXMF
|
||||
display_name = LXMF.display_name_from_app_data(raw)
|
||||
# A legacy blob-only announce is plain ascii, so LXMF's decoder
|
||||
# returns the whole ARCHY identity blob as a "name" — drop it.
|
||||
if display_name and display_name.startswith("ARCHY:"):
|
||||
display_name = None
|
||||
except Exception:
|
||||
display_name = None
|
||||
try:
|
||||
if raw[:1] and ((0x90 <= raw[0] <= 0x9F) or raw[0] == 0xDC):
|
||||
import RNS.vendor.umsgpack as msgpack
|
||||
peer_data = msgpack.unpackb(raw)
|
||||
if isinstance(peer_data, list):
|
||||
for el in peer_data[3:]:
|
||||
if isinstance(el, bytes) and el.startswith(b"ARCHY:"):
|
||||
archy_blob = el.decode("ascii", "ignore")
|
||||
break
|
||||
elif raw.startswith(b"ARCHY:"):
|
||||
# Legacy (pre-upgrade archy node): app_data IS the blob.
|
||||
archy_blob = raw.decode("ascii", "ignore")
|
||||
except Exception:
|
||||
archy_blob = None
|
||||
self.daemon._emit_threadsafe({
|
||||
"event": "announce",
|
||||
"dest_hash": destination_hash.hex(),
|
||||
"app_data": (app_data or b"").hex(),
|
||||
"app_data": raw.hex(),
|
||||
"display_name": display_name,
|
||||
"archy_blob": archy_blob,
|
||||
})
|
||||
|
||||
|
||||
@@ -604,8 +661,30 @@ def main(argv=None) -> int:
|
||||
if args.selftest:
|
||||
args.no_radio = True
|
||||
daemon.bring_up()
|
||||
# Announce app_data round-trip: the LXMF-standard msgpack name must be
|
||||
# decodable by stock clients AND (with archy keys set) the appended
|
||||
# identity blob must survive as an extra list element — this is the
|
||||
# exact wire contract the Rust announce handler and Sideband both
|
||||
# depend on, so verify it here where there's a real router to build it.
|
||||
import LXMF as _LXMF
|
||||
import RNS.vendor.umsgpack as _msgpack
|
||||
args.archy_ed_pubkey_hex = args.archy_ed_pubkey_hex or "ab" * 32
|
||||
args.archy_x25519_pubkey_hex = args.archy_x25519_pubkey_hex or "cd" * 32
|
||||
app_data = daemon._announce_app_data()
|
||||
decoded_name = _LXMF.display_name_from_app_data(app_data)
|
||||
assert decoded_name == args.display_name, (
|
||||
f"announce name round-trip failed: {decoded_name!r} != {args.display_name!r}"
|
||||
)
|
||||
blob_elems = [e for e in _msgpack.unpackb(app_data)[3:]
|
||||
if isinstance(e, bytes) and e.startswith(b"ARCHY:")]
|
||||
assert blob_elems, "identity blob missing from announce app_data"
|
||||
# Live rename: set_name must change what the next announce carries.
|
||||
daemon.delivery_destination.display_name = "selftest-renamed"
|
||||
renamed = _LXMF.display_name_from_app_data(daemon._announce_app_data())
|
||||
assert renamed == "selftest-renamed", f"rename round-trip failed: {renamed!r}"
|
||||
print(f"selftest ok — dest_hash={daemon.dest_hash_hex} "
|
||||
f"display_name={args.display_name!r} lxmf_router=up")
|
||||
f"display_name={args.display_name!r} lxmf_router=up "
|
||||
f"announce_app_data=verified set_name=verified")
|
||||
return 0
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
|
||||
@@ -84,6 +84,64 @@ if ! command -v nano >/dev/null 2>&1; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v ping >/dev/null 2>&1; then
|
||||
log "Installing iputils-ping..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq iputils-ping 2>>"$LOG_FILE"; then
|
||||
ok "ping installed"
|
||||
else
|
||||
warn "Unable to install ping automatically; continuing update"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! command -v esptool >/dev/null 2>&1; then
|
||||
log "Installing esptool for LoRa radio firmware flashing..."
|
||||
if sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq esptool 2>>"$LOG_FILE"; then
|
||||
ok "esptool installed"
|
||||
else
|
||||
warn "Unable to install esptool automatically; radio firmware flashing will be unavailable"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Debian's esptool package (4.7.0+dfsg-0.1) ships without the precompiled
|
||||
# esp32s3 "stub flasher" blob (stripped for DFSG compliance — no
|
||||
# buildable-from-source path Debian could verify). Without it, esptool's
|
||||
# normal stub-loader mode fails outright (FileNotFoundError), and the ROM
|
||||
# bootloader fallback (--no-stub) doesn't implement a full-chip erase at
|
||||
# all — confirmed live 2026-07-23 flashing a real Heltec V4, both ways.
|
||||
# Fetching the exact same file from the matching upstream esptool release
|
||||
# tag restores full (and correct) flashing behavior — it's the same
|
||||
# open-source codebase, just the one blob Debian's packaging couldn't
|
||||
# include.
|
||||
if command -v esptool >/dev/null 2>&1; then
|
||||
STUB_DIR="/usr/lib/python3/dist-packages/esptool/targets/stub_flasher"
|
||||
STUB_FILE="$STUB_DIR/stub_flasher_32s3.json"
|
||||
if [ ! -f "$STUB_FILE" ]; then
|
||||
log "Fetching esptool's esp32s3 stub flasher (missing from the Debian package)..."
|
||||
ESPTOOL_VERSION=$(esptool version 2>/dev/null | tail -1 | tr -d ' \t')
|
||||
if [ -n "$ESPTOOL_VERSION" ] && sudo curl -fsSL -o "$STUB_FILE" \
|
||||
"https://raw.githubusercontent.com/espressif/esptool/v${ESPTOOL_VERSION}/esptool/targets/stub_flasher/stub_flasher_32s3.json" \
|
||||
2>>"$LOG_FILE"; then
|
||||
sudo chmod 644 "$STUB_FILE"
|
||||
ok "esp32s3 stub flasher installed"
|
||||
else
|
||||
sudo rm -f "$STUB_FILE" 2>/dev/null
|
||||
warn "Unable to fetch esp32s3 stub flasher; LoRa firmware flashing will be unavailable"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build-time prerequisites for reticulum-daemon/build.sh's PyInstaller step
|
||||
# below (discovered the hard way: ensurepip needs python3-venv, and
|
||||
# PyInstaller itself needs objdump + libpython3.13.so at build time — none
|
||||
# of these are pulled in by a bare `python3` package on Debian trixie).
|
||||
for pkg in python3-venv binutils libpython3.13; do
|
||||
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
|
||||
log "Installing $pkg (reticulum-daemon build prerequisite)..."
|
||||
sudo apt-get update -qq 2>>"$LOG_FILE" && sudo apt-get install -y -qq "$pkg" 2>>"$LOG_FILE" \
|
||||
|| warn "Unable to install $pkg automatically; reticulum-daemon tools build may fail"
|
||||
fi
|
||||
done
|
||||
|
||||
# Fetch latest
|
||||
log "Fetching from origin..."
|
||||
git fetch origin main --quiet 2>>"$LOG_FILE"
|
||||
@@ -155,6 +213,30 @@ sudo cp "$BUILT_BIN" "$INSTALL_BIN"
|
||||
sudo chmod +x "$INSTALL_BIN"
|
||||
ok "Backend installed"
|
||||
|
||||
# Build + install reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf).
|
||||
# Non-fatal: archipelago falls back to its dev venv path if the packaged
|
||||
# binaries aren't present, so a missing/failed build here degrades mesh
|
||||
# Reticulum support rather than breaking the update. This mirrors
|
||||
# deploy-to-target.sh's existing manual-deploy step, which until now was the
|
||||
# only path that ever installed these — a node that only ever received OTA
|
||||
# self-updates had neither binary.
|
||||
if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then
|
||||
log "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)..."
|
||||
if (cd "$REPO_DIR/reticulum-daemon" && ./build.sh) 2>>"$LOG_FILE"; then
|
||||
for tool in archy-reticulum-daemon archy-rnodeconf; do
|
||||
if [ -f "$REPO_DIR/reticulum-daemon/dist/$tool" ]; then
|
||||
sudo cp "$REPO_DIR/reticulum-daemon/dist/$tool" /usr/local/bin/
|
||||
sudo chmod +x "/usr/local/bin/$tool"
|
||||
ok "$tool installed"
|
||||
else
|
||||
warn "$tool not built — leaving existing /usr/local/bin/$tool (if any) in place"
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn "reticulum-daemon tools build failed — continuing without updating them"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build frontend
|
||||
log "Building Vue frontend (production)..."
|
||||
cd "$FRONTEND_DIR"
|
||||
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
# Mesh / Reticulum test suite — the "is the mesh stack healthy" gate.
|
||||
#
|
||||
# Three layers, cheapest first:
|
||||
# 1. Rust unit tests (no hardware, ~2s once built)
|
||||
# 2. Daemon selftest (full RNS+LXMF bring-up, no radio; also verifies
|
||||
# the announce app_data wire contract + set_name)
|
||||
# 3. Live-node assertions (optional; needs a running archipelago with a
|
||||
# radio — set MESH_TEST_LIVE=1 MESH_TEST_PW=...)
|
||||
#
|
||||
# Usage:
|
||||
# tests/mesh/run-mesh-tests.sh # layers 1+2
|
||||
# MESH_TEST_LIVE=1 MESH_TEST_PW='...' tests/mesh/run-mesh-tests.sh
|
||||
# MESH_TEST_HOST=100.113.100.55 ... # live-test a remote node
|
||||
set -u
|
||||
cd "$(dirname "$0")/../.."
|
||||
FAIL=0
|
||||
ok() { echo "ok - $1"; }
|
||||
bad() { echo "not ok - $1"; FAIL=1; }
|
||||
|
||||
# ── 1. Rust unit tests ────────────────────────────────────────────────
|
||||
RUST_RESULTS=$(cd core && cargo test -p archipelago --bin archipelago mesh 2>&1 | grep "^test result:")
|
||||
if [ -n "$RUST_RESULTS" ] && ! echo "$RUST_RESULTS" | grep -vq " 0 failed"; then
|
||||
ok "rust mesh unit tests ($(echo "$RUST_RESULTS" | grep -o '[0-9]* passed' | head -1))"
|
||||
else
|
||||
bad "rust mesh unit tests"
|
||||
fi
|
||||
|
||||
# ── 2. Reticulum daemon selftest (no radio) ───────────────────────────
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
head -c 32 /dev/urandom > "$TMP/key"
|
||||
DAEMON=reticulum-daemon/.venv/bin/python
|
||||
if [ -x "$DAEMON" ]; then
|
||||
if "$DAEMON" reticulum-daemon/reticulum_daemon.py \
|
||||
--identity-key "$TMP/key" --rns-config "$TMP/rns" \
|
||||
--socket "$TMP/sock" --display-name "SelftestNode" --selftest 2>/dev/null \
|
||||
| grep -q "announce_app_data=verified set_name=verified"; then
|
||||
ok "daemon selftest (announce wire contract + set_name)"
|
||||
else
|
||||
bad "daemon selftest"
|
||||
fi
|
||||
else
|
||||
echo "skip - daemon selftest (no venv at $DAEMON)"
|
||||
fi
|
||||
|
||||
# ── 3. Live node assertions (opt-in) ──────────────────────────────────
|
||||
if [ "${MESH_TEST_LIVE:-0}" = "1" ]; then
|
||||
HOST="${MESH_TEST_HOST:-127.0.0.1}"
|
||||
PW="${MESH_TEST_PW:?set MESH_TEST_PW}"
|
||||
# Nodes differ: dev boxes serve plain http on :80, ISO installs https.
|
||||
RPC=""
|
||||
for base in "http://$HOST" "https://$HOST" "http://$HOST:5678"; do
|
||||
code=$(curl -ksS -o /dev/null -w '%{http_code}' -m 5 -X POST "$base/rpc/v1" 2>/dev/null || true)
|
||||
case "$code" in 000|"") continue ;; *) RPC="$base/rpc/v1"; break ;; esac
|
||||
done
|
||||
[ -n "$RPC" ] || { bad "live: no RPC endpoint reachable on $HOST"; echo FAIL; exit 1; }
|
||||
JAR="$TMP/jar"
|
||||
curl -ksS -c "$JAR" -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"$PW\"},\"id\":1}" \
|
||||
"$RPC" > "$TMP/login"
|
||||
if grep -q '"error":null' "$TMP/login"; then ok "live: rpc login"; else bad "live: rpc login"; fi
|
||||
call() {
|
||||
local csrf; csrf=$(awk '/^[^#]/ && /csrf_token/ {print $7; exit}' "$JAR")
|
||||
curl -ksS -b "$JAR" -c "$JAR" -H "Content-Type: application/json" \
|
||||
-H "X-CSRF-Token: $csrf" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"method\":\"$1\",\"params\":${2:-{\}},\"id\":2}" \
|
||||
--max-time 60 "$RPC"
|
||||
}
|
||||
ST=$(call mesh.status)
|
||||
echo "$ST" | grep -q '"device_connected":true' \
|
||||
&& ok "live: radio connected ($(echo "$ST" | grep -o '"device_type":"[a-z]*"'))" \
|
||||
|| bad "live: radio connected"
|
||||
echo "$ST" | grep -q '"self_advert_name":"[^"]' \
|
||||
&& ok "live: node has a mesh name" || bad "live: node has a mesh name"
|
||||
call mesh.refresh | grep -q '"refreshed":true' \
|
||||
&& ok "live: mesh.refresh" || bad "live: mesh.refresh"
|
||||
call mesh.broadcast | grep -q '"broadcast":true' \
|
||||
&& ok "live: mesh.broadcast" || bad "live: mesh.broadcast"
|
||||
# No peer may ever display a raw identity blob as its name.
|
||||
call mesh.peers | grep -q '"advert_name":"ARCHY:' \
|
||||
&& bad "live: no ARCHY-blob peer names" || ok "live: no ARCHY-blob peer names"
|
||||
fi
|
||||
|
||||
[ "$FAIL" = 0 ] && echo "PASS" || { echo "FAIL"; exit 1; }
|
||||
Reference in New Issue
Block a user