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
pull/86195/head
Erik Montnemery 2023-01-17 14:01:36 +01:00 committed by GitHub
parent 9b835f88c7
commit 11b9a0b383
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 289 additions and 0 deletions

View File

@ -858,6 +858,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/oralb/ @bdraco @conway20 /homeassistant/components/oralb/ @bdraco @conway20
/tests/components/oralb/ @bdraco @conway20 /tests/components/oralb/ @bdraco @conway20
/homeassistant/components/oru/ @bvlaicu /homeassistant/components/oru/ @bvlaicu
/homeassistant/components/otbr/ @home-assistant/core
/tests/components/otbr/ @home-assistant/core
/homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /homeassistant/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev /tests/components/overkiz/ @imicknl @vlebourl @tetienne @nyroDev
/homeassistant/components/ovo_energy/ @timmo001 /homeassistant/components/ovo_energy/ @timmo001

View File

@ -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

View File

@ -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']}"},
)

View File

@ -0,0 +1,3 @@
"""Constants for the Open Thread Border Router integration."""
DOMAIN = "otbr"

View File

@ -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"]
}

View File

@ -2077,6 +2077,9 @@ python-mystrom==1.1.2
# homeassistant.components.nest # homeassistant.components.nest
python-nest==4.2.0 python-nest==4.2.0
# homeassistant.components.otbr
python-otbr-api==1.0.1
# homeassistant.components.picnic # homeassistant.components.picnic
python-picnic-api==1.1.0 python-picnic-api==1.1.0

View File

@ -1467,6 +1467,9 @@ python-miio==0.5.12
# homeassistant.components.nest # homeassistant.components.nest
python-nest==4.2.0 python-nest==4.2.0
# homeassistant.components.otbr
python-otbr-api==1.0.1
# homeassistant.components.picnic # homeassistant.components.picnic
python-picnic-api==1.1.0 python-picnic-api==1.1.0

View File

@ -0,0 +1 @@
"""Tests for the Thread integration."""

View File

@ -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)

View File

@ -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()

View File

@ -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