pulled in main

This commit is contained in:
ssmithx 2026-07-23 21:42:02 +00:00
parent 4222a8507c
commit 078f3b3619
4 changed files with 88 additions and 14 deletions

View File

@ -101,12 +101,21 @@ impl RpcHandler {
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?,
};
// Only hold the mesh_service lock long enough for the quick
// active-path guard check — NEVER across the actual probe, which
// can take 15-60s across its internal collision retries. Confirmed
// live 2026-07-23: holding this read lock for the full probe starved
// a concurrent firmware-flash job's MeshService::stop() (which needs
// the write lock) well past its own bounded timeout, surfacing as
// "Mesh listener did not release the serial port" even though
// stop() itself was fast.
{
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
svc.ensure_probe_allowed(&path).await?;
}
}
let probe = mesh::listener::probe_device(&path).await?;
Ok(serde_json::to_value(probe)?)
}

View File

@ -547,6 +547,10 @@ pub fn spawn_mesh_listener(
let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx;
let mut reconnect_delay = RECONNECT_DELAY_INIT;
// Mutable so a successful auto-detect can pin the firmware kind for
// the rest of this listener's lifetime — see the pin-on-first-success
// block below for why.
let mut device_kind = device_kind;
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
// builds a fresh device struct (contacts/current_region/etc. all
// start empty), so per-device session state is naturally isolated
@ -618,6 +622,45 @@ pub fn spawn_mesh_listener(
}
}
// Pin the firmware kind after the first successful auto-detect.
// Confirmed live 2026-07-23: with device_kind left unpinned (e.g.
// after clearing a stale pin), EVERY reconnect re-runs the full
// Reticulum→Meshcore→Meshtastic auto-detect cascade — each
// candidate past the first does its own open() with the DTR/RTS
// reset both boards need, so a device correctly identified as
// Meshtastic still gets reset once for the failed Meshcore
// attempt before Meshtastic's own open() resets it again. That
// doubled the reset count on every single reconnect indefinitely,
// not just during initial detection. Once auto-detect has
// identified the device this listener is actually talking to,
// there's no reason to keep guessing on subsequent reconnects —
// pin it, both in this task's own loop (takes effect
// immediately) and on disk (survives a service restart). A
// genuine hot-swap to different firmware is still handled: the
// setup modal's `mesh.probe-device` always re-probes unpinned,
// and the flash flow already clears this pin on its own.
if device_kind.is_none() {
let detected = state.status.read().await.device_type;
if detected != super::types::DeviceType::Unknown {
device_kind = Some(detected);
match super::load_config(&data_dir).await {
Ok(mut cfg) if cfg.device_kind.is_none() => {
cfg.device_kind = Some(detected);
if let Err(e) = super::save_config(&data_dir, &cfg).await {
warn!("Failed to persist auto-detected device_kind: {}", e);
} else {
info!(
kind = %detected,
"Pinned auto-detected firmware kind to avoid repeated multi-protocol resets on reconnect"
);
}
}
Ok(_) => {}
Err(e) => warn!("Failed to load mesh config to persist device_kind: {}", e),
}
}
}
// 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

View File

@ -1064,19 +1064,17 @@ 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
/// Refuse 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> {
/// with the reconnect loop (whichever loses just retries). Split out from
/// 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");
}
drop(status);
listener::probe_device(path).await
Ok(())
}
/// Get message history.

View File

@ -556,14 +556,38 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
/// Scan for serial devices that could be Meshcore radios.
/// Returns paths to existing serial device files.
///
/// Dedupes by canonical (symlink-resolved) target: `/dev/mesh-radio` is a
/// stable udev symlink to whatever `/dev/ttyUSB*`/`/dev/ttyACM*` node the
/// primary radio currently enumerates as, so both names always pointed at
/// the same candidate list entry and both passed this scan — confirmed live
/// 2026-07-23, this made an already-connected, working radio (connected via
/// its `/dev/mesh-radio` alias) simultaneously appear as a second, separate
/// "detected but unclaimed" device under its raw `/dev/ttyUSBn` name. The
/// hot-swap UI's active-session guard compares path strings, so it didn't
/// recognize the two aliases as the same port, showed the "device detected"
/// modal for a radio that was already set up, and probing it there opened
/// (and DTR/RTS-reset) the exact port the live session was mid-conversation
/// with — a continuous, UI-driven reset loop that only ran while that view
/// was open (matches the reported "stops when I leave, resumes when I come
/// back"). SERIAL_CANDIDATES lists `/dev/mesh-radio` first, so it wins the
/// dedup and is what's reported when both alias and target are present.
pub async fn detect_serial_devices() -> Vec<String> {
let mut devices = Vec::new();
let mut seen_real_paths = std::collections::HashSet::new();
for path in SERIAL_CANDIDATES {
if tokio::fs::metadata(path).await.is_ok() {
if likely_non_mesh_serial_device(path) {
debug!(path = %path, "Skipping known non-mesh serial device");
continue;
}
let real_path = tokio::fs::canonicalize(path)
.await
.unwrap_or_else(|_| std::path::PathBuf::from(path));
if !seen_real_paths.insert(real_path.clone()) {
debug!(path = %path, real_path = %real_path.display(), "Skipping duplicate alias for an already-listed device");
continue;
}
devices.push(path.to_string());
}
}