Hardware integration MVP ()

pull/72500/head
Erik Montnemery 2022-05-25 20:39:15 +02:00 committed by GitHub
parent f166fc009a
commit 2bc093a04d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 535 additions and 3 deletions

View File

@ -416,6 +416,8 @@ build.json @home-assistant/supervisor
/tests/components/guardian/ @bachya
/homeassistant/components/habitica/ @ASMfreaK @leikoilja
/tests/components/habitica/ @ASMfreaK @leikoilja
/homeassistant/components/hardware/ @home-assistant/core
/tests/components/hardware/ @home-assistant/core
/homeassistant/components/harmony/ @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan
/tests/components/harmony/ @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor
@ -829,6 +831,8 @@ build.json @home-assistant/supervisor
/tests/components/rainmachine/ @bachya
/homeassistant/components/random/ @fabaff
/tests/components/random/ @fabaff
/homeassistant/components/raspberry_pi/ @home-assistant/core
/tests/components/raspberry_pi/ @home-assistant/core
/homeassistant/components/rdw/ @frenck
/tests/components/rdw/ @frenck
/homeassistant/components/recollect_waste/ @bachya

View File

@ -0,0 +1,17 @@
"""The Hardware integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
from .const import DOMAIN
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Hardware."""
hass.data[DOMAIN] = {}
websocket_api.async_setup(hass)
return True

View File

@ -0,0 +1,3 @@
"""Constants for the Hardware integration."""
DOMAIN = "hardware"

View File

@ -0,0 +1,31 @@
"""The Hardware integration."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.integration_platform import (
async_process_integration_platforms,
)
from .const import DOMAIN
from .models import HardwareProtocol
async def async_process_hardware_platforms(hass: HomeAssistant):
"""Start processing hardware platforms."""
hass.data[DOMAIN]["hardware_platform"] = {}
await async_process_integration_platforms(hass, DOMAIN, _register_hardware_platform)
return True
async def _register_hardware_platform(
hass: HomeAssistant, integration_domain: str, platform: HardwareProtocol
):
"""Register a hardware platform."""
if integration_domain == DOMAIN:
return
if not hasattr(platform, "async_info"):
raise HomeAssistantError(f"Invalid hardware platform {platform}")
hass.data[DOMAIN]["hardware_platform"][integration_domain] = platform

View File

@ -0,0 +1,7 @@
{
"domain": "hardware",
"name": "Hardware",
"config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/hardware",
"codeowners": ["@home-assistant/core"]
}

View File

@ -0,0 +1,34 @@
"""Models for Hardware."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from homeassistant.core import HomeAssistant, callback
@dataclass
class BoardInfo:
"""Board info type."""
hassio_board_id: str | None
manufacturer: str
model: str | None
revision: str | None
@dataclass
class HardwareInfo:
"""Hardware info type."""
name: str | None
board: BoardInfo | None
url: str | None
class HardwareProtocol(Protocol):
"""Define the format of hardware platforms."""
@callback
def async_info(self, hass: HomeAssistant) -> HardwareInfo:
"""Return info."""

View File

@ -0,0 +1,47 @@
"""The Hardware websocket API."""
from __future__ import annotations
import contextlib
from dataclasses import asdict
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
from .hardware import async_process_hardware_platforms
from .models import HardwareProtocol
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up the hardware websocket API."""
websocket_api.async_register_command(hass, ws_info)
@websocket_api.websocket_command(
{
vol.Required("type"): "hardware/info",
}
)
@websocket_api.async_response
async def ws_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
) -> None:
"""Return hardware info."""
hardware_info = []
if "hardware_platform" not in hass.data[DOMAIN]:
await async_process_hardware_platforms(hass)
hardware_platform: dict[str, HardwareProtocol] = hass.data[DOMAIN][
"hardware_platform"
]
for platform in hardware_platform.values():
if hasattr(platform, "async_info"):
with contextlib.suppress(HomeAssistantError):
hardware_info.append(asdict(platform.async_info(hass)))
connection.send_result(msg["id"], {"hardware": hardware_info})

View File

@ -205,6 +205,10 @@ MAP_SERVICE_API = {
),
}
HARDWARE_INTEGRATIONS = {
"rpi": "raspberry_pi",
}
@bind_hass
async def async_get_addon_info(hass: HomeAssistant, slug: str) -> dict:
@ -705,6 +709,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
# Init add-on ingress panels
await async_setup_addon_panel(hass, hassio)
# Setup hardware integration for the detected board type
async def _async_setup_hardware_integration(hass):
"""Set up hardaware integration for the detected board type."""
if (os_info := get_os_info(hass)) is None:
# os info not yet fetched from supervisor, retry later
async_track_point_in_utc_time(
hass,
_async_setup_hardware_integration,
utcnow() + HASSIO_UPDATE_INTERVAL,
)
return
if (board := os_info.get("board")) is None:
return
if (hw_integration := HARDWARE_INTEGRATIONS.get(board)) is None:
return
hass.async_create_task(
hass.config_entries.flow.async_init(
hw_integration, context={"source": "system"}
)
)
await _async_setup_hardware_integration(hass)
hass.async_create_task(
hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
)

View File

@ -0,0 +1,26 @@
"""The Raspberry Pi integration."""
from __future__ import annotations
from homeassistant.components.hassio import get_os_info
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Raspberry Pi config entry."""
if (os_info := get_os_info(hass)) is None:
# The hassio integration has not yet fetched data from the supervisor
raise ConfigEntryNotReady
board: str
if (board := os_info.get("board")) is None or not board.startswith("rpi"):
# Not running on a Raspberry Pi, Home Assistant may have been migrated
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
return False
await hass.config_entries.flow.async_init(
"rpi_power", context={"source": "onboarding"}
)
return True

