545 lines
18 KiB
Python
545 lines
18 KiB
Python
"""The USB Discovery integration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Callable, Coroutine, Sequence
|
|
import dataclasses
|
|
from datetime import datetime, timedelta
|
|
import fnmatch
|
|
from functools import partial
|
|
import logging
|
|
import os
|
|
import sys
|
|
from typing import Any, overload
|
|
|
|
from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError
|
|
from serial.tools.list_ports import comports
|
|
from serial.tools.list_ports_common import ListPortInfo
|
|
import voluptuous as vol
|
|
|
|
from homeassistant import config_entries
|
|
from homeassistant.components import websocket_api
|
|
from homeassistant.components.websocket_api import ActiveConnection
|
|
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
|
|
from homeassistant.core import (
|
|
CALLBACK_TYPE,
|
|
Event,
|
|
HomeAssistant,
|
|
callback as hass_callback,
|
|
)
|
|
from homeassistant.helpers import config_validation as cv, discovery_flow
|
|
from homeassistant.helpers.debounce import Debouncer
|
|
from homeassistant.helpers.deprecation import (
|
|
DeprecatedConstant,
|
|
all_with_deprecated_constants,
|
|
check_if_deprecated_constant,
|
|
dir_with_deprecated_constants,
|
|
)
|
|
from homeassistant.helpers.event import async_track_time_interval
|
|
from homeassistant.helpers.service_info.usb import UsbServiceInfo as _UsbServiceInfo
|
|
from homeassistant.helpers.typing import ConfigType
|
|
from homeassistant.loader import USBMatcher, async_get_usb
|
|
|
|
from .const import DOMAIN
|
|
from .models import USBDevice
|
|
from .utils import usb_device_from_port
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
PORT_EVENT_CALLBACK_TYPE = Callable[[set[USBDevice], set[USBDevice]], None]
|
|
|
|
POLLING_MONITOR_SCAN_PERIOD = timedelta(seconds=5)
|
|
REQUEST_SCAN_COOLDOWN = 10 # 10 second cooldown
|
|
ADD_REMOVE_SCAN_COOLDOWN = 5 # 5 second cooldown to give devices a chance to register
|
|
|
|
__all__ = [
|
|
"USBCallbackMatcher",
|
|
"async_is_plugged_in",
|
|
"async_register_port_event_callback",
|
|
"async_register_scan_request_callback",
|
|
]
|
|
|
|
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
|
|
|
|
|
class USBCallbackMatcher(USBMatcher):
|
|
"""Callback matcher for the USB integration."""
|
|
|
|
|
|
@hass_callback
|
|
def async_register_scan_request_callback(
|
|
hass: HomeAssistant, callback: CALLBACK_TYPE
|
|
) -> CALLBACK_TYPE:
|
|
"""Register to receive a callback when a scan should be initiated."""
|
|
discovery: USBDiscovery = hass.data[DOMAIN]
|
|
return discovery.async_register_scan_request_callback(callback)
|
|
|
|
|
|
@hass_callback
|
|
def async_register_initial_scan_callback(
|
|
hass: HomeAssistant, callback: CALLBACK_TYPE
|
|
) -> CALLBACK_TYPE:
|
|
"""Register to receive a callback when the initial USB scan is done.
|
|
|
|
If the initial scan is already done, the callback is called immediately.
|
|
"""
|
|
discovery: USBDiscovery = hass.data[DOMAIN]
|
|
return discovery.async_register_initial_scan_callback(callback)
|
|
|
|
|
|
@hass_callback
|
|
def async_register_port_event_callback(
|
|
hass: HomeAssistant, callback: PORT_EVENT_CALLBACK_TYPE
|
|
) -> CALLBACK_TYPE:
|
|
"""Register to receive a callback when a USB device is connected or disconnected."""
|
|
discovery: USBDiscovery = hass.data[DOMAIN]
|
|
return discovery.async_register_port_event_callback(callback)
|
|
|
|
|
|
@hass_callback
|
|
def async_is_plugged_in(hass: HomeAssistant, matcher: USBCallbackMatcher) -> bool:
|
|
"""Return True is a USB device is present."""
|
|
|
|
vid = matcher.get("vid", "")
|
|
pid = matcher.get("pid", "")
|
|
serial_number = matcher.get("serial_number", "")
|
|
manufacturer = matcher.get("manufacturer", "")
|
|
description = matcher.get("description", "")
|
|
|
|
if (
|
|
vid != vid.upper()
|
|
or pid != pid.upper()
|
|
or serial_number != serial_number.lower()
|
|
or manufacturer != manufacturer.lower()
|
|
or description != description.lower()
|
|
):
|
|
raise ValueError(
|
|
f"vid and pid must be uppercase, the rest lowercase in matcher {matcher!r}"
|
|
)
|
|
|
|
usb_discovery: USBDiscovery = hass.data[DOMAIN]
|
|
return any(
|
|
_is_matching(
|
|
USBDevice(
|
|
device=device,
|
|
vid=vid,
|
|
pid=pid,
|
|
serial_number=serial_number,
|
|
manufacturer=manufacturer,
|
|
description=description,
|
|
),
|
|
matcher,
|
|
)
|
|
for (
|
|
device,
|
|
vid,
|
|
pid,
|
|
serial_number,
|
|
manufacturer,
|
|
description,
|
|
) in usb_discovery.seen
|
|
)
|
|
|
|
|
|
_DEPRECATED_UsbServiceInfo = DeprecatedConstant(
|
|
_UsbServiceInfo,
|
|
"homeassistant.helpers.service_info.usb.UsbServiceInfo",
|
|
"2026.2",
|
|
)
|
|
|
|
|
|
@overload
|
|
def human_readable_device_name(
|
|
device: str,
|
|
serial_number: str | None,
|
|
manufacturer: str | None,
|
|
description: str | None,
|
|
vid: str | None,
|
|
pid: str | None,
|
|
) -> str: ...
|
|
|
|
|
|
@overload
|
|
def human_readable_device_name(
|
|
device: str,
|
|
serial_number: str | None,
|
|
manufacturer: str | None,
|
|
description: str | None,
|
|
vid: int | None,
|
|
pid: int | None,
|
|
) -> str: ...
|
|
|
|
|
|
def human_readable_device_name(
|
|
device: str,
|
|
serial_number: str | None,
|
|
manufacturer: str | None,
|
|
description: str | None,
|
|
vid: str | int | None,
|
|
pid: str | int | None,
|
|
) -> str:
|
|
"""Return a human readable name from USBDevice attributes."""
|
|
device_details = f"{device}, s/n: {serial_number or 'n/a'}"
|
|
manufacturer_details = f" - {manufacturer}" if manufacturer else ""
|
|
vendor_details = f" - {vid}:{pid}" if vid is not None else ""
|
|
full_details = f"{device_details}{manufacturer_details}{vendor_details}"
|
|
|
|
if not description:
|
|
return full_details
|
|
return f"{description[:26]} - {full_details}"
|
|
|
|
|
|
def get_serial_by_id(dev_path: str) -> str:
|
|
"""Return a /dev/serial/by-id match for given device if available."""
|
|
by_id = "/dev/serial/by-id"
|
|
if not os.path.isdir(by_id):
|
|
return dev_path
|
|
|
|
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
|
|
if os.path.realpath(path) == dev_path:
|
|
return path
|
|
return dev_path
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
"""Set up the USB Discovery integration."""
|
|
usb = await async_get_usb(hass)
|
|
usb_discovery = USBDiscovery(hass, usb)
|
|
await usb_discovery.async_setup()
|
|
hass.data[DOMAIN] = usb_discovery
|
|
websocket_api.async_register_command(hass, websocket_usb_scan)
|
|
|
|
return True
|
|
|
|
|
|
def _fnmatch_lower(name: str | None, pattern: str) -> bool:
|
|
"""Match a lowercase version of the name."""
|
|
if name is None:
|
|
return False
|
|
return fnmatch.fnmatch(name.lower(), pattern)
|
|
|
|
|
|
def _is_matching(device: USBDevice, matcher: USBMatcher | USBCallbackMatcher) -> bool:
|
|
"""Return True if a device matches."""
|
|
if "vid" in matcher and device.vid != matcher["vid"]:
|
|
return False
|
|
if "pid" in matcher and device.pid != matcher["pid"]:
|
|
return False
|
|
if "serial_number" in matcher and not _fnmatch_lower(
|
|
device.serial_number, matcher["serial_number"]
|
|
):
|
|
return False
|
|
if "manufacturer" in matcher and not _fnmatch_lower(
|
|
device.manufacturer, matcher["manufacturer"]
|
|
):
|
|
return False
|
|
if "description" in matcher and not _fnmatch_lower(
|
|
device.description, matcher["description"]
|
|
):
|
|
return False
|
|
return True
|
|
|
|
|
|
class USBDiscovery:
|
|
"""Manage USB Discovery."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass: HomeAssistant,
|
|
usb: list[USBMatcher],
|
|
) -> None:
|
|
"""Init USB Discovery."""
|
|
self.hass = hass
|
|
self.usb = usb
|
|
self.seen: set[tuple[str, ...]] = set()
|
|
self.observer_active = False
|
|
self._request_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
|
|
self._add_remove_debouncer: Debouncer[Coroutine[Any, Any, None]] | None = None
|
|
self._request_callbacks: list[CALLBACK_TYPE] = []
|
|
self.initial_scan_done = False
|
|
self._initial_scan_callbacks: list[CALLBACK_TYPE] = []
|
|
self._port_event_callbacks: set[PORT_EVENT_CALLBACK_TYPE] = set()
|
|
self._last_processed_devices: set[USBDevice] = set()
|
|
self._scan_lock = asyncio.Lock()
|
|
|
|
async def async_setup(self) -> None:
|
|
"""Set up USB Discovery."""
|
|
if self._async_supports_monitoring():
|
|
await self._async_start_monitor()
|
|
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start)
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
|
|
|
|
async def async_start(self, event: Event) -> None:
|
|
"""Start USB Discovery and run a manual scan."""
|
|
await self._async_scan_serial()
|
|
|
|
@hass_callback
|
|
def async_stop(self, event: Event) -> None:
|
|
"""Stop USB Discovery."""
|
|
if self._request_debouncer:
|
|
self._request_debouncer.async_shutdown()
|
|
|
|
@hass_callback
|
|
def _async_supports_monitoring(self) -> bool:
|
|
return sys.platform == "linux"
|
|
|
|
async def _async_start_monitor(self) -> None:
|
|
"""Start monitoring hardware."""
|
|
try:
|
|
await self._async_start_aiousbwatcher()
|
|
except InotifyNotAvailableError as ex:
|
|
_LOGGER.info(
|
|
"Falling back to periodic filesystem polling for development, aiousbwatcher "
|
|
"is not available on this system: %s",
|
|
ex,
|
|
)
|
|
self._async_start_monitor_polling()
|
|
|
|
@hass_callback
|
|
def _async_start_monitor_polling(self) -> None:
|
|
"""Start monitoring hardware with polling (for development only!)."""
|
|
|
|
async def _scan(event_time: datetime) -> None:
|
|
await self._async_scan_serial()
|
|
|
|
stop_callback = async_track_time_interval(
|
|
self.hass, _scan, POLLING_MONITOR_SCAN_PERIOD
|
|
)
|
|
|
|
@hass_callback
|
|
def _stop_polling(event: Event) -> None:
|
|
stop_callback()
|
|
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling)
|
|
|
|
async def _async_start_aiousbwatcher(self) -> None:
|
|
"""Start monitoring hardware with aiousbwatcher.
|
|
|
|
Returns True if successful.
|
|
"""
|
|
|
|
@hass_callback
|
|
def _usb_change_callback() -> None:
|
|
self._async_delayed_add_remove_scan()
|
|
|
|
watcher = AIOUSBWatcher()
|
|
watcher.async_register_callback(_usb_change_callback)
|
|
cancel = watcher.async_start()
|
|
|
|
@hass_callback
|
|
def _async_stop_watcher(event: Event) -> None:
|
|
cancel()
|
|
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_watcher)
|
|
|
|
self.observer_active = True
|
|
|
|
@hass_callback
|
|
def async_register_scan_request_callback(
|
|
self,
|
|
_callback: CALLBACK_TYPE,
|
|
) -> CALLBACK_TYPE:
|
|
"""Register a scan request callback."""
|
|
self._request_callbacks.append(_callback)
|
|
|
|
@hass_callback
|
|
def _async_remove_callback() -> None:
|
|
self._request_callbacks.remove(_callback)
|
|
|
|
return _async_remove_callback
|
|
|
|
@hass_callback
|
|
def async_register_initial_scan_callback(
|
|
self,
|
|
callback: CALLBACK_TYPE,
|
|
) -> CALLBACK_TYPE:
|
|
"""Register an initial scan callback."""
|
|
if self.initial_scan_done:
|
|
callback()
|
|
return lambda: None
|
|
|
|
self._initial_scan_callbacks.append(callback)
|
|
|
|
@hass_callback
|
|
def _async_remove_callback() -> None:
|
|
if callback not in self._initial_scan_callbacks:
|
|
return
|
|
self._initial_scan_callbacks.remove(callback)
|
|
|
|
return _async_remove_callback
|
|
|
|
@hass_callback
|
|
def async_register_port_event_callback(
|
|
self,
|
|
callback: PORT_EVENT_CALLBACK_TYPE,
|
|
) -> CALLBACK_TYPE:
|
|
"""Register a port event callback."""
|
|
self._port_event_callbacks.add(callback)
|
|
|
|
@hass_callback
|
|
def _async_remove_callback() -> None:
|
|
self._port_event_callbacks.discard(callback)
|
|
|
|
return _async_remove_callback
|
|
|
|
async def _async_process_discovered_usb_device(self, device: USBDevice) -> None:
|
|
"""Process a USB discovery."""
|
|
_LOGGER.debug("Discovered USB Device: %s", device)
|
|
device_tuple = dataclasses.astuple(device)
|
|
if device_tuple in self.seen:
|
|
return
|
|
self.seen.add(device_tuple)
|
|
|
|
matched = [matcher for matcher in self.usb if _is_matching(device, matcher)]
|
|
if not matched:
|
|
return
|
|
|
|
service_info: _UsbServiceInfo | None = None
|
|
|
|
sorted_by_most_targeted = sorted(matched, key=lambda item: -len(item))
|
|
most_matched_fields = len(sorted_by_most_targeted[0])
|
|
|
|
for matcher in sorted_by_most_targeted:
|
|
# If there is a less targeted match, we only
|
|
# want the most targeted match
|
|
if len(matcher) < most_matched_fields:
|
|
break
|
|
|
|
if service_info is None:
|
|
service_info = _UsbServiceInfo(
|
|
device=await self.hass.async_add_executor_job(
|
|
get_serial_by_id, device.device
|
|
),
|
|
vid=device.vid,
|
|
pid=device.pid,
|
|
serial_number=device.serial_number,
|
|
manufacturer=device.manufacturer,
|
|
description=device.description,
|
|
)
|
|
|
|
discovery_flow.async_create_flow(
|
|
self.hass,
|
|
matcher["domain"],
|
|
{"source": config_entries.SOURCE_USB},
|
|
service_info,
|
|
)
|
|
|
|
async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None:
|
|
"""Process each discovered port."""
|
|
_LOGGER.debug("Processing ports: %r", ports)
|
|
usb_devices = {
|
|
usb_device_from_port(port)
|
|
for port in ports
|
|
if port.vid is not None or port.pid is not None
|
|
}
|
|
_LOGGER.debug("USB devices: %r", usb_devices)
|
|
|
|
# CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and
|
|
# `/dev/cu.SLAB_USBtoUART*`. The former does not work and we should ignore them.
|
|
if sys.platform == "darwin":
|
|
silabs_serials = {
|
|
dev.serial_number
|
|
for dev in usb_devices
|
|
if dev.device.startswith("/dev/cu.SLAB_USBtoUART")
|
|
}
|
|
|
|
usb_devices = {
|
|
dev
|
|
for dev in usb_devices
|
|
if dev.serial_number not in silabs_serials
|
|
or (
|
|
dev.serial_number in silabs_serials
|
|
and dev.device.startswith("/dev/cu.SLAB_USBtoUART")
|
|
)
|
|
}
|
|
|
|
added_devices = usb_devices - self._last_processed_devices
|
|
removed_devices = self._last_processed_devices - usb_devices
|
|
self._last_processed_devices = usb_devices
|
|
|
|
_LOGGER.debug(
|
|
"Added devices: %r, removed devices: %r", added_devices, removed_devices
|
|
)
|
|
|
|
if added_devices or removed_devices:
|
|
for callback in self._port_event_callbacks.copy():
|
|
try:
|
|
callback(added_devices, removed_devices)
|
|
except Exception:
|
|
_LOGGER.exception("Error in USB port event callback")
|
|
|
|
for usb_device in usb_devices:
|
|
await self._async_process_discovered_usb_device(usb_device)
|
|
|
|
@hass_callback
|
|
def _async_delayed_add_remove_scan(self) -> None:
|
|
"""Request a serial scan after a debouncer delay."""
|
|
if not self._add_remove_debouncer:
|
|
self._add_remove_debouncer = Debouncer(
|
|
self.hass,
|
|
_LOGGER,
|
|
cooldown=ADD_REMOVE_SCAN_COOLDOWN,
|
|
immediate=False,
|
|
function=self._async_scan,
|
|
background=True,
|
|
)
|
|
self._add_remove_debouncer.async_schedule_call()
|
|
|
|
async def _async_scan_serial(self) -> None:
|
|
"""Scan serial ports."""
|
|
_LOGGER.debug("Executing comports scan")
|
|
async with self._scan_lock:
|
|
await self._async_process_ports(
|
|
await self.hass.async_add_executor_job(comports)
|
|
)
|
|
if self.initial_scan_done:
|
|
return
|
|
|
|
self.initial_scan_done = True
|
|
while self._initial_scan_callbacks:
|
|
self._initial_scan_callbacks.pop()()
|
|
|
|
async def _async_scan(self) -> None:
|
|
"""Scan for USB devices and notify callbacks to scan as well."""
|
|
for callback in self._request_callbacks:
|
|
callback()
|
|
await self._async_scan_serial()
|
|
|
|
async def async_request_scan(self) -> None:
|
|
"""Request a serial scan."""
|
|
if not self._request_debouncer:
|
|
self._request_debouncer = Debouncer(
|
|
self.hass,
|
|
_LOGGER,
|
|
cooldown=REQUEST_SCAN_COOLDOWN,
|
|
immediate=True,
|
|
function=self._async_scan,
|
|
background=True,
|
|
)
|
|
await self._request_debouncer.async_call()
|
|
|
|
|
|
@websocket_api.require_admin
|
|
@websocket_api.websocket_command({vol.Required("type"): "usb/scan"})
|
|
@websocket_api.async_response
|
|
async def websocket_usb_scan(
|
|
hass: HomeAssistant,
|
|
connection: ActiveConnection,
|
|
msg: dict[str, Any],
|
|
) -> None:
|
|
"""Scan for new usb devices."""
|
|
usb_discovery: USBDiscovery = hass.data[DOMAIN]
|
|
if not usb_discovery.observer_active:
|
|
await usb_discovery.async_request_scan()
|
|
connection.send_result(msg["id"])
|
|
|
|
|
|
# These can be removed if no deprecated constant are in this module anymore
|
|
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
|
|
__dir__ = partial(
|
|
dir_with_deprecated_constants, module_globals_keys=[*globals().keys()]
|
|
)
|
|
__all__ = all_with_deprecated_constants(globals())
|