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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user