diff --git a/supervisor/docker/addon.py b/supervisor/docker/addon.py index bb05f48c5..ea5357ee4 100644 --- a/supervisor/docker/addon.py +++ b/supervisor/docker/addon.py @@ -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: diff --git a/supervisor/docker/audio.py b/supervisor/docker/audio.py index c7f5c2492..d4f0ae0b2 100644 --- a/supervisor/docker/audio.py +++ b/supervisor/docker/audio.py @@ -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 diff --git a/supervisor/docker/homeassistant.py b/supervisor/docker/homeassistant.py index ff5ade52a..618d73ada 100644 --- a/supervisor/docker/homeassistant.py +++ b/supervisor/docker/homeassistant.py @@ -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: diff --git a/supervisor/docker/interface.py b/supervisor/docker/interface.py index 7aa4eebd9..75690155f 100644 --- a/supervisor/docker/interface.py +++ b/supervisor/docker/interface.py @@ -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) diff --git a/supervisor/hardware/const.py b/supervisor/hardware/const.py index 6ef14b4bc..1a1709f38 100644 --- a/supervisor/hardware/const.py +++ b/supervisor/hardware/const.py @@ -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" diff --git a/supervisor/hardware/container.py b/supervisor/hardware/container.py new file mode 100644 index 000000000..163203a78 --- /dev/null +++ b/supervisor/hardware/container.py @@ -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, + ) diff --git a/supervisor/hardware/helper.py b/supervisor/hardware/helper.py index 9efc0bef5..45844e922 100644 --- a/supervisor/hardware/helper.py +++ b/supervisor/hardware/helper.py @@ -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]: diff --git a/supervisor/hardware/module.py b/supervisor/hardware/module.py index f9846a0c6..cd4723dc9 100644 --- a/supervisor/hardware/module.py +++ b/supervisor/hardware/module.py @@ -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.""" diff --git a/supervisor/hardware/monitor.py b/supervisor/hardware/monitor.py index 1ece408b1..c6219241f 100644 --- a/supervisor/hardware/monitor.py +++ b/supervisor/hardware/monitor.py @@ -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), + ) diff --git a/supervisor/hardware/policy.py b/supervisor/hardware/policy.py index ace663f61..b4bbf57dd 100644 --- a/supervisor/hardware/policy.py +++ b/supervisor/hardware/policy.py @@ -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.""" diff --git a/tests/hardware/test_helper.py b/tests/hardware/test_helper.py index e97d03747..1a6fa07f6 100644 --- a/tests/hardware/test_helper.py +++ b/tests/hardware/test_helper.py @@ -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()