diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 645adfdcd2d..5e96e5e336f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -51,7 +51,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth -from . import passive_update_processor +from . import passive_update_processor, websocket_api from .api import ( _get_manager, async_address_present, @@ -232,6 +232,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: set_manager(manager) await storage_setup_task await manager.async_setup() + websocket_api.async_setup(hass) hass.async_create_background_task( _async_start_adapter_discovery(hass, manager, bluetooth_adapters), diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py new file mode 100644 index 00000000000..b295fb2ac63 --- /dev/null +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -0,0 +1,163 @@ +"""The bluetooth integration websocket apis.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable +from functools import lru_cache, partial +import time +from typing import Any + +from habluetooth import BluetoothScanningMode +from home_assistant_bluetooth import BluetoothServiceInfoBleak +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes + +from .api import _get_manager, async_register_callback +from .match import BluetoothCallbackMatcher +from .models import BluetoothChange + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the bluetooth websocket API.""" + websocket_api.async_register_command(hass, ws_subscribe_advertisements) + + +@lru_cache(maxsize=1024) +def serialize_service_info( + service_info: BluetoothServiceInfoBleak, time_diff: float +) -> dict[str, Any]: + """Serialize a BluetoothServiceInfoBleak object.""" + return { + "name": service_info.name, + "address": service_info.address, + "rssi": service_info.rssi, + "manufacturer_data": { + str(manufacturer_id): manufacturer_data.hex() + for manufacturer_id, manufacturer_data in service_info.manufacturer_data.items() + }, + "service_data": { + service_uuid: service_data.hex() + for service_uuid, service_data in service_info.service_data.items() + }, + "service_uuids": service_info.service_uuids, + "source": service_info.source, + "connectable": service_info.connectable, + "time": service_info.time + time_diff, + "tx_power": service_info.tx_power, + } + + +class _AdvertisementSubscription: + """Class to hold and manage the subscription data.""" + + def __init__( + self, + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + ws_msg_id: int, + match_dict: BluetoothCallbackMatcher, + ) -> None: + """Initialize the subscription data.""" + self.hass = hass + self.match_dict = match_dict + self.pending_service_infos: list[BluetoothServiceInfoBleak] = [] + self.ws_msg_id = ws_msg_id + self.connection = connection + self.pending = True + # Keep time_diff precise to 2 decimal places + # so the cached serialization can be reused, + # however we still want to calculate it each + # subscription in case the system clock is wrong + # and gets corrected. + self.time_diff = round(time.time() - time.monotonic(), 2) + + @callback + def _async_unsubscribe( + self, cancel_callbacks: tuple[Callable[[], None], ...] + ) -> None: + """Unsubscribe the callback.""" + for cancel_callback in cancel_callbacks: + cancel_callback() + + @callback + def async_start(self) -> None: + """Start the subscription.""" + connection = self.connection + cancel_adv_callback = async_register_callback( + self.hass, + self._async_on_advertisement, + self.match_dict, + BluetoothScanningMode.PASSIVE, + ) + cancel_disappeared_callback = _get_manager( + self.hass + ).async_register_disappeared_callback(self._async_removed) + connection.subscriptions[self.ws_msg_id] = partial( + self._async_unsubscribe, (cancel_adv_callback, cancel_disappeared_callback) + ) + self.pending = False + self.connection.send_message( + json_bytes(websocket_api.result_message(self.ws_msg_id)) + ) + self._async_added(self.pending_service_infos) + self.pending_service_infos.clear() + + def _async_added(self, service_infos: Iterable[BluetoothServiceInfoBleak]) -> None: + self.connection.send_message( + json_bytes( + websocket_api.event_message( + self.ws_msg_id, + { + "add": [ + serialize_service_info(service_info, self.time_diff) + for service_info in service_infos + ] + }, + ) + ) + ) + + def _async_removed(self, address: str) -> None: + self.connection.send_message( + json_bytes( + websocket_api.event_message( + self.ws_msg_id, + { + "remove": [ + { + "address": address, + } + ] + }, + ) + ) + ) + + @callback + def _async_on_advertisement( + self, service_info: BluetoothServiceInfoBleak, change: BluetoothChange + ) -> None: + """Handle the callback.""" + if self.pending: + self.pending_service_infos.append(service_info) + return + self._async_added((service_info,)) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_advertisements", + } +) +@websocket_api.async_response +async def ws_subscribe_advertisements( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe advertisements websocket command.""" + _AdvertisementSubscription( + hass, connection, msg["id"], BluetoothCallbackMatcher(connectable=False) + ).async_start() diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index 93a1c59cba1..71ed155cbc7 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -8,6 +8,12 @@ from dbus_fast.aio import message_bus import habluetooth.util as habluetooth_utils import pytest +# pylint: disable-next=no-name-in-module +from homeassistant.components import bluetooth +from homeassistant.core import HomeAssistant + +from . import FakeScanner + @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): @@ -304,3 +310,21 @@ def disable_new_discovery_flows_fixture(): "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" ) as mock_create_flow: yield mock_create_flow + + +@pytest.fixture +def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci0 scanner.""" + hci0_scanner = FakeScanner("hci0", "hci0") + cancel = bluetooth.async_register_scanner(hass, hci0_scanner) + yield + cancel() + + +@pytest.fixture +def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: + """Register an hci1 scanner.""" + hci1_scanner = FakeScanner("hci1", "hci1") + cancel = bluetooth.async_register_scanner(hass, hci1_scanner) + yield + cancel() diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 0454df9a4a7..77071368dd0 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -1,6 +1,5 @@ """Tests for the Bluetooth integration manager.""" -from collections.abc import Generator from datetime import timedelta import time from typing import Any @@ -8,6 +7,7 @@ from unittest.mock import patch from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import AdvertisementHistory +from freezegun import freeze_time # pylint: disable-next=no-name-in-module from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS @@ -36,10 +36,12 @@ from homeassistant.components.bluetooth.const import ( SOURCE_LOCAL, UNAVAILABLE_TRACK_SECONDS, ) +from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.dt import utcnow from homeassistant.util.json import json_loads from . import ( @@ -63,24 +65,6 @@ from tests.common import ( ) -@pytest.fixture -def register_hci0_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci0 scanner.""" - hci0_scanner = FakeScanner("hci0", "hci0") - cancel = bluetooth.async_register_scanner(hass, hci0_scanner) - yield - cancel() - - -@pytest.fixture -def register_hci1_scanner(hass: HomeAssistant) -> Generator[None]: - """Register an hci1 scanner.""" - hci1_scanner = FakeScanner("hci1", "hci1") - cancel = bluetooth.async_register_scanner(hass, hci1_scanner) - yield - cancel() - - @pytest.mark.usefixtures("enable_bluetooth") async def test_advertisements_do_not_switch_adapters_for_no_reason( hass: HomeAssistant, @@ -1660,3 +1644,71 @@ async def test_bluetooth_rediscover_no_match( cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_async_register_disappeared_callback( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, +) -> None: + """Test bluetooth async_register_disappeared_callback handles failures.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + failed_disappeared: list[str] = [] + + def _failing_callback(_address: str) -> None: + """Failing callback.""" + failed_disappeared.append(_address) + raise ValueError("This is a test") + + ok_disappeared: list[str] = [] + + def _ok_callback(_address: str) -> None: + """Ok callback.""" + ok_disappeared.append(_address) + + manager: HomeAssistantBluetoothManager = _get_manager() + cancel1 = manager.async_register_disappeared_callback(_failing_callback) + # Make sure the second callback still works if the first one fails and + # raises an exception + cancel2 = manager.async_register_disappeared_callback(_ok_callback) + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + + assert len(ok_disappeared) == 1 + assert ok_disappeared[0] == address + assert len(failed_disappeared) == 1 + assert failed_disappeared[0] == address + + cancel1() + cancel2() diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py new file mode 100644 index 00000000000..c9670f2f895 --- /dev/null +++ b/tests/components/bluetooth/test_websocket_api.py @@ -0,0 +1,116 @@ +"""The tests for the bluetooth WebSocket API.""" + +import asyncio +from datetime import timedelta +import time +from unittest.mock import ANY, patch + +from freezegun import freeze_time +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import ( + generate_advertisement_data, + generate_ble_device, + inject_advertisement_with_source, +) + +from tests.common import async_fire_time_changed +from tests.typing import WebSocketGenerator + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_advertisements( + hass: HomeAssistant, + register_hci0_scanner: None, + register_hci1_scanner: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_advertisements.""" + address = "44:44:33:11:23:12" + + switchbot_device_signal_100 = generate_ble_device( + address, "wohand_signal_100", rssi=-100 + ) + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", service_uuids=[] + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" + ) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_advertisements", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {}, + "name": "wohand_signal_100", + "rssi": -127, + "service_data": {}, + "service_uuids": [], + "source": "hci0", + "time": ANY, + "tx_power": -127, + } + ] + } + adv_time = response["event"]["add"][0]["time"] + + switchbot_adv_signal_100 = generate_advertisement_data( + local_name="wohand_signal_100", + manufacturer_data={123: b"abc"}, + service_uuids=[], + rssi=-80, + ) + inject_advertisement_with_source( + hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci1" + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "address": "44:44:33:11:23:12", + "connectable": True, + "manufacturer_data": {"123": "616263"}, + "name": "wohand_signal_100", + "rssi": -80, + "service_data": {}, + "service_uuids": [], + "source": "hci1", + "time": ANY, + "tx_power": -127, + } + ] + } + new_time = response["event"]["add"][0]["time"] + assert new_time > adv_time + future_time = utcnow() + timedelta(seconds=3600) + future_monotonic_time = time.monotonic() + 3600 + with ( + freeze_time(future_time), + patch( + "habluetooth.manager.monotonic_time_coarse", + return_value=future_monotonic_time, + ), + ): + async_fire_time_changed(hass, future_time) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == {"remove": [{"address": "44:44:33:11:23:12"}]}