mirror of
https://github.com/home-assistant/core.git
synced 2026-09-03 18:24:49 -05:00
Register optimized ESPHome serial proxy transport with serialx (#168817)
This commit is contained in:
@@ -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()]
|
||||
Reference in New Issue
Block a user