mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-28 11:36:32 +00:00
plug & play devices (#2482)
* Add support for plug&play container & full udev passtrhough * plug & play devices * this hack is not needed anymore * Fix GPIO issue * Apply suggestions from code review Co-authored-by: Franck Nijhof <git@frenck.dev> Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
parent
19f5fba3aa
commit
18953f0b7c
@ -128,7 +128,6 @@ class DockerAddon(DockerInterface):
|
||||
def devices(self) -> Optional[List[str]]:
|
||||
"""Return needed devices."""
|
||||
devices = set()
|
||||
map_strict = False
|
||||
|
||||
def _create_dev(device_path: Path) -> str:
|
||||
"""Add device to list."""
|
||||
@ -143,7 +142,6 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
# Dynamic devices
|
||||
for device in self.addon.devices:
|
||||
map_strict = True
|
||||
_create_dev(device.path)
|
||||
|
||||
# Auto mapping UART devices / LINKS
|
||||
@ -152,9 +150,6 @@ class DockerAddon(DockerInterface):
|
||||
subsystem=UdevSubsystem.SERIAL
|
||||
):
|
||||
_create_dev(device.path)
|
||||
if map_strict or not device.by_id:
|
||||
continue
|
||||
_create_dev(device.by_id)
|
||||
|
||||
# Auto mapping GPIO
|
||||
if self.addon.with_gpio:
|
||||
@ -340,6 +335,8 @@ class DockerAddon(DockerInterface):
|
||||
# GPIO support
|
||||
if self.addon.with_gpio and self.sys_hardware.helper.support_gpio:
|
||||
for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"):
|
||||
if not Path(gpio_path).exists():
|
||||
continue
|
||||
volumes.update({gpio_path: {"bind": gpio_path, "mode": "rw"}})
|
||||
|
||||
# DeviceTree support
|
||||
@ -355,13 +352,10 @@ class DockerAddon(DockerInterface):
|
||||
|
||||
# Host udev support
|
||||
if self.addon.with_udev:
|
||||
volumes.update({"/run/dbus": {"bind": "/run/dbus", "mode": "ro"}})
|
||||
volumes.update({"/run/udev": {"bind": "/run/udev", "mode": "ro"}})
|
||||
|
||||
# USB support
|
||||
if (self.addon.with_usb and self.sys_hardware.helper.usb_devices) or any(
|
||||
self.sys_hardware.check_subsystem_parents(device, UdevSubsystem.USB)
|
||||
for device in self.addon.devices
|
||||
):
|
||||
if self.addon.with_usb and self.sys_hardware.helper.support_usb:
|
||||
volumes.update({"/dev/bus/usb": {"bind": "/dev/bus/usb", "mode": "rw"}})
|
||||
|
||||
# Kernel Modules support
|
||||
@ -397,6 +391,13 @@ class DockerAddon(DockerInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# With UART
|
||||
if self.addon.with_uart:
|
||||
mount = self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.SERIAL)
|
||||
volumes.update(
|
||||
{str(mount.as_posix()): {"bind": str(mount.as_posix()), "mode": "ro"}}
|
||||
)
|
||||
|
||||
return volumes
|
||||
|
||||
def _run(self) -> None:
|
||||
|
@ -32,6 +32,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
|
||||
volumes = {
|
||||
str(self.sys_config.path_extern_audio): {"bind": "/data", "mode": "rw"},
|
||||
"/run/dbus": {"bind": "/run/dbus", "mode": "ro"},
|
||||
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
|
||||
}
|
||||
|
||||
# Machine ID
|
||||
|
@ -8,7 +8,7 @@ import requests
|
||||
|
||||
from ..const import ENV_TIME, ENV_TOKEN, ENV_TOKEN_HASSIO, LABEL_MACHINE, MACHINE_ID
|
||||
from ..exceptions import DockerError
|
||||
from ..hardware.const import PolicyGroup
|
||||
from ..hardware.const import PolicyGroup, UdevSubsystem
|
||||
from .interface import CommandReturn, DockerInterface
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
@ -60,7 +60,10 @@ class DockerHomeAssistant(DockerInterface):
|
||||
@property
|
||||
def volumes(self) -> Dict[str, Dict[str, str]]:
|
||||
"""Return Volumes for the mount."""
|
||||
volumes = {"/run/dbus": {"bind": "/run/dbus", "mode": "ro"}}
|
||||
volumes = {
|
||||
"/run/dbus": {"bind": "/run/dbus", "mode": "ro"},
|
||||
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
|
||||
}
|
||||
|
||||
# Add folders
|
||||
volumes.update(
|
||||
@ -103,6 +106,16 @@ class DockerHomeAssistant(DockerInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# udev helper
|
||||
for mount in (
|
||||
self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.SERIAL),
|
||||
self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.DISK),
|
||||
self.sys_hardware.container.get_udev_id_mount(UdevSubsystem.INPUT),
|
||||
):
|
||||
volumes.update(
|
||||
{str(mount.as_posix()): {"bind": str(mount.as_posix()), "mode": "ro"}}
|
||||
)
|
||||
|
||||
return volumes
|
||||
|
||||
def _run(self) -> None:
|
||||
|
@ -546,3 +546,28 @@ class DockerInterface(CoreSysAttributes):
|
||||
# Sort version and return latest version
|
||||
available_version.sort(reverse=True)
|
||||
return available_version[0]
|
||||
|
||||
@process_lock
|
||||
def run_inside(self, command: str) -> Awaitable[CommandReturn]:
|
||||
"""Execute a command inside Docker container."""
|
||||
return self.sys_run_in_executor(self._run_inside, command)
|
||||
|
||||
def _run_inside(self, command: str) -> CommandReturn:
|
||||
"""Execute a command inside Docker container.
|
||||
|
||||
Need run inside executor.
|
||||
"""
|
||||
try:
|
||||
docker_container = self.sys_docker.containers.get(self.name)
|
||||
except docker.errors.NotFound:
|
||||
raise DockerNotFound() from None
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
|
||||
# Execute
|
||||
try:
|
||||
code, output = docker_container.exec_run(command)
|
||||
except (docker.errors.DockerException, requests.RequestException) as err:
|
||||
raise DockerError() from err
|
||||
|
||||
return CommandReturn(code, output)
|
||||
|
@ -19,8 +19,8 @@ class UdevSubsystem(str, Enum):
|
||||
AUDIO = "sound"
|
||||
VIDEO = "video4linux"
|
||||
MEDIA = "media"
|
||||
GPIO = "GPIO"
|
||||
GPIOMEM = "GPIOMEM"
|
||||
GPIO = "gpio"
|
||||
GPIOMEM = "gpiomem"
|
||||
VCHIQ = "vchiq"
|
||||
GRAPHICS = "graphics"
|
||||
CEC = "CEC"
|
||||
|
73
supervisor/hardware/container.py
Normal file
73
supervisor/hardware/container.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""Handle udev for container."""
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from supervisor.const import AddonState
|
||||
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..docker.interface import DockerInterface
|
||||
from ..exceptions import DockerError, DockerNotFound, HardwareError
|
||||
from .const import UdevSubsystem
|
||||
from .data import Device
|
||||
|
||||
_UDEV_BY_ID = {
|
||||
UdevSubsystem.SERIAL: Path("/dev/serial/by-id"),
|
||||
UdevSubsystem.DISK: Path("/dev/disk"),
|
||||
UdevSubsystem.INPUT: Path("/dev/input/by-id"),
|
||||
}
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HwContainer(CoreSysAttributes):
|
||||
"""Representation of an interface to udev / container."""
|
||||
|
||||
def __init__(self, coresys: CoreSys):
|
||||
"""Init hardware object."""
|
||||
self.coresys = coresys
|
||||
|
||||
def get_udev_id_mount(self, subystem: UdevSubsystem) -> Optional[Path]:
|
||||
"""Return mount path for udev device by-id path."""
|
||||
return _UDEV_BY_ID.get(subystem)
|
||||
|
||||
async def process_serial_device(self, device: Device) -> None:
|
||||
"""Process a new Serial device."""
|
||||
# Add to all needed add-ons
|
||||
for addon in self.sys_addons.installed:
|
||||
if addon.state != AddonState.STARTED or addon.with_uart:
|
||||
continue
|
||||
with suppress(HardwareError):
|
||||
await self._create_device(addon.instance, device)
|
||||
|
||||
# Process on Home Assistant Core
|
||||
with suppress(HardwareError):
|
||||
await self._create_device(self.sys_homeassistant.core.instance, device)
|
||||
|
||||
async def process_input_device(self, device: Device) -> None:
|
||||
"""Process a new Serial device."""
|
||||
# Process on Home Assistant Core
|
||||
with suppress(HardwareError):
|
||||
await self._create_device(self.sys_homeassistant.core.instance, device)
|
||||
|
||||
async def _create_device(self, instance: DockerInterface, device: Device) -> None:
|
||||
"""Add device into container."""
|
||||
try:
|
||||
answer = await instance.run_inside(
|
||||
f'sh -c "mknod -m 660 {device.path.as_posix()} c {device.cgroups_major} {device.cgroups_minor}"'
|
||||
)
|
||||
except DockerNotFound:
|
||||
return
|
||||
except DockerError as err:
|
||||
_LOGGER.warning("Can't add new device %s to %s", device.path, instance.name)
|
||||
raise HardwareError() from err
|
||||
|
||||
if answer.exit_code == 0:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Container response with '%s' during process %s",
|
||||
answer.output.encode(),
|
||||
device.path,
|
||||
)
|
@ -17,9 +17,6 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
_PROC_STAT: Path = Path("/proc/stat")
|
||||
_RE_BOOT_TIME: re.Pattern = re.compile(r"btime (\d+)")
|
||||
|
||||
_GPIO_DEVICES: Path = Path("/sys/class/gpio")
|
||||
_SOC_DEVICES: Path = Path("/sys/devices/platform/soc")
|
||||
|
||||
_RE_HIDE_SYSFS: re.Pattern = re.compile(r"/sys/devices/virtual/(?:tty|block)/.*")
|
||||
|
||||
|
||||
@ -33,12 +30,17 @@ class HwHelper(CoreSysAttributes):
|
||||
@property
|
||||
def support_audio(self) -> bool:
|
||||
"""Return True if the system have audio support."""
|
||||
return len(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.AUDIO))
|
||||
return bool(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.AUDIO))
|
||||
|
||||
@property
|
||||
def support_gpio(self) -> bool:
|
||||
"""Return True if device support GPIOs."""
|
||||
return _SOC_DEVICES.exists() and _GPIO_DEVICES.exists()
|
||||
return bool(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.GPIO))
|
||||
|
||||
@property
|
||||
def support_usb(self) -> bool:
|
||||
"""Return True if the device have USB ports."""
|
||||
return bool(self.sys_hardware.filter_devices(subsystem=UdevSubsystem.USB))
|
||||
|
||||
@property
|
||||
def last_boot(self) -> Optional[str]:
|
||||
|
@ -9,6 +9,7 @@ from supervisor.hardware.const import UdevSubsystem
|
||||
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..exceptions import HardwareNotFound
|
||||
from .container import HwContainer
|
||||
from .data import Device
|
||||
from .helper import HwHelper
|
||||
from .monitor import HwMonitor
|
||||
@ -29,6 +30,7 @@ class HardwareManager(CoreSysAttributes):
|
||||
self._montior: HwMonitor = HwMonitor(coresys)
|
||||
self._helper: HwHelper = HwHelper(coresys)
|
||||
self._policy: HwPolicy = HwPolicy(coresys)
|
||||
self._container: HwContainer = HwContainer(coresys)
|
||||
|
||||
@property
|
||||
def monitor(self) -> HwMonitor:
|
||||
@ -45,6 +47,11 @@ class HardwareManager(CoreSysAttributes):
|
||||
"""Return Hardware policy instance."""
|
||||
return self._policy
|
||||
|
||||
@property
|
||||
def container(self) -> HwContainer:
|
||||
"""Return Hardware container instance."""
|
||||
return self._container
|
||||
|
||||
@property
|
||||
def devices(self) -> List[Device]:
|
||||
"""Return List of devices."""
|
||||
|
@ -1,5 +1,4 @@
|
||||
"""Supervisor Hardware monitor based on udev."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pprint import pformat
|
||||
@ -10,7 +9,6 @@ import pyudev
|
||||
from ..const import CoreState
|
||||
from ..coresys import CoreSys, CoreSysAttributes
|
||||
from ..resolution.const import UnhealthyReason
|
||||
from ..utils import AsyncCallFilter
|
||||
from .const import UdevSubsystem
|
||||
from .data import Device
|
||||
|
||||
@ -55,36 +53,74 @@ class HwMonitor(CoreSysAttributes):
|
||||
_LOGGER.debug("Hardware monitor: %s - %s", action, pformat(device))
|
||||
self.sys_loop.call_soon_threadsafe(self._async_udev_events, action, device)
|
||||
|
||||
def _async_udev_events(self, action: str, device: pyudev.Device):
|
||||
def _async_udev_events(self, action: str, udev_device: pyudev.Device):
|
||||
"""Incomming events from udev into loop."""
|
||||
if self.sys_core.state in (CoreState.RUNNING, CoreState.FREEZE):
|
||||
# Sound changes
|
||||
if device.subsystem == UdevSubsystem.AUDIO:
|
||||
self._action_sound(device)
|
||||
|
||||
# Update device List
|
||||
if not device.device_node or self.sys_hardware.helper.hide_virtual_device(
|
||||
device
|
||||
if not udev_device.device_node or self.sys_hardware.helper.hide_virtual_device(
|
||||
udev_device
|
||||
):
|
||||
return
|
||||
|
||||
device = Device(
|
||||
device.sys_name,
|
||||
Path(device.device_node),
|
||||
Path(device.sys_path),
|
||||
device.subsystem,
|
||||
[Path(node) for node in device.device_links],
|
||||
{attr: device.properties[attr] for attr in device.properties},
|
||||
udev_device.sys_name,
|
||||
Path(udev_device.device_node),
|
||||
Path(udev_device.sys_path),
|
||||
udev_device.subsystem,
|
||||
[Path(node) for node in udev_device.device_links],
|
||||
{attr: udev_device.properties[attr] for attr in udev_device.properties},
|
||||
)
|
||||
|
||||
# Process the action
|
||||
# Update internal Database
|
||||
if action == "add":
|
||||
self.sys_hardware.update_device(device)
|
||||
if action == "remove":
|
||||
self.sys_hardware.delete_device(device)
|
||||
|
||||
@AsyncCallFilter(timedelta(seconds=5))
|
||||
def _action_sound(self, device: pyudev.Device):
|
||||
# Process device
|
||||
if self.sys_core.state in (CoreState.RUNNING, CoreState.FREEZE):
|
||||
# New Sound device
|
||||
if device.subsystem == UdevSubsystem.AUDIO and action == "add":
|
||||
self._action_sound_add(device)
|
||||
|
||||
# New serial device
|
||||
if device.subsystem == UdevSubsystem.SERIAL and action == "add":
|
||||
self._action_tty_add(device)
|
||||
|
||||
# New input device
|
||||
if device.subsystem == UdevSubsystem.INPUT and action == "add":
|
||||
self._action_input_add(device)
|
||||
|
||||
def _action_sound_add(self, device: Device):
|
||||
"""Process sound actions."""
|
||||
_LOGGER.info("Detecting changed audio hardware")
|
||||
self.sys_loop.call_later(5, self.sys_create_task, self.sys_host.sound.update())
|
||||
_LOGGER.info("Detecting changed audio hardware - %s", device.path)
|
||||
self.sys_loop.call_later(2, self.sys_create_task, self.sys_host.sound.update())
|
||||
|
||||
def _action_tty_add(self, device: Device):
|
||||
"""Process tty actions."""
|
||||
_LOGGER.info(
|
||||
"Detecting changed serial hardware %s - %s", device.path, device.by_id
|
||||
)
|
||||
if not device.by_id:
|
||||
return
|
||||
|
||||
# Start process TTY
|
||||
self.sys_loop.call_later(
|
||||
2,
|
||||
self.sys_create_task,
|
||||
self.sys_hardware.container.process_serial_device(device),
|
||||
)
|
||||
|
||||
def _action_input_add(self, device: Device):
|
||||
"""Process input actions."""
|
||||
_LOGGER.info(
|
||||
"Detecting changed serial hardware %s - %s", device.path, device.by_id
|
||||
)
|
||||
if not device.by_id:
|
||||
return
|
||||
|
||||
# Start process input
|
||||
self.sys_loop.call_later(
|
||||
2,
|
||||
self.sys_create_task,
|
||||
self.sys_hardware.container.process_serial_device(device),
|
||||
)
|
||||
|
@ -9,7 +9,7 @@ from .data import Device
|
||||
_LOGGER: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
GROUP_CGROUPS: Dict[PolicyGroup, List[int]] = {
|
||||
_GROUP_CGROUPS: Dict[PolicyGroup, List[int]] = {
|
||||
PolicyGroup.UART: [204, 188, 166, 244],
|
||||
PolicyGroup.GPIO: [254, 245],
|
||||
PolicyGroup.VIDEO: [239, 29, 81, 251, 242, 226],
|
||||
@ -26,7 +26,7 @@ class HwPolicy(CoreSysAttributes):
|
||||
|
||||
def get_cgroups_rules(self, group: PolicyGroup) -> List[str]:
|
||||
"""Generate cgroups rules for a policy group."""
|
||||
return [f"c {dev}:* rwm" for dev in GROUP_CGROUPS.get(group, [])]
|
||||
return [f"c {dev}:* rwm" for dev in _GROUP_CGROUPS.get(group, [])]
|
||||
|
||||
def get_cgroups_rule(self, device: Device) -> str:
|
||||
"""Generate a cgroups rule for given device."""
|
||||
|
@ -23,6 +23,42 @@ def test_have_audio(coresys):
|
||||
assert coresys.hardware.helper.support_audio
|
||||
|
||||
|
||||
def test_have_usb(coresys):
|
||||
"""Test usb device filter."""
|
||||
assert not coresys.hardware.helper.support_usb
|
||||
|
||||
coresys.hardware.update_device(
|
||||
Device(
|
||||
"sda",
|
||||
Path("/dev/sda"),
|
||||
Path("/sys/bus/usb/000"),
|
||||
"usb",
|
||||
[],
|
||||
{"ID_NAME": "xy"},
|
||||
)
|
||||
)
|
||||
|
||||
assert coresys.hardware.helper.support_usb
|
||||
|
||||
|
||||
def test_have_gpio(coresys):
|
||||
"""Test usb device filter."""
|
||||
assert not coresys.hardware.helper.support_gpio
|
||||
|
||||
coresys.hardware.update_device(
|
||||
Device(
|
||||
"sda",
|
||||
Path("/dev/sda"),
|
||||
Path("/sys/bus/usb/000"),
|
||||
"gpio",
|
||||
[],
|
||||
{"ID_NAME": "xy"},
|
||||
)
|
||||
)
|
||||
|
||||
assert coresys.hardware.helper.support_gpio
|
||||
|
||||
|
||||
def test_hide_virtual_device(coresys):
|
||||
"""Test hidding virtual devices."""
|
||||
udev_device = MagicMock()
|
||||
|
Loading…
x
Reference in New Issue
Block a user