[api] Add outgoing connections so the device can dial Home Assistant

This commit is contained in:
J. Nick Koston
2026-08-31 14:06:05 -05:00
parent 813c000684
commit 676eac7686
17 changed files with 756 additions and 12 deletions
@@ -0,0 +1,73 @@
"""Tests for the api outgoing_connection option."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.api import CONFIG_SCHEMA
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
import esphome.config_validation as cv
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
ESP32_PLATFORM_DATA = {KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32}
def _api_config(outgoing: ConfigType, *, encryption: bool = True) -> ConfigType:
config: ConfigType = {"outgoing_connection": outgoing}
if encryption:
config["encryption"] = {"key": KEY}
return config
def test_outgoing_connection_generates_setters(
generate_main: Callable[[str | Path], str],
) -> None:
"""A valid config emits the setters with defaults applied."""
main_cpp = generate_main("tests/component_tests/api/test_outgoing_connection.yaml")
assert 'set_outgoing_connection_host("192.168.1.2")' in main_cpp
assert "set_outgoing_connection_port(6054)" in main_cpp
assert "set_outgoing_connection_delay(60000)" in main_cpp
def test_outgoing_connection_defaults(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
config = CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
outgoing = config["outgoing_connection"]
assert outgoing["port"] == 6054
assert outgoing["delay"].total_milliseconds == 60000
def test_outgoing_connection_requires_encryption(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
with pytest.raises(cv.Invalid, match="requires 'encryption'"):
CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}, encryption=False))
@pytest.mark.parametrize(
"platform_framework",
[PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO],
)
def test_outgoing_connection_rejected_on_raw_lwip_platforms(
set_core_config: SetCoreConfigCallable,
platform_framework: PlatformFramework,
) -> None:
set_core_config(platform_framework)
with pytest.raises(cv.Invalid, match="not supported on this platform"):
CONFIG_SCHEMA(_api_config({"host": "192.168.1.2"}))
def test_outgoing_connection_rejects_hostnames(
set_core_config: SetCoreConfigCallable,
) -> None:
set_core_config(PlatformFramework.ESP32_IDF, platform_data=ESP32_PLATFORM_DATA)
with pytest.raises(cv.Invalid, match="must be an IP address"):
CONFIG_SCHEMA(_api_config({"host": "homeassistant.local"}))
@@ -0,0 +1,17 @@
esphome:
name: test
esp32:
board: esp32dev
wifi:
ssid: SomeNetwork
password: SomePassword
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 192.168.1.2
@@ -0,0 +1,17 @@
packages:
common: !include common-base.yaml
wifi:
ssid: MySSID
password: password1
# Outgoing connection: the device dials Home Assistant when no client with a
# state subscription is connected. Requires encryption so the peer is
# verified by key.
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 192.168.1.2
port: 6054
delay: 60s
@@ -0,0 +1,11 @@
packages:
common: !include common-base.yaml
network:
# No host set: the device dials the last remembered Home Assistant address
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
delay: 30s
@@ -0,0 +1,14 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
host: 127.0.0.1
port: OUTGOING_PORT
delay: 1s
@@ -0,0 +1,13 @@
esphome:
name: outgoing-conn-test
host:
logger:
api:
encryption:
key: bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU=
outgoing_connection:
port: OUTGOING_PORT
delay: 1s
@@ -0,0 +1,176 @@
"""Integration tests for the api outgoing_connection option.
The device dials out to the test's listener when no client with a state
subscription is connected. The listener plays the Home Assistant side over the
accepted socket using aioesphomeapi's sans-IO Noise handshake: the device
sends its server hello first so the listener could pick the right key, and the
NNpsk0 handshake then verifies both sides. Protocol roles stay unchanged, so
the client speaks exactly the same frames as over a normal connection.
"""
from __future__ import annotations
import asyncio
import socket
from typing import Any
from aioesphomeapi import APIClient, api_pb2
import pytest
from .raw_api_client import MESSAGE_TYPE_OF
from .types import APIClientConnectedFactory, RunCompiledFunction
KEY = "bOFFzzvfpg5DB94DuBGLXD/hMnhpDKgP9UQyBulwWVU="
DEVICE_NAME = "outgoing-conn-test"
HA_CLIENT_INFO = "Home Assistant 2026.8.0"
@pytest.fixture(autouse=True)
def isolated_preferences(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
"""Keep host preferences per-test so every run starts with no saved peer."""
monkeypatch.setenv("ESPHOME_PREFDIR", str(tmp_path / "prefs"))
def _frame(payload: bytes) -> bytes:
return bytes((0x01, len(payload) >> 8, len(payload) & 0xFF)) + payload
async def _read_frame(reader: asyncio.StreamReader, timeout: float = 10.0) -> bytes:
header = await asyncio.wait_for(reader.readexactly(3), timeout)
assert header[0] == 0x01, f"Bad frame indicator: {header[0]}"
return await asyncio.wait_for(
reader.readexactly((header[1] << 8) | header[2]), timeout
)
async def _serve_home_assistant(
listener: socket.socket, *, subscribe_states: bool = False
) -> None:
"""Accept one dial-in from the device and run the client side over it."""
# Lazy import per the module's own contract (pulls in the noise stack)
from aioesphomeapi.noise import NoiseHandshake
loop = asyncio.get_running_loop()
conn, _ = await asyncio.wait_for(loop.sock_accept(listener), timeout=30)
reader, writer = await asyncio.open_connection(sock=conn)
try:
# On an outgoing connection the device announces itself first so the
# peer can pick the matching key before its PSK-mixed first message.
server_hello = await _read_frame(reader)
assert server_hello[0] == 0x01, "Bad chosen proto in server hello"
name, mac, _rest = server_hello[1:].split(b"\x00", 2)
assert name.decode() == DEVICE_NAME
assert len(mac) == 12, f"Expected bare MAC, got {mac!r}"
# Normal NNpsk0 handshake: client hello plus PSK-mixed message one,
# then the device's response completes it and proves the key matches.
handshake = NoiseHandshake(KEY, b"NoiseAPIInit\x00\x00")
writer.write(b"\x01\x00\x00" + _frame(b"\x00" + handshake.write_message()))
await writer.drain()
reply = await _read_frame(reader)
assert reply[0] == 0, (
f"Handshake rejected: {reply[1:].decode(errors='replace')}"
)
handshake.read_message(reply[1:])
encrypt_cipher, decrypt_cipher = handshake.get_ciphers()
async def transact(msg: Any, response_cls: Any | None) -> Any:
msg_type = MESSAGE_TYPE_OF[type(msg)]
payload = msg.SerializeToString()
plaintext = (
bytes(
(
msg_type >> 8,
msg_type & 0xFF,
len(payload) >> 8,
len(payload) & 0xFF,
)
)
+ payload
)
writer.write(_frame(encrypt_cipher.encrypt(plaintext)))
await writer.drain()
if response_cls is None:
return None
want = MESSAGE_TYPE_OF[response_cls]
while True:
plain = decrypt_cipher.decrypt(await _read_frame(reader))
if ((plain[0] << 8) | plain[1]) == want:
response = response_cls()
response.ParseFromString(bytes(plain[4:]))
return response
await transact(
api_pb2.HelloRequest(client_info=HA_CLIENT_INFO), api_pb2.HelloResponse
)
device_info = await transact(
api_pb2.DeviceInfoRequest(), api_pb2.DeviceInfoResponse
)
assert device_info.name == DEVICE_NAME
if subscribe_states:
await transact(api_pb2.SubscribeStatesRequest(), None)
# No entities are configured, so there is nothing to wait for;
# give the device a moment to process the subscription.
await asyncio.sleep(0.5)
finally:
writer.close()
@pytest.mark.asyncio
async def test_api_outgoing_connection(
yaml_config: str,
run_compiled: RunCompiledFunction,
) -> None:
"""With a configured host the device dials out and speaks the normal API."""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.bind(("127.0.0.1", 0))
listener.listen(2)
listener.setblocking(False)
port = listener.getsockname()[1]
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with run_compiled(yaml):
await _serve_home_assistant(listener, subscribe_states=True)
finally:
listener.close()
@pytest.mark.asyncio
async def test_api_outgoing_connection_remembered(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""No host configured: the device remembers the Home Assistant client that
connected inbound and dials that address after a restart."""
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# Bound but not yet listening: dial attempts in the first phase are
# refused, exercising the retry path without queueing stale connections.
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
try:
yaml = yaml_config.replace("OUTGOING_PORT", str(port))
async with (
run_compiled(yaml),
api_client_connected(noise_psk=KEY, client_info=HA_CLIENT_INFO) as client,
):
client: APIClient
device_info = await client.device_info()
assert device_info.name == DEVICE_NAME
# Subscribing to states marks this client as Home Assistant;
# the device persists the peer address for dial-back.
client.subscribe_states(lambda state: None)
await asyncio.sleep(1.0)
# Restart with the same preferences: the device now dials the
# remembered address on its own.
listener.listen(2)
listener.setblocking(False)
async with run_compiled(yaml):
await _serve_home_assistant(listener)
finally:
listener.close()