From e8c1d3dc3c29bc69a71d08a167f2ff76f01bf223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 11 Sep 2025 07:52:57 -0500 Subject: [PATCH] Add repair issue for Bluetooth adapter passive mode fallback (#152076) --- homeassistant/components/bluetooth/manager.py | 61 +++++++- .../components/bluetooth/strings.json | 8 ++ tests/components/bluetooth/test_manager.py | 133 +++++++++++++++++- 3 files changed, 198 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 0365ec2449c..c43f7dd5fd7 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -8,8 +8,19 @@ import itertools import logging from bleak_retry_connector import BleakSlotManager -from bluetooth_adapters import BluetoothAdapters, adapter_human_name, adapter_model -from habluetooth import BaseHaRemoteScanner, BaseHaScanner, BluetoothManager, HaScanner +from bluetooth_adapters import ( + ADAPTER_TYPE, + BluetoothAdapters, + adapter_human_name, + adapter_model, +) +from habluetooth import ( + BaseHaRemoteScanner, + BaseHaScanner, + BluetoothManager, + BluetoothScanningMode, + HaScanner, +) from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED @@ -326,7 +337,53 @@ class HomeAssistantBluetoothManager(BluetoothManager): # Only handle repair issues for local adapters (HaScanner instances) if not isinstance(scanner, HaScanner): return + self.async_check_degraded_mode(scanner) + self.async_check_scanning_mode(scanner) + @hass_callback + def async_check_scanning_mode(self, scanner: HaScanner) -> None: + """Check if the scanner is running in passive mode when active mode is requested.""" + passive_mode_issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + + # Check if scanner is NOT in passive mode when active mode was requested + if not ( + scanner.requested_mode is BluetoothScanningMode.ACTIVE + and scanner.current_mode is BluetoothScanningMode.PASSIVE + ): + # Delete passive mode issue if it exists and we're not in passive fallback + ir.async_delete_issue(self.hass, DOMAIN, passive_mode_issue_id) + return + + # Create repair issue for passive mode fallback + adapter_name = adapter_human_name( + scanner.adapter, scanner.mac_address or "00:00:00:00:00:00" + ) + adapter_details = self._bluetooth_adapters.adapters.get(scanner.adapter) + model = adapter_model(adapter_details) if adapter_details else None + + # Determine adapter type for specific instructions + # Default to USB for any other type or unknown + if adapter_details and adapter_details.get(ADAPTER_TYPE) == "uart": + translation_key = "bluetooth_adapter_passive_mode_uart" + else: + translation_key = "bluetooth_adapter_passive_mode_usb" + + ir.async_create_issue( + self.hass, + DOMAIN, + passive_mode_issue_id, + is_fixable=False, # Requires a reboot or unplug + severity=ir.IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders={ + "adapter": adapter_name, + "model": model or "Unknown", + }, + ) + + @hass_callback + def async_check_degraded_mode(self, scanner: HaScanner) -> None: + """Check if we are in degraded mode and create/delete repair issues.""" issue_id = f"bluetooth_adapter_missing_permissions_{scanner.source}" # Delete any existing issue if not in degraded mode diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index 904f8636ff2..5cbc3992f16 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -43,6 +43,14 @@ "bluetooth_adapter_missing_permissions": { "title": "Bluetooth adapter requires additional permissions", "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." + }, + "bluetooth_adapter_passive_mode_usb": { + "title": "Bluetooth USB adapter requires manual power cycle", + "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" + }, + "bluetooth_adapter_passive_mode_uart": { + "title": "Bluetooth adapter requires system power cycle", + "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" } } } diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 54e83007816..a9aa900e4a3 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -8,7 +8,7 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory from freezegun import freeze_time -from habluetooth import HaScanner +from habluetooth import BluetoothScanningMode, HaScanner # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -21,7 +21,6 @@ from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, - BluetoothScanningMode, BluetoothServiceInfo, BluetoothServiceInfoBleak, HaBluetoothConnector, @@ -1911,3 +1910,133 @@ async def test_no_repair_issue_for_remote_scanner( and "bluetooth_adapter_missing_permissions" in issue.issue_id ] assert len(issues) == 0 + + +@pytest.mark.usefixtures("one_adapter") +async def test_repair_issue_created_for_passive_mode_fallback( + hass: HomeAssistant, +) -> None: + """Test repair issue is created when scanner falls back to passive mode.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + manager = _get_manager() + + scanner = HaScanner( + mode=BluetoothScanningMode.ACTIVE, + adapter="hci0", + address="00:11:22:33:44:55", + ) + scanner.async_setup() + + cancel = manager.async_register_scanner(scanner, connection_slots=1) + + # Set scanner to passive mode when active was requested + scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) + scanner.set_current_mode(BluetoothScanningMode.PASSIVE) + + manager.on_scanner_start(scanner) + + # Check repair issue is created + issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + registry = ir.async_get(hass) + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + # Should default to USB translation key when adapter type is unknown + assert issue.translation_key == "bluetooth_adapter_passive_mode_usb" + assert not issue.is_fixable + + cancel() + + +async def test_repair_issue_created_for_passive_mode_fallback_uart( + hass: HomeAssistant, +) -> None: + """Test repair issue is created with UART-specific message for UART adapters.""" + with patch( + "bluetooth_adapters.systems.linux.LinuxAdapters.adapters", + { + "hci0": { + "address": "00:11:22:33:44:55", + "sw_version": "homeassistant", + "hw_version": "uart:bcm2711", + "passive_scan": False, + "manufacturer": "Raspberry Pi", + "product": "BCM2711", + "adapter_type": "uart", # UART adapter type + } + }, + ): + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + manager = _get_manager() + + scanner = HaScanner( + mode=BluetoothScanningMode.ACTIVE, + adapter="hci0", + address="00:11:22:33:44:55", + ) + scanner.async_setup() + + cancel = manager.async_register_scanner(scanner, connection_slots=1) + + # Set scanner to passive mode when active was requested + scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) + scanner.set_current_mode(BluetoothScanningMode.PASSIVE) + + manager.on_scanner_start(scanner) + + # Check repair issue is created with UART-specific translation key + issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + registry = ir.async_get(hass) + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_key == "bluetooth_adapter_passive_mode_uart" + assert not issue.is_fixable + + cancel() + + +@pytest.mark.usefixtures("one_adapter") +async def test_repair_issue_deleted_when_passive_mode_resolved( + hass: HomeAssistant, +) -> None: + """Test repair issue is deleted when scanner no longer in passive mode.""" + assert await async_setup_component(hass, bluetooth.DOMAIN, {}) + await hass.async_block_till_done() + + manager = _get_manager() + + scanner = HaScanner( + mode=BluetoothScanningMode.ACTIVE, + adapter="hci0", + address="00:11:22:33:44:55", + ) + scanner.async_setup() + + cancel = manager.async_register_scanner(scanner, connection_slots=1) + + # Initially set scanner to passive mode when active was requested + scanner.set_requested_mode(BluetoothScanningMode.ACTIVE) + scanner.set_current_mode(BluetoothScanningMode.PASSIVE) + + manager.on_scanner_start(scanner) + + # Check repair issue is created + issue_id = f"bluetooth_adapter_passive_mode_{scanner.source}" + registry = ir.async_get(hass) + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is not None + + # Now simulate scanner recovering to active mode + scanner.set_current_mode(BluetoothScanningMode.ACTIVE) + manager.on_scanner_start(scanner) + + # Check repair issue is deleted + issue = registry.async_get_issue(bluetooth.DOMAIN, issue_id) + assert issue is None + + cancel()