mirror of
https://github.com/home-assistant/core.git
synced 2025-07-19 11:17:21 +00:00
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:
parent
6d5c000e1f
commit
8767599ad4
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user