Register optimized ESPHome serial proxy transport with serialx (#168817)

This commit is contained in:
puddly
2026-04-22 13:16:56 -04:00
committed by GitHub
parent 1b71ef2a60
commit abdd132bdc
4 changed files with 378 additions and 3 deletions
+44 -2
View File
@@ -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
@@ -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"],
@@ -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,
)
@@ -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()]