Home Assistant Unofficial Reference 2024.12.1
discovery.py
Go to the documentation of this file.
1 """The Thread integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import dataclasses
7 import logging
8 from typing import cast
9 
10 from python_otbr_api.mdns import StateBitmap
11 from zeroconf import (
12  BadTypeInNameException,
13  DNSPointer,
14  ServiceListener,
15  Zeroconf,
16  instance_name_from_service_info,
17 )
18 from zeroconf.asyncio import AsyncServiceInfo, AsyncZeroconf
19 
20 from homeassistant.components import zeroconf
21 from homeassistant.core import HomeAssistant
22 
23 _LOGGER = logging.getLogger(__name__)
24 
25 KNOWN_BRANDS: dict[str | None, str] = {
26  "Amazon": "amazon",
27  "Apple Inc.": "apple",
28  "Aqara": "aqara_gateway",
29  "eero": "eero",
30  "Google Inc.": "google",
31  "HomeAssistant": "homeassistant",
32  "Home Assistant": "homeassistant",
33  "Nanoleaf": "nanoleaf",
34  "OpenThread": "openthread",
35  "Samsung": "samsung",
36 }
37 THREAD_TYPE = "_meshcop._udp.local."
38 CLASS_IN = 1
39 TYPE_PTR = 12
40 
41 
42 @dataclasses.dataclass
44  """Thread router discovery data."""
45 
46  instance_name: str
47  addresses: list[str]
48  border_agent_id: str | None
49  brand: str | None
50  extended_address: str
51  extended_pan_id: str
52  model_name: str | None
53  network_name: str | None
54  server: str | None
55  thread_version: str | None
56  unconfigured: bool | None
57  vendor_name: str | None
58 
59 
61  service: AsyncServiceInfo,
62  ext_addr: bytes,
63  ext_pan_id: bytes,
64 ) -> ThreadRouterDiscoveryData:
65  """Get a ThreadRouterDiscoveryData from an AsyncServiceInfo."""
66 
67  def try_decode(value: bytes | None) -> str | None:
68  """Try decoding UTF-8."""
69  if value is None:
70  return None
71  try:
72  return value.decode()
73  except UnicodeDecodeError:
74  return None
75 
76  service_properties = service.properties
77  border_agent_id = service_properties.get(b"id")
78  model_name = try_decode(service_properties.get(b"mn"))
79  network_name = try_decode(service_properties.get(b"nn"))
80  server = service.server
81  thread_version = try_decode(service_properties.get(b"tv"))
82  vendor_name = try_decode(service_properties.get(b"vn"))
83 
84  unconfigured = None
85  brand = KNOWN_BRANDS.get(vendor_name)
86  if brand == "homeassistant":
87  # Attempt to detect incomplete configuration
88  if (state_bitmap_b := service_properties.get(b"sb")) is not None:
89  try:
90  state_bitmap = StateBitmap.from_bytes(state_bitmap_b)
91  if not state_bitmap.is_active:
92  unconfigured = True
93  except ValueError:
94  _LOGGER.debug("Failed to decode state bitmap in service %s", service)
95  if service_properties.get(b"at") is None:
96  unconfigured = True
97 
99  instance_name=instance_name_from_service_info(service),
100  addresses=service.parsed_addresses(),
101  border_agent_id=border_agent_id.hex() if border_agent_id is not None else None,
102  brand=brand,
103  extended_address=ext_addr.hex(),
104  extended_pan_id=ext_pan_id.hex(),
105  model_name=model_name,
106  network_name=network_name,
107  server=server,
108  thread_version=thread_version,
109  unconfigured=unconfigured,
110  vendor_name=vendor_name,
111  )
112 
113 
114 def async_read_zeroconf_cache(aiozc: AsyncZeroconf) -> list[ThreadRouterDiscoveryData]:
115  """Return all meshcop records already in the zeroconf cache."""
116  results = []
117 
118  records = aiozc.zeroconf.cache.async_all_by_details(THREAD_TYPE, TYPE_PTR, CLASS_IN)
119  for record in records:
120  record = cast(DNSPointer, record)
121 
122  try:
123  info = AsyncServiceInfo(THREAD_TYPE, record.alias)
124  except BadTypeInNameException as ex:
125  _LOGGER.debug(
126  "Ignoring record with bad type in name: %s: %s", record.alias, ex
127  )
128  continue
129 
130  if not info.load_from_cache(aiozc.zeroconf):
131  # data is not fully in the cache, so ignore for now
132  continue
133 
134  service_properties = info.properties
135 
136  if not (xa := service_properties.get(b"xa")):
137  _LOGGER.debug("Ignoring record without xa %s", info)
138  continue
139  if not (xp := service_properties.get(b"xp")):
140  _LOGGER.debug("Ignoring record without xp %s", info)
141  continue
142 
143  results.append(async_discovery_data_from_service(info, xa, xp))
144 
145  return results
146 
147 
149  """mDNS based Thread router discovery."""
150 
151  class ThreadServiceListener(ServiceListener):
152  """Service listener which listens for thread routers."""
153 
154  def __init__(
155  self,
156  hass: HomeAssistant,
157  aiozc: AsyncZeroconf,
158  router_discovered: Callable,
159  router_removed: Callable,
160  ) -> None:
161  """Initialize."""
162  self._aiozc_aiozc = aiozc
163  self._hass_hass = hass
164  self._known_routers: dict[str, tuple[str, ThreadRouterDiscoveryData]] = {}
165  self._router_discovered_router_discovered = router_discovered
166  self._router_removed_router_removed = router_removed
167 
168  def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:
169  """Handle service added."""
170  _LOGGER.debug("add_service %s", name)
171  self._hass_hass.async_create_task(self._add_update_service_add_update_service(type_, name))
172 
173  def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:
174  """Handle service removed."""
175  _LOGGER.debug("remove_service %s", name)
176  if name not in self._known_routers:
177  return
178  extended_mac_address, _ = self._known_routers.pop(name)
179  self._router_removed_router_removed(extended_mac_address)
180 
181  def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:
182  """Handle service updated."""
183  _LOGGER.debug("update_service %s", name)
184  self._hass_hass.async_create_task(self._add_update_service_add_update_service(type_, name))
185 
186  async def _add_update_service(self, type_: str, name: str):
187  """Add or update a service."""
188  service = None
189  tries = 0
190  while service is None and tries < 4:
191  service = await self._aiozc_aiozc.async_get_service_info(type_, name)
192  tries += 1
193 
194  if not service:
195  _LOGGER.debug("_add_update_service failed to add %s, %s", type_, name)
196  return
197 
198  _LOGGER.debug("_add_update_service %s %s", name, service)
199  service_properties = service.properties
200 
201  # We need xa and xp, bail out if either is missing
202  if not (xa := service_properties.get(b"xa")):
203  _LOGGER.info(
204  "Discovered unsupported Thread router without extended address: %s",
205  service,
206  )
207  return
208  if not (xp := service_properties.get(b"xp")):
209  _LOGGER.info(
210  "Discovered unsupported Thread router without extended pan ID: %s",
211  service,
212  )
213  return
214 
215  data = async_discovery_data_from_service(service, xa, xp)
216  extended_mac_address = xa.hex()
217  if name in self._known_routers and self._known_routers[name] == (
218  extended_mac_address,
219  data,
220  ):
221  _LOGGER.debug(
222  "_add_update_service suppressing identical update for %s", name
223  )
224  return
225  self._known_routers[name] = (extended_mac_address, data)
226  self._router_discovered_router_discovered(extended_mac_address, data)
227 
228  def __init__(
229  self,
230  hass: HomeAssistant,
231  router_discovered: Callable[[str, ThreadRouterDiscoveryData], None],
232  router_removed: Callable[[str], None],
233  ) -> None:
234  """Initialize."""
235  self._hass_hass = hass
236  self._aiozc_aiozc: AsyncZeroconf | None = None
237  self._router_discovered_router_discovered = router_discovered
238  self._router_removed_router_removed = router_removed
239  self._service_listener_service_listener: ThreadRouterDiscovery.ThreadServiceListener | None = (
240  None
241  )
242 
243  async def async_start(self) -> None:
244  """Start discovery."""
245  self._aiozc_aiozc = aiozc = await zeroconf.async_get_async_instance(self._hass_hass)
246  self._service_listener_service_listener = self.ThreadServiceListenerThreadServiceListener(
247  self._hass_hass, aiozc, self._router_discovered_router_discovered, self._router_removed_router_removed
248  )
249  await aiozc.async_add_service_listener(THREAD_TYPE, self._service_listener_service_listener)
250 
251  async def async_stop(self) -> None:
252  """Stop discovery."""
253  if not self._aiozc_aiozc or not self._service_listener_service_listener:
254  return
255  await self._aiozc_aiozc.async_remove_service_listener(self._service_listener_service_listener)
256  self._service_listener_service_listener = None
None __init__(self, HomeAssistant hass, AsyncZeroconf aiozc, Callable router_discovered, Callable router_removed)
Definition: discovery.py:160
None __init__(self, HomeAssistant hass, Callable[[str, ThreadRouterDiscoveryData], None] router_discovered, Callable[[str], None] router_removed)
Definition: discovery.py:233
list[ThreadRouterDiscoveryData] async_read_zeroconf_cache(AsyncZeroconf aiozc)
Definition: discovery.py:114
ThreadRouterDiscoveryData async_discovery_data_from_service(AsyncServiceInfo service, bytes ext_addr, bytes ext_pan_id)
Definition: discovery.py:64