feat(mesh): radio hot-swap — probe on plug-in, keep-as-is vs apply-our-settings
Demo images / Build & push demo images (push) Failing after 1m49s

Plugging in any LoRa radio now surfaces the device setup modal EVERY time
(dismissals clear on unplug; works while mesh is enabled; 2-poll debounce
so ordinary reconnect blips don't flash it), showing what's currently on
the stick before anything is written:

- mesh.probe-device RPC: identifies the firmware read-only in the same
  Reticulum->Meshcore->Meshtastic order as auto-detect. Meshtastic yields
  region/modem-preset/channels (want_config is a read); MeshCore yields
  name/node-id + firmware version via the previously-unused
  CMD_DEVICE_QUERY; RNode via the bare KISS DETECT (no daemon spawn).
  Path must be a detected candidate port; the live session's port refuses.
- "Keep As Is": manage_radio=false persists in MeshConfig and the session
  skips every on-connect config write (region, channel, PHY params, advert
  name) — the radio runs exactly as flashed, hot-swappable.
- "Set Up with Archipelago Settings": second screen shows the params that
  will be applied (channel, region, the node's persisted RF params - e.g.
  the validated Portugal preset - with the per-region community plan as
  fallback) before writing anything.
- Stale-type fix: disconnect now resets device_type/firmware_version, so
  a swapped stick no longer shows the previous radio's firmware; probed
  kind pins device_kind so the right probe runs first on connect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-22 16:25:19 -04:00
co-authored by Claude Fable 5
parent 2f26cb2bc4
commit 8b744d377c
11 changed files with 550 additions and 93 deletions
@@ -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,
@@ -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()) {
@@ -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<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 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<serde_json::Value> {
let service = self.mesh_service.read().await;