From 68dbe34b89076b7f921c8b6daaf36ecc20174dec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jan 2025 13:06:24 -1000 Subject: [PATCH] Add Bluetooth WebSocket API to subscribe to scanner details (#136750) --- .../components/bluetooth/websocket_api.py | 64 +++++++- .../bluetooth/test_websocket_api.py | 142 +++++++++++++++++- 2 files changed, 201 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/websocket_api.py b/homeassistant/components/bluetooth/websocket_api.py index 2829617d09e..d21b11b050f 100644 --- a/homeassistant/components/bluetooth/websocket_api.py +++ b/homeassistant/components/bluetooth/websocket_api.py @@ -7,7 +7,12 @@ from functools import lru_cache, partial import time from typing import Any -from habluetooth import BluetoothScanningMode, HaBluetoothSlotAllocations +from habluetooth import ( + BluetoothScanningMode, + HaBluetoothSlotAllocations, + HaScannerRegistration, + HaScannerRegistrationEvent, +) from home_assistant_bluetooth import BluetoothServiceInfoBleak import voluptuous as vol @@ -16,6 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.json import json_bytes from .api import _get_manager, async_register_callback +from .const import DOMAIN from .match import BluetoothCallbackMatcher from .models import BluetoothChange from .util import InvalidConfigEntryID, InvalidSource, config_entry_id_to_source @@ -26,6 +32,7 @@ def async_setup(hass: HomeAssistant) -> None: """Set up the bluetooth websocket API.""" websocket_api.async_register_command(hass, ws_subscribe_advertisements) websocket_api.async_register_command(hass, ws_subscribe_connection_allocations) + websocket_api.async_register_command(hass, ws_subscribe_scanner_details) @lru_cache(maxsize=1024) @@ -191,3 +198,58 @@ async def ws_subscribe_connection_allocations( connection.send_message( json_bytes(websocket_api.event_message(ws_msg_id, current_allocations)) ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "bluetooth/subscribe_scanner_details", + vol.Optional("config_entry_id"): str, + } +) +@websocket_api.async_response +async def ws_subscribe_scanner_details( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe scanner details websocket command.""" + ws_msg_id = msg["id"] + source: str | None = None + if config_entry_id := msg.get("config_entry_id"): + if ( + not (entry := hass.config_entries.async_get_entry(config_entry_id)) + or entry.domain != DOMAIN + ): + connection.send_error( + ws_msg_id, + "invalid_config_entry_id", + f"Invalid config entry id: {config_entry_id}", + ) + return + source = entry.unique_id + assert source is not None + + def _async_event_message(message: dict[str, Any]) -> None: + connection.send_message( + json_bytes(websocket_api.event_message(ws_msg_id, message)) + ) + + def _async_registration_changed(registration: HaScannerRegistration) -> None: + added_event = HaScannerRegistrationEvent.ADDED + event_type = "add" if registration.event == added_event else "remove" + _async_event_message({event_type: [registration.scanner.details]}) + + manager = _get_manager(hass) + connection.subscriptions[ws_msg_id] = ( + manager.async_register_scanner_registration_callback( + _async_registration_changed, source + ) + ) + connection.send_message(json_bytes(websocket_api.result_message(ws_msg_id))) + if (scanners := manager.async_current_scanners()) and ( + matching_scanners := [ + scanner.details + for scanner in scanners + if source is None or scanner.source == source + ] + ): + _async_event_message({"add": matching_scanners}) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index d9289fe8380..bacdbbd5eed 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -17,6 +17,7 @@ from . import ( HCI0_SOURCE_ADDRESS, HCI1_SOURCE_ADDRESS, NON_CONNECTABLE_REMOTE_SOURCE_ADDRESS, + FakeScanner, _get_manager, generate_advertisement_data, generate_ble_device, @@ -123,7 +124,7 @@ async def test_subscribe_advertisements( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations( +async def test_subscribe_connection_allocations( hass: HomeAssistant, register_hci0_scanner: None, register_hci1_scanner: None, @@ -201,7 +202,7 @@ async def test_subscribe_subscribe_connection_allocations( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_specific_scanner( +async def test_subscribe_connection_allocations_specific_scanner( hass: HomeAssistant, register_non_connectable_scanner: None, hass_ws_client: WebSocketGenerator, @@ -237,7 +238,7 @@ async def test_subscribe_subscribe_connection_allocations_specific_scanner( @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_id( +async def test_subscribe_connection_allocations_invalid_config_entry_id( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -258,7 +259,7 @@ async def test_subscribe_subscribe_connection_allocations_invalid_config_entry_i @pytest.mark.usefixtures("enable_bluetooth") -async def test_subscribe_subscribe_connection_allocations_invalid_scanner( +async def test_subscribe_connection_allocations_invalid_scanner( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, ) -> None: @@ -278,3 +279,136 @@ async def test_subscribe_subscribe_connection_allocations_invalid_scanner( assert not response["success"] assert response["error"]["code"] == "invalid_source" assert response["error"]["message"] == "Source invalid not found" + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_connection_allocations.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + } + ) + 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": [ + { + "adapter": "hci0", + "connectable": False, + "name": "hci0 (00:00:00:00:00:01)", + "source": "00:00:00:00:00:01", + } + ] + } + + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_specific_scanner( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for a specific source address.""" + entry = MockConfigEntry(domain=DOMAIN, unique_id="AA:BB:CC:DD:EE:33") + entry.add_to_hass(hass) + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": entry.entry_id, + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["success"] + manager = _get_manager() + hci3_scanner = FakeScanner("AA:BB:CC:DD:EE:33", "hci3") + cancel_hci3 = manager.async_register_hass_scanner(hci3_scanner) + + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "add": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + cancel_hci3() + async with asyncio.timeout(1): + response = await client.receive_json() + assert response["event"] == { + "remove": [ + { + "adapter": "hci3", + "connectable": False, + "name": "hci3 (AA:BB:CC:DD:EE:33)", + "source": "AA:BB:CC:DD:EE:33", + } + ] + } + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_subscribe_scanner_details_invalid_config_entry_id( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test bluetooth subscribe_scanner_details for an invalid config entry id.""" + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "bluetooth/subscribe_scanner_details", + "config_entry_id": "non_existent", + } + ) + async with asyncio.timeout(1): + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "invalid_config_entry_id" + assert response["error"]["message"] == "Invalid config entry id: non_existent"