diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 78c5893c88..723a52828e 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -71,6 +71,11 @@ class OTBRData: """Get current active operational dataset in TLVS format, or None.""" return await self.api.get_active_dataset_tlvs() + @_handle_otbr_error + async def set_active_dataset_tlvs(self, dataset: bytes) -> None: + """Set current active operational dataset in TLVS format.""" + await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error async def create_active_dataset( self, dataset: python_otbr_api.OperationalDataSet diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0e9c8e9606..a7bcac05b9 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import logging +from typing import TYPE_CHECKING, Any import aiohttp import python_otbr_api @@ -10,14 +11,27 @@ from python_otbr_api import tlv_parser import voluptuous as vol from homeassistant.components.hassio import HassioServiceInfo -from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.components.thread import ( + async_add_dataset, + async_get_preferred_dataset, + async_set_preferred_dataset, +) +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) from homeassistant.const import CONF_URL +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DEFAULT_CHANNEL, DOMAIN +if TYPE_CHECKING: + from . import OTBRData + _LOGGER = logging.getLogger(__name__) @@ -26,6 +40,14 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OTBROptionsFlow: + """Get the options flow for this handler.""" + return OTBROptionsFlow(config_entry) + async def _connect_and_create_dataset(self, url: str) -> None: """Connect to the OTBR and create a dataset if it doesn't have one.""" api = python_otbr_api.OTBR(url, async_get_clientsession(self.hass), 10) @@ -101,5 +123,148 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(DOMAIN) return self.async_create_entry( title="Open Thread Border Router", - data={"url": url}, + data={CONF_URL: url}, + ) + + +class OTBROptionsFlow(OptionsFlowWithConfigEntry): + """Handle OTBR options.""" + + async def async_step_init(self, user_input: None = None) -> FlowResult: + """Manage the OTBR options.""" + if DOMAIN not in self.hass.data: + return self.async_abort(reason="config_entry_not_setup") + + data: OTBRData = self.hass.data[DOMAIN] + + menu_options = ["create_network"] + placeholders = {} + + otbr_dataset: dict[tlv_parser.MeshcopTLVType, str] | None = None + preferred_dataset: dict[tlv_parser.MeshcopTLVType, str] | None = None + + try: + if otbr_dataset_tlv := await data.get_active_dataset_tlvs(): + otbr_dataset = tlv_parser.parse_tlv(otbr_dataset_tlv.hex()) + except HomeAssistantError: + _LOGGER.warning("Could not read active dataset", exc_info=True) + + if preferred_dataset_tlv := await async_get_preferred_dataset(self.hass): + preferred_dataset = tlv_parser.parse_tlv(preferred_dataset_tlv) + + if otbr_dataset and otbr_dataset != preferred_dataset: + menu_options.append("prefer_otbr_network") + placeholders["otbr_network"] = otbr_dataset.get( + tlv_parser.MeshcopTLVType.NETWORKNAME, "" + ) + + if preferred_dataset and preferred_dataset != otbr_dataset: + menu_options.append("use_preferred_network") + placeholders["preferred_network"] = preferred_dataset.get( + tlv_parser.MeshcopTLVType.NETWORKNAME, "" + ) + + self.context["placeholders"] = placeholders + return self.async_show_menu( + step_id="thread_network_menu", + menu_options=menu_options, + description_placeholders=placeholders, + ) + + async def async_step_create_network( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create a new network, and optionally mark it as preferred.""" + if user_input is not None: + data: OTBRData = self.hass.data[DOMAIN] + + # We currently have no way to know which channel zha is using, assume it's + # the default + zha_channel = DEFAULT_CHANNEL + + try: + # Disable the router, create a new network and read it back + await data.set_enabled(False) + await data.create_active_dataset( + python_otbr_api.OperationalDataSet( + channel=zha_channel, network_name="home-assistant" + ) + ) + await data.set_enabled(True) + dataset_tlvs = await data.get_active_dataset_tlvs() + except HomeAssistantError: + _LOGGER.warning("Failed to create new Thread network", exc_info=True) + return self.async_abort(reason="unknown") + if not dataset_tlvs: + _LOGGER.warning("Got empty network") + return self.async_abort(reason="unknown") + + dataset_id = await async_add_dataset( + self.hass, self._config_entry.title, dataset_tlvs.hex() + ) + if user_input["set_as_preferred"]: + await async_set_preferred_dataset(self.hass, dataset_id) + + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="create_network", + data_schema=vol.Schema( + {vol.Required("set_as_preferred", default=True): bool} + ), + ) + + async def async_step_prefer_otbr_network( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Make the OTBR's network the preferred network.""" + + if user_input is not None: + data: OTBRData = self.hass.data[DOMAIN] + + try: + dataset_tlvs = await data.get_active_dataset_tlvs() + except HomeAssistantError: + _LOGGER.warning("Failed to get the active network", exc_info=True) + return self.async_abort(reason="unknown") + if not dataset_tlvs: + _LOGGER.warning("Got empty network") + return self.async_abort(reason="unknown") + + dataset_id = await async_add_dataset( + self.hass, self._config_entry.title, dataset_tlvs.hex() + ) + await async_set_preferred_dataset(self.hass, dataset_id) + + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="prefer_otbr_network", + description_placeholders=self.context["placeholders"], + ) + + async def async_step_use_preferred_network( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Make the OTBR use the preferred network.""" + + if not (thread_dataset_tlv := await async_get_preferred_dataset(self.hass)): + _LOGGER.warning("No preferred network") + return self.async_abort(reason="unknown") + + if user_input is not None: + data: OTBRData = self.hass.data[DOMAIN] + + try: + await data.set_enabled(False) + await data.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv)) + await data.set_enabled(True) + except HomeAssistantError: + return self.async_abort(reason="unknown") + + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="use_preferred_network", + description_placeholders=self.context["placeholders"], ) diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json index f2efea0c1e..6d6635a43f 100644 --- a/homeassistant/components/otbr/strings.json +++ b/homeassistant/components/otbr/strings.json @@ -15,6 +15,37 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "options": { + "step": { + "thread_network_menu": { + "title": "Reconfigure the OTBR's Thread network", + "menu_options": { + "create_network": "[%key:component::otbr::options::step::create_network::title%]", + "prefer_otbr_network": "[%key:component::otbr::options::step::prefer_otbr_network::title%]", + "use_preferred_network": "[%key:component::otbr::options::step::use_preferred_network::title%]" + } + }, + "create_network": { + "title": "Reset Thread network", + "data": { + "set_as_preferred": "Make the new network the preferred network" + }, + "description": "This will reset the Home Assistant Border Router to its factory defaults and form a new Thread network.\n\nThe old network may no longer be available, and any devices that were attached to this network may need to be recomissioned." + }, + "prefer_otbr_network": { + "title": "Prefer Thread network ''{otbr_network}''", + "description": "Use this Thread network when commissioning Thread devices with Home Assistant.\n\nAlready commisioned devices will remain on their current networks." + }, + "use_preferred_network": { + "title": "Join Thread network ''{preferred_network}''", + "description": "This will change the network your Home Assistant Border Router is attached to.\n\nThe old network may no longer be available, and any devices that were attached to this network may need to be recomissioned." + } + }, + "abort": { + "config_entry_not_setup": "The OTBR config entry is not setup", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, "issues": { "insecure_thread_network": { "title": "Insecure Thread network settings detected", diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 345fca854d..9abe32665f 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -6,7 +6,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .dataset_store import DatasetEntry, async_add_dataset, async_get_preferred_dataset +from .dataset_store import ( + DatasetEntry, + async_add_dataset, + async_get_preferred_dataset, + async_set_preferred_dataset, +) from .websocket_api import async_setup as async_setup_ws_api __all__ = [ @@ -14,6 +19,7 @@ __all__ = [ "DatasetEntry", "async_add_dataset", "async_get_preferred_dataset", + "async_set_preferred_dataset", ] diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index b9a27b617e..482f69c8de 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -82,13 +82,16 @@ class DatasetStore: ) @callback - def async_add(self, source: str, tlv: str) -> None: + def async_add(self, source: str, tlv: str) -> str: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid dataset = tlv_parser.parse_tlv(tlv) # Bail out if the dataset already exists - if any(entry for entry in self.datasets.values() if entry.dataset == dataset): - return + if entry := next( + (entry for entry in self.datasets.values() if entry.dataset == dataset), + None, + ): + return entry.id entry = DatasetEntry(source=source, tlv=tlv) self.datasets[entry.id] = entry @@ -96,6 +99,7 @@ class DatasetStore: if self.preferred_dataset is None: self.preferred_dataset = entry.id self.async_schedule_save() + return entry.id @callback def async_delete(self, dataset_id: str) -> None: @@ -110,6 +114,14 @@ class DatasetStore: """Get dataset by id.""" return self.datasets.get(dataset_id) + @callback + def async_set_preferred(self, dataset_id: str) -> None: + """Set preferred dataset.""" + if dataset_id not in self.datasets: + raise DatasetPreferredError("attempt to set unknown preferred dataset") + self.preferred_dataset = dataset_id + self.async_schedule_save() + async def async_load(self) -> None: """Load the datasets.""" data = await self._store.async_load() @@ -153,10 +165,10 @@ async def async_get_store(hass: HomeAssistant) -> DatasetStore: return store -async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> None: +async def async_add_dataset(hass: HomeAssistant, source: str, tlv: str) -> str: """Add a dataset.""" store = await async_get_store(hass) - store.async_add(source, tlv) + return store.async_add(source, tlv) async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: @@ -167,3 +179,9 @@ async def async_get_preferred_dataset(hass: HomeAssistant) -> str | None: ) is None: return None return entry.tlv + + +async def async_set_preferred_dataset(hass: HomeAssistant, dataset_id: str) -> None: + """Set the preferred dataset.""" + store = await async_get_store(hass) + store.async_set_preferred(dataset_id)