From abdd132bdcc4ab2755cce2b8c08ac83ec9c098a9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:16:56 -0400 Subject: [PATCH] Register optimized ESPHome serial proxy transport with serialx (#168817) --- homeassistant/components/esphome/__init__.py | 46 +++- .../components/esphome/manifest.json | 2 +- .../components/esphome/serial_proxy.py | 113 +++++++++ tests/components/esphome/test_serial_proxy.py | 220 ++++++++++++++++++ 4 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/esphome/serial_proxy.py create mode 100644 tests/components/esphome/test_serial_proxy.py diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 26814ae18a3d..4f0cf60da4d6 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -8,18 +8,24 @@ from aioesphomeapi import APIClient, APIConnectionError from homeassistant.components import zeroconf from homeassistant.components.bluetooth import async_remove_scanner +from homeassistant.components.usb import ( + SerialDevice, + USBDevice, + async_register_serial_port_scanner, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, __version__ as ha_version, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.helpers.typing import ConfigType +from homeassistant.util import slugify -from . import assist_satellite, dashboard, ffmpeg_proxy +from . import assist_satellite, dashboard, ffmpeg_proxy, serial_proxy from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN from .domain_data import DomainData from .encryption_key_storage import async_get_encryption_key_storage @@ -34,12 +40,48 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CLIENT_INFO = f"Home Assistant {ha_version}" +@callback +def _async_scan_serial_ports( + hass: HomeAssistant, +) -> list[USBDevice | SerialDevice]: + """Return serial-proxy ports exposed by connected ESPHome devices.""" + ports: list[USBDevice | SerialDevice] = [] + + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + entry_data = entry.runtime_data + if not entry_data.available: + continue + + device_info = entry_data.device_info + if device_info is None: + continue + + ports.extend( + SerialDevice( + device=str(serial_proxy.build_url(entry.entry_id, proxy.name)), + serial_number=( + device_info.mac_address.replace(":", "") + "-" + slugify(proxy.name) + ), + manufacturer=device_info.manufacturer, + description=f"{device_info.model} ({proxy.name})", + ) + for proxy in device_info.serial_proxies + ) + + return ports + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the esphome component.""" ffmpeg_proxy.async_setup(hass) await assist_satellite.async_setup(hass) await dashboard.async_setup(hass) async_setup_websocket_api(hass) + + if "usb" in hass.config.components: + async_register_serial_port_scanner(hass, _async_scan_serial_ports) + serial_proxy.set_hass_loop(hass.loop) + return True diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e11776ff7801..3e12097d5a97 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -1,7 +1,7 @@ { "domain": "esphome", "name": "ESPHome", - "after_dependencies": ["hassio", "zeroconf", "tag"], + "after_dependencies": ["hassio", "tag", "usb", "zeroconf"], "codeowners": ["@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], diff --git a/homeassistant/components/esphome/serial_proxy.py b/homeassistant/components/esphome/serial_proxy.py new file mode 100644 index 000000000000..860a0486ee54 --- /dev/null +++ b/homeassistant/components/esphome/serial_proxy.py @@ -0,0 +1,113 @@ +"""Home Assistant-aware ESPHome serial proxy URI handler for serialx.""" + +from __future__ import annotations + +import asyncio +from typing import cast + +from aioesphomeapi import APIClient +from serialx import register_uri_handler +from serialx.platforms.serial_esphome import ( + ESPHomeSerial, + ESPHomeSerialTransport, + InvalidSettingsError, +) +from yarl import URL + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant, async_get_hass + +from .const import DOMAIN +from .entry_data import ESPHomeConfigEntry + +SCHEME = "esphome-hass://" + +# This is required so that serialx can safely query Core for an instance of an +# aioesphomeapi client. We cannot make any assumptions here, some packages run separate +# asyncio event loops in dedicated threads. +_HASS_LOOP: asyncio.AbstractEventLoop | None = None + + +def set_hass_loop(loop: asyncio.AbstractEventLoop) -> None: + """Store a reference to the Core event loop.""" + global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement + _HASS_LOOP = loop + + +def build_url(entry_id: str, port_name: str) -> URL: + """Build a canonical `esphome-hass://` URL.""" + return URL.build( + scheme="esphome-hass", + host="esphome", + path=f"/{entry_id}", + query={"port_name": port_name}, + ) + + +async def _resolve_client(entry_id: str) -> APIClient: + """Look up the `APIClient` for a specific config entry.""" + + # This function is async specifically so that we can get a reference to the Home + # Assistant Core instance from its own thread + hass: HomeAssistant = async_get_hass() + entry = cast(ESPHomeConfigEntry, hass.config_entries.async_get_entry(entry_id)) + + if entry is None or entry.domain != DOMAIN: + raise InvalidSettingsError(f"No ESPHome config entry with id {entry_id!r}") + + if entry.state is not ConfigEntryState.LOADED: + raise InvalidSettingsError(f"ESPHome config entry {entry_id!r} is not loaded") + + return entry.runtime_data.client + + +class HassESPHomeSerial(ESPHomeSerial): + """ESPHomeSerial that resolves an HA config entry's APIClient from the URL.""" + + _api: APIClient | None + _path: str | None + + async def _async_open(self) -> None: + """Resolve the HA config entry's APIClient, then open the proxy.""" + if self._api is None and self._path is not None: + parsed = URL(str(self._path)) + + entry_id = parsed.path.lstrip("/") + if not entry_id: + raise InvalidSettingsError( + f"No ESPHome config entry id in URL {self._path!r}" + ) + + if "port_name" not in parsed.query: + raise InvalidSettingsError("Port name is required") + + self._port_name = parsed.query["port_name"] + + hass_loop = _HASS_LOOP + if hass_loop is None: + raise InvalidSettingsError( + "ESPHome integration has not registered its event loop" + ) + + # Fetch the `APIClient` from the Core via the appropriate event loop + self._api = await asyncio.wrap_future( + asyncio.run_coroutine_threadsafe(_resolve_client(entry_id), hass_loop) + ) + self._client_loop = self._api._loop # noqa: SLF001 + + await super()._async_open() + + +class HassESPHomeSerialTransport(ESPHomeSerialTransport): + """Transport variant that constructs :class:`HassESPHomeSerial`.""" + + transport_name = "esphome-hass" + _serial_cls = HassESPHomeSerial + + +register_uri_handler( + scheme=SCHEME, + unique_scheme=SCHEME, + sync_cls=HassESPHomeSerial, + async_transport_cls=HassESPHomeSerialTransport, +) diff --git a/tests/components/esphome/test_serial_proxy.py b/tests/components/esphome/test_serial_proxy.py new file mode 100644 index 000000000000..fcf2f7078a60 --- /dev/null +++ b/tests/components/esphome/test_serial_proxy.py @@ -0,0 +1,220 @@ +"""Tests for the ESPHome serial proxy helper.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, call, patch + +from aioesphomeapi import APIClient +from aioesphomeapi.model import SerialProxyInfo, SerialProxyPortType +import pytest +from serialx.platforms.serial_esphome import InvalidSettingsError +from yarl import URL + +from homeassistant.components.esphome import _async_scan_serial_ports, serial_proxy +from homeassistant.components.esphome.const import DOMAIN +from homeassistant.components.usb import SerialDevice +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import MockESPHomeDeviceType + +from tests.common import MockConfigEntry + + +def test_build_url_basic() -> None: + """Build a URL with a simple port name.""" + url = serial_proxy.build_url("abc123DEF456", "uart0") + assert url == URL("esphome-hass://esphome/abc123DEF456?port_name=uart0") + + +def test_build_url_escapes_port_name() -> None: + """Port names with special characters are URL-encoded.""" + url = serial_proxy.build_url("abc123", "uart 0/main") + # Round-trip via yarl recovers the original port name + assert URL(str(url)).query["port_name"] == "uart 0/main" + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_async_setup_stores_event_loop( + hass: HomeAssistant, +) -> None: + """async_setup registers hass.loop on the serial_proxy module.""" + assert await async_setup_component(hass, DOMAIN, {}) + assert serial_proxy._HASS_LOOP is hass.loop + + +async def test_resolve_client_unknown_entry(hass: HomeAssistant) -> None: + """An unknown entry_id raises InvalidSettingsError.""" + with ( + patch.object(serial_proxy, "async_get_hass", return_value=hass), + pytest.raises(InvalidSettingsError), + ): + await serial_proxy._resolve_client("does-not-exist") + + +async def test_resolve_client_wrong_domain(hass: HomeAssistant) -> None: + """A config entry from a different domain raises InvalidSettingsError.""" + entry = MockConfigEntry(domain="other", data={}) + entry.add_to_hass(hass) + + with ( + patch.object(serial_proxy, "async_get_hass", return_value=hass), + pytest.raises(InvalidSettingsError), + ): + await serial_proxy._resolve_client(entry.entry_id) + + +async def test_resolve_client_unloaded_entry(hass: HomeAssistant) -> None: + """An ESPHome entry that isn't loaded raises InvalidSettingsError.""" + entry = MockConfigEntry(domain=DOMAIN, data={}) + entry.add_to_hass(hass) + + with ( + patch.object(serial_proxy, "async_get_hass", return_value=hass), + pytest.raises(InvalidSettingsError), + ): + await serial_proxy._resolve_client(entry.entry_id) + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_resolve_client_loaded_entry( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """A loaded ESPHome entry returns its APIClient.""" + device = await mock_esphome_device(mock_client=mock_client) + + with patch.object(serial_proxy, "async_get_hass", return_value=hass): + client = await serial_proxy._resolve_client(device.entry.entry_id) + + assert client is mock_client + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_scan_serial_ports_no_entries(hass: HomeAssistant) -> None: + """No loaded ESPHome entries yields no ports.""" + assert _async_scan_serial_ports(hass) == [] + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_scan_serial_ports_happy_path( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """A loaded entry with serial proxies emits a SerialDevice per proxy.""" + device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "mac_address": "AA:BB:CC:DD:EE:FF", + "manufacturer": "Espressif", + "model": "ESP32", + "serial_proxies": [ + SerialProxyInfo(name="Left Port", port_type=SerialProxyPortType.TTL), + SerialProxyInfo(name="Right Port", port_type=SerialProxyPortType.TTL), + ], + }, + ) + + ports = _async_scan_serial_ports(hass) + + entry_id = device.entry.entry_id + assert ports == [ + SerialDevice( + device=str(serial_proxy.build_url(entry_id, "Left Port")), + serial_number="AABBCCDDEEFF-left_port", + manufacturer="Espressif", + description="ESP32 (Left Port)", + ), + SerialDevice( + device=str(serial_proxy.build_url(entry_id, "Right Port")), + serial_number="AABBCCDDEEFF-right_port", + manufacturer="Espressif", + description="ESP32 (Right Port)", + ), + ] + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_scan_serial_ports_skips_unavailable( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Unavailable entries are skipped by the scanner.""" + device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "serial_proxies": [ + SerialProxyInfo(name="uart0", port_type=SerialProxyPortType.TTL) + ], + }, + ) + # Mark the entry as unavailable + device.entry.runtime_data.available = False + + assert _async_scan_serial_ports(hass) == [] + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_async_open_missing_host(hass: HomeAssistant) -> None: + """A URL with an invalid entry_id raises InvalidSettingsError.""" + assert await async_setup_component(hass, DOMAIN, {}) + proxy = serial_proxy.HassESPHomeSerial("esphome-hass://unknown/?port_name=uart0") + + with pytest.raises(InvalidSettingsError): + await proxy._async_open() + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_async_open_missing_port_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """A URL with a missing port name raises InvalidSettingsError.""" + assert await async_setup_component(hass, DOMAIN, {}) + + device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "mac_address": "AA:BB:CC:DD:EE:FF", + "manufacturer": "Espressif", + "model": "ESP32", + "serial_proxies": [ + SerialProxyInfo(name="uart0", port_type=SerialProxyPortType.TTL), + ], + }, + ) + + entry_id = device.entry.entry_id + proxy = serial_proxy.HassESPHomeSerial(f"esphome-hass://{entry_id}") + + with pytest.raises(InvalidSettingsError): + await proxy._async_open() + + +@pytest.mark.usefixtures("mock_zeroconf") +async def test_async_open_happy_path( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Happy path sets _api from the loaded entry and applies port_name from query.""" + device = await mock_esphome_device(mock_client=mock_client) + mock_client._loop = hass.loop + + url = str(serial_proxy.build_url(device.entry.entry_id, "uart0")) + proxy = serial_proxy.HassESPHomeSerial(url) + + with patch( + "homeassistant.components.esphome.serial_proxy.ESPHomeSerial._async_open", + AsyncMock(), + ) as mock_super_open: + await proxy._async_open() + + assert proxy._api is mock_client + assert proxy._port_name == "uart0" + assert proxy._client_loop is hass.loop + assert mock_super_open.mock_calls == [call()]