Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions docs/reticulum-sidecar-ipc.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions reticulum-sidecar/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions reticulum-sidecar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,6 @@ cast_lossless = "allow"
assigning_clones = "allow"
map_unwrap_or = "allow"
case_sensitive_file_extension_comparisons = "allow"

[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = ["Devices_Bluetooth", "Devices_Enumeration", "Foundation"] }
4 changes: 2 additions & 2 deletions reticulum-sidecar/patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ----- | ----- |
Expand All @@ -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

Expand Down
21 changes: 21 additions & 0 deletions reticulum-sidecar/src/api/gatt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Arc<StackHandle>>,
) -> Json<serde_json::Value> {
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<Arc<StackHandle>>,
) -> Json<serde_json::Value> {
stack.gatt().clear_bond_recovery_hold();
Json(serde_json::json!({ "ok": true }))
}

pub async fn gatt_create_session(
State(stack): State<Arc<StackHandle>>,
Json(body): Json<CreateSessionBody>,
Expand Down
55 changes: 55 additions & 0 deletions reticulum-sidecar/src/api/interfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<Arc<StackHandle>>,
Json(body): Json<BleUnbondBody>,
) -> Json<serde_json::Value> {
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,
Expand Down
12 changes: 12 additions & 0 deletions reticulum-sidecar/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,21 @@ pub fn router(stack: Arc<StackHandle>) -> 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}",
Expand Down
163 changes: 163 additions & 0 deletions reticulum-sidecar/src/ble/error_classifier.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
}
12 changes: 12 additions & 0 deletions reticulum-sidecar/src/ble/mod.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading