From ce99319ea5ae0f5386bce07c7912ceb9668b5dca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Apr 2023 17:02:52 +0200 Subject: [PATCH] Add LED settings support to Home Assistant Yellow (#86451) * Add LED control support to Home Assistant Yellow * Fix the handlers * Remove switch platform * Allow configuring LED settings from the options flow * Add missing translations * Add tests * Add tests --- homeassistant/components/hassio/__init__.py | 3 + homeassistant/components/hassio/handler.py | 31 +++ .../homeassistant_yellow/config_flow.py | 98 +++++++++ .../homeassistant_yellow/strings.json | 24 +++ tests/components/hassio/test_handler.py | 53 +++++ .../homeassistant_yellow/test_config_flow.py | 199 +++++++++++++++++- 6 files changed, 406 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 715252734bb..78d974fe9cf 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -85,9 +85,12 @@ from .handler import ( # noqa: F401 async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_yellow_settings, async_install_addon, + async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_yellow_settings, async_start_addon, async_stop_addon, async_uninstall_addon, diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 9c4feb3989f..e4a0dd0f77e 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -262,6 +262,37 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b return await hassio.send_command(command, timeout=None) +@api_data +async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Yellow.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/yellow", method="get") + + +@api_data +async def async_set_yellow_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Yellow. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/yellow", method="post", payload=settings + ) + + +@api_data +async def async_reboot_host(hass: HomeAssistant) -> dict: + """Reboot the host. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/host/reboot", method="post", timeout=60) + + class HassIO: """Small API wrapper for Hass.io.""" diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 09cdcc1469a..3da67023abd 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -1,15 +1,37 @@ """Config flow for the Home Assistant Yellow integration.""" from __future__ import annotations +import logging from typing import Any +import aiohttp +import async_timeout +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_yellow_settings, + async_reboot_host, + async_set_yellow_settings, +) from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN, ZHA_HW_DISCOVERY_DATA +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + vol.Required("disk_led"): selector.BooleanSelector(), + vol.Required("heartbeat_led"): selector.BooleanSelector(), + vol.Required("power_led"): selector.BooleanSelector(), + } +) + class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Yellow.""" @@ -35,6 +57,82 @@ class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): """Handle an option flow for Home Assistant Yellow.""" + _hw_settings: dict[str, bool] | None = None + + async def async_step_on_supervisor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle logic when on Supervisor host.""" + return self.async_show_menu( + step_id="main_menu", + menu_options=[ + "hardware_settings", + "multipan_settings", + ], + ) + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with async_timeout.timeout(10): + await async_set_yellow_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return await self.async_step_confirm_reboot() + + try: + async with async_timeout.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_yellow_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) + + async def async_step_confirm_reboot( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reboot host.""" + return self.async_show_menu( + step_id="reboot_menu", + menu_options=[ + "reboot_now", + "reboot_later", + ], + ) + + async def async_step_reboot_now( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reboot now.""" + await async_reboot_host(self.hass) + return self.async_create_entry(data={}) + + async def async_step_reboot_later( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reboot later.""" + return self.async_create_entry(data={}) + + async def async_step_multipan_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle multipan settings.""" + return await super().async_step_on_supervisor(user_input) + async def _async_serial_port_settings( self, ) -> silabs_multiprotocol_addon.SerialPortSettings: diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 970f9d97a4c..d97b01c7c84 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,9 +11,31 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "disk_led": "Disk LED", + "heartbeat_led": "Heartbeat LED", + "power_led": "Power LED" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "main_menu": { + "menu_options": { + "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", + "multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + }, "show_revert_guide": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]", "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]" @@ -31,6 +53,8 @@ "addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]", "addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]", "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "write_hw_settings_error": "Failed to write hardware settings", "zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]" }, "progress": { diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index c7075dba932..e980bf214a0 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -7,7 +7,9 @@ import aiohttp from aiohttp import hdrs, web import pytest +from homeassistant.components.hassio import handler from homeassistant.components.hassio.handler import HassIO, HassioAPIError +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from tests.test_util.aiohttp import AiohttpClientMocker @@ -360,3 +362,54 @@ async def test_api_headers( assert received_request.headers[hdrs.CONTENT_TYPE] == "application/json" else: assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" + + +async def test_api_get_yellow_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/os/boards/yellow", + json={ + "result": "ok", + "data": {"disk_led": True, "heartbeat_led": True, "power_led": True}, + }, + ) + + assert await handler.async_get_yellow_settings(hass) == { + "disk_led": True, + "heartbeat_led": True, + "power_led": True, + } + assert aioclient_mock.call_count == 1 + + +async def test_api_set_yellow_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/os/boards/yellow", + json={"result": "ok", "data": {}}, + ) + + assert ( + await handler.async_set_yellow_settings( + hass, {"disk_led": True, "heartbeat_led": True, "power_led": True} + ) + == {} + ) + assert aioclient_mock.call_count == 1 + + +async def test_api_reboot_host( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/host/reboot", + json={"result": "ok", "data": {}}, + ) + + assert await handler.async_reboot_host(hass) == {} + assert aioclient_mock.call_count == 1 diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 53d1c5e974d..66401bcd7bc 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Home Assistant Yellow config flow.""" from unittest.mock import Mock, patch +import pytest + from homeassistant.components.homeassistant_yellow.const import DOMAIN from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN from homeassistant.core import HomeAssistant @@ -9,6 +11,34 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(name="get_yellow_settings") +def mock_get_yellow_settings(): + """Mock getting yellow settings.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings", + return_value={"disk_led": True, "heartbeat_led": True, "power_led": True}, + ) as get_yellow_settings: + yield get_yellow_settings + + +@pytest.fixture(name="set_yellow_settings") +def mock_set_yellow_settings(): + """Mock setting yellow settings.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings", + ) as set_yellow_settings: + yield set_yellow_settings + + +@pytest.fixture(name="reboot_host") +def mock_reboot_host(): + """Mock rebooting host.""" + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_reboot_host", + ) as reboot_host: + yield reboot_host + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -79,11 +109,17 @@ async def test_option_flow_install_multi_pan_addon( ) config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + with patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "multipan_settings"}, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "addon_not_installed" @@ -155,11 +191,17 @@ async def test_option_flow_install_multi_pan_addon_zha( ) zha_config_entry.add_to_hass(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + with patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", side_effect=Mock(return_value=True), ): - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "multipan_settings"}, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "addon_not_installed" @@ -210,3 +252,156 @@ async def test_option_flow_install_multi_pan_addon_zha( result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("reboot_menu_choice", "reboot_calls"), + [("reboot_now", 1), ("reboot_later", 0)], +) +async def test_option_flow_led_settings( + hass: HomeAssistant, + get_yellow_settings, + set_yellow_settings, + reboot_host, + reboot_menu_choice, + reboot_calls, +) -> None: + """Test updating LED settings.""" + 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) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"disk_led": False, "heartbeat_led": False, "power_led": False}, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "reboot_menu" + set_yellow_settings.assert_called_once_with( + hass, {"disk_led": False, "heartbeat_led": False, "power_led": False} + ) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": reboot_menu_choice}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert len(reboot_host.mock_calls) == reboot_calls + + +async def test_option_flow_led_settings_unchanged( + hass: HomeAssistant, + get_yellow_settings, + set_yellow_settings, +) -> None: + """Test updating LED settings.""" + 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) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"disk_led": True, "heartbeat_led": True, "power_led": True}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_yellow_settings.assert_not_called() + + +async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None: + """Test updating LED settings.""" + 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) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "read_hw_settings_error" + + +async def test_option_flow_led_settings_fail_2( + hass: HomeAssistant, get_yellow_settings +) -> None: + """Test updating LED settings.""" + 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) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"next_step_id": "hardware_settings"}, + ) + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"disk_led": False, "heartbeat_led": False, "power_led": False}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "write_hw_settings_error"