mirror of
https://github.com/home-assistant/core.git
synced 2026-09-07 14:01:20 +01:00
239 lines
7.7 KiB
Python
239 lines
7.7 KiB
Python
"""Attribution of serial ports to the integrations and apps using them."""
|
|
|
|
from collections.abc import Mapping, Sequence
|
|
import os
|
|
import re
|
|
from typing import Any
|
|
|
|
from homeassistant.components.hassio import HassioNotReadyError, get_addons_info
|
|
from homeassistant.config_entries import ConfigEntryState
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.helpers.hassio import is_hassio
|
|
from homeassistant.loader import async_get_integrations
|
|
|
|
from .const import DOMAIN
|
|
from .models import SerialDevice, SerialPortConsumer, USBDevice
|
|
|
|
# Key paths holding a serial port in the config entry data and options of
|
|
# searched integrations. Traversed literally, never recursively.
|
|
SERIAL_PORT_KEY_PATHS: tuple[tuple[str, ...], ...] = (
|
|
("device",),
|
|
("device", "path"), # zha
|
|
("device_path",), # alarmdecoder
|
|
("filename",), # bryant_evolution
|
|
("host",), # elkm1
|
|
("port",),
|
|
("serial_port",), # edl21, teleinfo
|
|
("socket_path",), # zwave_js
|
|
("usb_path",), # zwave_js, crownstone
|
|
)
|
|
|
|
# Integrations configured with a serial port but not depending on `usb`
|
|
NON_USB_SERIAL_DOMAINS = ("alarmdecoder", "bryant_evolution", "elkm1", "mysensors")
|
|
|
|
# States in which the entry claims its configured port, even if the port is not
|
|
# open right now: a retrying setup typically failed to open the port, while an
|
|
# unloading or failed-to-unload entry may still hold it
|
|
ACTIVE_CONFIG_ENTRY_STATES = (
|
|
ConfigEntryState.LOADED,
|
|
ConfigEntryState.SETUP_RETRY,
|
|
ConfigEntryState.SETUP_IN_PROGRESS,
|
|
ConfigEntryState.UNLOAD_IN_PROGRESS,
|
|
ConfigEntryState.FAILED_UNLOAD,
|
|
)
|
|
|
|
# Remote ports contributed by serial port scanners; a configured port missing
|
|
# from the scan is absent, e.g. because the providing integration is offline
|
|
SCANNED_PORT_SCHEMES = ("esphome-hass://",)
|
|
|
|
# Serial port URLs no scanner contributes; they can never be scanned, so a
|
|
# claiming consumer is the only evidence such a port exists
|
|
UNSCANNABLE_PORT_SCHEMES = (
|
|
"esphome://",
|
|
"rfc2217://",
|
|
"socket://",
|
|
"tcp://",
|
|
)
|
|
|
|
# upb wraps the port in a URL with an optional baud rate, e.g.
|
|
# `serial:///dev/ttyS0:4800`, which upb_lib strips itself when connecting
|
|
BAUD_SUFFIX_RE = re.compile(r":\d+$")
|
|
|
|
# Supervisor app state, mirrors `aiohasupervisor.models.AddonState.STARTED`
|
|
APP_STATE_STARTED = "started"
|
|
|
|
|
|
def _resolve_key_path(data: Mapping[str, Any], key_path: tuple[str, ...]) -> Any:
|
|
"""Return the value at a key path, or `None` if the path does not exist."""
|
|
value: Any = data
|
|
|
|
for key in key_path:
|
|
if not isinstance(value, Mapping) or key not in value:
|
|
return None
|
|
value = value[key]
|
|
|
|
return value
|
|
|
|
|
|
def _serial_port_from_value(
|
|
value: Any, known_devices: set[str], domain: str
|
|
) -> str | None:
|
|
"""Return the serial port a config entry value refers to."""
|
|
if not isinstance(value, str):
|
|
return None
|
|
|
|
if value in known_devices:
|
|
return value
|
|
|
|
if value.startswith(SCANNED_PORT_SCHEMES):
|
|
return value
|
|
|
|
if value.startswith(UNSCANNABLE_PORT_SCHEMES):
|
|
# zwave_js's esphome:// socket path embeds the noise PSK as `?key=`
|
|
return value.partition("?")[0]
|
|
|
|
path = value
|
|
|
|
if domain in ("elkm1", "upb"):
|
|
path = path.removeprefix("serial://").removeprefix("device://")
|
|
path = BAUD_SUFFIX_RE.sub("", path)
|
|
|
|
if path.startswith("/dev/"):
|
|
return path
|
|
|
|
return None
|
|
|
|
|
|
def _resolve_paths(paths: set[str]) -> dict[str, str]:
|
|
"""Resolve symlinks of local device paths, passing other values through."""
|
|
return {
|
|
path: os.path.realpath(path) if path.startswith("/") else path for path in paths
|
|
}
|
|
|
|
|
|
async def _async_get_config_entry_consumers(
|
|
hass: HomeAssistant, known_devices: set[str]
|
|
) -> dict[str, list[SerialPortConsumer]]:
|
|
"""Return serial ports configured in config entries of `usb` integrations."""
|
|
entries = hass.config_entries.async_entries(include_ignore=False)
|
|
integrations = await async_get_integrations(
|
|
hass, {entry.domain for entry in entries}
|
|
)
|
|
consumers: dict[str, list[SerialPortConsumer]] = {}
|
|
|
|
for entry in entries:
|
|
integration = integrations[entry.domain]
|
|
|
|
if isinstance(integration, Exception):
|
|
continue
|
|
|
|
if (
|
|
entry.domain not in NON_USB_SERIAL_DOMAINS
|
|
and DOMAIN not in integration.dependencies
|
|
and DOMAIN not in integration.after_dependencies
|
|
):
|
|
continue
|
|
|
|
for key_path in SERIAL_PORT_KEY_PATHS:
|
|
for data in (entry.data, entry.options):
|
|
port = _serial_port_from_value(
|
|
_resolve_key_path(data, key_path), known_devices, entry.domain
|
|
)
|
|
|
|
if port is None:
|
|
continue
|
|
|
|
consumers.setdefault(port, []).append(
|
|
SerialPortConsumer(
|
|
kind="config_entry",
|
|
title=entry.title,
|
|
active=entry.state in ACTIVE_CONFIG_ENTRY_STATES,
|
|
domain=entry.domain,
|
|
config_entry_id=entry.entry_id,
|
|
)
|
|
)
|
|
|
|
return consumers
|
|
|
|
|
|
@callback
|
|
def _async_get_app_consumers(
|
|
hass: HomeAssistant,
|
|
) -> dict[str, list[SerialPortConsumer]]:
|
|
"""Return devices mapped into apps, either statically or through options.
|
|
|
|
Supervisor resolves `device(subsystem=tty)` options into real devices, so device
|
|
paths that no longer exist are missing and non-serial devices are included.
|
|
"""
|
|
if not is_hassio(hass):
|
|
return {}
|
|
|
|
try:
|
|
apps_info = get_addons_info(hass)
|
|
except HassioNotReadyError:
|
|
return {}
|
|
|
|
consumers: dict[str, list[SerialPortConsumer]] = {}
|
|
|
|
for slug, info in apps_info.items():
|
|
if info is None:
|
|
continue
|
|
|
|
for device in info["devices"]:
|
|
consumers.setdefault(device, []).append(
|
|
SerialPortConsumer(
|
|
kind="app",
|
|
title=info["name"],
|
|
active=info["state"] == APP_STATE_STARTED,
|
|
slug=slug,
|
|
)
|
|
)
|
|
|
|
return consumers
|
|
|
|
|
|
async def async_get_serial_port_consumers(
|
|
hass: HomeAssistant, ports: Sequence[USBDevice | SerialDevice]
|
|
) -> dict[str, list[SerialPortConsumer]]:
|
|
"""Return the consumers of every serial port, keyed by device path.
|
|
|
|
Scanned ports are keyed by their scanned device path, ports that are configured
|
|
but not currently present are keyed by their configured path.
|
|
"""
|
|
known_devices = {port.device for port in ports}
|
|
|
|
entry_consumers = await _async_get_config_entry_consumers(hass, known_devices)
|
|
app_consumers = _async_get_app_consumers(hass)
|
|
|
|
resolved = await hass.async_add_executor_job(
|
|
_resolve_paths, known_devices | set(entry_consumers) | set(app_consumers)
|
|
)
|
|
|
|
# A port can be referred to by any of its symlinks, e.g. `/dev/serial/by-id`
|
|
aliases: dict[str, str] = {}
|
|
|
|
for port in ports:
|
|
aliases[resolved[port.device]] = port.device
|
|
aliases[port.device] = port.device
|
|
|
|
consumers: dict[str, list[SerialPortConsumer]] = {}
|
|
|
|
for path, path_consumers in entry_consumers.items():
|
|
# Ports that are configured but missing are kept and shown as absent
|
|
device = aliases.get(resolved[path], path)
|
|
consumers.setdefault(device, []).extend(path_consumers)
|
|
|
|
for path, path_consumers in app_consumers.items():
|
|
# Apps also map non-serial devices, only scanned ports are of interest
|
|
resolved_path = resolved[path]
|
|
|
|
if resolved_path not in aliases:
|
|
continue
|
|
|
|
consumers.setdefault(aliases[resolved_path], []).extend(path_consumers)
|
|
|
|
return {
|
|
device: list(dict.fromkeys(device_consumers))
|
|
for device, device_consumers in consumers.items()
|
|
}
|