Add dhcp discovery to velux (#135138)

Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
Paul Daumlechner 2025-01-13 12:01:04 +01:00 committed by GitHub
parent 96ad2b6ed8
commit 25041aa02d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 418 additions and 76 deletions

4
CODEOWNERS generated
View File

@ -1627,8 +1627,8 @@ build.json @home-assistant/supervisor
/tests/components/valve/ @home-assistant/core
/homeassistant/components/velbus/ @Cereal2nd @brefra
/tests/components/velbus/ @Cereal2nd @brefra
/homeassistant/components/velux/ @Julius2342 @DeerMaximum
/tests/components/velux/ @Julius2342 @DeerMaximum
/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio
/homeassistant/components/venstar/ @garbled1 @jhollowe
/tests/components/venstar/ @garbled1 @jhollowe
/homeassistant/components/versasense/ @imstevenxyz

View File

@ -1,15 +1,19 @@
"""Config flow for Velux integration."""
from typing import Any
from pyvlx import PyVLX, PyVLXException
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, LOGGER
DATA_SCHEMA = vol.Schema(
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
@ -17,9 +21,31 @@ DATA_SCHEMA = vol.Schema(
)
async def _check_connection(host: str, password: str) -> dict[str, Any]:
"""Check if we can connect to the Velux bridge."""
pyvlx = PyVLX(host=host, password=password)
try:
await pyvlx.connect()
await pyvlx.disconnect()
except (PyVLXException, ConnectionError) as err:
LOGGER.debug("Cannot connect: %s", err)
return {"base": "cannot_connect"}
except Exception as err: # noqa: BLE001
LOGGER.exception("Unexpected exception: %s", err)
return {"base": "unknown"}
return {}
class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for velux."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self.discovery_data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
@ -28,28 +54,78 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
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]
errors = await _check_connection(
user_input[CONF_HOST], 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: # noqa: BLE001
LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
else:
if not errors:
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,
data_schema=USER_SCHEMA,
errors=errors,
)
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle discovery by DHCP."""
# The hostname ends with the last 4 digits of the device MAC address.
self.discovery_data[CONF_HOST] = discovery_info.ip
self.discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress)
self.discovery_data[CONF_NAME] = discovery_info.hostname.upper().replace(
"LAN_", ""
)
await self.async_set_unique_id(self.discovery_data[CONF_NAME])
self._abort_if_unique_id_configured(
updates={CONF_HOST: self.discovery_data[CONF_HOST]}
)
# Abort if config_entry already exists without unigue_id configured.
for entry in self.hass.config_entries.async_entries(DOMAIN):
if (
entry.data[CONF_HOST] == self.discovery_data[CONF_HOST]
and entry.unique_id is None
and entry.state is ConfigEntryState.LOADED
):
self.hass.config_entries.async_update_entry(
entry=entry,
unique_id=self.discovery_data[CONF_NAME],
data={**entry.data, **self.discovery_data},
)
return self.async_abort(reason="already_configured")
self._async_abort_entries_match({CONF_HOST: self.discovery_data[CONF_HOST]})
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Prepare configuration for a discovered Velux device."""
errors: dict[str, str] = {}
if user_input is not None:
errors = await _check_connection(
self.discovery_data[CONF_HOST], user_input[CONF_PASSWORD]
)
if not errors:
return self.async_create_entry(
title=self.discovery_data[CONF_NAME],
data={**self.discovery_data, **user_input},
)
return self.async_show_form(
step_id="discovery_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): cv.string,
}
),
errors=errors,
description_placeholders={
"name": self.discovery_data[CONF_NAME],
"host": self.discovery_data[CONF_HOST],
},
)

View File

@ -1,8 +1,14 @@
{
"domain": "velux",
"name": "Velux",
"codeowners": ["@Julius2342", "@DeerMaximum"],
"codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"],
"config_flow": true,
"dhcp": [
{
"hostname": "velux_klf*",
"macaddress": "646184*"
}
],
"documentation": "https://www.home-assistant.io/integrations/velux",
"iot_class": "local_polling",
"loggers": ["pyvlx"],

View File

