Merge main into archy-hwconfig — reconcile probe/dedup/name work

Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-28 19:03:19 -04:00
co-authored by Claude Fable 5
173 changed files with 10967 additions and 1925 deletions
+161 -4
View File
@@ -478,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() {
@@ -773,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(),
@@ -1071,8 +1100,18 @@ impl MeshService {
/// 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 && status.device_path.as_deref() == Some(path) {
anyhow::bail!("{path} is the active mesh radio — already connected");
if status.device_connected {
if let Some(active) = status.device_path.as_deref() {
// Compare canonical paths: /dev/mesh-radio is a symlink to the
// ttyUSB*/ttyACM* node, and a probe through the alias would
// still open the very tty the live session is holding.
let canon = |p: &str| {
std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p))
};
if canon(active) == canon(path) {
anyhow::bail!("{path} is the active mesh radio — already connected");
}
}
}
Ok(())
}
@@ -1121,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"))?;
@@ -1259,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,
@@ -2088,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
@@ -2112,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)
@@ -2238,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,