Skip to content

Commit e8c1d3d

Browse files
authored
Add repair issue for Bluetooth adapter passive mode fallback (home-assistant#152076)
1 parent 46463ea commit e8c1d3d

File tree

3 files changed

+198
-4
lines changed

3 files changed

+198
-4
lines changed

homeassistant/components/bluetooth/manager.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,19 @@
88
import logging
99

1010
from bleak_retry_connector import BleakSlotManager
11-
from bluetooth_adapters import BluetoothAdapters, adapter_human_name, adapter_model
12-
from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, HaScanner
11+
from bluetooth_adapters import (
12+
ADAPTER_TYPE,
13+
BluetoothAdapters,
14+
adapter_human_name,
15+
adapter_model,
16+
)
17+
from habluetooth import (
18+
BaseHaRemoteScanner,
19+
BaseHaScanner,
20+
BluetoothManager,
21+
BluetoothScanningMode,
22+
HaScanner,
23+
)
1324

1425
from homeassistant import config_entries
1526
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
@@ -326,7 +337,53 @@ def on_scanner_start(self, scanner: BaseHaScanner) -> None:
326337
# Only handle repair issues for local adapters (HaScanner instances)
327338
if not isinstance(scanner, HaScanner):
328339
return
340+
self.async_check_degraded_mode(scanner)
341+
self.async_check_scanning_mode(scanner)
329342

343+
@hass_callback
344+
def async_check_scanning_mode(self, scanner: HaScanner) -> None:
345+
"""Check if the scanner is running in passive mode when active mode is requested."""
346+
passive_mode_issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
347+
348+
# Check if scanner is NOT in passive mode when active mode was requested
349+
if not (
350+
scanner.requested_mode is BluetoothScanningMode.ACTIVE
351+
and scanner.current_mode is BluetoothScanningMode.PASSIVE
352+
):
353+
# Delete passive mode issue if it exists and we're not in passive fallback
354+
ir.async_delete_issue(self.hass, DOMAIN, passive_mode_issue_id)
355+
return
356+
357+
# Create repair issue for passive mode fallback
358+
adapter_name = adapter_human_name(
359+
scanner.adapter, scanner.mac_address or "00:00:00:00:00:00"
360+
)
361+
adapter_details = self._bluetooth_adapters.adapters.get(scanner.adapter)
362+
model = adapter_model(adapter_details) if adapter_details else None
363+
364+
# Determine adapter type for specific instructions
365+
# Default to USB for any other type or unknown
366+
if adapter_details and adapter_details.get(ADAPTER_TYPE) == "uart":
367+
translation_key = "bluetooth_adapter_passive_mode_uart"
368+
else:
369+
translation_key = "bluetooth_adapter_passive_mode_usb"
370+
371+
ir.async_create_issue(
372+
self.hass,
373+
DOMAIN,
374+
passive_mode_issue_id,
375+
is_fixable=False, # Requires a reboot or unplug
376+
severity=ir.IssueSeverity.WARNING,
377+
translation_key=translation_key,
378+
translation_placeholders={
379+
"adapter": adapter_name,
380+
"model": model or "Unknown",
381+
},
382+
)
383+
384+
@hass_callback
385+
def async_check_degraded_mode(self, scanner: HaScanner) -> None:
386+
"""Check if we are in degraded mode and create/delete repair issues."""
330387
issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}"
331388

332389
# Delete any existing issue if not in degraded mode

homeassistant/components/bluetooth/strings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
"bluetooth_adapter_missing_permissions": {
4444
"title": "Bluetooth adapter requires additional permissions",
4545
"description": "The Bluetooth adapter **{adapter}** ({model}) is operating in degraded mode because your container needs additional permissions to fully access Bluetooth hardware.\n\nPlease follow the instructions in our documentation to add the required permissions:\n[Bluetooth permissions for Docker]({docs_url})\n\nAfter adding these permissions, restart your Home Assistant container for the changes to take effect."
46+
},
47+
"bluetooth_adapter_passive_mode_usb": {
48+
"title": "Bluetooth USB adapter requires manual power cycle",
49+
"description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the adapter requires a manual power cycle to recover.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Unplug the USB adapter**\n2. Wait 5 seconds\n3. **Plug it back in**\n4. Wait for Home Assistant to detect the adapter\n\nIf the issue persists after power cycling:\n- Try a different USB port\n- Check for kernel/firmware updates\n- Consider using a different Bluetooth adapter"
50+
},
51+
"bluetooth_adapter_passive_mode_uart": {
52+
"title": "Bluetooth adapter requires system power cycle",
53+
"description": "The Bluetooth adapter **{adapter}** ({model}) is stuck in passive scanning mode despite requesting active scanning mode. **Automatic recovery was attempted but failed.** This is likely a kernel, firmware, or operating system issue, and the system requires a complete power cycle to recover the adapter.\n\nIn passive mode, the adapter can only receive advertisements but cannot request additional data from devices, which will affect device discovery and functionality.\n\n**Manual intervention required:**\n1. **Shut down the system completely** (not just a reboot)\n2. **Remove power** (unplug or turn off at the switch)\n3. Wait 10 seconds\n4. Restore power and boot the system\n\nIf the issue persists after power cycling:\n- Check for kernel/firmware updates\n- The onboard Bluetooth adapter may have hardware issues"
4654
}
4755
}
4856
}

tests/components/bluetooth/test_manager.py

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from bleak.backends.scanner import AdvertisementData, BLEDevice
99
from bluetooth_adapters import AdvertisementHistory
1010
from freezegun import freeze_time
11-
from habluetooth import HaScanner
11+
from habluetooth import BluetoothScanningMode, HaScanner
1212

