Validate ESPHome mac address before updating IP on discovery (#142878)

* 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
This commit is contained in:
J. Nick Koston 2025-04-13 22:02:46 -10:00 committed by GitHub
parent 6d5c000e1f
commit 8767599ad4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 140 additions and 12 deletions

View File

@ -74,6 +74,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._device_info: DeviceInfo | None = None self._device_info: DeviceInfo | None = None
# The ESPHome name as per its config # The ESPHome name as per its config
self._device_name: str | None = None self._device_name: str | None = None
self._device_mac: str | None = None
async def _async_step_user_base( async def _async_step_user_base(
self, user_input: dict[str, Any] | None = None, error: str | None = None 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 # Check if already configured
await self.async_set_unique_id(mac_address) await self.async_set_unique_id(mac_address)
self._abort_if_unique_id_configured( await self._async_validate_mac_abort_configured(
updates={CONF_HOST: self._host, CONF_PORT: self._port} mac_address, self._host, self._port
) )
return await self.async_step_discovery_confirm() 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( async def async_step_mqtt(
self, discovery_info: MqttServiceInfo self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -314,8 +335,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self, discovery_info: DhcpServiceInfo self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle DHCP discovery.""" """Handle DHCP discovery."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress)) mac_address = format_mac(discovery_info.macaddress)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) 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 # This should never happen since we only listen to DHCP requests
# for configured devices. # for configured devices.
return self.async_abort(reason="already_configured") return self.async_abort(reason="already_configured")
@ -398,17 +422,17 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors, 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.""" """Fetch device info from API and return any errors."""
zeroconf_instance = await zeroconf.async_get_instance(self.hass) zeroconf_instance = await zeroconf.async_get_instance(self.hass)
assert self._host is not None
assert self._port is not None
cli = APIClient( cli = APIClient(
self._host, host,
self._port, port or 6053,
"", "",
zeroconf_instance=zeroconf_instance, zeroconf_instance=zeroconf_instance,
noise_psk=self._noise_psk, noise_psk=noise_psk,
) )
try: try:
@ -419,6 +443,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
except InvalidEncryptionKeyAPIError as ex: except InvalidEncryptionKeyAPIError as ex:
if ex.received_name: if ex.received_name:
self._device_name = 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 self._name = ex.received_name
return ERROR_INVALID_ENCRYPTION_KEY return ERROR_INVALID_ENCRYPTION_KEY
except ResolveAPIError: except ResolveAPIError:
@ -427,9 +453,20 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return "connection_error" return "connection_error"
finally: finally:
await cli.disconnect(force=True) await cli.disconnect(force=True)
self._name = self._device_info.friendly_name or self._device_info.name self._name = self._device_info.friendly_name or self._device_info.name
self._device_name = 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) mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False) await self.async_set_unique_id(mac_address, raise_on_progress=False)
if self.source != SOURCE_REAUTH: if self.source != SOURCE_REAUTH:

View File

@ -1087,6 +1087,9 @@ async def test_discovery_dhcp_updates_host(
unique_id="11:22:33:44:55:aa", unique_id="11:22:33:44:55:aa",
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(name="test8266", mac_address="1122334455aa")
)
service_info = DhcpServiceInfo( service_info = DhcpServiceInfo(
ip="192.168.43.184", 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" 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( async def test_discovery_dhcp_no_changes(
hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None hass: HomeAssistant, mock_client: APIClient, mock_setup_entry: None
) -> None: ) -> None:

View File

@ -742,7 +742,7 @@ async def test_connection_aborted_wrong_device(
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert entry.data[CONF_HOST] == "192.168.43.184" assert entry.data[CONF_HOST] == "192.168.43.184"
await hass.async_block_till_done() 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 assert "Unexpected device found at" not in caplog.text