included cloud logging with filters

This commit is contained in:
2026-08-23 08:16:15 +02:00
parent d43b375d73
commit ce8e20784a
13 changed files with 955 additions and 208 deletions
+141 -18
View File
@@ -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",