1313
# pylint: disable-next=no-name-in-module
1414
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
@@ -21,7 +21,6 @@
2121
MONOTONIC_TIME,
2222
BaseHaRemoteScanner,
2323
BluetoothChange,
24-
BluetoothScanningMode,
2524
BluetoothServiceInfo,
2625
BluetoothServiceInfoBleak,
2726
HaBluetoothConnector,
@@ -1911,3 +1910,133 @@ async def test_no_repair_issue_for_remote_scanner(
19111910
and "bluetooth_adapter_missing_permissions" in issue.issue_id
19121911
]
19131912
assert len(issues) == 0
1913+
1914+
1915+
@pytest.mark.usefixtures("one_adapter")
1916+
async def test_repair_issue_created_for_passive_mode_fallback(
1917+
hass: HomeAssistant,
1918+
) -> None:
1919+
"""Test repair issue is created when scanner falls back to passive mode."""
1920+
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
1921+
await hass.async_block_till_done()
1922+
1923+
manager = _get_manager()
1924+
1925+
scanner = HaScanner(
1926+
mode=BluetoothScanningMode.ACTIVE,
1927+
adapter="hci0",
1928+
address="00:11:22:33:44:55",
1929+
)
1930+
scanner.async_setup()
1931+
1932+
cancel = manager.async_register_scanner(scanner, connection_slots=1)
1933+
1934+
# Set scanner to passive mode when active was requested
1935+
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
1936+
scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
1937+
1938+
manager.on_scanner_start(scanner)
1939+
1940+
# Check repair issue is created
1941+
issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
1942+
registry = ir.async_get(hass)
1943+
issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
1944+
assert issue is not None
1945+
assert issue.severity == ir.IssueSeverity.WARNING
1946+
# Should default to USB translation key when adapter type is unknown
1947+
assert issue.translation_key == "bluetooth_adapter_passive_mode_usb"
1948+
assert not issue.is_fixable
1949+
1950+
cancel()
1951+
1952+
1953+
async def test_repair_issue_created_for_passive_mode_fallback_uart(
1954+
hass: HomeAssistant,
1955+
) -> None:
1956+
"""Test repair issue is created with UART-specific message for UART adapters."""
1957+
with patch(
1958+
"bluetooth_adapters.systems.linux.LinuxAdapters.adapters",
1959+
{
1960+
"hci0": {
1961+
"address": "00:11:22:33:44:55",
1962+
"sw_version": "homeassistant",
1963+
"hw_version": "uart:bcm2711",
1964+
"passive_scan": False,
1965+
"manufacturer": "Raspberry Pi",
1966+
"product": "BCM2711",
1967+
"adapter_type": "uart", # UART adapter type
1968+
}
1969+
},
1970+
):
1971+
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
1972+
await hass.async_block_till_done()
1973+
1974+
manager = _get_manager()
1975+
1976+
scanner = HaScanner(
1977+
mode=BluetoothScanningMode.ACTIVE,
1978+
adapter="hci0",
1979+
address="00:11:22:33:44:55",
1980+
)
1981+
scanner.async_setup()
1982+
1983+
cancel = manager.async_register_scanner(scanner, connection_slots=1)
1984+
1985+
# Set scanner to passive mode when active was requested
1986+
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
1987+
scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
1988+
1989+
manager.on_scanner_start(scanner)
1990+
1991+
# Check repair issue is created with UART-specific translation key
1992+
issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
1993+
registry = ir.async_get(hass)
1994+
issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
1995+
assert issue is not None
1996+
assert issue.severity == ir.IssueSeverity.WARNING
1997+
assert issue.translation_key == "bluetooth_adapter_passive_mode_uart"
1998+
assert not issue.is_fixable
1999+
2000+
cancel()
2001+
2002+
2003+
@pytest.mark.usefixtures("one_adapter")
2004+
async def test_repair_issue_deleted_when_passive_mode_resolved(
2005+
hass: HomeAssistant,
2006+
) -> None:
2007+
"""Test repair issue is deleted when scanner no longer in passive mode."""
2008+
assert await async_setup_component(hass, bluetooth.DOMAIN, {})
2009+
await hass.async_block_till_done()
2010+
2011+
manager = _get_manager()
2012+
2013+
scanner = HaScanner(
2014+
mode=BluetoothScanningMode.ACTIVE,
2015+
adapter="hci0",
2016+
address="00:11:22:33:44:55",
2017+
)
2018+
scanner.async_setup()
2019+
2020+
cancel = manager.async_register_scanner(scanner, connection_slots=1)
2021+
2022+
# Initially set scanner to passive mode when active was requested
2023+
scanner.set_requested_mode(BluetoothScanningMode.ACTIVE)
2024+
scanner.set_current_mode(BluetoothScanningMode.PASSIVE)
2025+
2026+
manager.on_scanner_start(scanner)
2027+
2028+
# Check repair issue is created
2029+
issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}"
2030+
registry = ir.async_get(hass)
2031+
issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
2032+
assert issue is not None
2033+
2034+
# Now simulate scanner recovering to active mode
2035+
scanner.set_current_mode(BluetoothScanningMode.ACTIVE)
2036+
manager.on_scanner_start(scanner)
2037+
2038+
# Check repair issue is deleted
2039+
issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id)
2040+
assert issue is None
2041+
2042+
cancel()

0 commit comments

Comments
 (0)