View File

@ -0,0 +1,22 @@
"""Config flow for the Raspberry Pi integration."""
from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigFlow
from homeassistant.data_entry_flow import FlowResult
from .const import DOMAIN
class RaspberryPiConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Raspberry Pi."""
VERSION = 1
async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult:
"""Handle the initial step."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
return self.async_create_entry(title="Raspberry Pi", data={})

View File

@ -0,0 +1,3 @@
"""Constants for the Raspberry Pi integration."""
DOMAIN = "raspberry_pi"

View File

@ -0,0 +1,54 @@
"""The Raspberry Pi hardware platform."""
from __future__ import annotations
from homeassistant.components.hardware.models import BoardInfo, HardwareInfo
from homeassistant.components.hassio import get_os_info
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
BOARD_NAMES = {
"rpi": "Raspberry Pi",
"rpi0": "Raspberry Pi Zero",
"rpi0-w": "Raspberry Pi Zero W",
"rpi2": "Raspberry Pi 2",
"rpi3": "Raspberry Pi 3 (32-bit)",
"rpi3-64": "Raspberry Pi 3",
"rpi4": "Raspberry Pi 4 (32-bit)",
"rpi4-64": "Raspberry Pi 4",
}
MODELS = {
"rpi": "1",
"rpi0": "zero",
"rpi0-w": "zero_w",
"rpi2": "2",
"rpi3": "3",
"rpi3-64": "3",
"rpi4": "4",
"rpi4-64": "4",
}
@callback
def async_info(hass: HomeAssistant) -> HardwareInfo:
"""Return board info."""
if (os_info := get_os_info(hass)) is None:
raise HomeAssistantError
board: str
if (board := os_info.get("board")) is None:
raise HomeAssistantError
if not board.startswith("rpi"):
raise HomeAssistantError
return HardwareInfo(
board=BoardInfo(
hassio_board_id=board,
manufacturer=DOMAIN,
model=MODELS.get(board),
revision=None,
),
name=BOARD_NAMES.get(board, f"Unknown Raspberry Pi model '{board}'"),
url=None,
)

View File

@ -0,0 +1,9 @@
{
"domain": "raspberry_pi",
"name": "Raspberry Pi",
"config_flow": false,
"documentation": "https://www.home-assistant.io/integrations/raspberry_pi",
"dependencies": ["hardware", "hassio"],
"codeowners": ["@home-assistant/core"],
"integration_type": "hardware"
}

View File

