From 549a9f22d2d6cbde4f415eb292585a9ccbb88570 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 15:40:58 -0600 Subject: [PATCH 1/7] fix(reticulum): recover macOS BLE RNode LTK desync without false Paired state Detect Peer-removed / LTK desync during exclusive LoRa GATT hold, purge the stale OS bond when possible, clear sticky alerts after online, and keep Disconnect & Quit from respawning the sidecar on exclusive release. --- docs/reticulum-sidecar-ipc.md | 4 +- reticulum-sidecar/Cargo.lock | 2 + reticulum-sidecar/Cargo.toml | 6 + reticulum-sidecar/patches/README.md | 4 +- reticulum-sidecar/src/api/gatt.rs | 21 + reticulum-sidecar/src/api/interfaces.rs | 55 ++ reticulum-sidecar/src/api/mod.rs | 12 + reticulum-sidecar/src/ble/error_classifier.rs | 163 ++++++ reticulum-sidecar/src/ble/mod.rs | 12 + reticulum-sidecar/src/ble/unbond.rs | 521 ++++++++++++++++++ reticulum-sidecar/src/gatt/lazy_backend.rs | 163 +++--- reticulum-sidecar/src/gatt/manager.rs | 23 + reticulum-sidecar/src/main.rs | 1 + reticulum-sidecar/src/stack/mod.rs | 2 +- ...apply-rsReticulum-ble-rnode-bond-desync.sh | 5 +- src/main/bluetoothSettings.test.ts | 30 + src/main/bluetoothSettings.ts | 31 ++ src/main/gatt-sidecar-proxy.test.ts | 28 + src/main/gatt-sidecar-proxy.ts | 94 ++++ src/main/index.contract.test.ts | 3 + src/main/index.ipc-security.test.ts | 2 + src/main/index.ts | 35 ++ src/main/ipc/reticulum-handlers.test.ts | 23 + src/main/ipc/reticulum-handlers.ts | 11 + src/main/reticulum-sidecar-manager.test.ts | 15 + src/main/reticulum-sidecar-manager.ts | 112 ++++ src/main/reticulumSidecarIssueTracker.test.ts | 21 +- src/main/reticulumSidecarIssueTracker.ts | 29 +- src/preload/index.ts | 7 + src/renderer/App.tsx | 2 + .../components/ReticulumAdminPanel.test.tsx | 47 +- .../components/ReticulumAdminPanel.tsx | 38 +- ...eticulumLocalInterfaceAlertsBlock.test.tsx | 6 +- .../flasher/RNodeFlasherSection.tsx | 102 ++-- src/renderer/lib/bleReconnectHelper.ts | 8 +- src/renderer/lib/connection.ts | 5 +- src/renderer/lib/devElectronApiStub.ts | 3 + .../lib/gattScanBusyConnect.contract.test.ts | 4 +- .../protocols/meshcore/MeshCoreTransport.ts | 5 +- ...umBleBondIssuesForOnlineInterfaces.test.ts | 27 + ...ticulumBleBondIssuesForOnlineInterfaces.ts | 18 + .../reticulumAdminBluetoothFocus.test.ts | 34 ++ .../reticulum/reticulumAdminBluetoothFocus.ts | 48 ++ .../lib/reticulum/reticulumBleBondDesync.ts | 4 +- .../reticulumLocalInterfaceLogging.test.ts | 17 + .../reticulumLocalInterfaceLogging.ts | 17 +- .../useReticulumInterfaceSnapshot.test.ts | 2 +- .../useReticulumInterfaceSnapshot.ts | 6 +- src/renderer/locales/cs/translation.json | 8 +- src/renderer/locales/de/translation.json | 8 +- src/renderer/locales/en/translation.json | 14 +- src/renderer/locales/es/translation.json | 8 +- src/renderer/locales/fr/translation.json | 8 +- src/renderer/locales/id/translation.json | 8 +- src/renderer/locales/it/translation.json | 8 +- src/renderer/locales/ja/translation.json | 8 +- src/renderer/locales/ko/translation.json | 8 +- src/renderer/locales/nl/translation.json | 8 +- src/renderer/locales/pl/translation.json | 8 +- src/renderer/locales/pt-BR/translation.json | 8 +- src/renderer/locales/ru/translation.json | 8 +- src/renderer/locales/tr/translation.json | 8 +- src/renderer/locales/uk/translation.json | 8 +- src/renderer/locales/zh/translation.json | 8 +- src/renderer/runtime/useMeshcoreRuntime.ts | 28 + src/renderer/runtime/useMeshtasticRuntime.ts | 13 + ...ticulumRuntime.reconnect-hardening.test.ts | 14 +- src/renderer/runtime/useReticulumRuntime.ts | 98 +++- src/renderer/vitest.electronApiMock.ts | 5 + src/shared/electron-api.types.ts | 11 + src/shared/reticulum-types.ts | 3 +- 71 files changed, 1894 insertions(+), 240 deletions(-) create mode 100644 reticulum-sidecar/src/ble/error_classifier.rs create mode 100644 reticulum-sidecar/src/ble/mod.rs create mode 100644 reticulum-sidecar/src/ble/unbond.rs create mode 100644 src/main/bluetoothSettings.test.ts create mode 100644 src/main/bluetoothSettings.ts create mode 100644 src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.test.ts create mode 100644 src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.ts create mode 100644 src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.test.ts create mode 100644 src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.ts diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index c46d20974..3ae181e90 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -56,6 +56,7 @@ Failures before file replacement still return an error so callers can roll back | GET | `/api/v1/serial/ports` | | `{ ports: [] }` | | GET | `/api/v1/ble/availability` | | `{ available, missing, permissions_granted, probe_failed? }` | | GET | `/api/v1/ble/scan` | `timeout_secs` (1–30, default 5), `mode` (`peer` \| `rnode` \| `all`) | `{ devices: [{ address, name?, rssi?, kind? }] }` or `{ ok: false, error }` | +| POST | `/api/v1/ble/handle-ltk-desync` | `{ address, error? }` — MAC / UUID / `ble://…`; optional driver error string | `{ ok, device_address, bond_purged, message, purge_error? }` + WS `BleLtkDesync` | ### LoRa GATT (Meshtastic / MeshCore) @@ -328,6 +329,7 @@ Shared `reticulum:proxy*` IPC is capped at **900/min**. `GET /api/v1/lxmf/recent | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `reticulum:start` / `stop` / `getStatus` | Sidecar lifecycle | | `reticulum:syncInterfaceIssueScope` | Drop TCP/TX latch entries for disabled/removed interfaces; sticky enabled-name filter for later log lines | +| `reticulum:clearBleBondIssuesForOnlineInterfaces` | Clear `bleBondRemoved` / `blePairingTimedOut` latches after named interfaces report online | | `reticulum:proxyGet` / `proxyPost` / `proxyPut` / `proxyDelete` | HTTP proxy to paths above (shared 900/min; lxmf/recent 120/min) | | `reticulum:factoryReset` | Factory reset (generic `proxyPost` blocks `/api/v1/system/factory-reset`; UI must use this channel) | | `reticulum:validateConfig` | One-shot `validate-config --json` against `userData/reticulum/config` (read-only; safe while stack runs) | @@ -348,7 +350,7 @@ Shared `reticulum:proxy*` IPC is capped at **900/min**. `GET /api/v1/lxmf/recent | `reticulum:gamesStatus` / `gamesApps` / `gamesSessions` / … | Dedicated LRGP games IPC (~600/min); generic proxy rejects `/api/v1/games/*` | | `electronAPI.reticulum.games.*` | Preload: `getStatus` / `listApps` / `listSessions` / `getSession` / `sendAction` / `resend` / `markRead` / `deleteSession` | -`getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops — including BLE / bond-stale cause keys used by Diagnostics and Connection hints, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds (sticky until stack stop / interface remove; not pruned solely by the 5‑minute window), **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`) for most issues; Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. Stopping the stack (or unexpected process exit) clears the tracker. +`getStatus` / `onStatus` may include `interfaceIssueAlert` (TCP connect failures, TX queue drops — including BLE / bond-stale cause keys used by Diagnostics and Connection hints, link-delivery timeouts, transport saturation / slow queries, **`bleBondRemoved`** stale RNode bonds (cleared when that interface reports online again, on stack stop / interface remove, or after the 5‑minute stale window), **`blePairingTimedOut`** OS passkey / TX-read timeouts). Per-entry latch timestamps use a **5-minute** stale window (`RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS`) for most issues; Connection syncs **enabled** interface names via `syncInterfaceIssueScope` so disabling or removing an interface clears that name immediately and rejects re-latch from lagging log lines. After a BLE RNode recovers, `clearBleBondIssuesForOnlineInterfaces` clears bond/pairing latches immediately. Stopping the stack (or unexpected process exit) clears the tracker. **`propagation_sync` WebSocket payload:** `{ active: boolean, progress: number, message: string | null }`. Progress uses 0–100 driven by the **client `/get` download** (Establishing ≈10 … Complete ≈100). Sticky success emits `active:false, progress:100`; cancel/stall/failure emit `active:false, progress:0` (and must not emit a trailing 100). Sync `POST /api/v1/propagation/sync` may return `PROPAGATION_PATH_UNKNOWN`, `PROPAGATION_RETRIEVE_BUSY`, `PROPAGATION_SYNC_OUTBOUND_BUSY`, `PROPAGATION_STACK_NOT_LIVE`, `PROPAGATION_IDENTITY_UNKNOWN`, `PROPAGATION_TARGET_NOT_PN`, `PROPAGATION_PEERING_STAMP_FAILED`, `PROPAGATION_PEER_COST_EXCEEDS_MAX`, or `LOCAL_PROPAGATION_SYNC_UNSUPPORTED`. Add may return `PROPAGATION_OFFER_UNSUPPORTED` / probe timeout failures. diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 0b032cb95..6e93d1eb3 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1664,6 +1664,7 @@ dependencies = [ "argon2", "axum", "base64", + "bluer", "btleplug", "bytes", "ciborium", @@ -1700,6 +1701,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows 0.58.0", "zeroize", ] diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index 3b0d340a1..ab79a1652 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -162,3 +162,9 @@ cast_lossless = "allow" assigning_clones = "allow" map_unwrap_or = "allow" case_sensitive_file_extension_comparisons = "allow" + +[target.'cfg(target_os = "linux")'.dependencies] +bluer = { version = "0.17", features = ["bluetoothd"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = ["Devices_Bluetooth", "Devices_Enumeration", "Foundation"] } diff --git a/reticulum-sidecar/patches/README.md b/reticulum-sidecar/patches/README.md index d919d210c..279f5adbc 100644 --- a/reticulum-sidecar/patches/README.md +++ b/reticulum-sidecar/patches/README.md @@ -245,7 +245,7 @@ When [ratspeak/rsReticulum#20](https://github.com/ratspeak/rsReticulum/pull/20) ## rsReticulum-ble-rnode-bond-desync.patch -Halt BLE RNode reconnect when CoreBluetooth reports **Peer removed pairing information**, and skip the TX-char SMP probe on reconnect after a successful session in the same task (fall back to pairing if subscribe fails with auth). Apply **after** the pairing-transition debounce overlay. +Detect CoreBluetooth **Peer removed pairing information**, skip the TX-char SMP probe when an OS bond may already exist (optimistic on Apple/Windows; fall back to pairing if subscribe fails with auth), and **keep retrying** while mesh-client pauses LoRa GATT / attempts OS unbond (`POST /api/v1/ble/handle-ltk-desync`). Apply **after** the pairing-transition debounce overlay. | Field | Value | | ----- | ----- | @@ -256,7 +256,7 @@ The upstream PR is **standalone off `main`** (independent of [#20](https://githu **Modifies (1 file):** -- `crates/rns-interface/src/ble_rnode.rs` — `is_bond_removed_error`, `session_already_bonded`, reconnect halt +- `crates/rns-interface/src/ble_rnode.rs` — `is_bond_removed_error`, optimistic `session_already_bonded`, bond-removed retry (no permanent halt) ### Apply locally diff --git a/reticulum-sidecar/src/api/gatt.rs b/reticulum-sidecar/src/api/gatt.rs index 1c158ed93..38d0942b7 100644 --- a/reticulum-sidecar/src/api/gatt.rs +++ b/reticulum-sidecar/src/api/gatt.rs @@ -76,6 +76,27 @@ pub async fn gatt_scan( } } +/// Drop LoRa GATT sessions + btleplug CBCentralManager for RNode bond recovery. +pub async fn gatt_release_central( + State(stack): State>, +) -> Json { + match stack.gatt().release_ble_central().await { + Ok(sessions_closed) => Json(serde_json::json!({ + "ok": true, + "sessions_closed": sessions_closed, + })), + Err(e) => Json(e.to_json()), + } +} + +/// Clear the LoRa GATT bond-recovery hold so MeshCore/Meshtastic may recreate a central. +pub async fn gatt_clear_bond_recovery( + State(stack): State>, +) -> Json { + stack.gatt().clear_bond_recovery_hold(); + Json(serde_json::json!({ "ok": true })) +} + pub async fn gatt_create_session( State(stack): State>, Json(body): Json, diff --git a/reticulum-sidecar/src/api/interfaces.rs b/reticulum-sidecar/src/api/interfaces.rs index 53b98bc85..6ec81b3e9 100644 --- a/reticulum-sidecar/src/api/interfaces.rs +++ b/reticulum-sidecar/src/api/interfaces.rs @@ -83,6 +83,61 @@ pub async fn ble_scan( } } +#[derive(Debug, serde::Deserialize)] +pub struct BleUnbondBody { + /// MAC, CoreBluetooth UUID, or `ble://…` URI. + pub address: String, + /// Optional OS Bluetooth display name (e.g. `RNode 41F4`). Required on macOS + /// when `address` is a CoreBluetooth UUID — blueutil cannot unpair by UUID. + #[serde(default)] + pub name: Option, + /// Optional driver error string used to confirm LTK desync classification. + #[serde(default)] + pub error: String, +} + +/// Classify a BLE failure and, on LTK desync, purge the OS bond then emit `BleLtkDesync`. +pub async fn ble_handle_ltk_desync( + State(stack): State>, + Json(body): Json, +) -> Json { + let error = if body.error.trim().is_empty() { + // Callers that already latched Peer-removed may omit the raw string. + "Peer removed pairing information".to_string() + } else { + body.error + }; + let name = body + .name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + match crate::ble::handle_ltk_desync_named(&body.address, name, &error).await { + Ok(result) => { + stack.emit_event( + "BleLtkDesync", + serde_json::json!({ + "device_address": result.device_address, + "bond_purged": result.bond_purged, + "message": result.message, + "purge_error": result.purge_error, + }), + ); + Json(serde_json::json!({ + "ok": true, + "device_address": result.device_address, + "bond_purged": result.bond_purged, + "message": result.message, + "purge_error": result.purge_error, + })) + } + Err(e) => Json(serde_json::json!({ + "ok": false, + "error": e.to_string(), + })), + } +} + #[derive(Debug, serde::Deserialize)] pub struct SetPrimaryLocalRnodeRequest { pub id: String, diff --git a/reticulum-sidecar/src/api/mod.rs b/reticulum-sidecar/src/api/mod.rs index be3ebfacf..f69a83494 100644 --- a/reticulum-sidecar/src/api/mod.rs +++ b/reticulum-sidecar/src/api/mod.rs @@ -99,9 +99,21 @@ pub fn router(stack: Arc) -> Router { get(interfaces::ble_availability), ) .route("/api/v1/ble/scan", get(interfaces::ble_scan)) + .route( + "/api/v1/ble/handle-ltk-desync", + post(interfaces::ble_handle_ltk_desync), + ) // App-wide GATT (Meshtastic / MeshCore) — same process as Reticulum BLE. .route("/api/v1/gatt/availability", get(gatt::gatt_availability)) .route("/api/v1/gatt/scan", get(gatt::gatt_scan)) + .route( + "/api/v1/gatt/release-central", + post(gatt::gatt_release_central), + ) + .route( + "/api/v1/gatt/clear-bond-recovery", + post(gatt::gatt_clear_bond_recovery), + ) .route("/api/v1/gatt/sessions", post(gatt::gatt_create_session)) .route( "/api/v1/gatt/sessions/{session_id}", diff --git a/reticulum-sidecar/src/ble/error_classifier.rs b/reticulum-sidecar/src/ble/error_classifier.rs new file mode 100644 index 000000000..4310be5bc --- /dev/null +++ b/reticulum-sidecar/src/ble/error_classifier.rs @@ -0,0 +1,163 @@ +//! Classify BLE connect/pair failures as ordinary errors vs LTK / bond desync. + +/// Outcome of inspecting a BLE driver / OS error string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BleFailureClass { + /// Transient connect/scan timeout or adapter busy — safe to retry. + Transient, + /// OS still has a bond entry but the peripheral rejected the LTK / cleared its table. + LtkDesync, + /// Other hard failure (not specifically LTK desync). + Other, +} + +/// Inspect a btleplug / CoreBluetooth / BlueZ / WinRT error string. +#[must_use] +pub fn classify_ble_error(message: &str) -> BleFailureClass { + let lower = message.to_ascii_lowercase(); + + if is_ltk_desync_message(&lower) { + return BleFailureClass::LtkDesync; + } + if is_transient_message(&lower) { + return BleFailureClass::Transient; + } + BleFailureClass::Other +} + +fn is_ltk_desync_message(lower: &str) -> bool { + // macOS CoreBluetooth + if lower.contains("peer removed pairing information") { + return true; + } + if lower.contains("cberrordomain") && (lower.contains("code=14") || lower.contains("code=15")) { + return true; + } + if lower.contains("encryption required failed") || lower.contains("encryption timed out") { + return true; + } + + // Linux BlueZ + if lower.contains("org.bluez.error.authenticationfailed") + || lower.contains("authentication failed") + || lower.contains("software caused connection abort") + || lower.contains("ebadmsg") + { + return true; + } + + // Windows WinRT / HRESULT + if lower.contains("0x80070490") + || lower.contains("element not found") + || lower.contains("consentrequired") + || lower.contains("consent required") + { + return true; + } + + // HCI status 0x05 (Authentication Failure) / 0x06 (PIN or Key Missing) + if lower.contains("hci status 0x05") + || lower.contains("hci status 0x06") + || lower.contains("status: 0x05") + || lower.contains("status: 0x06") + || lower.contains("authentication failure") + || lower.contains("pin or key missing") + { + return true; + } + + false +} + +fn is_transient_message(lower: &str) -> bool { + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("scan_busy") + || lower.contains("adapter missing") + || lower.contains("busy") + || (lower.contains("connection aborted") + && !lower.contains("software caused connection abort")) +} + +#[cfg(test)] +mod tests { + use super::{BleFailureClass, classify_ble_error}; + + #[test] + fn classifies_macos_peer_removed() { + assert_eq!( + classify_ble_error( + "BLE connect failed after 3 attempts: connect: Runtime Error: Peer removed pairing information" + ), + BleFailureClass::LtkDesync + ); + assert_eq!( + classify_ble_error("CBErrorDomain Code=14 \"Peer removed pairing information\""), + BleFailureClass::LtkDesync + ); + assert_eq!( + classify_ble_error("CBErrorDomain Code=15 Encryption required failed"), + BleFailureClass::LtkDesync + ); + } + + #[test] + fn classifies_linux_bluez_auth_failures() { + assert_eq!( + classify_ble_error("org.bluez.Error.AuthenticationFailed"), + BleFailureClass::LtkDesync + ); + assert_eq!( + classify_ble_error("Software caused connection abort"), + BleFailureClass::LtkDesync + ); + assert_eq!( + classify_ble_error("write failed: ebadmsg"), + BleFailureClass::LtkDesync + ); + } + + #[test] + fn classifies_windows_hresult_and_consent() { + assert_eq!( + classify_ble_error("Unpair failed: 0x80070490 Element not found"), + BleFailureClass::LtkDesync + ); + assert_eq!( + classify_ble_error("DevicePairingResult Status=ConsentRequired"), + BleFailureClass::LtkDesync + ); + } + + #[test] + fn classifies_hci_auth_and_key_missing() { + assert_eq!( + classify_ble_error("connect failed: HCI status 0x05 Authentication Failure"), + BleFailureClass::LtkDesync + ); + assert_eq!( + classify_ble_error("HCI status 0x06 PIN or Key Missing"), + BleFailureClass::LtkDesync + ); + } + + #[test] + fn classifies_transient_timeouts() { + assert_eq!( + classify_ble_error("Bluetooth adapter discovery timed out"), + BleFailureClass::Transient + ); + assert_eq!( + classify_ble_error("scan_busy: reticulum holds the adapter"), + BleFailureClass::Transient + ); + } + + #[test] + fn other_errors_are_not_ltk_desync() { + assert_eq!( + classify_ble_error("peripheral not found in scan results"), + BleFailureClass::Other + ); + } +} diff --git a/reticulum-sidecar/src/ble/mod.rs b/reticulum-sidecar/src/ble/mod.rs new file mode 100644 index 000000000..e6ebac8ee --- /dev/null +++ b/reticulum-sidecar/src/ble/mod.rs @@ -0,0 +1,12 @@ +//! BLE LTK / bond helpers for the reticulum sidecar (mesh-client owned). + +pub mod error_classifier; +pub mod unbond; + +#[allow(unused_imports)] // public API for callers / future GATT paths +pub use error_classifier::{BleFailureClass, classify_ble_error}; +#[allow(unused_imports)] // public API for callers / future GATT paths +pub use unbond::{ + BleBondError, LtkDesyncHandleResult, handle_ltk_desync, handle_ltk_desync_named, unbond_device, + unbond_device_named, +}; diff --git a/reticulum-sidecar/src/ble/unbond.rs b/reticulum-sidecar/src/ble/unbond.rs new file mode 100644 index 000000000..973575e24 --- /dev/null +++ b/reticulum-sidecar/src/ble/unbond.rs @@ -0,0 +1,521 @@ +//! OS-level BLE bond purge when the host LTK no longer matches the peripheral. + +use thiserror::Error; + +use super::error_classifier::{BleFailureClass, classify_ble_error}; + +#[derive(Debug, Error)] +pub enum BleBondError { + #[error("invalid BLE device identifier: {0}")] + InvalidIdentifier(String), + #[error("BLE unbond is not supported on this platform build")] + #[allow(dead_code)] // used on non linux/macos/windows cfgs + Unsupported, + #[error("BLE unbond tool missing: {0}")] + ToolMissing(String), + #[error("BLE unbond failed: {0}")] + Failed(String), +} + +/// Normalize `ble://…`, MAC, or CoreBluetooth UUID into a bare identifier string. +#[must_use] +pub fn normalize_ble_identifier(raw: &str) -> String { + let trimmed = raw.trim(); + let without_scheme = trimmed + .strip_prefix("ble://") + .or_else(|| trimmed.strip_prefix("BLE://")) + .unwrap_or(trimmed) + .trim(); + without_scheme + .trim_matches(|c| c == '<' || c == '>') + .to_string() +} + +/// Result of handling a classified LTK desync (purge attempt + UI payload fields). +#[derive(Debug, Clone, serde::Serialize)] +pub struct LtkDesyncHandleResult { + pub device_address: String, + pub bond_purged: bool, + pub message: String, + pub purge_error: Option, +} + +/// Classify `error_message`; if LTK desync, attempt OS unbond and build the event payload. +#[allow(dead_code)] // convenience wrapper; API uses handle_ltk_desync_named +pub async fn handle_ltk_desync( + identifier: &str, + error_message: &str, +) -> Result { + handle_ltk_desync_named(identifier, None, error_message).await +} + +/// Like [`handle_ltk_desync`], with an optional OS Bluetooth display name +/// (e.g. `RNode 41F4`) for platforms where CoreBluetooth UUIDs are not usable +/// as unpair identifiers. +pub async fn handle_ltk_desync_named( + identifier: &str, + display_name: Option<&str>, + error_message: &str, +) -> Result { + let device_address = normalize_ble_identifier(identifier); + if device_address.is_empty() { + return Err(BleBondError::InvalidIdentifier(identifier.to_string())); + } + + if classify_ble_error(error_message) != BleFailureClass::LtkDesync { + return Err(BleBondError::Failed( + "error message is not classified as LTK desync".into(), + )); + } + + match unbond_device_named(&device_address, display_name).await { + Ok(()) => Ok(LtkDesyncHandleResult { + device_address, + bond_purged: true, + message: "Bluetooth encryption key mismatch detected. Stale bond removed. Please re-pair the device.".into(), + purge_error: None, + }), + Err(e) => Ok(LtkDesyncHandleResult { + device_address, + bond_purged: false, + message: "Bluetooth encryption key mismatch detected. Forget the device in system Bluetooth settings, then re-pair.".into(), + purge_error: Some(e.to_string()), + }), + } +} + +/// Remove the OS BLE bond for `identifier` (MAC, UUID, or `ble://` URI). +#[allow(dead_code)] // convenience wrapper; callers use unbond_device_named +pub async fn unbond_device(identifier: &str) -> Result<(), BleBondError> { + unbond_device_named(identifier, None).await +} + +/// Remove the OS BLE bond, preferring `display_name` when the identifier is a +/// CoreBluetooth UUID that blueutil / classic Bluetooth APIs cannot resolve. +pub async fn unbond_device_named( + identifier: &str, + display_name: Option<&str>, +) -> Result<(), BleBondError> { + let id = normalize_ble_identifier(identifier); + if id.is_empty() { + return Err(BleBondError::InvalidIdentifier(identifier.to_string())); + } + + #[cfg(target_os = "linux")] + { + let _ = display_name; + return unbond_linux(&id).await; + } + #[cfg(target_os = "macos")] + { + return unbond_macos(&id, display_name).await; + } + #[cfg(target_os = "windows")] + { + let _ = display_name; + return unbond_windows(&id).await; + } + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + let _ = (id, display_name); + Err(BleBondError::Unsupported) + } +} + +#[cfg(target_os = "linux")] +async fn unbond_linux(id: &str) -> Result<(), BleBondError> { + use std::str::FromStr; + + use bluer::{Address, Session}; + + let addr = + Address::from_str(id).map_err(|e| BleBondError::InvalidIdentifier(format!("{id}: {e}")))?; + let session = Session::new() + .await + .map_err(|e| BleBondError::Failed(format!("bluer session: {e}")))?; + let adapter = session + .default_adapter() + .await + .map_err(|e| BleBondError::Failed(format!("bluer adapter: {e}")))?; + adapter + .remove_device(addr) + .await + .map_err(|e| BleBondError::Failed(format!("bluer remove_device: {e}")))?; + tracing::warn!(%id, "ble: removed BlueZ device bond (LTK desync recovery)"); + Ok(()) +} + +#[cfg(target_os = "macos")] +async fn unbond_macos(id: &str, display_name: Option<&str>) -> Result<(), BleBondError> { + // CoreBluetooth has no public unpair API. Prefer Homebrew `blueutil --unpair`. + // blueutil IDs are classic MAC or display name — never CoreBluetooth UUIDs. + let id_owned = id.to_string(); + let name_owned = display_name + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + tokio::task::spawn_blocking(move || run_blueutil_unpair(&id_owned, name_owned.as_deref())) + .await + .map_err(|e| BleBondError::Failed(format!("blueutil join: {e}")))? +} + +#[cfg(target_os = "macos")] +fn looks_like_corebluetooth_uuid(id: &str) -> bool { + let hex: String = id.chars().filter(char::is_ascii_hexdigit).collect(); + // 8-4-4-4-12 UUID → 32 hex digits; classic BT address is 12. + hex.len() == 32 +} + +#[cfg(target_os = "macos")] +fn looks_like_bt_address(id: &str) -> bool { + let hex: String = id.chars().filter(char::is_ascii_hexdigit).collect(); + hex.len() == 12 +} + +#[cfg(target_os = "macos")] +fn normalize_bt_address_dashed(id: &str) -> Option { + let hex: String = id + .chars() + .filter(char::is_ascii_hexdigit) + .map(|c| c.to_ascii_lowercase()) + .collect(); + if hex.len() != 12 { + return None; + } + Some(format!( + "{}-{}-{}-{}-{}-{}", + &hex[0..2], + &hex[2..4], + &hex[4..6], + &hex[6..8], + &hex[8..10], + &hex[10..12] + )) +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone)] +struct BlueutilPairedDevice { + address: String, + name: String, +} + +#[cfg(target_os = "macos")] +fn blueutil_bin_candidates() -> &'static [&'static str] { + &[ + "blueutil", + "/opt/homebrew/bin/blueutil", + "/usr/local/bin/blueutil", + ] +} + +#[cfg(target_os = "macos")] +fn run_blueutil_json(bin: &str, args: &[&str]) -> Result { + use std::process::Command; + + let output = match Command::new(bin).args(args).output() { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(BleBondError::ToolMissing( + "blueutil not found (brew install blueutil) — Forget the device in System Settings → Bluetooth" + .into(), + )); + } + Err(e) => return Err(BleBondError::Failed(format!("{bin}: {e}"))), + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(BleBondError::Failed(format!( + "{bin} {:?} failed (status={}): {}", + args, + output.status, + stderr.trim() + ))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + serde_json::from_str(stdout.trim()).map_err(|e| { + BleBondError::Failed(format!("{bin} JSON parse: {e}; stdout={}", stdout.trim())) + }) +} + +#[cfg(target_os = "macos")] +fn list_blueutil_paired(bin: &str) -> Result, BleBondError> { + let value = run_blueutil_json(bin, &["--paired", "--format", "json"])?; + let arr = value + .as_array() + .ok_or_else(|| BleBondError::Failed(format!("{bin} --paired: expected JSON array")))?; + let mut out = Vec::with_capacity(arr.len()); + for entry in arr { + let address = entry + .get("address") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + let name = entry + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if !address.is_empty() || !name.is_empty() { + out.push(BlueutilPairedDevice { address, name }); + } + } + Ok(out) +} + +#[cfg(target_os = "macos")] +fn paired_still_contains(paired: &[BlueutilPairedDevice], id: &str) -> bool { + let id_hex: String = id + .chars() + .filter(char::is_ascii_hexdigit) + .map(|c| c.to_ascii_lowercase()) + .collect(); + paired.iter().any(|d| { + if !d.name.is_empty() && d.name.eq_ignore_ascii_case(id.trim()) { + return true; + } + if !d.address.is_empty() { + if d.address.eq_ignore_ascii_case(id.trim()) { + return true; + } + let addr_hex: String = d + .address + .chars() + .filter(char::is_ascii_hexdigit) + .map(|c| c.to_ascii_lowercase()) + .collect(); + if !id_hex.is_empty() && addr_hex == id_hex { + return true; + } + } + false + }) +} + +#[cfg(target_os = "macos")] +fn resolve_blueutil_unpair_candidates( + id: &str, + display_name: Option<&str>, + paired: &[BlueutilPairedDevice], +) -> Vec { + let mut out: Vec = Vec::new(); + let mut push_unique = |s: String| { + if s.is_empty() { + return; + } + if !out.iter().any(|e| e.eq_ignore_ascii_case(&s)) { + out.push(s); + } + }; + + if let Some(name) = display_name.map(str::trim).filter(|s| !s.is_empty()) { + push_unique(name.to_string()); + if let Some(match_dev) = paired.iter().find(|d| d.name.eq_ignore_ascii_case(name)) { + if let Some(dashed) = normalize_bt_address_dashed(&match_dev.address) { + push_unique(dashed); + } + if !match_dev.address.is_empty() { + push_unique(match_dev.address.clone()); + } + } + } + + if looks_like_bt_address(id) { + if let Some(dashed) = normalize_bt_address_dashed(id) { + push_unique(dashed); + } + push_unique(id.to_string()); + } else if !looks_like_corebluetooth_uuid(id) { + // Likely already a display name or other blueutil-resolvable id. + push_unique(id.to_string()); + } + + // Last resort: if UUID and no name match, do not pass the UUID to blueutil — + // it always fails with "Device not found by name: ". + out +} + +#[cfg(target_os = "macos")] +fn run_blueutil_unpair(id: &str, display_name: Option<&str>) -> Result<(), BleBondError> { + use std::process::Command; + + let mut last_err = BleBondError::ToolMissing( + "blueutil not found (brew install blueutil) — Forget the device in System Settings → Bluetooth" + .into(), + ); + + for bin in blueutil_bin_candidates() { + let paired = match list_blueutil_paired(bin) { + Ok(p) => p, + Err(e) if matches!(e, BleBondError::ToolMissing(_)) => { + last_err = e; + continue; + } + Err(e) => { + // Binary exists but paired list failed — still try direct candidates. + tracing::debug!(bin, error = %e, "ble: blueutil --paired failed; trying direct ids"); + Vec::new() + } + }; + + let candidates = resolve_blueutil_unpair_candidates(id, display_name, &paired); + + if candidates.is_empty() { + last_err = BleBondError::Failed(format!( + "{bin}: no blueutil-resolvable id (CoreBluetooth UUID requires display name; got id={id}, name={display_name:?})" + )); + continue; + } + + for candidate in &candidates { + let output = match Command::new(bin).args(["--unpair", candidate]).output() { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + last_err = BleBondError::ToolMissing( + "blueutil not found (brew install blueutil) — Forget the device in System Settings → Bluetooth" + .into(), + ); + break; + } + Err(e) => { + last_err = BleBondError::Failed(format!("{bin}: {e}")); + continue; + } + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + last_err = BleBondError::Failed(format!( + "{bin} --unpair {candidate:?} failed (status={}): {} {}", + output.status, + stdout.trim(), + stderr.trim() + )); + continue; + } + + // blueutil --unpair is EXPERIMENTAL and may exit 0 without removing the bond. + let still = match list_blueutil_paired(bin) { + Ok(after) => { + candidates.iter().any(|c| paired_still_contains(&after, c)) + || paired_still_contains(&after, candidate) + } + Err(_) => false, + }; + if still { + last_err = BleBondError::Failed(format!( + "{bin} --unpair {candidate:?} exited 0 but device still listed in --paired (EXPERIMENTAL unpair ineffective on this macOS — Forget in System Settings → Bluetooth)" + )); + continue; + } + + tracing::warn!( + %id, + candidate, + name = ?display_name, + bin, + "ble: blueutil --unpair ok (LTK desync recovery)" + ); + return Ok(()); + } + } + Err(last_err) +} + +#[cfg(target_os = "windows")] +async fn unbond_windows(id: &str) -> Result<(), BleBondError> { + use windows::Devices::Bluetooth::BluetoothDevice; + use windows::core::HSTRING; + + // Prefer classic Bluetooth address (12 hex digits); else treat as device Id. + let device = if let Some(addr) = parse_bt_address_u64(id) { + BluetoothDevice::FromBluetoothAddressAsync(addr) + .map_err(|e| BleBondError::Failed(format!("FromBluetoothAddressAsync: {e}")))? + .await + .map_err(|e| BleBondError::Failed(format!("FromBluetoothAddress: {e}")))? + } else { + let id_h = HSTRING::from(id); + BluetoothDevice::FromIdAsync(&id_h) + .map_err(|e| BleBondError::Failed(format!("BluetoothDevice::FromIdAsync: {e}")))? + .await + .map_err(|e| BleBondError::Failed(format!("BluetoothDevice FromId: {e}")))? + }; + let pairing = device + .DeviceInformation() + .map_err(|e| BleBondError::Failed(format!("DeviceInformation: {e}")))? + .Pairing() + .map_err(|e| BleBondError::Failed(format!("Pairing: {e}")))?; + let result = pairing + .UnpairAsync() + .map_err(|e| BleBondError::Failed(format!("UnpairAsync: {e}")))? + .await + .map_err(|e| BleBondError::Failed(format!("Unpair await: {e}")))?; + let status = result + .Status() + .map_err(|e| BleBondError::Failed(format!("Unpair status: {e}")))?; + // DeviceUnpairingResultStatus::Unpaired == 1 + if i32::from(status) != 1 { + return Err(BleBondError::Failed(format!( + "Windows Unpair status={status:?} (expected Unpaired)" + ))); + } + tracing::warn!(%id, "ble: Windows UnpairAsync ok (LTK desync recovery)"); + Ok(()) +} + +#[cfg(target_os = "windows")] +fn parse_bt_address_u64(id: &str) -> Option { + let hex: String = id.chars().filter(char::is_ascii_hexdigit).collect(); + if hex.len() != 12 { + return None; + } + u64::from_str_radix(&hex, 16).ok() +} + +#[cfg(test)] +mod tests { + use super::normalize_ble_identifier; + + #[test] + fn strips_ble_scheme() { + assert_eq!( + normalize_ble_identifier("ble://AA:BB:CC:DD:EE:FF"), + "AA:BB:CC:DD:EE:FF" + ); + assert_eq!( + normalize_ble_identifier("ble://eccf2847-e1fd-3f5f-0811-064db1639a3d"), + "eccf2847-e1fd-3f5f-0811-064db1639a3d" + ); + } + + #[cfg(target_os = "macos")] + #[test] + fn resolve_candidates_prefers_name_and_mac_over_uuid() { + use super::{BlueutilPairedDevice, resolve_blueutil_unpair_candidates}; + + let paired = vec![BlueutilPairedDevice { + address: "f0-9e-9e-77-98-d5".into(), + name: "RNode 41F4".into(), + }]; + let c = resolve_blueutil_unpair_candidates( + "eccf2847-e1fd-3f5f-0811-064db1639a3d", + Some("RNode 41F4"), + &paired, + ); + assert_eq!(c.first().map(String::as_str), Some("RNode 41F4")); + assert!(c.iter().any(|x| x == "f0-9e-9e-77-98-d5")); + assert!(!c.iter().any(|x| x.contains("eccf2847"))); + } + + #[cfg(target_os = "macos")] + #[test] + fn resolve_candidates_skips_bare_uuid_without_name() { + use super::resolve_blueutil_unpair_candidates; + + let c = + resolve_blueutil_unpair_candidates("eccf2847-e1fd-3f5f-0811-064db1639a3d", None, &[]); + assert!(c.is_empty()); + } +} diff --git a/reticulum-sidecar/src/gatt/lazy_backend.rs b/reticulum-sidecar/src/gatt/lazy_backend.rs index 6623c085c..cfc408280 100644 --- a/reticulum-sidecar/src/gatt/lazy_backend.rs +++ b/reticulum-sidecar/src/gatt/lazy_backend.rs @@ -1,10 +1,14 @@ //! Retry adapter discovery after a missing-adapter failure without replacing live sessions. +//! +//! The cached backend can be disposed (bond-recovery) so CoreBluetooth's central is dropped +//! while an RNode reconnect owns the adapter exclusively. A hold bit blocks recreation via +//! `/api/v1/ble/availability` (and any other probe) until recovery clears. -use std::future::Future; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; -use tokio::sync::{OnceCell, mpsc}; +use tokio::sync::{Mutex, mpsc}; use super::backend::{BackendConnId, BackendEvent, BleBackend, ScannedDevice}; use super::btleplug_backend::BtleplugBackend; @@ -13,43 +17,63 @@ use super::profile::GattProfile; #[derive(Default)] pub struct LazyBtleplugBackend { - backend: OnceCell>, -} - -// Only successful discovery is cached. OnceCell also serializes concurrent first probes. -async fn get_or_probe(backend: &OnceCell>, probe: P) -> Result<&Arc, GattError> -where - P: FnOnce() -> F, - F: Future>, -{ - // Include time waiting behind another initial probe in this caller's budget. - tokio::time::timeout( - Duration::from_secs(3), - backend.get_or_try_init(|| async { probe().await.map(Arc::new) }), - ) - .await - .map_err(|_| { - GattError::new( - GattErrorCode::AdapterMissing, - "Bluetooth adapter discovery timed out", - ) - })? + backend: Mutex>>, + /// When true, refuse to construct a new CBCentralManager (RNode bond recovery). + bond_recovery_hold: AtomicBool, } impl LazyBtleplugBackend { pub const fn new() -> Self { Self { - backend: OnceCell::const_new(), + backend: Mutex::const_new(None), + bond_recovery_hold: AtomicBool::new(false), } } - async fn backend(&self) -> Result<&Arc, GattError> { - get_or_probe(&self.backend, || async { - let backend = BtleplugBackend::try_new().await?; + pub fn set_bond_recovery_hold(&self, hold: bool) { + self.bond_recovery_hold.store(hold, Ordering::SeqCst); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub fn bond_recovery_hold(&self) -> bool { + self.bond_recovery_hold.load(Ordering::SeqCst) + } + + /// Drop the cached btleplug central (if any). Call only after all GATT sessions are gone. + pub async fn dispose_adapter(&self) { + self.bond_recovery_hold.store(true, Ordering::SeqCst); + let mut guard = self.backend.lock().await; + let had = guard.take().is_some(); + if had { + tracing::warn!("gatt: disposed btleplug central (RNode bond recovery)"); + } + } + + async fn backend(&self) -> Result, GattError> { + // Include time waiting behind another initial probe in this caller's budget. + tokio::time::timeout(Duration::from_secs(3), async { + let mut guard = self.backend.lock().await; + if let Some(existing) = guard.as_ref() { + return Ok(existing.clone()); + } + if self.bond_recovery_hold.load(Ordering::SeqCst) { + return Err(GattError::new( + GattErrorCode::ScanBusy, + "RNode bond recovery holds the LoRa GATT adapter", + )); + } + let created = Arc::new(BtleplugBackend::try_new().await?); tracing::info!("gatt: using btleplug backend"); - Ok(backend) + *guard = Some(created.clone()); + Ok(created) }) .await + .map_err(|_| { + GattError::new( + GattErrorCode::AdapterMissing, + "Bluetooth adapter discovery timed out", + ) + })? } } @@ -96,82 +120,25 @@ impl BleBackend for LazyBtleplugBackend { #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicUsize, Ordering}; - use super::*; - use crate::gatt::fake::FakeBleBackend; #[tokio::test] - async fn failed_probe_retries_then_keeps_the_backend_and_its_session() { - let backend = OnceCell::new(); - let attempts = AtomicUsize::new(0); - let missing = get_or_probe::(&backend, || async { - attempts.fetch_add(1, Ordering::Relaxed); - Err(GattError::new( - GattErrorCode::AdapterMissing, - "adapter unplugged", - )) - }) - .await; - assert!(matches!(missing, Err(ref err) if err.code == GattErrorCode::AdapterMissing)); - assert!(backend.get().is_none()); - - let connected = get_or_probe(&backend, || async { - attempts.fetch_add(1, Ordering::Relaxed); - Ok(FakeBleBackend::new()) - }) - .await - .unwrap(); - let (conn, _events, _) = connected - .connect(GattProfile::Meshcore, "aa:bb:cc:dd:ee:ff") - .await - .unwrap(); - - let reused = get_or_probe(&backend, || async { - attempts.fetch_add(1, Ordering::Relaxed); - Err(GattError::new( - GattErrorCode::AdapterMissing, - "must not re-probe", - )) - }) - .await - .unwrap(); - assert!(Arc::ptr_eq(connected, reused)); - reused.write(&conn, &[1, 2, 3]).await.unwrap(); - assert_eq!(connected.writes_for(&conn.0).await, vec![vec![1, 2, 3]]); - assert_eq!(attempts.load(Ordering::Relaxed), 2); + async fn dispose_sets_hold_and_clear_releases() { + let lazy = LazyBtleplugBackend::new(); + assert!(!lazy.bond_recovery_hold()); + lazy.dispose_adapter().await; + assert!(lazy.bond_recovery_hold()); + lazy.set_bond_recovery_hold(false); + assert!(!lazy.bond_recovery_hold()); } #[tokio::test] - async fn concurrent_first_operations_share_one_successful_probe() { - let backend = OnceCell::new(); - let attempts = AtomicUsize::new(0); - let probe = || async { - attempts.fetch_add(1, Ordering::Relaxed); - tokio::task::yield_now().await; - Ok(FakeBleBackend::new()) + async fn backend_refuses_create_while_hold() { + let lazy = LazyBtleplugBackend::new(); + lazy.set_bond_recovery_hold(true); + let Err(err) = lazy.backend().await else { + panic!("must refuse create while bond recovery hold is set"); }; - let (first, second) = - tokio::join!(get_or_probe(&backend, probe), get_or_probe(&backend, probe),); - assert!(Arc::ptr_eq(first.unwrap(), second.unwrap())); - assert_eq!(attempts.load(Ordering::Relaxed), 1); - } - - #[tokio::test(start_paused = true)] - async fn timed_out_probe_can_retry_without_exceeding_the_connect_budget() { - let backend = OnceCell::new(); - let start = tokio::time::Instant::now(); - let error = get_or_probe::(&backend, std::future::pending) - .await - .err() - .expect("probe must time out"); - assert_eq!(error.code, GattErrorCode::AdapterMissing); - assert_eq!(start.elapsed(), Duration::from_secs(3)); - assert!(backend.get().is_none()); - assert!( - get_or_probe(&backend, || async { Ok(FakeBleBackend::new()) }) - .await - .is_ok() - ); + assert_eq!(err.code, GattErrorCode::ScanBusy); } } diff --git a/reticulum-sidecar/src/gatt/manager.rs b/reticulum-sidecar/src/gatt/manager.rs index 714ae0894..dfd1217a4 100644 --- a/reticulum-sidecar/src/gatt/manager.rs +++ b/reticulum-sidecar/src/gatt/manager.rs @@ -543,6 +543,29 @@ impl GattManager { self.drop_session_internal(session_id, false).await } + /// Disconnect every LoRa GATT session and drop the btleplug CBCentralManager + /// so an RNode bond-recovery connect is the only CoreBluetooth central. + pub async fn release_ble_central(&self) -> Result { + let ids: Vec = self.sessions.read().await.keys().cloned().collect(); + let count = ids.len(); + for id in &ids { + let _ = self.drop_session_internal(id, true).await; + } + #[cfg(feature = "gatt-ble")] + if let GattBackend::Btleplug(lazy) = &self.backend { + lazy.dispose_adapter().await; + } + Ok(count) + } + + /// Allow LoRa GATT to create a CBCentralManager again after RNode bond recovery. + pub fn clear_bond_recovery_hold(&self) { + #[cfg(feature = "gatt-ble")] + if let GattBackend::Btleplug(lazy) = &self.backend { + lazy.set_bond_recovery_hold(false); + } + } + pub async fn write(&self, session_id: &str, payload: &[u8]) -> Result<(), GattError> { validate_write_payload(payload)?; let conn = { diff --git a/reticulum-sidecar/src/main.rs b/reticulum-sidecar/src/main.rs index d155e5210..664eeda5d 100644 --- a/reticulum-sidecar/src/main.rs +++ b/reticulum-sidecar/src/main.rs @@ -21,6 +21,7 @@ )] mod api; +mod ble; mod gatt; mod stack; diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 8c7c068f0..afefb6b9c 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -583,7 +583,7 @@ impl StackHandle { } #[allow(clippy::needless_pass_by_value)] // payload is moved into the broadcast frame - fn emit_event(&self, event_type: &str, payload: serde_json::Value) { + pub fn emit_event(&self, event_type: &str, payload: serde_json::Value) { let msg = serde_json::json!({ "type": event_type, "payload": payload }); let _ = self.event_tx.send(msg.to_string()); } diff --git a/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh b/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh index 6c6585e56..52b227b71 100755 --- a/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh +++ b/scripts/apply-rsReticulum-ble-rnode-bond-desync.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # Apply mesh-client rsReticulum BLE RNode bond-desync overlay (after pairing-transition debounce). -# - Halt reconnect when CoreBluetooth reports "Peer removed pairing information" -# - Skip TX-char SMP probe on reconnect after a successful session in this task +# - Detect CoreBluetooth "Peer removed pairing information" +# - Skip TX-char SMP probe when an OS bond may already exist (optimistic on Apple/Windows) +# - Keep retrying on bond-removed (do not permanently halt / require Forget) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/src/main/bluetoothSettings.test.ts b/src/main/bluetoothSettings.test.ts new file mode 100644 index 000000000..dfd71d2df --- /dev/null +++ b/src/main/bluetoothSettings.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + BLUETOOTH_SETTINGS_URL, + isAllowedBluetoothSettingsUrl, + openOsBluetoothSettings, +} from './bluetoothSettings'; + +describe('bluetoothSettings', () => { + it('allowlists only fixed Bluetooth settings URLs', () => { + expect(isAllowedBluetoothSettingsUrl(BLUETOOTH_SETTINGS_URL.darwin)).toBe(true); + expect(isAllowedBluetoothSettingsUrl(BLUETOOTH_SETTINGS_URL.win32)).toBe(true); + expect(isAllowedBluetoothSettingsUrl('https://evil.example')).toBe(false); + }); + + it.each([ + ['darwin', BLUETOOTH_SETTINGS_URL.darwin], + ['win32', BLUETOOTH_SETTINGS_URL.win32], + ] as const)('opens Bluetooth settings on %s', async (platform, expectedUrl) => { + const openExternal = vi.fn().mockResolvedValue(undefined); + await expect(openOsBluetoothSettings({ platform, openExternal })).resolves.toBe(true); + expect(openExternal).toHaveBeenCalledWith(expectedUrl); + }); + + it('no-ops on linux', async () => { + const openExternal = vi.fn(); + await expect(openOsBluetoothSettings({ platform: 'linux', openExternal })).resolves.toBe(false); + expect(openExternal).not.toHaveBeenCalled(); + }); +}); diff --git a/src/main/bluetoothSettings.ts b/src/main/bluetoothSettings.ts new file mode 100644 index 000000000..715482b96 --- /dev/null +++ b/src/main/bluetoothSettings.ts @@ -0,0 +1,31 @@ +/** Fixed OS Bluetooth settings deep links — never open user-controlled URLs. */ + +export const BLUETOOTH_SETTINGS_URL = { + darwin: 'x-apple.systempreferences:com.apple.BluetoothSettings', + win32: 'ms-settings:bluetooth', +} as const; + +export function isAllowedBluetoothSettingsUrl(url: string): boolean { + return url === BLUETOOTH_SETTINGS_URL.darwin || url === BLUETOOTH_SETTINGS_URL.win32; +} + +export interface OpenBluetoothSettingsDeps { + platform: NodeJS.Platform; + openExternal: (url: string) => Promise; +} + +/** + * Open the OS Bluetooth settings pane so the user can Forget a stale bond. + * No-op on platforms without a known deep link (e.g. linux — users use blueman/gnome-control-center). + */ +export async function openOsBluetoothSettings(deps: OpenBluetoothSettingsDeps): Promise { + const url = + deps.platform === 'darwin' + ? BLUETOOTH_SETTINGS_URL.darwin + : deps.platform === 'win32' + ? BLUETOOTH_SETTINGS_URL.win32 + : null; + if (!url) return false; + await deps.openExternal(url); + return true; +} diff --git a/src/main/gatt-sidecar-proxy.test.ts b/src/main/gatt-sidecar-proxy.test.ts index 544b4b42b..dd8532a36 100644 --- a/src/main/gatt-sidecar-proxy.test.ts +++ b/src/main/gatt-sidecar-proxy.test.ts @@ -59,6 +59,10 @@ describe('GattSidecarProxy', () => { proxy = new GattSidecarProxy(); proxy.setPort(9876); fetchMock = vi.fn<(url?: unknown, init?: RequestInit) => Promise>(); + fetchMock.mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ ok: true }), + }); vi.stubGlobal('fetch', fetchMock); }); @@ -723,4 +727,28 @@ describe('GattSidecarProxy', () => { await vi.advanceTimersByTimeAsync(4_000 * 5); expect(deletes).toBe(3); }); + + it('clearing bond-recovery exclusive with port 0 does not ensure/start sidecar', async () => { + const ensure = vi.fn(() => Promise.resolve(5555)); + proxy.setEnsureSidecar(ensure); + proxy.invalidateAfterSidecarExit(); + proxy.setRnodeBondRecoveryExclusive(true); + proxy.setRnodeBondRecoveryExclusive(false); + await vi.waitFor(() => { + expect(ensure).not.toHaveBeenCalled(); + }); + expect(fetchMock).not.toHaveBeenCalledWith( + expect.stringContaining('/api/v1/gatt/clear-bond-recovery'), + expect.anything(), + ); + }); + + it('releaseBleCentral with port 0 disconnects locally without ensure/start', async () => { + const ensure = vi.fn(() => Promise.resolve(5555)); + proxy.setEnsureSidecar(ensure); + proxy.invalidateAfterSidecarExit(); + await proxy.releaseBleCentral(); + expect(ensure).not.toHaveBeenCalled(); + expect(proxy.isRnodeBondRecoveryExclusive()).toBe(true); + }); }); diff --git a/src/main/gatt-sidecar-proxy.ts b/src/main/gatt-sidecar-proxy.ts index 53f4b8e5a..ce2f092ed 100644 --- a/src/main/gatt-sidecar-proxy.ts +++ b/src/main/gatt-sidecar-proxy.ts @@ -91,6 +91,9 @@ export class GattSidecarProxy extends EventEmitter { private readonly reservations = new Map(); private ensureFn: (() => Promise) | null = null; private connectionGuard: ((sessionId: GattSessionProfile, address: string) => void) | null = null; + /** While true, LoRa GATT scan/connect are blocked so RNode owns CoreBluetooth alone. */ + private rnodeBondRecoveryExclusive = false; + private rnodeBondRecoveryExclusiveTimer: ReturnType | null = null; /** Wire ensure callback (starts sidecar for BLE-light, returns HTTP port). */ setEnsureSidecar(fn: () => Promise): void { @@ -101,6 +104,56 @@ export class GattSidecarProxy extends EventEmitter { this.connectionGuard = guard; } + /** + * Block LoRa GATT scan/connect while RNode recovers from CoreBluetooth Peer-removed + * or while an RNode BLE link is online (macOS cannot safely host two CBCentralManagers). + * Auto-clears after 10 minutes so a stuck latch cannot brick MeshCore/Meshtastic forever. + */ + setRnodeBondRecoveryExclusive(active: boolean): void { + if (this.rnodeBondRecoveryExclusiveTimer) { + clearTimeout(this.rnodeBondRecoveryExclusiveTimer); + this.rnodeBondRecoveryExclusiveTimer = null; + } + this.rnodeBondRecoveryExclusive = active; + if (active) { + this.rnodeBondRecoveryExclusiveTimer = setTimeout(() => { + this.rnodeBondRecoveryExclusiveTimer = null; + this.rnodeBondRecoveryExclusive = false; + void this.clearSidecarBondRecoveryHold(); + console.debug('[GATT] rnodeBondRecoveryExclusive auto-cleared after 10m'); + }, 10 * 60_000); + } else { + void this.clearSidecarBondRecoveryHold(); + } + } + + /** + * Best-effort clear of the sidecar bond-recovery hold. + * Never starts the sidecar — during quit `port` is already 0 and ensurePort would + * respawn reticulum mid-exit (Disconnect & Quit appears to hang). + */ + private async clearSidecarBondRecoveryHold(): Promise { + if (this.port <= 0) { + return; + } + try { + await this.jsonFetch( + '/api/v1/gatt/clear-bond-recovery', + { method: 'POST', body: '{}' }, + { timeoutMs: GATT_HTTP_TIMEOUT_MS, port: this.port }, + ); + } catch (err) { + console.debug( + '[GATT] clearSidecarBondRecoveryHold failed:', + err instanceof Error ? err.message : String(err), + ); + } + } + + isRnodeBondRecoveryExclusive(): boolean { + return this.rnodeBondRecoveryExclusive; + } + getConnections(): { mac: string; owner: `gatt:${GattSessionProfile}` }[] { return [...this.reservations.values()].map(({ profile, address }) => ({ mac: address, @@ -185,6 +238,9 @@ export class GattSidecarProxy extends EventEmitter { } async startScan(sessionId: GattSessionProfile): Promise { + if (this.rnodeBondRecoveryExclusive) { + return { ok: false, code: 'scan_busy', owner: 'rnode_bond_recovery' }; + } const mode = profileFromSession(sessionId); const { status, body } = await this.jsonFetch( `/api/v1/gatt/scan?mode=${mode}&timeout_secs=8`, @@ -232,6 +288,13 @@ export class GattSidecarProxy extends EventEmitter { } async connect(sessionId: GattSessionProfile, peripheralId: string): Promise { + if (this.rnodeBondRecoveryExclusive) { + return { + ok: false, + error: 'RNode bond recovery holds the Bluetooth adapter', + code: 'rnode_bond_recovery', + }; + } this.connectionGuard?.(sessionId, peripheralId); void this.disconnect(sessionId); const attempt = Symbol(); @@ -592,6 +655,37 @@ export class GattSidecarProxy extends EventEmitter { } } } + + /** + * Drop LoRa sessions and the sidecar btleplug CBCentralManager so RNode bond + * recovery is the only CoreBluetooth central in-process. + */ + async releaseBleCentral(): Promise { + const alreadyExclusive = this.rnodeBondRecoveryExclusive; + this.setRnodeBondRecoveryExclusive(true); + // Refreshing the exclusive timer while already holding must not thrash disconnectAll. + if (alreadyExclusive) { + return; + } + await this.disconnectAll(); + // Do not ensurePort() — during quit the sidecar is already stopped (port=0) and + // starting it again blocks Disconnect & Quit. + if (this.port <= 0) { + return; + } + try { + await this.jsonFetch( + '/api/v1/gatt/release-central', + { method: 'POST', body: '{}' }, + { timeoutMs: GATT_HTTP_LONG_TIMEOUT_MS, port: this.port }, + ); + } catch (err) { + console.debug( + '[GATT] releaseBleCentral failed:', + err instanceof Error ? err.message : String(err), + ); + } + } } export const gattSidecarProxy = new GattSidecarProxy(); diff --git a/src/main/index.contract.test.ts b/src/main/index.contract.test.ts index 7b5454d35..f3ec91cbd 100644 --- a/src/main/index.contract.test.ts +++ b/src/main/index.contract.test.ts @@ -353,6 +353,9 @@ describe('Reticulum sidecar IPC handlers (source contract)', () => { expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:stop'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:getStatus'"); expect(RETICULUM_HANDLERS_SOURCE).toContain("'reticulum:syncInterfaceIssueScope'"); + expect(RETICULUM_HANDLERS_SOURCE).toContain( + "'reticulum:clearBleBondIssuesForOnlineInterfaces'", + ); expect(RETICULUM_HANDLERS_SOURCE).toContain("ipcMain.handle('reticulum:proxyGet'"); expect(RETICULUM_HANDLERS_SOURCE).toContain('settleReticulumProxyFailure'); expect(RETICULUM_HANDLERS_SOURCE).toContain('reticulumProxyIpcErrorEnvelope'); diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index d8691ca8b..bcfd8403c 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -498,6 +498,8 @@ describe('privileged IPC sender validation (source contract)', () => { 'hostLink:getSessionMeter', 'gatt:connect', 'gatt:disconnect', + 'gatt:release-ble-central', + 'gatt:clear-bond-recovery-exclusive', 'notify:message', 'chat:outbox:add', 'chat:outbox:remove', diff --git a/src/main/index.ts b/src/main/index.ts index d50108832..c2304047c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -298,6 +298,9 @@ function ensureReticulumSidecarManager(): ReticulumSidecarManager { } gattSidecarProxy.setEnsureSidecar(async () => { + if (isQuitting) { + throw new Error('gatt sidecar ensure blocked: app is quitting'); + } const mgr = ensureReticulumSidecarManager(); const port = await mgr.ensureForBle(); gattSidecarProxy.setPort(port); @@ -383,6 +386,8 @@ async function quitMainProcess(opts: { relaunch?: boolean } = {}): Promise quitMainInFlight = true; isQuitting = true; isConnected = false; + // Drop exclusive hold locally without HTTP so quit never ensurePort()-respawns sidecar. + gattSidecarProxy.setRnodeBondRecoveryExclusive(false); try { try { await gattSidecarProxy.disconnectAll(); @@ -2928,6 +2933,13 @@ ipcMain.handle('gatt:connect', async (event, sessionId: unknown, peripheralId: u return { ok: false as const, error: 'App is quitting' }; } try { + if (gattSidecarProxy.isRnodeBondRecoveryExclusive()) { + return { + ok: false as const, + error: 'RNode bond recovery holds the Bluetooth adapter', + code: 'rnode_bond_recovery' as const, + }; + } return await bleCoexistenceCoordinator.withScan('gatt', () => gattSidecarProxy.connect(sessionId, peripheralId), ); @@ -2958,6 +2970,29 @@ ipcMain.handle('gatt:disconnect', async (event, sessionId: unknown) => { throw err; } }); +ipcMain.handle('gatt:release-ble-central', async (event) => { + assertIpcSender(event, 'gatt:release-ble-central'); + if (isQuitting) { + console.debug('[main] gatt:release-ble-central: ignoring (app is quitting)'); + return; + } + try { + await gattSidecarProxy.releaseBleCentral(); + } catch (err) { + console.error( + `[main] gatt:release-ble-central failed: message=${sanitizeLogMessage(err instanceof Error ? err.message : String(err))}`, + ); + throw err; + } +}); +ipcMain.handle('gatt:clear-bond-recovery-exclusive', (event) => { + assertIpcSender(event, 'gatt:clear-bond-recovery-exclusive'); + if (isQuitting) { + console.debug('[main] gatt:clear-bond-recovery-exclusive: ignoring (app is quitting)'); + return; + } + gattSidecarProxy.setRnodeBondRecoveryExclusive(false); +}); ipcMain.handle('gatt:is-connected', async (event, sessionId: unknown) => { assertIpcSender(event, 'gatt:is-connected'); if (sessionId !== 'meshtastic' && sessionId !== 'meshcore') { diff --git a/src/main/ipc/reticulum-handlers.test.ts b/src/main/ipc/reticulum-handlers.test.ts index 33ed0ee01..a592711b6 100644 --- a/src/main/ipc/reticulum-handlers.test.ts +++ b/src/main/ipc/reticulum-handlers.test.ts @@ -135,6 +135,9 @@ function createManagerStub() { stop: vi.fn().mockResolvedValue(undefined), getStatus: vi.fn().mockReturnValue({ running: true, port: 8080, pid: 123 }), syncInterfaceIssueScope: vi.fn().mockReturnValue({ running: true, port: 8080, pid: 123 }), + clearBleBondIssuesForOnlineInterfaces: vi + .fn() + .mockReturnValue({ running: true, port: 8080, pid: 123 }), proxyGet: vi.fn().mockResolvedValue({ ok: true }), proxyPost: vi.fn().mockResolvedValue({ ok: true }), proxyPut: vi.fn().mockResolvedValue({ ok: true }), @@ -183,6 +186,7 @@ describe('registerReticulumIpcHandlers', () => { 'reticulum:stop', 'reticulum:getStatus', 'reticulum:syncInterfaceIssueScope', + 'reticulum:clearBleBondIssuesForOnlineInterfaces', 'reticulum:proxyGet', 'reticulum:proxyPost', 'reticulum:voiceSendAudio', @@ -352,6 +356,25 @@ describe('registerReticulumIpcHandlers', () => { }); }); + describe('reticulum:clearBleBondIssuesForOnlineInterfaces', () => { + it('parses names and delegates to manager.clearBleBondIssuesForOnlineInterfaces', () => { + const result = handlers.get('reticulum:clearBleBondIssuesForOnlineInterfaces')?.(event, [ + 'RNode 41F4', + ' ', + ]); + expect(manager.clearBleBondIssuesForOnlineInterfaces).toHaveBeenCalledWith(['RNode 41F4']); + expect(result).toEqual({ running: true, port: 8080, pid: 123 }); + }); + + it('returns idleStatus when there is no manager', () => { + getManagerResult = null; + const result = handlers.get('reticulum:clearBleBondIssuesForOnlineInterfaces')?.(event, [ + 'RNode', + ]); + expect(result).toBe(IDLE_STATUS); + }); + }); + describe('reticulum:proxy* forwarding', () => { it('proxyGet forwards the path to manager.proxyGet', async () => { const result = await handlers.get('reticulum:proxyGet')?.(event, '/api/v1/diagnostics'); diff --git a/src/main/ipc/reticulum-handlers.ts b/src/main/ipc/reticulum-handlers.ts index 2e7828da8..28fcc2436 100644 --- a/src/main/ipc/reticulum-handlers.ts +++ b/src/main/ipc/reticulum-handlers.ts @@ -245,6 +245,17 @@ export function registerReticulumIpcHandlers(deps: ReticulumIpcDeps): void { return m.syncInterfaceIssueScope(names); }); + ipcMain.handle( + 'reticulum:clearBleBondIssuesForOnlineInterfaces', + (event, onlineInterfaceNames: unknown) => { + assertIpcSender(event, 'reticulum:clearBleBondIssuesForOnlineInterfaces'); + const names = parseEnabledInterfaceNames(onlineInterfaceNames); + const m = getManager(); + if (!m) return idleStatus; + return m.clearBleBondIssuesForOnlineInterfaces(names); + }, + ); + ipcMain.handle('reticulum:proxyGet', async (event, apiPath: unknown) => { assertIpcSender(event, 'reticulum:proxyGet'); const pathArg = assertProxyApiPath(apiPath); diff --git a/src/main/reticulum-sidecar-manager.test.ts b/src/main/reticulum-sidecar-manager.test.ts index 53b744997..b04816cbf 100644 --- a/src/main/reticulum-sidecar-manager.test.ts +++ b/src/main/reticulum-sidecar-manager.test.ts @@ -309,6 +309,21 @@ describe('ReticulumSidecarManager', () => { expect(statuses.length).toBe(1); }); + it('clearBleBondIssuesForOnlineInterfaces clears bond latch and emits status', () => { + const manager = new ReticulumSidecarManager(); + const tracker = getIssueTracker(manager); + tracker.recordLine( + 'BLE RNode bond removed — retrying with existing OS bond name = RNode 41F4 error = Peer removed pairing information', + Date.now(), + ); + expect(manager.getStatus().interfaceIssueAlert?.bleBondRemoved).toEqual(['RNode 41F4']); + const statuses: unknown[] = []; + manager.on('status', (s) => statuses.push(s)); + const status = manager.clearBleBondIssuesForOnlineInterfaces(['RNode 41F4']); + expect(status.interfaceIssueAlert?.bleBondRemoved ?? []).toEqual([]); + expect(statuses.length).toBe(1); + }); + it('syncInterfaceIssueScope does not emit when scope is unchanged', () => { const manager = new ReticulumSidecarManager(); const tracker = getIssueTracker(manager); diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 229a2dd7e..06f6de29a 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -246,12 +246,110 @@ export class ReticulumSidecarManager extends EventEmitter { } private recordSidecarOutputLine(text: string): void { + const beforeBond = this.interfaceIssueTracker.peekAlert()?.bleBondRemoved?.length ?? 0; this.mutateInterfaceIssues( () => { this.interfaceIssueTracker.recordLine(text); }, { alwaysEmitAfterMs: 5_000 }, ); + const afterBond = this.interfaceIssueTracker.peekAlert()?.bleBondRemoved?.length ?? 0; + // Dispose LoRa CBCentralManager as soon as stderr latches Peer-removed — + // do not wait for the renderer round-trip (bond-removed retries in ~3s). + if (afterBond > beforeBond) { + void this.handleBleLtkDesyncLatch(text).catch((err: unknown) => { + console.debug( + '[ReticulumSidecar] LTK desync latch handler failed:', + err instanceof Error ? err.message : String(err), + ); + }); + } + } + + /** + * Peer-removed / LTK desync: pause LoRa GATT, purge the OS bond when possible, + * and let the sidecar emit `BleLtkDesync` for the renderer toast / re-pair UX. + */ + private async handleBleLtkDesyncLatch(line: string): Promise { + try { + const { gattSidecarProxy } = await import('./gatt-sidecar-proxy'); + await gattSidecarProxy.releaseBleCentral(); + } catch (err: unknown) { + console.debug( + '[ReticulumSidecar] releaseBleCentral on bond latch failed:', + err instanceof Error ? err.message : String(err), + ); + } + + let address = ''; + let deviceName = ''; + try { + const listed = (await this.proxyGet('/api/v1/interfaces')) as { + interfaces?: { name?: string; serial_port?: string | null }[]; + }; + const alert = this.interfaceIssueTracker.peekAlert(); + const names = new Set(alert?.bleBondRemoved ?? []); + for (const row of listed.interfaces ?? []) { + if (typeof row.name === 'string' && names.has(row.name) && row.serial_port) { + address = row.serial_port; + deviceName = row.name; + break; + } + } + } catch (err: unknown) { + console.debug( + '[ReticulumSidecar] LTK desync interface lookup failed:', + err instanceof Error ? err.message : String(err), + ); + } + if (!address) { + console.debug('[ReticulumSidecar] LTK desync: no ble:// address for purge'); + return; + } + + try { + const result = (await this.proxyPost('/api/v1/ble/handle-ltk-desync', { + address, + ...(deviceName ? { name: deviceName } : {}), + error: line, + })) as { + ok?: boolean; + bond_purged?: boolean; + message?: string; + purge_error?: string | null; + }; + console.debug( + `[ReticulumSidecar] LTK desync handle ok=${String(result.ok)} purged=${String(result.bond_purged)} err=${result.purge_error ?? result.message ?? ''}`, + ); + // blueutil --unpair is EXPERIMENTAL and often a no-op on modern macOS; open + // System Settings → Bluetooth so the user can Forget when auto-purge fails. + if (result.ok === true && result.bond_purged !== true) { + try { + const { openOsBluetoothSettings, isAllowedBluetoothSettingsUrl } = + await import('./bluetoothSettings'); + const { shell } = await import('electron'); + await openOsBluetoothSettings({ + platform: process.platform, + openExternal: async (url) => { + if (!isAllowedBluetoothSettingsUrl(url)) { + throw new Error('Blocked unexpected Bluetooth settings URL'); + } + await shell.openExternal(url); + }, + }); + } catch (openErr: unknown) { + console.debug( + '[ReticulumSidecar] open Bluetooth settings failed:', + openErr instanceof Error ? openErr.message : String(openErr), + ); + } + } + } catch (err: unknown) { + console.debug( + '[ReticulumSidecar] handle-ltk-desync failed:', + err instanceof Error ? err.message : String(err), + ); + } } /** @@ -264,6 +362,20 @@ export class ReticulumSidecarManager extends EventEmitter { }); } + /** + * Clear BLE bond-removed / pairing-timeout banners after a named interface + * reconnects successfully (renderer reports health online). + */ + clearBleBondIssuesForOnlineInterfaces( + onlineInterfaceNames: readonly string[], + ): ReticulumSidecarStatus { + return this.mutateInterfaceIssues(() => { + this.interfaceIssueTracker.clearBleBondIssuesForOnlineInterfaces( + new Set(onlineInterfaceNames), + ); + }); + } + private clearSidecarTrackers(): void { this.interfaceIssueTracker.clear(); this.autoBeaconTracker.clear(); diff --git a/src/main/reticulumSidecarIssueTracker.test.ts b/src/main/reticulumSidecarIssueTracker.test.ts index 7d817e125..ab165179c 100644 --- a/src/main/reticulumSidecarIssueTracker.test.ts +++ b/src/main/reticulumSidecarIssueTracker.test.ts @@ -32,7 +32,7 @@ const BLE_BOND_REMOVED_LINE = 'BLE RNode connect failed name = RNode 41F4 error = send failed: BLE connect failed after 3 attempts: Runtime Error: Peer removed pairing information'; const BLE_BOND_REMOVED_STOP_LINE = - 'BLE RNode bond removed — stopping reconnect until stack restart (Forget OS bond + re-pair) name = RNode 41F4 error = send failed: BLE connect failed after 3 attempts: Runtime Error: Peer removed pairing information'; + 'BLE RNode bond removed — retrying with existing OS bond (Forget only if retries keep failing) name = RNode 41F4 error = send failed: BLE connect failed after 3 attempts: Runtime Error: Peer removed pairing information'; const BLE_BOND_ATTEMPT_LINE = 'BLE RNode connect attempt failed attempt = 1 error = Runtime Error: Peer removed pairing information'; @@ -96,16 +96,29 @@ describe('ReticulumSidecarInterfaceIssueTracker', () => { expect(alert?.lastAtMs).toBe(1_000); }); - it('builds alert from bond-removed halt-loop log without connect-failed prefix', () => { + it('builds alert from bond-removed retry log without connect-failed prefix', () => { tracker.recordLine(BLE_BOND_REMOVED_STOP_LINE, 1_000); const alert = tracker.getAlert(1_500); expect(alert?.bleBondRemoved).toEqual(['RNode 41F4']); }); - it('keeps bleBondRemoved sticky past the generic alert stale window', () => { + it('prunes bleBondRemoved after the generic alert stale window', () => { tracker.recordLine(BLE_BOND_REMOVED_STOP_LINE, 0); const alert = tracker.getAlert(RETICULUM_INTERFACE_ISSUE_ALERT_STALE_MS + 60_000); - expect(alert?.bleBondRemoved).toEqual(['RNode 41F4']); + expect(alert).toBeNull(); + }); + + it('clears bleBondRemoved when the named interface comes online', () => { + tracker.recordLine(BLE_BOND_REMOVED_STOP_LINE, 1_000); + expect(tracker.getAlert(1_500)?.bleBondRemoved).toEqual(['RNode 41F4']); + tracker.clearBleBondIssuesForOnlineInterfaces(new Set(['RNode 41F4'])); + expect(tracker.getAlert(1_600)).toBeNull(); + }); + + it('clearBleBondIssuesForOnlineInterfaces leaves other names latched', () => { + tracker.recordLine(BLE_BOND_REMOVED_STOP_LINE, 1_000); + tracker.clearBleBondIssuesForOnlineInterfaces(new Set(['Other RNode'])); + expect(tracker.getAlert(1_500)?.bleBondRemoved).toEqual(['RNode 41F4']); }); it('builds alert with BLE pairing-timed-out issues', () => { diff --git a/src/main/reticulumSidecarIssueTracker.ts b/src/main/reticulumSidecarIssueTracker.ts index a7295ecad..5685f7f85 100644 --- a/src/main/reticulumSidecarIssueTracker.ts +++ b/src/main/reticulumSidecarIssueTracker.ts @@ -13,7 +13,7 @@ const LINK_DELIVERY_TIMEOUT_MARKER = 'link delivery timed out'; const LXMF_PATH_REQUEST_SATURATED_MARKER = 'failed to queue path request for LXMF delivery'; const SLOW_TRANSPORT_QUERY_MARKER = 'transport query slow or failed'; const BLE_BOND_REMOVED_MARKER = 'Peer removed pairing information'; -/** rsReticulum bond-desync overlay: halt reconnect loop (tracing::warn). */ +/** rsReticulum bond-desync overlay: Peer removed pairing (tracing::warn; reconnect keeps retrying). */ const BLE_BOND_REMOVED_STOP_MARKER = 'BLE RNode bond removed'; const BLE_PAIRING_TIMED_OUT_MARKER = 'BLE pairing timed out'; @@ -115,8 +115,8 @@ function parseBleBondRemovedIface(line: string): string | null { const fromConnectFailed = parseBleRNodeConnectFailedIfaceName(plain); if (fromConnectFailed) return fromConnectFailed; } - // Overlay halt path: "BLE RNode bond removed — stopping reconnect… name=… error=…" - // (no longer emits the connect-failed prefix when exiting the reconnect loop). + // Overlay path: "BLE RNode bond removed — … name=… error=…" + // (reconnect keeps retrying; message no longer says "stopping"). if (plain.includes(BLE_BOND_REMOVED_STOP_MARKER)) { return parseSidecarIfaceNameField(plain); } @@ -334,6 +334,24 @@ export class ReticulumSidecarInterfaceIssueTracker { retainMapKeys(this.tcpReadEof, enabledNames); } + /** + * Clear BLE bond-desync / pairing-timeout latches after a successful reconnect + * (interface reported online). Does not affect TCP/TX maps. + */ + clearBleBondIssuesForOnlineInterfaces(onlineNames: ReadonlySet): void { + if (onlineNames.size === 0) return; + for (const name of [...this.bleBondRemoved.keys()]) { + if (onlineNames.has(name)) { + this.bleBondRemoved.delete(name); + } + } + for (const name of [...this.blePairingTimedOut.keys()]) { + if (onlineNames.has(name)) { + this.blePairingTimedOut.delete(name); + } + } + } + clear(): void { this.tcpConnectFailed.clear(); this.txQueueDrops.clear(); @@ -361,8 +379,9 @@ export class ReticulumSidecarInterfaceIssueTracker { pruneStaleMap(this.tcpConnectFailed, nowMs, (atMs) => atMs); pruneStaleMap(this.txQueueDrops, nowMs, (entry) => entry.atMs); pruneStaleMap(this.linkDeliveryTimeouts, nowMs, (entry) => entry.atMs); - // bleBondRemoved is sticky until stack stop / retainInterfaces / clear — sidecar has - // halted BLE reconnect for that interface; a 5‑min log TTL must not clear UI/Noble yield. + // Bond-removed no longer halts sidecar reconnect permanently — use the same + // stale window as other interface issues so recovery clears the banner. + pruneStaleMap(this.bleBondRemoved, nowMs, (atMs) => atMs); pruneStaleMap(this.blePairingTimedOut, nowMs, (atMs) => atMs); pruneStaleMap(this.tcpResetByPeer, nowMs, (atMs) => atMs); pruneStaleMap(this.tcpReadEof, nowMs, (atMs) => atMs); diff --git a/src/preload/index.ts b/src/preload/index.ts index 71ccda370..b591e77ce 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -732,6 +732,9 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('gatt:connect', sessionId, peripheralId), disconnectGatt: (sessionId: GattBleSessionId): Promise => ipcRenderer.invoke('gatt:disconnect', sessionId), + releaseGattBleCentral: (): Promise => ipcRenderer.invoke('gatt:release-ble-central'), + clearGattBondRecoveryExclusive: (): Promise => + ipcRenderer.invoke('gatt:clear-bond-recovery-exclusive'), isGattConnected: (sessionId: GattBleSessionId): Promise => ipcRenderer.invoke('gatt:is-connected', sessionId), gattToRadio: (sessionId: GattBleSessionId, bytes: Uint8Array): Promise => @@ -1120,6 +1123,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getStatus: (): Promise => ipcRenderer.invoke('reticulum:getStatus'), syncInterfaceIssueScope: (enabledInterfaceNames: string[]): Promise => ipcRenderer.invoke('reticulum:syncInterfaceIssueScope', enabledInterfaceNames), + clearBleBondIssuesForOnlineInterfaces: ( + onlineInterfaceNames: string[], + ): Promise => + ipcRenderer.invoke('reticulum:clearBleBondIssuesForOnlineInterfaces', onlineInterfaceNames), proxyGet: (apiPath: string): Promise => unwrapReticulumProxy(ipcRenderer.invoke('reticulum:proxyGet', apiPath)), proxyPost: (apiPath: string, body: unknown): Promise => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 81368b321..0b14112a3 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -84,6 +84,7 @@ import { chatChannelsFromRuntimeChannels, traceRouteHopLabels, } from '@/renderer/lib/protocolRuntimeAdapters'; +import { requestReticulumAdminBluetoothFocus } from '@/renderer/lib/reticulum/reticulumAdminBluetoothFocus'; import { useReticulumRawPacketPoll } from '@/renderer/lib/reticulum/useReticulumRawPacketPoll'; import { persistReticulumSelfLxmfHash } from '@/renderer/lib/reticulumLastSelfLxmfHash'; import { resolveReticulumOwnNodeIdSet } from '@/renderer/lib/reticulumOwnNodeIds'; @@ -3334,6 +3335,7 @@ function AppContent() { ); if (adminTabIdx >= 0) { setActiveTab(adminTabIdx); + requestReticulumAdminBluetoothFocus(); } } : undefined diff --git a/src/renderer/components/ReticulumAdminPanel.test.tsx b/src/renderer/components/ReticulumAdminPanel.test.tsx index 6d8c6471f..eb19df156 100644 --- a/src/renderer/components/ReticulumAdminPanel.test.tsx +++ b/src/renderer/components/ReticulumAdminPanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { axe } from 'vitest-axe'; @@ -25,11 +25,25 @@ vi.mock('@/renderer/lib/reticulum/useReticulumSidecarApi', () => ({ })); vi.mock('./flasher/RNodeFlasherSection', () => ({ - RNodeFlasherSection: ({ portBlocked }: { portBlocked: boolean }) => ( -
+ RNodeFlasherSection: ({ + portBlocked, + focusBluetoothNonce, + }: { + portBlocked: boolean; + focusBluetoothNonce?: number; + }) => ( +
), })); +import { + requestReticulumAdminBluetoothFocus, + resetReticulumAdminBluetoothFocusForTests, +} from '@/renderer/lib/reticulum/reticulumAdminBluetoothFocus'; import { useReticulumSidecarApi } from '@/renderer/lib/reticulum/useReticulumSidecarApi'; import { ReticulumAdminPanel } from './ReticulumAdminPanel'; @@ -37,6 +51,7 @@ import { ToastProvider } from './Toast'; describe('ReticulumAdminPanel', () => { beforeEach(() => { + resetReticulumAdminBluetoothFocusForTests(); refreshIdentity.mockReset(); vi.mocked(useReticulumSidecarApi).mockReturnValue({ sidecarUiRunning: true, @@ -61,6 +76,32 @@ describe('ReticulumAdminPanel', () => { expect(screen.getByText('adminPanel.reticulumFactoryReset.button')).toBeInTheDocument(); }); + it('expands the flasher section when Admin Bluetooth focus is requested', async () => { + const scrollIntoView = vi.fn(); + vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView); + + render( + + {}} /> + , + ); + + const summary = screen.getByText('flasher.title'); + const details = summary.closest('details'); + expect(details).not.toHaveAttribute('open'); + + act(() => { + requestReticulumAdminBluetoothFocus(); + }); + + await waitFor(() => { + expect(details).toHaveAttribute('open'); + }); + await waitFor(() => { + expect(screen.getByTestId('flasher-mock')).toHaveAttribute('data-focus-bluetooth-nonce', '1'); + }); + }); + it('collapses the flasher section by default and expands on click', async () => { const user = userEvent.setup(); render( diff --git a/src/renderer/components/ReticulumAdminPanel.tsx b/src/renderer/components/ReticulumAdminPanel.tsx index b8ce33642..1d774ea1e 100644 --- a/src/renderer/components/ReticulumAdminPanel.tsx +++ b/src/renderer/components/ReticulumAdminPanel.tsx @@ -1,10 +1,15 @@ /* eslint-disable react-hooks/set-state-in-effect */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; import { DetailsChevron } from '@/renderer/lib/icons/detailsChevron'; import { useRadioProvider } from '@/renderer/lib/radio/providerFactory'; +import { + consumeReticulumAdminBluetoothFocus, + peekReticulumAdminBluetoothFocusPending, + subscribeReticulumAdminBluetoothFocus, +} from '@/renderer/lib/reticulum/reticulumAdminBluetoothFocus'; import { isReticulumUsbSerialRnodeInterface } from '@/renderer/lib/reticulum/reticulumRnodeTransport'; import { useReticulumSidecarApi } from '@/renderer/lib/reticulum/useReticulumSidecarApi'; @@ -37,6 +42,9 @@ export function ReticulumAdminPanel({ connecting, onStartStack }: ReticulumAdmin const [interfaces, setInterfaces] = useState([]); const [showFactoryResetConfirm, setShowFactoryResetConfirm] = useState(false); const [resetInFlight, setResetInFlight] = useState(false); + const [bluetoothFocusNonce, setBluetoothFocusNonce] = useState(0); + const flasherDetailsRef = useRef(null); + const bluetoothSectionRef = useRef(null); const refreshInterfaces = useCallback(async () => { if (!sidecarApiReady) return; @@ -58,6 +66,26 @@ export function ReticulumAdminPanel({ connecting, onStartStack }: ReticulumAdmin void refreshInterfaces(); }, [sidecarApiReady, refreshInterfaces]); + const applyBluetoothFocus = useCallback(() => { + if (!consumeReticulumAdminBluetoothFocus()) return; + if (flasherDetailsRef.current) { + flasherDetailsRef.current.open = true; + } + setBluetoothFocusNonce((n) => n + 1); + requestAnimationFrame(() => { + bluetoothSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + }, []); + + useEffect(() => { + if (peekReticulumAdminBluetoothFocusPending()) { + applyBluetoothFocus(); + } + return subscribeReticulumAdminBluetoothFocus(() => { + applyBluetoothFocus(); + }); + }, [applyBluetoothFocus]); + const flasherPortBlocked = sidecarApiReady && interfaces.some((iface) => isReticulumUsbSerialRnodeInterface(iface)); @@ -97,13 +125,17 @@ export function ReticulumAdminPanel({ connecting, onStartStack }: ReticulumAdmin ) : null} {capabilities.hasRNodeFlasher ? ( -
+
{t('flasher.title')}
- +
) : null} diff --git a/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.test.tsx b/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.test.tsx index 73fc52ad2..4e38dbfd2 100644 --- a/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.test.tsx +++ b/src/renderer/components/ReticulumLocalInterfaceAlertsBlock.test.tsx @@ -115,11 +115,13 @@ describe('ReticulumLocalInterfaceAlertsBlock', () => { }); describe('Reticulum BLE offline hint copy', () => { - it('tells users to remove and re-add a stale BLE RNode interface', () => { + it('tells users to Forget and re-pair a stale BLE RNode bond via Admin Bluetooth', () => { const hint = en.connectionPanel.reticulumLocalInterfaces.offlineHintBle; expect(hint).toMatch(/remove this interface and add it back/i); expect(hint).toMatch(/Pick device/); const stale = en.connectionPanel.reticulumLocalInterfaces.offlineHintBleBondStale; - expect(stale).toMatch(/remove and re-add this interface/i); + expect(stale).toMatch(/Forget this RNode/i); + expect(stale).toMatch(/Reticulum Admin/); + expect(stale).toMatch(/Clear paired \/ Start pairing/); }); }); diff --git a/src/renderer/components/flasher/RNodeFlasherSection.tsx b/src/renderer/components/flasher/RNodeFlasherSection.tsx index 490950c7a..fe7321dc0 100644 --- a/src/renderer/components/flasher/RNodeFlasherSection.tsx +++ b/src/renderer/components/flasher/RNodeFlasherSection.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { sanitizeLogMessage } from '@/main/sanitize-log-message'; @@ -56,9 +56,17 @@ import { WifiConfig } from './WifiConfig'; export interface RNodeFlasherSectionProps { portBlocked: boolean; + /** Bumped when Admin Bluetooth focus is requested — scroll Bluetooth into view. */ + focusBluetoothNonce?: number; + /** Optional host ref for the Bluetooth section (Admin scroll target). */ + bluetoothSectionRef?: RefObject; } -export function RNodeFlasherSection({ portBlocked }: RNodeFlasherSectionProps) { +export function RNodeFlasherSection({ + portBlocked, + focusBluetoothNonce = 0, + bluetoothSectionRef, +}: RNodeFlasherSectionProps) { const { t } = useTranslation(); const { serialPorts, showSerialPicker, requestSerialPort, selectSerialPort, cancelSerialPicker } = useElectronSerialPortPicker(); @@ -525,53 +533,59 @@ export function RNodeFlasherSection({ portBlocked }: RNodeFlasherSectionProps) { }} /> - { - void runWithRNode(async (rnode) => { - await rnode.enableBluetooth(); - }); - }} - onDisable={() => { - void runWithRNode(async (rnode) => { - await rnode.disableBluetooth(); - pairingSessionRef.current.invalidate(); - setPairingPin(null); - setPairingPending(false); - }); - }} - onStartPairing={() => { - void runWithRNode(async (rnode) => { - setPairingPin(null); - setPairingPending(true); - // Keeps the USB session open until CMD_BT_PIN or pairing timeout. - const attempt = pairingSessionRef.current.begin(() => { +
+ { + void runWithRNode(async (rnode) => { + await rnode.enableBluetooth(); + }); + }} + onDisable={() => { + void runWithRNode(async (rnode) => { + await rnode.disableBluetooth(); + pairingSessionRef.current.invalidate(); + setPairingPin(null); setPairingPending(false); }); - try { - await rnode.startBluetoothPairing((pin) => { - if (!attempt.isCurrent()) return; - attempt.clearTimer(); - const pinLabel = String(pin).padStart(6, '0'); - setPairingPin(pin); + }} + onStartPairing={() => { + void runWithRNode(async (rnode) => { + setPairingPin(null); + setPairingPending(true); + // Keeps the USB session open until CMD_BT_PIN or pairing timeout. + const attempt = pairingSessionRef.current.begin(() => { setPairingPending(false); - showStatus(t('flasher.pairingPin', { pin: pinLabel })); }); - } catch (err) { - if (attempt.isCurrent()) { - attempt.clearTimer(); - setPairingPending(false); + try { + await rnode.startBluetoothPairing((pin) => { + if (!attempt.isCurrent()) return; + attempt.clearTimer(); + const pinLabel = String(pin).padStart(6, '0'); + setPairingPin(pin); + setPairingPending(false); + showStatus(t('flasher.pairingPin', { pin: pinLabel })); + }); + } catch (err) { + if (attempt.isCurrent()) { + attempt.clearTimer(); + setPairingPending(false); + } + throw err; } - throw err; - } - }); - }} - onClearPairedDevices={() => { - setShowClearBondsConfirm(true); - }} - /> + }); + }} + onClearPairedDevices={() => { + setShowClearBondsConfirm(true); + }} + /> +
boolean }, ): Promise { const maxWaitMs = opts?.maxWaitMs ?? BLE_SCAN_BUSY_MAX_WAIT_MS; const retryIntervalMs = opts?.retryIntervalMs ?? BLE_SCAN_BUSY_RETRY_INTERVAL_MS; @@ -89,6 +89,9 @@ export async function connectGattWithScanBusyRetry( let lastError = 'BLE connect failed'; while (Date.now() < deadline) { + if (opts?.shouldAbort?.()) { + throw new Error('RNode bond recovery holds the Bluetooth adapter'); + } const result = await window.electronAPI.connectGatt(sessionId, peripheralId); if (result.ok) { return; @@ -96,6 +99,9 @@ export async function connectGattWithScanBusyRetry( const message = result.error || 'BLE connect failed'; const code = 'code' in result && typeof result.code === 'string' ? result.code : undefined; lastError = code && code !== 'scan_busy' ? `${code}: ${message}` : message; + if (code === 'rnode_bond_recovery' || /RNode bond recovery/i.test(message)) { + throw new Error(lastError); + } if (!isBleScanBusyErrorMessage(message) && code !== 'scan_busy') { throw new Error(lastError); } diff --git a/src/renderer/lib/connection.ts b/src/renderer/lib/connection.ts index 3104d6be6..e45078198 100644 --- a/src/renderer/lib/connection.ts +++ b/src/renderer/lib/connection.ts @@ -20,6 +20,7 @@ import { isBlePeripheralConflictErrorMessage, isBleScanBusyErrorMessage, } from './reticulum/reticulumBleAdapterLease'; +import { getReticulumBleBondDesyncActive } from './reticulum/reticulumBleBondDesync'; import { SERIAL_OPEN_TIMEOUT_MS, withSerialTransportTimeout } from './serialPortRecovery'; import { getPortSignature, @@ -141,7 +142,9 @@ export async function createBleConnection( try { // Waits out short Reticulum BLE RNode yields instead of hard-failing; // peripheral conflict and other errors still fail immediately. - await connectGattWithScanBusyRetry(sessionId, peripheralId); + await connectGattWithScanBusyRetry(sessionId, peripheralId, { + shouldAbort: () => getReticulumBleBondDesyncActive(), + }); notifyBlePrimaryRfLinkReady(sessionId); if (attempt > 1) { console.info( diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index 4c8a1f934..ec4b40d19 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -179,6 +179,8 @@ export function createDevElectronApiStub(): typeof window.electronAPI { stopGattScanning: noopAsync, connectGatt: async () => ({ ok: true }), disconnectGatt: noopAsync, + releaseGattBleCentral: noopAsync, + clearGattBondRecoveryExclusive: noopAsync, isGattConnected: async () => false, gattToRadio: noopAsync, onSerialPortsDiscovered: noopUnsub, @@ -366,6 +368,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { stop: noopAsync, getStatus: async () => ({ running: false, port: 0, pid: null }), syncInterfaceIssueScope: async () => ({ running: false, port: 0, pid: null }), + clearBleBondIssuesForOnlineInterfaces: async () => ({ running: false, port: 0, pid: null }), proxyGet: async () => ({}), proxyPost: async () => ({ ok: true }), proxyPut: async () => ({ ok: true }), diff --git a/src/renderer/lib/gattScanBusyConnect.contract.test.ts b/src/renderer/lib/gattScanBusyConnect.contract.test.ts index c66466f07..4472afb06 100644 --- a/src/renderer/lib/gattScanBusyConnect.contract.test.ts +++ b/src/renderer/lib/gattScanBusyConnect.contract.test.ts @@ -29,7 +29,7 @@ describe('GATT scan-busy connect retry (regression)', () => { "import { connectGattWithScanBusyRetry } from './bleReconnectHelper';", ); expect(CONNECTION_SOURCE).toMatch( - /await connectGattWithScanBusyRetry\(sessionId, peripheralId\);/, + /await connectGattWithScanBusyRetry\(sessionId, peripheralId,\s*\{[\s\S]*shouldAbort:/, ); // Must not go back to a one-shot connect that hard-fails on reticulum scan lease. expect(CONNECTION_SOURCE).not.toMatch( @@ -43,7 +43,7 @@ describe('GATT scan-busy connect retry (regression)', () => { ); expect(MESHCORE_TRANSPORT_SOURCE).toContain('class IpcSidecarGattConnection'); expect(MESHCORE_TRANSPORT_SOURCE).toMatch( - /connectGattWithScanBusyRetry\(sessionId, this\.peripheralId\)/, + /connectGattWithScanBusyRetry\(sessionId, this\.peripheralId,\s*\{[\s\S]*shouldAbort:/, ); }); }); diff --git a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts index 17104f71f..482f4afa3 100644 --- a/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts +++ b/src/renderer/lib/protocols/meshcore/MeshCoreTransport.ts @@ -19,6 +19,7 @@ import { patchMeshcoreCompanionTxEchoFilter } from '../../meshcoreCompanionTxEch import { notifyBlePrimaryRfLinkReady } from '../../meshcoreDualNobleBleInit'; import { createSerializedWritableStream } from '../../meshtastic/meshtasticTransportLossDetection'; import { parseTcpAddress } from '../../parseTcpAddress'; +import { getReticulumBleBondDesyncActive } from '../../reticulum/reticulumBleBondDesync'; import { openSerialPortWithTimeout } from '../../serialPortRecovery'; import { persistSerialPortIdentity, selectGrantedSerialPort } from '../../serialPortSignature'; import { MESHCORE_BLE_DEVICE_QUERY_TIMEOUT_MS } from '../../timeConstants'; @@ -367,7 +368,9 @@ class IpcSidecarGattConnection { try { await withTimeout( - connectGattWithScanBusyRetry(sessionId, this.peripheralId), + connectGattWithScanBusyRetry(sessionId, this.peripheralId, { + shouldAbort: () => getReticulumBleBondDesyncActive(), + }), GATT_IPC_CONNECT_TIMEOUT_MS, 'MeshCore BLE IPC open', ); diff --git a/src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.test.ts b/src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.test.ts new file mode 100644 index 000000000..887f2489d --- /dev/null +++ b/src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createElectronAPIMock } from '@/renderer/vitest.electronApiMock'; + +import { clearReticulumBleBondIssuesForOnlineInterfaces } from './clearReticulumBleBondIssuesForOnlineInterfaces'; + +describe('clearReticulumBleBondIssuesForOnlineInterfaces', () => { + beforeEach(() => { + window.electronAPI = createElectronAPIMock(); + }); + + it('no-ops for empty names', async () => { + const spy = vi.mocked(window.electronAPI.reticulum.clearBleBondIssuesForOnlineInterfaces); + await clearReticulumBleBondIssuesForOnlineInterfaces([]); + await clearReticulumBleBondIssuesForOnlineInterfaces([' ', '']); + expect(spy).not.toHaveBeenCalled(); + }); + + it('dedupes and invokes IPC for online BLE names', async () => { + const spy = vi + .mocked(window.electronAPI.reticulum.clearBleBondIssuesForOnlineInterfaces) + .mockResolvedValue({ running: true, port: 1, pid: 1 }); + await clearReticulumBleBondIssuesForOnlineInterfaces(['RNode 41F4', ' RNode 41F4 ', 'Other']); + expect(spy).toHaveBeenCalledWith(['RNode 41F4', 'Other']); + }); +}); diff --git a/src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.ts b/src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.ts new file mode 100644 index 000000000..d947c9c38 --- /dev/null +++ b/src/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces.ts @@ -0,0 +1,18 @@ +import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; + +/** + * After a BLE RNode reports online, clear latched `bleBondRemoved` / + * `blePairingTimedOut` banners in main so Connection updates immediately + * (without waiting for the 5-minute stale window or a stack restart). + */ +export async function clearReticulumBleBondIssuesForOnlineInterfaces( + onlineNames: readonly string[], +): Promise { + const names = [...new Set(onlineNames.map((n) => n.trim()).filter(Boolean))]; + if (names.length === 0) return; + try { + await window.electronAPI.reticulum.clearBleBondIssuesForOnlineInterfaces(names); + } catch (e: unknown) { + console.debug('[reticulum] clearBleBondIssuesForOnlineInterfaces ' + errLikeToLogString(e)); + } +} diff --git a/src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.test.ts b/src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.test.ts new file mode 100644 index 000000000..32961820b --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + consumeReticulumAdminBluetoothFocus, + peekReticulumAdminBluetoothFocusPending, + requestReticulumAdminBluetoothFocus, + resetReticulumAdminBluetoothFocusForTests, + subscribeReticulumAdminBluetoothFocus, +} from './reticulumAdminBluetoothFocus'; + +describe('reticulumAdminBluetoothFocus', () => { + afterEach(() => { + resetReticulumAdminBluetoothFocusForTests(); + }); + + it('latches a pending focus until consumed', () => { + expect(peekReticulumAdminBluetoothFocusPending()).toBe(false); + requestReticulumAdminBluetoothFocus(); + expect(peekReticulumAdminBluetoothFocusPending()).toBe(true); + expect(consumeReticulumAdminBluetoothFocus()).toBe(true); + expect(peekReticulumAdminBluetoothFocusPending()).toBe(false); + expect(consumeReticulumAdminBluetoothFocus()).toBe(false); + }); + + it('notifies subscribers when focus is requested', () => { + const listener = vi.fn(); + const unsubscribe = subscribeReticulumAdminBluetoothFocus(listener); + requestReticulumAdminBluetoothFocus(); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + requestReticulumAdminBluetoothFocus(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.ts b/src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.ts new file mode 100644 index 000000000..b0fe183f2 --- /dev/null +++ b/src/renderer/lib/reticulum/reticulumAdminBluetoothFocus.ts @@ -0,0 +1,48 @@ +/** + * Cross-component signal: Connection issue alert → Admin tab → expand RNode flasher + * Bluetooth (Clear paired / Start pairing). + * + * Admin panel may be unmounted until the tab activates, so we keep a pending flag + * that the panel consumes on mount / when the nonce bumps. + */ + +let pendingAdminBluetoothFocus = false; +let focusNonce = 0; +const listeners = new Set<() => void>(); + +/** Request Admin → RNode flasher → Bluetooth focus (call after switching to Admin tab). */ +export function requestReticulumAdminBluetoothFocus(): number { + pendingAdminBluetoothFocus = true; + focusNonce += 1; + for (const listener of listeners) { + listener(); + } + return focusNonce; +} + +/** True if a focus was requested and not yet consumed. */ +export function peekReticulumAdminBluetoothFocusPending(): boolean { + return pendingAdminBluetoothFocus; +} + +/** Consume a pending focus request (idempotent). Returns true if one was pending. */ +export function consumeReticulumAdminBluetoothFocus(): boolean { + if (!pendingAdminBluetoothFocus) return false; + pendingAdminBluetoothFocus = false; + return true; +} + +/** Subscribe to focus requests (Admin panel mounts late). */ +export function subscribeReticulumAdminBluetoothFocus(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Test helper: reset module state. */ +export function resetReticulumAdminBluetoothFocusForTests(): void { + pendingAdminBluetoothFocus = false; + focusNonce = 0; + listeners.clear(); +} diff --git a/src/renderer/lib/reticulum/reticulumBleBondDesync.ts b/src/renderer/lib/reticulum/reticulumBleBondDesync.ts index f0841ecc5..bc6996598 100644 --- a/src/renderer/lib/reticulum/reticulumBleBondDesync.ts +++ b/src/renderer/lib/reticulum/reticulumBleBondDesync.ts @@ -1,6 +1,6 @@ /** - * Sticky flag while sidecar latched `bleBondRemoved` — yield watcher must not - * re-contend for Noble, and reconnect thrash should not hold the scan mutex. + * Active while sidecar latched `bleBondRemoved` — yield watcher must not + * re-contend for Noble. Clears when the alert drains (recovery / stale window). */ let bondDesyncActive = false; diff --git a/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.test.ts b/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.test.ts index 2a4e1528a..aed4bd504 100644 --- a/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.test.ts +++ b/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.test.ts @@ -40,6 +40,23 @@ describe('reticulumLocalInterfaceLogging', () => { expect(warn.mock.calls[0]?.[0]).toContain('ble://aa:bb:cc:dd:ee:ff'); }); + it('returns BLE RNode names that newly transitioned to online', () => { + const bleRnode = { + ...heltec, + id: 'rnode-ble', + name: 'RNode 41F4', + serial_port: 'ble://aa:bb:cc:dd:ee:ff', + status: 'down', + }; + expect(logReticulumLocalInterfaceHealthChanges([bleRnode], [])).toEqual([]); + expect(logReticulumLocalInterfaceHealthChanges([{ ...bleRnode, status: 'up' }], [])).toEqual([ + 'RNode 41F4', + ]); + expect(logReticulumLocalInterfaceHealthChanges([{ ...bleRnode, status: 'up' }], [])).toEqual( + [], + ); + }); + it('logs recovery at debug and stale USB ports at warn', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const debug = vi.spyOn(console, 'debug').mockImplementation(() => {}); diff --git a/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.ts b/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.ts index 7dfceedf5..83c7bb443 100644 --- a/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.ts +++ b/src/renderer/lib/reticulum/reticulumLocalInterfaceLogging.ts @@ -53,11 +53,14 @@ function logHealthTransition( ); } -/** Emit debug-log lines when local interface health changes (deduped per interface id). */ +/** Emit debug-log lines when local interface health changes (deduped per interface id). + * Returns BLE RNode display names that newly transitioned to online (for bond-alert clear). + */ export function logReticulumLocalInterfaceHealthChanges( interfaces: readonly ReticulumLocalInterfaceInput[], osSerialPorts: readonly string[], -): void { +): string[] { + const newlyOnlineBleNames: string[] = []; const seenIds = new Set(); for (const iface of interfaces) { seenIds.add(iface.id); @@ -68,12 +71,22 @@ export function logReticulumLocalInterfaceHealthChanges( } healthSnapshot.set(iface.id, health); logHealthTransition(iface, health, prev); + if ( + health === 'online' && + prev != null && + prev !== 'online' && + reticulumLocalOfflineDisplayKind(iface) === 'ble' && + iface.name.trim() + ) { + newlyOnlineBleNames.push(iface.name.trim()); + } } for (const id of healthSnapshot.keys()) { if (!seenIds.has(id)) { healthSnapshot.delete(id); } } + return newlyOnlineBleNames; } export function formatReticulumInterfaceStateEvent(payload: unknown): string { diff --git a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.test.ts b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.test.ts index 71a7f2acc..b0912425f 100644 --- a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.test.ts +++ b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.test.ts @@ -20,7 +20,7 @@ vi.mock('@/renderer/lib/reticulum/reticulumBleAdapterConflict', () => ({ vi.mock('@/renderer/lib/reticulum/reticulumBleAdapterLease', () => ({})); vi.mock('@/renderer/lib/reticulum/reticulumLocalInterfaceLogging', () => ({ - logReticulumLocalInterfaceHealthChanges: vi.fn(), + logReticulumLocalInterfaceHealthChanges: vi.fn().mockReturnValue([]), })); vi.mock('@/renderer/lib/reticulum/reticulumLocalInterfaceRefresh', () => ({ diff --git a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts index 85e114075..38e3cf82f 100644 --- a/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts +++ b/src/renderer/lib/reticulum/useReticulumInterfaceSnapshot.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNowMs } from '@/renderer/hooks/useNowMs'; import { errLikeToLogString } from '@/renderer/lib/errLikeToLogString'; +import { clearReticulumBleBondIssuesForOnlineInterfaces } from '@/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces'; import { syncReticulumBleRegistry } from '@/renderer/lib/reticulum/reticulumBleAdapterConflict'; import { beginReticulumBleConnectGrace, @@ -163,7 +164,10 @@ export function useReticulumInterfaceSnapshot({ setEffectivePrimaryLocalSerialInterfaceId( getCachedReticulumEffectivePrimaryLocalSerialInterfaceId(), ); - logReticulumLocalInterfaceHealthChanges(rows, paths); + const newlyOnlineBle = logReticulumLocalInterfaceHealthChanges(rows, paths); + if (newlyOnlineBle.length > 0) { + void clearReticulumBleBondIssuesForOnlineInterfaces(newlyOnlineBle); + } await syncReticulumBleRegistry(rows); return { interfaces: rows, paths }; } catch (e) { diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index e1dbd0788..cff4929d2 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -1395,7 +1395,7 @@ "tcpUnreachableHint": "Vzdálená instance Reticulum může být offline nebo blokovat připojení. Zakažte toto rozhraní nebo ověřte hostitele a port. Časté restarty mesh-client nebo zásobníku mohou způsobit, že uzly Reticulum 1.4.0+ zablokují vaši IP na 12 hodin — raději čekejte, než znovu restartovat.", "connectingHintBle": "Spojení BLE RNode mohou trvat až 60 sekund po spuštění zásobníku (okno passkey operačního systému). Pokud se zobrazí výzva, zadejte aktuální PIN kód RNode — nikdy 123456. To je normální, nejedná se o chybu. Přístroj se může objevit v nastavení systému až po úspěšném spojení.", "offlineHintBle": "Chcete-li tuto chybu odstranit, možná budete muset toto rozhraní odebrat a přidat jej zpět (Vybrat zařízení), aby se obnovilo uložené ID Bluetooth a poté restartujte zásobník. Operační systém může vyžadovat 6místný kód PIN Bluetooth (macOS, Windows nebo Linux). RNode generuje nový kód při každém párování — nikdy nepoužívejte 123456 (to je výchozí Meshtastic, ne RNode). Před připojením této aplikace se RNode nemusí objevit v systémovém nastavení → Bluetooth; dialogové okno OS se zobrazí, když se zásobník spáruje. Bezhlavá rádia: připojte USB → Reticulum Admin → Bluetooth → Spustit párování → zadejte PIN zobrazený na panelu Admin (ne na displeji rádia) do dialogu OS. Rádia s displejem: podržte tlačítko ~7 s na zařízeních Heltec (časování se liší; ~10 s na mnoha deskách místo toho umožňuje Wi-Fi AP), přečtěte si PIN na obrazovce a poté jej zadejte do dialogu OS. Před opakováním pokusu vymažte neúspěšné vazby v nastavení Bluetooth operačního systému.", - "offlineHintBleBondStale": "macOS stále zobrazuje tento RNode jako spárovaný, ale klíče Bluetooth se již neshodují (peer odstranil informace o párování). Zapomeňte zařízení v Nastavení systému → Bluetooth, přepněte RNode do režimu párování (Admin → Bluetooth → Spustit párování nebo držte tlačítko ~7 s), poté odeberte a znovu přidejte toto rozhraní (Vybrat zařízení), restartujte zásobník a zadejte nový 6místný PIN, když budete vyzváni.", + "offlineHintBleBondStale": "macOS stále zobrazuje Paired, ale Bluetooth odmítl vazbu (Peer removed pairing information). Zapomeňte tento RNode v Nastavení systému → Bluetooth, pak znovu spárujte: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing a zadejte nový PIN. Preferujte USB serial nebo Wi-Fi (tcp://), pokud potřebujete rádio online ihned.", "tcpFastFlap": "{{name}}: hub pravděpodobně zablokoval tvoji IP adresu po častém restartování zásobníku ({{host}}:{{port}})", "tcpFastFlapHint": "Veřejné rozbočovače Reticulum 1.4.0+ blokují IP po pěti krátkých relacích TCP během 12 hodin. Rychlé restarty mesh-client nebo zásobníku se počítají. Zakažte tento rozbočovač a počkejte až 12 hodin — opětovné spuštění prodlouží blok." }, @@ -1474,7 +1474,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "Sdílená instance je povolena — zcela ukončete ostatní aplikace Reticulum (MeshChatX, Ratspeak, samostatný rnsd) a restartujte tento zásobník před opakováním DM.", "bleBondRemoved": "{{name}}: Připojení Bluetooth je zastaralé (OS stále zobrazuje Spárováno)", - "bleBondRemovedHint": "Nejprve zapomeňte tento RNode v Nastavení systému → Bluetooth (macOS Paired automaticky nevymaže). Pokud můžete, preferujte USB serial nebo Wi‑Fi (tcp://), abyste se vyhnuli BLE vazbám. Poté: Admin → Bluetooth → Vymazat spárovaná zařízení (USB, ESP32) pokud je k dispozici, Spustit párování, odeberte a znovu přidejte toto BLE rozhraní (Vybrat zařízení), restartujte stack a zadejte nový 6místný PIN.", + "bleBondRemovedHint": "Automatické exclusive reconnect nemohlo tuto vazbu obnovit. Zapomeňte RNode v Nastavení systému → Bluetooth, pak Admin → Clear paired / Start pairing a zadejte nový PIN. Do opětovného spárování preferujte USB serial nebo Wi-Fi (tcp://). LoRa BLE (MeshCore/Meshtastic) zůstává pozastaveno, dokud tento RNode používá Bluetooth.", "blePairingTimedOut": "{{name}}: Vypršel časový limit výměny přístupového klíče Bluetooth", "blePairingTimedOutHint": "Zastavte zásobník, spusťte párování znovu (Správce → Bluetooth zobrazí PIN na panelu — displej rádia může zůstat prázdný — nebo tlačítko ~7 s podržte pro PIN na obrazovce), restartujte zásobník jednou a zadejte tento PIN v dialogovém okně OS během ~60 sekund. Nikdy nepoužívejte 123456 (výchozí Meshtastic).", "bleBondRemovedStopStack": "Zastavit zásobník", @@ -1484,7 +1484,9 @@ "txQueueDropsHintNeutral": "Tento kanál TX rozhraní je plný. Chcete-li zastavit kapky, zkontrolujte stav odkazu rozhraní (nebo jej zakažte).", "txQueueDropsHintBleFlowControl": "Protitlak ve frontě TX hostitele, když je rádio zaneprázdněno (řízení průtoku zapnuto). Očekává se při přetížení RF — ne zaseknutý odkaz. Snižte odchozí zatížení nebo počkejte na vysílání.", "tcpResetByPeer": "{{name}}: rozbočovač resetuje relaci TCP (připojení resetuje kolega)", - "tcpReadEof": "{{name}}: hub uzavřen TCP čistě (INFO: TCP přečteno: EOF)" + "tcpReadEof": "{{name}}: hub uzavřen TCP čistě (INFO: TCP přečteno: EOF)", + "bleLtkDesyncPurged": "Neshoda šifrovacího klíče (LTK desync) — odstraněna zastaralá vazba OS. Opětovné spárování: Správce → Bluetooth → Spusťte párování a zadejte nový kód PIN.", + "bleLtkDesyncManualForget": "Neshoda šifrovacího klíče (desynchronizace LTK). Zapomeňte na tento RNode v systémovém nastavení → Bluetooth a poté → spusťte párování s administrátorem. Nainstalujte blueutil (spařte a nainstalujte blueutil), abyste umožnili automatické zrušení párování v systému macOS." }, "reticulumSidecarBundledMissing": "V této instalaci chybí přibalený Reticulum sidecar. Upgradujte na novější verzi nebo nahlaste problém – sestavení ze zdroje není v balených instalacích k dispozici.", "radioMqttRootDivergesWarning": "Kořenové téma rádia MQTT je {{radioRoot}}, ale tento panel odebírá {{appPrefix}}. Přizpůsobením rádia omezíte celostátní provoz uzlů — aktualizujte předponu tématu a znovu připojte MQTT.", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 0ccdb2daa..8631f81f2 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -1374,7 +1374,7 @@ "tcpUnreachableHint": "Die Remote-Reticulum-Instanz kann offline sein oder Verbindungen blockieren. Deaktivieren Sie diese Schnittstelle oder überprüfen Sie den Host und den Port. Häufige Neustarts von mesh-client oder des Stacks können dazu führen, dass Reticulum-1.4.0+-Hubs Ihre IP 12 Stunden lang blockieren — lieber warten statt erneut neu starten.", "connectingHintBle": "BLE RNode-Links können bis zu 60 Sekunden nach dem Start des Stacks dauern (OS-Passkey-Fenster). Geben Sie bei Aufforderung die aktuelle RNode-PIN ein — niemals 123456. Das ist normal, kein Fehler. Das Gerät wird möglicherweise erst nach einer erfolgreichen Verbindung in den Systemeinstellungen angezeigt.", "offlineHintBle": "Um diesen Fehler zu beheben, müssen Sie möglicherweise diese Schnittstelle entfernen und wieder hinzufügen (Gerät auswählen), damit die gespeicherte Bluetooth-ID aktualisiert wird, und dann den Stack neu starten. Ihr Betriebssystem fordert möglicherweise eine 6-stellige Bluetooth-PIN an (macOS, Windows oder Linux). RNode generiert bei jeder Kopplung einen neuen Code — verwenden Sie niemals 123456 (das ist die Standardeinstellung von Meshtastic, nicht RNode). Der RNode muss nicht in den Systemeinstellungen → Bluetooth angezeigt werden, bevor diese App eine Verbindung herstellt; der OS-Dialog erscheint, wenn der Stack koppelt. Headless-Radios: USB verbinden → Reticulum Admin → Bluetooth → Kopplung starten → geben Sie die im Admin-Panel angezeigte PIN (nicht auf dem Radiodisplay) in den OS-Dialog ein. Radios mit Display: Taste ~7 s auf Heltec-Geräten gedrückt halten (Timing variiert; ~10 s auf vielen Boards aktiviert stattdessen Wi‑Fi-AP), PIN vom Bildschirm ablesen und im OS-Dialog eingeben. Löschen Sie fehlgeschlagene Bindungen in den Bluetooth-Einstellungen Ihres Betriebssystems, bevor Sie es erneut versuchen.", - "offlineHintBleBondStale": "macOS zeigt diesen RNode immer noch als gekoppelt an, aber die Bluetooth-Schlüssel stimmen nicht mehr überein (Peer entfernte die Kopplungsinformationen). Vergessen Sie das Gerät in den Systemeinstellungen → Bluetooth, schalten Sie den RNode in den Kopplungsmodus (Admin → Bluetooth → Start Pairing oder halten Sie die Taste ~7 s gedrückt), entfernen Sie dann diese Schnittstelle und fügen Sie sie erneut hinzu (Gerät auswählen), starten Sie den Stack neu und geben Sie die neue 6-stellige PIN ein, wenn Sie dazu aufgefordert werden.", + "offlineHintBleBondStale": "macOS zeigt weiterhin Paired, aber Bluetooth hat die Bindung abgelehnt (Peer removed pairing information). Vergessen Sie diesen RNode in Systemeinstellungen → Bluetooth, dann neu koppeln: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing und neue PIN eingeben. Bevorzugen Sie USB-Serial oder Wi-Fi (tcp://), wenn das Funkgerät sofort online sein muss.", "tcpFastFlap": "{{name}}: Hub hat deine IP wahrscheinlich nach häufigen Stack-Neustarts blockiert ({{host}}:{{port}})", "tcpFastFlapHint": "Reticulum 1.4.0+ öffentliche Hubs blockieren eine IP nach fünf kurzen TCP-Sitzungen innerhalb von 12 Stunden. Schnelle Neustarts von mesh-client oder des Stacks zählen. Deaktivieren Sie diesen Hub und warten Sie bis zu 12 Stunden — ein erneuter Neustart verlängert die Sperre." }, @@ -1453,7 +1453,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "Share-Instanz ist aktiviert — beenden Sie vollständig andere Reticulum-Apps (MeshChatX, Ratspeak, Standalone-RNSD) und starten Sie diesen Stack neu, bevor Sie DMs erneut versuchen.", "bleBondRemoved": "{{name}}: Bluetooth-Verbindung ist veraltet (das Betriebssystem zeigt immer noch „Paired“ an)", - "bleBondRemovedHint": "Vergessen Sie diesen RNode zuerst in Systemeinstellungen → Bluetooth (macOS entfernt Paired nicht automatisch). Bevorzugen Sie wenn möglich USB-Serial oder Wi‑Fi (tcp://), um BLE-Bindungen zu vermeiden. Dann: Admin → Bluetooth → Gekoppelte Geräte löschen (USB, ESP32) falls verfügbar, Kopplung starten, diese BLE-Schnittstelle entfernen und neu hinzufügen (Gerät wählen), Stack neu starten und die neue 6-stellige PIN eingeben.", + "bleBondRemovedHint": "Automatisches exclusive reconnect konnte diese Bindung nicht heilen. Vergessen Sie den RNode in Systemeinstellungen → Bluetooth, dann Admin → Clear paired / Start pairing und neue PIN eingeben. Bis zur Neu-Kopplung USB-Serial oder Wi-Fi (tcp://) bevorzugen. LoRa BLE (MeshCore/Meshtastic) bleibt pausiert, solange dieser RNode Bluetooth nutzt.", "blePairingTimedOut": "{{name}}: Zeitüberschreitung beim Austausch des Bluetooth-Passschlüssels", "blePairingTimedOutHint": "Stoppen Sie den Stack, starten Sie die Kopplung erneut (Admin → Bluetooth zeigt die PIN im Bedienfeld an — das Radio-Display bleibt möglicherweise leer — oder halten Sie die ~7 s-Taste für eine PIN auf dem Bildschirm gedrückt), starten Sie den Stack einmal neu und geben Sie diese PIN innerhalb von ~60 Sekunden in das OS-Dialogfeld ein. Verwenden Sie niemals 123456 (Meshtastic-Standard).", "bleBondRemovedStopStack": "Stapel stoppen", @@ -1463,7 +1463,9 @@ "txQueueDropsHintNeutral": "Dieser Schnittstellen-TX-Kanal ist voll. Überprüfen Sie die Verknüpfungsgesundheit der Schnittstelle (oder deaktivieren Sie sie), um das Herunterfallen zu stoppen.", "txQueueDropsHintBleFlowControl": "Host-TX-Warteschlangengegendruck, während das Funkgerät besetzt ist (Flusssteuerung eingeschaltet). Wird unter HF-Stau erwartet — keine steckengebliebene Verbindung. Reduzieren Sie die ausgehende Last oder warten Sie auf Sendezeit.", "tcpResetByPeer": "{{name}}: Hub setzt die TCP-Sitzung zurück (Verbindung von Peer zurückgesetzt)", - "tcpReadEof": "{{name}}: Hub hat TCP sauber geschlossen (INFO: TCP gelesen: EOF)" + "tcpReadEof": "{{name}}: Hub hat TCP sauber geschlossen (INFO: TCP gelesen: EOF)", + "bleLtkDesyncPurged": "Nichtübereinstimmung des Verschlüsselungsschlüssels (LTK-Desync) — veraltete OS-Bindung entfernt. Erneut koppeln: Admin → Bluetooth → Starten Sie die Kopplung und geben Sie dann die neue PIN ein.", + "bleLtkDesyncManualForget": "Nichtübereinstimmung des Verschlüsselungsschlüssels (LTK-Desync). Vergessen Sie diesen RNode in den Systemeinstellungen → Bluetooth und dann Admin → Start Pairing. Installieren Sie blueutil (brew install blueutil), um eine automatische Entkopplung unter macOS zu ermöglichen." }, "reticulumSidecarBundledMissing": "Bei dieser Installation fehlt der mitgelieferte Reticulum-Sidecar. Führen Sie ein Upgrade auf eine neuere Version durch oder melden Sie das Problem – das Erstellen aus dem Quellcode ist bei Paketinstallationen nicht verfügbar.", "radioMqttRootDivergesWarning": "Das Hauptthema von Radio MQTT ist {{radioRoot}}, aber dieses Panel abonniert {{appPrefix}}. Passen Sie das Funkgerät an, um den landesweiten Knotenverkehr zu begrenzen — aktualisieren Sie das Themenpräfix und verbinden Sie MQTT erneut.", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index daf970ab5..55b358b53 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -978,7 +978,7 @@ "offline": "{{name}}: local interface is enabled but offline", "offlineHint": "Check cable, release port from Meshtastic/MeshCore, then restart the stack.", "offlineHintBle": "To clear this error you may need to remove this interface and add it back (Pick device) so the saved Bluetooth id is refreshed, then restart the stack. Your OS may prompt for a 6-digit Bluetooth PIN (macOS, Windows, or Linux). RNode generates a new code each pairing — never use 123456 (that is Meshtastic’s default, not RNode). The RNode need not appear in System Settings → Bluetooth before this app connects; the OS dialog appears when the stack pairs. Headless radios: connect USB → Reticulum Admin → Bluetooth → Start pairing → enter the PIN shown in the Admin panel (not on the radio display) into the OS dialog. Radios with a display: hold the button ~7 s on Heltec devices (timing varies; ~10 s on many boards enables Wi-Fi AP instead), read the PIN on screen, then enter it in the OS dialog. Clear failed bonds in your OS Bluetooth settings before retrying.", - "offlineHintBleBondStale": "macOS still shows this RNode as Paired, but Bluetooth keys no longer match (Peer removed pairing information). Forget the device in System Settings → Bluetooth, put the RNode in pairing mode (Admin → Bluetooth → Start pairing, or hold the button ~7 s), then remove and re-add this interface (Pick device), restart the stack, and enter the new 6-digit PIN when prompted.", + "offlineHintBleBondStale": "macOS still shows Paired, but Bluetooth rejected the bond (Peer removed pairing information). Forget this RNode in System Settings → Bluetooth, then re-pair: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing, enter the new PIN. Prefer USB serial or Wi-Fi (tcp://) if you need the radio online immediately.", "offlineHintWifi": "Check that the RNode is on your LAN, powered on, and reachable at the configured IP (default TCP port 7633, not 4242). Update the host below if DHCP assigned a new address, or re-provision Wi-Fi in Reticulum → Admin.", "connectingHeading": "Connecting local interface(s)…", "connectingRow": "{{name}}: connecting over Bluetooth", @@ -1009,11 +1009,13 @@ "txQueueDrops": "{{name}}: outbound packets dropped ({{count}} total)", "txQueueDropsHint": "Usually caused by a dead TCP interface still enabled. Disable unreachable hubs to stop queue overflow.", "txQueueDropsHintBle": "This BLE RNode cannot drain its TX queue (link stuck or flapping). Prefer USB serial or Wi-Fi (tcp://), or Stop stack and re-pair after Forget in System Settings → Bluetooth.", - "txQueueDropsHintBleBondStale": "TX queue is full because the BLE bond is stale. Forget this RNode in System Settings → Bluetooth, then use Admin → Bluetooth (Clear paired / Start pairing) or switch to USB.", + "txQueueDropsHintBleBondStale": "TX queue is full because the BLE bond is invalid. Forget this RNode in System Settings → Bluetooth, then Admin → Clear paired / Start pairing, or switch to USB/Wi-Fi.", "txQueueDropsHintBleFlowControl": "Host TX queue backpressure while the radio is busy (flow control on). Expected under RF congestion — not a stuck link. Reduce outbound load or wait for airtime.", "txQueueDropsHintNeutral": "This interface TX channel is full. Check that interface’s link health (or disable it) to stop drops.", - "bleBondRemoved": "{{name}}: Bluetooth bond is stale (OS still shows Paired)", - "bleBondRemovedHint": "Forget this RNode in System Settings → Bluetooth first (macOS will not clear Paired automatically). Prefer USB serial or Wi-Fi (tcp://) when possible to avoid BLE bonds. Then: Admin → Bluetooth → Clear paired devices (USB, ESP32) if available, Start pairing, remove and re-add this BLE interface (Pick device), restart the stack, and enter the new 6-digit PIN.", + "bleBondRemoved": "{{name}}: Bluetooth bond invalid (OS still shows Paired) — Forget and re-pair required", + "bleBondRemovedHint": "Automatic exclusive reconnect could not heal this bond. Forget the RNode in System Settings → Bluetooth, then Admin → Clear paired / Start pairing and enter the new PIN. Prefer USB serial or Wi-Fi (tcp://) until re-paired. LoRa BLE (MeshCore/Meshtastic) stays paused while this RNode uses Bluetooth.", + "bleLtkDesyncPurged": "Encryption key mismatch (LTK desync) — stale OS bond removed. Re-pair: Admin → Bluetooth → Start pairing, then enter the new PIN.", + "bleLtkDesyncManualForget": "Encryption key mismatch (LTK desync). Forget this RNode in System Settings → Bluetooth, then Admin → Start pairing. Install blueutil (brew install blueutil) to allow automatic unpair on macOS.", "bleBondRemovedStopStack": "Stop stack", "bleBondRemovedOpenAdmin": "Open Admin Bluetooth", "blePairingTimedOut": "{{name}}: Bluetooth passkey exchange timed out", @@ -1295,7 +1297,7 @@ "restartStackUnavailable": "Stack restart is unavailable until the Reticulum runtime is ready.", "localOfflineRow": "Local interface offline — check cable and port contention, then restart the stack.", "localOfflineRowBle": "Bluetooth RNode needs pairing or is still connecting — enter the 6-digit PIN in your OS Bluetooth dialog when prompted (see hint below).", - "localOfflineRowBleBondStale": "Bluetooth bond is stale — Forget this RNode in System Settings → Bluetooth, then re-pair with a new PIN (OS may still show Paired).", + "localOfflineRowBleBondStale": "Bluetooth bond invalid — Forget in System Settings, then re-pair.", "localOfflineRowWifi": "Wi-Fi RNode unreachable — verify IP, port 7633, same LAN, and power; re-provision in Admin if the DHCP address changed.", "localOfflineRowStale": "Serial port {{port}} not found — USB replug may have changed the path.", "purposeAria": "Purpose of interface {{name}}", @@ -1872,7 +1874,7 @@ "txQueueDropsBleBondStale": "Interface \"{{name}}\" dropped {{count}} outbound packets (TX queue full) — BLE bond stale; Forget in OS Bluetooth and re-pair", "txQueueDropsBleFlowControl": "Interface \"{{name}}\" dropped {{count}} outbound packets (host TX queue full) — flow control on; expected under RF congestion, not a stuck link", "txQueueDropsNeutral": "Interface \"{{name}}\" dropped {{count}} outbound packets (TX queue full) — check that interface’s link", - "bleBondRemoved": "BLE RNode \"{{name}}\" bond is stale — Forget in OS Bluetooth settings and re-pair", + "bleBondRemoved": "BLE RNode \"{{name}}\" bond invalid — Forget in System Settings and re-pair", "blePairingTimedOut": "BLE RNode \"{{name}}\" passkey exchange timed out — enter the current PIN (not 123456) and re-pair", "linkDeliveryTimeout": "Direct LXMF link to {{hash}}… timed out ({{count}}×)", "transportSaturated": "RNS transport saturated ({{count}} LXMF path-request drops)", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index ce7a15fdd..1e2015385 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "La instancia remota de Reticulum puede estar desconectada o bloqueando conexiones. Deshabilite esta interfaz o verifique el host y el puerto. Los reinicios frecuentes de mesh-client o de la pila pueden hacer que los hubs Reticulum 1.4.0+ bloqueen su IP durante 12 horas: espere en lugar de reiniciar de nuevo.", "connectingHintBle": "Los enlaces BLE RNode pueden tardar hasta 60 segundos después de que se inicia la pila (ventana de clave de acceso del sistema operativo). Ingrese el PIN actual de RNode si se le solicita; nunca 123456. Esto es normal, no es un error. Es posible que el dispositivo no aparezca en Configuración del sistema hasta después de una vinculación exitosa.", "offlineHintBle": "Para borrar este error, es posible que deba eliminar esta interfaz y volver a agregarla (Seleccionar dispositivo) para que se actualice el ID de Bluetooth guardado y, a continuación, reinicie la pila. Su sistema operativo puede solicitar un PIN Bluetooth de 6 dígitos (macOS, Windows o Linux). RNode genera un nuevo código cada emparejamiento; nunca use 123456 (ese es el valor predeterminado de Meshtastic, no RNode). No es necesario que el RNode aparezca en Configuración del sistema → Bluetooth antes de que esta aplicación se conecte; el cuadro de diálogo del sistema operativo aparece cuando la pila se empareja. Radios sin cabeza: conecte USB → Reticulum Admin → Bluetooth → Comience a emparejar → introduzca el PIN que se muestra en el panel de administración (no en la pantalla de la radio) en el cuadro de diálogo del sistema operativo. Radios con pantalla: mantenga presionado el botón ~7 s en los dispositivos Heltec (el tiempo varía; ~10 s en muchas placas habilita Wi-Fi AP en su lugar), lea el PIN en la pantalla y luego ingréselo en el cuadro de diálogo del sistema operativo. Borre los enlaces fallidos en la configuración de Bluetooth de su sistema operativo antes de volver a intentarlo.", - "offlineHintBleBondStale": "macOS sigue mostrando este RNode como emparejado, pero las claves Bluetooth ya no coinciden (el par eliminó la información de emparejamiento). Olvide el dispositivo en Ajustes del sistema → Bluetooth, ponga el RNode en modo de emparejamiento (Admin → Bluetooth → Iniciar emparejamiento, o mantenga pulsado el botón ~7 s), luego quite y vuelva a agregar esta interfaz (Seleccionar dispositivo), reinicie la pila e introduzca el nuevo PIN de 6 dígitos cuando se le solicite.", + "offlineHintBleBondStale": "macOS sigue mostrando Paired, pero Bluetooth rechazó el vínculo (Peer removed pairing information). Olvide este RNode en Ajustes del sistema → Bluetooth y vuelva a emparejar: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing e introduzca el nuevo PIN. Prefiera USB serie o Wi-Fi (tcp://) si necesita la radio en línea de inmediato.", "tcpFastFlap": "{{name}}: Hub probablemente bloqueó tu IP después de reiniciar la pila con frecuencia ({{host}}:{{port}})", "tcpFastFlapHint": "Los hubs públicos de Reticulum 1.4.0+ bloquean una IP después de cinco sesiones TCP cortas en 12 horas. Los reinicios rápidos de mesh-client o de la pila cuentan. Deshabilite este concentrador y espere hasta 12 horas: reiniciar de nuevo extiende el bloqueo." }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "La instancia compartida está habilitada: salga por completo de otras aplicaciones de Reticulum (MeshChatX, Ratspeak, rnsd independiente) y reinicie esta pila antes de volver a intentar DM.", "bleBondRemoved": "{{name}}: El enlace Bluetooth está obsoleto (el sistema operativo todavía muestra Emparejado)", - "bleBondRemovedHint": "Olvide primero este RNode en Configuración del sistema → Bluetooth (macOS no quita Paired automáticamente). Prefiera USB serie o Wi‑Fi (tcp://) cuando pueda para evitar vínculos BLE. Luego: Admin → Bluetooth → Borrar dispositivos emparejados (USB, ESP32) si está disponible, Iniciar emparejamiento, quite y vuelva a añadir esta interfaz BLE (Elegir dispositivo), reinicie la pila e introduzca el nuevo PIN de 6 dígitos.", + "bleBondRemovedHint": "La reconexión exclusiva automática no pudo sanar este vínculo. Olvide el RNode en Ajustes del sistema → Bluetooth, luego Admin → Clear paired / Start pairing e introduzca el nuevo PIN. Prefiera USB serie o Wi-Fi (tcp://) hasta volver a emparejar. LoRa BLE (MeshCore/Meshtastic) permanece en pausa mientras este RNode usa Bluetooth.", "blePairingTimedOut": "{{name}}: Se agotó el tiempo de espera para el intercambio de clave de acceso de Bluetooth", "blePairingTimedOutHint": "Detenga la pila, comience a emparejar nuevamente (Admin → Bluetooth muestra el PIN en el panel; la pantalla de la radio puede permanecer en blanco, o mantenga presionado el botón ~7 s para obtener un PIN en pantalla), reinicie la pila una vez e ingrese ese PIN en el cuadro de diálogo del sistema operativo dentro de ~60 segundos. Nunca use 123456 (valor predeterminado de Meshtastic).", "bleBondRemovedStopStack": "Detener pila", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "Este canal TX de interfaz está lleno. Compruebe el estado del enlace de esa interfaz (o desactívelo) para detener las caídas.", "txQueueDropsHintBleFlowControl": "Contrapresión de la cola de TX del host mientras la radio está ocupada (control de flujo activado). Se espera bajo congestión de RF, no un enlace atascado. Reduzca la carga saliente o espere el tiempo de aire.", "tcpResetByPeer": "{{name}}: hub reset the TCP session (Restablecimiento de conexión por peer)", - "tcpReadEof": "{{name}}: hub cerrado TCP limpiamente (INFO: TCP leer: EOF)" + "tcpReadEof": "{{name}}: hub cerrado TCP limpiamente (INFO: TCP leer: EOF)", + "bleLtkDesyncPurged": "La clave de cifrado no coincide (desincronización LTK): se ha eliminado el enlace obsoleto del sistema operativo. Vuelva a emparejar: Admin → Bluetooth → Inicie el emparejamiento, luego introduzca el nuevo PIN.", + "bleLtkDesyncManualForget": "La clave de cifrado no coincide (desincronización LTK). Olvídese de este RNode en Configuración del sistema → Bluetooth, luego Admin → Inicie el emparejamiento. Instale blueutil (brew install blueutil) para permitir el desemparejamiento automático en macOS." }, "reticulumSidecarBundledMissing": "A esta instalación le falta el sidecar Reticulum incluido. Actualice a una versión más reciente o informe el problema: la compilación desde el código fuente no está disponible en instalaciones empaquetadas.", "radioMqttRootDivergesWarning": "El tema raíz de MQTT de radio es {{radioRoot}}, pero este panel se suscribe a {{appPrefix}}. Haga coincidir la radio para limitar el tráfico de nodos a nivel nacional: actualice el prefijo del tema y vuelva a conectar MQTT.", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index e336645e9..c90d1668f 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -1375,7 +1375,7 @@ "tcpUnreachableHint": "L'instance Reticulum distante peut être hors ligne ou bloquer les connexions. Désactivez cette interface ou vérifiez l'hôte et le port. Des redémarrages fréquents de mesh-client ou de la pile peuvent amener les concentrateurs Reticulum 1.4.0+ à bloquer votre IP pendant 12 heures — attendez plutôt que de redémarrer.", "connectingHintBle": "Les liaisons ble RNode peuvent prendre jusqu'à 60 secondes après le démarrage de la pile (fenêtre de clé de système d'exploitation). Entrez le code PIN RNode actuel si vous y êtes invité — jamais 123456. C'est normal, pas une erreur. Il se peut que l'appareil n'apparaisse dans les paramètres système qu'après une liaison réussie.", "offlineHintBle": "Pour effacer cette erreur, vous devrez peut-être supprimer cette interface et l'ajouter à nouveau (Choisir un périphérique) afin que l'identifiant Bluetooth enregistré soit actualisé, puis redémarrer la pile. Votre système d'exploitation peut vous demander un code PIN Bluetooth à 6 chiffres (macOS, Windows ou Linux). RNode génère un nouveau code à chaque appariement — n'utilisez jamais 123456 (c'est la valeur par défaut de Meshtastic, pas RNode). Le RNode n'a pas besoin d'apparaître dans Réglages système → Bluetooth avant que cette application ne se connecte ; la boîte de dialogue du système d'exploitation apparaît lorsque la pile s'apparie. Radios sans écran : connectez l'USB → Reticulum Admin → Bluetooth → Démarrer l'appairage → entrez le code PIN affiché dans le panneau Admin (pas sur l'écran de la radio) dans la boîte de dialogue du système. Radios avec écran : maintenez le bouton ~7 s sur les appareils Heltec (le temps varie ; ~10 s sur de nombreuses cartes active plutôt le Wi‑Fi AP), lisez le code PIN à l'écran, puis entrez-le dans la boîte de dialogue du système. Effacez les liaisons échouées dans les paramètres Bluetooth de votre système d'exploitation avant de réessayer.", - "offlineHintBleBondStale": "macOS affiche toujours ce RNode comme Jumelé, mais les clés Bluetooth ne correspondent plus (informations de jumelage supprimées par le pair). Oubliez l'appareil dans Réglages système → Bluetooth, mettez le RNode en mode d'appairage (Admin → Bluetooth → Démarrer l'appairage, ou maintenez le bouton ~7 s), puis retirez et ajoutez à nouveau cette interface (Choisir un périphérique), redémarrez la pile et entrez le nouveau code PIN à 6 chiffres lorsque vous y êtes invité.", + "offlineHintBleBondStale": "macOS affiche encore Paired, mais Bluetooth a rejeté la liaison (Peer removed pairing information). Oubliez ce RNode dans Réglages système → Bluetooth, puis réappairez : USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing, puis saisissez le nouveau PIN. Préférez USB série ou Wi-Fi (tcp://) si vous avez besoin de la radio immédiatement.", "tcpFastFlap": "{{name}} : Hub a probablement bloqué votre IP après des redémarrages fréquents de la pile ({{host}} :{{port}})", "tcpFastFlapHint": "Les concentrateurs publics Reticulum 1.4.0+ bloquent une adresse IP après cinq courtes sessions TCP dans les 12 heures. Les redémarrages rapides de mesh-client ou de la pile comptent. Désactivez ce concentrateur et attendez jusqu'à 12 heures — redémarrer étend le blocage." }, @@ -1454,7 +1454,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "L'instance de partage est activée — quittez complètement les autres applications Reticulum (MeshChatX, Ratspeak, rnsd autonome) et redémarrez cette pile avant de réessayer les DM.", "bleBondRemoved": "{{name}} : la liaison Bluetooth est obsolète (le système d'exploitation affiche toujours \"Jumelé\")", - "bleBondRemovedHint": "Oubliez d’abord ce RNode dans Réglages Système → Bluetooth (macOS n’efface pas Paired automatiquement). Préférez si possible USB série ou Wi‑Fi (tcp://) pour éviter les liaisons BLE. Puis : Admin → Bluetooth → Effacer les appareils appairés (USB, ESP32) si disponible, Démarrer l’appairage, retirez et rajoutez cette interface BLE (Choisir l’appareil), redémarrez la pile et saisissez le nouveau PIN à 6 chiffres.", + "bleBondRemovedHint": "La reconnexion exclusive automatique n’a pas pu réparer cette liaison. Oubliez le RNode dans Réglages système → Bluetooth, puis Admin → Clear paired / Start pairing et saisissez le nouveau PIN. Préférez USB série ou Wi-Fi (tcp://) jusqu’au réappariement. LoRa BLE (MeshCore/Meshtastic) reste en pause tant que ce RNode utilise Bluetooth.", "blePairingTimedOut": "{{name}} : Échange de clé Bluetooth expiré", "blePairingTimedOutHint": "Arrêtez la pile, recommencez le couplage (Admin → Bluetooth affiche le CODE PIN dans le panneau — l'écran de la radio peut rester vide — ou maintenez le bouton enfoncé pendant ~7 s pour un CODE PIN à l'écran), redémarrez la pile une fois et entrez ce CODE PIN dans la boîte de dialogue du système d'exploitation dans ~60 secondes. N'utilisez jamais 123456 (Meshtastic par défaut).", "bleBondRemovedStopStack": "Arrêt de la pile", @@ -1464,7 +1464,9 @@ "txQueueDropsHintNeutral": "Ce canal TX d'interface est plein. Vérifiez la santé du lien de cette interface (ou désactivez-la) pour arrêter les gouttes.", "txQueueDropsHintBleFlowControl": "Contrepression de la file d'attente TX de l'hôte pendant que la radio est occupée (contrôle de flux activé). Attendu en cas de congestion RF — pas un lien bloqué. Réduisez la charge sortante ou attendez le temps d'antenne.", "tcpResetByPeer": "{{name}} : le concentrateur réinitialise la session TCP (réinitialisation de la connexion par l'homologue)", - "tcpReadEof": "{{name}} : hub fermé TCP proprement (INFO : TCP read : EOF)" + "tcpReadEof": "{{name}} : hub fermé TCP proprement (INFO : TCP read : EOF)", + "bleLtkDesyncPurged": "Désadéquation de la clé de chiffrement (désynchronisation LTK) — liaison OS périmée supprimée. Réappairer : Admin → Bluetooth → Démarrer l'appairage, puis entrer le nouveau code PIN.", + "bleLtkDesyncManualForget": "Non-correspondance de la clé de chiffrement (désynchronisation LTK). Oubliez ce RNode dans les paramètres système → Bluetooth, puis l'appairage Admin → Start. Installez blueutil (brasser installer blueutil) pour permettre le désappairage automatique sur macOS." }, "reticulumSidecarBundledMissing": "Cette installation ne dispose pas du side-car Reticulum fourni. Effectuez une mise à niveau vers une version plus récente ou signalez le problème : la création à partir des sources n'est pas disponible dans les installations packagées.", "radioMqttRootDivergesWarning": "Le sujet racine du MQTT radio est {{radioRoot}}, mais ce panneau est abonné à {{appPrefix}}. Faites correspondre la radio pour limiter le trafic des nœuds à l'échelle nationale — mettez à jour le préfixe du sujet et reconnectez le MQTT.", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 3493dc8b6..064e328af 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "Instansi Reticulum jarak jauh mungkin offline atau memblokir koneksi. Nonaktifkan antarmuka ini atau verifikasi host dan port. Restart mesh-client atau stack yang sering dapat membuat hub Reticulum 1.4.0+ memblokir IP Anda selama 12 jam — lebih baik menunggu daripada me-restart lagi.", "connectingHintBle": "Tautan BLE RNode dapat memakan waktu hingga 60 detik setelah tumpukan dimulai (jendela kunci sandi OS). Masukkan PIN RNode saat ini jika diminta — jangan pernah 123456. Ini normal, bukan kesalahan. Perangkat mungkin tidak muncul di Pengaturan Sistem sampai ikatan berhasil.", "offlineHintBle": "Untuk menghapus kesalahan ini, Anda mungkin perlu menghapus antarmuka ini dan menambahkannya kembali (Pilih perangkat) agar ID Bluetooth tersimpan diperbarui, lalu mulai ulang stack. OS Anda mungkin meminta PIN Bluetooth 6 digit (macOS, Windows, atau Linux). RNode menghasilkan kode baru setiap pemasangan — jangan pernah menggunakan 123456 (itu default Meshtastic, bukan RNode). RNode tidak perlu muncul di Pengaturan Sistem → Bluetooth sebelum aplikasi ini terhubung; dialog OS muncul saat stack memasangkan. Radio tanpa layar: sambungkan USB → Reticulum Admin → Bluetooth → Mulai pemasangan → masukkan PIN yang ditampilkan di panel Admin (bukan di layar radio) ke dialog OS. Radio dengan layar: tahan tombol ~7 dtk pada perangkat Heltec (waktu bervariasi; ~10 dtk pada banyak papan mengaktifkan Wi‑Fi AP), baca PIN di layar, lalu masukkan ke dialog OS. Hapus ikatan yang gagal di pengaturan Bluetooth OS sebelum mencoba lagi.", - "offlineHintBleBondStale": "macOS masih menampilkan RNode ini sebagai Dipasangkan, tetapi kunci Bluetooth tidak lagi cocok (informasi pemasangan dihapus oleh peer). Lupakan perangkat di Pengaturan Sistem → Bluetooth, masukkan RNode ke mode pemasangan (Admin → Bluetooth → Mulai pemasangan, atau tahan tombol ~7 dtk), lalu hapus dan tambahkan kembali antarmuka ini (Pilih perangkat), mulai ulang stack, dan masukkan PIN 6 digit baru saat diminta.", + "offlineHintBleBondStale": "macOS masih menampilkan Paired, tetapi Bluetooth menolak ikatan (Peer removed pairing information). Lupakan RNode ini di Pengaturan Sistem → Bluetooth, lalu pasangkan ulang: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing, masukkan PIN baru. Utamakan USB serial atau Wi-Fi (tcp://) jika radio harus online segera.", "tcpFastFlap": "{{name}}: hub kemungkinan memblokir IP Anda setelah tumpukan sering dimulai ulang ({{host}}:{{port}})", "tcpFastFlapHint": "Reticulum 1.4.0+ hub publik memblokir IP setelah lima sesi TCP singkat dalam waktu 12 jam. Restart cepat mesh-client atau stack dihitung. Nonaktifkan hub ini dan tunggu hingga 12 jam — memulai ulang lagi memperpanjang blok." }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "Aktor transportasi sidecar dapat terjepit — nyalakan kembali tumpukan Reticulum.", "shareInstanceHint": "Contoh berbagi diaktifkan — sepenuhnya keluar dari aplikasi Reticulum lainnya (MeshChatX, Ratspeak, rnsd mandiri) dan mulai ulang tumpukan ini sebelum mencoba DM lagi.", "bleBondRemoved": "{{name}}: Ikatan Bluetooth sudah basi (OS masih menunjukkan Dipasangkan)", - "bleBondRemovedHint": "Lupakan dulu RNode ini di Pengaturan Sistem → Bluetooth (macOS tidak menghapus Paired otomatis). Lebih baik USB serial atau Wi‑Fi (tcp://) jika memungkinkan agar menghindari ikatan BLE. Lalu: Admin → Bluetooth → Hapus perangkat terpasang (USB, ESP32) jika tersedia, Mulai pairing, hapus dan tambahkan lagi antarmuka BLE ini (Pilih perangkat), mulai ulang stack, dan masukkan PIN 6 digit baru.", + "bleBondRemovedHint": "Reconnect eksklusif otomatis tidak dapat memperbaiki ikatan ini. Lupakan RNode di Pengaturan Sistem → Bluetooth, lalu Admin → Clear paired / Start pairing dan masukkan PIN baru. Utamakan USB serial atau Wi-Fi (tcp://) sampai dipasangkan ulang. LoRa BLE (MeshCore/Meshtastic) tetap dijeda selama RNode ini memakai Bluetooth.", "blePairingTimedOut": "{{name}}: Waktu pertukaran kunci sandi Bluetooth habis", "blePairingTimedOutHint": "Hentikan tumpukan, mulai memasangkan lagi (Admin → Bluetooth menampilkan PIN di panel — tampilan radio mungkin tetap kosong — atau tombol ~7 detik ditahan untuk PIN di layar), mulai ulang tumpukan satu kali, dan masukkan PIN tersebut dalam dialog OS dalam waktu ~60 detik. Jangan pernah menggunakan 123456 (default Meshtastic).", "bleBondRemovedStopStack": "Hentikan tumpukan", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "Saluran TX antarmuka ini penuh. Periksa kesehatan tautan antarmuka itu (atau nonaktifkan) untuk menghentikan tetesan.", "txQueueDropsHintBleFlowControl": "Tekanan balik antrean TX host saat radio sedang sibuk (kontrol aliran aktif). Diharapkan di bawah kemacetan RF — bukan tautan yang macet. Kurangi muatan keluar atau tunggu waktu tayang.", "tcpResetByPeer": "{{name}}: hub mengatur ulang sesi TCP (Pengaturan ulang koneksi oleh rekan)", - "tcpReadEof": "{{name}}: hub menutup TCP dengan bersih (INFO: TCP baca: EOF)" + "tcpReadEof": "{{name}}: hub menutup TCP dengan bersih (INFO: TCP baca: EOF)", + "bleLtkDesyncPurged": "Kunci enkripsi tidak cocok (ltk desync) — ikatan OS basi dihapus. Pasang ulang: Admin → Bluetooth → Mulai memasangkan, lalu masukkan PIN baru.", + "bleLtkDesyncManualForget": "Kunci enkripsi tidak cocok (ltk desync). Lupakan RNode ini di → Bluetooth Pengaturan Sistem, lalu Admin → Mulai memasangkan. Pasang blueutil (brew install blueutil) agar tidak berpasangan secara otomatis di macOS." }, "reticulumSidecarBundledMissing": "Instalasi ini tidak memiliki sespan Reticulum yang dibundel. Tingkatkan versi ke rilis yang lebih baru atau laporkan masalahnya — pembuatan dari sumber tidak tersedia dalam paket instalasi.", "radioMqttRootDivergesWarning": "Topik utama Radio MQTT adalah {{radioRoot}} tetapi panel ini berlangganan {{appPrefix}}. Cocokkan radio untuk membatasi lalu lintas simpul nasional — perbarui Prefiks Topik dan sambungkan kembali MQTT.", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index 5f61ddd00..d3b77ee49 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -1374,7 +1374,7 @@ "tcpUnreachableHint": "L'istanza Reticulum remota potrebbe essere offline o bloccare le connessioni. Disabilita questa interfaccia o verifica l'host e la porta. Riavvii frequenti di mesh-client o dello stack possono far sì che gli hub Reticulum 1.4.0+ blocchino il tuo IP per 12 ore — aspetta invece di riavviare di nuovo.", "connectingHintBle": "I collegamenti BLE RNode possono richiedere fino a 60 secondi dopo l'avvio dello stack (finestra della passkey del sistema operativo). Se richiesto, immettere il PIN RNode corrente, mai 123456. Si tratta di un comportamento normale, non di un errore. Il dispositivo potrebbe non essere visualizzato nelle Impostazioni di sistema finché non viene eseguito correttamente il collegamento.", "offlineHintBle": "Per eliminare questo errore potrebbe essere necessario rimuovere questa interfaccia e aggiungerla di nuovo (Scegli dispositivo) così da aggiornare l'ID Bluetooth salvato, quindi riavviare lo stack. Il sistema operativo potrebbe richiedere un PIN Bluetooth a 6 cifre (macOS, Windows o Linux). RNode genera un nuovo codice a ogni accoppiamento — non usare mai 123456 (è il valore predefinito di Meshtastic, non di RNode). L'RNode non deve comparire in Impostazioni di sistema → Bluetooth prima che questa app si colleghi; la finestra di dialogo del SO appare quando lo stack effettua l'accoppiamento. Radio senza display: collega USB → Reticulum Admin → Bluetooth → Avvia accoppiamento → inserisci il PIN mostrato nel pannello Admin (non sul display della radio) nella finestra del SO. Radio con display: tieni premuto il pulsante ~7 s sui dispositivi Heltec (i tempi variano; ~10 s su molte schede attiva invece l'AP Wi‑Fi), leggi il PIN sullo schermo e inseriscilo nella finestra del SO. Cancella gli accoppiamenti non riusciti nelle impostazioni Bluetooth del SO prima di riprovare.", - "offlineHintBleBondStale": "macOS mostra ancora questo RNode come Accoppiato, ma le chiavi Bluetooth non corrispondono più (Peer ha rimosso le informazioni di accoppiamento). Dimentica il dispositivo in Impostazioni di sistema → Bluetooth, metti l'RNode in modalità accoppiamento (Admin → Bluetooth → Avvia accoppiamento, oppure tieni premuto il pulsante ~7 s), poi rimuovi e riaggiungi questa interfaccia (Scegli dispositivo), riavvia lo stack e inserisci il nuovo PIN a 6 cifre quando richiesto.", + "offlineHintBleBondStale": "macOS mostra ancora Paired, ma Bluetooth ha rifiutato il bond (Peer removed pairing information). Dimentica questo RNode in Impostazioni di sistema → Bluetooth, poi riaccoppia: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing e inserisci il nuovo PIN. Preferisci USB seriale o Wi-Fi (tcp://) se ti serve la radio online subito.", "tcpFastFlap": "{{name}}: hub probabilmente ha bloccato il tuo IP dopo frequenti riavvii dello stack ({{host}}:{{port}})", "tcpFastFlapHint": "Gli hub pubblici Reticulum 1.4.0+ bloccano un IP dopo cinque brevi sessioni TCP entro 12 ore. I riavvii rapidi di mesh-client o dello stack contano. Disabilita questo hub e attendi fino a 12 ore — il riavvio estende il blocco." }, @@ -1453,7 +1453,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "L'istanza di condivisione è abilitata: chiudi completamente altre app Reticulum (MeshChatX, Ratspeak, rnsd autonomo) e riavvia questo stack prima di riprovare i DM.", "bleBondRemoved": "{{name}}: il collegamento Bluetooth è obsoleto (il sistema operativo mostra ancora Accoppiato)", - "bleBondRemovedHint": "Dimentica prima questo RNode in Impostazioni di Sistema → Bluetooth (macOS non rimuove Paired automaticamente). Preferisci se possibile USB seriale o Wi‑Fi (tcp://) per evitare legami BLE. Poi: Admin → Bluetooth → Cancella dispositivi accoppiati (USB, ESP32) se disponibile, Avvia accoppiamento, rimuovi e riaggiungi questa interfaccia BLE (Scegli dispositivo), riavvia lo stack e inserisci il nuovo PIN a 6 cifre.", + "bleBondRemovedHint": "La riconnessione esclusiva automatica non ha potuto riparare questo bond. Dimentica il RNode in Impostazioni di sistema → Bluetooth, poi Admin → Clear paired / Start pairing e inserisci il nuovo PIN. Preferisci USB seriale o Wi-Fi (tcp://) fino al riaccoppiamento. LoRa BLE (MeshCore/Meshtastic) resta in pausa mentre questo RNode usa Bluetooth.", "blePairingTimedOut": "{{name}}: Scambio passkey Bluetooth scaduto", "blePairingTimedOutHint": "Arresta lo stack, avvia nuovamente l'accoppiamento (Admin → Bluetooth mostra il PIN nel pannello — il display della radio potrebbe rimanere vuoto — o tieni premuto il pulsante per circa 7 secondi per un PIN sullo schermo), riavvia lo stack una volta e inserisci il PIN nella finestra di dialogo del sistema operativo entro ~60 secondi. Non utilizzare mai 123456 (impostazione predefinita Meshtastic).", "bleBondRemovedStopStack": "Arresta stack", @@ -1463,7 +1463,9 @@ "txQueueDropsHintNeutral": "Questo canale TX di interfaccia è pieno. Controllare lo stato del collegamento dell'interfaccia (o disabilitarlo) per arrestare le cadute.", "txQueueDropsHintBleFlowControl": "Contropressione della coda TX dell'host mentre la radio è occupata (controllo del flusso attivo). Previsto sotto congestione RF — non un collegamento bloccato. Ridurre il carico in uscita o attendere il tempo di trasmissione.", "tcpResetByPeer": "{{name}}: hub reimposta la sessione TCP (connessione reimpostata dal peer)", - "tcpReadEof": "{{name}}: hub chiuso in modo pulito TCP (INFO: TCP leggi: EOF)" + "tcpReadEof": "{{name}}: hub chiuso in modo pulito TCP (INFO: TCP leggi: EOF)", + "bleLtkDesyncPurged": "Mancata corrispondenza della chiave di crittografia (desincronizzazione LTK) — legame OS obsoleto rimosso. Ripara: Admin → Bluetooth → Avvia l'accoppiamento, quindi inserisci il nuovo PIN.", + "bleLtkDesyncManualForget": "Mancata corrispondenza della chiave di crittografia (desincronizzazione LTK). Dimenticare questo RNode in Impostazioni di sistema → Bluetooth, quindi Admin → Avviare l'associazione. Installare blueutil (brew install blueutil) per consentire la disaccoppiamento automatico su macOS." }, "reticulumSidecarBundledMissing": "In questa installazione manca il sidecar Reticulum in bundle. Esegui l'upgrade a una versione più recente o segnala il problema: la creazione dal codice sorgente non è disponibile nelle installazioni in pacchetto.", "radioMqttRootDivergesWarning": "L'argomento principale di Radio MQTT è {{radioRoot}}, ma questo panel si iscrive a {{appPrefix}}. Abbina la radio per limitare il traffico dei nodi a livello nazionale: aggiorna il prefisso dell'argomento e ricollega MQTT.", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index c457852af..114feb551 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "リモートReticulumインスタンスはオフラインか、接続をブロックしている可能性があります。このインターフェイスを無効にするか、ホストとポートを確認してください。mesh-client やスタックの頻繁な再起動は、Reticulum 1.4.0+ ハブが IP を 12 時間ブロックする原因になります。再起動せずに待ってください。", "connectingHintBle": "BLE RNode リンクは、スタックの開始後 (OS パスキー ウィンドウ) に最大 60 秒かかる場合があります。プロンプトが表示されたら、現在の RNode PIN を入力します。決して 123456 を入力しないでください。これは正常であり、エラーではありません。接続が成功するまで、デバイスはシステム設定に表示されない場合があります。", "offlineHintBle": "このエラーを解消するには、このインターフェースを削除して再度追加(デバイスを選択)し、保存済みの Bluetooth ID を更新してからスタックを再起動する必要がある場合があります。OS から 6 桁の Bluetooth PIN の入力を求められることがあります(macOS、Windows、または Linux)。RNode はペアリングのたびに新しいコードを生成します。123456 は使用しないでください(Meshtastic のデフォルトであり、RNode ではありません)。このアプリが接続する前に、RNode がシステム設定 → Bluetooth に表示されている必要はありません。スタックがペアリングすると OS のダイアログが表示されます。ディスプレイのない無線機:USB を接続 → Reticulum Admin → Bluetooth → ペアリング開始 → 管理パネルに表示された PIN(無線機の画面ではありません)を OS ダイアログに入力します。ディスプレイ付き無線機:Heltec デバイスでボタンを約 7 秒押し続け(タイミングは機種により異なります。多くのボードでは約 10 秒で Wi‑Fi AP が有効になります)、画面の PIN を読み取り、OS ダイアログに入力します。再試行する前に、OS の Bluetooth 設定で失敗したボンディングをクリアしてください。", - "offlineHintBleBondStale": "macOSは引き続きこのRNodeをペアリング済みとして表示しますが、Bluetoothキーは一致しなくなりました(ピアが削除されたペアリング情報)。システム設定→Bluetoothでデバイスを忘れ、RNodeをペアリングモードにします(管理→Bluetooth→開始ペアリング、またはボタンを7秒間押したままにします)。次に、このインターフェース(デバイスを選択)を削除して再追加し、スタックを再起動し、プロンプトが表示されたら新しい6桁のPINを入力します。", + "offlineHintBleBondStale": "macOS は引き続き Paired と表示しますが、Bluetooth がボンドを拒否しました(Peer removed pairing information)。システム設定 → Bluetooth でこの RNode を忘れ、再ペアリングしてください: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing、新しい PIN を入力。すぐにオンラインが必要なら USB シリアルまたは Wi-Fi(tcp://)を優先してください。", "tcpFastFlap": "{{name}} :頻繁にスタックを再起動した後、ハブがIPをブロックした可能性があります( {{host}} : {{port}} )", "tcpFastFlapHint": "Reticulum 1.4.0+ のパブリックハブは、12時間以内に5回の短いTCPセッションの後にIPをブロックします。mesh-client やスタックの素早い再起動もカウントされます。このハブを無効にして最大12時間待ってください。再起動するとブロックが延びます。" }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "サイドカーの輸送アクターがくさび形になっている可能性があります。Reticulumスタックを再起動してください。", "shareInstanceHint": "共有インスタンスが有効になっています—他のReticulumアプリ( MeshChatX、Ratspeak、スタンドアロンrnsd )を完全に終了し、DMを再試行する前にこのスタックを再起動します。", "bleBondRemoved": "{{name}}: Bluetooth ボンディングが無効です (OS にはペアリング済みと表示されたままです)", - "bleBondRemovedHint": "まずシステム設定 → BluetoothでこのRNodeを忘れてください(macOSはPairedを自動では消しません)。可能ならUSBシリアルまたはWi‑Fi(tcp://)を優先し、BLEボンドを避けてください。次に:Admin → Bluetooth → ペアリング済みデバイスを消去(USB、ESP32、利用可能な場合)、ペアリング開始、このBLEインターフェースを削除して再追加(デバイス選択)、スタックを再起動し、新しい6桁PINを入力します。", + "bleBondRemovedHint": "自動の exclusive reconnect ではこのボンドを修復できませんでした。システム設定 → Bluetooth で RNode を忘れ、Admin → Clear paired / Start pairing で新しい PIN を入力してください。再ペアリングまで USB シリアルまたは Wi-Fi(tcp://)を優先。この RNode が Bluetooth を使用している間、LoRa BLE(MeshCore/Meshtastic)は一時停止します。", "blePairingTimedOut": "{{name}}: Bluetooth パスキー交換がタイムアウトしました", "blePairingTimedOutHint": "スタックを停止し、ペアリングを再度開始し ([管理] → [Bluetooth] を選択すると、パネルに PIN が表示されます。無線ディスプレイは空白のままになる場合があります。または、画面上の PIN を表示するためにボタンを約 7 秒押し続けます)、スタックを 1 回再起動し、OS ダイアログに約 60 秒以内にその PIN を入力します。 123456 (Meshtastic のデフォルト) は決して使用しないでください。", "bleBondRemovedStopStack": "スタックを停止", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "このインターフェースTXチャネルは満杯です。ドロップを停止するには、そのインターフェイスのリンクの状態を確認するか、無効にします。", "txQueueDropsHintBleFlowControl": "無線がビジー状態の間、ホストTXキューバックプレッシャー(フロー制御オン)。RF輻輳下で予想されます—リンクがスタックしていません。アウトバウンドロードを減らすか、通話時間を待ちます。", "tcpResetByPeer": "{{name}} :ハブがTCPセッションをリセットしました(ピアによって接続がリセットされました)", - "tcpReadEof": "{{name}} :ハブはTCPをクリーンに閉じました(情報: TCP読み取り: EOF )" + "tcpReadEof": "{{name}} :ハブはTCPをクリーンに閉じました(情報: TCP読み取り: EOF )", + "bleLtkDesyncPurged": "暗号化キーの不一致( LTK非同期) —古いOSボンドが削除されました。再ペアリング:管理者→Bluetoothペアリング→を開始し、新しいPINを入力します。", + "bleLtkDesyncManualForget": "暗号化キーの不一致( LTKの同期解除)。システム設定→BluetoothでこのRNodeを忘れ、管理者ペアリング→を開始します。macOSで自動的にペアリングを解除できるようにするには、blueutil ( brew install blueutil )をインストールします。" }, "reticulumSidecarBundledMissing": "このインストールには、バンドルされている Reticulum サイドカーがありません。新しいリリースにアップグレードするか、問題を報告してください。パッケージ化されたインストールではソースからのビルドは利用できません。", "radioMqttRootDivergesWarning": "ラジオMQTTルートトピックは{{radioRoot}}ですが、このパネルは{{appPrefix}}に加入しています。全国のノードトラフィックを制限するために無線を一致させます—トピックプレフィックスを更新し、MQTTを再接続します。", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index 739feda80..20c643f1a 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "원격 Reticulum 인스턴스는 오프라인이거나 연결을 차단 중일 수 있습니다. 이 인터페이스를 비활성화하거나 호스트와 포트를 확인하십시오. mesh-client 또는 스택을 자주 재시작하면 Reticulum 1.4.0+ 허브가 IP를 12시간 동안 차단할 수 있습니다. 다시 시작하지 말고 기다리십시오.", "connectingHintBle": "BLE RNode 링크는 스택이 시작된 후 최대 60초가 걸릴 수 있습니다(OS 암호 키 창). 메시지가 나타나면 현재 RNode PIN을 입력하세요. 123456은 입력하지 마세요. 이는 오류가 아닌 정상입니다. 장치는 성공적으로 결합될 때까지 시스템 설정에 나타나지 않을 수 있습니다.", "offlineHintBle": "이 오류를 지우려면 이 인터페이스를 제거하고 다시 추가(장치 선택)하여 저장된 Bluetooth ID를 새로 고친 다음 스택을 다시 시작해야 할 수 있습니다. OS에서 6자리 Bluetooth PIN을 요청할 수 있습니다(macOS, Windows 또는 Linux). RNode는 페어링할 때마다 새 코드를 생성합니다. 123456을 사용하지 마세요(Meshtastic 기본값이며 RNode가 아닙니다). 이 앱이 연결되기 전에 RNode가 시스템 설정 → Bluetooth에 나타날 필요는 없습니다. 스택이 페어링할 때 OS 대화 상자가 나타납니다. 디스플레이가 없는 라디오: USB 연결 → Reticulum Admin → Bluetooth → 페어링 시작 → 관리자 패널에 표시된 PIN(라디오 디스플레이가 아님)을 OS 대화 상자에 입력하세요. 디스플레이가 있는 라디오: Heltec 장치에서 버튼을 약 7초 동안 길게 누르고(타이밍은 다를 수 있으며, 많은 보드에서는 약 10초에 Wi‑Fi AP가 활성화됨), 화면의 PIN을 읽은 다음 OS 대화 상자에 입력하세요. 다시 시도하기 전에 OS Bluetooth 설정에서 실패한 연결을 지우세요.", - "offlineHintBleBondStale": "macOS는 여전히 이 RNode를 페어링됨으로 표시하지만 블루투스 키가 더 이상 일치하지 않습니다 (피어 제거 페어링 정보). 시스템 설정 → 블루투스에서 장치를 잊어 버리고 RNode를 페어링 모드 (관리자 → 블루투스 페어링 → 시작 또는 ~ 7 초) 에 놓은 다음이 인터페이스 (장치 선택) 를 제거하고 다시 추가하고 스택을 다시 시작한 다음 메시지가 나타나면 새 6 자리 PIN을 입력하십시오.", + "offlineHintBleBondStale": "macOS는 여전히 Paired로 표시하지만 Bluetooth가 본드를 거부했습니다(Peer removed pairing information). 시스템 설정 → Bluetooth에서 이 RNode를 잊은 뒤 다시 페어링하세요: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing, 새 PIN 입력. 즉시 온라인이 필요하면 USB 시리얼 또는 Wi-Fi(tcp://)를 우선하세요.", "tcpFastFlap": "{{name}}: 잦은 스택 재시작 후 허브가 IP를 차단했을 수 있음 ({{host}}: {{port}})", "tcpFastFlapHint": "Reticulum 1.4.0+ 퍼블릭 허브는 12시간 이내에 짧은 TCP 세션 5회 후 IP를 차단합니다. mesh-client 또는 스택의 빠른 재시작이 포함됩니다. 이 허브를 비활성화하고 최대 12시간을 기다리십시오. 다시 시작하면 차단이 연장됩니다." }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "사이드카 트랜스포트 액터에 쐐기를 박을 수 있습니다. Reticulum 스택을 다시 시작하십시오.", "shareInstanceHint": "공유 인스턴스가 활성화되었습니다. DM을 다시 시도하기 전에 다른 Reticulum 앱 (MeshChatX, Ratspeak, 독립 실행형 rnsd) 을 완전히 종료하고 이 스택을 다시 시작하십시오.", "bleBondRemoved": "{{name}}: Bluetooth 연결이 오래되었습니다(OS에 여전히 페어링됨이 표시됨).", - "bleBondRemovedHint": "먼저 시스템 설정 → Bluetooth에서 이 RNode를 잊으세요(macOS는 Paired를 자동으로 지우지 않음). 가능하면 BLE 본드를 피하기 위해 USB 시리얼 또는 Wi‑Fi(tcp://)를 우선하세요. 그다음: Admin → Bluetooth → 페어링된 장치 지우기(USB, ESP32, 가능한 경우), 페어링 시작, 이 BLE 인터페이스를 제거 후 다시 추가(장치 선택), 스택을 다시 시작하고 새 6자리 PIN을 입력하세요.", + "bleBondRemovedHint": "자동 exclusive reconnect로 이 본드를 복구하지 못했습니다. 시스템 설정 → Bluetooth에서 RNode를 잊은 뒤 Admin → Clear paired / Start pairing으로 새 PIN을 입력하세요. 다시 페어링할 때까지 USB 시리얼 또는 Wi-Fi(tcp://)를 우선하세요. 이 RNode가 Bluetooth를 사용하는 동안 LoRa BLE(MeshCore/Meshtastic)는 일시 중지됩니다.", "blePairingTimedOut": "{{name}}: Bluetooth 암호 키 교환 시간이 초과되었습니다.", "blePairingTimedOutHint": "스택을 중지하고 페어링을 다시 시작하고(관리자 → Bluetooth는 패널에 PIN을 표시합니다. 라디오 디스플레이는 비어 있을 수 있습니다. 또는 화면 PIN을 보려면 ~7초 동안 버튼을 누르십시오.) 스택을 한 번 다시 시작하고 ~60초 내에 OS 대화 상자에 해당 PIN을 입력하십시오. 123456(Meshtastic 기본값)을 사용하지 마십시오.", "bleBondRemovedStopStack": "스택 중지", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "이 인터페이스 TX 채널이 가득 찼습니다. 해당 인터페이스의 링크 상태를 확인하거나 비활성화하여 드롭을 중지하십시오.", "txQueueDropsHintBleFlowControl": "라디오가 사용 중인 동안 호스트 TX 대기열 배압 (유량 제어 켜짐). RF 정체 시 예상 — 링크가 고정되지 않음. 아웃바운드 화물을 줄이거나 대기 시간을 기다리세요.", "tcpResetByPeer": "{{name}}: 허브 TCP 세션 재설정 (피어별 연결 재설정)", - "tcpReadEof": "{{name}}: 허브가 TCP를 깨끗하게 닫음 (정보: TCP 읽기: EOF)" + "tcpReadEof": "{{name}}: 허브가 TCP를 깨끗하게 닫음 (정보: TCP 읽기: EOF)", + "bleLtkDesyncPurged": "암호화 키 불일치 (LTK 비동기화) — 오래된 OS 본드가 제거되었습니다. 재페어링: 관리자 → 블루투스 페어링을 → 시작한 다음 새 PIN을 입력합니다.", + "bleLtkDesyncManualForget": "암호화 키 불일치 (LTK 비동기화). 시스템 설정 → Bluetooth에서 이 RNode를 지운 다음 관리자가 페어링을 → 시작합니다. macOS에서 자동으로 페어링을 해제하려면 blueutil (brew install blueutil) 을 설치하십시오." }, "reticulumSidecarBundledMissing": "이 설치에는 번들로 제공되는 Reticulum 사이드카가 없습니다. 최신 릴리스로 업그레이드하거나 문제를 보고하세요. 패키지 설치에서는 소스에서 빌드할 수 없습니다.", "radioMqttRootDivergesWarning": "라디오 MQTT 루트 주제는 {{radioRoot}} 이지만 이 패널은 {{appPrefix}} 을 (를) 구독합니다. 라디오를 일치시켜 전국 노드 트래픽을 제한하십시오 — 주제 접두사를 업데이트하고 MQTT를 다시 연결하십시오.", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 403854288..9c387369e 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -1375,7 +1375,7 @@ "tcpUnreachableHint": "De externe Reticulum-instantie kan offline zijn of verbindingen blokkeren. Schakel deze interface uit of verifieer de host en poort. Frequente herstarts van mesh-client of de stack kunnen ervoor zorgen dat Reticulum 1.4.0+ hubs je IP 12 uur blokkeren — wacht liever dan opnieuw te herstarten.", "connectingHintBle": "BLE RNode-koppelingen kunnen tot 60 seconden duren nadat de stapel is gestart (OS-wachtwoordvenster). Voer de huidige RNode-pincode in als daarom wordt gevraagd; nooit 123456. Dit is normaal en geen fout. Het apparaat verschijnt mogelijk pas in Systeeminstellingen na een succesvolle verbinding.", "offlineHintBle": "Om deze fout te verhelpen, moet u deze interface mogelijk verwijderen en weer toevoegen (Kies apparaat) zodat de opgeslagen Bluetooth-id wordt vernieuwd, en daarna de stack herstarten. Uw besturingssysteem kan om een 6-cijferige Bluetooth-pincode vragen (macOS, Windows of Linux). RNode genereert bij elke koppeling een nieuwe code — gebruik nooit 123456 (dat is de standaard van Meshtastic, niet van RNode). De RNode hoeft niet te verschijnen in Systeeminstellingen → Bluetooth voordat deze app verbindt; het dialoogvenster van het besturingssysteem verschijnt wanneer de stack koppelt. Radios zonder scherm: verbind USB → Reticulum Admin → Bluetooth → Koppelen starten → voer de pincode uit het Admin-paneel (niet op het radiodisplay) in het OS-dialoogvenster in. Radios met scherm: houd de knop ~7 s ingedrukt op Heltec-apparaten (timing varieert; ~10 s op veel boards activeert Wi‑Fi AP), lees de pincode op het scherm en voer die in het OS-dialoogvenster in. Wis mislukte koppelingen in de Bluetooth-instellingen van uw besturingssysteem voordat u het opnieuw probeert.", - "offlineHintBleBondStale": "macOS toont deze RNode nog steeds als gekoppeld, maar Bluetooth-sleutels komen niet meer overeen (peer heeft koppelingsinformatie verwijderd). Vergeet het apparaat in Systeeminstellingen → Bluetooth, zet de RNode in koppelmodus (Admin → Bluetooth → Koppelen starten, of houd de knop ~7 s ingedrukt), verwijder daarna deze interface en voeg hem opnieuw toe (Kies apparaat), herstart de stack en voer de nieuwe 6-cijferige pincode in wanneer daarom wordt gevraagd.", + "offlineHintBleBondStale": "macOS toont nog steeds Paired, maar Bluetooth weigerde de bond (Peer removed pairing information). Vergeet deze RNode in Systeeminstellingen → Bluetooth en koppel opnieuw: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing, voer de nieuwe PIN in. Geef de voorkeur aan USB-serie of Wi-Fi (tcp://) als de radio meteen online moet.", "tcpFastFlap": "{{name}}: hub heeft je IP-adres waarschijnlijk geblokkeerd nadat de stack vaak opnieuw is opgestart ({{host}}:{{port}})", "tcpFastFlapHint": "Reticulum 1.4.0+ openbare hubs blokkeren een IP na vijf korte TCP-sessies binnen 12 uur. Snelle herstarts van mesh-client of de stack tellen mee. Schakel deze hub uit en wacht tot 12 uur — opnieuw opstarten verlengt het blok." }, @@ -1454,7 +1454,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "Deel instantie is ingeschakeld — sluit andere Reticulum-apps (MeshChatX, Ratspeak, standalone rnsd) volledig af en start deze stapel opnieuw voordat u DM's opnieuw probeert.", "bleBondRemoved": "{{name}}: Bluetooth-verbinding is verouderd (OS toont nog steeds Gekoppeld)", - "bleBondRemovedHint": "Vergeet deze RNode eerst in Systeeminstellingen → Bluetooth (macOS wist Paired niet automatisch). Geef waar mogelijk de voorkeur aan USB-serie of Wi‑Fi (tcp://) om BLE-koppelingen te vermijden. Daarna: Admin → Bluetooth → Gekoppelde apparaten wissen (USB, ESP32) indien beschikbaar, Koppeling starten, verwijder en voeg deze BLE-interface opnieuw toe (Apparaat kiezen), herstart de stack en voer de nieuwe 6-cijferige pincode in.", + "bleBondRemovedHint": "Automatische exclusive reconnect kon deze bond niet herstellen. Vergeet de RNode in Systeeminstellingen → Bluetooth, daarna Admin → Clear paired / Start pairing en voer de nieuwe PIN in. Geef tot herkoppeling de voorkeur aan USB-serie of Wi-Fi (tcp://). LoRa BLE (MeshCore/Meshtastic) blijft gepauzeerd zolang deze RNode Bluetooth gebruikt.", "blePairingTimedOut": "{{name}}: Time-out voor uitwisseling van Bluetooth-wachtwoord", "blePairingTimedOutHint": "Stop de stapel, begin opnieuw met koppelen (Beheer → Bluetooth toont de pincode in het paneel - het radiodisplay kan leeg blijven - of houd de knop ~7 seconden ingedrukt voor een pincode op het scherm), start de stapel opnieuw op en voer die pincode binnen ~60 seconden in het dialoogvenster van het besturingssysteem in. Gebruik nooit 123456 (standaard Meshtastic).", "bleBondRemovedStopStack": "Stop stack", @@ -1464,7 +1464,9 @@ "txQueueDropsHintNeutral": "Dit interface TX-kanaal is vol. Controleer de linkstatus van die interface (of schakel deze uit) om vallen te stoppen.", "txQueueDropsHintBleFlowControl": "Host TX wachtrij tegendruk terwijl de radio bezig is (stroomregeling aan). Verwacht bij RF-congestie — geen vastzittende link. Verminder de uitgaande belasting of wacht op zendtijd.", "tcpResetByPeer": "{{name}}: hub heeft de TCP-sessie gereset (verbinding gereset door peer)", - "tcpReadEof": "{{name}}: hub sloot TCP netjes af (INFO: TCP lezen: EOF)" + "tcpReadEof": "{{name}}: hub sloot TCP netjes af (INFO: TCP lezen: EOF)", + "bleLtkDesyncPurged": "Versleutelingssleutel komt niet overeen (LTK desync) — verouderde OS-binding verwijderd. Opnieuw koppelen: Beheerder → Bluetooth → Start het koppelen en voer vervolgens de nieuwe pincode in.", + "bleLtkDesyncManualForget": "Versleutelingssleutel komt niet overeen (LTK desync). Vergeet deze RNode in Systeeminstellingen → Bluetooth en vervolgens Admin → Start pairing. Installeer blueutil (brouwselinstallatie blueutil) om automatisch ontkoppelen op macOS mogelijk te maken." }, "reticulumSidecarBundledMissing": "Bij deze installatie ontbreekt het gebundelde Reticulum sidecar. Upgrade naar een nieuwere release of rapporteer het probleem: bouwen vanaf de broncode is niet beschikbaar in pakketinstallaties.", "radioMqttRootDivergesWarning": "Radio MQTT-hoofdonderwerp is {{radioRoot}}, maar dit paneel is geabonneerd op {{appPrefix}}. Koppel de radio om landelijk knooppuntverkeer te beperken — werk het onderwerpvoorvoegsel bij en sluit MQTT opnieuw aan.", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index c7bfb09f8..a41cdfb10 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -1412,7 +1412,7 @@ "tcpUnreachableHint": "Zdalna instancja Reticulum może być offline lub blokować połączenia. Wyłącz ten interfejs lub sprawdź host i port. Częste restarty mesh-client lub stosu mogą sprawić, że koncentratory Reticulum 1.4.0+ zablokują Twój adres IP na 12 godzin — poczekaj zamiast restartować ponownie.", "connectingHintBle": "Łączenie węzła BLE RNode może zająć do 60 sekund po uruchomieniu stosu (okno hasła systemu operacyjnego). Wprowadź bieżący kod PIN RNode, jeśli zostaniesz o to poproszony — nigdy 123456. Jest to normalne, a nie błąd. Urządzenie może pojawić się w Ustawieniach systemu dopiero po pomyślnym nawiązaniu połączenia.", "offlineHintBle": "Aby usunąć ten błąd, konieczne może być usunięcie tego interfejsu i dodanie go z powrotem (wybierz urządzenie), aby odświeżyć zapisany identyfikator Bluetooth, a następnie ponownie uruchomić stos. Twój system operacyjny może poprosić o 6-cyfrowy kod PIN Bluetooth (macOS, Windows lub Linux). RNode generuje nowy kod dla każdej pary — nigdy nie używaj 123456 (jest to domyślne ustawienie Meshtastic, a nie RNode). Kod RNode nie musi pojawić się w ustawieniach systemowych → Bluetooth przed połączeniem z tą aplikacją; okno dialogowe systemu operacyjnego pojawia się, gdy stos zostanie sparowany. Radia bezgłowe: podłączanie USB → Reticulum Admin → Bluetooth → Rozpocznij parowanie → wprowadź KOD PIN pokazany w panelu administracyjnym (nie na wyświetlaczu radia) w oknie dialogowym systemu operacyjnego. Radia z wyświetlaczem: przytrzymaj przycisk ~7 s na urządzeniach Heltec (czas jest różny; ~10 s na wielu płytach zamiast tego włącza punkt dostępu Wi-Fi), odczytaj KOD PIN na ekranie, a następnie wprowadź go w oknie dialogowym systemu operacyjnego. Wyczyść nieudane powiązania w ustawieniach Bluetooth systemu operacyjnego przed ponowną próbą.", - "offlineHintBleBondStale": "macOS nadal wyświetla ten RNode jako sparowany, ale klucze Bluetooth już nie pasują (informacje o parowaniu zostały usunięte przez peer). Zapomnij urządzenie w Ustawieniach systemowych → Bluetooth, przełącz RNode w tryb parowania (Admin → Bluetooth → Rozpocznij parowanie lub przytrzymaj przycisk ~7 s), następnie usuń i ponownie dodaj ten interfejs (Wybierz urządzenie), uruchom ponownie stos i wprowadź nowy 6‑cyfrowy PIN, gdy pojawi się monit.", + "offlineHintBleBondStale": "macOS nadal pokazuje Paired, ale Bluetooth odrzucił wiązanie (Peer removed pairing information). Zapomnij ten RNode w Ustawieniach systemowych → Bluetooth, potem sparuj ponownie: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing i wpisz nowy PIN. Preferuj USB serial lub Wi-Fi (tcp://), jeśli radio ma być online od razu.", "tcpFastFlap": "{{name}}: Hub prawdopodobnie zablokował Twój adres IP po częstym ponownym uruchomieniu stosu ({{host}}:{{port}})", "tcpFastFlapHint": "Publiczne koncentratory Reticulum 1.4.0+ blokują adres IP po pięciu krótkich sesjach TCP w ciągu 12 godzin. Szybkie restarty mesh-client lub stosu się liczą. Wyłącz to centrum i odczekaj do 12 godzin — ponowne uruchomienie wydłuża blokadę." }, @@ -1491,7 +1491,7 @@ "slowTransportQueryHint": "Aktor transportowy wózka bocznego może być zaklinowany — uruchom ponownie stos Reticulum.", "shareInstanceHint": "Instancja udostępniania jest włączona — całkowicie zamknij inne aplikacje Reticulum (MeshChatX, Ratspeak, standalone rnsd) i uruchom ponownie ten stos przed ponowną próbą wysłania wiadomości SMS.", "bleBondRemoved": "{{name}}: Połączenie Bluetooth jest nieaktualne (system operacyjny nadal wyświetla komunikat Sparowano)", - "bleBondRemovedHint": "Najpierw zapomnij ten RNode w Ustawieniach systemowych → Bluetooth (macOS nie usuwa Paired automatycznie). Jeśli możesz, preferuj USB serial lub Wi‑Fi (tcp://), by uniknąć powiązań BLE. Potem: Admin → Bluetooth → Wyczyść sparowane urządzenia (USB, ESP32) jeśli dostępne, Rozpocznij parowanie, usuń i dodaj ponownie ten interfejs BLE (Wybierz urządzenie), zrestartuj stos i wpisz nowy 6-cyfrowy PIN.", + "bleBondRemovedHint": "Automatyczne exclusive reconnect nie mogło naprawić tego wiązania. Zapomnij RNode w Ustawieniach systemowych → Bluetooth, potem Admin → Clear paired / Start pairing i wpisz nowy PIN. Do ponownego sparowania preferuj USB serial lub Wi-Fi (tcp://). LoRa BLE (MeshCore/Meshtastic) pozostaje wstrzymane, gdy ten RNode używa Bluetooth.", "blePairingTimedOut": "{{name}}: Upłynął limit czasu wymiany hasła Bluetooth", "blePairingTimedOutHint": "Zatrzymaj stos, rozpocznij parowanie ponownie (Administrator → Bluetooth wyświetli PIN na panelu — wyświetlacz radia może pozostać pusty — lub przytrzymanie przycisku przez ~7 s, aby wprowadzić PIN na ekranie), ponownie uruchom stos i wprowadź ten kod PIN w oknie dialogowym systemu operacyjnego w ciągu ~60 sekund. Nigdy nie używaj numeru 123456 (domyślny Meshtastic).", "bleBondRemovedStopStack": "Stos ograniczników", @@ -1501,7 +1501,9 @@ "txQueueDropsHintNeutral": "Ten kanał interfejsu TX jest pełny. Sprawdź stan łącza w tym interfejsie (lub wyłącz je), aby zatrzymać spadki.", "txQueueDropsHintBleFlowControl": "Wsteczne ciśnienie kolejki hosta TX, gdy radio jest zajęte (kontrola przepływu włączona). Przewidywane w przypadku przeciążenia falami radiowymi — nie zablokowane łącze. Zmniejsz obciążenie wychodzące lub poczekaj na czas antenowy.", "tcpResetByPeer": "{{name}}: Hub resetuje sesję TCP (reset połączenia przez partnera)", - "tcpReadEof": "{{name}}: koncentrator zamknięty w czysty sposób TCP (INFO: TCP odczytano: EOF)" + "tcpReadEof": "{{name}}: koncentrator zamknięty w czysty sposób TCP (INFO: TCP odczytano: EOF)", + "bleLtkDesyncPurged": "Niezgodność klucza szyfrowania (desynchronizacja LTK) — usunięto nieaktualne połączenie z systemem operacyjnym. Ponowne parowanie: Admin → Bluetooth → Rozpocznij parowanie, a następnie wprowadź nowy kod PIN.", + "bleLtkDesyncManualForget": "Niezgodność klucza szyfrowania (desynchronizacja LTK). Zapomnij ten RNode w ustawieniach systemu → Bluetooth, a następnie Administrator → rozpocznij parowanie. Zainstaluj Blueutil (zainstaluj Blueutil), aby umożliwić automatyczne anulowanie parowania w systemie macOS." }, "reticulumSidecarBundledMissing": "W tej instalacji brakuje dołączonego sidecar Reticulum. Uaktualnij do nowszej wersji lub zgłoś problem — budowanie ze źródła nie jest możliwe w przypadku instalacji pakietowych.", "radioMqttRootDivergesWarning": "Główny temat radia MQTT to {{radioRoot}}, ale ten panel subskrybuje {{appPrefix}}. Dopasuj radio, aby ograniczyć ogólnokrajowy ruch w węzłach — zaktualizuj prefiks tematu i ponownie połącz MQTT.", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 9ae40d7ab..d922105fa 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "A instância remota do Reticulum pode estar offline ou bloqueando conexões. Desative esta interface ou verifique o host e a porta. Reinicializações frequentes do mesh-client ou da pilha podem fazer hubs Reticulum 1.4.0+ bloquearem seu IP por 12 horas — espere em vez de reiniciar de novo.", "connectingHintBle": "Os links BLE RNode podem levar até 60 segundos após o início da pilha (janela de chave de acesso do SO). Insira o PIN RNode atual, se solicitado – nunca 123456. Isso é normal, não é um erro. O dispositivo pode não aparecer nas configurações do sistema até que uma ligação seja bem-sucedida.", "offlineHintBle": "Para limpar esse erro, talvez seja necessário remover essa interface e adicioná-la novamente (Escolha o dispositivo) para que o ID do Bluetooth salvo seja atualizado e, em seguida, reinicie a pilha. Seu sistema operacional pode solicitar um PIN Bluetooth de 6 dígitos (macOS, Windows ou Linux). RNode gera um novo código a cada emparelhamento — nunca use 123456 (que é o padrão da Meshtastic, não RNode). O RNode não precisa aparecer nas Configurações do Sistema → Bluetooth antes que este aplicativo se conecte; a caixa de diálogo do sistema operacional aparece quando a pilha é emparelhada. Rádios sem cabeça: conectar USB → Reticulum Admin → Bluetooth → Iniciar o emparelhamento → digite o PIN mostrado no painel de administração (não no visor do rádio) na caixa de diálogo do sistema operacional. Rádios com um visor: segure o botão ~7 s nos dispositivos Heltec (o tempo varia; ~10 s em muitas placas habilita o Wi-Fi AP), leia o PIN na tela e, em seguida, insira-o na caixa de diálogo do sistema operacional. Limpe as ligações com falha nas configurações Bluetooth do sistema operacional antes de tentar novamente.", - "offlineHintBleBondStale": "O macOS ainda mostra este RNode como Emparelhado, mas as chaves Bluetooth não correspondem mais (informações de emparelhamento removidas pelo par). Esqueça o dispositivo em Ajustes do Sistema → Bluetooth, coloque o RNode no modo de emparelhamento (Admin → Bluetooth → Iniciar emparelhamento, ou segure o botão ~7 s), depois remova e adicione novamente esta interface (Escolher dispositivo), reinicie a pilha e digite o novo PIN de 6 dígitos quando solicitado.", + "offlineHintBleBondStale": "O macOS ainda mostra Paired, mas o Bluetooth rejeitou o vínculo (Peer removed pairing information). Esqueça este RNode em Ajustes do Sistema → Bluetooth e emparelhe de novo: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing e digite o novo PIN. Prefira USB serial ou Wi-Fi (tcp://) se precisar do rádio online imediatamente.", "tcpFastFlap": "{{name}}: o hub provavelmente bloqueou seu IP após reinicializações frequentes do stack ({{host}}:{{port}})", "tcpFastFlapHint": "Os hubs públicos do Reticulum 1.4.0+ bloqueiam um IP após cinco sessões TCP curtas em 12 horas. Reinicializações rápidas do mesh-client ou da pilha contam. Desative este hub e aguarde até 12 horas — reiniciar de novo prolonga o bloqueio." }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "Share instance is enabled — fully quit other Reticulum apps (MeshChatX, Ratspeak, standalone rnsd) and restart this stack before retrying DMs.", "bleBondRemoved": "{{name}}: A ligação Bluetooth está obsoleta (o sistema operacional ainda mostra Emparelhado)", - "bleBondRemovedHint": "Esqueça primeiro este RNode em Ajustes do Sistema → Bluetooth (macOS não remove Paired automaticamente). Prefira USB serial ou Wi‑Fi (tcp://) quando possível para evitar vínculos BLE. Depois: Admin → Bluetooth → Limpar dispositivos emparelhados (USB, ESP32) se disponível, Iniciar emparelhamento, remova e adicione de novo esta interface BLE (Escolher dispositivo), reinicie a pilha e digite o novo PIN de 6 dígitos.", + "bleBondRemovedHint": "A reconexão exclusiva automática não conseguiu corrigir este vínculo. Esqueça o RNode em Ajustes do Sistema → Bluetooth, depois Admin → Clear paired / Start pairing e digite o novo PIN. Prefira USB serial ou Wi-Fi (tcp://) até emparelhar de novo. LoRa BLE (MeshCore/Meshtastic) fica pausado enquanto este RNode usa Bluetooth.", "blePairingTimedOut": "{{name}}: A troca de senha do Bluetooth expirou", "blePairingTimedOutHint": "Pare a pilha, comece o emparelhamento novamente (Admin → Bluetooth mostra o PIN no painel - o display do rádio pode ficar em branco - ou pressione o botão por aproximadamente 7 s para obter um PIN na tela), reinicie a pilha uma vez e insira esse PIN na caixa de diálogo do sistema operacional dentro de aproximadamente 60 segundos. Nunca use 123456 (padrão Meshtastic).", "bleBondRemovedStopStack": "Parar pilha", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "Este canal TX de interface está cheio. Verifique a integridade do link dessa interface (ou desative-a) para interromper as quedas.", "txQueueDropsHintBleFlowControl": "Contrapressão da fila TX do host enquanto o rádio está ocupado (controle de fluxo ligado). Esperado sob congestionamento de RF — não um link preso. Reduza a carga de saída ou aguarde o tempo de antena.", "tcpResetByPeer": "{{name}}: Hub redefine a sessão TCP (Redefinição de conexão por peer)", - "tcpReadEof": "{{name}}: Hub fechou o TCP de forma limpa (INFO: TCP leu: EOF)" + "tcpReadEof": "{{name}}: Hub fechou o TCP de forma limpa (INFO: TCP leu: EOF)", + "bleLtkDesyncPurged": "Incompatibilidade da chave de criptografia (desincronização LTK) — ligação OS obsoleta removida. Reparação: Admin → Bluetooth → Iniciar emparelhamento e, em seguida, insira o novo PIN.", + "bleLtkDesyncManualForget": "Incompatibilidade da chave de criptografia (dessincronização LTK). Esqueça este RNode nas Configurações do Sistema → Bluetooth e, em seguida, → Inicie o Emparelhamento do Administrador. Instale blueutil (brew install blueutil) para permitir o desemparelhamento automático no macOS." }, "reticulumSidecarBundledMissing": "Esta instalação não contém o sidecar Reticulum incluído. Atualize para uma versão mais recente ou relate o problema – a compilação a partir do código-fonte não está disponível em instalações empacotadas.", "radioMqttRootDivergesWarning": "O tópico raiz do MQTT de rádio é {{radioRoot}}, mas este painel assina {{appPrefix}}. Combine o rádio para limitar o tráfego de nós em todo o país — atualize o Prefixo do Tópico e reconecte o MQTT.", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index e60047891..32fbef3bc 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -1414,7 +1414,7 @@ "tcpUnreachableHint": "Удалённый экземпляр Reticulum может быть offline или блокировать подключения. Отключите этот интерфейс или проверьте хост и порт. Частые перезапуски mesh-client или стека могут привести к блокировке вашего IP концентраторами Reticulum 1.4.0+ на 12 часов — лучше подождать, чем перезапускать снова.", "connectingHintBle": "Ссылки BLE RNode могут занять до 60 секунд после запуска стека (окно пароля ОС). Введите текущий PIN-код RNode, если будет предложено, — никогда не 123456. Это нормально, а не ошибка. Устройство может не появиться в настройках системы до тех пор, пока не будет выполнено успешное соединение.", "offlineHintBle": "Чтобы устранить эту ошибку, удалите этот интерфейс и добавьте его снова (Выбрать устройство), чтобы обновить сохранённый Bluetooth‑идентификатор, затем перезапустите стек. ОС может запросить 6‑значный Bluetooth PIN (macOS, Windows или Linux). RNode генерирует новый код при каждом сопряжении — никогда не используйте 123456 (это значение по умолчанию Meshtastic, не RNode). RNode не обязательно должен отображаться в Системных настройках → Bluetooth до подключения этого приложения; диалог ОС появляется, когда стек выполняет сопряжение. Радио без дисплея: подключите USB → Reticulum Admin → Bluetooth → Начать сопряжение → введите PIN, показанный в панели Admin (не на дисплее радио), в диалоге ОС. Радио с дисплеем: удерживайте кнопку ~7 с на устройствах Heltec (время различается; ~10 с на многих платах включает Wi‑Fi AP), прочитайте PIN на экране и введите его в диалоге ОС. Очистите неудачные связи в настройках Bluetooth ОС перед повторной попыткой.", - "offlineHintBleBondStale": "В macOS этот RNode по‑прежнему отображается как Сопряжённый, но ключи Bluetooth больше не совпадают (одноранговый узел удалил информацию о сопряжении). Забудьте устройство в Системных настройках → Bluetooth, переведите RNode в режим сопряжения (Admin → Bluetooth → Начать сопряжение или удерживайте кнопку ~7 с), затем удалите и снова добавьте этот интерфейс (Выбрать устройство), перезапустите стек и введите новый 6‑значный PIN по запросу.", + "offlineHintBleBondStale": "macOS по‑прежнему показывает Paired, но Bluetooth отклонил связь (Peer removed pairing information). Забудьте этот RNode в «Системные настройки» → Bluetooth, затем снова сопрягите: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing и введите новый PIN. Предпочитайте USB serial или Wi-Fi (tcp://), если радио нужно онлайн сразу.", "tcpFastFlap": "{{name}}: концентратор, вероятно, заблокировал ваш IP-адрес после частых перезагрузок стека ({{host}}:{{port}})", "tcpFastFlapHint": "Публичные концентраторы Reticulum 1.4.0+ блокируют IP после пяти коротких сеансов TCP в течение 12 часов. Быстрые перезапуски mesh-client или стека учитываются. Отключите этот концентратор и подождите до 12 часов — повторный запуск продлевает блокировку." }, @@ -1493,7 +1493,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "Экземпляр Share включен — полностью закройте другие приложения Reticulum (MeshChatX, Ratspeak, автономный rnsd) и перезапустите этот стек перед повторной попыткой DM.", "bleBondRemoved": "{{name}}: Связь Bluetooth устарела (ОС по-прежнему показывает «Сопряжено»)", - "bleBondRemovedHint": "Сначала забудьте этот RNode в «Системные настройки» → Bluetooth (macOS не снимает Paired автоматически). По возможности предпочитайте USB serial или Wi‑Fi (tcp://), чтобы избежать BLE-связей. Затем: Admin → Bluetooth → Очистить спаренные устройства (USB, ESP32) если доступно, Начать сопряжение, удалите и снова добавьте этот BLE-интерфейс (Выбрать устройство), перезапустите стек и введите новый 6-значный PIN.", + "bleBondRemovedHint": "Автоматический exclusive reconnect не смог восстановить эту связь. Забудьте RNode в «Системные настройки» → Bluetooth, затем Admin → Clear paired / Start pairing и введите новый PIN. До повторного сопряжения предпочитайте USB serial или Wi-Fi (tcp://). LoRa BLE (MeshCore/Meshtastic) остаётся на паузе, пока этот RNode использует Bluetooth.", "blePairingTimedOut": "{{name}}: истекло время ожидания обмена ключами доступа Bluetooth", "blePairingTimedOutHint": "Остановите стек, снова запустите сопряжение (Администратор → Bluetooth отобразит PIN-код на панели — дисплей радиостанции может оставаться пустым — или удерживайте кнопку ~7 секунд для ввода PIN-кода на экране), перезапустите стек один раз и введите этот PIN-код в диалоговом окне ОС в течение ~60 секунд. Никогда не используйте 123456 (по умолчанию Meshtastic).", "bleBondRemovedStopStack": "Стоп стек", @@ -1503,7 +1503,9 @@ "txQueueDropsHintNeutral": "Этот канал интерфейса TX заполнен. Проверьте работоспособность канала этого интерфейса (или отключите его), чтобы остановить отбрасывания.", "txQueueDropsHintBleFlowControl": "Host TX queue backpressure while the radio is busy (управление потоком включено). Ожидается при радиочастотной перегрузке — не застрявшее звено. Уменьшите исходящую нагрузку или дождитесь эфирного времени.", "tcpResetByPeer": "{{name}}: сброс TCP-сессии концентратором (сброс соединения одноранговым узлом)", - "tcpReadEof": "{{name}}: концентратор закрыл TCP чисто (ИНФОРМАЦИЯ: TCP чтение: EOF)" + "tcpReadEof": "{{name}}: концентратор закрыл TCP чисто (ИНФОРМАЦИЯ: TCP чтение: EOF)", + "bleLtkDesyncPurged": "Несоответствие ключа шифрования (рассинхронизация LTK) — устаревшая связь ОС удалена. Повторное сопряжение: Администратор → Bluetooth → Начните сопряжение, а затем введите новый PIN-код.", + "bleLtkDesyncManualForget": "Несоответствие ключа шифрования (рассинхронизация LTK). Забудьте об этом RNode в Системных настройках → Bluetooth, затем Администратор → Начать сопряжение. Установите blueutil (Brew install blueutil), чтобы включить автоматическое отсоединение в macOS." }, "reticulumSidecarBundledMissing": "В этой установке отсутствует входящая в комплект Reticulum sidecar. Обновите версию до более новой или сообщите о проблеме — сборка из исходного кода недоступна при пакетной установке.", "radioMqttRootDivergesWarning": "Корневая тема радио MQTT - {{radioRoot}}, но эта панель подписана на {{appPrefix}}. Сопоставьте радиостанцию, чтобы ограничить трафик узлов по всей стране — обновите префикс темы и повторно подключите MQTT.", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index ea72f230b..53eee21de 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "Uzak Reticulum örneği çevrimdışı olabilir veya bağlantıları engelleyebilir. Bu arayüzü devre dışı bırakın veya ana bilgisayarı ve bağlantı noktasını doğrulayın. Sık mesh-client veya yığın yeniden başlatmaları, Reticulum 1.4.0+ hub'larının IP'nizi 12 saat boyunca engellemesine neden olabilir — yeniden başlatmak yerine bekleyin.", "connectingHintBle": "BLE RNode bağlantıları, yığın başladıktan sonra (İşletim Sistemi geçiş anahtarı penceresi) 60 saniye kadar sürebilir. İstenirse mevcut RNode PIN'ini girin - asla 123456 değil. Bu normaldir, bir hata değildir. Başarılı bir bağlantı sağlanıncaya kadar cihaz Sistem Ayarlarında görünmeyebilir.", "offlineHintBle": "Bu hatayı temizlemek için bu arayüzü kaldırmanız ve geri eklemeniz gerekebilir (Cihaz seç) böylece kaydedilen Bluetooth kimliği yenilenir, ardından yığını yeniden başlatın. İşletim sisteminiz 6 haneli bir Bluetooth PIN isteyebilir (macOS, Windows veya Linux). RNode her eşleşmede yeni bir kod üretir — asla 123456 kullanmayın (bu Meshtastic varsayılanıdır, RNode değil). Bu uygulama bağlanmadan önce RNode'un Sistem Ayarları → Bluetooth'ta görünmesi gerekmez; yığın eşleştiğinde işletim sistemi iletişim kutusu görünür. Ekransız radyolar: USB bağlayın → Reticulum Admin → Bluetooth → Eşleştirmeyi başlat → Yönetici panelinde gösterilen PIN'i (radyo ekranında değil) işletim sistemi iletişim kutusuna girin. Ekranlı radyolar: Heltec cihazlarda düğmeyi ~7 sn basılı tutun (süre değişir; birçok kartta ~10 sn Wi‑Fi AP açar), ekrandaki PIN'i okuyun ve işletim sistemi iletişim kutusuna girin. Yeniden denemeden önce işletim sistemi Bluetooth ayarlarından başarısız bağları temizleyin.", - "offlineHintBleBondStale": "macOS hâlâ bu RNode'u Eşleştirilmiş olarak gösteriyor, ancak Bluetooth anahtarları artık eşleşmiyor (eş eşleştirme bilgilerini kaldırdı). Sistem Ayarları → Bluetooth'ta cihazı unutun, RNode'u eşleştirme moduna alın (Yönetici → Bluetooth → Eşleştirmeyi başlat, veya düğmeyi ~7 sn basılı tutun), ardından bu arayüzü kaldırıp yeniden ekleyin (Cihaz seç), yığını yeniden başlatın ve istendiğinde yeni 6 haneli PIN'i girin.", + "offlineHintBleBondStale": "macOS hâlâ Paired gösteriyor, ancak Bluetooth bağı reddetti (Peer removed pairing information). Sistem Ayarları → Bluetooth'ta bu RNode'u unutun, sonra yeniden eşleştirin: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing ve yeni PIN'i girin. Radyonun hemen çevrimiçi olması gerekiyorsa USB seri veya Wi-Fi (tcp://) tercih edin.", "tcpFastFlap": "{{name}}: Hub, sık sık yığın yeniden başlatıldıktan sonra muhtemelen IP'nizi engelledi ({{host}}:{{port}})", "tcpFastFlapHint": "Reticulum 1.4.0+ genel hub'lar, 12 saat içinde beş kısa TCP oturumundan sonra bir IP'yi engeller. Hızlı mesh-client veya yığın yeniden başlatmaları sayılır. Bu hub'ı devre dışı bırakın ve 12 saate kadar bekleyin — yeniden başlatmak bloğu uzatır." }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "Sepet taşıma aktörü sıkışmış olabilir — Reticulum yığınını yeniden başlatın.", "shareInstanceHint": "Paylaşım örneği etkinleştirildi — diğer Reticulum uygulamalarından (MeshChatX, Ratspeak, standalone rnsd) tamamen çıkın ve DM'leri yeniden denemeden önce bu yığını yeniden başlatın.", "bleBondRemoved": "{{name}}: Bluetooth bağlantısı eski (İşletim sistemi hâlâ Eşlendi gösteriyor)", - "bleBondRemovedHint": "Önce Sistem Ayarları → Bluetooth’ta bu RNode’u unutun (macOS Paired’i otomatik silmez). Mümkünse BLE bağlarından kaçınmak için USB seri veya Wi‑Fi (tcp://) tercih edin. Sonra: Admin → Bluetooth → Eşleşmiş cihazları temizle (USB, ESP32) varsa, Eşleştirmeyi başlat, bu BLE arayüzünü kaldırıp yeniden ekleyin (Cihaz seç), yığını yeniden başlatın ve yeni 6 haneli PIN’i girin.", + "bleBondRemovedHint": "Otomatik exclusive reconnect bu bağı onaramadı. Sistem Ayarları → Bluetooth'ta RNode'u unutun, sonra Admin → Clear paired / Start pairing ile yeni PIN'i girin. Yeniden eşleşene kadar USB seri veya Wi-Fi (tcp://) tercih edin. Bu RNode Bluetooth kullanırken LoRa BLE (MeshCore/Meshtastic) duraklatılır.", "blePairingTimedOut": "{{name}}: Bluetooth şifre değişimi zaman aşımına uğradı", "blePairingTimedOutHint": "Yığını durdurun, eşleştirmeyi yeniden başlatın (Yönetici → Bluetooth panelde PIN'i gösterir - radyo ekranı boş kalabilir - veya ekrandaki PIN için ~7 saniye basılı tutun), yığını bir kez yeniden başlatın ve bu PIN'i ~60 saniye içinde işletim sistemi iletişim kutusuna girin. Asla 123456 kullanmayın (Meshtastic varsayılanı).", "bleBondRemovedStopStack": "Yığını durdur", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "Bu arayüz TX kanalı dolu. Düşmeleri durdurmak için bu arayüzün bağlantı sağlığını kontrol edin (veya devre dışı bırakın).", "txQueueDropsHintBleFlowControl": "Radyo meşgulken ana bilgisayar TX kuyruğu geri basıncı (akış kontrolü açık). RF tıkanıklığı altında beklenir — sıkışmış bir bağlantı değil. Giden yükü azaltın veya yayın süresini bekleyin.", "tcpResetByPeer": "{{name}}: Hub TCP oturumunu sıfırladı (Bağlantı eş tarafından sıfırlandı)", - "tcpReadEof": "{{name}}: Hub TCP'yi temiz bir şekilde kapattı (BİLGİ: TCP okundu: EOF)" + "tcpReadEof": "{{name}}: Hub TCP'yi temiz bir şekilde kapattı (BİLGİ: TCP okundu: EOF)", + "bleLtkDesyncPurged": "Şifreleme anahtarı uyumsuzluğu (LTK DESYNC) — eski OS bağı kaldırıldı. Yeniden eşleştir: Yönetici → Bluetooth Eşleştirmeyi → başlatın, ardından yeni PIN kodunu girin.", + "bleLtkDesyncManualForget": "Şifreleme anahtarı eşleşmiyor (LTK DESYNC). Sistem Ayarları → Bluetooth'ta bu RNode'u unutun, ardından Yönetici Eşleştirmeyi → başlatın. macOS'ta otomatik eşleşmeyi kaldırmaya izin vermek için blueutil'i (brew install blueutil) yükleyin." }, "reticulumSidecarBundledMissing": "Bu kurulumda birlikte verilen Reticulum sidecar eksik. Daha yeni bir sürüme yükseltin veya sorunu bildirin; kaynaktan derleme, paketli kurulumlarda mevcut değildir.", "radioMqttRootDivergesWarning": "Radyo MQTT kök konusu {{radioRoot}} 'dır, ancak bu panel {{appPrefix}}' e abone olur. Ülke çapındaki düğüm trafiğini sınırlamak için radyoyu eşleştirin — Konu Önekini güncelleyin ve MQTT'yi yeniden bağlayın.", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index cbe6ee4d5..211aa9e00 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -1414,7 +1414,7 @@ "tcpUnreachableHint": "Віддалений екземпляр Reticulum може бути offline або блокувати з'єднання. Вимкніть цей інтерфейс або перевірте хост і порт. Часті перезапуски mesh-client або стека можуть призвести до того, що концентратори Reticulum 1.4.0+ заблокують вашу IP-адресу на 12 годин — краще зачекати, ніж перезапускати знову.", "connectingHintBle": "Посилання BLE RNode може зайняти до 60 секунд після запуску стека (вікно пароля ОС). Якщо буде запропоновано, введіть поточний PIN-код RNode — ніколи не 123456. Це нормально, а не помилка. Пристрій може з’явитися в налаштуваннях системи лише після успішного підключення.", "offlineHintBle": "Щоб усунути цю помилку, можливо, доведеться видалити цей інтерфейс і додати його знову (Вибрати пристрій), щоб оновити збережений Bluetooth‑ідентифікатор, а потім перезапустити стек. ОС може запитати 6‑цифровий Bluetooth PIN (macOS, Windows або Linux). RNode генерує новий код під час кожного сполучення — ніколи не використовуйте 123456 (це типовий код Meshtastic, не RNode). RNode не обовʼязково має зʼявлятися в Системних параметрах → Bluetooth до підключення цієї програми; діалогове вікно ОС зʼявляється, коли стек сполучається. Радіо без дисплея: підключіть USB → Reticulum Admin → Bluetooth → Почати сполучення → введіть PIN, показаний у панелі Admin (не на дисплеї радіо), у діалоговому вікні ОС. Радіо з дисплеєм: утримуйте кнопку ~7 с на пристроях Heltec (час різний; ~10 с на багатьох платах вмикає Wi‑Fi AP), прочитайте PIN на екрані та введіть його в діалоговому вікні ОС. Очистіть невдалі звʼязки в параметрах Bluetooth ОС перед повторною спробою.", - "offlineHintBleBondStale": "macOS досі показує цей RNode як сполучений, але ключі Bluetooth більше не збігаються (одноранговий вузол видалив інформацію про сполучення). Забудьте пристрій у Системних параметрах → Bluetooth, переведіть RNode у режим сполучення (Admin → Bluetooth → Почати сполучення або утримуйте кнопку ~7 с), потім видаліть і знову додайте цей інтерфейс (Вибрати пристрій), перезапустіть стек і введіть новий 6‑цифровий PIN за запитом.", + "offlineHintBleBondStale": "macOS досі показує Paired, але Bluetooth відхилив зв'язок (Peer removed pairing information). Забудьте цей RNode у Системних параметрах → Bluetooth, потім знову сполучіть: USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing і введіть новий PIN. Віддавайте перевагу USB serial або Wi-Fi (tcp://), якщо радіо має бути онлайн одразу.", "tcpFastFlap": "{{name}}: концентратор, ймовірно, заблокував вашу IP-адресу після частих перезавантажень стека ({{host}}:{{port}})", "tcpFastFlapHint": "Публічні концентратори Reticulum 1.4.0+ блокують IP-адресу після п'яти коротких сеансів TCP протягом 12 годин. Швидкі перезапуски mesh-client або стека враховуються. Вимкніть цей концентратор і зачекайте до 12 годин — повторний запуск подовжує блокування." }, @@ -1493,7 +1493,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "Екземпляр Share увімкнено — повністю закрийте інші програми Reticulum (MeshChatX, Ratspeak, автономний rnsd) і перезапустіть цей стек перед повторною спробою DM.", "bleBondRemoved": "{{name}}: з’єднання Bluetooth застаріле (ОС все ще відображає сполучення)", - "bleBondRemovedHint": "Спочатку забудьте цей RNode у Системних параметрах → Bluetooth (macOS не знімає Paired автоматично). За можливості надавайте перевагу USB serial або Wi‑Fi (tcp://), щоб уникнути BLE-зв’язків. Далі: Admin → Bluetooth → Очистити спарені пристрої (USB, ESP32) якщо доступно, Почати сполучення, видаліть і знову додайте цей BLE-інтерфейс (Вибрати пристрій), перезапустіть стек і введіть новий 6-значний PIN.", + "bleBondRemovedHint": "Автоматичний exclusive reconnect не зміг відновити цей зв'язок. Забудьте RNode у Системних параметрах → Bluetooth, потім Admin → Clear paired / Start pairing і введіть новий PIN. До повторного сполучення віддавайте перевагу USB serial або Wi-Fi (tcp://). LoRa BLE (MeshCore/Meshtastic) залишається на паузі, поки цей RNode використовує Bluetooth.", "blePairingTimedOut": "{{name}}: час очікування обміну паролем Bluetooth минув", "blePairingTimedOutHint": "Зупиніть стек, почніть сполучення знову (Адміністратор → Bluetooth показує PIN-код на панелі — дисплей радіо може залишатися порожнім — або утримуйте кнопку ~7 с для введення PIN-коду на екрані), перезапустіть стек один раз і введіть цей PIN-код у діалоговому вікні ОС протягом ~60 секунд. Ніколи не використовуйте 123456 (Meshtastic за умовчанням).", "bleBondRemovedStopStack": "Зупинити стек", @@ -1503,7 +1503,9 @@ "txQueueDropsHintNeutral": "Цей канал інтерфейсу TX заповнений. Перевірте здоров'я зв'язку цього інтерфейсу (або вимкніть його), щоб зупинити падіння.", "txQueueDropsHintBleFlowControl": "Зворотний тиск у черзі TX хоста, коли радіоприймач зайнятий (контроль потоку ввімкнено). Очікується в умовах радіочастотної перевантаженості — не застрягло посилання. Зменшіть вихідне навантаження або зачекайте ефірного часу.", "tcpResetByPeer": "{{name}}: скидання концентратором сеансу TCP (скидання з'єднання вузлом)", - "tcpReadEof": "{{name}}: концентратор закрив TCP чисто (ІНФОРМАЦІЯ: TCP читання: EOF)" + "tcpReadEof": "{{name}}: концентратор закрив TCP чисто (ІНФОРМАЦІЯ: TCP читання: EOF)", + "bleLtkDesyncPurged": "Невідповідність ключа шифрування (розсинхронізація LTK) — застарілий зв'язок ОС видалено. Повторне з'єднання: Admin → Bluetooth → Start pairing, потім введіть новий PIN.", + "bleLtkDesyncManualForget": "Невідповідність ключа шифрування (розсинхронізація LTK). Забудьте цей RNode у Системних параметрах → Bluetooth, потім Admin → Start pairing. Встановіть blueutil (brew install blueutil), щоб дозволити автоматичне від'єднання на macOS." }, "reticulumSidecarBundledMissing": "У цій інсталяції відсутній Reticulum sidecar. Оновіть до новішого випуску або повідомте про проблему — збірка з вихідного коду недоступна в пакетних встановленнях.", "radioMqttRootDivergesWarning": "Коренева тема радіо MQTT - {{radioRoot}}, але ця панель підписується на {{appPrefix}}. Підключіть радіоприймач, щоб обмежити загальнонаціональний трафік вузлів — оновіть префікс теми та повторно підключіть MQTT.", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index e2b306cca..9bbca7466 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -1376,7 +1376,7 @@ "tcpUnreachableHint": "远程 Reticulum 实例可能处于离线状态或正在阻止连接。请禁用此接口或验证主机和端口。频繁重启 mesh-client 或协议栈可能导致 Reticulum 1.4.0+ 枢纽将您的 IP 封锁 12 小时 — 请等待,不要再次重启。", "connectingHintBle": "堆栈启动后,BLE RNode 链接可能需要长达 60 秒的时间(操作系统密钥窗口)。如果出现提示,请输入当前的 RNode PIN — 切勿输入 123456。这是正常现象,不是错误。成功绑定后,该设备可能不会出现在系统设置中。", "offlineHintBle": "要清除此错误,您可能需要删除此接口并重新添加(选择设备),以刷新已保存的蓝牙 ID,然后重新启动堆栈。操作系统可能会提示输入 6 位蓝牙 PIN(macOS、Windows 或 Linux)。RNode 每次配对都会生成新代码——切勿使用 123456(那是 Meshtastic 的默认值,不是 RNode)。在此应用连接之前,RNode 不必出现在系统设置 → 蓝牙中;堆栈配对时会出现操作系统对话框。无显示屏的电台:连接 USB → Reticulum Admin → 蓝牙 → 开始配对 → 将管理面板中显示的 PIN(不是电台屏幕上的)输入到操作系统对话框。有显示屏的电台:在 Heltec 设备上按住按钮约 7 秒(时间因机型而异;许多板上约 10 秒会启用 Wi‑Fi AP),读取屏幕上的 PIN,然后输入到操作系统对话框。重试前请在操作系统蓝牙设置中清除失败的绑定。", - "offlineHintBleBondStale": "macOS 仍将此 RNode 显示为已配对,但蓝牙密钥不再匹配(对等方已删除配对信息)。在系统设置 → 蓝牙中忘记该设备,将 RNode 置于配对模式(管理 → 蓝牙 → 开始配对,或按住按钮约 7 秒),然后删除并重新添加此接口(选择设备),重新启动堆栈,并在提示时输入新的 6 位 PIN。", + "offlineHintBleBondStale": "macOS 仍显示 Paired,但 Bluetooth 拒绝了绑定(Peer removed pairing information)。在系统设置 → 蓝牙中忘记此 RNode,然后重新配对:USB → Reticulum Admin → Bluetooth → Clear paired / Start pairing,并输入新的 PIN。若需立即在线,请优先使用 USB 串口或 Wi-Fi(tcp://)。", "tcpFastFlap": "{{name}} : HUB可能在频繁重启堆栈后阻止了您的IP ( {{host}}: {{port}} )", "tcpFastFlapHint": "Reticulum 1.4.0+ 公共枢纽会在 12 小时内五次短 TCP 会话后封锁 IP。快速重启 mesh-client 或协议栈会计入。请禁用此枢纽并等待最多 12 小时 — 再次重启会延长封锁。" }, @@ -1455,7 +1455,7 @@ "slowTransportQueryHint": "The sidecar transport actor may be wedged — restart the Reticulum stack.", "shareInstanceHint": "共享实例已启用—在重试DM之前,完全退出其他Reticulum应用程序( MeshChatX、Ratspeak、独立rnsd )并重新启动此堆栈。", "bleBondRemoved": "{{name}}:蓝牙绑定已过时(操作系统仍显示已配对)", - "bleBondRemovedHint": "请先在系统设置 → 蓝牙中忘记此 RNode(macOS 不会自动清除“已配对”)。尽量优先使用 USB 串口或 Wi‑Fi(tcp://)以避免 BLE 绑定。然后:管理 → 蓝牙 → 清除已配对设备(USB,ESP32,如可用)、开始配对、移除此 BLE 接口并重新添加(选择设备)、重启协议栈,并输入新的 6 位 PIN。", + "bleBondRemovedHint": "自动 exclusive reconnect 无法修复此绑定。在系统设置 → 蓝牙中忘记 RNode,然后 Admin → Clear paired / Start pairing 并输入新的 PIN。重新配对前请优先使用 USB 串口或 Wi-Fi(tcp://)。此 RNode 使用 Bluetooth 时,LoRa BLE(MeshCore/Meshtastic)保持暂停。", "blePairingTimedOut": "{{name}}:蓝牙密钥交换超时", "blePairingTimedOutHint": "停止堆栈,再次开始配对(管理 → 蓝牙在面板中显示 PIN — 无线电显示屏可能保持空白 — 或按住按钮约 7 秒以获取屏幕上的 PIN),重新启动堆栈一次,然后在约 60 秒内在操作系统对话框中输入该 PIN。切勿使用 123456(Meshtastic 默认值)。", "bleBondRemovedStopStack": "停止堆栈", @@ -1465,7 +1465,9 @@ "txQueueDropsHintNeutral": "此接口TX通道已满。检查该接口的链接健康状况(或禁用它)以停止丢弃。", "txQueueDropsHintBleFlowControl": "无线电忙时主机TX队列背压(流量控制开启)。在射频拥塞下预期—不是卡住的链路。减少出站负载或等待通话时间。", "tcpResetByPeer": "{{name}} :集线器重置TCP会话(对等体重置连接)", - "tcpReadEof": "{{name}} :集线器已干净关闭TCP (信息: TCP读取: EOF )" + "tcpReadEof": "{{name}} :集线器已干净关闭TCP (信息: TCP读取: EOF )", + "bleLtkDesyncPurged": "加密密钥不匹配( LTK取消同步) —已删除过时的操作系统绑定。重新配对:管理员→蓝牙→开始配对,然后输入新的PIN码。", + "bleLtkDesyncManualForget": "加密密钥不匹配( LTK取消同步)。在系统设置→蓝牙中忘记此RNode ,然后管理员→开始配对。安装blueutil ( BREW安装blueutil ) ,以便在macOS上自动取消配对。" }, "reticulumSidecarBundledMissing": "此安装缺少捆绑的 Reticulum sidecar。升级到较新的版本或报告问题 - 在打包安装中无法从源代码构建。", "radioMqttRootDivergesWarning": "无线电MQTT根主题为{{radioRoot}} ,但此面板订阅了{{appPrefix}}。匹配无线电以限制全国节点流量—更新主题前缀并重新连接MQTT。", diff --git a/src/renderer/runtime/useMeshcoreRuntime.ts b/src/renderer/runtime/useMeshcoreRuntime.ts index 24e2c5ef0..d60229044 100644 --- a/src/renderer/runtime/useMeshcoreRuntime.ts +++ b/src/renderer/runtime/useMeshcoreRuntime.ts @@ -21,6 +21,7 @@ import { throwIfMeshcoreOffloadAborted, } from '@/renderer/lib/meshcoreOffload'; import { BLE_ADAPTER_LEASE_RELEASED_EVENT } from '@/renderer/lib/reticulum/reticulumBleAdapterLease'; +import { getReticulumBleBondDesyncActive } from '@/renderer/lib/reticulum/reticulumBleBondDesync'; import { touch } from '@/shared/touch'; import { bytesToHex } from '../../shared/hexBytes'; @@ -2197,6 +2198,12 @@ export function useMeshcoreRuntime() { const onBleLeaseReleased = () => { if (meshcoreConnectionParamsRef.current?.rfType !== 'ble') return; if (meshcoreExplicitDisconnectRef.current) return; + if (getReticulumBleBondDesyncActive()) { + console.debug( + '[useMeshcoreRuntime] Noble BLE yield released — skip nudge (RNode bond recovery)', + ); + return; + } if (meshcoreDriverConnectedRef.current || connRef.current) { return; } @@ -3719,6 +3726,18 @@ export function useMeshcoreRuntime() { const scheduleMeshcoreReconnectAttempt = useCallback(() => { meshcoreRfReconnectRef.current.scheduleOwner(() => { + if ( + meshcoreConnectionParamsRef.current?.rfType === 'ble' && + getReticulumBleBondDesyncActive() + ) { + console.debug( + '[useMeshcoreRuntime] abort reconnect schedule — RNode bond recovery holds the adapter', + ); + meshcoreIsReconnectingRef.current = false; + meshcoreDeferredReconnectRef.current = false; + meshcoreRfReconnectRef.current.endAttempt(); + return; + } if (!meshcoreIsReconnectingRef.current || meshcoreExplicitDisconnectRef.current) { return; } @@ -3739,6 +3758,15 @@ export function useMeshcoreRuntime() { console.debug('[useMeshcoreRuntime] skip reconnect (user disconnect)'); return; } + if ( + meshcoreConnectionParamsRef.current?.rfType === 'ble' && + getReticulumBleBondDesyncActive() + ) { + console.debug( + '[useMeshcoreRuntime] skip BLE reconnect — RNode bond recovery holds the adapter', + ); + return; + } if ( meshcoreConnectionParamsRef.current?.rfType === 'ble' && shouldSkipBleReconnectAfterExhaustion({ diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index 5f0b77219..a04ed2d0e 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -58,6 +58,7 @@ import { } from '@/renderer/lib/meshtasticMqttPublish'; import { readMeshtasticMqttSettingsFromStorage } from '@/renderer/lib/meshtasticMqttSettingsStorage'; import { BLE_ADAPTER_LEASE_RELEASED_EVENT } from '@/renderer/lib/reticulum/reticulumBleAdapterLease'; +import { getReticulumBleBondDesyncActive } from '@/renderer/lib/reticulum/reticulumBleBondDesync'; import { meshtasticDeviceRoleFromConfigSlice, resolveAppliedMeshtasticDeviceRole, @@ -2057,6 +2058,12 @@ export function useMeshtasticRuntime() { console.debug('[useMeshtasticRuntime] skip reconnect (user disconnect)'); return; } + if (connectionParamsRef.current?.type === 'ble' && getReticulumBleBondDesyncActive()) { + console.debug( + '[useMeshtasticRuntime] skip BLE reconnect — RNode bond recovery holds the adapter', + ); + return; + } if ( connectionParamsRef.current?.type === 'ble' && shouldSkipBleReconnectAfterExhaustion({ @@ -2479,6 +2486,12 @@ export function useMeshtasticRuntime() { const onBleLeaseReleased = () => { if (connectionParamsRef.current?.type !== 'ble') return; if (meshtasticExplicitDisconnectRef.current) return; + if (getReticulumBleBondDesyncActive()) { + console.debug( + '[useMeshtasticRuntime] Noble BLE yield released — skip nudge (RNode bond recovery)', + ); + return; + } if (meshtasticDriverConnectedRef.current && deviceConfiguredRef.current) { return; } diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index a93980068..d86b10ae8 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -60,12 +60,15 @@ describe('useReticulumRuntime reconnect hardening (regression)', () => { it('is concurrent-safe: stack connecting does not disconnect LoRa GATT sessions', () => { // Reticulum stack start/connect must not tear down Meshtastic/MeshCore GATT. - // disconnectAll is only for explicit RNode adapter yield (bleCoexistence), not connect(). + // releaseGattBleCentral is reserved for bleBondRemoved recovery (dual-central pause). expect(SOURCE).not.toContain('useReticulumNobleBleYieldWatcher'); expect(SOURCE).not.toMatch(/disconnectAll\s*\(/); - expect(SOURCE).not.toMatch(/disconnectGatt\s*\(/); const connectBody = extractUseCallbackBody(SOURCE, 'connect'); - expect(connectBody).not.toMatch(/gattSidecarProxy|disconnectAll|disconnectGatt/); + expect(connectBody).not.toMatch( + /gattSidecarProxy|disconnectAll|disconnectGatt|releaseGattBleCentral/, + ); + // Bond-recovery path may pause LoRa GATT; keep it out of connect(). + expect(SOURCE).toMatch(/bleBondRemoved[\s\S]*?releaseGattBleCentral\(\)/); }); }); @@ -188,9 +191,10 @@ describe('useReticulumRuntime resume-generation cancel (H7)', () => { expect(resumeBody).toContain('powerSuspendHadBleRnodeRef.current'); }); - it('latches bleBondRemoved to release BLE RNode lease and set bond-desync sticky flag', () => { + it('latches bleBondRemoved to pause LoRa GATT and set bond-desync sticky flag', () => { expect(SOURCE).toMatch(/setReticulumBleBondDesyncActive\(true\)/); - expect(SOURCE).toMatch(/releaseReticulumBleRnodeConnect\(\)/); + expect(SOURCE).toMatch(/releaseGattBleCentral\(\)/); + expect(SOURCE).toMatch(/prepareReticulumBleRnodeConnect\(\)/); expect(SOURCE).toMatch(/status\.interfaceIssueAlert\?\.bleBondRemoved/); }); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 1a8136101..876274175 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -40,6 +40,7 @@ import { resolveReticulumOutboundViaFromPath, reticulumViaToMessageTransport, } from '@/renderer/lib/reticulum/classifyReticulumVia'; +import { clearReticulumBleBondIssuesForOnlineInterfaces } from '@/renderer/lib/reticulum/clearReticulumBleBondIssuesForOnlineInterfaces'; import { clearReticulumSessionStores } from '@/renderer/lib/reticulum/clearReticulumSessionStores'; import { resolveReticulumDestinationHash, @@ -58,9 +59,18 @@ import { } from '@/renderer/lib/reticulum/reticulumAnnounceIfaceAttribution'; import { cacheReticulumInboundAttachment } from '@/renderer/lib/reticulum/reticulumAttachmentCache'; import { cacheReticulumInboundAudio } from '@/renderer/lib/reticulum/reticulumAudioAttachmentCache'; -import { isReticulumBleRnodeInterfaceRow } from '@/renderer/lib/reticulum/reticulumBleAdapterConflict'; -import { releaseReticulumBleRnodeConnect } from '@/renderer/lib/reticulum/reticulumBleAdapterLease'; -import { setReticulumBleBondDesyncActive } from '@/renderer/lib/reticulum/reticulumBleBondDesync'; +import { + isReticulumBleRnodeInterfaceRow, + isReticulumBleRnodeOnline, +} from '@/renderer/lib/reticulum/reticulumBleAdapterConflict'; +import { + prepareReticulumBleRnodeConnect, + releaseReticulumBleRnodeConnect, +} from '@/renderer/lib/reticulum/reticulumBleAdapterLease'; +import { + getReticulumBleBondDesyncActive, + setReticulumBleBondDesyncActive, +} from '@/renderer/lib/reticulum/reticulumBleBondDesync'; import { fetchReticulumConfigAudit } from '@/renderer/lib/reticulum/reticulumConfigAudit'; import { RETICULUM_CONFIGURED_EVENT } from '@/renderer/lib/reticulum/reticulumConfiguredEvent'; import { maybeNotifyInboundGamesChallenge } from '@/renderer/lib/reticulum/reticulumGamesNotifications'; @@ -313,6 +323,8 @@ export function useReticulumRuntime(): ProtocolRuntime { const suppressReconnectRef = useRef(false); /** Set on power-suspend when an enabled BLE RNode was configured — wake must not reuseIfRunning. */ const powerSuspendHadBleRnodeRef = useRef(false); + /** True while we are holding LoRa GATT exclusive for an online BLE RNode. */ + const rnodeBleOnlineLoRaHoldRef = useRef(false); /** * Bumped on every power-suspend so a `connect()` flight started before an earlier suspend * (and still in flight when a *later* suspend/resume pair fires) can detect it has been @@ -503,7 +515,10 @@ export function useReticulumRuntime(): ProtocolRuntime { return { interfaces, osSerialPorts }; } localInterfacesRef.current = interfaces; - logReticulumLocalInterfaceHealthChanges(interfaces, osSerialPorts); + const newlyOnlineBle = logReticulumLocalInterfaceHealthChanges(interfaces, osSerialPorts); + if (newlyOnlineBle.length > 0) { + void clearReticulumBleBondIssuesForOnlineInterfaces(newlyOnlineBle); + } setQueueStatus(aggregateReticulumLocalRfTxQueue(interfaces)); return { interfaces, osSerialPorts }; }, []); @@ -1497,6 +1512,21 @@ export function useReticulumRuntime(): ProtocolRuntime { void refreshGamesSessions(); } } + if (evt.type === 'BleLtkDesync' && evt.payload && typeof evt.payload === 'object') { + const p = evt.payload as { + device_address?: string; + bond_purged?: boolean; + message?: string; + }; + setReticulumBleBondDesyncActive(true); + pushAppToast( + p.bond_purged === true + ? i18n.t('connectionPanel.reticulumSidecarIssues.bleLtkDesyncPurged') + : i18n.t('connectionPanel.reticulumSidecarIssues.bleLtkDesyncManualForget'), + 'error', + 12_000, + ); + } if (evt.type === 'rncp.progress' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { transfer_id?: string; progress?: number }; if (p.transfer_id && typeof p.progress === 'number') { @@ -1695,12 +1725,44 @@ export function useReticulumRuntime(): ProtocolRuntime { useEffect(() => { const unsubStatus = window.electronAPI.reticulum.onStatus((status) => { const bondRemoved = status.interfaceIssueAlert?.bleBondRemoved ?? []; - // Sticky: set true when latched; clear only on sidecar stop / tearDown (not empty alert). + // Peer-removed recovery needs exclusive adapter time on macOS. Dual LoRa GATT + // centrals in the same sidecar precipitate repeated CBError 14 — pause LoRa BLE + // instead of releasing the lease (which nudges MeshCore/Meshtastic to reconnect). if (bondRemoved.length > 0) { + const firstLatch = !getReticulumBleBondDesyncActive(); setReticulumBleBondDesyncActive(true); + // Only dispose once per episode — re-entrant status updates were recreating + // the LoRa CBCentralManager between RNode retries (had_backend:true again). + if (firstLatch) { + void (async () => { + try { + await window.electronAPI.releaseGattBleCentral(); + } catch (e: unknown) { + console.debug( + '[useReticulumRuntime] releaseGattBleCentral during bond recovery ' + + errLikeToLogString(e), + ); + } + try { + await prepareReticulumBleRnodeConnect(); + } catch (e: unknown) { + console.debug( + '[useReticulumRuntime] hold scan lease during bond recovery ' + + errLikeToLogString(e), + ); + } + })(); + } + } else if (getReticulumBleBondDesyncActive()) { + setReticulumBleBondDesyncActive(false); + void window.electronAPI.clearGattBondRecoveryExclusive().catch((e: unknown) => { + console.debug( + '[useReticulumRuntime] clearGattBondRecoveryExclusive ' + errLikeToLogString(e), + ); + }); void releaseReticulumBleRnodeConnect().catch((e: unknown) => { console.debug( - '[useReticulumRuntime] release Noble after bleBondRemoved ' + errLikeToLogString(e), + '[useReticulumRuntime] release lease after bond recovery ' + errLikeToLogString(e), ); }); } @@ -2232,7 +2294,29 @@ export function useReticulumRuntime(): ProtocolRuntime { return; } localInterfacesRef.current = interfaces; - logReticulumLocalInterfaceHealthChanges(interfaces, osSerialPorts); + const newlyOnlineBle = logReticulumLocalInterfaceHealthChanges(interfaces, osSerialPorts); + if (newlyOnlineBle.length > 0) { + void clearReticulumBleBondIssuesForOnlineInterfaces(newlyOnlineBle); + } + // macOS: two CBCentralManagers in one sidecar can invalidate the RNode bond. + // While any BLE RNode is online, keep LoRa GATT disposed/exclusive. + const rnodeBleOnline = interfaces.some((row) => isReticulumBleRnodeOnline(row)); + if (rnodeBleOnline) { + rnodeBleOnlineLoRaHoldRef.current = true; + void window.electronAPI.releaseGattBleCentral().catch((e: unknown) => { + console.debug( + '[useReticulumRuntime] releaseGattBleCentral while RNode BLE online ' + + errLikeToLogString(e), + ); + }); + } else if (rnodeBleOnlineLoRaHoldRef.current && !getReticulumBleBondDesyncActive()) { + rnodeBleOnlineLoRaHoldRef.current = false; + void window.electronAPI.clearGattBondRecoveryExclusive().catch((e: unknown) => { + console.debug( + '[useReticulumRuntime] clearGattBondRecoveryExclusive ' + errLikeToLogString(e), + ); + }); + } const queueAgg = aggregateReticulumLocalRfTxQueue(interfaces); setQueueStatus(queueAgg); const health = { interfaces, osSerialPorts }; diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index e7d757d58..2f7e2f8e0 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -194,6 +194,8 @@ export function createElectronAPIMock(): ElectronAPI { stopGattScanning: vi.fn().mockResolvedValue(undefined), connectGatt: vi.fn().mockResolvedValue({ ok: true }), disconnectGatt: vi.fn().mockResolvedValue(undefined), + releaseGattBleCentral: vi.fn().mockResolvedValue(undefined), + clearGattBondRecoveryExclusive: vi.fn().mockResolvedValue(undefined), isGattConnected: vi.fn().mockResolvedValue(true), gattToRadio: vi.fn().mockResolvedValue(undefined), onSerialPortsDiscovered: vi.fn().mockReturnValue(() => {}), @@ -418,6 +420,9 @@ export function createElectronAPIMock(): ElectronAPI { stop: vi.fn().mockResolvedValue(undefined), getStatus: vi.fn().mockResolvedValue({ running: false, port: 0, pid: null }), syncInterfaceIssueScope: vi.fn().mockResolvedValue({ running: false, port: 0, pid: null }), + clearBleBondIssuesForOnlineInterfaces: vi + .fn() + .mockResolvedValue({ running: false, port: 0, pid: null }), proxyGet: vi.fn().mockResolvedValue({ status: 'ok' }), proxyPost: vi.fn().mockResolvedValue({ ok: true }), proxyPut: vi.fn().mockResolvedValue({ ok: true }), diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index aecf37989..567aa1134 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -942,6 +942,10 @@ export interface ElectronAPI { stopGattScanning: (sessionId: GattBleSessionId) => Promise; connectGatt: (sessionId: GattBleSessionId, peripheralId: string) => Promise; disconnectGatt: (sessionId: GattBleSessionId) => Promise; + /** Drop LoRa GATT sessions + sidecar CBCentralManager for RNode bond recovery. */ + releaseGattBleCentral: () => Promise; + /** End exclusive RNode bond-recovery hold so LoRa GATT may scan/connect again. */ + clearGattBondRecoveryExclusive: () => Promise; isGattConnected: (sessionId: GattBleSessionId) => Promise; gattToRadio: (sessionId: GattBleSessionId, bytes: Uint8Array) => Promise; @@ -1198,6 +1202,13 @@ export interface ElectronAPI { getStatus: () => Promise; /** Drop latched TCP/TX issues for interfaces not in the enabled set; returns updated status. */ syncInterfaceIssueScope: (enabledInterfaceNames: string[]) => Promise; + /** + * Clear BLE bond-removed / pairing-timeout latches after named interfaces report online. + * Returns updated sidecar status (emits onStatus when the alert changes). + */ + clearBleBondIssuesForOnlineInterfaces: ( + onlineInterfaceNames: string[], + ) => Promise; proxyGet: (apiPath: string) => Promise; proxyPost: (apiPath: string, body: unknown) => Promise; proxyPut: (apiPath: string, body: unknown) => Promise; diff --git a/src/shared/reticulum-types.ts b/src/shared/reticulum-types.ts index 203de3922..27c81b5f2 100644 --- a/src/shared/reticulum-types.ts +++ b/src/shared/reticulum-types.ts @@ -60,7 +60,8 @@ export interface ReticulumInterfaceIssueAlert { linkDeliveryTimeouts: ReticulumLinkDeliveryTimeout[]; /** * BLE RNode interface names where CoreBluetooth reported - * "Peer removed pairing information" (OS still shows Paired; bond keys are stale). + * "Peer removed pairing information" (OS may still show Paired). Sidecar keeps + * retrying with the existing OS bond; banner clears after the stale window or recovery. */ bleBondRemoved: string[]; /** From 37157ebec1d2cf2ad8a3ad626eaa7a59a3fafa99 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 15:53:56 -0600 Subject: [PATCH 2/7] fix(reticulum): fix sidecar CI for LTK unbond on Linux and Windows Drop the always-on bluer/libdbus dependency (use bluetoothctl instead), await WinRT unpair via blocking .get(), and quiet unused ToolMissing on Windows so stub and rns-stack sidecar jobs compile cleanly. --- reticulum-sidecar/Cargo.lock | 1 - reticulum-sidecar/Cargo.toml | 3 -- reticulum-sidecar/src/ble/unbond.rs | 64 ++++++++++++++++++++--------- 3 files changed, 44 insertions(+), 24 deletions(-) diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 6e93d1eb3..cbf7b1e88 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1664,7 +1664,6 @@ dependencies = [ "argon2", "axum", "base64", - "bluer", "btleplug", "bytes", "ciborium", diff --git a/reticulum-sidecar/Cargo.toml b/reticulum-sidecar/Cargo.toml index ab79a1652..e591b6028 100644 --- a/reticulum-sidecar/Cargo.toml +++ b/reticulum-sidecar/Cargo.toml @@ -163,8 +163,5 @@ assigning_clones = "allow" map_unwrap_or = "allow" case_sensitive_file_extension_comparisons = "allow" -[target.'cfg(target_os = "linux")'.dependencies] -bluer = { version = "0.17", features = ["bluetoothd"] } - [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = ["Devices_Bluetooth", "Devices_Enumeration", "Foundation"] } diff --git a/reticulum-sidecar/src/ble/unbond.rs b/reticulum-sidecar/src/ble/unbond.rs index 973575e24..e7e547c09 100644 --- a/reticulum-sidecar/src/ble/unbond.rs +++ b/reticulum-sidecar/src/ble/unbond.rs @@ -11,7 +11,9 @@ pub enum BleBondError { #[error("BLE unbond is not supported on this platform build")] #[allow(dead_code)] // used on non linux/macos/windows cfgs Unsupported, + /// Constructed on macOS (`blueutil`) and Linux (`bluetoothctl`) when the helper is absent. #[error("BLE unbond tool missing: {0}")] + #[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))] ToolMissing(String), #[error("BLE unbond failed: {0}")] Failed(String), @@ -124,24 +126,37 @@ pub async fn unbond_device_named( #[cfg(target_os = "linux")] async fn unbond_linux(id: &str) -> Result<(), BleBondError> { - use std::str::FromStr; + // Prefer bluetoothctl over bluer so stub Linux CI builds do not need libdbus-1-dev. + let id_owned = id.to_string(); + tokio::task::spawn_blocking(move || run_bluetoothctl_remove(&id_owned)) + .await + .map_err(|e| BleBondError::Failed(format!("bluetoothctl join: {e}")))? +} - use bluer::{Address, Session}; +#[cfg(target_os = "linux")] +fn run_bluetoothctl_remove(id: &str) -> Result<(), BleBondError> { + use std::process::Command; - let addr = - Address::from_str(id).map_err(|e| BleBondError::InvalidIdentifier(format!("{id}: {e}")))?; - let session = Session::new() - .await - .map_err(|e| BleBondError::Failed(format!("bluer session: {e}")))?; - let adapter = session - .default_adapter() - .await - .map_err(|e| BleBondError::Failed(format!("bluer adapter: {e}")))?; - adapter - .remove_device(addr) - .await - .map_err(|e| BleBondError::Failed(format!("bluer remove_device: {e}")))?; - tracing::warn!(%id, "ble: removed BlueZ device bond (LTK desync recovery)"); + let output = match Command::new("bluetoothctl").args(["remove", id]).output() { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(BleBondError::ToolMissing( + "bluetoothctl not found — Forget the device in system Bluetooth settings".into(), + )); + } + Err(e) => return Err(BleBondError::Failed(format!("bluetoothctl: {e}"))), + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(BleBondError::Failed(format!( + "bluetoothctl remove {id:?} failed (status={}): {} {}", + output.status, + stdout.trim(), + stderr.trim() + ))); + } + tracing::warn!(%id, "ble: bluetoothctl remove ok (LTK desync recovery)"); Ok(()) } @@ -426,6 +441,15 @@ fn run_blueutil_unpair(id: &str, display_name: Option<&str>) -> Result<(), BleBo #[cfg(target_os = "windows")] async fn unbond_windows(id: &str) -> Result<(), BleBondError> { + // windows 0.58 IAsyncOperation is not a Future — block via `.get()` off the async runtime. + let id_owned = id.to_string(); + tokio::task::spawn_blocking(move || unbond_windows_blocking(&id_owned)) + .await + .map_err(|e| BleBondError::Failed(format!("Windows unbond join: {e}")))? +} + +#[cfg(target_os = "windows")] +fn unbond_windows_blocking(id: &str) -> Result<(), BleBondError> { use windows::Devices::Bluetooth::BluetoothDevice; use windows::core::HSTRING; @@ -433,13 +457,13 @@ async fn unbond_windows(id: &str) -> Result<(), BleBondError> { let device = if let Some(addr) = parse_bt_address_u64(id) { BluetoothDevice::FromBluetoothAddressAsync(addr) .map_err(|e| BleBondError::Failed(format!("FromBluetoothAddressAsync: {e}")))? - .await + .get() .map_err(|e| BleBondError::Failed(format!("FromBluetoothAddress: {e}")))? } else { let id_h = HSTRING::from(id); BluetoothDevice::FromIdAsync(&id_h) .map_err(|e| BleBondError::Failed(format!("BluetoothDevice::FromIdAsync: {e}")))? - .await + .get() .map_err(|e| BleBondError::Failed(format!("BluetoothDevice FromId: {e}")))? }; let pairing = device @@ -450,8 +474,8 @@ async fn unbond_windows(id: &str) -> Result<(), BleBondError> { let result = pairing .UnpairAsync() .map_err(|e| BleBondError::Failed(format!("UnpairAsync: {e}")))? - .await - .map_err(|e| BleBondError::Failed(format!("Unpair await: {e}")))?; + .get() + .map_err(|e| BleBondError::Failed(format!("Unpair get: {e}")))?; let status = result .Status() .map_err(|e| BleBondError::Failed(format!("Unpair status: {e}")))?; From 3560bba7205f47cdb0f68827f34148af8498af31 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 16:01:16 -0600 Subject: [PATCH 3/7] fix(reticulum): compare WinRT unpair status via enum constants DeviceUnpairingResultStatus is not Into; accept Unpaired and AlreadyUnpaired so the Windows sidecar stub build compiles. --- reticulum-sidecar/src/ble/unbond.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/reticulum-sidecar/src/ble/unbond.rs b/reticulum-sidecar/src/ble/unbond.rs index e7e547c09..220ed6c2b 100644 --- a/reticulum-sidecar/src/ble/unbond.rs +++ b/reticulum-sidecar/src/ble/unbond.rs @@ -479,8 +479,10 @@ fn unbond_windows_blocking(id: &str) -> Result<(), BleBondError> { let status = result .Status() .map_err(|e| BleBondError::Failed(format!("Unpair status: {e}")))?; - // DeviceUnpairingResultStatus::Unpaired == 1 - if i32::from(status) != 1 { + use windows::Devices::Enumeration::DeviceUnpairingResultStatus; + if status != DeviceUnpairingResultStatus::Unpaired + && status != DeviceUnpairingResultStatus::AlreadyUnpaired + { return Err(BleBondError::Failed(format!( "Windows Unpair status={status:?} (expected Unpaired)" ))); From a737b63b5ba1210b74085c8648fa7fcf0c535200 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 16:24:23 -0600 Subject: [PATCH 4/7] fix(reticulum): triage CodeRabbit bond-recovery review findings Abort Meshtastic BLE reconnect while RNode recovery holds the adapter, latch LTK purge to the newly flagged interface name, track recovery-hold application separately from the desync flag, and document the optional macOS display-name field on handle-ltk-desync. --- docs/reticulum-sidecar-ipc.md | 28 +++++++++---------- src/main/reticulum-sidecar-manager.ts | 15 +++++----- ...htasticRuntime.reconnect-hardening.test.ts | 1 + src/renderer/runtime/useMeshtasticRuntime.ts | 9 ++++++ src/renderer/runtime/useReticulumRuntime.ts | 12 +++++++- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 3ae181e90..231d2b75a 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -43,20 +43,20 @@ Failures before file replacement still return an error so callers can roll back ### Interfaces -| Method | Path | Body / notes | Response | -| ------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | -| GET | `/api/v1/interfaces` | | `{ interfaces: [], primary_local_serial_interface_id?, effective_primary_local_serial_interface_id? }` | -| POST | `/api/v1/interfaces` | `{ type, name?, host?, port?, preset?, serial_port?, callsign?, mode? }` | `{ ok, interface? }` | -| POST | `/api/v1/interfaces/primary-local-rnode` | `{ id }` — enabled locally connected serial interface (`rnode`, `rnode_multi`, `kiss` over USB, BLE, or local TCP) | `{ ok, reordered?, effective_id?, error? }` | -| PUT | `/api/v1/interfaces/{id}` | Partial patch (see below) | `{ ok, interface? }` | -| DELETE | `/api/v1/interfaces/{id}` | | `{ ok }` | -| POST | `/api/v1/interfaces/{id}/enable` | | `{ ok }` | -| POST | `/api/v1/interfaces/{id}/disable` | | `{ ok }` | -| GET | `/api/v1/rnode/presets` | | `{ presets: [] }` | -| GET | `/api/v1/serial/ports` | | `{ ports: [] }` | -| GET | `/api/v1/ble/availability` | | `{ available, missing, permissions_granted, probe_failed? }` | -| GET | `/api/v1/ble/scan` | `timeout_secs` (1–30, default 5), `mode` (`peer` \| `rnode` \| `all`) | `{ devices: [{ address, name?, rssi?, kind? }] }` or `{ ok: false, error }` | -| POST | `/api/v1/ble/handle-ltk-desync` | `{ address, error? }` — MAC / UUID / `ble://…`; optional driver error string | `{ ok, device_address, bond_purged, message, purge_error? }` + WS `BleLtkDesync` | +| Method | Path | Body / notes | Response | +| ------ | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| GET | `/api/v1/interfaces` | | `{ interfaces: [], primary_local_serial_interface_id?, effective_primary_local_serial_interface_id? }` | +| POST | `/api/v1/interfaces` | `{ type, name?, host?, port?, preset?, serial_port?, callsign?, mode? }` | `{ ok, interface? }` | +| POST | `/api/v1/interfaces/primary-local-rnode` | `{ id }` — enabled locally connected serial interface (`rnode`, `rnode_multi`, `kiss` over USB, BLE, or local TCP) | `{ ok, reordered?, effective_id?, error? }` | +| PUT | `/api/v1/interfaces/{id}` | Partial patch (see below) | `{ ok, interface? }` | +| DELETE | `/api/v1/interfaces/{id}` | | `{ ok }` | +| POST | `/api/v1/interfaces/{id}/enable` | | `{ ok }` | +| POST | `/api/v1/interfaces/{id}/disable` | | `{ ok }` | +| GET | `/api/v1/rnode/presets` | | `{ presets: [] }` | +| GET | `/api/v1/serial/ports` | | `{ ports: [] }` | +| GET | `/api/v1/ble/availability` | | `{ available, missing, permissions_granted, probe_failed? }` | +| GET | `/api/v1/ble/scan` | `timeout_secs` (1–30, default 5), `mode` (`peer` \| `rnode` \| `all`) | `{ devices: [{ address, name?, rssi?, kind? }] }` or `{ ok: false, error }` | +| POST | `/api/v1/ble/handle-ltk-desync` | `{ address, name?, error? }` — MAC / UUID / `ble://…`; `name` (OS Bluetooth display name) is required on macOS when `address` is a CoreBluetooth UUID; optional driver error string | `{ ok, device_address, bond_purged, message, purge_error? }` + WS `BleLtkDesync` | ### LoRa GATT (Meshtastic / MeshCore) diff --git a/src/main/reticulum-sidecar-manager.ts b/src/main/reticulum-sidecar-manager.ts index 06f6de29a..89226425c 100644 --- a/src/main/reticulum-sidecar-manager.ts +++ b/src/main/reticulum-sidecar-manager.ts @@ -246,18 +246,19 @@ export class ReticulumSidecarManager extends EventEmitter { } private recordSidecarOutputLine(text: string): void { - const beforeBond = this.interfaceIssueTracker.peekAlert()?.bleBondRemoved?.length ?? 0; + const beforeBond = new Set(this.interfaceIssueTracker.peekAlert()?.bleBondRemoved ?? []); this.mutateInterfaceIssues( () => { this.interfaceIssueTracker.recordLine(text); }, { alwaysEmitAfterMs: 5_000 }, ); - const afterBond = this.interfaceIssueTracker.peekAlert()?.bleBondRemoved?.length ?? 0; + const afterBond = this.interfaceIssueTracker.peekAlert()?.bleBondRemoved ?? []; + const newlyLatchedBond = afterBond.find((name) => !beforeBond.has(name)); // Dispose LoRa CBCentralManager as soon as stderr latches Peer-removed — // do not wait for the renderer round-trip (bond-removed retries in ~3s). - if (afterBond > beforeBond) { - void this.handleBleLtkDesyncLatch(text).catch((err: unknown) => { + if (newlyLatchedBond) { + void this.handleBleLtkDesyncLatch(newlyLatchedBond, text).catch((err: unknown) => { console.debug( '[ReticulumSidecar] LTK desync latch handler failed:', err instanceof Error ? err.message : String(err), @@ -270,7 +271,7 @@ export class ReticulumSidecarManager extends EventEmitter { * Peer-removed / LTK desync: pause LoRa GATT, purge the OS bond when possible, * and let the sidecar emit `BleLtkDesync` for the renderer toast / re-pair UX. */ - private async handleBleLtkDesyncLatch(line: string): Promise { + private async handleBleLtkDesyncLatch(interfaceName: string, line: string): Promise { try { const { gattSidecarProxy } = await import('./gatt-sidecar-proxy'); await gattSidecarProxy.releaseBleCentral(); @@ -287,10 +288,8 @@ export class ReticulumSidecarManager extends EventEmitter { const listed = (await this.proxyGet('/api/v1/interfaces')) as { interfaces?: { name?: string; serial_port?: string | null }[]; }; - const alert = this.interfaceIssueTracker.peekAlert(); - const names = new Set(alert?.bleBondRemoved ?? []); for (const row of listed.interfaces ?? []) { - if (typeof row.name === 'string' && names.has(row.name) && row.serial_port) { + if (row.name === interfaceName && row.serial_port) { address = row.serial_port; deviceName = row.name; break; diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index aae8b4ced..4c39222a5 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -289,6 +289,7 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { expect(SOURCE).toContain('meshtasticRfReconnectRef'); const scheduleBody = extractUseCallbackBody(SOURCE, 'scheduleMeshtasticReconnectAttempt'); expect(scheduleBody).toContain('scheduleOwner'); + expect(scheduleBody).toContain('getReticulumBleBondDesyncActive()'); expect(scheduleBody).toContain('attemptReconnectRef.current()'); expect(SOURCE).toMatch( /useLayoutEffect\(\(\) => \{\s*scheduleMeshtasticReconnectAttemptRef\.current = scheduleMeshtasticReconnectAttempt;\s*\}, \[scheduleMeshtasticReconnectAttempt\]\)/, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index a04ed2d0e..d1eaa5c50 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -2525,6 +2525,15 @@ export function useMeshtasticRuntime() { const scheduleMeshtasticReconnectAttempt = useCallback(() => { meshtasticRfReconnectRef.current.scheduleOwner(() => { + if (connectionParamsRef.current?.type === 'ble' && getReticulumBleBondDesyncActive()) { + console.debug( + '[useMeshtasticRuntime] abort reconnect schedule — RNode bond recovery holds the adapter', + ); + isReconnectingRef.current = false; + meshtasticDeferredReconnectRef.current = false; + meshtasticRfReconnectRef.current.endAttempt(); + return; + } if (!isReconnectingRef.current || meshtasticExplicitDisconnectRef.current) { return; } diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 876274175..bb81dff02 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -325,6 +325,12 @@ export function useReticulumRuntime(): ProtocolRuntime { const powerSuspendHadBleRnodeRef = useRef(false); /** True while we are holding LoRa GATT exclusive for an online BLE RNode. */ const rnodeBleOnlineLoRaHoldRef = useRef(false); + /** + * True once this recovery episode has run releaseGattBleCentral + scan-lease hold. + * Distinct from {@link getReticulumBleBondDesyncActive}: `BleLtkDesync` can set the + * shared flag before `onStatus` arrives, which must not skip the hold setup. + */ + const bondRecoveryHoldAppliedRef = useRef(false); /** * Bumped on every power-suspend so a `connect()` flight started before an earlier suspend * (and still in flight when a *later* suspend/resume pair fires) can detect it has been @@ -1717,6 +1723,7 @@ export function useReticulumRuntime(): ProtocolRuntime { propagationHydratedForBridgeRef.current = false; linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); + bondRecoveryHoldAppliedRef.current = false; setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); syncConnectionStore(INITIAL_STATE); @@ -1729,11 +1736,12 @@ export function useReticulumRuntime(): ProtocolRuntime { // centrals in the same sidecar precipitate repeated CBError 14 — pause LoRa BLE // instead of releasing the lease (which nudges MeshCore/Meshtastic to reconnect). if (bondRemoved.length > 0) { - const firstLatch = !getReticulumBleBondDesyncActive(); + const firstLatch = !bondRecoveryHoldAppliedRef.current; setReticulumBleBondDesyncActive(true); // Only dispose once per episode — re-entrant status updates were recreating // the LoRa CBCentralManager between RNode retries (had_backend:true again). if (firstLatch) { + bondRecoveryHoldAppliedRef.current = true; void (async () => { try { await window.electronAPI.releaseGattBleCentral(); @@ -1755,6 +1763,7 @@ export function useReticulumRuntime(): ProtocolRuntime { } } else if (getReticulumBleBondDesyncActive()) { setReticulumBleBondDesyncActive(false); + bondRecoveryHoldAppliedRef.current = false; void window.electronAPI.clearGattBondRecoveryExclusive().catch((e: unknown) => { console.debug( '[useReticulumRuntime] clearGattBondRecoveryExclusive ' + errLikeToLogString(e), @@ -2059,6 +2068,7 @@ export function useReticulumRuntime(): ProtocolRuntime { propagationHydratedForBridgeRef.current = false; linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); + bondRecoveryHoldAppliedRef.current = false; setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); syncConnectionStore(INITIAL_STATE); From ef9a1fbc5eb6a9199657b68e07d06b9e0a262bb7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 22:26:31 +0000 Subject: [PATCH 5/7] test(reticulum): assert bond-recovery hold latch in runtime contract Lock the bondRecoveryHoldAppliedRef firstLatch path so BleLtkDesync cannot silently skip the Reticulum scan lease without a source-contract failure. --- .../runtime/useReticulumRuntime.reconnect-hardening.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index d86b10ae8..13fc31c77 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -196,6 +196,9 @@ describe('useReticulumRuntime resume-generation cancel (H7)', () => { expect(SOURCE).toMatch(/releaseGattBleCentral\(\)/); expect(SOURCE).toMatch(/prepareReticulumBleRnodeConnect\(\)/); expect(SOURCE).toMatch(/status\.interfaceIssueAlert\?\.bleBondRemoved/); + // Hold application is tracked separately so BleLtkDesync WS cannot skip the lease. + expect(SOURCE).toContain('bondRecoveryHoldAppliedRef'); + expect(SOURCE).toMatch(/const firstLatch = !bondRecoveryHoldAppliedRef\.current/); }); it('wires LXMF send rekey with replacesMessageHash for pending orphan cleanup', () => { From 3f6fccf40664be614ebcce0f8dbce8ba67500f68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 20 Sep 2026 22:44:16 +0000 Subject: [PATCH 6/7] docs(reticulum): document BleLtkDesync WebSocket event contract List BleLtkDesync on shared /ws with payload fields matching sidecar emission and renderer handling. --- docs/reticulum-sidecar-ipc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 231d2b75a..88289cd1b 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -302,7 +302,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`, plus LRGP games: `games.update` / `games.action_result` (turn-based; stay on shared `/ws`). +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), `BleLtkDesync` (BLE RNode LTK / Peer-removed recovery; payload `{ device_address, bond_purged, message, purge_error? }`), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`, plus LRGP games: `games.update` / `games.action_result` (turn-based; stay on shared `/ws`). **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. From 9518e3e32c7d5da1ede069ae7151163de3265c3e Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Sun, 20 Sep 2026 16:46:09 -0600 Subject: [PATCH 7/7] fix(reticulum): supersede in-flight BLE reconnect during bond recovery Cancel/bump Meshtastic reconnect generation when RNode recovery latches, recheck the latch after open/configure boundaries, invalidate stale prepareReticulumBleRnodeConnect work with a recovery generation, and document BleLtkDesync on the shared WebSocket contract. --- docs/reticulum-sidecar-ipc.md | 2 +- ...htasticRuntime.reconnect-hardening.test.ts | 1 + src/renderer/runtime/useMeshtasticRuntime.ts | 21 +++++++++++++------ ...ticulumRuntime.reconnect-hardening.test.ts | 2 ++ src/renderer/runtime/useReticulumRuntime.ts | 21 +++++++++++++++++++ 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 231d2b75a..dcca4a0ba 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -302,7 +302,7 @@ Listener persistence: a successful `POST /api/v1/rncp/listener` stores the confi { "type": "lxmf_message", "payload": { ... } } ``` -Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`, plus LRGP games: `games.update` / `games.action_result` (turn-based; stay on shared `/ws`). +Event types: `lxmf_message`, `lxmf_outbound_status`, `events_lagged` (WS subscriber skipped N broadcast frames — client should `GET /api/v1/lxmf/recent`), `announce.received`, `peers_updated`, `path_medium_preference` (global preference changed; payload `{ preference }`), `stats_update`, `interface.state`, `stack_restart_requested`, `propagation_sync`, `propagation.discovered` (heard `lxmf.propagation` announce), `resource.received`, `rmap.discovery` (payload `{ discovered: RmapDiscoveredWireRow[] }`), `nomadnetwork.node` (Nomad peer announce heard), `nomad.serving_start` / `nomad.serving_stop` (local hosting lifecycle; payload includes `destination_hash` / `display_name` on start — renderer currently polls serving status via HTTP), `BleLtkDesync` (BLE RNode LTK / Peer-removed recovery; payload `{ device_address, bond_purged, message, purge_error? }` — emitted after `POST /api/v1/ble/handle-ltk-desync`), RRC: `rrc.hub`, `rrc.connected`, `rrc.disconnected`, `rrc.room.joined`, `rrc.room.parted`, `rrc.message`, `rrc.error`, plus Remote: `rnsh.stdout` / `rnsh.stderr` / `rnsh.status` / `rnsh.closed` / `rnsh.error`, `rncp.offer` / `rncp.progress` / `rncp.completed` / `rncp.failed` / `rncp.cancelled`, plus LXST voice signalling: `voice.update` / `voice.incoming` / `voice.terminated` / `voice.error` / `voice.stats`, plus LRGP games: `games.update` / `games.action_result` (turn-based; stay on shared `/ws`). **Note:** Live `wire_packet` frames are **not** pushed on `/ws` (they starved critical `lxmf_message` events on large meshes). Sniffer/Stats poll `GET /api/v1/packets` while those panels are mounted. PacketTap rows still feed the sidecar packet log and LXMF egress evidence. diff --git a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts index 4c39222a5..395daa566 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.reconnect-hardening.test.ts @@ -290,6 +290,7 @@ describe('useMeshtasticRuntime reconnect hardening (regression)', () => { const scheduleBody = extractUseCallbackBody(SOURCE, 'scheduleMeshtasticReconnectAttempt'); expect(scheduleBody).toContain('scheduleOwner'); expect(scheduleBody).toContain('getReticulumBleBondDesyncActive()'); + expect(scheduleBody).toContain('meshtasticRfReconnectRef.current.cancel()'); expect(scheduleBody).toContain('attemptReconnectRef.current()'); expect(SOURCE).toMatch( /useLayoutEffect\(\(\) => \{\s*scheduleMeshtasticReconnectAttemptRef\.current = scheduleMeshtasticReconnectAttempt;\s*\}, \[scheduleMeshtasticReconnectAttempt\]\)/, diff --git a/src/renderer/runtime/useMeshtasticRuntime.ts b/src/renderer/runtime/useMeshtasticRuntime.ts index d1eaa5c50..40d31f9cf 100644 --- a/src/renderer/runtime/useMeshtasticRuntime.ts +++ b/src/renderer/runtime/useMeshtasticRuntime.ts @@ -2303,7 +2303,13 @@ export function useMeshtasticRuntime() { }, runOpenAndAttach: async (ctx, params) => { const { generation, attemptActive, lateTransport } = ctx; - if (reconnectGenerationRef.current !== generation || !attemptActive()) { + const bleBondRecoveryBlocks = () => + params.type === 'ble' && getReticulumBleBondDesyncActive(); + const isSuperseded = () => + reconnectGenerationRef.current !== generation || + !attemptActive() || + bleBondRecoveryBlocks(); + if (isSuperseded()) { throw new Error('Reconnect superseded before open'); } const opened = await openMeshtasticTransport(params.type, { @@ -2312,7 +2318,7 @@ export function useMeshtasticRuntime() { lastSerialPortId: params.lastSerialPortId, }); openedDriverIdentityId = opened.driverIdentityId; - if (reconnectGenerationRef.current !== generation || !attemptActive()) { + if (isSuperseded()) { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('Reconnect superseded after open'); } @@ -2320,7 +2326,7 @@ export function useMeshtasticRuntime() { wireSubscriptions(opened.device, params.type, { driverIdentityId: opened.driverIdentityId, }); - if (reconnectGenerationRef.current !== generation || !attemptActive()) { + if (isSuperseded()) { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('Reconnect superseded before configure'); } @@ -2331,7 +2337,7 @@ export function useMeshtasticRuntime() { logTag: 'useMeshtasticRuntime reconnect', }); - if (reconnectGenerationRef.current !== generation || !attemptActive()) { + if (isSuperseded()) { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('Reconnect superseded during configure'); } @@ -2339,7 +2345,7 @@ export function useMeshtasticRuntime() { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('RF link lost after reconnect configure'); } - if (!attemptActive() || reconnectGenerationRef.current !== generation) { + if (isSuperseded()) { await lateTransport.cleanup(opened.driverIdentityId); throw new Error('Reconnect superseded after configure'); } @@ -2529,9 +2535,12 @@ export function useMeshtasticRuntime() { console.debug( '[useMeshtasticRuntime] abort reconnect schedule — RNode bond recovery holds the adapter', ); + // cancel() bumps controller generation; sync reconnectGenerationRef so an in-flight + // runOpenAndAttach sees supersession (endAttempt alone leaves generation unchanged). + meshtasticRfReconnectRef.current.cancel(); + reconnectGenerationRef.current = meshtasticRfReconnectRef.current.generation; isReconnectingRef.current = false; meshtasticDeferredReconnectRef.current = false; - meshtasticRfReconnectRef.current.endAttempt(); return; } if (!isReconnectingRef.current || meshtasticExplicitDisconnectRef.current) { diff --git a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts index 13fc31c77..f64e123f4 100644 --- a/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.reconnect-hardening.test.ts @@ -198,7 +198,9 @@ describe('useReticulumRuntime resume-generation cancel (H7)', () => { expect(SOURCE).toMatch(/status\.interfaceIssueAlert\?\.bleBondRemoved/); // Hold application is tracked separately so BleLtkDesync WS cannot skip the lease. expect(SOURCE).toContain('bondRecoveryHoldAppliedRef'); + expect(SOURCE).toContain('bondRecoveryGenerationRef'); expect(SOURCE).toMatch(/const firstLatch = !bondRecoveryHoldAppliedRef\.current/); + expect(SOURCE).toMatch(/releaseReticulumBleRnodeConnect\(\{\s*notify:\s*false\s*\}\)/); }); it('wires LXMF send rekey with replacesMessageHash for pending orphan cleanup', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index bb81dff02..4a05d0fe1 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -331,6 +331,11 @@ export function useReticulumRuntime(): ProtocolRuntime { * shared flag before `onStatus` arrives, which must not skip the hold setup. */ const bondRecoveryHoldAppliedRef = useRef(false); + /** + * Bumped when bond recovery clears / disconnect / teardown so a stale recovery IIFE + * cannot reacquire the Reticulum scan lease after release. + */ + const bondRecoveryGenerationRef = useRef(0); /** * Bumped on every power-suspend so a `connect()` flight started before an earlier suspend * (and still in flight when a *later* suspend/resume pair fires) can detect it has been @@ -1724,6 +1729,7 @@ export function useReticulumRuntime(): ProtocolRuntime { linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); bondRecoveryHoldAppliedRef.current = false; + bondRecoveryGenerationRef.current += 1; setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); syncConnectionStore(INITIAL_STATE); @@ -1742,6 +1748,7 @@ export function useReticulumRuntime(): ProtocolRuntime { // the LoRa CBCentralManager between RNode retries (had_backend:true again). if (firstLatch) { bondRecoveryHoldAppliedRef.current = true; + const recoveryGeneration = bondRecoveryGenerationRef.current; void (async () => { try { await window.electronAPI.releaseGattBleCentral(); @@ -1751,6 +1758,9 @@ export function useReticulumRuntime(): ProtocolRuntime { errLikeToLogString(e), ); } + if (bondRecoveryGenerationRef.current !== recoveryGeneration) { + return; + } try { await prepareReticulumBleRnodeConnect(); } catch (e: unknown) { @@ -1758,12 +1768,22 @@ export function useReticulumRuntime(): ProtocolRuntime { '[useReticulumRuntime] hold scan lease during bond recovery ' + errLikeToLogString(e), ); + return; + } + if (bondRecoveryGenerationRef.current !== recoveryGeneration) { + void releaseReticulumBleRnodeConnect({ notify: false }).catch((e: unknown) => { + console.debug( + '[useReticulumRuntime] release stale bond-recovery lease ' + + errLikeToLogString(e), + ); + }); } })(); } } else if (getReticulumBleBondDesyncActive()) { setReticulumBleBondDesyncActive(false); bondRecoveryHoldAppliedRef.current = false; + bondRecoveryGenerationRef.current += 1; void window.electronAPI.clearGattBondRecoveryExclusive().catch((e: unknown) => { console.debug( '[useReticulumRuntime] clearGattBondRecoveryExclusive ' + errLikeToLogString(e), @@ -2069,6 +2089,7 @@ export function useReticulumRuntime(): ProtocolRuntime { linkTimeoutBridgeGenerationRef.current += 1; setReticulumBleBondDesyncActive(false); bondRecoveryHoldAppliedRef.current = false; + bondRecoveryGenerationRef.current += 1; setReticulumAnnounceBusPressureActive(false); setState(INITIAL_STATE); syncConnectionStore(INITIAL_STATE);