Home Assistant Unofficial Reference 2024.12.1
config_flow.py
Go to the documentation of this file.
1 """Config flow for the Open Thread Border Router integration."""
2 
3 from __future__ import annotations
4 
5 from contextlib import suppress
6 import logging
7 from typing import TYPE_CHECKING, cast
8 
9 import aiohttp
10 import python_otbr_api
11 from python_otbr_api import tlv_parser
12 from python_otbr_api.tlv_parser import MeshcopTLVType
13 import voluptuous as vol
14 import yarl
15 
16 from homeassistant.components.hassio import AddonError, AddonManager
17 from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
18 from homeassistant.components.thread import async_get_preferred_dataset
19 from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult
20 from homeassistant.const import CONF_URL
21 from homeassistant.core import HomeAssistant, callback
22 from homeassistant.exceptions import HomeAssistantError
23 from homeassistant.helpers.aiohttp_client import async_get_clientsession
24 from homeassistant.helpers.service_info.hassio import HassioServiceInfo
25 
26 from .const import DEFAULT_CHANNEL, DOMAIN
27 from .util import (
28  compose_default_network_name,
29  generate_random_pan_id,
30  get_allowed_channel,
31 )
32 
33 if TYPE_CHECKING:
34  from . import OTBRConfigEntry
35 
36 _LOGGER = logging.getLogger(__name__)
37 
38 
40  """Raised when the router is already configured."""
41 
42 
43 @callback
44 def get_addon_manager(hass: HomeAssistant, slug: str) -> AddonManager:
45  """Get the add-on manager."""
46  return AddonManager(hass, _LOGGER, "OpenThread Border Router", slug)
47 
48 
49 def _is_yellow(hass: HomeAssistant) -> bool:
50  """Return True if Home Assistant is running on a Home Assistant Yellow."""
51  try:
52  yellow_hardware.async_info(hass)
53  except HomeAssistantError:
54  return False
55  return True
56 
57 
58 async def _title(hass: HomeAssistant, discovery_info: HassioServiceInfo) -> str:
59  """Return config entry title."""
60  device: str | None = None
61  addon_manager = get_addon_manager(hass, discovery_info.slug)
62 
63  with suppress(AddonError):
64  addon_info = await addon_manager.async_get_addon_info()
65  device = addon_info.options.get("device")
66 
67  if _is_yellow(hass) and device == "/dev/ttyAMA1":
68  return f"Home Assistant Yellow ({discovery_info.name})"
69 
70  if device and "SkyConnect" in device:
71  return f"Home Assistant SkyConnect ({discovery_info.name})"
72 
73  if device and "Connect_ZBT-1" in device:
74  return f"Home Assistant Connect ZBT-1 ({discovery_info.name})"
75 
76  return discovery_info.name
77 
78 
79 class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
80  """Handle a config flow for Open Thread Border Router."""
81 
82  VERSION = 1
83 
84  async def _set_dataset(self, api: python_otbr_api.OTBR, otbr_url: str) -> None:
85  """Connect to the OTBR and create or apply a dataset if it doesn't have one."""
86  if await api.get_active_dataset_tlvs() is None:
87  allowed_channel = await get_allowed_channel(self.hass, otbr_url)
88 
89  thread_dataset_channel = None
90  thread_dataset_tlv = await async_get_preferred_dataset(self.hass)
91  if thread_dataset_tlv:
92  dataset = tlv_parser.parse_tlv(thread_dataset_tlv)
93  if channel := dataset.get(MeshcopTLVType.CHANNEL):
94  thread_dataset_channel = cast(tlv_parser.Channel, channel).channel
95 
96  if thread_dataset_tlv is not None and (
97  not allowed_channel or allowed_channel == thread_dataset_channel
98  ):
99  await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv))
100  else:
101  _LOGGER.debug(
102  "not importing TLV with channel %s for %s",
103  thread_dataset_channel,
104  otbr_url,
105  )
106  pan_id = generate_random_pan_id()
107  await api.create_active_dataset(
108  python_otbr_api.ActiveDataSet(
109  channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL,
110  network_name=compose_default_network_name(pan_id),
111  pan_id=pan_id,
112  )
113  )
114  await api.set_enabled(True)
115 
116  async def _is_border_agent_id_configured(self, border_agent_id: bytes) -> bool:
117  """Return True if another config entry's OTBR has the same border agent id."""
118  config_entry: OTBRConfigEntry
119  for config_entry in self.hass.config_entries.async_loaded_entries(DOMAIN):
120  data = config_entry.runtime_data
121  try:
122  other_border_agent_id = await data.get_border_agent_id()
123  except HomeAssistantError:
124  _LOGGER.debug(
125  "Could not read border agent id from %s", data.url, exc_info=True
126  )
127  continue
128  _LOGGER.debug(
129  "border agent id for existing url %s: %s",
130  data.url,
131  other_border_agent_id.hex(),
132  )
133  if border_agent_id == other_border_agent_id:
134  return True
135  return False
136 
137  async def _connect_and_configure_router(self, otbr_url: str) -> bytes:
138  """Connect to the router and configure it if needed.
139 
140  Will raise if the router's border agent id is in use by another config entry.
141  Returns the router's border agent id.
142  """
143  api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10)
144  border_agent_id = await api.get_border_agent_id()
145  _LOGGER.debug("border agent id for url %s: %s", otbr_url, border_agent_id.hex())
146 
147  if await self._is_border_agent_id_configured_is_border_agent_id_configured(border_agent_id):
148  raise AlreadyConfigured
149 
150  await self._set_dataset_set_dataset(api, otbr_url)
151 
152  return border_agent_id
153 
154  async def async_step_user(
155  self, user_input: dict[str, str] | None = None
156  ) -> ConfigFlowResult:
157  """Set up by user."""
158  errors = {}
159 
160  if user_input is not None:
161  url = user_input[CONF_URL].rstrip("/")
162  try:
163  border_agent_id = await self._connect_and_configure_router_connect_and_configure_router(url)
164  except AlreadyConfigured:
165  errors["base"] = "already_configured"
166  except (
167  python_otbr_api.OTBRError,
168  aiohttp.ClientError,
169  TimeoutError,
170  ) as exc:
171  _LOGGER.debug("Failed to communicate with OTBR@%s: %s", url, exc)
172  errors["base"] = "cannot_connect"
173  else:
174  await self.async_set_unique_idasync_set_unique_id(border_agent_id.hex())
175  return self.async_create_entryasync_create_entryasync_create_entry(
176  title="Open Thread Border Router",
177  data={CONF_URL: url},
178  )
179 
180  data_schema = vol.Schema({CONF_URL: str})
181  return self.async_show_formasync_show_formasync_show_form(
182  step_id="user", data_schema=data_schema, errors=errors
183  )
184 
185  async def async_step_hassio(
186  self, discovery_info: HassioServiceInfo
187  ) -> ConfigFlowResult:
188  """Handle hassio discovery."""
189  config = discovery_info.config
190  url = f"http://{config['host']}:{config['port']}"
191  config_entry_data = {"url": url}
192 
193  if current_entries := self._async_current_entries_async_current_entries():
194  for current_entry in current_entries:
195  if current_entry.source != SOURCE_HASSIO:
196  continue
197  current_url = yarl.URL(current_entry.data["url"])
198  if not (unique_id := current_entry.unique_id):
199  # The first version did not set a unique_id
200  # so if the entry does not have a unique_id
201  # we have to assume it's the first version
202  # This check can be removed in HA Core 2025.9
203  unique_id = discovery_info.uuid
204  if (
205  unique_id != discovery_info.uuid
206  or current_url.host != config["host"]
207  or current_url.port == config["port"]
208  ):
209  continue
210  # Update URL with the new port
211  self.hass.config_entries.async_update_entry(
212  current_entry,
213  data=config_entry_data,
214  unique_id=unique_id, # Remove in HA Core 2025.9
215  )
216  return self.async_abortasync_abortasync_abort(reason="already_configured")
217 
218  try:
219  await self._connect_and_configure_router_connect_and_configure_router(url)
220  except AlreadyConfigured:
221  return self.async_abortasync_abortasync_abort(reason="already_configured")
222  except (
223  python_otbr_api.OTBRError,
224  aiohttp.ClientError,
225  TimeoutError,
226  ) as exc:
227  _LOGGER.warning("Failed to communicate with OTBR@%s: %s", url, exc)
228  return self.async_abortasync_abortasync_abort(reason="unknown")
229 
230  await self.async_set_unique_idasync_set_unique_id(discovery_info.uuid)
231  return self.async_create_entryasync_create_entryasync_create_entry(
232  title=await _title(self.hass, discovery_info),
233  data=config_entry_data,
234  )
ConfigFlowResult async_step_hassio(self, HassioServiceInfo discovery_info)
Definition: config_flow.py:187
bool _is_border_agent_id_configured(self, bytes border_agent_id)
Definition: config_flow.py:116
ConfigFlowResult async_step_user(self, dict[str, str]|None user_input=None)
Definition: config_flow.py:156
None _set_dataset(self, python_otbr_api.OTBR api, str otbr_url)
Definition: config_flow.py:84
ConfigEntry|None async_set_unique_id(self, str|None unique_id=None, *bool raise_on_progress=True)
ConfigFlowResult async_create_entry(self, *str title, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None, Mapping[str, Any]|None options=None)
list[ConfigEntry] _async_current_entries(self, bool|None include_ignore=None)
ConfigFlowResult async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
ConfigFlowResult async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_show_form(self, *str|None step_id=None, vol.Schema|None data_schema=None, dict[str, str]|None errors=None, Mapping[str, str]|None description_placeholders=None, bool|None last_step=None, str|None preview=None)
_FlowResultT async_create_entry(self, *str|None title=None, Mapping[str, Any] data, str|None description=None, Mapping[str, str]|None description_placeholders=None)
_FlowResultT async_abort(self, *str reason, Mapping[str, str]|None description_placeholders=None)
AddonManager get_addon_manager(HomeAssistant hass, str slug)
Definition: config_flow.py:44
str _title(HomeAssistant hass, HassioServiceInfo discovery_info)
Definition: config_flow.py:58
bool _is_yellow(HomeAssistant hass)
Definition: config_flow.py:49
int|None get_allowed_channel(HomeAssistant hass, str otbr_url)
Definition: util.py:167
str compose_default_network_name(int pan_id)
Definition: util.py:59
str|None async_get_preferred_dataset(HomeAssistant hass)
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)