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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+8
@@ -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-*",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user