From 8767599ad4c9cd72886f923656a395af802cfbd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 13 Apr 2025 22:02:46 -1000 Subject: [PATCH] Validate ESPHome mac address before updating IP on discovery (#142878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump aioesphomeapi to 29.10.0 changelog: https://github.com/esphome/aioesphomeapi/compare/v29.9.0...v29.10.0 * Validate ESPHome mac address before updating IP on discovery In some cases the data coming in from discovery may be stale since there is a small race window if devices get new IP allocations. Since some routers do not update their names right away and zeroconf has a non-zero TTL there is a small window where the discovery data can be stale. This is a rare condition but it does happen. With aioesphomeapi 29.10.0+ and ESPHome 2025.4.x+ we can validate the mac address even without the correct encryption key which allows us to be able to always validate the MAC before updating the IP from any discovery method. * tweaks * fix test --- .../components/esphome/config_flow.py | 59 +++++++++--- tests/components/esphome/test_config_flow.py | 91 +++++++++++++++++++ tests/components/esphome/test_manager.py | 2 +- 3 files changed, 140 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 686d77d9b34..52b8514088a 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -74,6 +74,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): self._device_info: DeviceInfo | None = None # The ESPHome name as per its config self._device_name: str | None = None + self._device_mac: str | None = None async def _async_step_user_base( self, user_input: dict[str, Any] | None = None, error: str | None = None @@ -265,12 +266,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(mac_address) - self._abort_if_unique_id_configured( - updates={CONF_HOST: self._host, CONF_PORT: self._port} + await self._async_validate_mac_abort_configured( + mac_address, self._host, self._port ) return await self.async_step_discovery_confirm() + async def _async_validate_mac_abort_configured( + self, formatted_mac: str, host: str, port: int | None + ) -> None: + """Validate if the MAC address is already configured.""" + if not ( + entry := self.hass.config_entries.async_entry_for_domain_unique_id( + self.handler, formatted_mac + ) + ): + return + configured_port: int | None = entry.data.get(CONF_PORT) + configured_psk: str | None = entry.data.get(CONF_NOISE_PSK) + await self._fetch_device_info(host, port or configured_port, configured_psk) + updates: dict[str, Any] = {} + if self._device_mac == formatted_mac: + updates[CONF_HOST] = host + if port is not None: + updates[CONF_PORT] = port + self._abort_if_unique_id_configured(updates=updates) + async def async_step_mqtt( self, discovery_info: MqttServiceInfo ) -> ConfigFlowResult: @@ -314,8 +335,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): 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}) + mac_address = format_mac(discovery_info.macaddress) + await self.async_set_unique_id(format_mac(mac_address)) + await self._async_validate_mac_abort_configured( + mac_address, discovery_info.ip, None + ) # This should never happen since we only listen to DHCP requests # for configured devices. return self.async_abort(reason="already_configured") @@ -398,17 +422,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def fetch_device_info(self) -> str | None: + async def _fetch_device_info( + self, host: str, port: int | None, noise_psk: str | None + ) -> str | None: """Fetch device info from API and return any errors.""" zeroconf_instance = await zeroconf.async_get_instance(self.hass) - assert self._host is not None - assert self._port is not None cli = APIClient( - self._host, - self._port, + host, + port or 6053, "", zeroconf_instance=zeroconf_instance, - noise_psk=self._noise_psk, + noise_psk=noise_psk, ) try: @@ -419,6 +443,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): except InvalidEncryptionKeyAPIError as ex: if ex.received_name: self._device_name = ex.received_name + if ex.received_mac: + self._device_mac = format_mac(ex.received_mac) self._name = ex.received_name return ERROR_INVALID_ENCRYPTION_KEY except ResolveAPIError: @@ -427,9 +453,20 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return "connection_error" finally: await cli.disconnect(force=True) - self._name = self._device_info.friendly_name or self._device_info.name self._device_name = self._device_info.name + self._device_mac = format_mac(self._device_info.mac_address) + return None + + async def fetch_device_info(self) -> str | None: + """Fetch device info from API and return any errors.""" + assert self._host is not None + assert self._port is not None + if error := await self._fetch_device_info( + self._host, self._port, self._noise_psk + ): + return error + assert self._device_info is not None mac_address = format_mac(self._device_info.mac_address) await self.async_set_unique_id(mac_address, raise_on_progress=False) if self.source != SOURCE_REAUTH: diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index d48a1f40482..60c93d5fb2c 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1087,6 +1087,9 @@ async def test_discovery_dhcp_updates_host( unique_id="11:22:33:44:55:aa", ) entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455aa") + ) service_info = DhcpServiceInfo( ip="192.168.43.184", @@ -1103,6 +1106,94 @@ async def test_discovery_dhcp_updates_host( assert entry.data[CONF_HOST] == "192.168.43.184" +async def test_discovery_dhcp_does_not_update_host_wrong_mac( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info = AsyncMock( + return_value=DeviceInfo(name="test8266", mac_address="1122334455ff") + ) + + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +async def test_discovery_dhcp_does_not_update_host_wrong_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: + """Test dhcp discovery does not update the host if the mac is wrong.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", "1122334455cc" + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Mac was wrong, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + +async def test_discovery_dhcp_does_not_update_host_missing_mac_bad_key( + hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None +) -> None: + """Test dhcp discovery does not update the host if the mac is missing.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + unique_id="11:22:33:44:55:aa", + ) + entry.add_to_hass(hass) + mock_client.device_info.side_effect = InvalidEncryptionKeyAPIError( + "Wrong key", "test8266", None + ) + service_info = DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="1122334455aa", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + # Mac was missing, should not update + assert entry.data[CONF_HOST] == "192.168.43.183" + + async def test_discovery_dhcp_no_changes( hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None ) -> None: diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 905a3f6bdc7..f4cae1e1499 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -742,7 +742,7 @@ async def test_connection_aborted_wrong_device( assert result["reason"] == "already_configured" assert entry.data[CONF_HOST] == "192.168.43.184" await hass.async_block_till_done() - assert len(new_info.mock_calls) == 1 + assert len(new_info.mock_calls) == 2 assert "Unexpected device found at" not in caplog.text