diff --git a/CODEOWNERS b/CODEOWNERS index 9a57f3e791a..2d4fb5ceebf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -447,6 +447,8 @@ build.json @home-assistant/supervisor /tests/components/home_plus_control/ @chemaaa /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core +/homeassistant/components/homeassistant_yellow/ @home-assistant/core +/tests/components/homeassistant_yellow/ @home-assistant/core /homeassistant/components/homekit/ @bdraco /tests/components/homekit/ @bdraco /homeassistant/components/homekit_controller/ @Jc2k @bdraco diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 0df29a6153b..cd3c704d4c9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -215,6 +215,7 @@ HARDWARE_INTEGRATIONS = { "rpi3-64": "raspberry_pi", "rpi4": "raspberry_pi", "rpi4-64": "raspberry_pi", + "yellow": "homeassistant_yellow", } diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py new file mode 100644 index 00000000000..89a73ab769a --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -0,0 +1,35 @@ +"""The Home Assistant Yellow 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 Home Assistant Yellow 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 | None + if (board := os_info.get("board")) is None or not board == "yellow": + # Not running on a Home Assistant Yellow, 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( + "zha", + context={"source": "hardware"}, + data={ + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + }, + ) + + return True diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py new file mode 100644 index 00000000000..191a28f47a4 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for the Home Assistant Yellow 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 HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Yellow.""" + + 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="Home Assistant Yellow", data={}) diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py new file mode 100644 index 00000000000..41eae70b3f2 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -0,0 +1,3 @@ +"""Constants for the Home Assistant Yellow integration.""" + +DOMAIN = "homeassistant_yellow" diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py new file mode 100644 index 00000000000..aa1fe4b745b --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -0,0 +1,34 @@ +"""The Home Assistant Yellow 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 + +BOARD_NAME = "Home Assistant Yellow" +MANUFACTURER = "homeassistant" +MODEL = "yellow" + + +@callback +def async_info(hass: HomeAssistant) -> HardwareInfo: + """Return board info.""" + if (os_info := get_os_info(hass)) is None: + raise HomeAssistantError + board: str | None + if (board := os_info.get("board")) is None: + raise HomeAssistantError + if not board == "yellow": + raise HomeAssistantError + + return HardwareInfo( + board=BoardInfo( + hassio_board_id=board, + manufacturer=MANUFACTURER, + model=MODEL, + revision=None, + ), + name=BOARD_NAME, + url=None, + ) diff --git a/homeassistant/components/homeassistant_yellow/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json new file mode 100644 index 00000000000..47e6c8e2cd8 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "homeassistant_yellow", + "name": "Home Assistant Yellow", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", + "dependencies": ["hardware", "hassio"], + "codeowners": ["@home-assistant/core"], + "integration_type": "hardware" +} diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index e116954cdcb..1832424587b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from homeassistant.components import usb, zeroconf +from homeassistant.components import onboarding, usb, zeroconf from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult @@ -36,6 +36,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize flow instance.""" self._device_path = None + self._device_settings = None self._radio_type = None self._title = None @@ -242,6 +243,54 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_hardware(self, data=None): + """Handle hardware flow.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if not data: + return self.async_abort(reason="invalid_hardware_data") + if data.get("radio_type") != "efr32": + return self.async_abort(reason="invalid_hardware_data") + self._radio_type = RadioType.ezsp.name + app_cls = RadioType[self._radio_type].controller + + schema = { + vol.Required( + CONF_DEVICE_PATH, default=self._device_path or vol.UNDEFINED + ): str + } + radio_schema = app_cls.SCHEMA_DEVICE.schema + assert not isinstance(radio_schema, vol.Schema) + + for param, value in radio_schema.items(): + if param in SUPPORTED_PORT_SETTINGS: + schema[param] = value + try: + self._device_settings = vol.Schema(schema)(data.get("port")) + except vol.Invalid: + return self.async_abort(reason="invalid_hardware_data") + + self._title = data["port"]["path"] + + self._set_confirm_only() + return await self.async_step_confirm_hardware() + + async def async_step_confirm_hardware(self, user_input=None): + """Confirm a hardware discovery.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry( + title=self._title, + data={ + CONF_DEVICE: self._device_settings, + CONF_RADIO_TYPE: self._radio_type, + }, + ) + + return self.async_show_form( + step_id="confirm_hardware", + description_placeholders={CONF_NAME: self._title}, + ) + async def detect_radios(dev_path: str) -> dict[str, Any] | None: """Probe all radio types on the device port.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 023af7a8a0e..71131f240a9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -87,7 +87,7 @@ "name": "*zigate*" } ], - "after_dependencies": ["usb", "zeroconf"], + "after_dependencies": ["onboarding", "usb", "zeroconf"], "iot_class": "local_polling", "loggers": [ "aiosqlite", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 7f2e8e0d477..0cd20364533 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -56,6 +56,7 @@ NO_IOT_CLASS = [ "hardware", "history", "homeassistant", + "homeassistant_yellow", "image", "input_boolean", "input_button", diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1569e834562..6ac3debe3d8 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -732,6 +732,7 @@ async def test_coordinator_updates(hass, caplog): ({"board": "rpi3-64"}, "raspberry_pi"), ({"board": "rpi4"}, "raspberry_pi"), ({"board": "rpi4-64"}, "raspberry_pi"), + ({"board": "yellow"}, "homeassistant_yellow"), ], ) async def test_setup_hardware_integration(hass, aioclient_mock, integration): diff --git a/tests/components/homeassistant_yellow/__init__.py b/tests/components/homeassistant_yellow/__init__.py new file mode 100644 index 00000000000..a03eed7b9b2 --- /dev/null +++ b/tests/components/homeassistant_yellow/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Yellow integration.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py new file mode 100644 index 00000000000..8700e361dc8 --- /dev/null +++ b/tests/components/homeassistant_yellow/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for the Home Assistant Yellow integration.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_zha(): + """Mock the zha integration.""" + with patch( + "homeassistant.components.zha.async_setup_entry", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py new file mode 100644 index 00000000000..2e96b05a919 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the Home Assistant Yellow config flow.""" +from unittest.mock import patch + +from homeassistant.components.homeassistant_yellow.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.homeassistant_yellow.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"] == "Home Assistant Yellow" + 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 == "Home Assistant Yellow" + + +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="Home Assistant Yellow", + ) + 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( + 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/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py new file mode 100644 index 00000000000..28403334ec1 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -0,0 +1,89 @@ +"""Test the Home Assistant Yellow hardware platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, 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")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value={"board": "yellow"}, + ): + 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": "yellow", + "manufacturer": "homeassistant", + "model": "yellow", + "revision": None, + }, + "name": "Home Assistant Yellow", + "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")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value=os_info, + ): + 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/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py new file mode 100644 index 00000000000..308c392ea26 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_init.py @@ -0,0 +1,84 @@ +"""Test the Home Assistant Yellow integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +@pytest.mark.parametrize( + "onboarded, num_entries, num_flows", ((False, 1, 0), (True, 0, 1)) +) +async def test_setup_entry( + hass: HomeAssistant, onboarded, num_entries, num_flows +) -> None: + """Test setup of a config entry, including setup of zha.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ) as mock_get_os_info, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded + ): + 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 + + assert len(hass.config_entries.async_entries("zha")) == num_entries + assert len(hass.config_entries.flow.async_progress_by_handler("zha")) == num_flows + + +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="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.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="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.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 diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index dee04165c1e..df68c21b6c0 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -766,3 +766,107 @@ async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_not_onboarded(hass): + """Test hardware flow.""" + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "/dev/ttyAMA1" + assert result["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_onboarded(hass): + """Test hardware flow.""" + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm_hardware" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "/dev/ttyAMA1" + assert result["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +async def test_hardware_already_setup(hass): + """Test hardware flow -- already setup.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.parametrize( + "data", (None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}) +) +async def test_hardware_invalid_data(hass, data): + """Test onboarding flow -- invalid data.""" + + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_hardware_data"