feat(mesh): radio hot-swap — probe on plug-in, keep-as-is vs apply-our-settings
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m49s
Some checks failed
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:
parent
2f26cb2bc4
commit
8b744d377c
@ -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;
|
||||
|
||||
@ -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<String>,
|
||||
device_kind: Option<super::types::DeviceType>,
|
||||
reticulum_tcp: Option<super::types::ReticulumTcpConfig>,
|
||||
manage_radio: bool,
|
||||
shutdown: tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: mpsc::Receiver<MeshCommand>,
|
||||
) -> 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);
|
||||
|
||||
|
||||
@ -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<String>,
|
||||
pub node_id: Option<u32>,
|
||||
pub advert_name: Option<String>,
|
||||
/// Meshtastic-only: current radio config, read via `want_config`.
|
||||
pub region: Option<String>,
|
||||
pub modem_preset: Option<String>,
|
||||
pub primary_channel: Option<String>,
|
||||
pub secondary_channel: Option<String>,
|
||||
pub max_contacts: Option<u16>,
|
||||
}
|
||||
|
||||
/// 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<DeviceProbe> {
|
||||
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<DeviceType>,
|
||||
reticulum_tcp: Option<ReticulumTcpConfig>,
|
||||
manage_radio: bool,
|
||||
shutdown: &mut tokio::sync::watch::Receiver<bool>,
|
||||
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
|
||||
) -> 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<crate::mesh::LoraRadioParams> = 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::<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 {
|
||||
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::<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 {
|
||||
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
|
||||
|
||||
@ -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<String>,
|
||||
pub modem_preset: Option<String>,
|
||||
pub primary_channel: Option<String>,
|
||||
pub secondary_channel: Option<String>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => {
|
||||
@ -1321,6 +1354,53 @@ fn derive_channel_psk(channel_name: &str) -> Vec<u8> {
|
||||
/// 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<u32> {
|
||||
Some(match name.trim().to_uppercase().as_str() {
|
||||
"UNSET" => 0,
|
||||
|
||||
@ -420,6 +420,12 @@ pub struct MeshConfig {
|
||||
/// serial-only/auto-detect behavior unchanged.
|
||||
#[serde(default)]
|
||||
pub reticulum_tcp: Option<types::ReticulumTcpConfig>,
|
||||
/// 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<listener::DeviceProbe> {
|
||||
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<usize>) -> Vec<MeshMessage> {
|
||||
let messages = self.state.messages.read().await;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -149,6 +149,35 @@ impl MeshcoreDevice {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// The advert name learned from SELF_INFO during `initialize`.
|
||||
pub fn advert_name(&self) -> Option<String> {
|
||||
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))
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
:title="step === 1 ? 'Mesh Device Detected' : 'Set Up Your Mesh Radio'"
|
||||
:title="step === 1 ? 'Mesh Radio Detected' : 'Apply Archipelago Settings'"
|
||||
max-width="max-w-lg"
|
||||
content-class="max-h-[90vh] overflow-y-auto"
|
||||
@close="dismiss"
|
||||
>
|
||||
<!-- Step 1: detection + hardware graphic -->
|
||||
<!-- Step 1: detection graphic + what's currently on the radio -->
|
||||
<div v-if="step === 1" class="text-center">
|
||||
<div class="mx-auto my-2 w-44 h-44 relative">
|
||||
<div class="mx-auto my-2 w-40 h-40 relative">
|
||||
<!-- pulsing signal waves behind the board image -->
|
||||
<svg viewBox="0 0 160 160" class="absolute inset-0 w-full h-full" aria-hidden="true">
|
||||
<g class="mesh-detect-waves" stroke="rgba(251,146,60,0.5)" fill="none" stroke-width="2.5" stroke-linecap="round">
|
||||
@ -32,26 +32,96 @@
|
||||
{{ deviceImage.exact ? 'Detected' : 'Detected LoRa radio' }} on
|
||||
<span class="font-mono text-orange-300">{{ devicePath }}</span>
|
||||
</p>
|
||||
<p class="text-white/50 text-xs mt-2">
|
||||
Connect it to join the off-grid mesh — chat, block headers, and payments
|
||||
keep flowing even without internet.
|
||||
</p>
|
||||
<div class="flex gap-2 mt-6">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="dismiss">
|
||||
Not Now
|
||||
|
||||
<!-- 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>
|
||||
<template v-else-if="probe">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded-md text-[11px] font-semibold"
|
||||
:class="{
|
||||
'bg-emerald-400/15 text-emerald-300': probe.kind === 'meshcore',
|
||||
'bg-sky-400/15 text-sky-300': probe.kind === 'meshtastic',
|
||||
'bg-violet-400/15 text-violet-300': probe.kind === 'reticulum',
|
||||
}">{{ kindLabel }}</span>
|
||||
<span v-if="probe.firmware_version" class="text-white/50 text-xs truncate">{{ probe.firmware_version }}</span>
|
||||
</div>
|
||||
<dl class="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<template v-if="probe.advert_name">
|
||||
<dt class="text-white/40">Name</dt><dd class="text-white/80">{{ probe.advert_name }}</dd>
|
||||
</template>
|
||||
<template v-if="probe.node_id != null">
|
||||
<dt class="text-white/40">Node ID</dt><dd class="text-white/80 font-mono">{{ probe.node_id }}</dd>
|
||||
</template>
|
||||
<template v-if="probe.region">
|
||||
<dt class="text-white/40">Region</dt><dd class="text-white/80">{{ probe.region }}</dd>
|
||||
</template>
|
||||
<template v-if="probe.modem_preset">
|
||||
<dt class="text-white/40">Preset</dt><dd class="text-white/80">{{ probe.modem_preset }}</dd>
|
||||
</template>
|
||||
<template v-if="probe.primary_channel">
|
||||
<dt class="text-white/40">Channel</dt><dd class="text-white/80">{{ probe.primary_channel }}</dd>
|
||||
</template>
|
||||
<template v-if="probe.secondary_channel">
|
||||
<dt class="text-white/40">2nd channel</dt><dd class="text-white/80">{{ probe.secondary_channel }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
<p v-else class="text-white/50 text-xs py-1">
|
||||
Couldn't identify the firmware on this radio{{ probeError ? ` (${probeError})` : '' }} —
|
||||
you can still set it up (auto-detect will keep trying) or use it as-is.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-5">
|
||||
<button
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm disabled:opacity-50"
|
||||
:disabled="!!connecting"
|
||||
@click="keepAsIs"
|
||||
>
|
||||
{{ connecting === 'keep' ? 'Connecting…' : 'Keep As Is' }}
|
||||
</button>
|
||||
<button class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium" @click="step = 2">
|
||||
Set Up
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="!!connecting"
|
||||
@click="step = 2"
|
||||
>
|
||||
Set Up with Archipelago Settings
|
||||
</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>
|
||||
<p v-if="error" class="text-xs text-red-400 mt-2">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: configuration with presets -->
|
||||
<!-- Step 2: our latest parameters, shown before anything is written -->
|
||||
<div v-else>
|
||||
<p class="text-white/60 text-xs mb-4">
|
||||
Archipelago presets are pre-selected — confirm the region and you're on the mesh.
|
||||
<p class="text-white/60 text-xs mb-3">
|
||||
These are the latest Archipelago settings — nothing is written to the
|
||||
radio until you confirm.
|
||||
</p>
|
||||
|
||||
<!-- Summary of what will be applied -->
|
||||
<div class="rounded-xl bg-white/[0.05] border border-white/10 p-3 mb-4">
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt class="text-white/40">Channel</dt>
|
||||
<dd class="text-white/80">{{ form.channel || 'archipelago' }} <span class="text-white/40">(+ public default kept for interop)</span></dd>
|
||||
<dt class="text-white/40">Name</dt>
|
||||
<dd class="text-white/80">{{ form.name || '(radio keeps its name)' }}</dd>
|
||||
<dt class="text-white/40">Region</dt>
|
||||
<dd class="text-white/80">{{ form.region || "(radio keeps its region)" }}</dd>
|
||||
<template v-if="effectiveKind === 'meshcore' && rfPreset">
|
||||
<dt class="text-white/40">RF params</dt>
|
||||
<dd class="text-white/80">{{ rfPreset.freqMhz }} MHz · {{ rfPreset.bwKhz }} kHz · SF{{ rfPreset.sf }} · CR4/{{ rfPreset.cr }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Region — the meaning adapts to the firmware: Meshtastic takes a
|
||||
region enum directly; MeshCore/RNode take raw RF params owned by
|
||||
@ -68,11 +138,8 @@
|
||||
<p v-if="selectedRegion && selectedRegion.dutyCyclePct < 100" class="text-[11px] text-amber-400/80 mt-1">
|
||||
{{ selectedRegion.code }} limits airtime to {{ selectedRegion.dutyCyclePct }}% duty cycle ({{ selectedRegion.band }} MHz) — the radio enforces this automatically.
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'meshcore' && meshcorePlan" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore community plan for {{ selectedRegion?.code }}: {{ meshcorePlan.freqMhz }} MHz, {{ meshcorePlan.bwKhz }} kHz, SF{{ meshcorePlan.sf }}, CR4/{{ meshcorePlan.cr }} — set on the radio via its MeshCore app if it differs.
|
||||
</p>
|
||||
<p v-else-if="effectiveKind === 'meshcore' && selectedRegion" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore radios keep their flashed RF settings — make sure the radio is on the {{ selectedRegion.band }} MHz band for {{ selectedRegion.code }}.
|
||||
<p v-if="effectiveKind === 'meshcore' && rfPreset" class="text-[11px] text-sky-300/80 mt-1">
|
||||
MeshCore RF params for {{ selectedRegion?.code }} are applied to the radio automatically on connect.
|
||||
</p>
|
||||
<p v-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
|
||||
RNode radio parameters (frequency / bandwidth / SF / CR) are managed by the Reticulum daemon's interface config on this node.
|
||||
@ -96,25 +163,6 @@
|
||||
Archipelago nodes find each other on the "archipelago" channel; the public default channel stays active for interop.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Firmware (advanced) -->
|
||||
<div>
|
||||
<button class="text-xs text-orange-300 hover:text-orange-200" @click="showAdvanced = !showAdvanced">
|
||||
{{ showAdvanced ? '▾' : '▸' }} Advanced
|
||||
</button>
|
||||
<div v-if="showAdvanced" class="mt-2">
|
||||
<label class="block text-sm text-white/80 mb-1">Radio firmware</label>
|
||||
<select v-model="form.deviceKind" 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="auto">Auto-detect (Meshcore → Meshtastic → RNode)</option>
|
||||
<option value="meshcore">MeshCore</option>
|
||||
<option value="meshtastic">Meshtastic</option>
|
||||
<option value="reticulum">Reticulum / RNode</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-white/40 mt-1">
|
||||
Pin this if you know what's flashed on the board — auto-detect probes each firmware in order.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-xs text-red-400 mt-3">{{ error }}</p>
|
||||
@ -123,10 +171,10 @@
|
||||
<button class="glass-button px-4 py-2 rounded-lg text-sm" @click="step = 1">Back</button>
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="connecting"
|
||||
@click="connect"
|
||||
:disabled="!!connecting"
|
||||
@click="applySetup"
|
||||
>
|
||||
{{ connecting ? 'Connecting…' : 'Connect to Mesh' }}
|
||||
{{ connecting === 'setup' ? 'Applying…' : 'Apply Settings & Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -137,7 +185,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useMeshStore } from '@/stores/mesh'
|
||||
import { useMeshStore, type MeshDeviceProbe, type MeshConfigureParams } from '@/stores/mesh'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { LORA_REGIONS, regionByCode, suggestRegionFromLatLon, meshcorePlanFor } from '@/utils/loraRegions'
|
||||
import { resolveMeshDeviceImage } from '@/utils/meshDeviceImages'
|
||||
@ -147,9 +195,11 @@ const appStore = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref<1 | 2>(1)
|
||||
const showAdvanced = ref(false)
|
||||
const connecting = ref(false)
|
||||
const connecting = ref<false | 'keep' | 'setup'>(false)
|
||||
const error = ref('')
|
||||
const probing = ref(false)
|
||||
const probe = ref<MeshDeviceProbe | null>(null)
|
||||
const probeError = ref('')
|
||||
|
||||
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
|
||||
const show = computed(() => !!devicePath.value)
|
||||
@ -160,6 +210,15 @@ const deviceImage = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
const kindLabel = computed(() => {
|
||||
switch (probe.value?.kind) {
|
||||
case 'meshcore': return 'MeshCore'
|
||||
case 'meshtastic': return 'Meshtastic'
|
||||
case 'reticulum': return 'Reticulum RNode'
|
||||
default: return ''
|
||||
}
|
||||
})
|
||||
|
||||
const suggestedRegion = computed(() => {
|
||||
const info = appStore.serverInfo as { lat?: number | null; lon?: number | null } | undefined
|
||||
if (info?.lat != null && info?.lon != null) {
|
||||
@ -172,52 +231,113 @@ const form = ref({
|
||||
region: '',
|
||||
name: '',
|
||||
channel: 'archipelago',
|
||||
deviceKind: 'auto',
|
||||
})
|
||||
|
||||
const selectedRegion = computed(() => regionByCode(form.value.region))
|
||||
// The firmware whose options we surface: the explicit pin wins, else the
|
||||
// last detected/connected type, else meshtastic-style (where presets apply).
|
||||
// The firmware whose options we surface: the probe result wins, else the
|
||||
// last connected type, else meshtastic-style (where presets apply).
|
||||
const effectiveKind = computed(() => {
|
||||
if (form.value.deviceKind !== 'auto') return form.value.deviceKind
|
||||
if (probe.value) return probe.value.kind
|
||||
const t = (mesh.status?.device_type ?? '').toLowerCase()
|
||||
return t === 'meshcore' || t === 'meshtastic' || t === 'reticulum' ? t : 'auto'
|
||||
})
|
||||
const meshcorePlan = computed(() => meshcorePlanFor(form.value.region))
|
||||
|
||||
// (Re)apply presets each time a new device surfaces the modal
|
||||
watch(show, (visible) => {
|
||||
if (visible) {
|
||||
step.value = 1
|
||||
error.value = ''
|
||||
imageFailed.value = false
|
||||
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
|
||||
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
|
||||
form.value.channel = mesh.status?.channel_name || 'archipelago'
|
||||
form.value.deviceKind = mesh.status?.device_kind ?? 'auto'
|
||||
// The node's persisted RF params (e.g. the validated Portugal preset) ARE
|
||||
// "our latest parameters" — the generic per-region community plan is only
|
||||
// the fallback for nodes that never configured RF params.
|
||||
const rfPreset = computed(() => {
|
||||
const cfg = mesh.status?.lora_radio_params
|
||||
if (cfg) {
|
||||
return {
|
||||
freqMhz: cfg.freq_khz / 1000,
|
||||
bwKhz: cfg.bw_hz / 1000,
|
||||
sf: cfg.sf,
|
||||
cr: cfg.cr,
|
||||
}
|
||||
}
|
||||
return meshcorePlanFor(form.value.region)
|
||||
})
|
||||
|
||||
// (Re)probe + (re)apply presets each time a new device surfaces the modal
|
||||
watch([show, devicePath], async ([visible]) => {
|
||||
if (!visible) return
|
||||
step.value = 1
|
||||
error.value = ''
|
||||
imageFailed.value = false
|
||||
form.value.region = suggestedRegion.value?.code ?? mesh.status?.lora_region ?? ''
|
||||
form.value.name = mesh.status?.self_advert_name ?? appStore.serverName ?? ''
|
||||
form.value.channel = mesh.status?.channel_name || 'archipelago'
|
||||
// Read-only probe of what's on the stick — drives the details card.
|
||||
probe.value = null
|
||||
probeError.value = ''
|
||||
probing.value = true
|
||||
const path = devicePath.value
|
||||
try {
|
||||
const res = await mesh.probeDevice(path)
|
||||
if (devicePath.value === path) probe.value = res
|
||||
} catch (e) {
|
||||
if (devicePath.value === path) {
|
||||
probeError.value = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
} finally {
|
||||
if (devicePath.value === path) probing.value = false
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
function dismiss() {
|
||||
if (devicePath.value) mesh.dismissDetectedDevice(devicePath.value)
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
connecting.value = true
|
||||
/** Use the radio exactly as flashed: no config writes, firmware pinned to
|
||||
* what the probe saw (auto if the probe failed), hot-swappable from here. */
|
||||
async function keepAsIs() {
|
||||
connecting.value = 'keep'
|
||||
error.value = ''
|
||||
try {
|
||||
const path = devicePath.value
|
||||
await mesh.configure({
|
||||
enabled: true,
|
||||
device_path: path,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||
...(form.value.region ? { lora_region: form.value.region } : {}),
|
||||
device_kind: form.value.deviceKind,
|
||||
device_kind: probe.value?.kind ?? 'auto',
|
||||
manage_radio: false,
|
||||
})
|
||||
mesh.dismissDetectedDevice(path)
|
||||
// The Mesh view lives under the dashboard shell — a bare /mesh 404s.
|
||||
void router.push('/dashboard/mesh')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to connect the mesh radio'
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the Archipelago parameters shown on this screen, then connect. */
|
||||
async function applySetup() {
|
||||
connecting.value = 'setup'
|
||||
error.value = ''
|
||||
try {
|
||||
const path = devicePath.value
|
||||
const params: MeshConfigureParams = {
|
||||
enabled: true,
|
||||
device_path: path,
|
||||
channel_name: form.value.channel.trim() || 'archipelago',
|
||||
...(form.value.name.trim() ? { advert_name: form.value.name.trim() } : {}),
|
||||
...(form.value.region ? { lora_region: form.value.region } : {}),
|
||||
device_kind: probe.value?.kind ?? 'auto',
|
||||
manage_radio: true,
|
||||
}
|
||||
// MeshCore radios get their RF params written directly (freq/bw/sf/cr in
|
||||
// firmware units) — same conversion as the Mesh settings panel.
|
||||
if (effectiveKind.value === 'meshcore' && rfPreset.value) {
|
||||
params.lora_radio_params = {
|
||||
freq_khz: Math.round(rfPreset.value.freqMhz * 1000),
|
||||
bw_hz: Math.round(rfPreset.value.bwKhz * 1000),
|
||||
sf: rfPreset.value.sf,
|
||||
cr: rfPreset.value.cr,
|
||||
}
|
||||
}
|
||||
await mesh.configure(params)
|
||||
mesh.dismissDetectedDevice(path)
|
||||
void router.push('/dashboard/mesh')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to configure the mesh radio'
|
||||
} finally {
|
||||
|
||||
@ -35,6 +35,24 @@ export interface MeshStatus {
|
||||
product?: string | null
|
||||
manufacturer?: string | null
|
||||
}>
|
||||
/** Hot-swap "keep as is": false = archipelago never writes config to the radio. */
|
||||
manage_radio?: boolean
|
||||
/** Persisted config: MeshCore LoRa PHY params (firmware units). */
|
||||
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
|
||||
}
|
||||
|
||||
/** What mesh.probe-device read off a just-plugged radio (no provisioning). */
|
||||
export interface MeshDeviceProbe {
|
||||
path: string
|
||||
kind: 'reticulum' | 'meshcore' | 'meshtastic'
|
||||
firmware_version: string | null
|
||||
node_id: number | null
|
||||
advert_name: string | null
|
||||
region: string | null
|
||||
modem_preset: string | null
|
||||
primary_channel: string | null
|
||||
secondary_channel: string | null
|
||||
max_contacts: number | null
|
||||
}
|
||||
|
||||
/** Params accepted by mesh.configure (superset of the status fields). */
|
||||
@ -51,6 +69,8 @@ export interface MeshConfigureParams {
|
||||
/** MeshCore LoRa PHY params in firmware field units (freq_khz = MHz×1000,
|
||||
* bw_hz = kHz×1000). null clears; omitted leaves unchanged. */
|
||||
lora_radio_params?: { freq_khz: number; bw_hz: number; sf: number; cr: number } | null
|
||||
/** Hot-swap "keep as is": false = use the radio exactly as flashed. */
|
||||
manage_radio?: boolean
|
||||
}
|
||||
|
||||
export interface MeshPeer {
|
||||
@ -255,6 +275,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
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'
|
||||
} finally {
|
||||
@ -264,20 +285,64 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
|
||||
// ── Global device-detection (for the app-wide "device detected" modal) ──
|
||||
// Ports the user dismissed the setup modal for, persisted per browser.
|
||||
// Dismissals are cleared the moment the port disappears (unplug), so the
|
||||
// modal re-appears on EVERY plug-in — including swapping a different stick
|
||||
// into the same /dev path — per the hot-swap UX (2026-07-22).
|
||||
const DETECT_DISMISS_KEY = 'archipelago.mesh.detect-dismissed.v1'
|
||||
const dismissedDetectedPaths = ref<Set<string>>(new Set(
|
||||
JSON.parse(localStorage.getItem(DETECT_DISMISS_KEY) || '[]') as string[]
|
||||
))
|
||||
// Consecutive polls each candidate port has been present-but-not-connected.
|
||||
// The modal waits for 2 sightings so it doesn't flash during the couple of
|
||||
// seconds an ordinary reconnect (same radio, transient blip) needs.
|
||||
const detectSightings = ref<Record<string, number>>({})
|
||||
const undismissedDetectedDevices = computed(() => {
|
||||
const s = status.value
|
||||
if (!s || s.device_connected || s.enabled) return []
|
||||
return (s.detected_devices || []).filter(p => !dismissedDetectedPaths.value.has(p))
|
||||
if (!s) return []
|
||||
return (s.detected_devices || []).filter(p =>
|
||||
!dismissedDetectedPaths.value.has(p) &&
|
||||
// The port the live session occupies is not a candidate…
|
||||
!(s.device_connected && s.device_path === p) &&
|
||||
// …and a port only qualifies once it has survived the blip debounce.
|
||||
(detectSightings.value[p] ?? 0) >= 2
|
||||
)
|
||||
})
|
||||
/** Called after every status fetch: advance sighting counters and clear
|
||||
* dismissals/counters for ports that vanished (unplug → replug reshows). */
|
||||
function trackDetectedDevices(s: MeshStatus) {
|
||||
const present = new Set(s.detected_devices || [])
|
||||
const next: Record<string, number> = {}
|
||||
for (const p of present) {
|
||||
next[p] = s.device_connected && s.device_path === p
|
||||
? 0
|
||||
: (detectSightings.value[p] ?? 0) + 1
|
||||
}
|
||||
detectSightings.value = next
|
||||
let dirty = false
|
||||
for (const p of [...dismissedDetectedPaths.value]) {
|
||||
if (!present.has(p)) {
|
||||
dismissedDetectedPaths.value.delete(p)
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
if (dirty) {
|
||||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
||||
}
|
||||
}
|
||||
function dismissDetectedDevice(path: string) {
|
||||
dismissedDetectedPaths.value.add(path)
|
||||
dismissedDetectedPaths.value = new Set(dismissedDetectedPaths.value)
|
||||
localStorage.setItem(DETECT_DISMISS_KEY, JSON.stringify([...dismissedDetectedPaths.value]))
|
||||
}
|
||||
/** Read-only firmware/config probe of a detected port (hot-swap modal). */
|
||||
async function probeDevice(path: string): Promise<MeshDeviceProbe> {
|
||||
return rpcClient.call<MeshDeviceProbe>({
|
||||
method: 'mesh.probe-device',
|
||||
params: { path },
|
||||
timeout: 45000, // serial probes are slow (multi-firmware handshakes)
|
||||
})
|
||||
}
|
||||
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). */
|
||||
@ -913,6 +978,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
fetchStatus,
|
||||
undismissedDetectedDevices,
|
||||
dismissDetectedDevice,
|
||||
probeDevice,
|
||||
startGlobalDetection,
|
||||
fetchPeers,
|
||||
fetchMessages,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user