Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
186 changes: 174 additions & 12 deletions src/ble_reticulum/linux_bluetooth_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -680,20 +680,110 @@ def detection_callback(device, advertisement_data):
raise

# Detect scanner callback corruption
#
# A service-filtered scan seeing nothing is NOT by itself evidence the
# adapter is wedged: at boot (and whenever no Reticulum peer is in
# range) it is normal for the filtered scan to return zero callbacks,
# because the peer's GATT server takes a few seconds to start
# advertising after ours. Declaring that a "corrupted / reboot
# required" stack is a false positive that fires on_error(critical) and
# tears the interface down right in the window the peer is coming up.
#
# Disambiguate with a short UNFILTERED health scan: if the adapter can
# see ANY device at all, it is alive and we are simply waiting for a
# Reticulum peer to advertise. Only a fully blind adapter (unfiltered
# scan also empty) counts as a genuine wedge.
if callback_count[0] == 0:
self.consecutive_empty_scans += 1
self._log(f"⚠️ Scanner corruption detected: 0 callbacks after {scan_time}s scan (streak: {self.consecutive_empty_scans})", "WARNING")

if self.consecutive_empty_scans >= 3:
self._log("⚠️ CRITICAL: Bleak scanner callbacks not firing", "ERROR")
self._log("⚠️ Bluetooth/BlueZ/D-Bus state is corrupted", "ERROR")
self._log("⚠️ System reboot required to restore BLE scanning", "ERROR")
# Re-check connection state before starting a SECOND scan. The
# pause check at the top of _perform_scan ran before the main scan;
# a connection could have started during the main scan window. If so,
# running the unfiltered health scan now would start another BlueZ
# scan mid-connection and trigger an "Operation already in progress"
# error. Skip the health scan this cycle (it will run next cycle);
# treat the filtered result as-is without changing the streak.
if self._should_pause_scanning():
self._log("Pausing health scan: connection(s) in progress", "DEBUG")
return
health_devices = await self._adapter_health_check()
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if health_devices > 0:
if self.consecutive_empty_scans > 0:
self._log(f"✓ Scanner callbacks resumed after {self.consecutive_empty_scans} empty scans", "INFO")
self.consecutive_empty_scans = 0
self._log(
f"✓ No Reticulum peer advertising, but adapter is healthy "
f"(unfiltered health scan saw {health_devices} device(s)) - not a wedge",
"INFO"
)
elif health_devices < 0:
# The health scan itself FAILED (D-Bus/adapter error). A scan
# that cannot even run is direct evidence the adapter is in a
# bad state - unlike a clean zero, which in a quiet RF
# environment may be benign. Escalate regardless of whether we
# ever saw a healthy scan.
self.consecutive_empty_scans += 1
Comment thread
greptile-apps[bot] marked this conversation as resolved.
self._log(
f"⚠️ Unfiltered health scan FAILED (adapter in fault state) "
f"(streak: {self.consecutive_empty_scans})", "WARNING"
)
if self.consecutive_empty_scans >= 3:
self._log("⚠️ CRITICAL: unfiltered health scan repeatedly failing (adapter wedged)", "ERROR")
self._log("⚠️ Bluetooth/BlueZ/D-Bus state is corrupted", "ERROR")
self._log("⚠️ System reboot required to restore BLE scanning", "ERROR")
if self.on_error:
self.on_error("critical",
f"Adapter health scan has failed for {self.consecutive_empty_scans} "
f"consecutive scans (adapter in fault state). Bluetooth stack requires reboot.",
Exception("BleakScanner health scan failed"))
else:
# Clean zero: unfiltered scan ran but saw no devices at all.
# An empty scan CANNOT by itself distinguish a genuinely quiet
# RF environment (adapter healthy, simply no BLE devices in
# range) from a dead/wedged adapter (no devices because the
# adapter is down). Both look identical by device count.
#
# Resolve it with the adapter's OWN Powered state (independent
# of what it can see): a working adapter in a quiet room reads
# Powered=True; a powered-off/dead one reads Powered=False.
# Only escalate to a "reboot required" critical on POSITIVE
# fault evidence (adapter present but not powered). A powered
# adapter, or an unknown state, only warns - never cry wolf.
self.consecutive_empty_scans += 1
self._log(
f"⚠️ Scanner blind: 0 Reticulum callbacks AND 0 devices in unfiltered "
f"health scan (streak: {self.consecutive_empty_scans})", "WARNING"
)

