mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add bluetooth subscribe_advertisements WebSocket API (#134291)
This commit is contained in:
parent
ab8af033c0
commit
cdc96fdf6f
@ -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),
|
||||
|
163
homeassistant/components/bluetooth/websocket_api.py
Normal file
163
homeassistant/components/bluetooth/websocket_api.py
Normal 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()
|
@ -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()
|
||||
|
@ -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()
|
||||
|
116
tests/components/bluetooth/test_websocket_api.py
Normal file
116
tests/components/bluetooth/test_websocket_api.py
Normal 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"}]}
|
Loading…
x
Reference in New Issue
Block a user