Files

213 lines
7.9 KiB
Python
Raw Permalink Normal View History

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)
2026-08-23 15:09:18 +02:00
LOG.info(
"event received: sender=%s event=%s id=%s text=%r",
message["sender"],
message["event"],
message["id"],
message["text"],
)
2026-08-23 08:16:15 +02:00
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")
2026-08-23 15:09:18 +02:00
LOG.info(
"action started: action=%s event=%s id=%s",
action_name,
message["event"],
message["id"],
)
2026-08-23 08:16:15 +02:00
handler(self._component_config(action_spec), message)
2026-08-23 15:09:18 +02:00
LOG.info(
"action completed: action=%s event=%s id=%s",
action_name,
message["event"],
message["id"],
)
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
2026-08-23 15:09:18 +02:00
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,
)
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 15:09:18 +02:00
configure_logging(config)
2026-08-22 16:12:14 +02:00
return run(config, args.once)
if __name__ == "__main__":
raise SystemExit(main())