1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Add zeroconf discovery to Lunatone integration (#167582)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
MoonDevLT
2026-04-09 17:31:01 +02:00
committed by GitHub
parent 93e9575547
commit f2c20fedeb
6 changed files with 188 additions and 19 deletions
@@ -5,15 +5,17 @@ from typing import Any, Final
import aiohttp
from lunatone_rest_api_client import Auth, Info
import voluptuous as vol
from yarl import URL
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -28,13 +30,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
url = user_input[CONF_URL]
url = URL(user_input[CONF_URL]).human_repr()[:-1]
data = {CONF_URL: url}
self._async_abort_entries_match(data)
auth_api = Auth(
@@ -64,13 +70,58 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(title=url, data=data)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
url = URL.build(scheme="http", host=discovery_info.host).human_repr()[:-1]
uid = discovery_info.properties["uid"]
await self.async_set_unique_id(uid.replace("-", ""))
self._abort_if_unique_id_configured(updates={CONF_URL: url})
auth_api = Auth(
session=async_get_clientsession(self.hass),
base_url=url,
)
info_api = Info(auth_api)
try:
await info_api.async_update()
except aiohttp.InvalidUrlClientError:
return self.async_abort(reason="invalid_url")
except aiohttp.ClientConnectionError:
return self.async_abort(reason="cannot_connect")
self._data[CONF_URL] = url
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the discovered device."""
if user_input is not None:
return self.async_create_entry(title=self._data[CONF_URL], data=self._data)
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders=self._data,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a reconfiguration flow initialized by the user."""
return await self.async_step_user(user_input)
if user_input is not None:
return await self.async_step_user(user_input)
entry = self._get_reconfigure_entry()
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema(
{vol.Required(CONF_URL, default=entry.data[CONF_URL]): cv.string},
),
description_placeholders={CONF_NAME: entry.title},
)
@@ -7,5 +7,15 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.9.1"]
"requirements": ["lunatone-rest-api-client==0.9.1"],
"zeroconf": [
{
"properties": {
"manufacturer": "lunatone industrielle elektronik gmbh",
"type": "dali-2-*",
"uid": "*"
},
"type": "_http._tcp.local."
}
]
}
@@ -2,17 +2,19 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
"missing_device_info": "Failed to read device information. Check the network connection of the device"
},
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
"discovery_confirm": {
"description": "Do you want to setup the Lunatone device with {url}?"
},
"reconfigure": {
"data": {
@@ -21,16 +23,16 @@
"data_description": {
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
},
"description": "Update the URL."
"description": "Update configuration for {name}."
},
"user": {
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
"data_description": {
"url": "The URL of the Lunatone gateway device."
"url": "The URL of the Lunatone device to connect to."
},
"description": "Connect to the API of your Lunatone DALI IoT Gateway."
"description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API."
}
}
}
+8
View File
@@ -615,6 +615,14 @@ ZEROCONF = {
"domain": "loqed",
"name": "loqed*",
},
{
"domain": "lunatone",
"properties": {
"manufacturer": "lunatone industrielle elektronik gmbh",
"type": "dali-2-*",
"uid": "*",
},
},
{
"domain": "nam",
"name": "nam-*",
+4 -1
View File
@@ -13,12 +13,15 @@ from lunatone_rest_api_client.models import (
)
from lunatone_rest_api_client.models.common import ColorRGBData, ColorWAFData, Status
from lunatone_rest_api_client.models.devices import DeviceStatus
from yarl import URL
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
BASE_URL: Final = "http://10.0.0.131"
BASE_IP: Final = "10.0.0.131"
BASE_URL: Final = URL.build(scheme="http", host=BASE_IP).human_repr()[:-1]
MANUFACTURER: Final = "Lunatone Industrielle Elektronik GmbH"
PRODUCT_NAME: Final = "Test Product"
SERIAL_NUMBER: Final = 12345
UUID: Final = "be37ca9c-47c2-4498-a38b-c62c7c711840"
+101 -6
View File
@@ -1,21 +1,48 @@
"""Define tests for the Lunatone config flow."""
from ipaddress import ip_address
from unittest.mock import AsyncMock
import aiohttp
from lunatone_rest_api_client.models import InfoData
import pytest
from yarl import URL
from homeassistant.components.lunatone.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import BASE_URL, INFO_DATA, LEGACY_INFO_DATA
from . import (
BASE_IP,
BASE_URL,
INFO_DATA,
LEGACY_INFO_DATA,
MANUFACTURER,
UUID,
setup_integration,
)
from tests.common import MockConfigEntry
ZEROCONF_DISCOVERY = ZeroconfServiceInfo(
ip_address=ip_address(BASE_IP),
ip_addresses=[ip_address(BASE_IP)],
hostname="dali2_display.local.",
name="DALI-2 Display._http._tcp.local.",
port=80,
type="_http._tcp.local.",
properties={
"path": "/",
"manufacturer": MANUFACTURER.lower(),
"device": "dali-2 display",
"uid": UUID.lower(),
"type": "dali-2-display",
},
)
@pytest.mark.parametrize(("info_data"), [INFO_DATA, LEGACY_INFO_DATA])
async def test_full_flow(
@@ -128,6 +155,74 @@ async def test_user_step_fail_with_error(
assert result["data"] == {CONF_URL: BASE_URL}
async def test_zeroconf_flow(
hass: HomeAssistant, mock_lunatone_devices: AsyncMock, mock_lunatone_info: AsyncMock
) -> None:
"""Test zeroconf flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_ZEROCONF}, data=ZEROCONF_DISCOVERY
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
await hass.async_block_till_done()
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == BASE_URL
assert result["data"] == {CONF_URL: BASE_URL}
assert result["result"].unique_id == UUID.replace("-", "")
async def test_zeroconf_flow_abort_duplicate(
hass: HomeAssistant,
mock_lunatone_devices: AsyncMock,
mock_lunatone_info: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test zeroconf flow aborts with duplicate."""
await setup_integration(hass, mock_config_entry)
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.parametrize(
("exception", "expected_error"),
[
(aiohttp.InvalidUrlClientError(BASE_URL), "invalid_url"),
(aiohttp.ClientConnectionError(), "cannot_connect"),
],
)
async def test_zeroconf_flow_abort_with_error(
hass: HomeAssistant,
mock_lunatone_devices: AsyncMock,
mock_lunatone_info: AsyncMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test zeroconf flow aborts with error."""
mock_lunatone_info.async_update.side_effect = exception
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"] == expected_error
mock_lunatone_info.async_update.side_effect = None
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"
async def test_reconfigure(
hass: HomeAssistant,
mock_lunatone_info: AsyncMock,
@@ -135,13 +230,13 @@ async def test_reconfigure(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure flow."""
url = "http://10.0.0.100"
url = URL.build(scheme="http", host="10.0.0.100").human_repr()[:-1]
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_URL: url}
@@ -167,7 +262,7 @@ async def test_reconfigure_fail_with_error(
expected_error: str,
) -> None:
"""Test reconfigure flow with an error."""
url = "http://10.0.0.100"
url = URL.build(scheme="http", host="10.0.0.100").human_repr()[:-1]
mock_lunatone_info.async_update.side_effect = exception
@@ -175,7 +270,7 @@ async def test_reconfigure_fail_with_error(
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_URL: url}