ESPHome: Use MAC as unique ID (#83741)

* ESPHome: Use MAC as unique ID

* Normalize incoming zeroconf/dhcp macs

* Update comment

* Test ESPHome without mac in zeroconf

* Use format_mac

* Remove unique ID index from DomainData
pull/83742/head^2
Paulus Schoutsen 2022-12-10 22:26:42 -05:00 committed by GitHub
parent bd342ddc13
commit d3df4dd3c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 183 additions and 201 deletions

View File

@ -43,6 +43,7 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import template from homeassistant.helpers import template
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory from homeassistant.helpers.entity import DeviceInfo, Entity, EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -252,6 +253,14 @@ async def async_setup_entry( # noqa: C901
nonlocal device_id nonlocal device_id
try: try:
device_info = await cli.device_info() device_info = await cli.device_info()
# Migrate config entry to new unique ID if necessary
# This was changed in 2023.1
if entry.unique_id != format_mac(device_info.mac_address):
hass.config_entries.async_update_entry(
entry, unique_id=format_mac(device_info.mac_address)
)
entry_data.device_info = device_info entry_data.device_info = device_info
assert cli.api_version is not None assert cli.api_version is not None
entry_data.api_version = cli.api_version entry_data.api_version = cli.api_version

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from functools import partial
import logging import logging
from aioesphomeapi import APIClient from aioesphomeapi import APIClient
@ -63,7 +64,9 @@ async def async_connect_scanner(
connectable, connectable,
) )
connector = HaBluetoothConnector( connector = HaBluetoothConnector(
client=ESPHomeClient, # MyPy doesn't like partials, but this is correct
# https://github.com/python/mypy/issues/1484
client=partial(ESPHomeClient, config_entry=entry), # type: ignore[arg-type]
source=source, source=source,
can_connect=_async_can_connect_factory(entry_data, source), can_connect=_async_can_connect_factory(entry_data, source),
) )

View File

@ -23,6 +23,7 @@ from bleak.backends.service import BleakGATTServiceCollection
from bleak.exc import BleakError from bleak.exc import BleakError
from homeassistant.components.bluetooth import async_scanner_by_source from homeassistant.components.bluetooth import async_scanner_by_source
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from ..domain_data import DomainData from ..domain_data import DomainData
@ -125,7 +126,11 @@ class ESPHomeClient(BaseBleakClient):
"""ESPHome Bleak Client.""" """ESPHome Bleak Client."""
def __init__( def __init__(
self, address_or_ble_device: BLEDevice | str, *args: Any, **kwargs: Any self,
address_or_ble_device: BLEDevice | str,
*args: Any,
config_entry: ConfigEntry,
**kwargs: Any,
) -> None: ) -> None:
"""Initialize the ESPHomeClient.""" """Initialize the ESPHomeClient."""
assert isinstance(address_or_ble_device, BLEDevice) assert isinstance(address_or_ble_device, BLEDevice)
@ -136,7 +141,6 @@ class ESPHomeClient(BaseBleakClient):
assert self._ble_device.details is not None assert self._ble_device.details is not None
self._source = self._ble_device.details["source"] self._source = self._ble_device.details["source"]
self.domain_data = DomainData.get(self._hass) self.domain_data = DomainData.get(self._hass)
config_entry = self.domain_data.get_by_unique_id(self._source)
self.entry_data = self.domain_data.get_entry_data(config_entry) self.entry_data = self.domain_data.get_entry_data(config_entry)
self._client = self.entry_data.client self._client = self.entry_data.client
self._is_connected = False self._is_connected = False

View File

@ -21,8 +21,9 @@ from homeassistant.config_entries import ConfigEntry, ConfigFlow
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.device_registry import format_mac
from . import CONF_NOISE_PSK, DOMAIN, DomainData from . import CONF_NOISE_PSK, DOMAIN
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key" ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ESPHOME_URL = "https://esphome.io/" ESPHOME_URL = "https://esphome.io/"
@ -149,93 +150,35 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self, discovery_info: zeroconf.ZeroconfServiceInfo self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult: ) -> FlowResult:
"""Handle zeroconf discovery.""" """Handle zeroconf discovery."""
mac_address: str | None = discovery_info.properties.get("mac")
# Mac address was added in Sept 20, 2021.
# https://github.com/esphome/esphome/pull/2303
if mac_address is None:
return self.async_abort(reason="mdns_missing_mac")
# mac address is lowercase and without :, normalize it
mac_address = format_mac(mac_address)
# Hostname is format: livingroom.local. # Hostname is format: livingroom.local.
local_name = discovery_info.hostname[:-1] self._name = discovery_info.hostname[: -len(".local.")]
node_name = local_name[: -len(".local")]
address = discovery_info.properties.get("address", local_name)
# Check if already configured
await self.async_set_unique_id(node_name)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
for entry in self._async_current_entries():
already_configured = False
if CONF_HOST in entry.data and entry.data[CONF_HOST] in (
address,
discovery_info.host,
):
# Is this address or IP address already configured?
already_configured = True
elif DomainData.get(self.hass).is_entry_loaded(entry):
# Does a config entry with this name already exist?
data = DomainData.get(self.hass).get_entry_data(entry)
# Node names are unique in the network
if data.device_info is not None:
already_configured = data.device_info.name == node_name
if already_configured:
# Backwards compat, we update old entries
if not entry.unique_id:
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_HOST: discovery_info.host,
},
unique_id=node_name,
)
return self.async_abort(reason="already_configured")
self._host = discovery_info.host self._host = discovery_info.host
self._port = discovery_info.port self._port = discovery_info.port
self._name = node_name
# 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}
)
return await self.async_step_discovery_confirm() return await self.async_step_discovery_confirm()
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
"""Handle DHCP discovery.""" """Handle DHCP discovery."""
node_name = discovery_info.hostname await self.async_set_unique_id(format_mac(discovery_info.macaddress))
await self.async_set_unique_id(node_name)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
# This should never happen since we only listen to DHCP requests
for entry in self._async_current_entries(): # for configured devices.
found = False
if CONF_HOST in entry.data and entry.data[CONF_HOST] in (
discovery_info.ip,
f"{node_name}.local",
):
# Is this address or IP address already configured?
found = True
elif DomainData.get(self.hass).is_entry_loaded(entry):
# Does a config entry with this name already exist?
data = DomainData.get(self.hass).get_entry_data(entry)
# Node names are unique in the network
if data.device_info is not None:
found = data.device_info.name == node_name
if found:
# Backwards compat, we update old entries
if not entry.unique_id:
self.hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_HOST: discovery_info.ip,
},
unique_id=node_name,
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(entry.entry_id)
)
break
return self.async_abort(reason="already_configured") return self.async_abort(reason="already_configured")
@callback @callback
@ -334,9 +277,13 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
await cli.disconnect(force=True) await cli.disconnect(force=True)
self._name = self._device_info.name self._name = self._device_info.name
await self.async_set_unique_id(self._name, raise_on_progress=False) await self.async_set_unique_id(
self._device_info.mac_address, raise_on_progress=False
)
if not self._reauth_entry: if not self._reauth_entry:
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) self._abort_if_unique_id_configured(
updates={CONF_HOST: self._host, CONF_PORT: self._port}
)
return None return None

