From 0f641fcb745fc1f80a3c64038f772b4697e8588f Mon Sep 17 00:00:00 2001 From: TimL Date: Mon, 3 Feb 2025 10:08:32 +1100 Subject: [PATCH] Switch to using IP Addresses for connecting to smlight devices (#137204) --- .../components/smlight/config_flow.py | 53 +++++++---- .../components/smlight/manifest.json | 5 + homeassistant/generated/dhcp.py | 4 + tests/components/smlight/conftest.py | 3 +- .../smlight/snapshots/test_init.ambr | 2 +- tests/components/smlight/test_config_flow.py | 93 ++++++++++++++++--- 6 files changed, 128 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py index 34bd0758174..88ac3cde008 100644 --- a/homeassistant/components/smlight/config_flow.py +++ b/homeassistant/components/smlight/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResu from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.aiohttp_client import async_get_clientsession 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 @@ -35,7 +36,8 @@ STEP_AUTH_DATA_SCHEMA = vol.Schema( class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for SMLIGHT Zigbee.""" - host: str + _host: str + _device_name: str client: Api2 async def async_step_user( @@ -45,11 +47,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - self.host = user_input[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = user_input[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) try: info = await self.client.get_info() + self._host = str(info.device_ip) + self._device_name = str(info.hostname) if info.model not in Devices: return self.async_abort(reason="unsupported_device") @@ -93,15 +97,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): self, discovery_info: ZeroconfServiceInfo ) -> ConfigFlowResult: """Handle a discovered Lan coordinator.""" - local_name = discovery_info.hostname[:-1] - node_name = local_name.removesuffix(".local") + mac: str | None = discovery_info.properties.get("mac") + self._device_name = discovery_info.hostname.removesuffix(".local.") + self._host = discovery_info.host - self.host = local_name - self.context["title_placeholders"] = {CONF_NAME: node_name} - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self.context["title_placeholders"] = {CONF_NAME: self._device_name} + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) - mac = discovery_info.properties.get("mac") - # fallback for legacy firmware + # fallback for legacy firmware older than v2.3.x if mac is None: try: info = await self.client.get_info() @@ -111,7 +114,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): mac = info.MAC await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) return await self.async_step_confirm_discovery() @@ -122,7 +125,6 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - user_input[CONF_HOST] = self.host try: info = await self.client.get_info() @@ -142,7 +144,7 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="confirm_discovery", - description_placeholders={"host": self.host}, + description_placeholders={"host": self._device_name}, errors=errors, ) @@ -151,8 +153,8 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle reauth when API Authentication failed.""" - self.host = entry_data[CONF_HOST] - self.client = Api2(self.host, session=async_get_clientsession(self.hass)) + self._host = entry_data[CONF_HOST] + self.client = Api2(self._host, session=async_get_clientsession(self.hass)) return await self.async_step_reauth_confirm() @@ -182,6 +184,16 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_dhcp( + self, discovery_info: DhcpServiceInfo + ) -> ConfigFlowResult: + """Handle DHCP discovery.""" + await self.async_set_unique_id(format_mac(discovery_info.macaddress)) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + # This should never happen since we only listen to DHCP requests + # for configured devices. + return self.async_abort(reason="already_configured") + async def _async_check_auth_required(self, user_input: dict[str, Any]) -> bool: """Check if auth required and attempt to authenticate.""" if await self.client.check_auth_needed(): @@ -200,11 +212,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN): await self.async_set_unique_id( format_mac(info.MAC), raise_on_progress=self.source != SOURCE_USER ) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) - if user_input.get(CONF_HOST) is None: - user_input[CONF_HOST] = self.host + user_input[CONF_HOST] = self._host assert info.model is not None - title = self.context.get("title_placeholders", {}).get(CONF_NAME) or info.model + title = ( + self.context.get("title_placeholders", {}).get(CONF_NAME) + or self._device_name + or info.model + ) return self.async_create_entry(title=title, data=user_input) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 3691c211838..afd186f4b9a 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -3,6 +3,11 @@ "name": "SMLIGHT SLZB", "codeowners": ["@tl-sl"], "config_flow": true, + "dhcp": [ + { + "registered_devices": true + } + ], "documentation": "https://www.home-assistant.io/integrations/smlight", "integration_type": "device", "iot_class": "local_push", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b9d51ac1006..3dba5a98f3c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -616,6 +616,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "hostname": "hub*", "macaddress": "286D97*", }, + { + "domain": "smlight", + "registered_devices": True, + }, { "domain": "solaredge", "hostname": "target", diff --git a/tests/components/smlight/conftest.py b/tests/components/smlight/conftest.py index 665a55ba880..80e89e4eb16 100644 --- a/tests/components/smlight/conftest.py +++ b/tests/components/smlight/conftest.py @@ -18,7 +18,8 @@ from tests.common import ( load_json_object_fixture, ) -MOCK_HOST = "slzb-06.local" +MOCK_DEVICE_NAME = "slzb-06" +MOCK_HOST = "192.168.1.161" MOCK_USERNAME = "test-user" MOCK_PASSWORD = "test-pass" diff --git a/tests/components/smlight/snapshots/test_init.ambr b/tests/components/smlight/snapshots/test_init.ambr index 598166e537b..457a529065c 100644 --- a/tests/components/smlight/snapshots/test_init.ambr +++ b/tests/components/smlight/snapshots/test_init.ambr @@ -3,7 +3,7 @@ DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , - 'configuration_url': 'http://slzb-06.local', + 'configuration_url': 'http://192.168.1.161', 'connections': set({ tuple( 'mac', diff --git a/tests/components/smlight/test_config_flow.py b/tests/components/smlight/test_config_flow.py index 4dad06b0fa3..a1c9c9d6945 100644 --- a/tests/components/smlight/test_config_flow.py +++ b/tests/components/smlight/test_config_flow.py @@ -8,19 +8,20 @@ from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError import pytest from homeassistant.components.smlight.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME 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 .conftest import MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME +from .conftest import MOCK_DEVICE_NAME, MOCK_HOST, MOCK_PASSWORD, MOCK_USERNAME from tests.common import MockConfigEntry DISCOVERY_INFO = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -29,8 +30,8 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) DISCOVERY_INFO_LEGACY = ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], + ip_address=ip_address("192.168.1.161"), + ip_addresses=[ip_address("192.168.1.161")], hostname="slzb-06.local.", name="mock_name", port=6638, @@ -52,7 +53,7 @@ async def test_user_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_HOST: MOCK_HOST, + CONF_HOST: "slzb-06p7.local", }, ) @@ -76,7 +77,7 @@ async def test_zeroconf_flow( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -113,7 +114,7 @@ async def test_zeroconf_flow_auth( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -167,7 +168,7 @@ async def test_zeroconf_unsupported_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_discovery" @@ -489,7 +490,7 @@ async def test_zeroconf_legacy_mac( data=DISCOVERY_INFO_LEGACY, ) - assert result["description_placeholders"] == {"host": MOCK_HOST} + assert result["description_placeholders"] == {"host": MOCK_DEVICE_NAME} result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -507,6 +508,76 @@ async def test_zeroconf_legacy_mac( assert len(mock_smlight_client.get_info.mock_calls) == 3 +@pytest.mark.usefixtures("mock_smlight_client") +async def test_zeroconf_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zeroconf discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DISCOVERY_INFO + service_info.ip_address = ip_address("192.168.1.164") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_updates_host( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.164", + hostname="slzb-06", + macaddress="aabbccddeeff", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.164" + + +@pytest.mark.usefixtures("mock_smlight_client") +async def test_dhcp_discovery_aborts( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test dhcp discovery updates host ip.""" + mock_config_entry.add_to_hass(hass) + + service_info = DhcpServiceInfo( + ip="192.168.1.161", + hostname="slzb-06", + macaddress="000000000000", + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "192.168.1.161" + + async def test_reauth_flow( hass: HomeAssistant, mock_smlight_client: MagicMock,