mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 04:07:08 +00:00
Add support for multiple otbr config entries (#124289)
* Add support for multiple otbr config entries * Fix test * Drop useless fixture * Address review comments * Change unique id from xa to id * Improve error text * Store data in ConfigEntry.runtime_data * Remove useless function
This commit is contained in:
parent
52b6f00363
commit
e2d84f9a58
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
|
|
||||||
@ -14,22 +16,28 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import websocket_api
|
from . import websocket_api
|
||||||
from .const import DATA_OTBR, DOMAIN
|
from .const import DOMAIN
|
||||||
from .util import OTBRData, update_issues
|
from .util import (
|
||||||
|
GetBorderAgentIdNotSupported,
|
||||||
|
OTBRData,
|
||||||
|
update_issues,
|
||||||
|
update_unique_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||||
|
|
||||||
|
type OTBRConfigEntry = ConfigEntry[OTBRData]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Open Thread Border Router component."""
|
"""Set up the Open Thread Border Router component."""
|
||||||
websocket_api.async_setup(hass)
|
websocket_api.async_setup(hass)
|
||||||
if len(config_entries := hass.config_entries.async_entries(DOMAIN)):
|
|
||||||
for config_entry in config_entries[1:]:
|
|
||||||
await hass.config_entries.async_remove(config_entry.entry_id)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool:
|
||||||
"""Set up an Open Thread Border Router config entry."""
|
"""Set up an Open Thread Border Router config entry."""
|
||||||
api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10)
|
api = python_otbr_api.OTBR(entry.data["url"], async_get_clientsession(hass), 10)
|
||||||
|
|
||||||
@ -38,13 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
border_agent_id = await otbrdata.get_border_agent_id()
|
border_agent_id = await otbrdata.get_border_agent_id()
|
||||||
dataset_tlvs = await otbrdata.get_active_dataset_tlvs()
|
dataset_tlvs = await otbrdata.get_active_dataset_tlvs()
|
||||||
extended_address = await otbrdata.get_extended_address()
|
extended_address = await otbrdata.get_extended_address()
|
||||||
except (
|
except GetBorderAgentIdNotSupported:
|
||||||
HomeAssistantError,
|
|
||||||
aiohttp.ClientError,
|
|
||||||
TimeoutError,
|
|
||||||
) as err:
|
|
||||||
raise ConfigEntryNotReady("Unable to connect") from err
|
|
||||||
if border_agent_id is None:
|
|
||||||
ir.async_create_issue(
|
ir.async_create_issue(
|
||||||
hass,
|
hass,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -55,6 +57,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
translation_key="get_get_border_agent_id_unsupported",
|
translation_key="get_get_border_agent_id_unsupported",
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
except (
|
||||||
|
HomeAssistantError,
|
||||||
|
aiohttp.ClientError,
|
||||||
|
TimeoutError,
|
||||||
|
) as err:
|
||||||
|
raise ConfigEntryNotReady("Unable to connect") from err
|
||||||
|
await update_unique_id(hass, entry, border_agent_id)
|
||||||
if dataset_tlvs:
|
if dataset_tlvs:
|
||||||
await update_issues(hass, otbrdata, dataset_tlvs)
|
await update_issues(hass, otbrdata, dataset_tlvs)
|
||||||
await async_add_dataset(
|
await async_add_dataset(
|
||||||
@ -66,18 +75,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||||
|
entry.runtime_data = otbrdata
|
||||||
hass.data[DATA_OTBR] = otbrdata
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
hass.data.pop(DATA_OTBR)
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def async_reload_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> None:
|
||||||
"""Handle an options update."""
|
"""Handle an options update."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
import logging
|
import logging
|
||||||
from typing import cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
@ -33,9 +33,16 @@ from .util import (
|
|||||||
get_allowed_channel,
|
get_allowed_channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import OTBRConfigEntry
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyConfigured(HomeAssistantError):
|
||||||
|
"""Raised when the router is already configured."""
|
||||||
|
|
||||||
|
|
||||||
def _is_yellow(hass: HomeAssistant) -> bool:
|
def _is_yellow(hass: HomeAssistant) -> bool:
|
||||||
"""Return True if Home Assistant is running on a Home Assistant Yellow."""
|
"""Return True if Home Assistant is running on a Home Assistant Yellow."""
|
||||||
try:
|
try:
|
||||||
@ -70,9 +77,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
async def _connect_and_set_dataset(self, otbr_url: str) -> None:
|
async def _set_dataset(self, api: python_otbr_api.OTBR, otbr_url: str) -> None:
|
||||||
"""Connect to the OTBR and create or apply a dataset if it doesn't have one."""
|
"""Connect to the OTBR and create or apply a dataset if it doesn't have one."""
|
||||||
api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10)
|
|
||||||
if await api.get_active_dataset_tlvs() is None:
|
if await api.get_active_dataset_tlvs() is None:
|
||||||
allowed_channel = await get_allowed_channel(self.hass, otbr_url)
|
allowed_channel = await get_allowed_channel(self.hass, otbr_url)
|
||||||
|
|
||||||
@ -89,7 +95,9 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv))
|
await api.set_active_dataset_tlvs(bytes.fromhex(thread_dataset_tlv))
|
||||||
else:
|
else:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"not importing TLV with channel %s", thread_dataset_channel
|
"not importing TLV with channel %s for %s",
|
||||||
|
thread_dataset_channel,
|
||||||
|
otbr_url,
|
||||||
)
|
)
|
||||||
pan_id = generate_random_pan_id()
|
pan_id = generate_random_pan_id()
|
||||||
await api.create_active_dataset(
|
await api.create_active_dataset(
|
||||||
@ -101,27 +109,65 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
await api.set_enabled(True)
|
await api.set_enabled(True)
|
||||||
|
|
||||||
|
async def _is_border_agent_id_configured(self, border_agent_id: bytes) -> bool:
|
||||||
|
"""Return True if another config entry's OTBR has the same border agent id."""
|
||||||
|
config_entry: OTBRConfigEntry
|
||||||
|
for config_entry in self.hass.config_entries.async_loaded_entries(DOMAIN):
|
||||||
|
data = config_entry.runtime_data
|
||||||
|
try:
|
||||||
|
other_border_agent_id = await data.get_border_agent_id()
|
||||||
|
except HomeAssistantError:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Could not read border agent id from %s", data.url, exc_info=True
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
_LOGGER.debug(
|
||||||
|
"border agent id for existing url %s: %s",
|
||||||
|
data.url,
|
||||||
|
other_border_agent_id.hex(),
|
||||||
|
)
|
||||||
|
if border_agent_id == other_border_agent_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _connect_and_configure_router(self, otbr_url: str) -> bytes:
|
||||||
|
"""Connect to the router and configure it if needed.
|
||||||
|
|
||||||
|
Will raise if the router's border agent id is in use by another config entry.
|
||||||
|
Returns the router's border agent id.
|
||||||
|
"""
|
||||||
|
api = python_otbr_api.OTBR(otbr_url, async_get_clientsession(self.hass), 10)
|
||||||
|
border_agent_id = await api.get_border_agent_id()
|
||||||
|
_LOGGER.debug("border agent id for url %s: %s", otbr_url, border_agent_id.hex())
|
||||||
|
|
||||||
|
if await self._is_border_agent_id_configured(border_agent_id):
|
||||||
|
raise AlreadyConfigured
|
||||||
|
|
||||||
|
await self._set_dataset(api, otbr_url)
|
||||||
|
|
||||||
|
return border_agent_id
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, str] | None = None
|
self, user_input: dict[str, str] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Set up by user."""
|
"""Set up by user."""
|
||||||
if self._async_current_entries():
|
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
url = user_input[CONF_URL].rstrip("/")
|
url = user_input[CONF_URL].rstrip("/")
|
||||||
try:
|
try:
|
||||||
await self._connect_and_set_dataset(url)
|
border_agent_id = await self._connect_and_configure_router(url)
|
||||||
|
except AlreadyConfigured:
|
||||||
|
errors["base"] = "already_configured"
|
||||||
except (
|
except (
|
||||||
python_otbr_api.OTBRError,
|
python_otbr_api.OTBRError,
|
||||||
aiohttp.ClientError,
|
aiohttp.ClientError,
|
||||||
TimeoutError,
|
TimeoutError,
|
||||||
):
|
) as exc:
|
||||||
|
_LOGGER.debug("Failed to communicate with OTBR@%s: %s", url, exc)
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
else:
|
else:
|
||||||
await self.async_set_unique_id(DOMAIN)
|
await self.async_set_unique_id(border_agent_id.hex())
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="Open Thread Border Router",
|
title="Open Thread Border Router",
|
||||||
data={CONF_URL: url},
|
data={CONF_URL: url},
|
||||||
@ -140,34 +186,35 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
url = f"http://{config['host']}:{config['port']}"
|
url = f"http://{config['host']}:{config['port']}"
|
||||||
config_entry_data = {"url": url}
|
config_entry_data = {"url": url}
|
||||||
|
|
||||||
if self._async_in_progress(include_uninitialized=True):
|
|
||||||
# We currently don't handle multiple config entries, abort if hassio
|
|
||||||
# discovers multiple addons with otbr support
|
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
|
||||||
|
|
||||||
if current_entries := self._async_current_entries():
|
if current_entries := self._async_current_entries():
|
||||||
for current_entry in current_entries:
|
for current_entry in current_entries:
|
||||||
if current_entry.source != SOURCE_HASSIO:
|
if current_entry.source != SOURCE_HASSIO:
|
||||||
continue
|
continue
|
||||||
current_url = yarl.URL(current_entry.data["url"])
|
current_url = yarl.URL(current_entry.data["url"])
|
||||||
if (
|
if not (unique_id := current_entry.unique_id):
|
||||||
# The first version did not set a unique_id
|
# The first version did not set a unique_id
|
||||||
# so if the entry does not have a unique_id
|
# so if the entry does not have a unique_id
|
||||||
# we have to assume it's the first version
|
# we have to assume it's the first version
|
||||||
current_entry.unique_id
|
# This check can be removed in HA Core 2025.9
|
||||||
and (current_entry.unique_id != discovery_info.uuid)
|
unique_id = discovery_info.uuid
|
||||||
|
if (
|
||||||
|
unique_id != discovery_info.uuid
|
||||||
or current_url.host != config["host"]
|
or current_url.host != config["host"]
|
||||||
or current_url.port == config["port"]
|
or current_url.port == config["port"]
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
# Update URL with the new port
|
# Update URL with the new port
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
current_entry, data=config_entry_data
|
current_entry,
|
||||||
|
data=config_entry_data,
|
||||||
|
unique_id=unique_id, # Remove in HA Core 2025.9
|
||||||
)
|
)
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
return self.async_abort(reason="already_configured")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._connect_and_set_dataset(url)
|
await self._connect_and_configure_router(url)
|
||||||
|
except AlreadyConfigured:
|
||||||
|
return self.async_abort(reason="already_configured")
|
||||||
except (
|
except (
|
||||||
python_otbr_api.OTBRError,
|
python_otbr_api.OTBRError,
|
||||||
aiohttp.ClientError,
|
aiohttp.ClientError,
|
||||||
|
@ -2,14 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from homeassistant.util.hass_dict import HassKey
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .util import OTBRData
|
|
||||||
|
|
||||||
DOMAIN = "otbr"
|
DOMAIN = "otbr"
|
||||||
DATA_OTBR: HassKey[OTBRData] = HassKey(DOMAIN)
|
|
||||||
|
|
||||||
DEFAULT_CHANNEL = 15
|
DEFAULT_CHANNEL = 15
|
||||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Concatenate
|
from typing import TYPE_CHECKING, Any, Concatenate
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from python_otbr_api import tlv_parser
|
from python_otbr_api import tlv_parser
|
||||||
@ -18,9 +18,12 @@ from homeassistant.components.thread import async_add_dataset
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from .const import DATA_OTBR, DOMAIN
|
from .const import DOMAIN
|
||||||
from .util import OTBRData
|
from .util import OTBRData
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import OTBRConfigEntry
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -45,15 +48,13 @@ def async_get_otbr_data[**_P, _R, _R_Def](
|
|||||||
hass: HomeAssistant, *args: _P.args, **kwargs: _P.kwargs
|
hass: HomeAssistant, *args: _P.args, **kwargs: _P.kwargs
|
||||||
) -> _R | _R_Def:
|
) -> _R | _R_Def:
|
||||||
"""Fetch OTBR data and pass to orig_func."""
|
"""Fetch OTBR data and pass to orig_func."""
|
||||||
if DATA_OTBR not in hass.data:
|
config_entry: OTBRConfigEntry
|
||||||
return retval
|
for config_entry in hass.config_entries.async_loaded_entries(DOMAIN):
|
||||||
|
data = config_entry.runtime_data
|
||||||
|
if is_multiprotocol_url(data.url):
|
||||||
|
return await orig_func(hass, data, *args, **kwargs)
|
||||||
|
|
||||||
data = hass.data[DATA_OTBR]
|
return retval
|
||||||
|
|
||||||
if not is_multiprotocol_url(data.url):
|
|
||||||
return retval
|
|
||||||
|
|
||||||
return await orig_func(hass, data, *args, **kwargs)
|
|
||||||
|
|
||||||
return async_get_otbr_data_wrapper
|
return async_get_otbr_data_wrapper
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"already_configured": "The Thread border router is already configured",
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
|
@ -7,7 +7,7 @@ import dataclasses
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
from typing import Any, Concatenate, cast
|
from typing import TYPE_CHECKING, Any, Concatenate, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
@ -22,12 +22,16 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
|
|||||||
multi_pan_addon_using_device,
|
multi_pan_addon_using_device,
|
||||||
)
|
)
|
||||||
from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO
|
from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO
|
||||||
|
from homeassistant.config_entries import SOURCE_USER
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import OTBRConfigEntry
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
INFO_URL_SKY_CONNECT = (
|
INFO_URL_SKY_CONNECT = (
|
||||||
@ -48,6 +52,10 @@ INSECURE_PASSPHRASES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GetBorderAgentIdNotSupported(HomeAssistantError):
|
||||||
|
"""Raised from python_otbr_api.GetBorderAgentIdNotSupportedError."""
|
||||||
|
|
||||||
|
|
||||||
def compose_default_network_name(pan_id: int) -> str:
|
def compose_default_network_name(pan_id: int) -> str:
|
||||||
"""Generate a default network name."""
|
"""Generate a default network name."""
|
||||||
return f"ha-thread-{pan_id:04x}"
|
return f"ha-thread-{pan_id:04x}"
|
||||||
@ -83,7 +91,7 @@ class OTBRData:
|
|||||||
entry_id: str
|
entry_id: str
|
||||||
|
|
||||||
@_handle_otbr_error
|
@_handle_otbr_error
|
||||||
async def factory_reset(self) -> None:
|
async def factory_reset(self, hass: HomeAssistant) -> None:
|
||||||
"""Reset the router."""
|
"""Reset the router."""
|
||||||
try:
|
try:
|
||||||
await self.api.factory_reset()
|
await self.api.factory_reset()
|
||||||
@ -92,14 +100,19 @@ class OTBRData:
|
|||||||
"OTBR does not support factory reset, attempting to delete dataset"
|
"OTBR does not support factory reset, attempting to delete dataset"
|
||||||
)
|
)
|
||||||
await self.delete_active_dataset()
|
await self.delete_active_dataset()
|
||||||
|
await update_unique_id(
|
||||||
|
hass,
|
||||||
|
hass.config_entries.async_get_entry(self.entry_id),
|
||||||
|
await self.get_border_agent_id(),
|
||||||
|
)
|
||||||
|
|
||||||
@_handle_otbr_error
|
@_handle_otbr_error
|
||||||
async def get_border_agent_id(self) -> bytes | None:
|
async def get_border_agent_id(self) -> bytes:
|
||||||
"""Get the border agent ID or None if not supported by the router."""
|
"""Get the border agent ID or None if not supported by the router."""
|
||||||
try:
|
try:
|
||||||
return await self.api.get_border_agent_id()
|
return await self.api.get_border_agent_id()
|
||||||
except python_otbr_api.GetBorderAgentIdNotSupportedError:
|
except python_otbr_api.GetBorderAgentIdNotSupportedError as exc:
|
||||||
return None
|
raise GetBorderAgentIdNotSupported from exc
|
||||||
|
|
||||||
@_handle_otbr_error
|
@_handle_otbr_error
|
||||||
async def set_enabled(self, enabled: bool) -> None:
|
async def set_enabled(self, enabled: bool) -> None:
|
||||||
@ -258,3 +271,18 @@ async def update_issues(
|
|||||||
"""Raise or clear repair issues related to network settings."""
|
"""Raise or clear repair issues related to network settings."""
|
||||||
await _warn_on_channel_collision(hass, otbrdata, dataset_tlvs)
|
await _warn_on_channel_collision(hass, otbrdata, dataset_tlvs)
|
||||||
_warn_on_default_network_settings(hass, otbrdata, dataset_tlvs)
|
_warn_on_default_network_settings(hass, otbrdata, dataset_tlvs)
|
||||||
|
|
||||||
|
|
||||||
|
async def update_unique_id(
|
||||||
|
hass: HomeAssistant, entry: OTBRConfigEntry | None, border_agent_id: bytes
|
||||||
|
) -> None:
|
||||||
|
"""Update the config entry's unique_id if not matching."""
|
||||||
|
border_agent_id_hex = border_agent_id.hex()
|
||||||
|
if entry and entry.source == SOURCE_USER and entry.unique_id != border_agent_id_hex:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Updating unique_id of entry %s from %s to %s",
|
||||||
|
entry.entry_id,
|
||||||
|
entry.unique_id,
|
||||||
|
border_agent_id_hex,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(entry, unique_id=border_agent_id_hex)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser
|
from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser
|
||||||
@ -17,7 +17,7 @@ from homeassistant.components.thread import async_add_dataset, async_get_dataset
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
from .const import DATA_OTBR, DEFAULT_CHANNEL, DOMAIN
|
from .const import DEFAULT_CHANNEL, DOMAIN
|
||||||
from .util import (
|
from .util import (
|
||||||
OTBRData,
|
OTBRData,
|
||||||
compose_default_network_name,
|
compose_default_network_name,
|
||||||
@ -26,6 +26,9 @@ from .util import (
|
|||||||
update_issues,
|
update_issues,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import OTBRConfigEntry
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_setup(hass: HomeAssistant) -> None:
|
def async_setup(hass: HomeAssistant) -> None:
|
||||||
@ -47,41 +50,45 @@ async def websocket_info(
|
|||||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Get OTBR info."""
|
"""Get OTBR info."""
|
||||||
if DATA_OTBR not in hass.data:
|
config_entries: list[OTBRConfigEntry]
|
||||||
|
config_entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||||
|
|
||||||
|
if not config_entries:
|
||||||
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
||||||
return
|
return
|
||||||
|
|
||||||
data = hass.data[DATA_OTBR]
|
response: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
try:
|
for config_entry in config_entries:
|
||||||
border_agent_id = await data.get_border_agent_id()
|
data = config_entry.runtime_data
|
||||||
dataset = await data.get_active_dataset()
|
try:
|
||||||
dataset_tlvs = await data.get_active_dataset_tlvs()
|
border_agent_id = await data.get_border_agent_id()
|
||||||
extended_address = (await data.get_extended_address()).hex()
|
dataset = await data.get_active_dataset()
|
||||||
except HomeAssistantError as exc:
|
dataset_tlvs = await data.get_active_dataset_tlvs()
|
||||||
connection.send_error(msg["id"], "otbr_info_failed", str(exc))
|
extended_address = (await data.get_extended_address()).hex()
|
||||||
return
|
except HomeAssistantError as exc:
|
||||||
|
connection.send_error(msg["id"], "otbr_info_failed", str(exc))
|
||||||
|
return
|
||||||
|
|
||||||
# The border agent ID is checked when the OTBR config entry is setup,
|
# The border agent ID is checked when the OTBR config entry is setup,
|
||||||
# we can assert it's not None
|
# we can assert it's not None
|
||||||
assert border_agent_id is not None
|
assert border_agent_id is not None
|
||||||
|
|
||||||
extended_pan_id = (
|
extended_pan_id = (
|
||||||
dataset.extended_pan_id.lower() if dataset and dataset.extended_pan_id else None
|
dataset.extended_pan_id.lower()
|
||||||
)
|
if dataset and dataset.extended_pan_id
|
||||||
connection.send_result(
|
else None
|
||||||
msg["id"],
|
)
|
||||||
{
|
response[extended_address] = {
|
||||||
extended_address: {
|
"active_dataset_tlvs": dataset_tlvs.hex() if dataset_tlvs else None,
|
||||||
"active_dataset_tlvs": dataset_tlvs.hex() if dataset_tlvs else None,
|
"border_agent_id": border_agent_id.hex(),
|
||||||
"border_agent_id": border_agent_id.hex(),
|
"channel": dataset.channel if dataset else None,
|
||||||
"channel": dataset.channel if dataset else None,
|
"extended_address": extended_address,
|
||||||
"extended_address": extended_address,
|
"extended_pan_id": extended_pan_id,
|
||||||
"extended_pan_id": extended_pan_id,
|
"url": data.url,
|
||||||
"url": data.url,
|
}
|
||||||
}
|
|
||||||
},
|
connection.send_result(msg["id"], response)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def async_get_otbr_data(
|
def async_get_otbr_data(
|
||||||
@ -99,22 +106,29 @@ def async_get_otbr_data(
|
|||||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fetch OTBR data and pass to orig_func."""
|
"""Fetch OTBR data and pass to orig_func."""
|
||||||
if DATA_OTBR not in hass.data:
|
config_entries: list[OTBRConfigEntry]
|
||||||
|
config_entries = hass.config_entries.async_loaded_entries(DOMAIN)
|
||||||
|
|
||||||
|
if not config_entries:
|
||||||
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
connection.send_error(msg["id"], "not_loaded", "No OTBR API loaded")
|
||||||
return
|
return
|
||||||
|
|
||||||
data = hass.data[DATA_OTBR]
|
for config_entry in config_entries:
|
||||||
|
data = config_entry.runtime_data
|
||||||
|
try:
|
||||||
|
extended_address = await data.get_extended_address()
|
||||||
|
except HomeAssistantError as exc:
|
||||||
|
connection.send_error(
|
||||||
|
msg["id"], "get_extended_address_failed", str(exc)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if extended_address.hex() != msg["extended_address"]:
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
await orig_func(hass, connection, msg, data)
|
||||||
extended_address = await data.get_extended_address()
|
|
||||||
except HomeAssistantError as exc:
|
|
||||||
connection.send_error(msg["id"], "get_extended_address_failed", str(exc))
|
|
||||||
return
|
|
||||||
if extended_address.hex() != msg["extended_address"]:
|
|
||||||
connection.send_error(msg["id"], "unknown_router", "")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await orig_func(hass, connection, msg, data)
|
connection.send_error(msg["id"], "unknown_router", "")
|
||||||
|
|
||||||
return async_check_extended_address_func
|
return async_check_extended_address_func
|
||||||
|
|
||||||
@ -144,7 +158,7 @@ async def websocket_create_network(
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await data.factory_reset()
|
await data.factory_reset(hass)
|
||||||
except HomeAssistantError as exc:
|
except HomeAssistantError as exc:
|
||||||
connection.send_error(msg["id"], "factory_reset_failed", str(exc))
|
connection.send_error(msg["id"], "factory_reset_failed", str(exc))
|
||||||
return
|
return
|
||||||
|
@ -31,6 +31,7 @@ DATASET_INSECURE_PASSPHRASE = bytes.fromhex(
|
|||||||
TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF")
|
TEST_BORDER_AGENT_EXTENDED_ADDRESS = bytes.fromhex("AEEB2F594B570BBF")
|
||||||
|
|
||||||
TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C")
|
TEST_BORDER_AGENT_ID = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52C")
|
||||||
|
TEST_BORDER_AGENT_ID_2 = bytes.fromhex("230C6A1AC57F6F4BE262ACF32E5EF52D")
|
||||||
|
|
||||||
ROUTER_DISCOVERY_HASS = {
|
ROUTER_DISCOVERY_HASS = {
|
||||||
"type_": "_meshcop._udp.local.",
|
"type_": "_meshcop._udp.local.",
|
||||||
|
@ -77,16 +77,18 @@ async def otbr_config_entry_multipan_fixture(
|
|||||||
get_active_dataset_tlvs: AsyncMock,
|
get_active_dataset_tlvs: AsyncMock,
|
||||||
get_border_agent_id: AsyncMock,
|
get_border_agent_id: AsyncMock,
|
||||||
get_extended_address: AsyncMock,
|
get_extended_address: AsyncMock,
|
||||||
) -> None:
|
) -> str:
|
||||||
"""Mock Open Thread Border Router config entry."""
|
"""Mock Open Thread Border Router config entry."""
|
||||||
config_entry = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
data=CONFIG_ENTRY_DATA_MULTIPAN,
|
data=CONFIG_ENTRY_DATA_MULTIPAN,
|
||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="Open Thread Border Router",
|
title="Open Thread Border Router",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
return config_entry.entry_id
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="otbr_config_entry_thread")
|
@pytest.fixture(name="otbr_config_entry_thread")
|
||||||
@ -102,6 +104,7 @@ async def otbr_config_entry_thread_fixture(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="Open Thread Border Router",
|
title="Open Thread Border Router",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
@ -13,7 +13,7 @@ from homeassistant.components import hassio, otbr
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
from . import DATASET_CH15, DATASET_CH16
|
from . import DATASET_CH15, DATASET_CH16, TEST_BORDER_AGENT_ID, TEST_BORDER_AGENT_ID_2
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, MockModule, mock_integration
|
from tests.common import MockConfigEntry, MockModule, mock_integration
|
||||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||||
@ -57,12 +57,91 @@ def addon_info_fixture():
|
|||||||
"http://custom_url:1234//",
|
"http://custom_url:1234//",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"get_active_dataset_tlvs",
|
||||||
|
"get_border_agent_id",
|
||||||
|
)
|
||||||
async def test_user_flow(
|
async def test_user_flow(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, url: str
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, url: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the user flow."""
|
"""Test the user flow."""
|
||||||
|
await _finish_user_flow(hass, url)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"get_active_dataset_tlvs",
|
||||||
|
"get_extended_address",
|
||||||
|
)
|
||||||
|
async def test_user_flow_additional_entry(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test more than a single entry is allowed."""
|
||||||
|
url1 = "http://custom_url:1234"
|
||||||
|
url2 = "http://custom_url_2:1234"
|
||||||
|
aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex())
|
||||||
|
aioclient_mock.get(f"{url2}/node/ba-id", json=TEST_BORDER_AGENT_ID_2.hex())
|
||||||
|
|
||||||
|
mock_integration(hass, MockModule("hassio"))
|
||||||
|
|
||||||
|
# Setup a config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data={"url": url2},
|
||||||
|
domain=otbr.DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Open Thread Border Router",
|
||||||
|
unique_id=TEST_BORDER_AGENT_ID_2.hex(),
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
# Do a user flow
|
||||||
|
await _finish_user_flow(hass)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"get_active_dataset_tlvs",
|
||||||
|
"get_extended_address",
|
||||||
|
)
|
||||||
|
async def test_user_flow_additional_entry_fail_get_address(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Test more than a single entry is allowed.
|
||||||
|
|
||||||
|
This tets the behavior when we can't read the extended address from the existing
|
||||||
|
config entry.
|
||||||
|
"""
|
||||||
|
url1 = "http://custom_url:1234"
|
||||||
|
url2 = "http://custom_url_2:1234"
|
||||||
|
aioclient_mock.get(f"{url2}/node/ba-id", json=TEST_BORDER_AGENT_ID_2.hex())
|
||||||
|
|
||||||
|
mock_integration(hass, MockModule("hassio"))
|
||||||
|
|
||||||
|
# Setup a config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data={"url": url2},
|
||||||
|
domain=otbr.DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Open Thread Border Router",
|
||||||
|
unique_id=TEST_BORDER_AGENT_ID_2.hex(),
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
# Do a user flow
|
||||||
|
aioclient_mock.clear_requests()
|
||||||
|
aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex())
|
||||||
|
aioclient_mock.get(f"{url2}/node/ba-id", status=HTTPStatus.NOT_FOUND)
|
||||||
|
await _finish_user_flow(hass)
|
||||||
|
assert f"Could not read border agent id from {url2}" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def _finish_user_flow(
|
||||||
|
hass: HomeAssistant, url: str = "http://custom_url:1234"
|
||||||
|
) -> None:
|
||||||
|
"""Finish a user flow."""
|
||||||
stripped_url = "http://custom_url:1234"
|
stripped_url = "http://custom_url:1234"
|
||||||
aioclient_mock.get(f"{stripped_url}/node/dataset/active", text="aa")
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
otbr.DOMAIN, context={"source": "user"}
|
otbr.DOMAIN, context={"source": "user"}
|
||||||
)
|
)
|
||||||
@ -88,13 +167,56 @@ async def test_user_flow(
|
|||||||
assert result["options"] == {}
|
assert result["options"] == {}
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0]
|
config_entry = result["result"]
|
||||||
assert config_entry.data == expected_data
|
assert config_entry.data == expected_data
|
||||||
assert config_entry.options == {}
|
assert config_entry.options == {}
|
||||||
assert config_entry.title == "Open Thread Border Router"
|
assert config_entry.title == "Open Thread Border Router"
|
||||||
assert config_entry.unique_id == otbr.DOMAIN
|
assert config_entry.unique_id == TEST_BORDER_AGENT_ID.hex()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"get_active_dataset_tlvs",
|
||||||
|
"get_border_agent_id",
|
||||||
|
"get_extended_address",
|
||||||
|
)
|
||||||
|
async def test_user_flow_additional_entry_same_address(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
|
) -> None:
|
||||||
|
"""Test more than a single entry is allowed."""
|
||||||
|
mock_integration(hass, MockModule("hassio"))
|
||||||
|
|
||||||
|
# Setup a config entry
|
||||||
|
config_entry = MockConfigEntry(
|
||||||
|
data={"url": "http://custom_url:1234"},
|
||||||
|
domain=otbr.DOMAIN,
|
||||||
|
options={},
|
||||||
|
title="Open Thread Border Router",
|
||||||
|
unique_id=TEST_BORDER_AGENT_ID.hex(),
|
||||||
|
)
|
||||||
|
config_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||||
|
|
||||||
|
# Start user flow
|
||||||
|
url = "http://custom_url:1234"
|
||||||
|
aioclient_mock.get(f"{url}/node/dataset/active", text="aa")
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
otbr.DOMAIN, context={"source": "user"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {}
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
{
|
||||||
|
"url": url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result["type"] is FlowResultType.FORM
|
||||||
|
assert result["errors"] == {"base": "already_configured"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_user_flow_router_not_setup(
|
async def test_user_flow_router_not_setup(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -158,10 +280,11 @@ async def test_user_flow_router_not_setup(
|
|||||||
assert config_entry.data == expected_data
|
assert config_entry.data == expected_data
|
||||||
assert config_entry.options == {}
|
assert config_entry.options == {}
|
||||||
assert config_entry.title == "Open Thread Border Router"
|
assert config_entry.title == "Open Thread Border Router"
|
||||||
assert config_entry.unique_id == otbr.DOMAIN
|
assert config_entry.unique_id == TEST_BORDER_AGENT_ID.hex()
|
||||||
|
|
||||||
|
|
||||||
async def test_user_flow_404(
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
|
async def test_user_flow_get_dataset_404(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the user flow."""
|
"""Test the user flow."""
|
||||||
@ -192,7 +315,30 @@ async def test_user_flow_404(
|
|||||||
aiohttp.ClientError,
|
aiohttp.ClientError,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_user_flow_connect_error(hass: HomeAssistant, error) -> None:
|
async def test_user_flow_get_ba_id_connect_error(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, error
|
||||||
|
) -> None:
|
||||||
|
"""Test the user flow."""
|
||||||
|
await _test_user_flow_connect_error(hass, "get_border_agent_id", error)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
TimeoutError,
|
||||||
|
python_otbr_api.OTBRError,
|
||||||
|
aiohttp.ClientError,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_user_flow_get_dataset_connect_error(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, error
|
||||||
|
) -> None:
|
||||||
|
"""Test the user flow."""
|
||||||
|
await _test_user_flow_connect_error(hass, "get_active_dataset_tlvs", error)
|
||||||
|
|
||||||
|
|
||||||
|
async def _test_user_flow_connect_error(hass: HomeAssistant, func, error) -> None:
|
||||||
"""Test the user flow."""
|
"""Test the user flow."""
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
otbr.DOMAIN, context={"source": "user"}
|
otbr.DOMAIN, context={"source": "user"}
|
||||||
@ -201,7 +347,7 @@ async def test_user_flow_connect_error(hass: HomeAssistant, error) -> None:
|
|||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
assert result["errors"] == {}
|
assert result["errors"] == {}
|
||||||
|
|
||||||
with patch("python_otbr_api.OTBR.get_active_dataset_tlvs", side_effect=error):
|
with patch(f"python_otbr_api.OTBR.{func}", side_effect=error):
|
||||||
result = await hass.config_entries.flow.async_configure(
|
result = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
@ -212,6 +358,7 @@ async def test_user_flow_connect_error(hass: HomeAssistant, error) -> None:
|
|||||||
assert result["errors"] == {"base": "cannot_connect"}
|
assert result["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow(
|
async def test_hassio_discovery_flow(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -244,6 +391,7 @@ async def test_hassio_discovery_flow(
|
|||||||
assert config_entry.unique_id == HASSIO_DATA.uuid
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_yellow(
|
async def test_hassio_discovery_flow_yellow(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -301,6 +449,7 @@ async def test_hassio_discovery_flow_yellow(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_sky_connect(
|
async def test_hassio_discovery_flow_sky_connect(
|
||||||
device: str,
|
device: str,
|
||||||
title: str,
|
title: str,
|
||||||
@ -346,6 +495,7 @@ async def test_hassio_discovery_flow_sky_connect(
|
|||||||
assert config_entry.unique_id == HASSIO_DATA.uuid
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address")
|
||||||
async def test_hassio_discovery_flow_2x_addons(
|
async def test_hassio_discovery_flow_2x_addons(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -354,6 +504,8 @@ async def test_hassio_discovery_flow_2x_addons(
|
|||||||
url2 = "http://core-silabs-multiprotocol_2:8081"
|
url2 = "http://core-silabs-multiprotocol_2:8081"
|
||||||
aioclient_mock.get(f"{url1}/node/dataset/active", text="aa")
|
aioclient_mock.get(f"{url1}/node/dataset/active", text="aa")
|
||||||
aioclient_mock.get(f"{url2}/node/dataset/active", text="bb")
|
aioclient_mock.get(f"{url2}/node/dataset/active", text="bb")
|
||||||
|
aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex())
|
||||||
|
aioclient_mock.get(f"{url2}/node/ba-id", json=TEST_BORDER_AGENT_ID_2.hex())
|
||||||
|
|
||||||
async def _addon_info(hass: HomeAssistant, slug: str) -> dict[str, Any]:
|
async def _addon_info(hass: HomeAssistant, slug: str) -> dict[str, Any]:
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
@ -387,18 +539,107 @@ async def test_hassio_discovery_flow_2x_addons(
|
|||||||
|
|
||||||
addon_info.side_effect = _addon_info
|
addon_info.side_effect = _addon_info
|
||||||
|
|
||||||
with patch(
|
result1 = await hass.config_entries.flow.async_init(
|
||||||
"homeassistant.components.otbr.async_setup_entry",
|
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA
|
||||||
return_value=True,
|
)
|
||||||
) as mock_setup_entry:
|
result2 = await hass.config_entries.flow.async_init(
|
||||||
result1 = await hass.config_entries.flow.async_init(
|
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_2
|
||||||
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA
|
)
|
||||||
)
|
|
||||||
result2 = await hass.config_entries.flow.async_init(
|
|
||||||
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_2
|
|
||||||
)
|
|
||||||
|
|
||||||
results = [result1, result2]
|
results = [result1, result2]
|
||||||
|
|
||||||
|
expected_data = {
|
||||||
|
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
||||||
|
}
|
||||||
|
expected_data_2 = {
|
||||||
|
"url": f"http://{HASSIO_DATA_2.config['host']}:{HASSIO_DATA_2.config['port']}",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert results[0]["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert (
|
||||||
|
results[0]["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)"
|
||||||
|
)
|
||||||
|
assert results[0]["data"] == expected_data
|
||||||
|
assert results[0]["options"] == {}
|
||||||
|
|
||||||
|
assert results[1]["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
assert (
|
||||||
|
results[1]["title"] == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)"
|
||||||
|
)
|
||||||
|
assert results[1]["data"] == expected_data_2
|
||||||
|
assert results[1]["options"] == {}
|
||||||
|
|
||||||
|
assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 2
|
||||||
|
|
||||||
|
config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0]
|
||||||
|
assert config_entry.data == expected_data
|
||||||
|
assert config_entry.options == {}
|
||||||
|
assert (
|
||||||
|
config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)"
|
||||||
|
)
|
||||||
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[1]
|
||||||
|
assert config_entry.data == expected_data_2
|
||||||
|
assert config_entry.options == {}
|
||||||
|
assert (
|
||||||
|
config_entry.title == "Home Assistant SkyConnect (Silicon Labs Multiprotocol)"
|
||||||
|
)
|
||||||
|
assert config_entry.unique_id == HASSIO_DATA_2.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_active_dataset_tlvs", "get_extended_address")
|
||||||
|
async def test_hassio_discovery_flow_2x_addons_same_ext_address(
|
||||||
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
||||||
|
) -> None:
|
||||||
|
"""Test the hassio discovery flow when the user has 2 addons with otbr support."""
|
||||||
|
url1 = "http://core-silabs-multiprotocol:8081"
|
||||||
|
url2 = "http://core-silabs-multiprotocol_2:8081"
|
||||||
|
aioclient_mock.get(f"{url1}/node/dataset/active", text="aa")
|
||||||
|
aioclient_mock.get(f"{url2}/node/dataset/active", text="bb")
|
||||||
|
aioclient_mock.get(f"{url1}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex())
|
||||||
|
aioclient_mock.get(f"{url2}/node/ba-id", json=TEST_BORDER_AGENT_ID.hex())
|
||||||
|
|
||||||
|
async def _addon_info(hass: HomeAssistant, slug: str) -> dict[str, Any]:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
if slug == "otbr":
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"hostname": None,
|
||||||
|
"options": {
|
||||||
|
"device": (
|
||||||
|
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_"
|
||||||
|
"9e2adbd75b8beb119fe564a0f320645d-if00-port0"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"state": None,
|
||||||
|
"update_available": False,
|
||||||
|
"version": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"hostname": None,
|
||||||
|
"options": {
|
||||||
|
"device": (
|
||||||
|
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_"
|
||||||
|
"9e2adbd75b8beb119fe564a0f320645d-if00-port1"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"state": None,
|
||||||
|
"update_available": False,
|
||||||
|
"version": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
addon_info.side_effect = _addon_info
|
||||||
|
|
||||||
|
result1 = await hass.config_entries.flow.async_init(
|
||||||
|
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA
|
||||||
|
)
|
||||||
|
result2 = await hass.config_entries.flow.async_init(
|
||||||
|
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA_2
|
||||||
|
)
|
||||||
|
|
||||||
|
results = [result1, result2]
|
||||||
|
|
||||||
expected_data = {
|
expected_data = {
|
||||||
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
||||||
@ -411,9 +652,8 @@ async def test_hassio_discovery_flow_2x_addons(
|
|||||||
assert results[0]["data"] == expected_data
|
assert results[0]["data"] == expected_data
|
||||||
assert results[0]["options"] == {}
|
assert results[0]["options"] == {}
|
||||||
assert results[1]["type"] is FlowResultType.ABORT
|
assert results[1]["type"] is FlowResultType.ABORT
|
||||||
assert results[1]["reason"] == "single_instance_allowed"
|
assert results[1]["reason"] == "already_configured"
|
||||||
assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1
|
assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
|
||||||
|
|
||||||
config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0]
|
config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0]
|
||||||
assert config_entry.data == expected_data
|
assert config_entry.data == expected_data
|
||||||
@ -424,6 +664,7 @@ async def test_hassio_discovery_flow_2x_addons(
|
|||||||
assert config_entry.unique_id == HASSIO_DATA.uuid
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_router_not_setup(
|
async def test_hassio_discovery_flow_router_not_setup(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -481,6 +722,7 @@ async def test_hassio_discovery_flow_router_not_setup(
|
|||||||
assert config_entry.unique_id == HASSIO_DATA.uuid
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_router_not_setup_has_preferred(
|
async def test_hassio_discovery_flow_router_not_setup_has_preferred(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, addon_info
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -533,6 +775,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred(
|
|||||||
assert config_entry.unique_id == HASSIO_DATA.uuid
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_router_not_setup_has_preferred_2(
|
async def test_hassio_discovery_flow_router_not_setup_has_preferred_2(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
aioclient_mock: AiohttpClientMocker,
|
aioclient_mock: AiohttpClientMocker,
|
||||||
@ -596,6 +839,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2(
|
|||||||
assert config_entry.unique_id == HASSIO_DATA.uuid
|
assert config_entry.unique_id == HASSIO_DATA.uuid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_404(
|
async def test_hassio_discovery_flow_404(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -610,6 +854,7 @@ async def test_hassio_discovery_flow_404(
|
|||||||
assert result["reason"] == "unknown"
|
assert result["reason"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_new_port_missing_unique_id(
|
async def test_hassio_discovery_flow_new_port_missing_unique_id(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -633,7 +878,7 @@ async def test_hassio_discovery_flow_new_port_missing_unique_id(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] is FlowResultType.ABORT
|
assert result["type"] is FlowResultType.ABORT
|
||||||
assert result["reason"] == "single_instance_allowed"
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
expected_data = {
|
expected_data = {
|
||||||
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
||||||
@ -642,6 +887,7 @@ async def test_hassio_discovery_flow_new_port_missing_unique_id(
|
|||||||
assert config_entry.data == expected_data
|
assert config_entry.data == expected_data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("get_border_agent_id")
|
||||||
async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None:
|
async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None:
|
||||||
"""Test the port can be updated."""
|
"""Test the port can be updated."""
|
||||||
mock_integration(hass, MockModule("hassio"))
|
mock_integration(hass, MockModule("hassio"))
|
||||||
@ -664,7 +910,7 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] is FlowResultType.ABORT
|
assert result["type"] is FlowResultType.ABORT
|
||||||
assert result["reason"] == "single_instance_allowed"
|
assert result["reason"] == "already_configured"
|
||||||
|
|
||||||
expected_data = {
|
expected_data = {
|
||||||
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
"url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}",
|
||||||
@ -673,6 +919,12 @@ async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None:
|
|||||||
assert config_entry.data == expected_data
|
assert config_entry.data == expected_data
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"addon_info",
|
||||||
|
"get_active_dataset_tlvs",
|
||||||
|
"get_border_agent_id",
|
||||||
|
"get_extended_address",
|
||||||
|
)
|
||||||
async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) -> None:
|
async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) -> None:
|
||||||
"""Test the port is not updated if we get data for another addon hosting OTBR."""
|
"""Test the port is not updated if we get data for another addon hosting OTBR."""
|
||||||
mock_integration(hass, MockModule("hassio"))
|
mock_integration(hass, MockModule("hassio"))
|
||||||
@ -691,22 +943,34 @@ async def test_hassio_discovery_flow_new_port_other_addon(hass: HomeAssistant) -
|
|||||||
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA
|
otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] is FlowResultType.ABORT
|
# Another entry will be created
|
||||||
assert result["reason"] == "single_instance_allowed"
|
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||||
|
|
||||||
# Make sure the data was not updated
|
# Make sure the data of the existing entry was not updated
|
||||||
expected_data = {
|
expected_data = {
|
||||||
"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}",
|
"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}",
|
||||||
}
|
}
|
||||||
config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0]
|
config_entry = hass.config_entries.async_get_entry(config_entry.entry_id)
|
||||||
assert config_entry.data == expected_data
|
assert config_entry.data == expected_data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("source", "data"), [("hassio", HASSIO_DATA), ("user", None)])
|
@pytest.mark.parametrize(
|
||||||
async def test_config_flow_single_entry(
|
("source", "data", "expected_result"),
|
||||||
hass: HomeAssistant, source: str, data: Any
|
[
|
||||||
|
("hassio", HASSIO_DATA, FlowResultType.CREATE_ENTRY),
|
||||||
|
("user", None, FlowResultType.FORM),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.usefixtures(
|
||||||
|
"addon_info",
|
||||||
|
"get_active_dataset_tlvs",
|
||||||
|
"get_border_agent_id",
|
||||||
|
"get_extended_address",
|
||||||
|
)
|
||||||
|
async def test_config_flow_additional_entry(
|
||||||
|
hass: HomeAssistant, source: str, data: Any, expected_result: FlowResultType
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test only a single entry is allowed."""
|
"""Test more than a single entry is allowed."""
|
||||||
mock_integration(hass, MockModule("hassio"))
|
mock_integration(hass, MockModule("hassio"))
|
||||||
|
|
||||||
# Setup the config entry
|
# Setup the config entry
|
||||||
@ -719,13 +983,11 @@ async def test_config_flow_single_entry(
|
|||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.homeassistant_yellow.async_setup_entry",
|
"homeassistant.components.otbr.async_setup_entry",
|
||||||
return_value=True,
|
return_value=True,
|
||||||
) as mock_setup_entry:
|
):
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
otbr.DOMAIN, context={"source": source}, data=data
|
otbr.DOMAIN, context={"source": source}, data=data
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result["type"] is FlowResultType.ABORT
|
assert result["type"] is expected_result
|
||||||
assert result["reason"] == "single_instance_allowed"
|
|
||||||
mock_setup_entry.assert_not_called()
|
|
||||||
|
@ -11,6 +11,7 @@ from zeroconf.asyncio import AsyncServiceInfo
|
|||||||
|
|
||||||
from homeassistant.components import otbr, thread
|
from homeassistant.components import otbr, thread
|
||||||
from homeassistant.components.thread import discovery
|
from homeassistant.components.thread import discovery
|
||||||
|
from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_USER
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import issue_registry as ir
|
from homeassistant.helpers import issue_registry as ir
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
@ -18,7 +19,6 @@ from homeassistant.setup import async_setup_component
|
|||||||
from . import (
|
from . import (
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
CONFIG_ENTRY_DATA_MULTIPAN,
|
CONFIG_ENTRY_DATA_MULTIPAN,
|
||||||
CONFIG_ENTRY_DATA_THREAD,
|
|
||||||
DATASET_CH15,
|
DATASET_CH15,
|
||||||
DATASET_CH16,
|
DATASET_CH16,
|
||||||
DATASET_INSECURE_NW_KEY,
|
DATASET_INSECURE_NW_KEY,
|
||||||
@ -71,6 +71,7 @@ async def test_import_dataset(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
@ -138,6 +139,7 @@ async def test_import_share_radio_channel_collision(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
with (
|
with (
|
||||||
@ -177,6 +179,7 @@ async def test_import_share_radio_no_channel_collision(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
with (
|
with (
|
||||||
@ -214,6 +217,7 @@ async def test_import_insecure_dataset(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
with (
|
with (
|
||||||
@ -252,6 +256,7 @@ async def test_config_entry_not_ready(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
get_active_dataset_tlvs.side_effect = error
|
get_active_dataset_tlvs.side_effect = error
|
||||||
@ -268,6 +273,7 @@ async def test_border_agent_id_not_supported(
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
get_border_agent_id.side_effect = python_otbr_api.GetBorderAgentIdNotSupportedError
|
get_border_agent_id.side_effect = python_otbr_api.GetBorderAgentIdNotSupportedError
|
||||||
@ -281,6 +287,7 @@ async def test_config_entry_update(hass: HomeAssistant) -> None:
|
|||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
title="My OTBR",
|
title="My OTBR",
|
||||||
|
unique_id=TEST_BORDER_AGENT_EXTENDED_ADDRESS.hex(),
|
||||||
)
|
)
|
||||||
config_entry.add_to_hass(hass)
|
config_entry.add_to_hass(hass)
|
||||||
mock_api = MagicMock()
|
mock_api = MagicMock()
|
||||||
@ -314,25 +321,33 @@ async def test_remove_entry(
|
|||||||
await hass.config_entries.async_remove(config_entry.entry_id)
|
await hass.config_entries.async_remove(config_entry.entry_id)
|
||||||
|
|
||||||
|
|
||||||
async def test_remove_extra_entries(
|
@pytest.mark.parametrize(
|
||||||
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
|
("source", "unique_id", "updated_unique_id"),
|
||||||
|
[
|
||||||
|
(SOURCE_HASSIO, None, None),
|
||||||
|
(SOURCE_HASSIO, "abcd", "abcd"),
|
||||||
|
(SOURCE_USER, None, TEST_BORDER_AGENT_ID.hex()),
|
||||||
|
(SOURCE_USER, "abcd", TEST_BORDER_AGENT_ID.hex()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_update_unique_id(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
aioclient_mock: AiohttpClientMocker,
|
||||||
|
source: str,
|
||||||
|
unique_id: str | None,
|
||||||
|
updated_unique_id: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we remove additional config entries."""
|
"""Test we update the unique id if extended address has changed."""
|
||||||
|
|
||||||
config_entry1 = MockConfigEntry(
|
config_entry = MockConfigEntry(
|
||||||
data=CONFIG_ENTRY_DATA_MULTIPAN,
|
data=CONFIG_ENTRY_DATA_MULTIPAN,
|
||||||
domain=otbr.DOMAIN,
|
domain=otbr.DOMAIN,
|
||||||
options={},
|
options={},
|
||||||
|
source=source,
|
||||||
title="Open Thread Border Router",
|
title="Open Thread Border Router",
|
||||||
|
unique_id=unique_id,
|
||||||
)
|
)
|
||||||
config_entry2 = MockConfigEntry(
|
config_entry.add_to_hass(hass)
|
||||||
data=CONFIG_ENTRY_DATA_THREAD,
|
|
||||||
domain=otbr.DOMAIN,
|
|
||||||
options={},
|
|
||||||
title="Open Thread Border Router",
|
|
||||||
)
|
|
||||||
config_entry1.add_to_hass(hass)
|
|
||||||
config_entry2.add_to_hass(hass)
|
|
||||||
assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 2
|
|
||||||
assert await async_setup_component(hass, otbr.DOMAIN, {})
|
assert await async_setup_component(hass, otbr.DOMAIN, {})
|
||||||
assert len(hass.config_entries.async_entries(otbr.DOMAIN)) == 1
|
config_entry = hass.config_entries.async_get_entry(config_entry.entry_id)
|
||||||
|
assert config_entry.unique_id == updated_unique_id
|
||||||
|
@ -5,7 +5,6 @@ from unittest.mock import patch
|
|||||||
import pytest
|
import pytest
|
||||||
from python_otbr_api import ActiveDataSet, tlv_parser
|
from python_otbr_api import ActiveDataSet, tlv_parser
|
||||||
|
|
||||||
from homeassistant.components import otbr
|
|
||||||
from homeassistant.components.otbr import (
|
from homeassistant.components.otbr import (
|
||||||
silabs_multiprotocol as otbr_silabs_multiprotocol,
|
silabs_multiprotocol as otbr_silabs_multiprotocol,
|
||||||
)
|
)
|
||||||
@ -127,10 +126,11 @@ async def test_async_change_channel_no_otbr(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_async_change_channel_non_matching_url(
|
async def test_async_change_channel_non_matching_url(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan
|
hass: HomeAssistant, otbr_config_entry_multipan: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test async_change_channel when otbr is not configured."""
|
"""Test async_change_channel when otbr is not configured."""
|
||||||
hass.data[otbr.DATA_OTBR].url = OTBR_NON_MULTIPAN_URL
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
|
config_entry.runtime_data.url = OTBR_NON_MULTIPAN_URL
|
||||||
with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel:
|
with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel:
|
||||||
await otbr_silabs_multiprotocol.async_change_channel(hass, 16, delay=0)
|
await otbr_silabs_multiprotocol.async_change_channel(hass, 16, delay=0)
|
||||||
mock_set_channel.assert_not_awaited()
|
mock_set_channel.assert_not_awaited()
|
||||||
@ -184,10 +184,11 @@ async def test_async_get_channel_no_otbr(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_async_get_channel_non_matching_url(
|
async def test_async_get_channel_non_matching_url(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan
|
hass: HomeAssistant, otbr_config_entry_multipan: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test async_change_channel when otbr is not configured."""
|
"""Test async_change_channel when otbr is not configured."""
|
||||||
hass.data[otbr.DATA_OTBR].url = OTBR_NON_MULTIPAN_URL
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
|
config_entry.runtime_data.url = OTBR_NON_MULTIPAN_URL
|
||||||
with patch("python_otbr_api.OTBR.get_active_dataset") as mock_get_active_dataset:
|
with patch("python_otbr_api.OTBR.get_active_dataset") as mock_get_active_dataset:
|
||||||
assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None
|
assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None
|
||||||
mock_get_active_dataset.assert_not_awaited()
|
mock_get_active_dataset.assert_not_awaited()
|
||||||
@ -198,10 +199,11 @@ async def test_async_get_channel_non_matching_url(
|
|||||||
[(OTBR_MULTIPAN_URL, True), (OTBR_NON_MULTIPAN_URL, False)],
|
[(OTBR_MULTIPAN_URL, True), (OTBR_NON_MULTIPAN_URL, False)],
|
||||||
)
|
)
|
||||||
async def test_async_using_multipan(
|
async def test_async_using_multipan(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan, url: str, expected: bool
|
hass: HomeAssistant, otbr_config_entry_multipan: str, url: str, expected: bool
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test async_change_channel when otbr is not configured."""
|
"""Test async_change_channel when otbr is not configured."""
|
||||||
hass.data[otbr.DATA_OTBR].url = url
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
|
config_entry.runtime_data.url = url
|
||||||
|
|
||||||
assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is expected
|
assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is expected
|
||||||
|
|
||||||
@ -213,8 +215,9 @@ async def test_async_using_multipan_no_otbr(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def test_async_using_multipan_non_matching_url(
|
async def test_async_using_multipan_non_matching_url(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan
|
hass: HomeAssistant, otbr_config_entry_multipan: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test async_change_channel when otbr is not configured."""
|
"""Test async_change_channel when otbr is not configured."""
|
||||||
hass.data[otbr.DATA_OTBR].url = OTBR_NON_MULTIPAN_URL
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
|
config_entry.runtime_data.url = OTBR_NON_MULTIPAN_URL
|
||||||
assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is False
|
assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is False
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
"""Test OTBR Utility functions."""
|
"""Test OTBR Utility functions."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import python_otbr_api
|
import python_otbr_api
|
||||||
@ -31,24 +31,37 @@ async def test_get_allowed_channel(
|
|||||||
assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None
|
assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None
|
||||||
|
|
||||||
|
|
||||||
async def test_factory_reset(hass: HomeAssistant, otbr_config_entry_multipan) -> None:
|
async def test_factory_reset(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
otbr_config_entry_multipan: str,
|
||||||
|
get_border_agent_id: AsyncMock,
|
||||||
|
) -> None:
|
||||||
"""Test factory_reset."""
|
"""Test factory_reset."""
|
||||||
|
new_ba_id = b"new_ba_id"
|
||||||
|
get_border_agent_id.return_value = new_ba_id
|
||||||
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
|
assert config_entry.unique_id != new_ba_id.hex()
|
||||||
with (
|
with (
|
||||||
patch("python_otbr_api.OTBR.factory_reset") as factory_reset_mock,
|
patch("python_otbr_api.OTBR.factory_reset") as factory_reset_mock,
|
||||||
patch(
|
patch(
|
||||||
"python_otbr_api.OTBR.delete_active_dataset"
|
"python_otbr_api.OTBR.delete_active_dataset"
|
||||||
) as delete_active_dataset_mock,
|
) as delete_active_dataset_mock,
|
||||||
):
|
):
|
||||||
await hass.data[otbr.DATA_OTBR].factory_reset()
|
await config_entry.runtime_data.factory_reset(hass)
|
||||||
|
|
||||||
delete_active_dataset_mock.assert_not_called()
|
delete_active_dataset_mock.assert_not_called()
|
||||||
factory_reset_mock.assert_called_once_with()
|
factory_reset_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
# Check the unique_id is updated
|
||||||
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
|
assert config_entry.unique_id == new_ba_id.hex()
|
||||||
|
|
||||||
|
|
||||||
async def test_factory_reset_not_supported(
|
async def test_factory_reset_not_supported(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan
|
hass: HomeAssistant, otbr_config_entry_multipan: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test factory_reset."""
|
"""Test factory_reset."""
|
||||||
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_otbr_api.OTBR.factory_reset",
|
"python_otbr_api.OTBR.factory_reset",
|
||||||
@ -58,16 +71,17 @@ async def test_factory_reset_not_supported(
|
|||||||
"python_otbr_api.OTBR.delete_active_dataset"
|
"python_otbr_api.OTBR.delete_active_dataset"
|
||||||
) as delete_active_dataset_mock,
|
) as delete_active_dataset_mock,
|
||||||
):
|
):
|
||||||
await hass.data[otbr.DATA_OTBR].factory_reset()
|
await config_entry.runtime_data.factory_reset(hass)
|
||||||
|
|
||||||
delete_active_dataset_mock.assert_called_once_with()
|
delete_active_dataset_mock.assert_called_once_with()
|
||||||
factory_reset_mock.assert_called_once_with()
|
factory_reset_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
async def test_factory_reset_error_1(
|
async def test_factory_reset_error_1(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan
|
hass: HomeAssistant, otbr_config_entry_multipan: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test factory_reset."""
|
"""Test factory_reset."""
|
||||||
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_otbr_api.OTBR.factory_reset",
|
"python_otbr_api.OTBR.factory_reset",
|
||||||
@ -80,16 +94,17 @@ async def test_factory_reset_error_1(
|
|||||||
HomeAssistantError,
|
HomeAssistantError,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await hass.data[otbr.DATA_OTBR].factory_reset()
|
await config_entry.runtime_data.factory_reset(hass)
|
||||||
|
|
||||||
delete_active_dataset_mock.assert_not_called()
|
delete_active_dataset_mock.assert_not_called()
|
||||||
factory_reset_mock.assert_called_once_with()
|
factory_reset_mock.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
async def test_factory_reset_error_2(
|
async def test_factory_reset_error_2(
|
||||||
hass: HomeAssistant, otbr_config_entry_multipan
|
hass: HomeAssistant, otbr_config_entry_multipan: str
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test factory_reset."""
|
"""Test factory_reset."""
|
||||||
|
config_entry = hass.config_entries.async_get_entry(otbr_config_entry_multipan)
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"python_otbr_api.OTBR.factory_reset",
|
"python_otbr_api.OTBR.factory_reset",
|
||||||
@ -103,7 +118,7 @@ async def test_factory_reset_error_2(
|
|||||||
HomeAssistantError,
|
HomeAssistantError,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await hass.data[otbr.DATA_OTBR].factory_reset()
|
await config_entry.runtime_data.factory_reset(hass)
|
||||||
|
|
||||||
delete_active_dataset_mock.assert_called_once_with()
|
delete_active_dataset_mock.assert_called_once_with()
|
||||||
factory_reset_mock.assert_called_once_with()
|
factory_reset_mock.assert_called_once_with()
|
||||||
|
Loading…
x
Reference in New Issue
Block a user