diff --git a/homeassistant/components/lunatone/config_flow.py b/homeassistant/components/lunatone/config_flow.py index bb48361299e..fa9951d2ae7 100644 --- a/homeassistant/components/lunatone/config_flow.py +++ b/homeassistant/components/lunatone/config_flow.py @@ -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}, + ) diff --git a/homeassistant/components/lunatone/manifest.json b/homeassistant/components/lunatone/manifest.json index 9337485caab..8f6ee96b727 100644 --- a/homeassistant/components/lunatone/manifest.json +++ b/homeassistant/components/lunatone/manifest.json @@ -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." + } + ] } diff --git a/homeassistant/components/lunatone/strings.json b/homeassistant/components/lunatone/strings.json index 438d67782fb..76006f73eef 100644 --- a/homeassistant/components/lunatone/strings.json +++ b/homeassistant/components/lunatone/strings.json @@ -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." } } } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 50bb4f31414..9f602f3c501 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -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-*", diff --git a/tests/components/lunatone/__init__.py b/tests/components/lunatone/__init__.py index 0c2580b5ce4..f4520ab0457 100644 --- a/tests/components/lunatone/__init__.py +++ b/tests/components/lunatone/__init__.py @@ -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" diff --git a/tests/components/lunatone/test_config_flow.py b/tests/components/lunatone/test_config_flow.py index f48c5179bf8..695dad3139e 100644 --- a/tests/components/lunatone/test_config_flow.py +++ b/tests/components/lunatone/test_config_flow.py @@ -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}