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:
Pascal Vizeli 2021-01-29 17:17:41 +01:00 committed by GitHub
parent 19f5fba3aa
commit 18953f0b7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 236 additions and 42 deletions

View File

@ -128,7 +128,6 @@ class DockerAddon(DockerInterface):
def devices(self) -> Optional[List[str]]: def devices(self) -> Optional[List[str]]:
"""Return needed devices.""" """Return needed devices."""
devices = set() devices = set()
map_strict = False
def _create_dev(device_path: Path) -> str: def _create_dev(device_path: Path) -> str:
"""Add device to list.""" """Add device to list."""
@ -143,7 +142,6 @@ class DockerAddon(DockerInterface):
# Dynamic devices # Dynamic devices
for device in self.addon.devices: for device in self.addon.devices:
map_strict = True
_create_dev(device.path) _create_dev(device.path)
# Auto mapping UART devices / LINKS # Auto mapping UART devices / LINKS
@ -152,9 +150,6 @@ class DockerAddon(DockerInterface):
subsystem=UdevSubsystem.SERIAL subsystem=UdevSubsystem.SERIAL
): ):
_create_dev(device.path) _create_dev(device.path)
if map_strict or not device.by_id:
continue
_create_dev(device.by_id)
# Auto mapping GPIO # Auto mapping GPIO
if self.addon.with_gpio: if self.addon.with_gpio:
@ -340,6 +335,8 @@ class DockerAddon(DockerInterface):
# GPIO support # GPIO support
if self.addon.with_gpio and self.sys_hardware.helper.support_gpio: if self.addon.with_gpio and self.sys_hardware.helper.support_gpio:
for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"): 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"}}) volumes.update({gpio_path: {"bind": gpio_path, "mode": "rw"}})
# DeviceTree support # DeviceTree support
@ -355,13 +352,10 @@ class DockerAddon(DockerInterface):
# Host udev support # Host udev support
if self.addon.with_udev: 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 # USB support
if (self.addon.with_usb and self.sys_hardware.helper.usb_devices) or any( if self.addon.with_usb and self.sys_hardware.helper.support_usb:
self.sys_hardware.check_subsystem_parents(device, UdevSubsystem.USB)
for device in self.addon.devices
):
volumes.update({"/dev/bus/usb": {"bind": "/dev/bus/usb", "mode": "rw"}}) volumes.update({"/dev/bus/usb": {"bind": "/dev/bus/usb", "mode": "rw"}})
# Kernel Modules support # 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 return volumes
def _run(self) -> None: def _run(self) -> None:

View File

@ -32,6 +32,7 @@ class DockerAudio(DockerInterface, CoreSysAttributes):
volumes = { volumes = {
str(self.sys_config.path_extern_audio): {"bind": "/data", "mode": "rw"}, str(self.sys_config.path_extern_audio): {"bind": "/data", "mode": "rw"},
"/run/dbus": {"bind": "/run/dbus", "mode": "ro"}, "/run/dbus": {"bind": "/run/dbus", "mode": "ro"},
"/run/udev": {"bind": "/run/udev", "mode": "ro"},
} }
# Machine ID # Machine ID

View File

