mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add support for ESPHome device-initiated connections (#180961)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user