Skip to content

Commit abdd132

Browse files
authored
Register optimized ESPHome serial proxy transport with serialx (#168817)
1 parent 1b71ef2 commit abdd132

4 files changed

Lines changed: 378 additions & 3 deletions

File tree

homeassistant/components/esphome/__init__.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,24 @@
88

99
from homeassistant.components import zeroconf
1010
from homeassistant.components.bluetooth import async_remove_scanner
11+
from homeassistant.components.usb import (
12+
SerialDevice,
13+
USBDevice,
14+
async_register_serial_port_scanner,
15+
)
1116
from homeassistant.const import (
1217
CONF_HOST,
1318
CONF_PASSWORD,
1419
CONF_PORT,
1520
__version__ as ha_version,
1621
)
17-
from homeassistant.core import HomeAssistant
22+
from homeassistant.core import HomeAssistant, callback
1823
from homeassistant.helpers import config_validation as cv
1924
from homeassistant.helpers.issue_registry import async_delete_issue
2025
from homeassistant.helpers.typing import ConfigType
26+
from homeassistant.util import slugify
2127

22-
from . import assist_satellite, dashboard, ffmpeg_proxy
28+
from . import assist_satellite, dashboard, ffmpeg_proxy, serial_proxy
2329
from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
2430
from .domain_data import DomainData
2531
from .encryption_key_storage import async_get_encryption_key_storage
@@ -34,12 +40,48 @@
3440
CLIENT_INFO = f"Home Assistant {ha_version}"
3541

3642

43+
@callback
44+
def _async_scan_serial_ports(
45+
hass: HomeAssistant,
46+
) -> list[USBDevice | SerialDevice]:
47+
"""Return serial-proxy ports exposed by connected ESPHome devices."""
48+
ports: list[USBDevice | SerialDevice] = []
49+
50+
for entry in hass.config_entries.async_loaded_entries(DOMAIN):
51+
entry_data = entry.runtime_data
52+
if not entry_data.available:
53+
continue
54+
55+
device_info = entry_data.device_info
56+
if device_info is None:
57+
continue
58+
59+
ports.extend(
60+
SerialDevice(
61+
device=str(serial_proxy.build_url(entry.entry_id, proxy.name)),
62+
serial_number=(
63+
device_info.mac_address.replace(":", "") + "-" + slugify(proxy.name)
64+
),
65+
manufacturer=device_info.manufacturer,
66+
description=f"{device_info.model} ({proxy.name})",
67+
)
68+
for proxy in device_info.serial_proxies
69+
)
70+
71+
return ports
72+
73+
3774
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
3875
"""Set up the esphome component."""
3976
ffmpeg_proxy.async_setup(hass)
4077
await assist_satellite.async_setup(hass)
4178
await dashboard.async_setup(hass)
4279
async_setup_websocket_api(hass)
80+
81+
if "usb" in hass.config.components:
82+
async_register_serial_port_scanner(hass, _async_scan_serial_ports)
83+
serial_proxy.set_hass_loop(hass.loop)
84+
4385
return True
4486

4587

homeassistant/components/esphome/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"domain": "esphome",
33
"name": "ESPHome",
4-
"after_dependencies": ["hassio", "zeroconf", "tag"],
4+
"after_dependencies": ["hassio", "tag", "usb", "zeroconf"],
55
"codeowners": ["@jesserockz", "@kbx81", "@bdraco"],
66
"config_flow": true,
77
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Home Assistant-aware ESPHome serial proxy URI handler for serialx."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
from typing import cast
7+
8+
from aioesphomeapi import APIClient
9+
from serialx import register_uri_handler
10+
from serialx.platforms.serial_esphome import (
11+
ESPHomeSerial,
12+
ESPHomeSerialTransport,
13+
InvalidSettingsError,
14+
)
15+
from yarl import URL
16+
17+
from homeassistant.config_entries import ConfigEntryState
18+
from homeassistant.core import HomeAssistant, async_get_hass
19+
20+
from .const import DOMAIN
21+
from .entry_data import ESPHomeConfigEntry
22+
23+
SCHEME = "esphome-hass://"
24+
25+
# This is required so that serialx can safely query Core for an instance of an
26+
# aioesphomeapi client. We cannot make any assumptions here, some packages run separate
27+
# asyncio event loops in dedicated threads.
28+
_HASS_LOOP: asyncio.AbstractEventLoop | None = None
29+
30+
31+
def set_hass_loop(loop: asyncio.AbstractEventLoop) -> None:
32+
"""Store a reference to the Core event loop."""
33+
global _HASS_LOOP # noqa: PLW0603 # pylint: disable=global-statement
34+
_HASS_LOOP = loop
35+
36+
37+
def build_url(entry_id: str, port_name: str) -> URL:
38+
"""Build a canonical `esphome-hass://` URL."""
39+
return URL.build(
40+
scheme="esphome-hass",
41+
host="esphome",
42+
path=f"/{entry_id}",
43+
query={"port_name": port_name},
44+
)
45+
46+
47+
async def _resolve_client(entry_id: str) -> APIClient:
48+
"""Look up the `APIClient` for a specific config entry."""
49+
50+
# This function is async specifically so that we can get a reference to the Home
51+
# Assistant Core instance from its own thread
52+
hass: HomeAssistant = async_get_hass()
53+
entry = cast(ESPHomeConfigEntry, hass.config_entries.async_get_entry(entry_id))
54+
55+
if entry is None or entry.domain != DOMAIN:
56+
raise InvalidSettingsError(f"No ESPHome config entry with id {entry_id!r}")
57+
58+
if entry.state is not ConfigEntryState.LOADED:
59+
raise InvalidSettingsError(f"ESPHome config entry {entry_id!r} is not loaded")
60+
61+
return entry.runtime_data.client
62+
63+
64+
class HassESPHomeSerial(ESPHomeSerial):
65+
"""ESPHomeSerial that resolves an HA config entry's APIClient from the URL."""
66+
67+
_api: APIClient | None
68+
_path: str | None
69+
70+
async def _async_open(self) -> None:
71+
"""Resolve the HA config entry's APIClient, then open the proxy."""
72+
if self._api is None and self._path is not None:
73+
parsed = URL(str(self._path))
74+
75+
entry_id = parsed.path.lstrip("/")
76+
if not entry_id:
77+
raise InvalidSettingsError(
78+
f"No ESPHome config entry id in URL {self._path!r}"
79+
)
80+
81+
if "port_name" not in parsed.query:
82+
raise InvalidSettingsError("Port name is required")
83+
84+
self._port_name = parsed.query["port_name"]
85+
86+
hass_loop = _HASS_LOOP
87+
if hass_loop is None:
88+
raise InvalidSettingsError(
89+
"ESPHome integration has not registered its event loop"
90+
)
91+
92+
# Fetch the `APIClient` from the Core via the appropriate event loop
93+
self._api = await asyncio.wrap_future(
94+
asyncio.run_coroutine_threadsafe(_resolve_client(entry_id), hass_loop)
95+
)
96+
self._client_loop = self._api._loop # noqa: SLF001
97+
98+
await super()._async_open()
99+
100+
101+
class HassESPHomeSerialTransport(ESPHomeSerialTransport):
102+
"""Transport variant that constructs :class:`HassESPHomeSerial`."""
103+
104+
transport_name = "esphome-hass"
105+
_serial_cls = HassESPHomeSerial
106+
107+
108+
register_uri_handler(
109+
scheme=SCHEME,
110+
unique_scheme=SCHEME,
111+
sync_cls=HassESPHomeSerial,
112+
async_transport_cls=HassESPHomeSerialTransport,
113+
)

0 commit comments

Comments
 (0)