From 8e88e039f7091f77270fba420a674e4a6f00abca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Aug 2022 16:41:49 -0500 Subject: [PATCH] Add diagnostics to bluetooth (#77393) --- .../components/bluetooth/diagnostics.py | 28 ++++ homeassistant/components/bluetooth/manager.py | 26 +++- .../components/bluetooth/manifest.json | 2 +- homeassistant/components/bluetooth/models.py | 14 ++ homeassistant/components/bluetooth/scanner.py | 11 ++ homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/conftest.py | 5 + .../components/bluetooth/test_diagnostics.py | 126 ++++++++++++++++++ 10 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/bluetooth/diagnostics.py create mode 100644 tests/components/bluetooth/test_diagnostics.py diff --git a/homeassistant/components/bluetooth/diagnostics.py b/homeassistant/components/bluetooth/diagnostics.py new file mode 100644 index 00000000000..612c51806dd --- /dev/null +++ b/homeassistant/components/bluetooth/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for bluetooth.""" +from __future__ import annotations + +import platform +from typing import Any + +from bluetooth_adapters import get_dbus_managed_objects + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import _get_manager + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + manager = _get_manager(hass) + manager_diagnostics = await manager.async_diagnostics() + adapters = await manager.async_get_bluetooth_adapters() + diagnostics = { + "manager": manager_diagnostics, + "adapters": adapters, + } + if platform.system() == "Linux": + diagnostics["dbus"] = await get_dbus_managed_objects() + return diagnostics diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index be5038a6d31..984d37d806d 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -1,11 +1,13 @@ """The bluetooth integration.""" from __future__ import annotations +import asyncio from collections.abc import Callable, Iterable +from dataclasses import asdict from datetime import datetime, timedelta import itertools import logging -from typing import TYPE_CHECKING, Final +from typing import TYPE_CHECKING, Any, Final from bleak.backends.scanner import AdvertisementDataCallback @@ -145,6 +147,28 @@ class BluetoothManager: self._connectable_scanners: list[BaseHaScanner] = [] self._adapters: dict[str, AdapterDetails] = {} + async def async_diagnostics(self) -> dict[str, Any]: + """Diagnostics for the manager.""" + scanner_diagnostics = await asyncio.gather( + *[ + scanner.async_diagnostics() + for scanner in itertools.chain( + self._scanners, self._connectable_scanners + ) + ] + ) + return { + "adapters": self._adapters, + "scanners": scanner_diagnostics, + "connectable_history": [ + asdict(service_info) + for service_info in self._connectable_history.values() + ], + "history": [ + asdict(service_info) for service_info in self._history.values() + ], + } + def _find_adapter_by_address(self, address: str) -> str | None: for adapter, details in self._adapters.items(): if details[ADAPTER_ADDRESS] == address: diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index cfe9590b2db..8e4f0eb75de 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -6,7 +6,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.15.1", - "bluetooth-adapters==0.2.0", + "bluetooth-adapters==0.3.2", "bluetooth-auto-recovery==0.2.2" ], "codeowners": ["@bdraco"], diff --git a/homeassistant/components/bluetooth/models.py b/homeassistant/components/bluetooth/models.py index 285e991ff81..6c70633f597 100644 --- a/homeassistant/components/bluetooth/models.py +++ b/homeassistant/components/bluetooth/models.py @@ -70,6 +70,20 @@ class BaseHaScanner: def discovered_devices(self) -> list[BLEDevice]: """Return a list of discovered devices.""" + async def async_diagnostics(self) -> dict[str, Any]: + """Return diagnostic information about the scanner.""" + return { + "type": self.__class__.__name__, + "discovered_devices": [ + { + "name": device.name, + "address": device.address, + "rssi": device.rssi, + } + for device in self.discovered_devices + ], + } + class HaBleakScannerWrapper(BaseBleakScanner): """A wrapper that uses the single instance.""" diff --git a/homeassistant/components/bluetooth/scanner.py b/homeassistant/components/bluetooth/scanner.py index d186f613c94..78979198e5c 100644 --- a/homeassistant/components/bluetooth/scanner.py +++ b/homeassistant/components/bluetooth/scanner.py @@ -146,6 +146,17 @@ class HaScanner(BaseHaScanner): """Return a list of discovered devices.""" return self.scanner.discovered_devices + async def async_diagnostics(self) -> dict[str, Any]: + """Return diagnostic information about the scanner.""" + base_diag = await super().async_diagnostics() + return base_diag | { + "adapter": self.adapter, + "source": self.source, + "name": self.name, + "last_detection": self._last_detection, + "start_time": self._start_time, + } + @hass_callback def async_register_callback( self, callback: Callable[[BluetoothServiceInfoBleak], None] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 412a1841394..5de59a22eb7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ attrs==21.2.0 awesomeversion==22.6.0 bcrypt==3.1.7 bleak==0.15.1 -bluetooth-adapters==0.2.0 +bluetooth-adapters==0.3.2 bluetooth-auto-recovery==0.2.2 certifi>=2021.5.30 ciso8601==2.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 55bb47eb0e4..cc1e87871d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -424,7 +424,7 @@ blockchain==1.4.4 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.2.0 +bluetooth-adapters==0.3.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==0.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f34a5a48818..0d76676ec8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ blebox_uniapi==2.0.2 blinkpy==0.19.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.2.0 +bluetooth-adapters==0.3.2 # homeassistant.components.bluetooth bluetooth-auto-recovery==0.2.2 diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 1ea9b8706d4..44b9a60d1b5 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -53,6 +53,11 @@ def one_adapter_fixture(): def two_adapters_fixture(): """Fixture that mocks two adapters on Linux.""" with patch( + "homeassistant.components.bluetooth.platform.system", return_value="Linux" + ), patch( + "homeassistant.components.bluetooth.scanner.platform.system", + return_value="Linux", + ), patch( "homeassistant.components.bluetooth.util.platform.system", return_value="Linux" ), patch( "bluetooth_adapters.get_bluetooth_adapter_details", diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py new file mode 100644 index 00000000000..6f5eeefa5b6 --- /dev/null +++ b/tests/components/bluetooth/test_diagnostics.py @@ -0,0 +1,126 @@ +"""Test bluetooth diagnostics.""" + + +from unittest.mock import ANY, patch + +from bleak.backends.scanner import BLEDevice + +from homeassistant.components import bluetooth + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass, hass_client, mock_bleak_scanner_start, enable_bluetooth, two_adapters +): + """Test we can setup and unsetup bluetooth with multiple adapters.""" + # Normally we do not want to patch our classes, but since bleak will import + # a different scanner based on the operating system, we need to patch here + # because we cannot import the scanner class directly without it throwing an + # error if the test is not running on linux since we won't have the correct + # deps installed when testing on MacOS. + with patch( + "homeassistant.components.bluetooth.scanner.HaScanner.discovered_devices", + [BLEDevice(name="x", rssi=-60, address="44:44:33:11:23:45")], + ), patch( + "homeassistant.components.bluetooth.diagnostics.platform.system", + return_value="Linux", + ), patch( + "homeassistant.components.bluetooth.diagnostics.get_dbus_managed_objects", + return_value={ + "org.bluez": { + "/org/bluez/hci0": { + "Interfaces": {"org.bluez.Adapter1": {"Discovering": False}} + } + } + }, + ): + entry1 = MockConfigEntry( + domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" + ) + entry1.add_to_hass(hass) + + entry2 = MockConfigEntry( + domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:02" + ) + entry2.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + assert await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry1) + assert diag == { + "adapters": { + "hci0": { + "address": "00:00:00:00:00:01", + "hw_version": "usbid:1234", + "sw_version": "BlueZ 4.63", + }, + "hci1": { + "address": "00:00:00:00:00:02", + "hw_version": "usbid:1234", + "sw_version": "BlueZ 4.63", + }, + }, + "dbus": { + "org.bluez": { + "/org/bluez/hci0": { + "Interfaces": {"org.bluez.Adapter1": {"Discovering": False}} + } + } + }, + "manager": { + "adapters": { + "hci0": { + "address": "00:00:00:00:00:01", + "hw_version": "usbid:1234", + "sw_version": "BlueZ 4.63", + }, + "hci1": { + "address": "00:00:00:00:00:02", + "hw_version": "usbid:1234", + "sw_version": "BlueZ 4.63", + }, + }, + "connectable_history": [], + "history": [], + "scanners": [ + { + "adapter": "hci0", + "discovered_devices": [ + {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + ], + "last_detection": ANY, + "name": "hci0 (00:00:00:00:00:01)", + "source": "hci0", + "start_time": ANY, + "type": "HaScanner", + }, + { + "adapter": "hci0", + "discovered_devices": [ + {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + ], + "last_detection": ANY, + "name": "hci0 (00:00:00:00:00:01)", + "source": "hci0", + "start_time": ANY, + "type": "HaScanner", + }, + { + "adapter": "hci1", + "discovered_devices": [ + {"address": "44:44:33:11:23:45", "name": "x", "rssi": -60} + ], + "last_detection": ANY, + "name": "hci1 (00:00:00:00:00:02)", + "source": "hci1", + "start_time": ANY, + "type": "HaScanner", + }, + ], + }, + }