From 31001280c8118a76be557c705b1aa39ba069e5fb Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 9 Aug 2021 19:30:26 +0200 Subject: [PATCH] Add bus system for handling events hw/pulse/docker (#2999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add bus system for handling events hw/pulse/docker * give sound update back * register events * Add tests * Add debug logger * Update supervisor/coresys.py Co-authored-by: Joakim Sørensen Co-authored-by: Joakim Sørensen --- supervisor/bootstrap.py | 4 +- supervisor/bus.py | 43 ++++++++++ supervisor/const.py | 7 ++ supervisor/coresys.py | 25 +++++- supervisor/hardware/monitor.py | 74 +++--------------- supervisor/host/__init__.py | 125 +---------------------------- supervisor/host/manager.py | 138 +++++++++++++++++++++++++++++++++ tests/test_bus.py | 44 +++++++++++ 8 files changed, 271 insertions(+), 189 deletions(-) create mode 100644 supervisor/bus.py create mode 100644 supervisor/host/manager.py create mode 100644 tests/test_bus.py diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 30041c827..cfd83314e 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -21,6 +21,7 @@ from .api import RestAPI from .arch import CpuArch from .auth import Auth from .backups.manager import BackupManager +from .bus import Bus from .const import ( ENV_HOMEASSISTANT_REPOSITORY, ENV_SUPERVISOR_MACHINE, @@ -39,7 +40,7 @@ from .discovery import Discovery from .hardware.module import HardwareManager from .hassos import HassOS from .homeassistant.module import HomeAssistant -from .host import HostManager +from .host.manager import HostManager from .ingress import Ingress from .misc.filter import filter_data from .misc.scheduler import Scheduler @@ -83,6 +84,7 @@ async def initialize_coresys() -> CoreSys: coresys.hassos = HassOS(coresys) coresys.scheduler = Scheduler(coresys) coresys.security = Security(coresys) + coresys.bus = Bus(coresys) # diagnostics setup_diagnostics(coresys) diff --git a/supervisor/bus.py b/supervisor/bus.py new file mode 100644 index 000000000..83534d1b1 --- /dev/null +++ b/supervisor/bus.py @@ -0,0 +1,43 @@ +"""Bus event system.""" +from __future__ import annotations + +import logging +from typing import Any, Awaitable, Callable, Dict, List + +import attr + +from .const import BusEvent +from .coresys import CoreSys, CoreSysAttributes + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class Bus(CoreSysAttributes): + """Handle Bus event system.""" + + def __init__(self, coresys: CoreSys): + """Initialize bus backend.""" + self.coresys = coresys + self._listeners: Dict[BusEvent, List[EventListener]] = {} + + def register_event( + self, event: BusEvent, callback: Callable[[Any], Awaitable[None]] + ) -> EventListener: + """Register callback for an event.""" + listener = EventListener(event, callback) + self._listeners.setdefault(event, []).append(listener) + return listener + + def fire_event(self, event: BusEvent, reference: Any) -> None: + """Fire an event to the bus.""" + _LOGGER.debug("Fire event '%s' with '%s'", event, reference) + for listener in self._listeners.get(event, []): + self.sys_create_task(listener.callback(reference)) + + +@attr.s(slots=True, frozen=True) +class EventListener: + """Event listener.""" + + event_type: BusEvent = attr.ib() + callback: Callable[[Any], Awaitable[None]] = attr.ib() diff --git a/supervisor/const.py b/supervisor/const.py index 6e502f7a9..7704b3d4e 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -446,3 +446,10 @@ class HostFeature(str, Enum): SERVICES = "services" SHUTDOWN = "shutdown" TIMEDATE = "timedate" + + +class BusEvent(str, Enum): + """Bus event type.""" + + HARDWARE_NEW_DEVICE = "hardware_new_device" + HARDWARE_REMOVE_DEVICE = "hardware_remove_device" diff --git a/supervisor/coresys.py b/supervisor/coresys.py index 719001cde..0e8031283 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -28,7 +28,7 @@ if TYPE_CHECKING: from .hardware.module import HardwareManager from .hassos import HassOS from .homeassistant.module import HomeAssistant - from .host import HostManager + from .host.manager import HostManager from .ingress import Ingress from .jobs import JobManager from .misc.scheduler import Scheduler @@ -40,6 +40,7 @@ if TYPE_CHECKING: from .store import StoreManager from .supervisor import Supervisor from .updater import Updater + from .bus import Bus T = TypeVar("T") @@ -88,6 +89,7 @@ class CoreSys: self._resolution: Optional[ResolutionManager] = None self._jobs: Optional[JobManager] = None self._security: Optional[Security] = None + self._bus: Optional[Bus] = None # Set default header for aiohttp self._websession._default_headers = MappingProxyType( @@ -352,6 +354,20 @@ class CoreSys: raise RuntimeError("DBusManager already set!") self._dbus = value + @property + def bus(self) -> Bus: + """Return Bus object.""" + if self._bus is None: + raise RuntimeError("Bus not set!") + return self._bus + + @bus.setter + def bus(self, value: Bus) -> None: + """Set a Bus object.""" + if self._bus: + raise RuntimeError("Bus already set!") + self._bus = value + @property def host(self) -> HostManager: """Return HostManager object.""" @@ -540,9 +556,14 @@ class CoreSysAttributes: @property def sys_core(self) -> Core: - """Return core object.""" + """Return Core object.""" return self.coresys.core + @property + def sys_bus(self) -> Bus: + """Return Bus object.""" + return self.coresys.bus + @property def sys_plugins(self) -> PluginManager: """Return PluginManager object.""" diff --git a/supervisor/hardware/monitor.py b/supervisor/hardware/monitor.py index b3712df6f..1705570e3 100644 --- a/supervisor/hardware/monitor.py +++ b/supervisor/hardware/monitor.py @@ -7,11 +7,11 @@ from typing import Optional import pyudev -from ..const import CoreState +from ..const import BusEvent from ..coresys import CoreSys, CoreSysAttributes from ..exceptions import HardwareNotFound from ..resolution.const import UnhealthyReason -from .const import HardwareAction, PolicyGroup, UdevKernelAction, UdevSubsystem +from .const import HardwareAction, UdevKernelAction from .data import Device _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -70,8 +70,8 @@ class HwMonitor(CoreSysAttributes): ): return - hw_action = None - device = None + hw_action: Optional[HardwareAction] = None + device: Optional[Device] = None ## # Remove @@ -121,65 +121,15 @@ class HwMonitor(CoreSysAttributes): if kernel.action == UdevKernelAction.ADD: hw_action = HardwareAction.ADD - # Process Action - if ( - device - and hw_action - and self.sys_core.state in (CoreState.RUNNING, CoreState.FREEZE) - ): - # New Sound device - if device.subsystem == UdevSubsystem.AUDIO: - await self._action_sound(device, hw_action) - - # serial device - elif device.subsystem == UdevSubsystem.SERIAL: - await self._action_tty(device, hw_action) - - # input device - elif device.subsystem == UdevSubsystem.INPUT: - await self._action_input(device, hw_action) - - # USB device - elif device.subsystem == UdevSubsystem.USB: - await self._action_usb(device, hw_action) - - # GPIO device - elif device.subsystem == UdevSubsystem.GPIO: - await self._action_gpio(device, hw_action) - - async def _action_sound(self, device: Device, action: HardwareAction): - """Process sound actions.""" - if not self.sys_hardware.policy.is_match_cgroup(PolicyGroup.AUDIO, device): - return - _LOGGER.info("Detecting %s audio hardware - %s", action, device.path) - await self.sys_create_task(self.sys_host.sound.update()) - - async def _action_tty(self, device: Device, action: HardwareAction): - """Process tty actions.""" - if not device.by_id or not self.sys_hardware.policy.is_match_cgroup( - PolicyGroup.UART, device - ): + # Ignore event for future processing + if device is None or hw_action is None: return _LOGGER.info( - "Detecting %s serial hardware %s - %s", action, device.path, device.by_id + "Detecting %s hardware %s - %s", hw_action, device.path, device.by_id ) - async def _action_input(self, device: Device, action: HardwareAction): - """Process input actions.""" - if not device.by_id: - return - _LOGGER.info( - "Detecting %s serial hardware %s - %s", action, device.path, device.by_id - ) - - async def _action_usb(self, device: Device, action: HardwareAction): - """Process usb actions.""" - if not self.sys_hardware.policy.is_match_cgroup(PolicyGroup.USB, device): - return - _LOGGER.info("Detecting %s usb hardware %s", action, device.path) - - async def _action_gpio(self, device: Device, action: HardwareAction): - """Process gpio actions.""" - if not self.sys_hardware.policy.is_match_cgroup(PolicyGroup.GPIO, device): - return - _LOGGER.info("Detecting %s GPIO hardware %s", action, device.path) + # Fire Hardware event to bus + if hw_action == HardwareAction.ADD: + self.sys_bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, device) + elif hw_action == HardwareAction.REMOVE: + self.sys_bus.fire_event(BusEvent.HARDWARE_REMOVE_DEVICE, device) diff --git a/supervisor/host/__init__.py b/supervisor/host/__init__.py index 329b203df..b71d25d3f 100644 --- a/supervisor/host/__init__.py +++ b/supervisor/host/__init__.py @@ -1,124 +1 @@ -"""Host function like audio, D-Bus or systemd.""" -from contextlib import suppress -from functools import lru_cache -import logging -from typing import List - -from ..coresys import CoreSys, CoreSysAttributes -from ..exceptions import HassioError, PulseAudioError -from .apparmor import AppArmorControl -from .const import HostFeature -from .control import SystemControl -from .info import InfoCenter -from .network import NetworkManager -from .services import ServiceManager -from .sound import SoundControl - -_LOGGER: logging.Logger = logging.getLogger(__name__) - - -class HostManager(CoreSysAttributes): - """Manage supported function from host.""" - - def __init__(self, coresys: CoreSys): - """Initialize Host manager.""" - self.coresys: CoreSys = coresys - - self._apparmor: AppArmorControl = AppArmorControl(coresys) - self._control: SystemControl = SystemControl(coresys) - self._info: InfoCenter = InfoCenter(coresys) - self._services: ServiceManager = ServiceManager(coresys) - self._network: NetworkManager = NetworkManager(coresys) - self._sound: SoundControl = SoundControl(coresys) - - @property - def apparmor(self) -> AppArmorControl: - """Return host AppArmor handler.""" - return self._apparmor - - @property - def control(self) -> SystemControl: - """Return host control handler.""" - return self._control - - @property - def info(self) -> InfoCenter: - """Return host info handler.""" - return self._info - - @property - def services(self) -> ServiceManager: - """Return host services handler.""" - return self._services - - @property - def network(self) -> NetworkManager: - """Return host NetworkManager handler.""" - return self._network - - @property - def sound(self) -> SoundControl: - """Return host PulseAudio control.""" - return self._sound - - @property - def features(self) -> List[HostFeature]: - """Return a list of host features.""" - return self.supported_features() - - @lru_cache - def supported_features(self) -> List[HostFeature]: - """Return a list of supported host features.""" - features = [] - - if self.sys_dbus.systemd.is_connected: - features.extend( - [HostFeature.REBOOT, HostFeature.SHUTDOWN, HostFeature.SERVICES] - ) - - if self.sys_dbus.network.is_connected and self.sys_dbus.network.interfaces: - features.append(HostFeature.NETWORK) - - if self.sys_dbus.hostname.is_connected: - features.append(HostFeature.HOSTNAME) - - if self.sys_dbus.timedate.is_connected: - features.append(HostFeature.TIMEDATE) - - if self.sys_dbus.agent.is_connected: - features.append(HostFeature.AGENT) - - if self.sys_hassos.available: - features.append(HostFeature.HAOS) - - return features - - async def reload(self): - """Reload host functions.""" - await self.info.update() - - if self.sys_dbus.systemd.is_connected: - await self.services.update() - - if self.sys_dbus.network.is_connected: - await self.network.update() - - if self.sys_dbus.agent.is_connected: - await self.sys_dbus.agent.update() - - with suppress(PulseAudioError): - await self.sound.update() - - _LOGGER.info("Host information reload completed") - self.supported_features.cache_clear() # pylint: disable=no-member - - async def load(self): - """Load host information.""" - with suppress(HassioError): - await self.reload() - - # Load profile data - try: - await self.apparmor.load() - except HassioError as err: - _LOGGER.warning("Loading host AppArmor on start failed: %s", err) +"""Host backend.""" diff --git a/supervisor/host/manager.py b/supervisor/host/manager.py new file mode 100644 index 000000000..aa31c6dee --- /dev/null +++ b/supervisor/host/manager.py @@ -0,0 +1,138 @@ +"""Host function like audio, D-Bus or systemd.""" +from contextlib import suppress +from functools import lru_cache +import logging +from typing import List + +from ..const import BusEvent +from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import HassioError, PulseAudioError +from ..hardware.const import PolicyGroup +from ..hardware.data import Device +from .apparmor import AppArmorControl +from .const import HostFeature +from .control import SystemControl +from .info import InfoCenter +from .network import NetworkManager +from .services import ServiceManager +from .sound import SoundControl + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class HostManager(CoreSysAttributes): + """Manage supported function from host.""" + + def __init__(self, coresys: CoreSys): + """Initialize Host manager.""" + self.coresys: CoreSys = coresys + + self._apparmor: AppArmorControl = AppArmorControl(coresys) + self._control: SystemControl = SystemControl(coresys) + self._info: InfoCenter = InfoCenter(coresys) + self._services: ServiceManager = ServiceManager(coresys) + self._network: NetworkManager = NetworkManager(coresys) + self._sound: SoundControl = SoundControl(coresys) + + @property + def apparmor(self) -> AppArmorControl: + """Return host AppArmor handler.""" + return self._apparmor + + @property + def control(self) -> SystemControl: + """Return host control handler.""" + return self._control + + @property + def info(self) -> InfoCenter: + """Return host info handler.""" + return self._info + + @property + def services(self) -> ServiceManager: + """Return host services handler.""" + return self._services + + @property + def network(self) -> NetworkManager: + """Return host NetworkManager handler.""" + return self._network + + @property + def sound(self) -> SoundControl: + """Return host PulseAudio control.""" + return self._sound + + @property + def features(self) -> List[HostFeature]: + """Return a list of host features.""" + return self.supported_features() + + @lru_cache + def supported_features(self) -> List[HostFeature]: + """Return a list of supported host features.""" + features = [] + + if self.sys_dbus.systemd.is_connected: + features.extend( + [HostFeature.REBOOT, HostFeature.SHUTDOWN, HostFeature.SERVICES] + ) + + if self.sys_dbus.network.is_connected and self.sys_dbus.network.interfaces: + features.append(HostFeature.NETWORK) + + if self.sys_dbus.hostname.is_connected: + features.append(HostFeature.HOSTNAME) + + if self.sys_dbus.timedate.is_connected: + features.append(HostFeature.TIMEDATE) + + if self.sys_dbus.agent.is_connected: + features.append(HostFeature.AGENT) + + if self.sys_hassos.available: + features.append(HostFeature.HAOS) + + return features + + async def reload(self): + """Reload host functions.""" + await self.info.update() + + if self.sys_dbus.systemd.is_connected: + await self.services.update() + + if self.sys_dbus.network.is_connected: + await self.network.update() + + if self.sys_dbus.agent.is_connected: + await self.sys_dbus.agent.update() + + with suppress(PulseAudioError): + await self.sound.update() + + _LOGGER.info("Host information reload completed") + self.supported_features.cache_clear() # pylint: disable=no-member + + async def load(self): + """Load host information.""" + with suppress(HassioError): + await self.reload() + + # Register for events + self.sys_bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, self._hardware_events) + self.sys_bus.register_event( + BusEvent.HARDWARE_REMOVE_DEVICE, self._hardware_events + ) + + # Load profile data + try: + await self.apparmor.load() + except HassioError as err: + _LOGGER.warning("Loading host AppArmor on start failed: %s", err) + + async def _hardware_events(self, device: Device) -> None: + """Process hardware requests.""" + if self.sys_hardware.policy.is_match_cgroup(PolicyGroup.AUDIO, device): + await self.sound.update() diff --git a/tests/test_bus.py b/tests/test_bus.py new file mode 100644 index 000000000..b9ae6df5c --- /dev/null +++ b/tests/test_bus.py @@ -0,0 +1,44 @@ +"""Test bus backend.""" + +import asyncio + +import pytest + +from supervisor.const import BusEvent +from supervisor.coresys import CoreSys + + +@pytest.mark.asyncio +async def test_bus_event(coresys: CoreSys) -> None: + """Test bus events over the backend.""" + results = [] + + async def callback(data) -> None: + """Test callback.""" + results.append(data) + + coresys.bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, callback) + + coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, None) + await asyncio.sleep(0) + assert results[-1] is None + + coresys.bus.fire_event(BusEvent.HARDWARE_NEW_DEVICE, "test") + await asyncio.sleep(0) + assert results[-1] == "test" + + +@pytest.mark.asyncio +async def test_bus_event_not_called(coresys: CoreSys) -> None: + """Test bus events over the backend.""" + results = [] + + async def callback(data) -> None: + """Test callback.""" + results.append(data) + + coresys.bus.register_event(BusEvent.HARDWARE_NEW_DEVICE, callback) + + coresys.bus.fire_event(BusEvent.HARDWARE_REMOVE_DEVICE, None) + await asyncio.sleep(0) + assert len(results) == 0