if self.on_error:
self.on_error("critical",
f"Scanner callback failure detected (0 callbacks for {self.consecutive_empty_scans} consecutive scans). "
"Bluetooth stack requires reboot.",
Exception("BleakScanner callbacks not invoked"))
if self.consecutive_empty_scans >= 3:
powered = await self._adapter_is_powered()
if powered is False:
# The adapter is present on the bus but not powered
# while we need it to scan. That is genuine fault
# evidence (not a quiet room - a working adapter in a
# quiet room is powered). This covers both an adapter
# that was never healthy and one that went down.
self._log("⚠️ CRITICAL: adapter is not powered but discovery is running (adapter wedged/powered-off)", "ERROR")
self._log("⚠️ Bluetooth/BlueZ state is inconsistent", "ERROR")
self._log("⚠️ Reboot (or 'bluetoothctl power on') required to restore BLE scanning", "ERROR")
if self.on_error:
self.on_error("critical",
f"Adapter is not powered after {self.consecutive_empty_scans} "
f"consecutive blind scans (0 devices, adapter Powered=False). "
f"Bluetooth stack requires reboot or manual power-on.",
Exception("Adapter not powered during discovery"))
else:
# powered is True (working adapter in a quiet room) or
# None (could not determine state). No positive fault
# evidence - warn without mandating a reboot so a
# healthy, self-recovering adapter is never torn down
# over a quiet environment.
state = "powered" if powered is True else "state unknown"
self._log(
f"⚠️ No BLE devices in {self.consecutive_empty_scans} consecutive scans "
f"but adapter is {state}. Likely a quiet RF environment or a "
f"transient wedge that will recover on its own; not escalating to a "
f"reboot-required critical.",
"WARNING"
)
else:
# Reset counter on successful callback
if self.consecutive_empty_scans > 0:
Expand Down Expand Up @@ -736,6 +826,78 @@ def detection_callback(device, advertisement_data):
else:
self._log(f"✗ {device.address} ({device.name or 'Unknown'}): service UUID mismatch (has {adv_data.service_uuids}, want {self.service_uuid})", "EXTRA")

async def _adapter_health_check(self) -> int:
"""Run a short UNFILTERED scan to distinguish "no Reticulum peer in
range" (adapter healthy) from "adapter blind" (genuine wedge).

The main discovery scan is service-filtered, so it returns zero
callbacks whenever no peer is advertising our Reticulum service - which
is the normal state at boot before the peer's GATT server starts
advertising. That is not evidence of a wedged adapter. To disambiguate,
we run a brief one-shot scan with NO service filter: if the adapter can
see any BLE device at all (including weak ones we would filter out by
RSSI), it is alive and we are simply waiting for a peer.

Returns the number of distinct devices seen. A negative value (-1)
indicates the health scan itself failed (D-Bus/adapter error) rather
than returning zero devices - the caller treats a failed scan as direct
evidence of an adapter fault (unlike a clean zero, which in a quiet RF
environment may be benign).
"""
try:
devices = await BleakScanner.discover(timeout=1.0)
count = len(devices)
self._log(f"🩺 Adapter health scan: {count} device(s) visible (unfiltered)", "DEBUG")
return count
except Exception as e:
self._log(f"⚠️ Adapter health scan failed: {e}", "WARNING")
return -1

async def _adapter_is_powered(self) -> Optional[bool]:
"""Query the adapter's OWN BlueZ "Powered" state via D-Bus.

Why this is needed: an empty unfiltered scan cannot distinguish a
genuinely quiet RF environment (adapter healthy, simply no BLE devices
in range) from a dead/wedged adapter (no devices BECAUSE the adapter is
down). Both look identical by device count. The adapter's own Powered
property is an independent signal that does not depend on what the
adapter can see:
* a working adapter in a quiet room reads Powered=True;
* a dead/powered-off adapter reads Powered=False (or its D-Bus object
is absent).
This is what lets us issue a "reboot required" critical only on genuine
fault evidence instead of on an empty scan.

Returns True (adapter powered), False (adapter present but not
powered), or None if the state could not be determined (D-Bus
unavailable, no bluez, or a query error). A None means "no positive
fault evidence" - the caller must not escalate to critical on it.
"""
if not HAS_DBUS:
return None
bus = None
try:
bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
introspection = await bus.introspect('org.bluez', self.adapter_path)
adapter_obj = bus.get_proxy_object('org.bluez', self.adapter_path, introspection)
adapter_iface = adapter_obj.get_interface('org.bluez.Adapter1')
powered = await adapter_iface.get_powered()
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
self._log(f"🩺 Adapter powered state: {powered}", "DEBUG")
return bool(powered)
except Exception as e:
# Could not determine adapter state. This alone is NOT proof the
# adapter is broken (D-Bus may be busy, adapter object not yet
# registered at boot, etc.) - report unknown and let the caller
# treat it as "no positive fault evidence."
self._log(f"⚠️ Could not determine adapter powered state: {e}", "DEBUG")
return None
finally:
if bus:
try:
bus.disconnect()
except Exception:
pass

# ========================================================================
# Advertising (Peripheral Mode)
# ========================================================================
Expand Down
Loading
Loading