diff --git a/CODEOWNERS b/CODEOWNERS index 8a7a058e01f..98d60fbfcb7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py new file mode 100644 index 00000000000..b3f342d4e32 --- /dev/null +++ b/homeassistant/components/hardware/__init__.py @@ -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 diff --git a/homeassistant/components/hardware/const.py b/homeassistant/components/hardware/const.py new file mode 100644 index 00000000000..7fd64d5d968 --- /dev/null +++ b/homeassistant/components/hardware/const.py @@ -0,0 +1,3 @@ +"""Constants for the Hardware integration.""" + +DOMAIN = "hardware" diff --git a/homeassistant/components/hardware/hardware.py b/homeassistant/components/hardware/hardware.py new file mode 100644 index 00000000000..e07f70022f4 --- /dev/null +++ b/homeassistant/components/hardware/hardware.py @@ -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 diff --git a/homeassistant/components/hardware/manifest.json b/homeassistant/components/hardware/manifest.json new file mode 100644 index 00000000000..e7e156b6065 --- /dev/null +++ b/homeassistant/components/hardware/manifest.json @@ -0,0 +1,7 @@ +{ + "domain": "hardware", + "name": "Hardware", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/hardware", + "codeowners": ["@home-assistant/core"] +} diff --git a/homeassistant/components/hardware/models.py b/homeassistant/components/hardware/models.py new file mode 100644 index 00000000000..067c2d955df --- /dev/null +++ b/homeassistant/components/hardware/models.py @@ -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.""" diff --git a/homeassistant/components/hardware/websocket_api.py b/homeassistant/components/hardware/websocket_api.py new file mode 100644 index 00000000000..388b9597481 --- /dev/null +++ b/homeassistant/components/hardware/websocket_api.py @@ -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}) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 7e976536691..93d902e4bae 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -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"}) ) diff --git a/homeassistant/components/raspberry_pi/__init__.py b/homeassistant/components/raspberry_pi/__init__.py new file mode 100644 index 00000000000..ab1114722c6 --- /dev/null +++ b/homeassistant/components/raspberry_pi/__init__.py @@ -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 diff --git a/homeassistant/components/raspberry_pi/config_flow.py b/homeassistant/components/raspberry_pi/config_flow.py new file mode 100644 index 00000000000..db0f8643e5c --- /dev/null +++ b/homeassistant/components/raspberry_pi/config_flow.py @@ -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={}) diff --git a/homeassistant/components/raspberry_pi/const.py b/homeassistant/components/raspberry_pi/const.py new file mode 100644 index 00000000000..48c004a6447 --- /dev/null +++ b/homeassistant/components/raspberry_pi/const.py @@ -0,0 +1,3 @@ +"""Constants for the Raspberry Pi integration.""" + +DOMAIN = "raspberry_pi" diff --git a/homeassistant/components/raspberry_pi/hardware.py b/homeassistant/components/raspberry_pi/hardware.py new file mode 100644 index 00000000000..343ba69d76b --- /dev/null +++ b/homeassistant/components/raspberry_pi/hardware.py @@ -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, + ) diff --git a/homeassistant/components/raspberry_pi/manifest.json b/homeassistant/components/raspberry_pi/manifest.json new file mode 100644 index 00000000000..5ba4f87e783 --- /dev/null +++ b/homeassistant/components/raspberry_pi/manifest.json @@ -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" +} diff --git a/homeassistant/components/rpi_power/config_flow.py b/homeassistant/components/rpi_power/config_flow.py index ed8a45822b0..97814c2a866 100644 --- a/homeassistant/components/rpi_power/config_flow.py +++ b/homeassistant/components/rpi_power/config_flow.py @@ -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: diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index b66b33486cb..c478d16cf0f 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -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"): [ diff --git a/tests/components/hardware/__init__.py b/tests/components/hardware/__init__.py new file mode 100644 index 00000000000..9b3acf65c59 --- /dev/null +++ b/tests/components/hardware/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hardware integration.""" diff --git a/tests/components/hardware/test_websocket_api.py b/tests/components/hardware/test_websocket_api.py new file mode 100644 index 00000000000..116879aa628 --- /dev/null +++ b/tests/components/hardware/test_websocket_api.py @@ -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": []} diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 6f4b9a39a9f..ff595aaa602 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -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 diff --git a/tests/components/raspberry_pi/__init__.py b/tests/components/raspberry_pi/__init__.py new file mode 100644 index 00000000000..0b10b50c6f0 --- /dev/null +++ b/tests/components/raspberry_pi/__init__.py @@ -0,0 +1 @@ +"""Tests for the Raspberry Pi integration.""" diff --git a/tests/components/raspberry_pi/test_config_flow.py b/tests/components/raspberry_pi/test_config_flow.py new file mode 100644 index 00000000000..dfad1100cad --- /dev/null +++ b/tests/components/raspberry_pi/test_config_flow.py @@ -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() diff --git a/tests/components/raspberry_pi/test_hardware.py b/tests/components/raspberry_pi/test_hardware.py new file mode 100644 index 00000000000..748972c8d60 --- /dev/null +++ b/tests/components/raspberry_pi/test_hardware.py @@ -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": []} diff --git a/tests/components/raspberry_pi/test_init.py b/tests/components/raspberry_pi/test_init.py new file mode 100644 index 00000000000..dd86da7bce0 --- /dev/null +++ b/tests/components/raspberry_pi/test_init.py @@ -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