Add config flow to Velux (#89155)

* Add config_flow

* Add old config import

* Change from platform setup to entry setup

* Improve yaml config import

* Allow multiple hosts

* Apply recommendations

* Add DeerMaximum as codeowner

* Apply recommendations

* Fix config schema

* Fix hass data

* Remove DeerMaximum from CODEOWNERS

* Try to fix tests in ci

* Try to fix tests in ci 2

* Try to fix tests in ci 3

* Revert: Try to fix tests in ci 3

* Add end-to-end flow to connection error test

* Fix rebase

* Add required changes

* Change deprecation date

* Import only valid config entries

* Improve issue creation

* Fix error type

* Add missing test

* Optimize issue creation

* Optimize tests

* Add check for duplicate entries

* Add already_configured message

* Create issue for duplicate entries
This commit is contained in:
DeerMaximum 2024-02-13 20:31:56 +00:00 committed by GitHub
parent 2981d7ed0e
commit d16d9d72c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 351 additions and 55 deletions

View File

@ -1461,6 +1461,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 /homeassistant/components/velux/ @Julius2342
/tests/components/velux/ @Julius2342
/homeassistant/components/venstar/ @garbled1 @jhollowe /homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/versasense/ @imstevenxyz

View File

@ -1,54 +1,68 @@
"""Support for VELUX KLF 200 devices.""" """Support for VELUX KLF 200 devices."""
import logging
from pyvlx import Node, PyVLX, PyVLXException from pyvlx import Node, PyVLX, PyVLXException
import voluptuous as vol import voluptuous as vol
from homeassistant.const import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
CONF_HOST, from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
DOMAIN = "velux" from .const import DOMAIN, LOGGER, PLATFORMS
DATA_VELUX = "data_velux"
PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SCENE]
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
{vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PASSWORD): cv.string} {
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
}
) )
}, },
extra=vol.ALLOW_EXTRA, extra=vol.ALLOW_EXTRA,
) )
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the velux component.""" """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: try:
hass.data[DATA_VELUX] = VeluxModule(hass, config[DOMAIN]) module.setup()
hass.data[DATA_VELUX].setup() await module.async_start()
await hass.data[DATA_VELUX].async_start()
except PyVLXException as ex: 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 return False
for platform in PLATFORMS: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = module
hass.async_create_task(
discovery.async_load_platform(hass, platform, DOMAIN, {}, config) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
)
return True 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: class VeluxModule:
"""Abstraction for velux component.""" """Abstraction for velux component."""
@ -63,7 +77,7 @@ class VeluxModule:
async def on_hass_stop(event): async def on_hass_stop(event):
"""Close connection when hass stops.""" """Close connection when hass stops."""
_LOGGER.debug("Velux interface terminated") LOGGER.debug("Velux interface terminated")
await self.pyvlx.disconnect() await self.pyvlx.disconnect()
async def async_reboot_gateway(service_call: ServiceCall) -> None: async def async_reboot_gateway(service_call: ServiceCall) -> None:
@ -80,7 +94,7 @@ class VeluxModule:
async def async_start(self): async def async_start(self):
"""Start velux component.""" """Start velux component."""
_LOGGER.debug("Velux interface started") LOGGER.debug("Velux interface started")
await self.pyvlx.load_scenes() await self.pyvlx.load_scenes()
await self.pyvlx.load_nodes() await self.pyvlx.load_nodes()

View File

@ -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,
)

View File

@ -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__)

View File

@ -13,24 +13,22 @@ from homeassistant.components.cover import (
CoverEntity, CoverEntity,
CoverEntityFeature, CoverEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 PARALLEL_UPDATES = 1
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up cover(s) for Velux platform.""" """Set up cover(s) for Velux platform."""
entities = [] 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): if isinstance(node, OpeningDevice):
entities.append(VeluxCover(node)) entities.append(VeluxCover(node))
async_add_entities(entities) async_add_entities(entities)

View File

@ -6,25 +6,24 @@ from typing import Any
from pyvlx import Intensity, LighteningDevice from pyvlx import Intensity, LighteningDevice
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 PARALLEL_UPDATES = 1
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up light(s) for Velux platform.""" """Set up light(s) for Velux platform."""
module = hass.data[DOMAIN][config.entry_id]
async_add_entities( async_add_entities(
VeluxLight(node) VeluxLight(node)
for node in hass.data[DATA_VELUX].pyvlx.nodes for node in module.pyvlx.nodes
if isinstance(node, LighteningDevice) if isinstance(node, LighteningDevice)
) )

View File

@ -2,6 +2,7 @@
"domain": "velux", "domain": "velux",
"name": "Velux", "name": "Velux",
"codeowners": ["@Julius2342"], "codeowners": ["@Julius2342"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/velux", "documentation": "https://www.home-assistant.io/integrations/velux",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyvlx"], "loggers": ["pyvlx"],

View File

@ -4,23 +4,22 @@ from __future__ import annotations
from typing import Any from typing import Any
from homeassistant.components.scene import Scene from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 PARALLEL_UPDATES = 1
async def async_setup_platform( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None: ) -> None:
"""Set up the scenes for Velux platform.""" """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) async_add_entities(entities)
@ -29,7 +28,6 @@ class VeluxScene(Scene):
def __init__(self, scene): def __init__(self, scene):
"""Init velux scene.""" """Init velux scene."""
_LOGGER.info("Adding Velux scene: %s", scene)
self.scene = scene self.scene = scene
@property @property

View File

@ -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": { "services": {
"reboot_gateway": { "reboot_gateway": {
"name": "Reboot gateway", "name": "Reboot gateway",

View File

@ -564,6 +564,7 @@ FLOWS = {
"v2c", "v2c",
"vallox", "vallox",
"velbus", "velbus",
"velux",
"venstar", "venstar",
"vera", "vera",
"verisure", "verisure",

View File

@ -6451,7 +6451,7 @@
"velux": { "velux": {
"name": "Velux", "name": "Velux",
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": true,
"iot_class": "local_polling" "iot_class": "local_polling"
}, },
"venstar": { "venstar": {

View File

@ -1805,6 +1805,9 @@ pyvesync==2.1.10
# homeassistant.components.vizio # homeassistant.components.vizio
pyvizio==0.1.61 pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.21
# homeassistant.components.volumio # homeassistant.components.volumio
pyvolumio==0.1.5 pyvolumio==0.1.5

View File

@ -0,0 +1 @@
"""Tests for the Velux integration."""

View File

@ -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

View File

@ -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