From 11b9a0b3839aebb5cdbfa42151f1941636cda2b5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Jan 2023 14:01:36 +0100 Subject: [PATCH] Add Thread integration (#85002) * Add Thread integration * Add get/set operational dataset as TLVS * Add create operational dataset * Add set thread state * Adjust after rebase * Improve HTTP status handling * Improve test coverage * Change domains from thread to otbr * Setup otbr from a config entry * Add files * Store URL in config entry data * Make sure manifest is not sorted * Remove useless async * Call the JSON parser more * Don't raise exceptions without messages * Remove stuff which will be needed in the future * Remove more future stuff * Use API library * Bump library to 1.0.1 --- CODEOWNERS | 2 + homeassistant/components/otbr/__init__.py | 59 +++++++++++++ homeassistant/components/otbr/config_flow.py | 25 ++++++ homeassistant/components/otbr/const.py | 3 + homeassistant/components/otbr/manifest.json | 11 +++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/otbr/__init__.py | 1 + tests/components/otbr/conftest.py | 22 +++++ tests/components/otbr/test_config_flow.py | 67 ++++++++++++++ tests/components/otbr/test_init.py | 93 ++++++++++++++++++++ 11 files changed, 289 insertions(+) create mode 100644 homeassistant/components/otbr/__init__.py create mode 100644 homeassistant/components/otbr/config_flow.py create mode 100644 homeassistant/components/otbr/const.py create mode 100644 homeassistant/components/otbr/manifest.json create mode 100644 tests/components/otbr/__init__.py create mode 100644 tests/components/otbr/conftest.py create mode 100644 tests/components/otbr/test_config_flow.py create mode 100644 tests/components/otbr/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index ec2575e93840f6..b7030bc2cb9c42 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -858,6 +858,8 @@ build.json @home-assistant/supervisor /homeassistant/components/oralb/ @bdraco @conway20 /tests/components/oralb/ @bdraco @conway20 /homeassistant/components/oru/ @bvlaicu +/homeassistant/components/otbr/ @home-assistant/core +/tests/components/otbr/ @home-assistant/core /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /homeassistant/components/ovo_energy/ @timmo001 diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py new file mode 100644 index 00000000000000..4c4301a85cdd5f --- /dev/null +++ b/homeassistant/components/otbr/__init__.py @@ -0,0 +1,59 @@ +"""The Open Thread Border Router integration.""" +from __future__ import annotations + +import dataclasses + +import python_otbr_api + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +@dataclasses.dataclass +class OTBRData: + """Container for OTBR data.""" + + url: str + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up an Open Thread Border Router config entry.""" + + hass.data[DOMAIN] = OTBRData(entry.data["url"]) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data.pop(DOMAIN) + return True + + +def _async_get_thread_rest_service_url(hass) -> str: + """Return Thread REST API URL.""" + otbr_data: OTBRData | None = hass.data.get(DOMAIN) + if not otbr_data: + raise HomeAssistantError("otbr not setup") + + return otbr_data.url + + +async def async_get_active_dataset_tlvs(hass: HomeAssistant) -> bytes | None: + """Get current active operational dataset in TLVS format, or None. + + Returns None if there is no active operational dataset. + Raises if the http status is 400 or higher or if the response is invalid. + """ + + api = python_otbr_api.OTBR( + _async_get_thread_rest_service_url(hass), async_get_clientsession(hass), 10 + ) + try: + return await api.get_active_dataset_tlvs() + except python_otbr_api.OTBRError as exc: + raise HomeAssistantError("Failed to call OTBR API") from exc diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py new file mode 100644 index 00000000000000..f4e129c68ead4f --- /dev/null +++ b/homeassistant/components/otbr/config_flow.py @@ -0,0 +1,25 @@ +"""Config flow for the Open Thread Border Router integration.""" +from __future__ import annotations + +from homeassistant.components.hassio import HassioServiceInfo +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Sky Connect.""" + + VERSION = 1 + + 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 + return self.async_create_entry( + title="Thread", + data={"url": f"http://{config['host']}:{config['port']}"}, + ) diff --git a/homeassistant/components/otbr/const.py b/homeassistant/components/otbr/const.py new file mode 100644 index 00000000000000..72884a198d81c3 --- /dev/null +++ b/homeassistant/components/otbr/const.py @@ -0,0 +1,3 @@ +"""Constants for the Open Thread Border Router integration.""" + +DOMAIN = "otbr" diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json new file mode 100644 index 00000000000000..9779ce74d946a0 --- /dev/null +++ b/homeassistant/components/otbr/manifest.json @@ -0,0 +1,11 @@ +{ + "codeowners": ["@home-assistant/core"], + "after_dependencies": ["hassio"], + "domain": "otbr", + "iot_class": "local_polling", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/otbr", + "integration_type": "system", + "name": "Thread", + "requirements": ["python-otbr-api==1.0.1"] +} diff --git a/requirements_all.txt b/requirements_all.txt index e10c82aa213d5a..64af72d81a9eae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,6 +2077,9 @@ python-mystrom==1.1.2 # homeassistant.components.nest python-nest==4.2.0 +# homeassistant.components.otbr +python-otbr-api==1.0.1 + # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3771c08f36139a..33ab374cfb4feb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1467,6 +1467,9 @@ python-miio==0.5.12 # homeassistant.components.nest python-nest==4.2.0 +# homeassistant.components.otbr +python-otbr-api==1.0.1 + # homeassistant.components.picnic python-picnic-api==1.1.0 diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py new file mode 100644 index 00000000000000..4643d876d9e20f --- /dev/null +++ b/tests/components/otbr/__init__.py @@ -0,0 +1 @@ +"""Tests for the Thread integration.""" diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py new file mode 100644 index 00000000000000..295960284517ce --- /dev/null +++ b/tests/components/otbr/conftest.py @@ -0,0 +1,22 @@ +"""Test fixtures for the Home Assistant Sky Connect integration.""" + +import pytest + +from homeassistant.components import otbr + +from tests.common import MockConfigEntry + +CONFIG_ENTRY_DATA = {"url": "http://core-silabs-multiprotocol:8081"} + + +@pytest.fixture(name="thread_config_entry") +async def thread_config_entry_fixture(hass): + """Mock Thread config entry.""" + config_entry = MockConfigEntry( + data=CONFIG_ENTRY_DATA, + domain=otbr.DOMAIN, + options={}, + title="Thread", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py new file mode 100644 index 00000000000000..a45085857161ba --- /dev/null +++ b/tests/components/otbr/test_config_flow.py @@ -0,0 +1,67 @@ +"""Test the Open Thread Border Router config flow.""" +from unittest.mock import patch + +from homeassistant.components import hassio, otbr +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry, MockModule, mock_integration + +HASSIO_DATA = hassio.HassioServiceInfo( + config={"host": "blah", "port": "bluh"}, + name="blah", + slug="blah", +) + + +async def test_hassio_discovery_flow(hass: HomeAssistant) -> None: + """Test the hassio discovery flow.""" + with patch( + "homeassistant.components.otbr.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA + ) + + expected_data = { + "url": f"http://{HASSIO_DATA.config['host']}:{HASSIO_DATA.config['port']}", + } + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Thread" + assert result["data"] == expected_data + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + assert config_entry.data == expected_data + assert config_entry.options == {} + assert config_entry.title == "Thread" + assert config_entry.unique_id is None + + +async def test_config_flow_single_entry(hass: HomeAssistant) -> None: + """Test only a single entry is allowed.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=otbr.DOMAIN, + options={}, + title="Thread", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + 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" + mock_setup_entry.assert_not_called() diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py new file mode 100644 index 00000000000000..de3ee861ecc8e9 --- /dev/null +++ b/tests/components/otbr/test_init.py @@ -0,0 +1,93 @@ +"""Test the Open Thread Border Router integration.""" + +from http import HTTPStatus + +import pytest + +from homeassistant.components import otbr +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.test_util.aiohttp import AiohttpClientMocker + +BASE_URL = "http://core-silabs-multiprotocol:8081" + + +async def test_remove_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, thread_config_entry +): + """Test async_get_thread_state.""" + + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text="0E") + + assert await otbr.async_get_active_dataset_tlvs(hass) == bytes.fromhex("0E") + + config_entry = hass.config_entries.async_entries(otbr.DOMAIN)[0] + await hass.config_entries.async_remove(config_entry.entry_id) + + with pytest.raises(HomeAssistantError): + assert await otbr.async_get_active_dataset_tlvs(hass) + + +async def test_get_active_dataset_tlvs( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, thread_config_entry +): + """Test async_get_active_dataset_tlvs.""" + + mock_response = ( + "0E080000000000010000000300001035060004001FFFE00208F642646DA209B1C00708FDF57B5A" + "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" + "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" + ) + + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text=mock_response) + + assert await otbr.async_get_active_dataset_tlvs(hass) == bytes.fromhex( + mock_response + ) + + +async def test_get_active_dataset_tlvs_empty( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, thread_config_entry +): + """Test async_get_active_dataset_tlvs.""" + + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.NO_CONTENT) + assert await otbr.async_get_active_dataset_tlvs(hass) is None + + +async def test_get_active_dataset_tlvs_addon_not_installed(hass: HomeAssistant): + """Test async_get_active_dataset_tlvs when the multi-PAN addon is not installed.""" + + with pytest.raises(HomeAssistantError): + await otbr.async_get_active_dataset_tlvs(hass) + + +async def test_get_active_dataset_tlvs_404( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, thread_config_entry +): + """Test async_get_active_dataset_tlvs with error.""" + + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.NOT_FOUND) + with pytest.raises(HomeAssistantError): + await otbr.async_get_active_dataset_tlvs(hass) + + +async def test_get_active_dataset_tlvs_201( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, thread_config_entry +): + """Test async_get_active_dataset_tlvs with error.""" + + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", status=HTTPStatus.CREATED) + with pytest.raises(HomeAssistantError): + assert await otbr.async_get_active_dataset_tlvs(hass) is None + + +async def test_get_active_dataset_tlvs_invalid( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, thread_config_entry +): + """Test async_get_active_dataset_tlvs with error.""" + + aioclient_mock.get(f"{BASE_URL}/node/dataset/active", text="unexpected") + with pytest.raises(HomeAssistantError): + assert await otbr.async_get_active_dataset_tlvs(hass) is None