diff --git a/CODEOWNERS b/CODEOWNERS index 7e53ae3058b..a29bf728fe1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1461,6 +1461,7 @@ build.json @home-assistant/supervisor /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 +/tests/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index d6a5f540c06..64da09b7ac2 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,54 +1,68 @@ """Support for VELUX KLF 200 devices.""" -import logging - from pyvlx import Node, PyVLX, PyVLXException import voluptuous as vol -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - EVENT_HOMEASSISTANT_STOP, - Platform, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, ServiceCall, callback -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType -DOMAIN = "velux" -DATA_VELUX = "data_velux" -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER, PLATFORMS CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string} - ) - }, - extra=vol.ALLOW_EXTRA, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, + ) ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the velux component.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the velux component.""" + module = VeluxModule(hass, entry.data) try: - hass.data[DATA_VELUX] = VeluxModule(hass, config[DOMAIN]) - hass.data[DATA_VELUX].setup() - await hass.data[DATA_VELUX].async_start() + module.setup() + await module.async_start() except PyVLXException as ex: - _LOGGER.exception("Can't connect to velux interface: %s", ex) + LOGGER.exception("Can't connect to velux interface: %s", ex) return False - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform(hass, platform, DOMAIN, {}, config) - ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = module + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + class VeluxModule: """Abstraction for velux component.""" @@ -63,7 +77,7 @@ class VeluxModule: async def on_hass_stop(event): """Close connection when hass stops.""" - _LOGGER.debug("Velux interface terminated") + LOGGER.debug("Velux interface terminated") await self.pyvlx.disconnect() async def async_reboot_gateway(service_call: ServiceCall) -> None: @@ -80,7 +94,7 @@ class VeluxModule: async def async_start(self): """Start velux component.""" - _LOGGER.debug("Velux interface started") + LOGGER.debug("Velux interface started") await self.pyvlx.load_scenes() await self.pyvlx.load_nodes() diff --git a/homeassistant/components/velux/config_flow.py b/homeassistant/components/velux/config_flow.py new file mode 100644 index 00000000000..57791ea01dd --- /dev/null +++ b/homeassistant/components/velux/config_flow.py @@ -0,0 +1,112 @@ +"""Config flow for Velux integration.""" +from typing import Any + +from pyvlx import PyVLX, PyVLXException +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue + +from .const import DOMAIN, LOGGER + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +class VeluxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for velux.""" + + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: + """Import a config entry.""" + + def create_repair(error: str | None = None) -> None: + if error: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_yaml_import_issue_{error}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + ) + else: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.9.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Velux", + }, + ) + + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == config[CONF_HOST]: + create_repair() + return self.async_abort(reason="already_configured") + + pyvlx = PyVLX(host=config[CONF_HOST], password=config[CONF_PASSWORD]) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError): + create_repair("cannot_connect") + return self.async_abort(reason="cannot_connect") + except Exception: # pylint: disable=broad-except + create_repair("unknown") + return self.async_abort(reason="unknown") + + create_repair() + return self.async_create_entry( + title=config[CONF_HOST], + data=config, + ) + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + + pyvlx = PyVLX( + host=user_input[CONF_HOST], password=user_input[CONF_PASSWORD] + ) + try: + await pyvlx.connect() + await pyvlx.disconnect() + except (PyVLXException, ConnectionError) as err: + errors["base"] = "cannot_connect" + LOGGER.debug("Cannot connect: %s", err) + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception: %s", err) + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=user_input[CONF_HOST], + data=user_input, + ) + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/velux/const.py b/homeassistant/components/velux/const.py new file mode 100644 index 00000000000..9a686adf920 --- /dev/null +++ b/homeassistant/components/velux/const.py @@ -0,0 +1,8 @@ +"""Constants for the Velux integration.""" +from logging import getLogger + +from homeassistant.const import Platform + +DOMAIN = "velux" +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE] +LOGGER = getLogger(__package__) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c8fb2aafb96..2162e63096a 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -13,24 +13,22 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_VELUX, VeluxEntity +from . import DOMAIN, VeluxEntity PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up cover(s) for Velux platform.""" entities = [] - for node in hass.data[DATA_VELUX].pyvlx.nodes: + module = hass.data[DOMAIN][config.entry_id] + for node in module.pyvlx.nodes: if isinstance(node, OpeningDevice): entities.append(VeluxCover(node)) async_add_entities(entities) diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index a6d63436ecf..dae38f3d9bf 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -6,25 +6,24 @@ from typing import Any from pyvlx import Intensity, LighteningDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_VELUX, VeluxEntity +from . import DOMAIN, VeluxEntity PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up light(s) for Velux platform.""" + module = hass.data[DOMAIN][config.entry_id] + async_add_entities( VeluxLight(node) - for node in hass.data[DATA_VELUX].pyvlx.nodes + for node in module.pyvlx.nodes if isinstance(node, LighteningDevice) ) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 901034aa387..1a19d1e4ba4 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -2,6 +2,7 @@ "domain": "velux", "name": "Velux", "codeowners": ["@Julius2342"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/velux", "iot_class": "local_polling", "loggers": ["pyvlx"], diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 20f94c74f0b..956663c23f1 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -4,23 +4,22 @@ from __future__ import annotations from typing import Any from homeassistant.components.scene import Scene +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import _LOGGER, DATA_VELUX +from . import DOMAIN PARALLEL_UPDATES = 1 -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the scenes for Velux platform.""" - entities = [VeluxScene(scene) for scene in hass.data[DATA_VELUX].pyvlx.scenes] + module = hass.data[DOMAIN][config.entry_id] + + entities = [VeluxScene(scene) for scene in module.pyvlx.scenes] async_add_entities(entities) @@ -29,7 +28,6 @@ class VeluxScene(Scene): def __init__(self, scene): """Init velux scene.""" - _LOGGER.info("Adding Velux scene: %s", scene) self.scene = scene @property diff --git a/homeassistant/components/velux/strings.json b/homeassistant/components/velux/strings.json index 6a7e8c6e1ec..3964c22efe2 100644 --- a/homeassistant/components/velux/strings.json +++ b/homeassistant/components/velux/strings.json @@ -1,4 +1,32 @@ { + "config": { + "step": { + "user": { + "title": "Setup Velux", + "data": { + "host": "[%key:common::config_flow::data::host%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Velux YAML configuration import cannot connect to server", + "description": "Configuring Velux using YAML is being removed but there was an connection error importing your YAML configuration.\n\nMake sure your home assistant can reach the KLF 200." + }, + "deprecated_yaml_import_issue_unknown": { + "title": "The Velux YAML configuration import failed with unknown error raised by pyvlx", + "description": "Configuring Velux using YAML is being removed but there was an unknown error importing your YAML configuration.\n\nCheck your configuration or have a look at the documentation of the integration." + } + }, "services": { "reboot_gateway": { "name": "Reboot gateway", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0a4683d724a..bd5ffd8cbbf 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -564,6 +564,7 @@ FLOWS = { "v2c", "vallox", "velbus", + "velux", "venstar", "vera", "verisure", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 38f1dfe070b..676f45f3965 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6451,7 +6451,7 @@ "velux": { "name": "Velux", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "venstar": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6be2a685fac..bca7e7f4180 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1805,6 +1805,9 @@ pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 +# homeassistant.components.velux +pyvlx==0.2.21 + # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/tests/components/velux/__init__.py b/tests/components/velux/__init__.py new file mode 100644 index 00000000000..6cf5cd366fb --- /dev/null +++ b/tests/components/velux/__init__.py @@ -0,0 +1 @@ +"""Tests for the Velux integration.""" diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py new file mode 100644 index 00000000000..60144d7137c --- /dev/null +++ b/tests/components/velux/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for Velux tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.velux.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/velux/test_config_flow.py b/tests/components/velux/test_config_flow.py new file mode 100644 index 00000000000..f44debcb892 --- /dev/null +++ b/tests/components/velux/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Velux config flow.""" +from __future__ import annotations + +from copy import deepcopy +from typing import Any +from unittest.mock import patch + +import pytest +from pyvlx import PyVLXException + +from homeassistant import data_entry_flow +from homeassistant.components.velux import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DUMMY_DATA: dict[str, Any] = { + CONF_HOST: "127.0.0.1", + CONF_PASSWORD: "NotAStrongPassword", +} + +PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH = ( + "homeassistant.components.velux.config_flow.PyVLX.connect" +) +PYVLX_CONFIG_FLOW_CLASS_PATH = "homeassistant.components.velux.config_flow.PyVLX" + +error_types_to_test: list[tuple[Exception, str]] = [ + (PyVLXException("DUMMY"), "cannot_connect"), + (Exception("DUMMY"), "unknown"), +] + +pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_success(hass: HomeAssistant) -> None: + """Test starting a flow by user with valid values.""" + with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True) as client_mock: + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) + ) + + client_mock.return_value.disconnect.assert_called_once() + client_mock.return_value.connect.assert_called_once() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DUMMY_DATA[CONF_HOST] + assert result["data"] == DUMMY_DATA + + +@pytest.mark.parametrize(("error", "error_name"), error_types_to_test) +async def test_user_errors( + hass: HomeAssistant, error: Exception, error_name: str +) -> None: + """Test starting a flow by user but with exceptions.""" + with patch( + PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH, side_effect=error + ) as connect_mock: + result: dict[str, Any] = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=deepcopy(DUMMY_DATA) + ) + + connect_mock.assert_called_once() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error_name} + + +async def test_import_valid_config(hass: HomeAssistant) -> None: + """Test import initialized flow with valid config.""" + with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=DUMMY_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DUMMY_DATA[CONF_HOST] + assert result["data"] == DUMMY_DATA + + +@pytest.mark.parametrize("flow_source", [SOURCE_IMPORT, SOURCE_USER]) +async def test_flow_duplicate_entry(hass: HomeAssistant, flow_source: str) -> None: + """Test import initialized flow with a duplicate entry.""" + with patch(PYVLX_CONFIG_FLOW_CLASS_PATH, autospec=True): + conf_entry: MockConfigEntry = MockConfigEntry( + domain=DOMAIN, title=DUMMY_DATA[CONF_HOST], data=DUMMY_DATA + ) + + conf_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": flow_source}, + data=DUMMY_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize(("error", "error_name"), error_types_to_test) +async def test_import_errors( + hass: HomeAssistant, error: Exception, error_name: str +) -> None: + """Test import initialized flow with exceptions.""" + with patch( + PYVLX_CONFIG_FLOW_CONNECT_FUNCTION_PATH, + side_effect=error, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=DUMMY_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == error_name