Fix ESPHome dashboard adoption locking HA out of provisioned devices (#178039)

This commit is contained in:
J. Nick Koston
2026-08-03 13:01:34 -05:00
committed by GitHub
parent 9d24d8a1d9
commit 9f74948e26
2 changed files with 652 additions and 1 deletions
+106 -1
View File
@@ -3,6 +3,7 @@
import asyncio
import base64
from functools import partial
import json
import logging
import secrets
import struct
@@ -29,6 +30,7 @@ from aioesphomeapi import (
ZWaveProxyRequestType,
parse_log_message,
)
import aiohttp
from awesomeversion import AwesomeVersion
import voluptuous as vol
@@ -135,6 +137,9 @@ _LOGGER = logging.getLogger(__name__)
# Max time to wait at startup for a BLE proxy to register its scanner.
STARTUP_SCANNER_WAIT: Final = 3.0
# Dashboard responses that mean the encryption-key handoff landed.
_DASHBOARD_KEY_SYNC_OK: Final = frozenset({"stored", "updated", "unchanged"})
LOG_LEVEL_TO_LOGGER = {
LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
@@ -215,6 +220,7 @@ class ESPHomeManager:
__slots__ = (
"_cancel_subscribe_logs",
"_dashboard_key_sync_warned",
"_log_level",
"cli",
"device_id",
@@ -250,6 +256,7 @@ class ESPHomeManager:
self.zeroconf_instance = zeroconf_instance
self.entry_data = entry.runtime_data
self._cancel_subscribe_logs: CALLBACK_TYPE | None = None
self._dashboard_key_sync_warned = False
self._log_level = LogLevel.LOG_LEVEL_NONE
async def on_stop(self, event: Event) -> None:
@@ -882,6 +889,84 @@ class ESPHomeManager:
await cli.disconnect(force=True)
return False
@callback
def _async_schedule_dashboard_key_sync(
self, device_info: EsphomeDeviceInfo, key: str
) -> None:
"""Schedule the best-effort dashboard key sync off the connect path."""
self.entry.async_create_background_task(
self.hass,
self._async_sync_encryption_key_to_dashboard(device_info, key),
"esphome-sync-encryption-key",
)
async def _async_sync_encryption_key_to_dashboard(
self, device_info: EsphomeDeviceInfo, key: str
) -> None:
"""Best effort: tell the ESPHome dashboard about the provisioned key.
Without this the dashboard has no way to know the key HA set on
the device, so adopting the device generates a competing key and
the next flash locks HA out. Never fails the connect flow: a
dashboard without the endpoint (404/405) logs at debug, any
other failure warns once per manager.
"""
if (dashboard := async_get_dashboard(self.hass)) is None:
return
try:
result = await dashboard.api.post_encryption_key(
device_info.name, key, mac=self.entry.unique_id
)
except (aiohttp.ClientError, TimeoutError, json.JSONDecodeError) as err:
if isinstance(err, aiohttp.ClientResponseError) and err.status in (
404,
405,
):
# Endpoint absent — an old dashboard, not a failed store.
_LOGGER.debug(
"The ESPHome dashboard does not support the encryption key "
"handoff for %s: %s",
device_info.name,
err,
)
else:
self._async_warn_dashboard_key_sync_failed(device_info, err)
return
result_value = result.get("result") if isinstance(result, dict) else None
reason = result.get("reason") if isinstance(result, dict) else None
if isinstance(result_value, str) and result_value in _DASHBOARD_KEY_SYNC_OK:
if reason:
# Partial success: a duplicate-name sibling refused the
# key and flashing it can still lock HA out.
self._async_warn_dashboard_key_sync_failed(device_info, reason)
return
_LOGGER.debug(
"Synced encryption key for %s to the ESPHome dashboard: %s",
device_info.name,
result,
)
return
self._async_warn_dashboard_key_sync_failed(
device_info, reason or f"unexpected response {result}"
)
@callback
def _async_warn_dashboard_key_sync_failed(
self, device_info: EsphomeDeviceInfo, cause: Exception | str
) -> None:
"""Warn once that the dashboard did not store the key."""
if self._dashboard_key_sync_warned:
return
self._dashboard_key_sync_warned = True
_LOGGER.warning(
"The ESPHome dashboard could not store the encryption key for "
"%s (%s), so installing that configuration may use a different "
"key and lock Home Assistant out: %s",
device_info.name,
self.entry.unique_id,
cause,
)
async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo
) -> None:
@@ -892,7 +977,22 @@ class ESPHomeManager:
"""
noise_psk: str | None = self.entry.data.get(CONF_NOISE_PSK)
if noise_psk:
# we're already connected with a noise PSK - nothing to do
# We're already connected with this key, so it's proven valid;
# re-offer it so a dashboard that missed the original handoff
# (added later, upgraded, or temporarily unreachable) catches
# up. Deliberately re-offered on every connect (no success
# latch): the dashboard no-ops on an identical key, and a
# dashboard whose copy was deleted out from under it gets it
# back on the next connect.
# Only keys in our storage are ours to push — a user-authored
# YAML key is not (mirrors _async_clear_dynamic_encryption_key).
# Background task: this runs on every connect and must not
# delay entity setup.
storage = await async_get_encryption_key_storage(self.hass)
if self.entry.unique_id and (
await storage.async_get_key(self.entry.unique_id) == noise_psk
):
self._async_schedule_dashboard_key_sync(device_info, noise_psk)
return
if not device_info.api_encryption_supported:
@@ -962,6 +1062,11 @@ class ESPHomeManager:
data={**self.entry.data, CONF_NOISE_PSK: new_key_str},
)
# The dashboard must learn the key or its next adoption/flash of
# this device bakes in a competing key and locks HA out. Background
# task: an unreachable dashboard must not stall entity setup.
self._async_schedule_dashboard_key_sync(device_info, new_key_str)
if from_storage:
_LOGGER.info(
"Set encryption key from storage on device %s (%s)",
+546
View File
@@ -3,6 +3,7 @@
import asyncio
import base64
from collections.abc import Generator
import json
import logging
from typing import Any
from unittest.mock import AsyncMock, Mock, call, patch
@@ -31,6 +32,7 @@ from aioesphomeapi import (
ZWaveProxyRequest,
ZWaveProxyRequestType,
)
import aiohttp
import pytest
import voluptuous as vol
@@ -2886,6 +2888,550 @@ async def test_dynamic_encryption_key_provisioned_over_zero_psk_from_storage(
assert entry.data[CONF_NOISE_PSK] == test_key
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dynamic_encryption_key_synced_to_dashboard(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
) -> None:
"""Test a newly generated key is pushed to the dashboard."""
mac_address = "11:22:33:44:55:aa"
test_key_bytes = b"test_key_32_bytes_long_exactly!"
mock_token_bytes.return_value = test_key_bytes
expected_key = base64.b64encode(test_key_bytes).decode()
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
return_value={"result": "stored"},
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.data[CONF_NOISE_PSK] == expected_key
# Provisioning-time push first; the reconnect re-offers the same key.
assert mock_post_key.await_args_list[0] == call(
"test-device", expected_key, mac=mac_address
)
async def test_dynamic_encryption_key_from_storage_synced_to_dashboard(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
) -> None:
"""Test a storage-recovered key is pushed to the dashboard too."""
mac_address = "11:22:33:44:55:aa"
test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode()
hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"key": ENCRYPTION_KEY_STORAGE_KEY,
"data": {"keys": {mac_address: test_key}},
}
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
return_value={"result": "stored"},
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.data[CONF_NOISE_PSK] == test_key
assert mock_post_key.await_args_list[0] == call(
"test-device", test_key, mac=mac_address
)
@pytest.mark.parametrize(
("sync_error", "expect_warning"),
[
(
aiohttp.ClientResponseError(request_info=Mock(), history=(), status=404),
False,
),
(
aiohttp.ClientResponseError(request_info=Mock(), history=(), status=405),
False,
),
(
aiohttp.ClientResponseError(request_info=Mock(), history=(), status=500),
True,
),
(aiohttp.ClientError("boom"), True),
(TimeoutError(), True),
(json.JSONDecodeError("boom", "x", 0), True),
],
)
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dynamic_encryption_key_dashboard_sync_failure_is_not_fatal(
mock_token_bytes: Mock,
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
sync_error: Exception,
expect_warning: bool,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test an old dashboard (404) or a flaky one never fails the connect flow."""
mac_address = "11:22:33:44:55:aa"
test_key_bytes = b"test_key_32_bytes_long_exactly!"
mock_token_bytes.return_value = test_key_bytes
expected_key = base64.b64encode(test_key_bytes).decode()
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
side_effect=sync_error,
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert mock_post_key.await_count >= 1
# The flow must have run to completion — a sync exception escaping
# _on_connect would skip the device-registry setup after the handoff.
assert (
device_registry.async_get_device(
connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}
)
is not None
)
assert entry.data[CONF_NOISE_PSK] == expected_key
assert (
hass_storage[ENCRYPTION_KEY_STORAGE_KEY]["data"]["keys"][mac_address]
== expected_key
)
# Endpoint-absent (404/405) stays debug; real failures warn so the
# possible lockout is visible.
assert ("could not store the encryption key" in caplog.text) is expect_warning
@pytest.mark.parametrize(
"unexpected",
[
pytest.param({"error": "unknown device"}, id="not_an_outcome"),
pytest.param(["valid", "json", "wrong", "shape"], id="not_a_dict"),
pytest.param({"result": ["updated"]}, id="unhashable_result"),
],
)
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dashboard_unexpected_sync_result_logs_warning(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
caplog: pytest.LogCaptureFixture,
unexpected: Any,
) -> None:
"""Test an unrecognized dashboard response is treated as a failed handoff."""
mac_address = "11:22:33:44:55:aa"
mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!"
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
return_value=unexpected,
):
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert caplog.text.count("could not store the encryption key") == 1
assert "unexpected response" in caplog.text
async def test_existing_key_resynced_to_dashboard_on_connect(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
) -> None:
"""Test a connect with a proven-valid HA-provisioned key re-offers it."""
mac_address = "11:22:33:44:55:aa"
test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode()
hass_storage[ENCRYPTION_KEY_STORAGE_KEY] = {
"version": 1,
"minor_version": 1,
"key": ENCRYPTION_KEY_STORAGE_KEY,
"data": {"keys": {mac_address: test_key}},
}
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test-device",
CONF_NOISE_PSK: test_key,
},
unique_id=mac_address,
)
entry.add_to_hass(hass)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
return_value={"result": "unchanged", "configurations": ["test-device.yaml"]},
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
mock_post_key.assert_awaited_with("test-device", test_key, mac=mac_address)
# No success latch: every connect re-offers so a dashboard whose
# copy was deleted gets it back; the dashboard no-ops otherwise.
assert mock_post_key.await_count == 2
async def test_user_provided_key_not_resynced_to_dashboard(
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
) -> None:
"""Test a user-authored YAML key (absent from storage) is never pushed."""
mac_address = "11:22:33:44:55:aa"
test_key = base64.b64encode(b"existing_key_32_bytes_long!!!").decode()
entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "192.168.1.100",
CONF_PORT: 6053,
CONF_PASSWORD: "",
CONF_DEVICE_NAME: "test-device",
CONF_NOISE_PSK: test_key,
},
unique_id=mac_address,
)
entry.add_to_hass(hass)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
mock_post_key.assert_not_awaited()
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dashboard_not_writable_response_logs_warning(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test a dashboard that declined the key surfaces an actionable warning."""
mac_address = "11:22:33:44:55:aa"
test_key_bytes = b"test_key_32_bytes_long_exactly!"
mock_token_bytes.return_value = test_key_bytes
expected_key = base64.b64encode(test_key_bytes).decode()
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
return_value={
"result": "not_writable",
"configurations": ["test-device.yaml"],
"reason": "the key is provided via !secret or a substitution",
},
):
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.data[CONF_NOISE_PSK] == expected_key
assert caplog.text.count("could not store the encryption key") == 1
assert "!secret" in caplog.text
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dashboard_partial_success_warns_and_keeps_retrying(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test an updated-with-reason response warns and does not latch success."""
mac_address = "11:22:33:44:55:aa"
mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!"
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
return_value={
"result": "updated",
"configurations": ["test-device.yaml", "test-device (1).yaml"],
"reason": "the key is provided via !secret or a substitution",
},
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
# The duplicate-name sibling still carries a competing key: warn
# (once) while reconnects keep retrying.
assert caplog.text.count("could not store the encryption key") == 1
assert "!secret" in caplog.text
assert mock_post_key.await_count == 2
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dashboard_sync_warns_once_across_causes(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test the handoff warns exactly once regardless of failure causes."""
mac_address = "11:22:33:44:55:aa"
mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!"
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
side_effect=[
aiohttp.ClientError("boom"),
{"result": "not_writable", "reason": "the key is provided via !secret"},
],
):
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert caplog.text.count("could not store the encryption key") == 1
assert "boom" in caplog.text
assert "!secret" not in caplog.text
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dynamic_encryption_key_not_synced_without_dashboard(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
) -> None:
"""Test no dashboard registered means no push is attempted."""
mac_address = "11:22:33:44:55:aa"
mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!"
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=True)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert entry.data[CONF_NOISE_PSK] != ""
mock_post_key.assert_not_awaited()
@patch("homeassistant.components.esphome.manager.secrets.token_bytes")
async def test_dynamic_encryption_key_not_synced_when_provisioning_fails(
mock_token_bytes: Mock,
hass: HomeAssistant,
mock_client: APIClient,
mock_esphome_device: MockESPHomeDeviceType,
hass_storage: dict[str, Any],
mock_dashboard: dict[str, Any],
) -> None:
"""Test a key the device rejected is never pushed to the dashboard."""
mac_address = "11:22:33:44:55:aa"
mock_token_bytes.return_value = b"test_key_32_bytes_long_exactly!"
entry = _make_provisionable_entry(hass, mac_address)
mock_client.noise_encryption_set_key = AsyncMock(return_value=False)
with patch(
"esphome_dashboard_api.ESPHomeDashboardAPI.post_encryption_key",
new_callable=AsyncMock,
) as mock_post_key:
device = await mock_esphome_device(
mock_client=mock_client,
entry=entry,
device_info={
"uses_password": False,
"name": "test-device",
"mac_address": mac_address,
"esphome_version": "2023.12.0",
"api_encryption_supported": True,
},
)
await device.mock_disconnect(True)
await device.mock_connect()
await hass.async_block_till_done(wait_background_tasks=True)
assert CONF_NOISE_PSK not in entry.data or entry.data[CONF_NOISE_PSK] == ""
mock_post_key.assert_not_awaited()
@pytest.mark.parametrize(
("connect_error", "set_key_result"),
[