included cloud logging with filters
This commit is contained in:
+141
-18
@@ -1,16 +1,21 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Entry point and orchestrator for enabled home-control functions."""
|
||||
"""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
|
||||
|
||||
from lib import _arrival_detection
|
||||
|
||||
LOG = logging.getLogger("home_control")
|
||||
EVENT_FIELDS = ("sender", "event", "id", "text")
|
||||
|
||||
|
||||
def load_config(path: Path) -> dict:
|
||||
@@ -21,33 +26,151 @@ def load_config(path: Path) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def run(config: dict, once: bool = False) -> int:
|
||||
stopped = False
|
||||
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
|
||||
|
||||
def stop(*_: object) -> None:
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
class Controller:
|
||||
"""Load, supervise, and route plugins without plugin-specific code."""
|
||||
|
||||
arrival_config = config.get("arrival_detection")
|
||||
if not isinstance(arrival_config, dict):
|
||||
raise ValueError("arrival_detection configuration is required")
|
||||
if not arrival_config.get("enabled", True):
|
||||
logging.getLogger("home_control").info("arrival detection is disabled")
|
||||
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)
|
||||
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")
|
||||
handler(self._component_config(action_spec), message)
|
||||
|
||||
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
|
||||
return _arrival_detection.run(arrival_config, lambda: stopped, once=once)
|
||||
|
||||
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 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 once and print connected/absent")
|
||||
parser.add_argument("--once", action="store_true", help="check plugins once")
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
arrival = config.get("arrival_detection", {})
|
||||
logging_config = arrival.get("logging", {}) if isinstance(arrival, dict) else {}
|
||||
logging_config = config.get("controller", {}).get("logging", {})
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, str(logging_config.get("level", "INFO")).upper()),
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
|
||||
Reference in New Issue
Block a user