Add support for ESPHome device-initiated connections (#180961)

This commit is contained in:
J. Nick Koston
2026-09-10 11:14:42 -05:00
committed by GitHub
parent fae77cc1f5
commit ddcdfbf148
7 changed files with 333 additions and 4 deletions
+9 -2
View File
@@ -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:
+58 -2
View File
@@ -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
@@ -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)
+24
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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
@@ -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()