diff --git a/homeassistant/components/thread/discovery.py b/homeassistant/components/thread/discovery.py index 5a2ee54c5bb..b2373ff9825 100644 --- a/homeassistant/components/thread/discovery.py +++ b/homeassistant/components/thread/discovery.py @@ -6,6 +6,7 @@ import dataclasses import logging from typing import cast +from python_otbr_api.mdns import StateBitmap from zeroconf import BadTypeInNameException, DNSPointer, ServiceListener, Zeroconf from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf @@ -29,14 +30,15 @@ TYPE_PTR = 12 class ThreadRouterDiscoveryData: """Thread router discovery data.""" + addresses: list[str] | None brand: str | None extended_pan_id: str | None model_name: str | None network_name: str | None server: str | None - vendor_name: str | None - addresses: list[str] | None thread_version: str | None + unconfigured: bool | None + vendor_name: str | None def async_discovery_data_from_service( @@ -59,15 +61,30 @@ def async_discovery_data_from_service( server = service.server vendor_name = try_decode(service.properties.get(b"vn")) thread_version = try_decode(service.properties.get(b"tv")) + unconfigured = None + brand = KNOWN_BRANDS.get(vendor_name) + if brand == "homeassistant": + # Attempt to detect incomplete configuration + if (state_bitmap_b := service.properties.get(b"sb")) is not None: + try: + state_bitmap = StateBitmap.from_bytes(state_bitmap_b) + if not state_bitmap.is_active: + unconfigured = True + except ValueError: + _LOGGER.debug("Failed to decode state bitmap in service %s", service) + if service.properties.get(b"at") is None: + unconfigured = True + return ThreadRouterDiscoveryData( - brand=KNOWN_BRANDS.get(vendor_name), + addresses=service.parsed_addresses(), + brand=brand, 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, - addresses=service.parsed_addresses(), thread_version=thread_version, + unconfigured=unconfigured, + vendor_name=vendor_name, ) diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index c8e4453da1f..fd3cc3d9d85 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -185,3 +185,109 @@ ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA = { }, "interface_index": None, } + + +ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP = { + "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"pt": b"\x8f\x06Q~", + b"sq": b"3", + b"bb": b"\xf0\xbf", + b"dn": b"DefaultDomain", + }, + "interface_index": None, +} + + +ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP = { + "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"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_STATE_BITMAP = { + "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"\xff\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_STATE_BITMAP_NOT_ACTIVE = { + "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\x31", + 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, +} diff --git a/tests/components/thread/test_discovery.py b/tests/components/thread/test_discovery.py index fc19b3f10ac..e832f18c4e6 100644 --- a/tests/components/thread/test_discovery.py +++ b/tests/components/thread/test_discovery.py @@ -14,8 +14,12 @@ from . import ( ROUTER_DISCOVERY_GOOGLE_1, ROUTER_DISCOVERY_HASS, ROUTER_DISCOVERY_HASS_BAD_DATA, + ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, ROUTER_DISCOVERY_HASS_MISSING_DATA, ROUTER_DISCOVERY_HASS_MISSING_MANDATORY_DATA, + ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, + ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, + ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, ) @@ -67,14 +71,15 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) assert discovered[-1] == ( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], brand="homeassistant", extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", - vendor_name="HomeAssistant", thread_version="1.3.0", - addresses=["192.168.0.115"], + unconfigured=None, + vendor_name="HomeAssistant", ), ) @@ -91,14 +96,15 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) assert discovered[-1] == ( "f6a99b425a67abed", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.124"], 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.", thread_version="1.3.0", - addresses=["192.168.0.124"], + unconfigured=None, + vendor_name="Google Inc.", ), ) @@ -130,6 +136,56 @@ async def test_discover_routers(hass: HomeAssistant, mock_async_zeroconf: None) mock_async_zeroconf.async_remove_service_listener.assert_called_once_with(listener) +@pytest.mark.parametrize( + ("data", "unconfigured"), + [ + (ROUTER_DISCOVERY_HASS_NO_ACTIVE_TIMESTAMP, True), + (ROUTER_DISCOVERY_HASS_BAD_STATE_BITMAP, None), + (ROUTER_DISCOVERY_HASS_NO_STATE_BITMAP, None), + (ROUTER_DISCOVERY_HASS_STATE_BITMAP_NOT_ACTIVE, True), + ], +) +async def test_discover_routers_unconfigured( + hass: HomeAssistant, mock_async_zeroconf: None, data, unconfigured +) -> 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( + addresses=["192.168.0.115"], + brand="homeassistant", + extended_pan_id="e60fc7c186212ce5", + model_name="OpenThreadBorderRouter", + network_name="OpenThread HC", + server="core-silabs-multiprotocol.local.", + thread_version="1.3.0", + unconfigured=unconfigured, + vendor_name="HomeAssistant", + ), + ) + + @pytest.mark.parametrize( "data", (ROUTER_DISCOVERY_HASS_BAD_DATA, ROUTER_DISCOVERY_HASS_MISSING_DATA) ) @@ -161,14 +217,15 @@ async def test_discover_routers_bad_data( router_discovered_removed.assert_called_once_with( "aeeb2f594b570bbf", discovery.ThreadRouterDiscoveryData( + addresses=["192.168.0.115"], brand=None, extended_pan_id="e60fc7c186212ce5", model_name="OpenThreadBorderRouter", network_name="OpenThread HC", server="core-silabs-multiprotocol.local.", - vendor_name=None, thread_version="1.3.0", - addresses=["192.168.0.115"], + unconfigured=None, + vendor_name=None, ), ) diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index 2ebeef92c52..0f3a2ff7654 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -234,14 +234,15 @@ async def test_discover_routers( assert msg == { "event": { "data": { + "addresses": ["192.168.0.115"], "brand": "homeassistant", "extended_pan_id": "e60fc7c186212ce5", "model_name": "OpenThreadBorderRouter", "network_name": "OpenThread HC", "server": "core-silabs-multiprotocol.local.", - "vendor_name": "HomeAssistant", - "addresses": ["192.168.0.115"], "thread_version": "1.3.0", + "unconfigured": None, + "vendor_name": "HomeAssistant", }, "key": "aeeb2f594b570bbf", "type": "router_discovered", @@ -261,14 +262,15 @@ async def test_discover_routers( assert msg == { "event": { "data": { + "addresses": ["192.168.0.124"], "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.", "thread_version": "1.3.0", - "addresses": ["192.168.0.124"], + "unconfigured": None, + "vendor_name": "Google Inc.", }, "key": "f6a99b425a67abed", "type": "router_discovered",