diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 2f45d283e12b..362b8981a73f 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -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)", diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 2f5ef95ce8c8..12f779c189fe 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -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"), [