Add zeroconf discovery to Velux integration (#181284)

This commit is contained in:
wollew
2026-09-04 18:37:23 +02:00
committed by GitHub
parent b939b0b98d
commit f3a9758f71
5 changed files with 199 additions and 15 deletions
+51 -12
View File
@@ -4,6 +4,7 @@ from collections.abc import Mapping
from typing import Any, override
from pyvlx import PyVLX, PyVLXException
from pyvlx.discovery import sanitize_hostname
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState, ConfigFlow, ConfigFlowResult
@@ -11,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER, PYVLX_FROM_CONFIG_FLOW
@@ -128,6 +130,34 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
},
)
def _is_already_configured_without_unique_id(self) -> bool:
"""Checks if a config entry already exists for the given host without a unique_id configured.
If yes, it updates the entry with the unique_id and discovery data and returns True.
If no, it returns False.
Comparing the host is the best we can do, it will fail if the user configured manually
with a different name, but the gateway does not provide a good unique ID other than the
announced name, which does not exist if configured manually.
"""
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
):
LOGGER.info(
"Config entry for host %s exists without unique_id, updating entry",
self.discovery_data[CONF_HOST],
)
self.hass.config_entries.async_update_entry(
entry=entry,
unique_id=self.discovery_data[CONF_NAME],
data={**entry.data, **self.discovery_data},
)
return True
return False
@override
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
@@ -146,18 +176,27 @@ class VeluxConfigFlow(ConfigFlow, domain=DOMAIN):
)
# Abort if config_entry already exists without unique_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")
if self._is_already_configured_without_unique_id():
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()
@override
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle discovery by zeroconf."""
self.discovery_data[CONF_HOST] = discovery_info.host
self.discovery_data[CONF_NAME] = sanitize_hostname(discovery_info.name)
self.context["title_placeholders"] = {CONF_NAME: self.discovery_data[CONF_NAME]}
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 unique_id configured.
if self._is_already_configured_without_unique_id():
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()
+7 -1
View File
@@ -14,5 +14,11 @@
"iot_class": "local_polling",
"loggers": ["pyvlx"],
"quality_scale": "silver",
"requirements": ["pyvlx==0.2.36"]
"requirements": ["pyvlx==0.2.36"],
"zeroconf": [
{
"name": "velux_klf_lan_*",
"type": "_http._tcp.local."
}
]
}
@@ -40,7 +40,7 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
+4
View File
@@ -695,6 +695,10 @@ ZEROCONF = {
"vendor": "tailwind",
},
},
{
"domain": "velux",
"name": "velux_klf_lan_*",
},
],
"_hue._tcp.local.": [
{
+136 -1
View File
@@ -1,5 +1,6 @@
"""Test the Velux config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock
import pytest
@@ -7,11 +8,17 @@ from pyvlx import PyVLXException
from homeassistant.components.velux import DOMAIN
from homeassistant.components.velux.const import PYVLX_FROM_CONFIG_FLOW
from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, ConfigEntryState
from homeassistant.config_entries import (
SOURCE_DHCP,
SOURCE_USER,
SOURCE_ZEROCONF,
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 homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from tests.common import MockConfigEntry
@@ -21,6 +28,26 @@ DHCP_DISCOVERY = DhcpServiceInfo(
macaddress="64618400abcd",
)
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
hostname="VELUX_KLF_LAN_ABCD.local.",
name="VELUX_KLF_LAN_ABCD._http._tcp.local.",
port=80,
type="_http._tcp.local.",
properties={},
)
ZEROCONF_DISCOVERY_NEW_IP = ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.2"),
ip_addresses=[ip_address("127.0.0.2")],
hostname="VELUX_KLF_LAN_ABCD.local.",
name="VELUX_KLF_LAN_ABCD._http._tcp.local.",
port=80,
type="_http._tcp.local.",
properties={},
)
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow(hass: HomeAssistant, mock_pyvlx: AsyncMock) -> None:
@@ -310,6 +337,92 @@ async def test_dhcp_discovery_errors(
}
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_discovery(hass: HomeAssistant, mock_pyvlx: AsyncMock) -> None:
"""Test we can set up from zeroconf discovery."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_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_NAME: "VELUX_KLF_ABCD",
CONF_PASSWORD: "NotAStrongPassword",
}
assert result["result"].unique_id == "VELUX_KLF_ABCD"
mock_pyvlx.connect.assert_awaited_once()
mock_pyvlx.disconnect.assert_not_awaited()
assert hass.data[PYVLX_FROM_CONFIG_FLOW]["127.0.0.1"] is mock_pyvlx
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_discovery_already_configured(
hass: HomeAssistant,
mock_discovered_config_entry: MockConfigEntry,
) -> None:
"""Test zeroconf discovery when already configured."""
mock_discovered_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_discovery_updates_host(
hass: HomeAssistant,
mock_discovered_config_entry: MockConfigEntry,
) -> None:
"""Test zeroconf discovery updates the host for an existing unique ID."""
mock_discovered_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY_NEW_IP,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_discovered_config_entry.data[CONF_HOST] == "127.0.0.2"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_discovery_manual_entry_same_host(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test zeroconf discovery when a manual entry has the same host."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_already_configured(
hass: HomeAssistant,
@@ -350,6 +463,28 @@ async def test_dhcp_discover_unique_id(
assert mock_config_entry.unique_id == "VELUX_KLF_ABCD"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_zeroconf_discover_unique_id(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test zeroconf discovery updates a loaded entry without a unique ID."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.unique_id is None
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_ZEROCONF},
data=ZEROCONF_DISCOVERY,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert mock_config_entry.unique_id == "VELUX_KLF_ABCD"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_dhcp_discovery_not_loaded(
hass: HomeAssistant, mock_pyvlx: AsyncMock, mock_config_entry: MockConfigEntry