View File

@ -23,11 +23,6 @@ class DomainData:
_entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict) _entry_datas: dict[str, RuntimeEntryData] = field(default_factory=dict)
_stores: dict[str, Store] = field(default_factory=dict) _stores: dict[str, Store] = field(default_factory=dict)
_entry_by_unique_id: dict[str, ConfigEntry] = field(default_factory=dict)
def get_by_unique_id(self, unique_id: str) -> ConfigEntry:
"""Get the config entry by its unique ID."""
return self._entry_by_unique_id[unique_id]
def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData: def get_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
"""Return the runtime entry data associated with this config entry. """Return the runtime entry data associated with this config entry.
@ -41,13 +36,9 @@ class DomainData:
if entry.entry_id in self._entry_datas: if entry.entry_id in self._entry_datas:
raise ValueError("Entry data for this entry is already set") raise ValueError("Entry data for this entry is already set")
self._entry_datas[entry.entry_id] = entry_data self._entry_datas[entry.entry_id] = entry_data
if entry.unique_id:
self._entry_by_unique_id[entry.unique_id] = entry
def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData: def pop_entry_data(self, entry: ConfigEntry) -> RuntimeEntryData:
"""Pop the runtime entry data instance associated with this config entry.""" """Pop the runtime entry data instance associated with this config entry."""
if entry.unique_id:
del self._entry_by_unique_id[entry.unique_id]
return self._entry_datas.pop(entry.entry_id) return self._entry_datas.pop(entry.entry_id)
def is_entry_loaded(self, entry: ConfigEntry) -> bool: def is_entry_loaded(self, entry: ConfigEntry) -> bool:

View File

@ -3,7 +3,8 @@
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"mdns_missing_mac": "Missing MAC address in MDNS properties."
}, },
"error": { "error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address", "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address",

View File

@ -1,5 +1,11 @@
"""esphome session fixtures.""" """esphome session fixtures."""
from __future__ import annotations
from unittest.mock import AsyncMock, Mock, patch
from aioesphomeapi import APIClient
import pytest import pytest
from zeroconf import Zeroconf
from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
@ -25,7 +31,7 @@ def mock_config_entry() -> MockConfigEntry:
CONF_PASSWORD: "pwd", CONF_PASSWORD: "pwd",
CONF_NOISE_PSK: "12345678123456781234567812345678", CONF_NOISE_PSK: "12345678123456781234567812345678",
}, },
unique_id="esphome-device", unique_id="11:22:33:44:55:aa",
) )
@ -40,3 +46,37 @@ async def init_integration(
await hass.async_block_till_done() await hass.async_block_till_done()
return mock_config_entry return mock_config_entry
@pytest.fixture
def mock_client():
"""Mock APIClient."""
mock_client = Mock(spec=APIClient)
def mock_constructor(
address: str,
port: int,
password: str | None,
*,
client_info: str = "aioesphomeapi",
keepalive: float = 15.0,
zeroconf_instance: Zeroconf = None,
noise_psk: str | None = None,
expected_name: str | None = None,
):
"""Fake the client constructor."""
mock_client.host = address
mock_client.port = port
mock_client.password = password
mock_client.zeroconf_instance = zeroconf_instance
mock_client.noise_psk = noise_psk
return mock_client
mock_client.side_effect = mock_constructor
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
with patch("homeassistant.components.esphome.APIClient", mock_client), patch(
"homeassistant.components.esphome.config_flow.APIClient", mock_client
):
yield mock_client

View File

@ -1,9 +1,9 @@
"""Test config flow.""" """Test config flow."""
from collections import namedtuple
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from aioesphomeapi import ( from aioesphomeapi import (
APIConnectionError, APIConnectionError,
DeviceInfo,
InvalidAuthAPIError, InvalidAuthAPIError,
InvalidEncryptionKeyAPIError, InvalidEncryptionKeyAPIError,
RequiresEncryptionAPIError, RequiresEncryptionAPIError,
@ -19,34 +19,10 @@ from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"])
VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=" VALID_NOISE_PSK = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM=" INVALID_NOISE_PSK = "lSYBYEjQI1bVL8s2Vask4YytGMj1f1epNtmoim2yuTM="
@pytest.fixture
def mock_client():
"""Mock APIClient."""
with patch("homeassistant.components.esphome.config_flow.APIClient") as mock_client:
def mock_constructor(
host, port, password, zeroconf_instance=None, noise_psk=None
):
"""Fake the client constructor."""
mock_client.host = host
mock_client.port = port
mock_client.password = password
mock_client.zeroconf_instance = zeroconf_instance
mock_client.noise_psk = noise_psk
return mock_client
mock_client.side_effect = mock_constructor
mock_client.connect = AsyncMock()
mock_client.disconnect = AsyncMock()
yield mock_client
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def mock_setup_entry(): def mock_setup_entry():
"""Mock setting up a config entry.""" """Mock setting up a config entry."""
@ -65,7 +41,11 @@ async def test_user_connection_works(hass, mock_client, mock_zeroconf):
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False, name="test", mac_address="mock-mac"
)
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", "esphome",
@ -81,7 +61,7 @@ async def test_user_connection_works(hass, mock_client, mock_zeroconf):
CONF_NOISE_PSK: "", CONF_NOISE_PSK: "",
} }
assert result["title"] == "test" assert result["title"] == "test"
assert result["result"].unique_id == "test" assert result["result"].unique_id == "mock-mac"
assert len(mock_client.connect.mock_calls) == 1 assert len(mock_client.connect.mock_calls) == 1
assert len(mock_client.device_info.mock_calls) == 1 assert len(mock_client.device_info.mock_calls) == 1
@ -97,7 +77,7 @@ async def test_user_connection_updates_host(hass, mock_client, mock_zeroconf):
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""}, data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="test", unique_id="mock-mac",
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -109,7 +89,11 @@ async def test_user_connection_updates_host(hass, mock_client, mock_zeroconf):
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "user" assert result["step_id"] == "user"
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False, name="test", mac_address="mock-mac"
)
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", "esphome",
@ -165,7 +149,9 @@ async def test_user_connection_error(hass, mock_client, mock_zeroconf):
async def test_user_with_password(hass, mock_client, mock_zeroconf): async def test_user_with_password(hass, mock_client, mock_zeroconf):
"""Test user step with password.""" """Test user step with password."""
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=True, name="test")
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", "esphome",
@ -192,7 +178,9 @@ async def test_user_with_password(hass, mock_client, mock_zeroconf):
async def test_user_invalid_password(hass, mock_client, mock_zeroconf): async def test_user_invalid_password(hass, mock_client, mock_zeroconf):
"""Test user step with invalid password.""" """Test user step with invalid password."""
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=True, name="test")
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", "esphome",
@ -216,7 +204,9 @@ async def test_user_invalid_password(hass, mock_client, mock_zeroconf):
async def test_login_connection_error(hass, mock_client, mock_zeroconf): async def test_login_connection_error(hass, mock_client, mock_zeroconf):
"""Test user step with connection error on login attempt.""" """Test user step with connection error on login attempt."""
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(True, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=True, name="test")
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", "esphome",
@ -240,7 +230,11 @@ async def test_login_connection_error(hass, mock_client, mock_zeroconf):
async def test_discovery_initiation(hass, mock_client, mock_zeroconf): async def test_discovery_initiation(hass, mock_client, mock_zeroconf):
"""Test discovery importing works.""" """Test discovery importing works."""
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
uses_password=False, name="test8266", mac_address="11:22:33:44:55:aa"
)
)
service_info = zeroconf.ZeroconfServiceInfo( service_info = zeroconf.ZeroconfServiceInfo(
host="192.168.43.183", host="192.168.43.183",
@ -248,7 +242,9 @@ async def test_discovery_initiation(hass, mock_client, mock_zeroconf):
hostname="test8266.local.", hostname="test8266.local.",
name="mock_name", name="mock_name",
port=6053, port=6053,
properties={}, properties={
"mac": "1122334455aa",
},
type="mock_type", type="mock_type",
) )
flow = await hass.config_entries.flow.async_init( flow = await hass.config_entries.flow.async_init(
@ -265,18 +261,11 @@ async def test_discovery_initiation(hass, mock_client, mock_zeroconf):
assert result["data"][CONF_PORT] == 6053 assert result["data"][CONF_PORT] == 6053
assert result["result"] assert result["result"]
assert result["result"].unique_id == "test8266" assert result["result"].unique_id == "11:22:33:44:55:aa"
async def test_discovery_already_configured_hostname(hass, mock_client): async def test_discovery_no_mac(hass, mock_client, mock_zeroconf):
"""Test discovery aborts if already configured via hostname.""" """Test discovery aborted if old ESPHome without mac in zeroconf."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "test8266.local", CONF_PORT: 6053, CONF_PASSWORD: ""},
)
entry.add_to_hass(hass)
service_info = zeroconf.ZeroconfServiceInfo( service_info = zeroconf.ZeroconfServiceInfo(
host="192.168.43.183", host="192.168.43.183",
addresses=["192.168.43.183"], addresses=["192.168.43.183"],
@ -286,21 +275,19 @@ async def test_discovery_already_configured_hostname(hass, mock_client):
properties={}, properties={},
type="mock_type", type="mock_type",
) )
result = await hass.config_entries.flow.async_init( flow = await hass.config_entries.flow.async_init(
"esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info "esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info
) )
assert flow["type"] == FlowResultType.ABORT
assert result["type"] == FlowResultType.ABORT assert flow["reason"] == "mdns_missing_mac"
assert result["reason"] == "already_configured"
assert entry.unique_id == "test8266"
async def test_discovery_already_configured_ip(hass, mock_client): async def test_discovery_already_configured(hass, mock_client):
"""Test discovery aborts if already configured via static IP.""" """Test discovery aborts if already configured via hostname."""
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, data={CONF_HOST: "test8266.local", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="11:22:33:44:55:aa",
) )
entry.add_to_hass(hass) entry.add_to_hass(hass)
@ -311,7 +298,7 @@ async def test_discovery_already_configured_ip(hass, mock_client):
hostname="test8266.local.", hostname="test8266.local.",
name="mock_name", name="mock_name",
port=6053, port=6053,
properties={"address": "192.168.43.183"}, properties={"mac": "1122334455aa"},
type="mock_type", type="mock_type",
) )
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -321,41 +308,6 @@ async def test_discovery_already_configured_ip(hass, mock_client):
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert entry.unique_id == "test8266"
async def test_discovery_already_configured_name(hass, mock_client):
"""Test discovery aborts if already configured via name."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""},
)
entry.add_to_hass(hass)
mock_entry_data = MagicMock()
mock_entry_data.device_info.name = "test8266"
domain_data = DomainData.get(hass)
domain_data.set_entry_data(entry, mock_entry_data)
service_info = zeroconf.ZeroconfServiceInfo(
host="192.168.43.184",
addresses=["192.168.43.184"],
hostname="test8266.local.",
name="mock_name",
port=6053,
properties={"address": "test8266.local"},
type="mock_type",
)
result = await hass.config_entries.flow.async_init(
"esphome", context={"source": config_entries.SOURCE_ZEROCONF}, data=service_info
)
assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert entry.unique_id == "test8266"
assert entry.data[CONF_HOST] == "192.168.43.184"
async def test_discovery_duplicate_data(hass, mock_client): async def test_discovery_duplicate_data(hass, mock_client):
"""Test discovery aborts if same mDNS packet arrives.""" """Test discovery aborts if same mDNS packet arrives."""
@ -365,11 +317,13 @@ async def test_discovery_duplicate_data(hass, mock_client):
hostname="test8266.local.", hostname="test8266.local.",
name="mock_name", name="mock_name",
port=6053, port=6053,
properties={"address": "test8266.local"}, properties={"address": "test8266.local", "mac": "1122334455aa"},
type="mock_type", type="mock_type",
) )
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test8266")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test8266")
)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF} "esphome", data=service_info, context={"source": config_entries.SOURCE_ZEROCONF}
@ -389,6 +343,7 @@ async def test_discovery_updates_unique_id(hass, mock_client):
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, 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) entry.add_to_hass(hass)
@ -399,7 +354,7 @@ async def test_discovery_updates_unique_id(hass, mock_client):
hostname="test8266.local.", hostname="test8266.local.",
name="mock_name", name="mock_name",
port=6053, port=6053,
properties={"address": "test8266.local"}, properties={"address": "test8266.local", "mac": "1122334455aa"},
type="mock_type", type="mock_type",
) )
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -409,7 +364,7 @@ async def test_discovery_updates_unique_id(hass, mock_client):
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert entry.unique_id == "test8266" assert entry.unique_id == "11:22:33:44:55:aa"
async def test_user_requires_psk(hass, mock_client, mock_zeroconf): async def test_user_requires_psk(hass, mock_client, mock_zeroconf):
@ -445,7 +400,9 @@ async def test_encryption_key_valid_psk(hass, mock_client, mock_zeroconf):
assert result["type"] == FlowResultType.FORM assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "encryption_key" assert result["step_id"] == "encryption_key"
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
) )
@ -522,7 +479,9 @@ async def test_reauth_confirm_valid(hass, mock_client, mock_zeroconf):
}, },
) )
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
) )
@ -559,7 +518,9 @@ async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf):
assert result["errors"] assert result["errors"]
assert result["errors"]["base"] == "invalid_psk" assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
) )
@ -597,7 +558,9 @@ async def test_reauth_confirm_invalid_with_unique_id(hass, mock_client, mock_zer
assert result["errors"] assert result["errors"]
assert result["errors"]["base"] == "invalid_psk" assert result["errors"]["base"] == "invalid_psk"
mock_client.device_info = AsyncMock(return_value=MockDeviceInfo(False, "test")) mock_client.device_info = AsyncMock(
return_value=DeviceInfo(uses_password=False, name="test")
)
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK} result["flow_id"], user_input={CONF_NOISE_PSK: VALID_NOISE_PSK}
) )
@ -612,18 +575,14 @@ async def test_discovery_dhcp_updates_host(hass, mock_client):
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, 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) entry.add_to_hass(hass)
mock_entry_data = MagicMock()
mock_entry_data.device_info.name = "test8266"
domain_data = DomainData.get(hass)
domain_data.set_entry_data(entry, mock_entry_data)
service_info = dhcp.DhcpServiceInfo( service_info = dhcp.DhcpServiceInfo(
ip="192.168.43.184", ip="192.168.43.184",
hostname="test8266", hostname="test8266",
macaddress="00:00:00:00:00:00", macaddress="1122334455aa",
) )
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
"esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info
@ -632,7 +591,6 @@ async def test_discovery_dhcp_updates_host(hass, mock_client):
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert entry.unique_id == "test8266"
assert entry.data[CONF_HOST] == "192.168.43.184" assert entry.data[CONF_HOST] == "192.168.43.184"
@ -661,5 +619,4 @@ async def test_discovery_dhcp_no_changes(hass, mock_client):
assert result["type"] == FlowResultType.ABORT assert result["type"] == FlowResultType.ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
assert entry.unique_id == "test8266"
assert entry.data[CONF_HOST] == "192.168.43.183" assert entry.data[CONF_HOST] == "192.168.43.183"

View File

@ -27,4 +27,4 @@ async def test_diagnostics(
CONF_PASSWORD: "**REDACTED**", CONF_PASSWORD: "**REDACTED**",
CONF_NOISE_PSK: "**REDACTED**", CONF_NOISE_PSK: "**REDACTED**",
} }
assert result["config"]["unique_id"] == "esphome-device" assert result["config"]["unique_id"] == "11:22:33:44:55:aa"

View File

@ -0,0 +1,30 @@
"""ESPHome set up tests."""
from unittest.mock import AsyncMock
from aioesphomeapi import DeviceInfo
from homeassistant.components.esphome import DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from tests.common import MockConfigEntry
async def test_unique_id_updated_to_mac(hass, mock_client, mock_zeroconf):
"""Test we update config entry unique ID to MAC address."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""},
unique_id="mock-config-name",
)
entry.add_to_hass(hass)
mock_client.device_info = AsyncMock(
return_value=DeviceInfo(
mac_address="1122334455aa",
)
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.unique_id == "11:22:33:44:55:aa"