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 DomainDatapull/83742/head^2
parent
bd342ddc13
commit
d3df4dd3c1
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
Loading…
Reference in New Issue