@ -7,6 +7,13 @@
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"discovery_confirm": {
"title": "Setup Velux",
"description": "Please enter the password for {name} ({host})",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {

View File

@ -1111,6 +1111,11 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "unifiprotect",
"macaddress": "74ACB9*",
},
{
"domain": "velux",
"hostname": "velux_klf*",
"macaddress": "646184*",
},
{
"domain": "verisure",
"macaddress": "0023C1*",

View File

@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.velux import DOMAIN
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
@ -13,3 +18,44 @@ def mock_setup_entry() -> Generator[AsyncMock]:
"homeassistant.components.velux.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_velux_client() -> Generator[AsyncMock]:
"""Mock a Velux client."""
with (
patch(
"homeassistant.components.velux.config_flow.PyVLX",
autospec=True,
) as mock_client,
):
client = mock_client.return_value
yield client
@pytest.fixture
def mock_user_config_entry() -> MockConfigEntry:
"""Return the user config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="127.0.0.1",
data={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
},
)
@pytest.fixture
def mock_discovered_config_entry() -> MockConfigEntry:
"""Return the user config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="127.0.0.1",
data={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
CONF_MAC: "64:61:84:00:ab:cd",
},
unique_id="VELUX_KLF_ABCD",
)

View File

@ -2,86 +2,288 @@
from __future__ import annotations
from copy import deepcopy
from typing import Any
from unittest.mock import patch
from unittest.mock import AsyncMock
import pytest
from pyvlx import PyVLXException
from homeassistant.components.dhcp import DhcpServiceInfo
from homeassistant.components.velux import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
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"
DHCP_DISCOVERY = DhcpServiceInfo(
ip="127.0.0.1",
hostname="VELUX_KLF_LAN_ABCD",
macaddress="64618400abcd",
)
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:
async def test_user_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_velux_client: AsyncMock,
) -> 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)
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
client_mock.return_value.disconnect.assert_called_once()
client_mock.return_value.connect.assert_called_once()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == DUMMY_DATA[CONF_HOST]
assert result["data"] == DUMMY_DATA
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "127.0.0.1"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
}
assert not result["result"].unique_id
mock_velux_client.disconnect.assert_called_once()
mock_velux_client.connect.assert_called_once()
@pytest.mark.parametrize(("error", "error_name"), error_types_to_test)
@pytest.mark.parametrize(
("exception", "error"),
[
(PyVLXException("DUMMY"), "cannot_connect"),
(Exception("DUMMY"), "unknown"),
],
)
async def test_user_errors(
hass: HomeAssistant, error: Exception, error_name: str
hass: HomeAssistant,
mock_velux_client: AsyncMock,
exception: Exception,
error: str,
mock_setup_entry: AsyncMock,
) -> 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()
mock_velux_client.connect.side_effect = exception
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": error_name}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": error}
mock_velux_client.connect.assert_called_once()
mock_velux_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
async def test_flow_duplicate_entry(hass: HomeAssistant) -> None:
async def test_user_flow_duplicate_entry(
hass: HomeAssistant,
mock_user_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test 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
)
mock_user_config_entry.add_to_hass(hass)
conf_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data=DUMMY_DATA,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "127.0.0.1",
CONF_PASSWORD: "NotAStrongPassword",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_dhcp_discovery(
hass: HomeAssistant,
mock_velux_client: AsyncMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we can setup from dhcp discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "NotAStrongPassword"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "VELUX_KLF_ABCD"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_MAC: "64:61:84:00:ab:cd",
CONF_NAME: "VELUX_KLF_ABCD",
CONF_PASSWORD: "NotAStrongPassword",
}
assert result["result"].unique_id == "VELUX_KLF_ABCD"
mock_velux_client.disconnect.assert_called()
mock_velux_client.connect.assert_called()
@pytest.mark.parametrize(
("exception", "error"),
[
(PyVLXException("DUMMY"), "cannot_connect"),
(Exception("DUMMY"), "unknown"),
],
)
async def test_dhcp_discovery_errors(
hass: HomeAssistant,
mock_velux_client: AsyncMock,
exception: Exception,
error: str,
mock_setup_entry: AsyncMock,
) -> None:
"""Test we can setup from dhcp discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
mock_velux_client.connect.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "NotAStrongPassword"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
assert result["errors"] == {"base": error}
mock_velux_client.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_PASSWORD: "NotAStrongPassword"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "VELUX_KLF_ABCD"
assert result["data"] == {
CONF_HOST: "127.0.0.1",
CONF_MAC: "64:61:84:00:ab:cd",
CONF_NAME: "VELUX_KLF_ABCD",
CONF_PASSWORD: "NotAStrongPassword",
}
async def test_dhcp_discovery_already_configured(
hass: HomeAssistant,
mock_velux_client: AsyncMock,
mock_discovered_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test dhcp discovery when already configured."""
mock_discovered_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_dhcp_discover_unique_id(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_velux_client: AsyncMock,
mock_user_config_entry: MockConfigEntry,
) -> None:
"""Test dhcp discovery when already configured."""
mock_user_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_user_config_entry.entry_id)
assert mock_user_config_entry.state is ConfigEntryState.LOADED
assert mock_user_config_entry.unique_id is None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_user_config_entry.unique_id == "VELUX_KLF_ABCD"
async def test_dhcp_discovery_not_loaded(
hass: HomeAssistant,
mock_velux_client: AsyncMock,
mock_user_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test dhcp discovery when entry with same host not loaded."""
mock_user_config_entry.add_to_hass(hass)
assert mock_user_config_entry.state is not ConfigEntryState.LOADED
assert mock_user_config_entry.unique_id is None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DHCP},
data=DHCP_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_user_config_entry.unique_id is None