Home Assistant Unofficial Reference 2024.12.1
util.py
Go to the documentation of this file.
1 """Utility functions for the Open Thread Border Router integration."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable, Coroutine
6 import dataclasses
7 from functools import wraps
8 import logging
9 import random
10 from typing import TYPE_CHECKING, Any, Concatenate, cast
11 
12 import aiohttp
13 import python_otbr_api
14 from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser
15 from python_otbr_api.pskc import compute_pskc
16 from python_otbr_api.tlv_parser import MeshcopTLVType
17 
19  MultiprotocolAddonManager,
20  get_multiprotocol_addon_manager,
21  is_multiprotocol_url,
22  multi_pan_addon_using_device,
23 )
24 from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO
25 from homeassistant.config_entries import SOURCE_USER
26 from homeassistant.core import HomeAssistant
27 from homeassistant.exceptions import HomeAssistantError
28 from homeassistant.helpers import issue_registry as ir
29 
30 from .const import DOMAIN
31 
32 if TYPE_CHECKING:
33  from . import OTBRConfigEntry
34 
35 _LOGGER = logging.getLogger(__name__)
36 
37 INFO_URL_SKY_CONNECT = (
38  "https://skyconnect.home-assistant.io/multiprotocol-channel-missmatch"
39 )
40 INFO_URL_YELLOW = "https://yellow.home-assistant.io/multiprotocol-channel-missmatch"
41 
42 INSECURE_NETWORK_KEYS = (
43  # Thread web UI default
44  bytes.fromhex("00112233445566778899AABBCCDDEEFF"),
45 )
46 
47 INSECURE_PASSPHRASES = (
48  # Thread web UI default
49  "j01Nme",
50  # Thread documentation default
51  "J01NME",
52 )
53 
54 
56  """Raised from python_otbr_api.GetBorderAgentIdNotSupportedError."""
57 
58 
59 def compose_default_network_name(pan_id: int) -> str:
60  """Generate a default network name."""
61  return f"ha-thread-{pan_id:04x}"
62 
63 
65  """Generate a random PAN ID."""
66  # PAN ID is 2 bytes, 0xffff is reserved for broadcast
67  return random.randint(0, 0xFFFE)
68 
69 
70 def _handle_otbr_error[**_P, _R](
71  func: Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]],
72 ) -> Callable[Concatenate[OTBRData, _P], Coroutine[Any, Any, _R]]:
73  """Handle OTBR errors."""
74 
75  @wraps(func)
76  async def _func(self: OTBRData, *args: _P.args, **kwargs: _P.kwargs) -> _R:
77  try:
78  return await func(self, *args, **kwargs)
79  except (python_otbr_api.OTBRError, aiohttp.ClientError, TimeoutError) as exc:
80  raise HomeAssistantError("Failed to call OTBR API") from exc
81 
82  return _func
83 
84 
85 @dataclasses.dataclass
86 class OTBRData:
87  """Container for OTBR data."""
88 
89  url: str
90  api: python_otbr_api.OTBR
91  entry_id: str
92 
93  @_handle_otbr_error
94  async def factory_reset(self, hass: HomeAssistant) -> None:
95  """Reset the router."""
96  try:
97  await self.api.factory_reset()
98  except python_otbr_api.FactoryResetNotSupportedError:
99  _LOGGER.warning(
100  "OTBR does not support factory reset, attempting to delete dataset"
101  )
102  await self.delete_active_dataset()
103  await update_unique_id(
104  hass,
105  hass.config_entries.async_get_entry(self.entry_id),
106  await self.get_border_agent_id(),
107  )
108 
109  @_handle_otbr_error
110  async def get_border_agent_id(self) -> bytes:
111  """Get the border agent ID or None if not supported by the router."""
112  try:
113  return await self.api.get_border_agent_id()
114  except python_otbr_api.GetBorderAgentIdNotSupportedError as exc:
115  raise GetBorderAgentIdNotSupported from exc
116 
117  @_handle_otbr_error
118  async def set_enabled(self, enabled: bool) -> None:
119  """Enable or disable the router."""
120  return await self.api.set_enabled(enabled)
121 
122  @_handle_otbr_error
123  async def get_active_dataset(self) -> python_otbr_api.ActiveDataSet | None:
124  """Get current active operational dataset, or None."""
125  return await self.api.get_active_dataset()
126 
127  @_handle_otbr_error
128  async def get_active_dataset_tlvs(self) -> bytes | None:
129  """Get current active operational dataset in TLVS format, or None."""
130  return await self.api.get_active_dataset_tlvs()
131 
132  @_handle_otbr_error
133  async def get_pending_dataset_tlvs(self) -> bytes | None:
134  """Get current pending operational dataset in TLVS format, or None."""
135  return await self.api.get_pending_dataset_tlvs()
136 
137  @_handle_otbr_error
138  async def create_active_dataset(
139  self, dataset: python_otbr_api.ActiveDataSet
140  ) -> None:
141  """Create an active operational dataset."""
142  return await self.api.create_active_dataset(dataset)
143 
144  @_handle_otbr_error
145  async def delete_active_dataset(self) -> None:
146  """Delete the active operational dataset."""
147  return await self.api.delete_active_dataset()
148 
149  @_handle_otbr_error
150  async def set_active_dataset_tlvs(self, dataset: bytes) -> None:
151  """Set current active operational dataset in TLVS format."""
152  await self.api.set_active_dataset_tlvs(dataset)
153 
154  @_handle_otbr_error
155  async def set_channel(
156  self, channel: int, delay: float = PENDING_DATASET_DELAY_TIMER / 1000
157  ) -> None:
158  """Set current channel."""
159  await self.api.set_channel(channel, delay=int(delay * 1000))
160 
161  @_handle_otbr_error
162  async def get_extended_address(self) -> bytes:
163  """Get extended address (EUI-64)."""
164  return await self.api.get_extended_address()
165 
166 
167 async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None:
168  """Return the allowed channel, or None if there's no restriction."""
169  if not is_multiprotocol_url(otbr_url):
170  # The OTBR is not sharing the radio, no restriction
171  return None
172 
173  multipan_manager: MultiprotocolAddonManager = await get_multiprotocol_addon_manager(
174  hass
175  )
176  return multipan_manager.async_get_channel()
177 
178 
180  hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes
181 ) -> None:
182  """Warn user if OTBR and ZHA attempt to use different channels."""
183 
184  def delete_issue() -> None:
185  ir.async_delete_issue(
186  hass,
187  DOMAIN,
188  f"otbr_zha_channel_collision_{otbrdata.entry_id}",
189  )
190 
191  if (allowed_channel := await get_allowed_channel(hass, otbrdata.url)) is None:
192  delete_issue()
193  return
194 
195  dataset = tlv_parser.parse_tlv(dataset_tlvs.hex())
196 
197  if (channel_s := dataset.get(MeshcopTLVType.CHANNEL)) is None:
198  delete_issue()
199  return
200  channel = cast(tlv_parser.Channel, channel_s).channel
201 
202  if channel == allowed_channel:
203  delete_issue()
204  return
205 
206  yellow = await multi_pan_addon_using_device(hass, YELLOW_RADIO)
207  learn_more_url = INFO_URL_YELLOW if yellow else INFO_URL_SKY_CONNECT
208 
209  ir.async_create_issue(
210  hass,
211  DOMAIN,
212  f"otbr_zha_channel_collision_{otbrdata.entry_id}",
213  is_fixable=False,
214  is_persistent=False,
215  learn_more_url=learn_more_url,
216  severity=ir.IssueSeverity.WARNING,
217  translation_key="otbr_zha_channel_collision",
218  translation_placeholders={
219  "otbr_channel": str(channel),
220  "zha_channel": str(allowed_channel),
221  },
222  )
223 
224 
226  hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes
227 ) -> None:
228  """Warn user if insecure default network settings are used."""
229  dataset = tlv_parser.parse_tlv(dataset_tlvs.hex())
230  insecure = False
231 
232  if (
233  network_key := dataset.get(MeshcopTLVType.NETWORKKEY)
234  ) is not None and network_key.data in INSECURE_NETWORK_KEYS:
235  insecure = True
236  if (
237  not insecure
238  and MeshcopTLVType.EXTPANID in dataset
239  and MeshcopTLVType.NETWORKNAME in dataset
240  and MeshcopTLVType.PSKC in dataset
241  ):
242  ext_pan_id = dataset[MeshcopTLVType.EXTPANID]
243  network_name = cast(tlv_parser.NetworkName, dataset[MeshcopTLVType.NETWORKNAME])
244  pskc = dataset[MeshcopTLVType.PSKC].data
245  for passphrase in INSECURE_PASSPHRASES:
246  if pskc == compute_pskc(ext_pan_id.data, network_name.name, passphrase):
247  insecure = True
248  break
249 
250  if insecure:
251  ir.async_create_issue(
252  hass,
253  DOMAIN,
254  f"insecure_thread_network_{otbrdata.entry_id}",
255  is_fixable=False,
256  is_persistent=False,
257  severity=ir.IssueSeverity.WARNING,
258  translation_key="insecure_thread_network",
259  )
260  else:
261  ir.async_delete_issue(
262  hass,
263  DOMAIN,
264  f"insecure_thread_network_{otbrdata.entry_id}",
265  )
266 
267 
268 async def update_issues(
269  hass: HomeAssistant, otbrdata: OTBRData, dataset_tlvs: bytes
270 ) -> None:
271  """Raise or clear repair issues related to network settings."""
272  await _warn_on_channel_collision(hass, otbrdata, dataset_tlvs)
273  _warn_on_default_network_settings(hass, otbrdata, dataset_tlvs)
274 
275 
277  hass: HomeAssistant, entry: OTBRConfigEntry | None, border_agent_id: bytes
278 ) -> None:
279  """Update the config entry's unique_id if not matching."""
280  border_agent_id_hex = border_agent_id.hex()
281  if entry and entry.source == SOURCE_USER and entry.unique_id != border_agent_id_hex:
282  _LOGGER.debug(
283  "Updating unique_id of entry %s from %s to %s",
284  entry.entry_id,
285  entry.unique_id,
286  border_agent_id_hex,
287  )
288  hass.config_entries.async_update_entry(entry, unique_id=border_agent_id_hex)
None factory_reset(self, HomeAssistant hass)
Definition: util.py:94
None _warn_on_channel_collision(HomeAssistant hass, OTBRData otbrdata, bytes dataset_tlvs)
Definition: util.py:181
int|None get_allowed_channel(HomeAssistant hass, str otbr_url)
Definition: util.py:167
None update_unique_id(HomeAssistant hass, OTBRConfigEntry|None entry, bytes border_agent_id)
Definition: util.py:278
str compose_default_network_name(int pan_id)
Definition: util.py:59
None _warn_on_default_network_settings(HomeAssistant hass, OTBRData otbrdata, bytes dataset_tlvs)
Definition: util.py:227
None update_issues(HomeAssistant hass, OTBRData otbrdata, bytes dataset_tlvs)
Definition: util.py:270
None delete_issue(HomeAssistant hass, str domain, str issue_id)