@ -8,7 +8,7 @@ import requests
from ..const import ENV_TIME, ENV_TOKEN, ENV_TOKEN_HASSIO, LABEL_MACHINE, MACHINE_ID from ..const import ENV_TIME, ENV_TOKEN, ENV_TOKEN_HASSIO, LABEL_MACHINE, MACHINE_ID
from ..exceptions import DockerError from ..exceptions import DockerError
from ..hardware.const import PolicyGroup from ..hardware.const import PolicyGroup, UdevSubsystem
from .interface import CommandReturn, DockerInterface from .interface import CommandReturn, DockerInterface
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -60,7 +60,10 @@ class DockerHomeAssistant(DockerInterface):
@property @property
def volumes(self) -> Dict[str, Dict[str, str]]: def volumes(self) -> Dict[str, Dict[str, str]]:
"""Return Volumes for the mount.""" """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 # Add folders
volumes.update( 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 return volumes
def _run(self) -> None: def _run(self) -> None:

View File

@ -546,3 +546,28 @@ class DockerInterface(CoreSysAttributes):
# Sort version and return latest version # Sort version and return latest version
available_version.sort(reverse=True) available_version.sort(reverse=True)
return available_version[0] 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)

View File

@ -19,8 +19,8 @@ class UdevSubsystem(str, Enum):
AUDIO = "sound" AUDIO = "sound"
VIDEO = "video4linux" VIDEO = "video4linux"
MEDIA = "media" MEDIA = "media"
GPIO = "GPIO" GPIO = "gpio"
GPIOMEM = "GPIOMEM" GPIOMEM = "gpiomem"
VCHIQ = "vchiq" VCHIQ = "vchiq"
GRAPHICS = "graphics" GRAPHICS = "graphics"
CEC = "CEC" CEC = "CEC"

View 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,
)

View File

@ -17,9 +17,6 @@ _LOGGER: logging.Logger = logging.getLogger(__name__)
_PROC_STAT: Path = Path("/proc/stat") _PROC_STAT: Path = Path("/proc/stat")
_RE_BOOT_TIME: re.Pattern = re.compile(r"btime (\d+)") _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)/.*") _RE_HIDE_SYSFS: re.Pattern = re.compile(r"/sys/devices/virtual/(?:tty|block)/.*")
@ -33,12 +30,17 @@ class HwHelper(CoreSysAttributes):
@property @property
def support_audio(self) -> bool: def support_audio(self) -> bool:
"""Return True if the system have audio support.""" """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 @property
def support_gpio(self) -> bool: def support_gpio(self) -> bool:
"""Return True if device support GPIOs.""" """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 @property
def last_boot(self) -> Optional[str]: def last_boot(self) -> Optional[str]:

View File

@ -9,6 +9,7 @@ from supervisor.hardware.const import UdevSubsystem
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import HardwareNotFound from ..exceptions import HardwareNotFound
from .container import HwContainer
from .data import Device from .data import Device
from .helper import HwHelper from .helper import HwHelper
from .monitor import HwMonitor from .monitor import HwMonitor
@ -29,6 +30,7 @@ class HardwareManager(CoreSysAttributes):
self._montior: HwMonitor = HwMonitor(coresys) self._montior: HwMonitor = HwMonitor(coresys)
self._helper: HwHelper = HwHelper(coresys) self._helper: HwHelper = HwHelper(coresys)
self._policy: HwPolicy = HwPolicy(coresys) self._policy: HwPolicy = HwPolicy(coresys)
self._container: HwContainer = HwContainer(coresys)
@property @property
def monitor(self) -> HwMonitor: def monitor(self) -> HwMonitor:
@ -45,6 +47,11 @@ class HardwareManager(CoreSysAttributes):
"""Return Hardware policy instance.""" """Return Hardware policy instance."""
return self._policy return self._policy
@property
def container(self) -> HwContainer:
"""Return Hardware container instance."""
return self._container
@property @property
def devices(self) -> List[Device]: def devices(self) -> List[Device]:
"""Return List of devices.""" """Return List of devices."""

View File

@ -1,5 +1,4 @@
"""Supervisor Hardware monitor based on udev.""" """Supervisor Hardware monitor based on udev."""
from datetime import timedelta
import logging import logging
from pathlib import Path from pathlib import Path
from pprint import pformat from pprint import pformat
@ -10,7 +9,6 @@ import pyudev
from ..const import CoreState from ..const import CoreState
from ..coresys import CoreSys, CoreSysAttributes from ..coresys import CoreSys, CoreSysAttributes
from ..resolution.const import UnhealthyReason from ..resolution.const import UnhealthyReason
from ..utils import AsyncCallFilter
from .const import UdevSubsystem from .const import UdevSubsystem
from .data import Device from .data import Device
@ -55,36 +53,74 @@ class HwMonitor(CoreSysAttributes):
_LOGGER.debug("Hardware monitor: %s - %s", action, pformat(device)) _LOGGER.debug("Hardware monitor: %s - %s", action, pformat(device))
self.sys_loop.call_soon_threadsafe(self._async_udev_events, action, 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.""" """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 # Update device List
if not device.device_node or self.sys_hardware.helper.hide_virtual_device( if not udev_device.device_node or self.sys_hardware.helper.hide_virtual_device(
device udev_device
): ):
return return
device = Device( device = Device(
device.sys_name, udev_device.sys_name,
Path(device.device_node), Path(udev_device.device_node),
Path(device.sys_path), Path(udev_device.sys_path),
device.subsystem, udev_device.subsystem,
[Path(node) for node in device.device_links], [Path(node) for node in udev_device.device_links],
{attr: device.properties[attr] for attr in device.properties}, {attr: udev_device.properties[attr] for attr in udev_device.properties},
) )
# Process the action # Update internal Database
if action == "add": if action == "add":
self.sys_hardware.update_device(device) self.sys_hardware.update_device(device)
if action == "remove": if action == "remove":
self.sys_hardware.delete_device(device) self.sys_hardware.delete_device(device)
@AsyncCallFilter(timedelta(seconds=5)) # Process device
def _action_sound(self, device: pyudev.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.""" """Process sound actions."""
_LOGGER.info("Detecting changed audio hardware") _LOGGER.info("Detecting changed audio hardware - %s", device.path)
self.sys_loop.call_later(5, self.sys_create_task, self.sys_host.sound.update()) 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),
)

View File

@ -9,7 +9,7 @@ from .data import Device
_LOGGER: logging.Logger = logging.getLogger(__name__) _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.UART: [204, 188, 166, 244],
PolicyGroup.GPIO: [254, 245], PolicyGroup.GPIO: [254, 245],
PolicyGroup.VIDEO: [239, 29, 81, 251, 242, 226], PolicyGroup.VIDEO: [239, 29, 81, 251, 242, 226],
@ -26,7 +26,7 @@ class HwPolicy(CoreSysAttributes):
def get_cgroups_rules(self, group: PolicyGroup) -> List[str]: def get_cgroups_rules(self, group: PolicyGroup) -> List[str]:
"""Generate cgroups rules for a policy group.""" """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: def get_cgroups_rule(self, device: Device) -> str:
"""Generate a cgroups rule for given device.""" """Generate a cgroups rule for given device."""

View File

@ -23,6 +23,42 @@ def test_have_audio(coresys):
assert coresys.hardware.helper.support_audio 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): def test_hide_virtual_device(coresys):
"""Test hidding virtual devices.""" """Test hidding virtual devices."""
udev_device = MagicMock() udev_device = MagicMock()