pulled in main
This commit is contained in:
parent
4222a8507c
commit
078f3b3619
@ -101,12 +101,21 @@ impl RpcHandler {
|
|||||||
detected.iter().any(|d| d == &path),
|
detected.iter().any(|d| d == &path),
|
||||||
"{path} is not a detected mesh-radio candidate port"
|
"{path} is not a detected mesh-radio candidate port"
|
||||||
);
|
);
|
||||||
let service = self.mesh_service.read().await;
|
// Only hold the mesh_service lock long enough for the quick
|
||||||
let probe = match service.as_ref() {
|
// active-path guard check — NEVER across the actual probe, which
|
||||||
Some(svc) => svc.probe_device(&path).await?,
|
// can take 15-60s across its internal collision retries. Confirmed
|
||||||
// No mesh service yet (radio never enabled) — probe directly.
|
// live 2026-07-23: holding this read lock for the full probe starved
|
||||||
None => mesh::listener::probe_device(&path).await?,
|
// 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)?)
|
Ok(serde_json::to_value(probe)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -547,6 +547,10 @@ pub fn spawn_mesh_listener(
|
|||||||
let mut shutdown = shutdown;
|
let mut shutdown = shutdown;
|
||||||
let mut cmd_rx = cmd_rx;
|
let mut cmd_rx = cmd_rx;
|
||||||
let mut reconnect_delay = RECONNECT_DELAY_INIT;
|
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
|
// Backlog #12 hot-swap re-binding: each run_mesh_session call already
|
||||||
// builds a fresh device struct (contacts/current_region/etc. all
|
// builds a fresh device struct (contacts/current_region/etc. all
|
||||||
// start empty), so per-device session state is naturally isolated
|
// 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
|
// Update status to disconnected. device_type/firmware_version are
|
||||||
// reset too — they were previously left holding the LAST radio's
|
// reset too — they were previously left holding the LAST radio's
|
||||||
// identity, so after a hot-swap the UI showed the old firmware
|
// identity, so after a hot-swap the UI showed the old firmware
|
||||||
|
|||||||
@ -1064,19 +1064,17 @@ impl MeshService {
|
|||||||
self.state.peers.read().await.values().cloned().collect()
|
self.state.peers.read().await.values().cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe a serial port for a mesh radio without provisioning or keeping
|
/// Refuse to probe the port the live session currently occupies (the
|
||||||
/// it — powers the hot-swap "device detected" modal's current-details
|
/// probe would steal the serial port from under the session); a
|
||||||
/// 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
|
/// detected-but-not-connected port is fair game, accepting a benign race
|
||||||
/// with the reconnect loop (whichever loses just retries).
|
/// with the reconnect loop (whichever loses just retries). Split out from
|
||||||
pub async fn probe_device(&self, path: &str) -> Result<listener::DeviceProbe> {
|
/// 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;
|
let status = self.state.status.read().await;
|
||||||
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
if status.device_connected && status.device_path.as_deref() == Some(path) {
|
||||||
anyhow::bail!("{path} is the active mesh radio — already connected");
|
anyhow::bail!("{path} is the active mesh radio — already connected");
|
||||||
}
|
}
|
||||||
drop(status);
|
Ok(())
|
||||||
listener::probe_device(path).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get message history.
|
/// Get message history.
|
||||||
|
|||||||
@ -556,14 +556,38 @@ fn likely_non_mesh_serial_device(path: &str) -> bool {
|
|||||||
|
|
||||||
/// Scan for serial devices that could be Meshcore radios.
|
/// Scan for serial devices that could be Meshcore radios.
|
||||||
/// Returns paths to existing serial device files.
|
/// 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> {
|
pub async fn detect_serial_devices() -> Vec<String> {
|
||||||
let mut devices = Vec::new();
|
let mut devices = Vec::new();
|
||||||
|
let mut seen_real_paths = std::collections::HashSet::new();
|
||||||
for path in SERIAL_CANDIDATES {
|
for path in SERIAL_CANDIDATES {
|
||||||
if tokio::fs::metadata(path).await.is_ok() {
|
if tokio::fs::metadata(path).await.is_ok() {
|
||||||
if likely_non_mesh_serial_device(path) {
|
if likely_non_mesh_serial_device(path) {
|
||||||
debug!(path = %path, "Skipping known non-mesh serial device");
|
debug!(path = %path, "Skipping known non-mesh serial device");
|
||||||
continue;
|
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());
|
devices.push(path.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user