From dd7db85529de0a88c5618c35a8f30cc455e830bf Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Thu, 11 May 2023 01:17:23 +0300 Subject: [PATCH] User config flow and custom panel for Dynalite integration (#77181) --- homeassistant/components/dynalite/__init__.py | 163 ++---------------- .../components/dynalite/config_flow.py | 57 +++++- .../components/dynalite/manifest.json | 4 +- homeassistant/components/dynalite/panel.py | 117 +++++++++++++ homeassistant/components/dynalite/schema.py | 155 +++++++++++++++++ .../components/dynalite/strings.json | 24 +++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/dynalite/test_config_flow.py | 87 +++++++++- tests/components/dynalite/test_panel.py | 141 +++++++++++++++ 10 files changed, 591 insertions(+), 163 deletions(-) create mode 100644 homeassistant/components/dynalite/panel.py create mode 100644 homeassistant/components/dynalite/schema.py create mode 100644 homeassistant/components/dynalite/strings.json create mode 100644 tests/components/dynalite/test_panel.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index fe1872e1fe3..c7f23af5fb1 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,14 +1,11 @@ """Support for the Dynalite networks.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -17,36 +14,10 @@ from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( - ACTIVE_INIT, - ACTIVE_OFF, - ACTIVE_ON, ATTR_AREA, ATTR_CHANNEL, ATTR_HOST, - CONF_ACTIVE, - CONF_AREA, - CONF_AUTO_DISCOVER, CONF_BRIDGES, - CONF_CHANNEL, - CONF_CHANNEL_COVER, - CONF_CLOSE_PRESET, - CONF_DEVICE_CLASS, - CONF_DURATION, - CONF_FADE, - CONF_LEVEL, - CONF_NO_DEFAULT, - CONF_OPEN_PRESET, - CONF_POLL_TIMER, - CONF_PRESET, - CONF_ROOM_OFF, - CONF_ROOM_ON, - CONF_STOP_PRESET, - CONF_TEMPLATE, - CONF_TILT_TIME, - DEFAULT_CHANNEL_TYPE, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_TEMPLATES, DOMAIN, LOGGER, PLATFORMS, @@ -54,128 +25,18 @@ from .const import ( SERVICE_REQUEST_CHANNEL_LEVEL, ) from .convert_config import convert_config - - -def num_string(value: int | str) -> str: - """Test if value is a string of digits, aka an integer.""" - new_value = str(value) - if new_value.isdigit(): - return new_value - raise vol.Invalid("Not a string with numbers") - - -CHANNEL_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( - "light", "switch" - ), - } -) - -CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) - -PRESET_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_LEVEL): vol.Coerce(float), - } -) - -PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) - -TEMPLATE_ROOM_SCHEMA = vol.Schema( - {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} -) - -TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - } -) - -TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) - -TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) - - -def validate_area(config: dict[str, Any]) -> dict[str, Any]: - """Validate that template parameters are only used if area is using the relevant template.""" - conf_set = set() - for configs in DEFAULT_TEMPLATES.values(): - for conf in configs: - conf_set.add(conf) - if config.get(CONF_TEMPLATE): - for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: - conf_set.remove(conf) - for conf in conf_set: - if config.get(conf): - raise vol.Invalid( - f"{conf} should not be part of area {config[CONF_NAME]} config" - ) - return config - - -AREA_DATA_SCHEMA = vol.Schema( - vol.All( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_NO_DEFAULT): cv.boolean, - vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - # the next ones can be part of the templates - vol.Optional(CONF_ROOM_ON): num_string, - vol.Optional(CONF_ROOM_OFF): num_string, - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - }, - validate_area, - ) -) - -AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) - -PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) - - -BRIDGE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), - vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), - vol.Optional(CONF_AREA): AREA_SCHEMA, - vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, - vol.Optional(CONF_ACTIVE, default=False): vol.Any( - ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean - ), - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, - } -) +from .panel import async_register_dynalite_frontend +from .schema import BRIDGE_SCHEMA CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} + ), + }, + ), extra=vol.ALLOW_EXTRA, ) @@ -277,6 +138,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_register_dynalite_frontend(hass) + return True diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index d723825319a..946d4ac653d 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -3,12 +3,16 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .bridge import DynaliteBridge -from .const import DOMAIN, LOGGER +from .const import DEFAULT_PORT, DOMAIN, LOGGER from .convert_config import convert_config @@ -23,8 +27,20 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Import a new bridge as a config entry.""" - LOGGER.debug("Starting async_step_import - %s", import_info) + LOGGER.debug("Starting async_step_import (deprecated) - %s", import_info) + # Raise an issue that this is deprecated and has been imported + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + host = import_info[CONF_HOST] + # Check if host already exists for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: self.hass.config_entries.async_update_entry( @@ -33,9 +49,34 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") # New entry - bridge = DynaliteBridge(self.hass, convert_config(import_info)) + return await self._try_create(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when user initializes a integration.""" + if user_input is not None: + return await self._try_create(user_input) + + schema = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def _try_create(self, info: dict[str, Any]) -> FlowResult: + """Try to connect and if successful, create entry.""" + host = info[CONF_HOST] + configured_hosts = [ + entry.data[CONF_HOST] for entry in self._async_current_entries() + ] + if host in configured_hosts: + return self.async_abort(reason="already_configured") + bridge = DynaliteBridge(self.hass, convert_config(info)) if not await bridge.async_setup(): - LOGGER.error("Unable to setup bridge - import info=%s", import_info) - return self.async_abort(reason="no_connection") - LOGGER.debug("Creating entry for the bridge - %s", import_info) - return self.async_create_entry(title=host, data=import_info) + LOGGER.error("Unable to setup bridge - import info=%s", info) + return self.async_abort(reason="cannot_connect") + LOGGER.debug("Creating entry for the bridge - %s", info) + return self.async_create_entry(title=info[CONF_HOST], data=info) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index cded6a7e365..a3dd890cc11 100644 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -1,10 +1,12 @@ { "domain": "dynalite", "name": "Philips Dynalite", + "after_dependencies": ["panel_custom"], "codeowners": ["@ziv1234"], "config_flow": true, + "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/dynalite", "iot_class": "local_push", "loggers": ["dynalite_devices_lib"], - "requirements": ["dynalite_devices==0.1.47"] + "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"] } diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py new file mode 100644 index 00000000000..e7a0890033c --- /dev/null +++ b/homeassistant/components/dynalite/panel.py @@ -0,0 +1,117 @@ +"""Dynalite API interface for the frontend.""" + +from dynalite_panel import get_build_id, locate_dir +import voluptuous as vol + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant, callback + +from .const import ( + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_PRESET, + CONF_TEMPLATE, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) +from .schema import BRIDGE_SCHEMA + +URL_BASE = "/dynalite_static" + +RELEVANT_CONFS = [ + CONF_NAME, + CONF_HOST, + CONF_PORT, + CONF_AUTO_DISCOVER, + CONF_AREA, + CONF_DEFAULT, + CONF_ACTIVE, + CONF_PRESET, + CONF_TEMPLATE, +] + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/get-config", + } +) +@websocket_api.require_admin +@callback +def get_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entries = hass.config_entries.async_entries(DOMAIN) + relevant_config = { + entry.entry_id: { + conf: entry.data[conf] for conf in RELEVANT_CONFS if conf in entry.data + } + for entry in entries + } + dynalite_defaults = { + "DEFAULT_NAME": DEFAULT_NAME, + "DEVICE_CLASSES": DEVICE_CLASSES, + "DEFAULT_PORT": DEFAULT_PORT, + } + connection.send_result( + msg["id"], {"config": relevant_config, "default": dynalite_defaults} + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/save-config", + vol.Required("entry_id"): str, + vol.Required("config"): BRIDGE_SCHEMA, + } +) +@websocket_api.require_admin +@callback +def save_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entry_id = msg["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + if not entry: + LOGGER.error( + "Dynalite - received updated config for invalid entry - %s", entry_id + ) + connection.send_result(msg["id"], {"error": True}) + return + message_conf = msg["config"] + message_data = { + conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf + } + LOGGER.info("Updating Dynalite config entry") + hass.config_entries.async_update_entry(entry, data=message_data) + connection.send_result(msg["id"], {}) + + +async def async_register_dynalite_frontend(hass: HomeAssistant): + """Register the Dynalite frontend configuration panel.""" + websocket_api.async_register_command(hass, get_dynalite_config) + websocket_api.async_register_command(hass, save_dynalite_config) + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="dynalite-panel", + sidebar_title=DOMAIN.capitalize(), + sidebar_icon="mdi:power", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py new file mode 100644 index 00000000000..d470243782b --- /dev/null +++ b/homeassistant/components/dynalite/schema.py @@ -0,0 +1,155 @@ +"""Schema for config entries.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.helpers import config_validation as cv + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_LEVEL, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + DEFAULT_CHANNEL_TYPE, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_TEMPLATES, +) + + +def num_string(value: str | int) -> str: + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + "light", "switch" + ), + } +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +PRESET_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_LEVEL): vol.Coerce(float), + } +) + +PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) + +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) + +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: dict[str, Any]) -> dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for configs in DEFAULT_TEMPLATES.values(): + for conf in configs: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + + +AREA_DATA_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Any( + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean + ), + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, + } +) diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json new file mode 100644 index 00000000000..1d78108f909 --- /dev/null +++ b/homeassistant/components/dynalite/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Configure Dynalite Connection", + "description": "Gateway address to connect to DYNET network" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Dynalite YAML configuration is being removed", + "description": "Configuring Dynalite using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Dynalite YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/requirements_all.txt b/requirements_all.txt index badce2c5bd7..7d1f2c7328f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -625,6 +625,9 @@ dweepy==0.3.0 # homeassistant.components.dynalite dynalite_devices==0.1.47 +# homeassistant.components.dynalite +dynalite_panel==0.0.4 + # homeassistant.components.rainforest_eagle eagle100==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ae9df6d755..e69f742d4cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -499,6 +499,9 @@ dwdwfsapi==1.0.6 # homeassistant.components.dynalite dynalite_devices==0.1.47 +# homeassistant.components.dynalite +dynalite_panel==0.0.4 + # homeassistant.components.rainforest_eagle eagle100==0.1.1 diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index c2b0cc1a7d7..d0bd335decc 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -5,7 +5,13 @@ import pytest from homeassistant import config_entries from homeassistant.components import dynalite +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_get as async_get_issue_registry, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -14,14 +20,22 @@ from tests.common import MockConfigEntry ("first_con", "second_con", "exp_type", "exp_result", "exp_reason"), [ (True, True, "create_entry", config_entries.ConfigEntryState.LOADED, ""), - (False, False, "abort", None, "no_connection"), + (False, False, "abort", None, "cannot_connect"), (True, False, "create_entry", config_entries.ConfigEntryState.SETUP_RETRY, ""), ], ) async def test_flow( - hass: HomeAssistant, first_con, second_con, exp_type, exp_result, exp_reason + hass: HomeAssistant, + first_con, + second_con, + exp_type, + exp_result, + exp_reason, ) -> None: """Run a flow with or without errors and return result.""" + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + assert issue is None host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -38,6 +52,19 @@ async def test_flow( assert result["result"].state == exp_result if exp_reason: assert result["reason"] == exp_reason + issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + assert issue is not None + assert issue.severity == IssueSeverity.WARNING + + +async def test_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Check that deprecation warning appears in caplog.""" + await async_setup_component( + hass, dynalite.DOMAIN, {dynalite.DOMAIN: {dynalite.CONF_HOST: "aaa"}} + ) + assert "The 'dynalite' option is deprecated" in caplog.text async def test_existing(hass: HomeAssistant) -> None: @@ -66,7 +93,7 @@ async def test_existing_update(hass: HomeAssistant) -> None: port2 = 8888 entry = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1}, + data={dynalite.CONF_HOST: host, CONF_PORT: port1}, ) entry.add_to_hass(hass) with patch( @@ -80,7 +107,7 @@ async def test_existing_update(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port2}, + data={dynalite.CONF_HOST: host, CONF_PORT: port2}, ) await hass.async_block_till_done() assert mock_dyn_dev().configure.call_count == 2 @@ -107,3 +134,55 @@ async def test_two_entries(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" assert result["result"].state == config_entries.ConfigEntryState.LOADED + + +async def test_setup_user(hass): + """Test configuration via the user flow.""" + host = "3.4.5.6" + port = 1234 + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": host, "port": port}, + ) + + assert result["type"] == "create_entry" + assert result["result"].state == config_entries.ConfigEntryState.LOADED + assert result["title"] == host + assert result["data"] == { + "host": host, + "port": port, + } + + +async def test_setup_user_existing_host(hass): + """Test that when we setup a host that is defined, we get an error.""" + host = "3.4.5.6" + MockConfigEntry( + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} + ).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": host, "port": 1234}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/dynalite/test_panel.py b/tests/components/dynalite/test_panel.py new file mode 100644 index 00000000000..a0acad54551 --- /dev/null +++ b/tests/components/dynalite/test_panel.py @@ -0,0 +1,141 @@ +"""Test websocket commands for the panel.""" + + +from unittest.mock import patch + +from homeassistant.components import dynalite +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_get_config(hass, hass_ws_client): + """Get the config via websocket.""" + host = "1.2.3.4" + port = 765 + + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host, CONF_PORT: port}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 24, + "type": "dynalite/get-config", + } + ) + + msg = await client.receive_json() + assert msg["success"] + result = msg["result"] + entry_id = entry.entry_id + assert result == { + "config": {entry_id: {dynalite.CONF_HOST: host, CONF_PORT: port}}, + "default": { + "DEFAULT_NAME": dynalite.const.DEFAULT_NAME, + "DEFAULT_PORT": dynalite.const.DEFAULT_PORT, + "DEVICE_CLASSES": DEVICE_CLASSES, + }, + } + + +async def test_save_config(hass, hass_ws_client): + """Save the config via websocket.""" + host1 = "1.2.3.4" + port1 = 765 + host2 = "5.6.7.8" + port2 = 432 + host3 = "5.3.2.1" + port3 = 543 + + entry1 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + ) + entry1.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + entry2 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host2, CONF_PORT: port2}, + ) + entry2.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 24, + "type": "dynalite/save-config", + "entry_id": entry2.entry_id, + "config": {dynalite.CONF_HOST: host3, CONF_PORT: port3}, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {} + + existing_entry = hass.config_entries.async_get_entry(entry1.entry_id) + assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} + modified_entry = hass.config_entries.async_get_entry(entry2.entry_id) + assert modified_entry.data[dynalite.CONF_HOST] == host3 + assert modified_entry.data[CONF_PORT] == port3 + + +async def test_save_config_invalid_entry(hass, hass_ws_client): + """Try to update nonexistent entry.""" + host1 = "1.2.3.4" + port1 = 765 + host2 = "5.6.7.8" + port2 = 432 + + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 24, + "type": "dynalite/save-config", + "entry_id": "junk", + "config": {dynalite.CONF_HOST: host2, CONF_PORT: port2}, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"error": True} + + existing_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1}