From 02b760f14297e4fff39ed24c4f62610fda3284e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 30 May 2026 08:49:56 -0500 Subject: [PATCH] Expose bluetooth address reachability diagnostics API (#172578) --- .../components/bluetooth/__init__.py | 4 ++ homeassistant/components/bluetooth/api.py | 9 ++++ tests/components/bluetooth/test_api.py | 53 +++++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 61a7fa6de26f..1f8caa91eeda 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -27,6 +27,7 @@ from bluetooth_data_tools import monotonic_time_coarse as MONOTONIC_TIME from habluetooth import ( BaseHaRemoteScanner, BaseHaScanner, + BluetoothReachabilityIntent, BluetoothScannerDevice, BluetoothScanningMode, HaBluetoothConnector, @@ -55,6 +56,7 @@ from . import passive_update_processor, websocket_api from .api import ( _get_manager, async_address_present, + async_address_reachability_diagnostics, async_ble_device_from_address, async_clear_address_from_match_history, async_clear_advertisement_history, @@ -108,12 +110,14 @@ __all__ = [ "BluetoothCallback", "BluetoothCallbackMatcher", "BluetoothChange", + "BluetoothReachabilityIntent", "BluetoothScannerDevice", "BluetoothScanningMode", "BluetoothServiceInfo", "BluetoothServiceInfoBleak", "HaBluetoothConnector", "async_address_present", + "async_address_reachability_diagnostics", "async_ble_device_from_address", "async_clear_address_from_match_history", "async_clear_advertisement_history", diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index 5440bdf5a706..dc5d5fff44c5 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, cast from bleak import BleakScanner from habluetooth import ( BaseHaScanner, + BluetoothReachabilityIntent, BluetoothScannerDevice, BluetoothScanningMode, HaBleakScannerWrapper, @@ -108,6 +109,14 @@ def async_ble_device_from_address( return _get_manager(hass).async_ble_device_from_address(address, connectable) +@hass_callback +def async_address_reachability_diagnostics( + hass: HomeAssistant, address: str, intent: BluetoothReachabilityIntent +) -> str: + """Return a human readable explanation of why an address may be unreachable.""" + return _get_manager(hass).async_address_reachability_diagnostics(address, intent) + + @hass_callback def async_scanner_devices_by_address( hass: HomeAssistant, address: str, connectable: bool = True diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index ab188f2799d4..dffdcf943d78 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -10,9 +10,11 @@ from homeassistant.components.bluetooth import ( MONOTONIC_TIME, BaseHaRemoteScanner, BluetoothChange, + BluetoothReachabilityIntent, BluetoothScanningMode, BluetoothServiceInfo, HaBluetoothConnector, + async_address_reachability_diagnostics, async_clear_advertisement_history, async_request_active_scan, async_scanner_by_source, @@ -112,6 +114,57 @@ async def test_async_scanner_devices_by_address_connectable( cancel() +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_address_reachability_diagnostics(hass: HomeAssistant) -> None: + """Test the address reachability diagnostics passthrough.""" + # An address that was never seen reports as unknown. + assert "unknown" in async_address_reachability_diagnostics( + hass, "44:44:33:11:23:99", BluetoothReachabilityIntent.CONNECTION + ) + + manager = _get_manager() + + class FakeInjectableScanner(BaseHaRemoteScanner): + def inject_advertisement( + self, device: BLEDevice, advertisement_data: AdvertisementData + ) -> None: + """Inject an advertisement.""" + self._async_on_advertisement( + device.address, + advertisement_data.rssi, + device.name, + advertisement_data.service_uuids, + advertisement_data.service_data, + advertisement_data.manufacturer_data, + advertisement_data.tx_power, + {"scanner_specific_data": "test"}, + MONOTONIC_TIME(), + ) + + connector = HaBluetoothConnector(MockBleakClient, "esp32", lambda: True) + scanner = FakeInjectableScanner("esp32", "esp32", connector, True) + unsetup = scanner.async_setup() + cancel = manager.async_register_scanner(scanner) + switchbot_device = generate_ble_device("44:44:33:11:23:45", "wohand", {}) + switchbot_device_adv = generate_advertisement_data(local_name="wohand", rssi=-80) + scanner.inject_advertisement(switchbot_device, switchbot_device_adv) + + connection_diag = async_address_reachability_diagnostics( + hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.CONNECTION + ) + assert "in connectable history" in connection_diag + assert "esp32" in connection_diag + + # An advertisement intent does not report connectable paths or slots. + advertisement_diag = async_address_reachability_diagnostics( + hass, "44:44:33:11:23:45", BluetoothReachabilityIntent.PASSIVE_ADVERTISEMENT + ) + assert "advertising" in advertisement_diag + + unsetup() + cancel() + + @pytest.mark.usefixtures("enable_bluetooth") async def test_async_scanner_devices_by_address_non_connectable( hass: HomeAssistant,