Skip to content

Commit

Permalink
Add Thread integration (home-assistant#85002)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
emontnemery authored and rlippmann committed Jan 18, 2023
1 parent a401597 commit ce6c7ea
Show file tree
Hide file tree
Showing 11 changed files with 289 additions and 0 deletions.
2 changes: 2 additions & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions homeassistant/components/otbr/__init__.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions homeassistant/components/otbr/config_flow.py
Original file line number Diff line number Diff line change
@@ -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']}"},
)
3 changes: 3 additions & 0 deletions homeassistant/components/otbr/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Open Thread Border Router integration."""

DOMAIN = "otbr"
11 changes: 11 additions & 0 deletions homeassistant/components/otbr/manifest.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions tests/components/otbr/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Tests for the Thread integration."""
22 changes: 22 additions & 0 deletions tests/components/otbr/conftest.py
Original file line number Diff line number Diff line change
@@ -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)
67 changes: 67 additions & 0 deletions tests/components/otbr/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -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()
93 changes: 93 additions & 0 deletions tests/components/otbr/test_init.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit ce6c7ea

Please sign in to comment.