From ddcdfbf148ffd1ab43efc39d0476c802677b8b9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Sep 2026 11:14:42 -0500 Subject: [PATCH] Add support for ESPHome device-initiated connections (#180961) --- homeassistant/components/esphome/__init__.py | 11 +- homeassistant/components/esphome/manager.py | 60 +++++- .../components/esphome/outgoing_connection.py | 40 ++++ tests/components/esphome/conftest.py | 24 +++ tests/components/esphome/test_init.py | 2 + tests/components/esphome/test_manager.py | 3 + .../esphome/test_outgoing_connection.py | 197 ++++++++++++++++++ 7 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/esphome/outgoing_connection.py create mode 100644 tests/components/esphome/test_outgoing_connection.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2ab5451e6d96..7e52b7c7a6f3 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -95,7 +95,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b zeroconf_instance = await zeroconf.async_get_instance(hass) cli = async_create_api_client( - hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) + hass, + entry, + zeroconf_instance, + noise_psk=entry.data.get(CONF_NOISE_PSK), + declare_outgoing_target=True, ) domain_data = DomainData.get(hass) @@ -154,7 +158,10 @@ async def _async_clear_dynamic_encryption_key( zeroconf_instance = await zeroconf.async_get_instance(hass) cli = async_create_api_client( - hass, entry, zeroconf_instance, noise_psk=entry.data.get(CONF_NOISE_PSK) + hass, + entry, + zeroconf_instance, + noise_psk=entry.data.get(CONF_NOISE_PSK), ) try: diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 2ea140594100..38014b20bf80 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -103,11 +103,20 @@ from .encryption_key_storage import async_get_encryption_key_storage # Import config flow so that it's added to the registry from .entry_data import ESPHomeConfigEntry, RuntimeEntryData from .enum_mapper import EsphomeEnumMapper +from .outgoing_connection import async_register_outgoing_target DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}" UNPACK_UINT32_BE = struct.Struct(">I").unpack_from +@callback +def _mac_unique_id(entry: ESPHomeConfigEntry) -> str | None: + """Dial-in routing is keyed by MAC; pre-2023 name unique ids have none.""" + if (unique_id := entry.unique_id) is not None and ":" in unique_id: + return unique_id + return None + + @callback def async_create_api_client( hass: HomeAssistant, @@ -115,8 +124,13 @@ def async_create_api_client( zeroconf_instance: zeroconf.HaZeroconf, *, noise_psk: str | None, + declare_outgoing_target: bool = False, ) -> APIClient: - """Create an APIClient for a config entry.""" + """Create an APIClient for a config entry. + + Only the entry's long-lived session declares itself a dial-back target; + key management sessions must never be remembered by the device. + """ return APIClient( entry.data[CONF_HOST], entry.data[CONF_PORT], @@ -125,6 +139,10 @@ def async_create_api_client( zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, timezone=hass.config.time_zone, + # The library drops the declaration without a real key; the MAC + # gate is ours, a route needs a MAC unique id + outgoing_connection_target=declare_outgoing_target + and _mac_unique_id(entry) is not None, ) @@ -577,6 +595,36 @@ class ESPHomeManager: self._async_on_log, self._log_level ) + @callback + def _async_register_outgoing_target(self, reconnect_logic: ReconnectLogic) -> None: + """Register this entry's MAC with the shared dial-in listener. + + Runs once at setup; a dynamically provisioned key takes effect on + the reload that follows it: the device drops the keyless session + and the reauth flow reloads the entry with the stored key. + """ + entry = self.entry + # Read the flag off the client so the route matches the hello; the + # flag implies a MAC unique id, which is the routing key + if ( + not self.cli.outgoing_connection_target + or (mac := _mac_unique_id(entry)) is None + ): + _LOGGER.debug("%s: Not routing dial-ins; not a target", entry.title) + return + try: + unregister = async_register_outgoing_target(self.hass, mac, reconnect_logic) + except Exception: + # Dial-in is an optional extra; it must not fail the entry + _LOGGER.exception("%s: Could not set up dial-in routing", entry.title) + return + if unregister is None: + # Shutting down; the device is on its own until the next reload + _LOGGER.debug("%s: Dial-in routing not started", entry.title) + return + # async_on_unload also runs when setup fails or is cancelled + entry.async_on_unload(unregister) + async def _on_connect(self) -> None: """Subscribe to states and list entities on successful API login.""" entry = self.entry @@ -850,7 +898,10 @@ class ESPHomeManager: """ unique_id = self.entry.unique_id cli = async_create_api_client( - self.hass, self.entry, self.zeroconf_instance, noise_psk=ZERO_NOISE_PSK + self.hass, + self.entry, + self.zeroconf_instance, + noise_psk=ZERO_NOISE_PSK, ) device_name = self.entry.data.get(CONF_DEVICE_NAME, self.host) try: @@ -1174,6 +1225,11 @@ class ESPHomeManager: await reconnect_logic.start() + # After start(), the last call that can raise, so a failed setup + # cannot leak the route; before the BLE wait below so a dial-in can + # satisfy the first connect during a short wake window + self._async_register_outgoing_target(reconnect_logic) + # Wait for a cached BLE proxy to register its scanner before finishing setup. if ( device_info := entry_data.device_info diff --git a/homeassistant/components/esphome/outgoing_connection.py b/homeassistant/components/esphome/outgoing_connection.py new file mode 100644 index 000000000000..3ac3ccc599db --- /dev/null +++ b/homeassistant/components/esphome/outgoing_connection.py @@ -0,0 +1,40 @@ +"""Shared listener for ESPHome device-initiated connections.""" + +from aioesphomeapi import OutgoingConnectionServer, ReconnectLogic + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers.singleton import singleton +from homeassistant.util.hass_dict import HassKey + +_KEY_OUTGOING_CONNECTION_SERVER: HassKey[OutgoingConnectionServer] = HassKey( + "esphome_outgoing_connection_server" +) + + +@singleton(_KEY_OUTGOING_CONNECTION_SERVER) +@callback +def _async_get_server(hass: HomeAssistant) -> OutgoingConnectionServer: + """Create the shared listener and tie it to Home Assistant's shutdown.""" + server = OutgoingConnectionServer() + + @callback + def _async_hass_stop(event: Event) -> None: + server.close() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_hass_stop) + return server + + +@callback +def async_register_outgoing_target( + hass: HomeAssistant, mac: str, reconnect_logic: ReconnectLogic +) -> CALLBACK_TYPE | None: + """Route dial-ins from this MAC to the reconnect logic. + + The library manages the listener lifecycle. Returns the unregister + callback, or None during shutdown. + """ + if hass.is_stopping: + return None + return _async_get_server(hass).register(mac, reconnect_logic) diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 97bee517b258..88ecf6d743b9 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Protocol from unittest.mock import AsyncMock, MagicMock, Mock, patch from aioesphomeapi import ( + ZERO_NOISE_PSK, APIClient, APIVersion, BluetoothProxyFeature, @@ -16,6 +17,7 @@ from aioesphomeapi import ( EntityState, HomeassistantServiceCall, LogLevel, + OutgoingConnectionServer, ReconnectLogic, UserService, VoiceAssistantAnnounceFinished, @@ -94,6 +96,21 @@ def mock_bluetooth(enable_bluetooth: None) -> None: """Auto mock bluetooth.""" +@pytest.fixture(autouse=True) +def mock_outgoing_connection_server() -> Generator[MagicMock]: + """Patch the shared dial-in listener so tests never bind a real socket.""" + server = MagicMock(spec=OutgoingConnectionServer) + # A real unregister callback returns None + server.register.return_value.return_value = None + with patch( + "homeassistant.components.esphome.outgoing_connection.OutgoingConnectionServer", + return_value=server, + ) as server_class: + # Tests assert the singleton builds exactly one server + server.constructor = server_class + yield server + + @pytest.fixture(autouse=True) def esphome_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None: """Auto mock zeroconf.""" @@ -186,6 +203,7 @@ def mock_client(mock_device_info) -> Generator[APIClient]: noise_psk: str | None = None, expected_name: str | None = None, timezone: str | None = None, + outgoing_connection_target: bool = False, ) -> None: """Fake the client constructor.""" mock_client.host = address @@ -194,6 +212,12 @@ def mock_client(mock_device_info) -> Generator[APIClient]: mock_client.zeroconf_instance = zeroconf_instance mock_client.noise_psk = noise_psk mock_client.timezone = timezone + # Mirror the real constructor's gate on a real key + mock_client.outgoing_connection_target = ( + outgoing_connection_target + and bool(noise_psk) + and noise_psk != ZERO_NOISE_PSK + ) return mock_client mock_client.side_effect = mock_constructor diff --git a/tests/components/esphome/test_init.py b/tests/components/esphome/test_init.py index 9a1af3b3a28c..09db0e8da4b2 100644 --- a/tests/components/esphome/test_init.py +++ b/tests/components/esphome/test_init.py @@ -56,6 +56,8 @@ async def test_remove_entry_clears_dynamic_encryption_key( mock_client.connect.assert_called_once() mock_client.noise_encryption_set_key.assert_called_once_with(b"") mock_client.disconnect.assert_called_once() + # The connection that wipes the key must not become a dial-back target + assert mock_client.outgoing_connection_target is False assert await storage.async_get_key(mock_config_entry.unique_id) is None diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index 8113a9a0e394..6f12a79ce46f 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -3249,6 +3249,7 @@ def mock_provisioning_client(mock_client: APIClient) -> Generator[Mock]: def _api_client(*args: Any, **kwargs: Any) -> Mock: if kwargs.get("noise_psk") == ZERO_NOISE_PSK: + client.outgoing_connection_target = kwargs["outgoing_connection_target"] return client return mock_client(*args, **kwargs) @@ -3317,6 +3318,8 @@ async def test_dynamic_encryption_key_provisioned_over_zero_psk( ) mock_client.noise_encryption_set_key.assert_not_called() mock_provisioning_client.disconnect.assert_called_with(force=True) + # The key exchange session must not become a dial-back target + assert mock_provisioning_client.outgoing_connection_target is False # Entry and storage were updated assert entry.data[CONF_NOISE_PSK] == expected_key diff --git a/tests/components/esphome/test_outgoing_connection.py b/tests/components/esphome/test_outgoing_connection.py new file mode 100644 index 000000000000..64fce97f489b --- /dev/null +++ b/tests/components/esphome/test_outgoing_connection.py @@ -0,0 +1,197 @@ +"""Tests for device-initiated outgoing connections.""" + +import asyncio +from unittest.mock import MagicMock, patch + +from aioesphomeapi import ZERO_NOISE_PSK, APIClient +import pytest + +from homeassistant.components.esphome.const import CONF_NOISE_PSK, DOMAIN +from homeassistant.components.esphome.manager import ESPHomeManager +from homeassistant.components.esphome.outgoing_connection import ( + async_register_outgoing_target, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import CoreState, HomeAssistant + +from . import VALID_NOISE_PSK +from .conftest import MockESPHomeDeviceType + +from tests.common import MockConfigEntry + +MAC = "11:22:33:44:55:aa" + + +def _make_entry( + *, + noise_psk: str | None = VALID_NOISE_PSK, + unique_id: str = MAC, +) -> MockConfigEntry: + data = {CONF_HOST: "test.local", CONF_PORT: 6053, CONF_PASSWORD: ""} + if noise_psk is not None: + data[CONF_NOISE_PSK] = noise_psk + return MockConfigEntry(domain=DOMAIN, data=data, unique_id=unique_id) + + +async def test_outgoing_connection_registration( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """An encrypted entry registers with the shared listener.""" + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + assert mock_outgoing_connection_server.register.call_args.args[0] == MAC + # The client declares itself a dial-back target in its hello + assert mock_client.outgoing_connection_target is True + + # Unloading the entry removes its route; the library owns the rest + unregister = mock_outgoing_connection_server.register.return_value + unregister.assert_not_called() + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + unregister.assert_called() + + +@pytest.mark.parametrize("noise_psk", [None, "", ZERO_NOISE_PSK]) +async def test_outgoing_connection_requires_noise_psk( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, + noise_psk: str | None, +) -> None: + """No real key (missing, empty, or the zero provisioning PSK), no route.""" + entry = _make_entry(noise_psk=noise_psk) + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + mock_outgoing_connection_server.register.assert_not_called() + assert mock_client.outgoing_connection_target is False + + +async def test_outgoing_connection_shared_listener( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """Two entries share the one listener; each registers its own MAC.""" + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + entry2 = _make_entry(unique_id="aa:bb:cc:dd:ee:01") + entry2.add_to_hass(hass) + await mock_esphome_device( + mock_client=mock_client, + entry=entry2, + device_info={"mac_address": "AA:BB:CC:DD:EE:01", "name": "test2"}, + ) + await hass.async_block_till_done() + + assert mock_outgoing_connection_server.register.call_count == 2 + macs = [ + call.args[0] for call in mock_outgoing_connection_server.register.call_args_list + ] + assert macs == [MAC, "aa:bb:cc:dd:ee:01"] + # One server for both entries; a lost @singleton would build two + assert mock_outgoing_connection_server.constructor.call_count == 1 + + +async def test_outgoing_connection_requires_mac_unique_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """A pre-2023 non-MAC unique id gets no route and declares no flag.""" + entry = _make_entry(unique_id="my-old-device") + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + mock_outgoing_connection_server.register.assert_not_called() + assert mock_client.outgoing_connection_target is False + + +async def test_outgoing_connection_stops_on_hass_stop( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, +) -> None: + """The shared listener is closed when Home Assistant stops.""" + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_outgoing_connection_server.close.assert_called_once() + + +async def test_outgoing_connection_not_started_during_shutdown( + hass: HomeAssistant, + mock_outgoing_connection_server: MagicMock, +) -> None: + """No route is registered once Home Assistant is stopping.""" + hass.set_state(CoreState.stopping) + assert async_register_outgoing_target(hass, MAC, MagicMock()) is None + mock_outgoing_connection_server.register.assert_not_called() + + +async def test_outgoing_connection_register_error_does_not_fail_setup( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + mock_outgoing_connection_server: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising register is contained; the entry still loads.""" + mock_outgoing_connection_server.register.side_effect = RuntimeError("boom") + entry = _make_entry() + entry.add_to_hass(hass) + await mock_esphome_device(mock_client=mock_client, entry=entry, device_info={}) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + assert "Could not set up dial-in routing" in caplog.text + + +async def test_outgoing_connection_route_removed_on_failed_setup( + hass: HomeAssistant, + mock_client: APIClient, + mock_outgoing_connection_server: MagicMock, +) -> None: + """A setup that fails after the route is registered drains it. + + Registration is the last step of async_start that can raise; a failure + after it is the Bluetooth scanner wait being cancelled. + """ + entry = _make_entry() + entry.add_to_hass(hass) + real_start = ESPHomeManager.async_start + + async def start_then_cancel(self: ESPHomeManager) -> None: + await real_start(self) + raise asyncio.CancelledError + + with patch.object(ESPHomeManager, "async_start", start_then_cancel): + assert not await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_ERROR + mock_outgoing_connection_server.register.assert_called_once() + mock_outgoing_connection_server.register.return_value.assert_called_once()