Add bluetooth subscribe_advertisements WebSocket API (#134291)

This commit is contained in:
J. Nick Koston 2025-01-10 16:49:53 -10:00 committed by GitHub
parent ab8af033c0
commit cdc96fdf6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 376 additions and 20 deletions

View File

@ -51,7 +51,7 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.loader import async_get_bluetooth from homeassistant.loader import async_get_bluetooth
from . import passive_update_processor from . import passive_update_processor, websocket_api
from .api import ( from .api import (
_get_manager, _get_manager,
async_address_present, async_address_present,
@ -232,6 +232,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
set_manager(manager) set_manager(manager)
await storage_setup_task await storage_setup_task
await manager.async_setup() await manager.async_setup()
websocket_api.async_setup(hass)
hass.async_create_background_task( hass.async_create_background_task(
_async_start_adapter_discovery(hass, manager, bluetooth_adapters), _async_start_adapter_discovery(hass, manager, bluetooth_adapters),

View File

@ -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()

View File

@ -8,6 +8,12 @@ from dbus_fast.aio import message_bus
import habluetooth.util as habluetooth_utils import habluetooth.util as habluetooth_utils
import pytest 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") @pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package")
def disable_bluez_manager_socket(): def disable_bluez_manager_socket():
@ -304,3 +310,21 @@ def disable_new_discovery_flows_fixture():
"homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow" "homeassistant.components.bluetooth.manager.discovery_flow.async_create_flow"
) as mock_create_flow: ) as mock_create_flow:
yield 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()

View File

@ -1,6 +1,5 @@
"""Tests for the Bluetooth integration manager.""" """Tests for the Bluetooth integration manager."""
from collections.abc import Generator
from datetime import timedelta from datetime import timedelta
import time import time
from typing import Any from typing import Any
@ -8,6 +7,7 @@ from unittest.mock import patch
from bleak.backends.scanner import AdvertisementData, BLEDevice from bleak.backends.scanner import AdvertisementData, BLEDevice
from bluetooth_adapters import AdvertisementHistory from bluetooth_adapters import AdvertisementHistory
from freezegun import freeze_time
# pylint: disable-next=no-name-in-module # pylint: disable-next=no-name-in-module
from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS from habluetooth.advertisement_tracker import TRACKER_BUFFERING_WOBBLE_SECONDS
@ -36,10 +36,12 @@ from homeassistant.components.bluetooth.const import (
SOURCE_LOCAL, SOURCE_LOCAL,
UNAVAILABLE_TRACK_SECONDS, UNAVAILABLE_TRACK_SECONDS,
) )
from homeassistant.components.bluetooth.manager import HomeAssistantBluetoothManager
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.discovery_flow import DiscoveryKey from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.dt import utcnow
from homeassistant.util.json import json_loads from homeassistant.util.json import json_loads
from . import ( 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") @pytest.mark.usefixtures("enable_bluetooth")
async def test_advertisements_do_not_switch_adapters_for_no_reason( async def test_advertisements_do_not_switch_adapters_for_no_reason(
hass: HomeAssistant, hass: HomeAssistant,
@ -1660,3 +1644,71 @@ async def test_bluetooth_rediscover_no_match(
cancel() cancel()
unsetup_connectable_scanner() unsetup_connectable_scanner()
cancel_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()

View File

@ -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"}]}