diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 602c76f77ef..a25ff8b46bc 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -155,6 +155,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _warn_on_default_network_settings(hass, entry, dataset_tlvs) await async_add_dataset(hass, DOMAIN, dataset_tlvs.hex()) + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + hass.data[DOMAIN] = otbrdata return True @@ -166,6 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_get_active_dataset_tlvs(hass: HomeAssistant) -> bytes | None: """Get current active operational dataset in TLVS format, or None. diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 0e9c8e96060..4247d5dbd65 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -8,10 +8,11 @@ import aiohttp import python_otbr_api from python_otbr_api import tlv_parser import voluptuous as vol +import yarl from homeassistant.components.hassio import HassioServiceInfo from homeassistant.components.thread import async_get_preferred_dataset -from homeassistant.config_entries import ConfigFlow +from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow from homeassistant.const import CONF_URL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -86,11 +87,25 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle hassio discovery.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - config = discovery_info.config url = f"http://{config['host']}:{config['port']}" + config_entry_data = {"url": url} + + if current_entries := self._async_current_entries(): + for current_entry in current_entries: + if current_entry.source != SOURCE_HASSIO: + continue + current_url = yarl.URL(current_entry.data["url"]) + if ( + current_url.host != config["host"] + or current_url.port == config["port"] + ): + continue + # Update URL with the new port + self.hass.config_entries.async_update_entry( + current_entry, data=config_entry_data + ) + return self.async_abort(reason="single_instance_allowed") try: await self._connect_and_create_dataset(url) @@ -101,5 +116,5 @@ 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=config_entry_data, ) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 2ec79dcaeed..ae49c63002a 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Open Thread Border Router config flow.""" import asyncio from http import HTTPStatus +from typing import Any from unittest.mock import patch import aiohttp @@ -373,8 +374,69 @@ async def test_hassio_discovery_flow_404( assert result["reason"] == "unknown" -@pytest.mark.parametrize("source", ("hassio", "user")) -async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> None: +async def test_hassio_discovery_flow_new_port(hass: HomeAssistant) -> None: + """Test the port can be updated.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={ + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']+1}" + }, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +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.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={"url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}"}, + domain=otbr.DOMAIN, + options={}, + source="hassio", + title="Open Thread Border Router", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + # Make sure the data was not updated + expected_data = { + "url": f"http://openthread_border_router:{HASSIO_DATA.config['port']+1}", + } + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + + +@pytest.mark.parametrize(("source", "data"), [("hassio", HASSIO_DATA), ("user", None)]) +async def test_config_flow_single_entry( + hass: HomeAssistant, source: str, data: Any +) -> None: """Test only a single entry is allowed.""" mock_integration(hass, MockModule("hassio")) @@ -392,7 +454,7 @@ async def test_config_flow_single_entry(hass: HomeAssistant, source: str) -> Non return_value=True, ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( - otbr.DOMAIN, context={"source": source} + otbr.DOMAIN, context={"source": source}, data=data ) assert result["type"] == FlowResultType.ABORT diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index 2b329ae8d99..3ed3ec8c30a 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -100,6 +100,31 @@ async def test_config_entry_not_ready(hass: HomeAssistant, error) -> None: assert not await hass.config_entries.async_setup(config_entry.entry_id) +async def test_config_entry_update(hass: HomeAssistant) -> None: + """Test update config entry settings.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="My OTBR", + ) + config_entry.add_to_hass(hass) + mock_api = MagicMock() + mock_api.get_active_dataset_tlvs = AsyncMock(return_value=None) + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + assert await hass.config_entries.async_setup(config_entry.entry_id) + + mock_otrb_api.assert_called_once_with(CONFIG_ENTRY_DATA["url"], ANY, ANY) + + new_config_entry_data = {"url": "http://core-silabs-multiprotocol:8082"} + assert CONFIG_ENTRY_DATA["url"] != new_config_entry_data["url"] + with patch("python_otbr_api.OTBR", return_value=mock_api) as mock_otrb_api: + hass.config_entries.async_update_entry(config_entry, data=new_config_entry_data) + await hass.async_block_till_done() + + mock_otrb_api.assert_called_once_with(new_config_entry_data["url"], ANY, ANY) + + async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, otbr_config_entry ) -> None: