Skip to content

Commit

Permalink
Add bluetooth subscribe_advertisements WebSocket API (home-assistant#…
Browse files Browse the repository at this point in the history
  • Loading branch information
bdraco authored Jan 11, 2025
1 parent ab8af03 commit cdc96fd
Show file tree
Hide file tree
Showing 5 changed files with 376 additions and 20 deletions.
3 changes: 2 additions & 1 deletion homeassistant/components/bluetooth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
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,
Expand Down Expand Up @@ -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),
Expand Down
163 changes: 163 additions & 0 deletions homeassistant/components/bluetooth/websocket_api.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 24 additions & 0 deletions tests/components/bluetooth/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
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():
Expand Down Expand Up @@ -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()
90 changes: 71 additions & 19 deletions tests/components/bluetooth/test_manager.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Tests for the Bluetooth integration manager."""

from collections.abc import Generator
from datetime import timedelta
import time
from typing import Any
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
Expand Down Expand Up @@ -36,10 +36,12 @@
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 (
Expand All @@ -63,24 +65,6 @@
)


@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,
Expand Down Expand Up @@ -1660,3 +1644,71 @@ def clear_all_devices(self) -> None:
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()
Loading

0 comments on commit cdc96fd

Please sign in to comment.