@ -36,6 +36,8 @@ class RPiPowerFlow(DiscoveryFlowHandler[Awaitable[bool]], domain=DOMAIN):
self, data: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by onboarding."""
if self._async_current_entries():
return self.async_abort(reason="single_instance_allowed")
has_devices = await self._discovery_function(self.hass)
if not has_devices:

View File

@ -52,6 +52,7 @@ NO_IOT_CLASS = [
"downloader",
"ffmpeg",
"frontend",
"hardware",
"history",
"homeassistant",
"image",
@ -76,6 +77,7 @@ NO_IOT_CLASS = [
"profiler",
"proxy",
"python_script",
"raspberry_pi",
"safe_mode",
"script",
"search",
@ -153,7 +155,7 @@ MANIFEST_SCHEMA = vol.Schema(
{
vol.Required("domain"): str,
vol.Required("name"): str,
vol.Optional("integration_type"): "helper",
vol.Optional("integration_type"): vol.In(["hardware", "helper"]),
vol.Optional("config_flow"): bool,
vol.Optional("mqtt"): [str],
vol.Optional("zeroconf"): [

View File

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

View File

@ -0,0 +1,18 @@
"""Test the hardware websocket API."""
from homeassistant.components.hardware.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
async def test_board_info(hass: HomeAssistant, hass_ws_client) -> None:
"""Test we can get the board info."""
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "hardware/info"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert msg["result"] == {"hardware": []}

View File

@ -20,8 +20,19 @@ from tests.common import MockConfigEntry, async_fire_time_changed
MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"}
@pytest.fixture()
def os_info():
"""Mock os/info."""
return {
"json": {
"result": "ok",
"data": {"version_latest": "1.0.0", "version": "1.0.0"},
}
}
@pytest.fixture(autouse=True)
def mock_all(aioclient_mock, request):
def mock_all(aioclient_mock, request, os_info):
"""Mock all setup requests."""
aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"})
aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"})
@ -64,7 +75,7 @@ def mock_all(aioclient_mock, request):
)
aioclient_mock.get(
"http://127.0.0.1/os/info",
json={"result": "ok", "data": {"version_latest": "1.0.0", "version": "1.0.0"}},
**os_info,
)
aioclient_mock.get(
"http://127.0.0.1/supervisor/info",
@ -701,3 +712,29 @@ async def test_coordinator_updates(hass, caplog):
)
assert refresh_updates_mock.call_count == 1
assert "Error on Supervisor API: Unknown" in caplog.text
@pytest.mark.parametrize(
"os_info",
[
{
"json": {
"result": "ok",
"data": {"version_latest": "1.0.0", "version": "1.0.0", "board": "rpi"},
}
}
],
)
async def test_setup_hardware_integration(hass, aioclient_mock):
"""Test setup initiates hardware integration."""
with patch.dict(os.environ, MOCK_ENVIRON), patch(
"homeassistant.components.raspberry_pi.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await async_setup_component(hass, "hassio", {"hassio": {}})
assert result
await hass.async_block_till_done()
assert aioclient_mock.call_count == 15
assert len(mock_setup_entry.mock_calls) == 1

View File

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

View File

@ -0,0 +1,58 @@
"""Test the Raspberry Pi config flow."""
from unittest.mock import patch
from homeassistant.components.raspberry_pi.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY
from tests.common import MockConfigEntry, MockModule, mock_integration
async def test_config_flow(hass: HomeAssistant) -> None:
"""Test the config flow."""
mock_integration(hass, MockModule("hassio"))
with patch(
"homeassistant.components.raspberry_pi.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "system"}
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Raspberry Pi"
assert result["data"] == {}
assert result["options"] == {}
assert len(mock_setup_entry.mock_calls) == 1
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {}
assert config_entry.options == {}
assert config_entry.title == "Raspberry Pi"
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=DOMAIN,
options={},
title="Raspberry Pi",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.raspberry_pi.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "system"}
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "single_instance_allowed"
mock_setup_entry.assert_not_called()

View File

@ -0,0 +1,57 @@
"""Test the Raspberry Pi hardware platform."""
import pytest
from homeassistant.components.hassio import DATA_OS_INFO
from homeassistant.components.raspberry_pi.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockModule, mock_integration
async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None:
"""Test we can get the board info."""
mock_integration(hass, MockModule("hassio"))
hass.data[DATA_OS_INFO] = {"board": "rpi"}
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "hardware/info"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert msg["result"] == {
"hardware": [
{
"board": {
"hassio_board_id": "rpi",
"manufacturer": "raspberry_pi",
"model": "1",
"revision": None,
},
"name": "Raspberry Pi",
"url": None,
}
]
}
@pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}])
async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None:
"""Test async_info raises if os_info is not as expected."""
mock_integration(hass, MockModule("hassio"))
hass.data[DATA_OS_INFO] = os_info
assert await async_setup_component(hass, DOMAIN, {})
client = await hass_ws_client(hass)
await client.send_json({"id": 1, "type": "hardware/info"})
msg = await client.receive_json()
assert msg["id"] == 1
assert msg["success"]
assert msg["result"] == {"hardware": []}

View File

@ -0,0 +1,72 @@
"""Test the Raspberry Pi integration."""
from unittest.mock import patch
from homeassistant.components.raspberry_pi.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, MockModule, mock_integration
async def test_setup_entry(hass: HomeAssistant) -> None:
"""Test setup of a config entry."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Raspberry Pi",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.raspberry_pi.get_os_info",
return_value={"board": "rpi"},
) as mock_get_os_info:
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(mock_get_os_info.mock_calls) == 1
async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None:
"""Test setup of a config entry with wrong board type."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Raspberry Pi",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.raspberry_pi.get_os_info",
return_value={"board": "generic-x86-64"},
) as mock_get_os_info:
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(mock_get_os_info.mock_calls) == 1
async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None:
"""Test setup of a config entry when hassio has not fetched os_info."""
mock_integration(hass, MockModule("hassio"))
# Setup the config entry
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={},
title="Raspberry Pi",
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.raspberry_pi.get_os_info",
return_value=None,
) as mock_get_os_info:
assert not await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(mock_get_os_info.mock_calls) == 1
assert config_entry.state == ConfigEntryState.SETUP_RETRY