mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 18:57:06 +00:00
Add WS command for discovering Thread routers (#88021)
* Add WS command for discovering Thread routers * Fix type annotations * Mock zeroconf in tests * Key discovery by external MAC address * Add tests * Include hostname in data, allow missing fields * Fix typo * Include server instead of hostname
This commit is contained in:
parent
c7fc90f8a0
commit
8613d60c5e
154
homeassistant/components/thread/discovery.py
Normal file
154
homeassistant/components/thread/discovery.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""The Thread integration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
import dataclasses
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from zeroconf import ServiceListener, Zeroconf
|
||||||
|
from zeroconf.asyncio import AsyncZeroconf
|
||||||
|
|
||||||
|
from homeassistant.components import zeroconf
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
KNOWN_BRANDS: dict[str | None, str] = {
|
||||||
|
"Apple Inc.": "apple",
|
||||||
|
"Google Inc.": "google",
|
||||||
|
"HomeAssistant": "homeassistant",
|
||||||
|
}
|
||||||
|
THREAD_TYPE = "_meshcop._udp.local."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class ThreadRouterDiscoveryData:
|
||||||
|
"""Thread router discovery data."""
|
||||||
|
|
||||||
|
brand: str | None
|
||||||
|
extended_pan_id: str | None
|
||||||
|
model_name: str | None
|
||||||
|
network_name: str | None
|
||||||
|
server: str | None
|
||||||
|
vendor_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class ThreadRouterDiscovery:
|
||||||
|
"""mDNS based Thread router discovery."""
|
||||||
|
|
||||||
|
class ThreadServiceListener(ServiceListener):
|
||||||
|
"""Service listener which listens for thread routers."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
aiozc: AsyncZeroconf,
|
||||||
|
router_discovered: Callable,
|
||||||
|
router_removed: Callable,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self._aiozc = aiozc
|
||||||
|
self._hass = hass
|
||||||
|
self._known_routers: dict[str, tuple[str, ThreadRouterDiscoveryData]] = {}
|
||||||
|
self._router_discovered = router_discovered
|
||||||
|
self._router_removed = router_removed
|
||||||
|
|
||||||
|
def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
||||||
|
"""Handle service added."""
|
||||||
|
_LOGGER.debug("add_service %s", name)
|
||||||
|
self._hass.async_create_task(self._add_update_service(type_, name))
|
||||||
|
|
||||||
|
def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
||||||
|
"""Handle service removed."""
|
||||||
|
_LOGGER.debug("remove_service %s", name)
|
||||||
|
if name not in self._known_routers:
|
||||||
|
return
|
||||||
|
extended_mac_address, _ = self._known_routers.pop(name)
|
||||||
|
self._router_removed(extended_mac_address)
|
||||||
|
|
||||||
|
def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
|
||||||
|
"""Handle service updated."""
|
||||||
|
_LOGGER.debug("update_service %s", name)
|
||||||
|
self._hass.async_create_task(self._add_update_service(type_, name))
|
||||||
|
|
||||||
|
async def _add_update_service(self, type_: str, name: str):
|
||||||
|
"""Add or update a service."""
|
||||||
|
service = None
|
||||||
|
tries = 0
|
||||||
|
while service is None and tries < 4:
|
||||||
|
service = await self._aiozc.async_get_service_info(type_, name)
|
||||||
|
tries += 1
|
||||||
|
|
||||||
|
if not service:
|
||||||
|
_LOGGER.debug("_add_update_service failed to add %s, %s", type_, name)
|
||||||
|
return
|
||||||
|
|
||||||
|
def try_decode(value: bytes | None) -> str | None:
|
||||||
|
"""Try decoding UTF-8."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return value.decode()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
_LOGGER.debug("_add_update_service %s %s", name, service)
|
||||||
|
# We use the extended mac address as key, bail out if it's missing
|
||||||
|
try:
|
||||||
|
extended_mac_address = service.properties[b"xa"].hex()
|
||||||
|
except (KeyError, UnicodeDecodeError) as err:
|
||||||
|
_LOGGER.debug("_add_update_service failed to parse service %s", err)
|
||||||
|
return
|
||||||
|
ext_pan_id = service.properties.get(b"xp")
|
||||||
|
network_name = try_decode(service.properties.get(b"nn"))
|
||||||
|
model_name = try_decode(service.properties.get(b"mn"))
|
||||||
|
server = service.server
|
||||||
|
vendor_name = try_decode(service.properties.get(b"vn"))
|
||||||
|
data = ThreadRouterDiscoveryData(
|
||||||
|
brand=KNOWN_BRANDS.get(vendor_name),
|
||||||
|
extended_pan_id=ext_pan_id.hex() if ext_pan_id is not None else None,
|
||||||
|
model_name=model_name,
|
||||||
|
network_name=network_name,
|
||||||
|
server=server,
|
||||||
|
vendor_name=vendor_name,
|
||||||
|
)
|
||||||
|
if name in self._known_routers and self._known_routers[name] == (
|
||||||
|
extended_mac_address,
|
||||||
|
data,
|
||||||
|
):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"_add_update_service suppressing identical update for %s", name
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._known_routers[name] = (extended_mac_address, data)
|
||||||
|
self._router_discovered(extended_mac_address, data)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
router_discovered: Callable[[str, ThreadRouterDiscoveryData], None],
|
||||||
|
router_removed: Callable[[str], None],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
self._hass = hass
|
||||||
|
self._aiozc: AsyncZeroconf | None = None
|
||||||
|
self._router_discovered = router_discovered
|
||||||
|
self._router_removed = router_removed
|
||||||
|
self._service_listener: ThreadRouterDiscovery.ThreadServiceListener | None = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_start(self) -> None:
|
||||||
|
"""Start discovery."""
|
||||||
|
self._aiozc = aiozc = await zeroconf.async_get_async_instance(self._hass)
|
||||||
|
self._service_listener = self.ThreadServiceListener(
|
||||||
|
self._hass, aiozc, self._router_discovered, self._router_removed
|
||||||
|
)
|
||||||
|
await aiozc.async_add_service_listener(THREAD_TYPE, self._service_listener)
|
||||||
|
|
||||||
|
async def async_stop(self) -> None:
|
||||||
|
"""Stop discovery."""
|
||||||
|
if not self._aiozc or not self._service_listener:
|
||||||
|
return
|
||||||
|
await self._aiozc.async_remove_service_listener(self._service_listener)
|
||||||
|
self._service_listener = None
|
@ -3,6 +3,7 @@
|
|||||||
"name": "Thread",
|
"name": "Thread",
|
||||||
"codeowners": ["@home-assistant/core"],
|
"codeowners": ["@home-assistant/core"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
|
"dependencies": ["zeroconf"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/thread",
|
"documentation": "https://www.home-assistant.io/integrations/thread",
|
||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
|
@ -9,13 +9,14 @@ import voluptuous as vol
|
|||||||
from homeassistant.components import websocket_api
|
from homeassistant.components import websocket_api
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from . import dataset_store
|
from . import dataset_store, discovery
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_setup(hass: HomeAssistant) -> None:
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
"""Set up the sensor websocket API."""
|
"""Set up the sensor websocket API."""
|
||||||
websocket_api.async_register_command(hass, ws_add_dataset)
|
websocket_api.async_register_command(hass, ws_add_dataset)
|
||||||
|
websocket_api.async_register_command(hass, ws_discover_routers)
|
||||||
websocket_api.async_register_command(hass, ws_get_dataset)
|
websocket_api.async_register_command(hass, ws_get_dataset)
|
||||||
websocket_api.async_register_command(hass, ws_list_datasets)
|
websocket_api.async_register_command(hass, ws_list_datasets)
|
||||||
|
|
||||||
@ -100,3 +101,59 @@ async def ws_list_datasets(
|
|||||||
)
|
)
|
||||||
|
|
||||||
connection.send_result(msg["id"], {"datasets": result})
|
connection.send_result(msg["id"], {"datasets": result})
|
||||||
|
|
||||||
|
|
||||||
|
@websocket_api.require_admin
|
||||||
|
@websocket_api.websocket_command(
|
||||||
|
{
|
||||||
|
vol.Required("type"): "thread/discover_routers",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@websocket_api.async_response
|
||||||
|
async def ws_discover_routers(
|
||||||
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Discover Thread routers."""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def router_discovered(key: str, data: discovery.ThreadRouterDiscoveryData) -> None:
|
||||||
|
"""Forward router discovery or update to websocket."""
|
||||||
|
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(
|
||||||
|
msg["id"],
|
||||||
|
{
|
||||||
|
"type": "router_discovered",
|
||||||
|
"key": key,
|
||||||
|
"data": data,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def router_removed(key: str) -> None:
|
||||||
|
"""Forward router discovery or update to websocket."""
|
||||||
|
|
||||||
|
connection.send_message(
|
||||||
|
websocket_api.event_message(
|
||||||
|
msg["id"],
|
||||||
|
{
|
||||||
|
"type": "router_removed",
|
||||||
|
"key": key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def stop_discovery() -> None:
|
||||||
|
"""Stop discovery."""
|
||||||
|
hass.async_create_task(thread_discovery.async_stop())
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
thread_discovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered, router_removed
|
||||||
|
)
|
||||||
|
await thread_discovery.async_start()
|
||||||
|
connection.subscriptions[msg["id"]] = stop_discovery
|
||||||
|
|
||||||
|
connection.send_message(websocket_api.result_message(msg["id"]))
|
||||||
|
@ -17,3 +17,171 @@ DATASET_3 = (
|
|||||||
"E5AA15DD051000112233445566778899AABBCCDDEEFF030E7ef09f90a3f09f90a5f09f90a47e01"
|
"E5AA15DD051000112233445566778899AABBCCDDEEFF030E7ef09f90a3f09f90a5f09f90a47e01"
|
||||||
"0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8"
|
"0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROUTER_DISCOVERY_GOOGLE_1 = {
|
||||||
|
"type_": "_meshcop._udp.local.",
|
||||||
|
"name": "Google-Nest-Hub-#ABED._meshcop._udp.local.",
|
||||||
|
"addresses": [b"\xc0\xa8\x00|"],
|
||||||
|
"port": 49191,
|
||||||
|
"weight": 0,
|
||||||
|
"priority": 0,
|
||||||
|
"server": "2d99f293-cd8e-2770-8dd2-6675de9fa000.local.",
|
||||||
|
"properties": {
|
||||||
|
b"rv": b"1",
|
||||||
|
b"vn": b"Google Inc.",
|
||||||
|
b"mn": b"Google Nest Hub",
|
||||||
|
b"nn": b"NEST-PAN-E1AF",
|
||||||
|
b"xp": b"\x9eu\xe2V\xf6\x14\t\xa3",
|
||||||
|
b"tv": b"1.3.0",
|
||||||
|
b"xa": b"\xf6\xa9\x9bBZg\xab\xed",
|
||||||
|
b"sb": b"\x00\x00\x01\xb1",
|
||||||
|
b"at": b"\x00\x00b\xf2\xf8$T\xe3",
|
||||||
|
b"pt": b"4\x860D",
|
||||||
|
b"sq": b"{",
|
||||||
|
b"bb": b"\xf0\xbf",
|
||||||
|
b"dn": b"DefaultDomain",
|
||||||
|
b"id": b"\xbc7@\xc3\xe9c\xaa\x875\xbe\xbe\xcd|\xc5\x03\xc7",
|
||||||
|
b"vat": b"000062f2f82454e3",
|
||||||
|
b"vcd": b"BC3740C3E963AA8735BEBECD7CC503C7",
|
||||||
|
b"vo": b"|\xd9\\",
|
||||||
|
b"vvo": b"7CD95C",
|
||||||
|
b"vxp": b"9e75e256f61409a3",
|
||||||
|
},
|
||||||
|
"interface_index": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUTER_DISCOVERY_GOOGLE_2 = {
|
||||||
|
"type": "_meshcop._udp.local.",
|
||||||
|
"name": "Google-Nest-Hub-#D8D5._meshcop._udp.local.",
|
||||||
|
"addresses": [b"\xc0\xa8\x00q"],
|
||||||
|
"port": 49191,
|
||||||
|
"weight": 0,
|
||||||
|
"priority": 0,
|
||||||
|
"server": "80adee71-a563-2cfe-4402-95a9bc6ae3a1.local.",
|
||||||
|
"properties": {
|
||||||
|
b"rv": b"1",
|
||||||
|
b"vn": b"Google Inc.",
|
||||||
|
b"mn": b"Google Nest Hub",
|
||||||
|
b"nn": b"NEST-PAN-E1AF",
|
||||||
|
b"xp": b"\x9eu\xe2V\xf6\x14\t\xa3",
|
||||||
|
b"tv": b"1.3.0",
|
||||||
|
b"xa": b"\x8e9Z\xaek\xd5\xd8\xd5",
|
||||||
|
b"sb": b"\x00\x00\x00\xb1",
|
||||||
|
b"at": b"\x00\x00b\xf2\xf8$T\xe3",
|
||||||
|
b"pt": b"4\x860D",
|
||||||
|
b"sq": b'"',
|
||||||
|
b"bb": b"\xf0\xbf",
|
||||||
|
b"dn": b"DefaultDomain",
|
||||||
|
b"id": b"\xffi]\x11\xf6\xac)\xbe\xdb\x84\xb1o{\x8c\x1e\x82",
|
||||||
|
b"vat": b"000062f2f82454e3",
|
||||||
|
b"vcd": b"FF695D11F6AC29BEDB84B16F7B8C1E82",
|
||||||
|
b"vo": b"|\xd9\\",
|
||||||
|
b"vvo": b"7CD95C",
|
||||||
|
b"vxp": b"9e75e256f61409a3",
|
||||||
|
},
|
||||||
|
"interface_index": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUTER_DISCOVERY_HASS = {
|
||||||
|
"type_": "_meshcop._udp.local.",
|
||||||
|
"name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.",
|
||||||
|
"addresses": [b"\xc0\xa8\x00s"],
|
||||||
|
"port": 49153,
|
||||||
|
"weight": 0,
|
||||||
|
"priority": 0,
|
||||||
|
"server": "core-silabs-multiprotocol.local.",
|
||||||
|
"properties": {
|
||||||
|
b"rv": b"1",
|
||||||
|
b"vn": b"HomeAssistant",
|
||||||
|
b"mn": b"OpenThreadBorderRouter",
|
||||||
|
b"nn": b"OpenThread HC",
|
||||||
|
b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5",
|
||||||
|
b"tv": b"1.3.0",
|
||||||
|
b"xa": b"\xae\xeb/YKW\x0b\xbf",
|
||||||
|
b"sb": b"\x00\x00\x01\xb1",
|
||||||
|
b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00",
|
||||||
|
b"pt": b"\x8f\x06Q~",
|
||||||
|
b"sq": b"3",
|
||||||
|
b"bb": b"\xf0\xbf",
|
||||||
|
b"dn": b"DefaultDomain",
|
||||||
|
},
|
||||||
|
"interface_index": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUTER_DISCOVERY_HASS_BAD_DATA = {
|
||||||
|
"type_": "_meshcop._udp.local.",
|
||||||
|
"name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.",
|
||||||
|
"addresses": [b"\xc0\xa8\x00s"],
|
||||||
|
"port": 49153,
|
||||||
|
"weight": 0,
|
||||||
|
"priority": 0,
|
||||||
|
"server": "core-silabs-multiprotocol.local.",
|
||||||
|
"properties": {
|
||||||
|
b"rv": b"1",
|
||||||
|
b"vn": b"HomeAssistant\xff", # Invalid UTF-8
|
||||||
|
b"mn": b"OpenThreadBorderRouter",
|
||||||
|
b"nn": b"OpenThread HC",
|
||||||
|
b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5",
|
||||||
|
b"tv": b"1.3.0",
|
||||||
|
b"xa": b"\xae\xeb/YKW\x0b\xbf",
|
||||||
|
b"sb": b"\x00\x00\x01\xb1",
|
||||||
|
b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00",
|
||||||
|
b"pt": b"\x8f\x06Q~",
|
||||||
|
b"sq": b"3",
|
||||||
|
b"bb": b"\xf0\xbf",
|
||||||
|
b"dn": b"DefaultDomain",
|
||||||
|
},
|
||||||
|
"interface_index": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
ROUTER_DISCOVERY_HASS_MISSING_DATA = {
|
||||||
|
"type_": "_meshcop._udp.local.",
|
||||||
|
"name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.",
|
||||||
|
"addresses": [b"\xc0\xa8\x00s"],
|
||||||
|
"port": 49153,
|
||||||
|
"weight": 0,
|
||||||
|
"priority": 0,
|
||||||
|
"server": "core-silabs-multiprotocol.local.",
|
||||||
|
"properties": {
|
||||||
|
b"rv": b"1",
|
||||||
|
b"mn": b"OpenThreadBorderRouter",
|
||||||
|
b"nn": b"OpenThread HC",
|
||||||
|
b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5",
|
||||||
|
b"tv": b"1.3.0",
|
||||||
|
b"xa": b"\xae\xeb/YKW\x0b\xbf",
|
||||||
|
b"sb": b"\x00\x00\x01\xb1",
|
||||||
|
b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00",
|
||||||
|
b"pt": b"\x8f\x06Q~",
|
||||||
|
b"sq": b"3",
|
||||||
|
b"bb": b"\xf0\xbf",
|
||||||
|
b"dn": b"DefaultDomain",
|
||||||
|
},
|
||||||
|
"interface_index": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = {
|
||||||
|
"type_": "_meshcop._udp.local.",
|
||||||
|
"name": "HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.",
|
||||||
|
"addresses": [b"\xc0\xa8\x00s"],
|
||||||
|
"port": 49153,
|
||||||
|
"weight": 0,
|
||||||
|
"priority": 0,
|
||||||
|
"server": "core-silabs-multiprotocol.local.",
|
||||||
|
"properties": {
|
||||||
|
b"rv": b"1",
|
||||||
|
b"vn": b"HomeAssistant",
|
||||||
|
b"mn": b"OpenThreadBorderRouter",
|
||||||
|
b"nn": b"OpenThread HC",
|
||||||
|
b"xp": b"\xe6\x0f\xc7\xc1\x86!,\xe5",
|
||||||
|
b"tv": b"1.3.0",
|
||||||
|
b"sb": b"\x00\x00\x01\xb1",
|
||||||
|
b"at": b"\x00\x00\x00\x00\x00\x01\x00\x00",
|
||||||
|
b"pt": b"\x8f\x06Q~",
|
||||||
|
b"sq": b"3",
|
||||||
|
b"bb": b"\xf0\xbf",
|
||||||
|
b"dn": b"DefaultDomain",
|
||||||
|
},
|
||||||
|
"interface_index": None,
|
||||||
|
}
|
||||||
|
@ -20,3 +20,8 @@ async def thread_config_entry_fixture(hass):
|
|||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def use_mocked_zeroconf(mock_async_zeroconf):
|
||||||
|
"""Mock zeroconf in all tests."""
|
||||||
|
300
tests/components/thread/test_discovery.py
Normal file
300
tests/components/thread/test_discovery.py
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"""Test the thread websocket API."""
|
||||||
|
|
||||||
|
from unittest.mock import ANY, AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from zeroconf.asyncio import AsyncServiceInfo
|
||||||
|
|
||||||
|
from homeassistant.components.thread import discovery
|
||||||
|
from homeassistant.components.thread.const import DOMAIN
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from . import (
|
||||||
|
ROUTER_DISCOVERY_GOOGLE_1,
|
||||||
|
ROUTER_DISCOVERY_HASS,
|
||||||
|
ROUTER_DISCOVERY_HASS_BAD_DATA,
|
||||||
|
ROUTER_DISCOVERY_HASS_MISSING_DATA,
|
||||||
|
ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf) -> None:
|
||||||
|
"""Test discovering thread routers."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
discovered = []
|
||||||
|
removed = []
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def router_discovered(key: str, data: discovery.ThreadRouterDiscoveryData) -> None:
|
||||||
|
"""Handle router discovered."""
|
||||||
|
discovered.append((key, data))
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def router_removed(key: str) -> None:
|
||||||
|
"""Handle router removed."""
|
||||||
|
removed.append(key)
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
thread_disovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered, router_removed
|
||||||
|
)
|
||||||
|
await thread_disovery.async_start()
|
||||||
|
|
||||||
|
mock_async_zeroconf.async_add_service_listener.assert_called_once_with(
|
||||||
|
"_meshcop._udp.local.", ANY
|
||||||
|
)
|
||||||
|
listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = (
|
||||||
|
mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover a service
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_HASS
|
||||||
|
)
|
||||||
|
listener.add_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(discovered) == 1
|
||||||
|
assert len(removed) == 0
|
||||||
|
assert discovered[-1] == (
|
||||||
|
"aeeb2f594b570bbf",
|
||||||
|
discovery.ThreadRouterDiscoveryData(
|
||||||
|
brand="homeassistant",
|
||||||
|
extended_pan_id="e60fc7c186212ce5",
|
||||||
|
model_name="OpenThreadBorderRouter",
|
||||||
|
network_name="OpenThread HC",
|
||||||
|
server="core-silabs-multiprotocol.local.",
|
||||||
|
vendor_name="HomeAssistant",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover another service - we don't care if zeroconf considers this an update
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_GOOGLE_1
|
||||||
|
)
|
||||||
|
listener.update_service(
|
||||||
|
None, ROUTER_DISCOVERY_GOOGLE_1["type_"], ROUTER_DISCOVERY_GOOGLE_1["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(discovered) == 2
|
||||||
|
assert len(removed) == 0
|
||||||
|
assert discovered[-1] == (
|
||||||
|
"f6a99b425a67abed",
|
||||||
|
discovery.ThreadRouterDiscoveryData(
|
||||||
|
brand="google",
|
||||||
|
extended_pan_id="9e75e256f61409a3",
|
||||||
|
model_name="Google Nest Hub",
|
||||||
|
network_name="NEST-PAN-E1AF",
|
||||||
|
server="2d99f293-cd8e-2770-8dd2-6675de9fa000.local.",
|
||||||
|
vendor_name="Google Inc.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove a service
|
||||||
|
listener.remove_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(discovered) == 2
|
||||||
|
assert len(removed) == 1
|
||||||
|
assert removed[-1] == "aeeb2f594b570bbf"
|
||||||
|
|
||||||
|
# Remove the service again
|
||||||
|
listener.remove_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(discovered) == 2
|
||||||
|
assert len(removed) == 1
|
||||||
|
|
||||||
|
# Remove an unknown service
|
||||||
|
listener.remove_service(None, ROUTER_DISCOVERY_HASS["type_"], "unknown")
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert len(discovered) == 2
|
||||||
|
assert len(removed) == 1
|
||||||
|
|
||||||
|
# Stop Thread router discovery
|
||||||
|
await thread_disovery.async_stop()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener.assert_called_once_with(listener)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"data", (ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA)
|
||||||
|
)
|
||||||
|
async def test_discover_routers_bad_data(
|
||||||
|
hass: HomeAssistant, mock_async_zeroconf, data
|
||||||
|
) -> None:
|
||||||
|
"""Test discovering thread routers with bad or missing vendor mDNS data."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
router_discovered_removed = Mock()
|
||||||
|
thread_disovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered_removed, router_discovered_removed
|
||||||
|
)
|
||||||
|
await thread_disovery.async_start()
|
||||||
|
listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = (
|
||||||
|
mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover a service with bad or missing data
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(**data)
|
||||||
|
listener.add_service(None, data["type_"], data["name"])
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
router_discovered_removed.assert_called_once_with(
|
||||||
|
"aeeb2f594b570bbf",
|
||||||
|
discovery.ThreadRouterDiscoveryData(
|
||||||
|
brand=None,
|
||||||
|
extended_pan_id="e60fc7c186212ce5",
|
||||||
|
model_name="OpenThreadBorderRouter",
|
||||||
|
network_name="OpenThread HC",
|
||||||
|
server="core-silabs-multiprotocol.local.",
|
||||||
|
vendor_name=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_routers_missing_mandatory_data(
|
||||||
|
hass: HomeAssistant, mock_async_zeroconf
|
||||||
|
) -> None:
|
||||||
|
"""Test discovering thread routers with missing mandatory mDNS data."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
router_discovered_removed = Mock()
|
||||||
|
thread_disovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered_removed, router_discovered_removed
|
||||||
|
)
|
||||||
|
await thread_disovery.async_start()
|
||||||
|
listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = (
|
||||||
|
mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover a service with missing mandatory data
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA
|
||||||
|
)
|
||||||
|
listener.add_service(
|
||||||
|
None,
|
||||||
|
ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA["type_"],
|
||||||
|
ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA["name"],
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
router_discovered_removed.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_routers_get_service_info_fails(
|
||||||
|
hass: HomeAssistant, mock_async_zeroconf
|
||||||
|
) -> None:
|
||||||
|
"""Test discovering thread routers with invalid mDNS data."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
router_discovered_removed = Mock()
|
||||||
|
thread_disovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered_removed, router_discovered_removed
|
||||||
|
)
|
||||||
|
await thread_disovery.async_start()
|
||||||
|
listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = (
|
||||||
|
mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover a service with missing data
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = None
|
||||||
|
listener.add_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
router_discovered_removed.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_routers_update_unchanged(
|
||||||
|
hass: HomeAssistant, mock_async_zeroconf
|
||||||
|
) -> None:
|
||||||
|
"""Test discovering thread routers with identical mDNS data in update."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
router_discovered_removed = Mock()
|
||||||
|
thread_disovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered_removed, router_discovered_removed
|
||||||
|
)
|
||||||
|
await thread_disovery.async_start()
|
||||||
|
listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = (
|
||||||
|
mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover a service
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_HASS
|
||||||
|
)
|
||||||
|
listener.add_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
router_discovered_removed.assert_called_once()
|
||||||
|
|
||||||
|
# Update the service unchanged
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_HASS
|
||||||
|
)
|
||||||
|
listener.update_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
router_discovered_removed.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_routers_stop_twice(
|
||||||
|
hass: HomeAssistant, mock_async_zeroconf
|
||||||
|
) -> None:
|
||||||
|
"""Test discovering thread routers stopping discovery twice."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
# Start Thread router discovery
|
||||||
|
router_discovered_removed = Mock()
|
||||||
|
thread_disovery = discovery.ThreadRouterDiscovery(
|
||||||
|
hass, router_discovered_removed, router_discovered_removed
|
||||||
|
)
|
||||||
|
await thread_disovery.async_start()
|
||||||
|
|
||||||
|
# Stop Thread router discovery
|
||||||
|
await thread_disovery.async_stop()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener.assert_called_once()
|
||||||
|
|
||||||
|
# Stop Thread router discovery again
|
||||||
|
await thread_disovery.async_stop()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener.assert_called_once()
|
@ -1,11 +1,21 @@
|
|||||||
"""Test the thread websocket API."""
|
"""Test the thread websocket API."""
|
||||||
|
|
||||||
from homeassistant.components.thread import dataset_store
|
from unittest.mock import ANY, AsyncMock
|
||||||
|
|
||||||
|
from zeroconf.asyncio import AsyncServiceInfo
|
||||||
|
|
||||||
|
from homeassistant.components.thread import dataset_store, discovery
|
||||||
from homeassistant.components.thread.const import DOMAIN
|
from homeassistant.components.thread.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from . import DATASET_1, DATASET_2, DATASET_3
|
from . import (
|
||||||
|
DATASET_1,
|
||||||
|
DATASET_2,
|
||||||
|
DATASET_3,
|
||||||
|
ROUTER_DISCOVERY_GOOGLE_1,
|
||||||
|
ROUTER_DISCOVERY_HASS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_add_dataset(hass: HomeAssistant, hass_ws_client) -> None:
|
async def test_add_dataset(hass: HomeAssistant, hass_ws_client) -> None:
|
||||||
@ -121,3 +131,98 @@ async def test_list_get_dataset(hass: HomeAssistant, hass_ws_client) -> None:
|
|||||||
msg = await client.receive_json()
|
msg = await client.receive_json()
|
||||||
assert not msg["success"]
|
assert not msg["success"]
|
||||||
assert msg["error"] == {"code": "not_found", "message": "unknown dataset"}
|
assert msg["error"] == {"code": "not_found", "message": "unknown dataset"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_discover_routers(
|
||||||
|
hass: HomeAssistant, hass_ws_client, mock_async_zeroconf
|
||||||
|
) -> None:
|
||||||
|
"""Test discovering thread routers."""
|
||||||
|
mock_async_zeroconf.async_add_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_remove_service_listener = AsyncMock()
|
||||||
|
mock_async_zeroconf.async_get_service_info = AsyncMock()
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
client = await hass_ws_client(hass)
|
||||||
|
|
||||||
|
# Subscribe
|
||||||
|
await client.send_json({"id": 1, "type": "thread/discover_routers"})
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg["success"]
|
||||||
|
assert msg["result"] is None
|
||||||
|
|
||||||
|
mock_async_zeroconf.async_add_service_listener.assert_called_once_with(
|
||||||
|
"_meshcop._udp.local.", ANY
|
||||||
|
)
|
||||||
|
listener: discovery.ThreadRouterDiscovery.ThreadServiceListener = (
|
||||||
|
mock_async_zeroconf.async_add_service_listener.mock_calls[0][1][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover a service
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_HASS
|
||||||
|
)
|
||||||
|
listener.add_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg == {
|
||||||
|
"event": {
|
||||||
|
"data": {
|
||||||
|
"brand": "homeassistant",
|
||||||
|
"extended_pan_id": "e60fc7c186212ce5",
|
||||||
|
"model_name": "OpenThreadBorderRouter",
|
||||||
|
"network_name": "OpenThread HC",
|
||||||
|
"server": "core-silabs-multiprotocol.local.",
|
||||||
|
"vendor_name": "HomeAssistant",
|
||||||
|
},
|
||||||
|
"key": "aeeb2f594b570bbf",
|
||||||
|
"type": "router_discovered",
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"type": "event",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Discover another service - we don't care if zeroconf considers this an update
|
||||||
|
mock_async_zeroconf.async_get_service_info.return_value = AsyncServiceInfo(
|
||||||
|
**ROUTER_DISCOVERY_GOOGLE_1
|
||||||
|
)
|
||||||
|
listener.update_service(
|
||||||
|
None, ROUTER_DISCOVERY_GOOGLE_1["type_"], ROUTER_DISCOVERY_GOOGLE_1["name"]
|
||||||
|
)
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg == {
|
||||||
|
"event": {
|
||||||
|
"data": {
|
||||||
|
"brand": "google",
|
||||||
|
"extended_pan_id": "9e75e256f61409a3",
|
||||||
|
"model_name": "Google Nest Hub",
|
||||||
|
"network_name": "NEST-PAN-E1AF",
|
||||||
|
"server": "2d99f293-cd8e-2770-8dd2-6675de9fa000.local.",
|
||||||
|
"vendor_name": "Google Inc.",
|
||||||
|
},
|
||||||
|
"key": "f6a99b425a67abed",
|
||||||
|
"type": "router_discovered",
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
"type": "event",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Remove a service
|
||||||
|
listener.remove_service(
|
||||||
|
None, ROUTER_DISCOVERY_HASS["type_"], ROUTER_DISCOVERY_HASS["name"]
|
||||||
|
)
|
||||||
|
msg = await client.receive_json()
|
||||||
|
assert msg == {
|
||||||
|
"event": {"key": "aeeb2f594b570bbf", "type": "router_removed"},
|
||||||
|
"id": 1,
|
||||||
|
"type": "event",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unsubscribe
|
||||||
|
await client.send_json({"id": 2, "type": "unsubscribe_events", "subscription": 1})
|
||||||
|
response = await client.receive_json()
|
||||||
|
assert response["success"]
|
||||||
|
|
||||||
|
mock_async_zeroconf.async_remove_service_listener.assert_called_once_with(listener)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user