2026-08-22 16:12:14 +02:00
|
|
|
#!/usr/bin/env python3
|
2026-08-23 08:16:15 +02:00
|
|
|
"""Configuration-driven event controller for home-control plugins."""
|
2026-08-22 16:12:14 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
2026-08-23 08:16:15 +02:00
|
|
|
import importlib
|
2026-08-22 16:12:14 +02:00
|
|
|
import logging
|
|
|
|
|
import signal
|
|
|
|
|
from pathlib import Path
|
2026-08-23 08:16:15 +02:00
|
|
|
from threading import Event
|
|
|
|
|
from typing import Any, Callable
|
2026-08-22 16:12:14 +02:00
|
|
|
|
|
|
|
|
import yaml
|
|
|
|
|
|
2026-08-23 08:16:15 +02:00
|
|
|
|
|
|
|
|
LOG = logging.getLogger("home_control")
|
|
|
|
|
EVENT_FIELDS = ("sender", "event", "id", "text")
|
2026-08-22 16:12:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 08:16:15 +02:00
|
|
|
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
|
2026-08-22 16:12:14 +02:00
|
|
|
|
2026-08-23 08:16:15 +02:00
|
|
|
def handle_event(self, message: dict) -> None:
|
|
|
|
|
"""Route a validated plugin event to its configured action."""
|
|
|
|
|
self._validate_event(message)
|
|
|
|
|
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"
|
|
|
|
|
)
|
2026-08-22 16:12:14 +02:00
|
|
|
|
2026-08-23 08:16:15 +02:00
|
|
|
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")
|
|
|
|
|
handler(self._component_config(action_spec), message)
|
2026-08-22 16:12:14 +02:00
|
|
|
|
2026-08-23 08:16:15 +02:00
|
|
|
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)
|
2026-08-22 16:12:14 +02:00
|
|
|
return 0
|
2026-08-23 08:16:15 +02:00
|
|
|
|
|
|
|
|
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()
|
2026-08-22 16:12:14 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
parser = argparse.ArgumentParser()
|
|
|
|
|
parser.add_argument("--config", type=Path, default=Path(__file__).with_name("service.yaml"))
|
2026-08-23 08:16:15 +02:00
|
|
|
parser.add_argument("--once", action="store_true", help="check plugins once")
|
2026-08-22 16:12:14 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
config = load_config(args.config)
|
2026-08-23 08:16:15 +02:00
|
|
|
logging_config = config.get("controller", {}).get("logging", {})
|
2026-08-22 16:12:14 +02:00
|
|
|
logging.basicConfig(
|
|
|
|
|
level=getattr(logging, str(logging_config.get("level", "INFO")).upper()),
|
|
|
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
|
|
|
)
|
|
|
|
|
return run(config, args.once)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
raise SystemExit(main())
|