diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index 7a22b8b3..3a74bb95 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -383,6 +383,7 @@ impl RpcHandler { // Mesh networking (Meshcore LoRa) "mesh.status" => self.handle_mesh_status().await, + "mesh.probe-device" => self.handle_mesh_probe_device(params).await, "mesh.peers" => self.handle_mesh_peers().await, "mesh.messages" => self.handle_mesh_messages(params).await, "mesh.debug-dump" => self.handle_mesh_debug_dump().await, diff --git a/core/archipelago/src/api/rpc/mesh/messaging.rs b/core/archipelago/src/api/rpc/mesh/messaging.rs index 01ee5107..3d4da867 100644 --- a/core/archipelago/src/api/rpc/mesh/messaging.rs +++ b/core/archipelago/src/api/rpc/mesh/messaging.rs @@ -181,6 +181,11 @@ impl RpcHandler { config.lora_radio_params = Some(parsed); } } + // Hot-swap "keep as is": false = never write config to the radio + // (region/channel/PHY/advert name) — use it exactly as flashed. + if let Some(manage) = params.get("manage_radio").and_then(|v| v.as_bool()) { + config.manage_radio = manage; + } // Firmware pin: probe only the named firmware on the port ("auto"/"" // clears the pin and restores strict-probe auto-detect). if let Some(kind) = params.get("device_kind").and_then(|v| v.as_str()) { diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 74332a69..2f004ffd 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -52,6 +52,9 @@ impl RpcHandler { .map(|k| k.to_string().to_lowercase()) .into(), ); + // Hot-swap "keep as is" state: false = archipelago never writes + // config to the radio (see MeshConfig::manage_radio). + obj.insert("manage_radio".into(), config.manage_radio.into()); // USB identity per detected port so the setup modal can show the // actual board (product string on native-USB boards, vid:pid as // the fallback for bridge chips). @@ -78,6 +81,35 @@ impl RpcHandler { Ok(value) } + /// mesh.probe-device — Identify the firmware on a detected serial port and + /// read its current configuration WITHOUT provisioning it. Powers the + /// hot-swap "device detected" modal's current-details view. The path must + /// be one of the currently detected candidate ports (no arbitrary device + /// paths), and probing the live session's own port is refused. + pub(in crate::api::rpc) async fn handle_mesh_probe_device( + &self, + params: Option, + ) -> Result { + 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 detected = mesh::detect_devices().await; + anyhow::ensure!( + 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?, + }; + Ok(serde_json::to_value(probe)?) + } + /// mesh.peers — List discovered mesh peers. pub(in crate::api::rpc) async fn handle_mesh_peers(&self) -> Result { let service = self.mesh_service.read().await; diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 4f2b9117..2dce9bde 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -15,6 +15,8 @@ mod frames; mod node_cmd; mod session; +pub(crate) use session::{probe_device, DeviceProbe}; + use super::types::*; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -525,6 +527,7 @@ pub fn spawn_mesh_listener( channel_name: Option, device_kind: Option, reticulum_tcp: Option, + manage_radio: bool, shutdown: tokio::sync::watch::Receiver, cmd_rx: mpsc::Receiver, ) -> tokio::task::JoinHandle<()> { @@ -561,6 +564,7 @@ pub fn spawn_mesh_listener( channel_name.as_deref(), device_kind, reticulum_tcp.clone(), + manage_radio, &mut shutdown, &mut cmd_rx, ) @@ -600,11 +604,17 @@ pub fn spawn_mesh_listener( } } - // Update status to disconnected + // 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 + // ("meshcore, disconnected") while a different stick sat in the + // port. Unknown-until-next-successful-connect is the honest state. { let mut status = state.status.write().await; status.device_connected = false; status.device_path = None; + status.device_type = super::types::DeviceType::Unknown; + status.firmware_version = None; } let _ = state.event_tx.send(MeshEvent::DeviceDisconnected); diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index a6e4c6c7..b27acaf8 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -340,6 +340,83 @@ async fn auto_detect_and_open( ) } +/// What the hot-swap probe learned about a just-plugged radio, without +/// provisioning anything. Serialized straight into the `mesh.probe-device` +/// RPC response for the device setup modal. +#[derive(Debug, Clone, serde::Serialize)] +pub struct DeviceProbe { + pub path: String, + /// "reticulum" | "meshcore" | "meshtastic" + pub kind: String, + pub firmware_version: Option, + pub node_id: Option, + pub advert_name: Option, + /// Meshtastic-only: current radio config, read via `want_config`. + pub region: Option, + pub modem_preset: Option, + pub primary_channel: Option, + pub secondary_channel: Option, + pub max_contacts: Option, +} + +/// Probe a serial port for a mesh radio WITHOUT provisioning it: identify the +/// firmware (same strict Reticulum→Meshcore→Meshtastic order as auto-detect, +/// for the same RNode-wedging reason) and read what's currently configured on +/// it. The port is released when this returns, so the listener's reconnect +/// loop can pick the device up afterwards. Reticulum uses the bare KISS +/// DETECT probe — no daemon spawn just to identify a stick. +pub(crate) async fn probe_device(path: &str) -> Result { + if super::super::reticulum::probe_rnode(path).await.is_ok() { + return Ok(DeviceProbe { + path: path.to_string(), + kind: "reticulum".to_string(), + firmware_version: Some("RNode (KISS)".to_string()), + node_id: None, + advert_name: None, + region: None, + modem_preset: None, + primary_channel: None, + secondary_channel: None, + max_contacts: None, + }); + } + if let Ok(mut dev) = MeshcoreDevice::open(path).await { + if let Ok(info) = dev.initialize().await { + let queried = dev.query_device_info().await; + return Ok(DeviceProbe { + path: path.to_string(), + kind: "meshcore".to_string(), + firmware_version: queried.as_ref().map(|(v, _)| v.clone()), + node_id: Some(info.node_id), + advert_name: dev.advert_name(), + region: None, + modem_preset: None, + primary_channel: None, + secondary_channel: None, + max_contacts: queried.map(|(_, m)| m).or(Some(info.max_contacts)), + }); + } + } + if let Ok(mut dev) = MeshtasticDevice::open(path).await { + if let Ok(info) = dev.initialize().await { + let cfg = dev.probed_config(); + return Ok(DeviceProbe { + path: path.to_string(), + kind: "meshtastic".to_string(), + firmware_version: Some(info.firmware_version.clone()), + node_id: Some(info.node_id), + advert_name: dev.advert_name(), + region: cfg.region, + modem_preset: cfg.modem_preset, + primary_channel: cfg.primary_channel, + secondary_channel: cfg.secondary_channel, + max_contacts: Some(info.max_contacts), + }); + } + } + anyhow::bail!("no supported mesh radio firmware answered on {path}") +} + async fn open_preferred_path( path: &str, data_dir: &Path, @@ -859,6 +936,7 @@ pub(super) async fn run_mesh_session( channel_name: Option<&str>, device_kind: Option, reticulum_tcp: Option, + manage_radio: bool, shutdown: &mut tokio::sync::watch::Receiver, cmd_rx: &mut mpsc::Receiver, ) -> Result<()> { @@ -923,8 +1001,15 @@ pub(super) async fn run_mesh_session( // re-handshake the freshly-rebooted radio (and then set its name on the // reconnect, where the region already matches and no reboot occurs). use std::sync::atomic::Ordering; + if !manage_radio { + // Keep-as-is mode (hot-swap "keep as is" decision): the operator told + // us to use whatever is flashed/configured on this radio. Skip every + // config write below — region, channel, PHY params, advert name — and + // run with the device's own settings. + info!("manage_radio=false — leaving the radio's own configuration untouched"); + } let region_attempts = REGION_PROVISION_ATTEMPTS.load(Ordering::Relaxed); - if region_attempts < MAX_REGION_PROVISION_ATTEMPTS { + if manage_radio && region_attempts < MAX_REGION_PROVISION_ATTEMPTS { match device.ensure_lora_region(lora_region).await { Ok(true) => { REGION_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed); @@ -943,7 +1028,7 @@ pub(super) async fn run_mesh_session( Ok(false) => REGION_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed), Err(e) => warn!("Failed to provision LoRa region: {}", e), } - } else if lora_region.is_some() { + } else if manage_radio && lora_region.is_some() { warn!( region = lora_region.unwrap_or(""), attempts = MAX_REGION_PROVISION_ATTEMPTS, @@ -958,7 +1043,7 @@ pub(super) async fn run_mesh_session( // the radio). Without a matching channel two same-region radios still can't // decode each other's traffic. Same retry-cap + restart-on-change pattern. let channel_attempts = CHANNEL_PROVISION_ATTEMPTS.load(Ordering::Relaxed); - if channel_attempts < MAX_REGION_PROVISION_ATTEMPTS { + if manage_radio && channel_attempts < MAX_REGION_PROVISION_ATTEMPTS { match device.ensure_channel(channel_name).await { Ok(true) => { CHANNEL_PROVISION_ATTEMPTS.fetch_add(1, Ordering::Relaxed); @@ -974,7 +1059,7 @@ pub(super) async fn run_mesh_session( Ok(false) => CHANNEL_PROVISION_ATTEMPTS.store(0, Ordering::Relaxed), Err(e) => warn!("Failed to provision mesh channel: {}", e), } - } else if channel_name.is_some() { + } else if manage_radio && channel_name.is_some() { warn!( channel = channel_name.unwrap_or(""), attempts = MAX_REGION_PROVISION_ATTEMPTS, @@ -991,7 +1076,9 @@ pub(super) async fn run_mesh_session( // versions), so we send the set-command once per configured value and never // reboot-loop a radio that refuses it. The firmware reboots on RESP_OK, so // a successful write restarts the session like the region path. - if let (Some(params), MeshRadioDevice::Meshcore(dev)) = (lora_radio_params, &mut device) { + if let (true, Some(params), MeshRadioDevice::Meshcore(dev)) = + (manage_radio, lora_radio_params, &mut device) + { let marker_path = data_dir.join("meshcore-radio-params.json"); let applied: Option = tokio::fs::read(&marker_path) .await @@ -1040,23 +1127,27 @@ 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. - let advert_name = if let Some(name) = server_name { - // Meshcore firmware limits advert names — truncate to 20 chars - name.chars().take(20).collect::() - } else { - let short_did = our_did.chars().skip(8).take(8).collect::(); - format!("Archy-{}", short_did) - }; - if let Err(e) = device.set_advert_name(&advert_name).await { - warn!("Failed to set advert name: {}", e); - } else { - // Reflect the post-set name in MeshStatus too so the UI can filter - // its own radio echo from the peer list. Without this, the status - // 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()); + // 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::() + } else { + let short_did = our_did.chars().skip(8).take(8).collect::(); + format!("Archy-{}", short_did) + }; + if let Err(e) = device.set_advert_name(&advert_name).await { + warn!("Failed to set advert name: {}", e); + } else { + // Reflect the post-set name in MeshStatus too so the UI can filter + // its own radio echo from the peer list. Without this, the status + // 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()); + } } // Broadcast our advertisement so other nodes can discover us diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index 0163c460..e68c2bb5 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -167,7 +167,40 @@ pub struct MeshtasticDevice { pending_reinit: bool, } +/// Read-only snapshot of the radio config learned during `initialize`'s +/// `want_config` stream — surfaced by the hot-swap probe so the setup modal +/// can show what's currently on a just-plugged radio. +#[derive(Debug, Clone)] +pub struct MeshtasticProbedConfig { + pub region: Option, + pub modem_preset: Option, + pub primary_channel: Option, + pub secondary_channel: Option, +} + impl MeshtasticDevice { + /// See [`MeshtasticProbedConfig`]. Everything here was learned via reads + /// (`want_config`) — safe for a probe that must not modify the device. + pub fn probed_config(&self) -> MeshtasticProbedConfig { + MeshtasticProbedConfig { + region: self + .current_region + .and_then(region_code_to_name) + .map(str::to_string), + modem_preset: self + .current_modem_preset + .and_then(modem_preset_name) + .map(str::to_string), + primary_channel: self + .current_primary_channel + .as_ref() + .map(|(name, _)| if name.is_empty() { "(default public)".to_string() } else { name.clone() }), + secondary_channel: self + .current_secondary_channel + .as_ref() + .map(|(name, _)| name.clone()), + } + } pub async fn open(path: &str) -> Result { match tokio::fs::metadata(path).await { Ok(meta) => { @@ -1321,6 +1354,53 @@ fn derive_channel_psk(channel_name: &str) -> Vec { /// Map a Meshtastic `RegionCode` name (as set in `mesh-config.json`, e.g. /// "EU_868", "US", "ANZ") to its protobuf enum value. Case-insensitive. /// Returns `None` for an unrecognised name so we never write a bogus region. +/// Inverse of `region_name_to_code` — for showing a probed radio's current +/// region to the user (hot-swap setup modal). +pub(crate) fn region_code_to_name(code: u32) -> Option<&'static str> { + Some(match code { + 0 => "UNSET", + 1 => "US", + 2 => "EU_433", + 3 => "EU_868", + 4 => "CN", + 5 => "JP", + 6 => "ANZ", + 7 => "KR", + 8 => "TW", + 9 => "RU", + 10 => "IN", + 11 => "NZ_865", + 12 => "TH", + 13 => "LORA_24", + 14 => "UA_433", + 15 => "UA_868", + 16 => "MY_433", + 17 => "MY_919", + 18 => "SG_923", + 19 => "PH_433", + 20 => "PH_868", + 21 => "PH_915", + 22 => "ANZ_433", + _ => return None, + }) +} + +/// Meshtastic `Config.LoRaConfig.ModemPreset` enum names, for display. +pub(crate) fn modem_preset_name(code: u32) -> Option<&'static str> { + Some(match code { + 0 => "LONG_FAST", + 1 => "LONG_SLOW", + 2 => "VERY_LONG_SLOW", + 3 => "MEDIUM_SLOW", + 4 => "MEDIUM_FAST", + 5 => "SHORT_SLOW", + 6 => "SHORT_FAST", + 7 => "LONG_MODERATE", + 8 => "SHORT_TURBO", + _ => return None, + }) +} + pub(crate) fn region_name_to_code(name: &str) -> Option { Some(match name.trim().to_uppercase().as_str() { "UNSET" => 0, diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 62681b16..4b2b76bf 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -420,6 +420,12 @@ pub struct MeshConfig { /// serial-only/auto-detect behavior unchanged. #[serde(default)] pub reticulum_tcp: Option, + /// Whether archipelago manages the radio's configuration (region, shared + /// channel, PHY params, advert name) on connect. `true` (default) is + /// today's behavior. `false` = the hot-swap modal's "keep as is": use the + /// radio exactly as flashed — no config writes at all. + #[serde(default = "default_true")] + pub manage_radio: bool, } /// True when `name` is a LoRa region the Meshtastic driver can program @@ -458,6 +464,7 @@ impl Default for MeshConfig { assistant_allowed_contacts: Vec::new(), device_kind: None, reticulum_tcp: None, + manage_radio: true, } } } @@ -755,6 +762,7 @@ impl MeshService { self.config.channel_name.clone(), self.config.device_kind, self.config.reticulum_tcp.clone(), + self.config.manage_radio, shutdown_rx, cmd_rx, ); @@ -1011,6 +1019,21 @@ 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 + /// 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 { + 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"); + } + drop(status); + listener::probe_device(path).await + } + /// Get message history. pub async fn messages(&self, limit: Option) -> Vec { let messages = self.state.messages.read().await; diff --git a/core/archipelago/src/mesh/reticulum.rs b/core/archipelago/src/mesh/reticulum.rs index b278eb76..4a6459c5 100644 --- a/core/archipelago/src/mesh/reticulum.rs +++ b/core/archipelago/src/mesh/reticulum.rs @@ -1083,7 +1083,7 @@ fn parse_hash16(hex_str: &str) -> Result<[u8; 16]> { /// Send the verified RNode KISS detect sequence and look for `DETECT_RESP`. /// Bytes confirmed against the canonical Reticulum source — see the module /// doc comment and `docs/RETICULUM-TRANSPORT-PROGRESS.md`. -async fn probe_rnode(path: &str) -> Result<()> { +pub(crate) async fn probe_rnode(path: &str) -> Result<()> { let port = serial2_tokio::SerialPort::open(path, PROBE_BAUD) .with_context(|| format!("Failed to open {} for Reticulum probe", path))?; // ESP32-S3 native-USB boards (Heltec V3/V4 etc. — no separate USB-UART diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 6caa3a03..f87eb86d 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -149,6 +149,35 @@ impl MeshcoreDevice { Ok(info) } + /// The advert name learned from SELF_INFO during `initialize`. + pub fn advert_name(&self) -> Option { + self.advert_name.clone() + } + + /// Read firmware version + contact capacity via CMD_DEVICE_QUERY (0x16). + /// Read-only — used by the hot-swap probe to show what's on a + /// just-plugged radio. Tolerates interleaved push frames and firmware + /// that doesn't answer the query (returns None rather than erroring). + pub async fn query_device_info(&mut self) -> Option<(String, u16)> { + if self + .send_raw(&protocol::build_device_query()) + .await + .is_err() + { + return None; + } + for _ in 0..5 { + match self.recv_frame_timeout(Duration::from_secs(2)).await { + Ok(f) if f.code == protocol::RESP_DEVICE_INFO => { + return protocol::parse_device_info(&f.data).ok(); + } + Ok(_) => continue, // push notification — keep waiting + Err(_) => return None, + } + } + None + } + /// Set the advertised name on the mesh network. pub async fn set_advert_name(&mut self, name: &str) -> Result<()> { self.send_raw(&protocol::build_set_advert_name(name)) diff --git a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue index fdfbabfd..c33e56e5 100644 --- a/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue +++ b/neode-ui/src/components/mesh/MeshDeviceSetupModal.vue @@ -1,14 +1,14 @@