#!/usr/bin/env python3 """Configuration-driven event controller for home-control plugins.""" from __future__ import annotations import argparse import importlib import logging import signal from pathlib import Path from threading import Event from typing import Any, Callable import yaml LOG = logging.getLogger("home_control") EVENT_FIELDS = ("sender", "event", "id", "text") def load_config(path: Path) -> dict: with path.open(encoding="utf-8") as stream: config = yaml.safe_load(stream) if not isinstance(config, dict): raise ValueError("configuration must be a YAML mapping") return config def _configured_callable(spec: dict, key: str, default: str) -> Callable: module_name = spec.get("module") if not isinstance(module_name, str) or not module_name.startswith("lib."): raise ValueError("component module must be inside the lib package") attribute = spec.get(key, default) if not isinstance(attribute, str): raise ValueError(f"component {key} must be a string") try: function = getattr(importlib.import_module(module_name), attribute) except (ImportError, AttributeError) as error: raise ValueError(f"could not load {module_name}.{attribute}: {error}") from error if not callable(function): raise ValueError(f"configured component {module_name}.{attribute} is not callable") return function class Controller: """Load, supervise, and route plugins without plugin-specific code.""" def __init__(self, config: dict) -> None: self.config = config controller = config.get("controller", {}) self.plugin_specs = controller.get("plugins", {}) self.action_specs = controller.get("actions", {}) if not isinstance(self.plugin_specs, dict): raise ValueError("controller.plugins must be a mapping") if not isinstance(self.action_specs, dict): raise ValueError("controller.actions must be a mapping") self.stopped = Event() self.plugins: list[Any] = [] @staticmethod def _validate_event(message: dict) -> None: if not isinstance(message, dict): raise ValueError("plugin event must be a dictionary") missing = [field for field in EVENT_FIELDS if field not in message] if missing: raise ValueError(f"plugin event is missing fields: {', '.join(missing)}") if any(not isinstance(message[field], str) for field in EVENT_FIELDS): raise ValueError("plugin event fields must all be strings") def _component_config(self, spec: dict) -> dict: section = spec.get("config") if not isinstance(section, str): raise ValueError("component config must name a configuration section") component_config = self.config.get(section) if not isinstance(component_config, dict): raise ValueError(f"configuration section {section!r} is required") return component_config def handle_event(self, message: dict) -> None: """Route a validated plugin event to its configured action.""" self._validate_event(message) LOG.info( "event received: sender=%s event=%s id=%s text=%r", message["sender"], message["event"], message["id"], message["text"], ) plugin_spec = self.plugin_specs.get(message["sender"]) if not isinstance(plugin_spec, dict): LOG.warning("no controller route for plugin %s", message["sender"]) return configured_actions = plugin_spec.get("on_event") action_names = ( [configured_actions] if isinstance(configured_actions, str) else configured_actions ) if not isinstance(action_names, list) or any( not isinstance(name, str) for name in action_names ): raise ValueError( f"on_event for plugin {message['sender']!r} must be a string or list of strings" ) for action_name in action_names: action_spec = self.action_specs.get(action_name) if not isinstance(action_spec, dict): raise ValueError( f"unknown on_event action {action_name!r} " f"for plugin {message['sender']!r}" ) handler = _configured_callable(action_spec, "handler", "on_event") LOG.info( "action started: action=%s event=%s id=%s", action_name, message["event"], message["id"], ) handler(self._component_config(action_spec), message) LOG.info( "action completed: action=%s event=%s id=%s", action_name, message["event"], message["id"], ) def _load_plugins(self) -> None: for name, spec in self.plugin_specs.items(): if not isinstance(spec, dict): raise ValueError(f"controller plugin {name!r} must be a mapping") plugin_config = self._component_config(spec) if not plugin_config.get("enabled", True): LOG.info("plugin %s is disabled", name) continue factory = _configured_callable(spec, "factory", "create") plugin = factory(plugin_config, self.handle_event) self.plugins.append(plugin) def run(self) -> int: self._load_plugins() for plugin in self.plugins: plugin.start() try: while not self.stopped.wait(1): if any(not plugin.is_alive for plugin in self.plugins): LOG.error("a controller plugin stopped unexpectedly") return 1 finally: for plugin in self.plugins: plugin.stop() for plugin in self.plugins: plugin.join(timeout=20) return 0 def stop(self, *_: object) -> None: self.stopped.set() def check_once(self) -> int: self._load_plugins() result = 0 for plugin in self.plugins: try: check_once = getattr(plugin, "check_once", None) if callable(check_once): output = check_once() if output: print("\n".join(output) if isinstance(output, list) else output) except (OSError, RuntimeError) as error: LOG.error("plugin check failed: %s", error) result = 1 finally: close = getattr(plugin, "close", None) if callable(close): close() return result def run(config: dict, once: bool = False) -> int: controller = Controller(config) signal.signal(signal.SIGTERM, controller.stop) signal.signal(signal.SIGINT, controller.stop) return controller.check_once() if once else controller.run() def configure_logging(config: dict) -> None: """Configure console logging and an optional append-only log file.""" logging_config = config.get("controller", {}).get("logging", {}) level = getattr(logging, str(logging_config.get("level", "INFO")).upper()) handlers: list[logging.Handler] = [logging.StreamHandler()] filename = logging_config.get("file") if filename: handlers.append(logging.FileHandler(filename, encoding="utf-8")) logging.basicConfig( level=level, format="%(asctime)s %(levelname)s %(message)s", handlers=handlers, ) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--config", type=Path, default=Path(__file__).with_name("service.yaml")) parser.add_argument("--once", action="store_true", help="check plugins once") args = parser.parse_args() config = load_config(args.config) configure_logging(config) return run(config, args.once) if __name__ == "__main__": raise SystemExit(main())