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.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),

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

View File

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

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