POC event handler

This commit is contained in:
2026-08-22 14:22:26 +02:00
parent 6995ae8dba
commit 35969ad305
9 changed files with 582 additions and 10 deletions
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""Listen for Zigbee2MQTT device events and append them to a log file."""
from __future__ import annotations
import argparse
import json
import logging
import logging.handlers
import signal
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import paho.mqtt.client as mqtt
import yaml
import lib_denon
BASE_DIR = Path(__file__).resolve().parent
LOG = logging.getLogger("zigbee-events")
# Only functions explicitly listed here can be selected from events.yaml.
FUNCTIONS = {
"denon.on": lib_denon.on,
"denon.off": lib_denon.off,
"denon.next": lib_denon.next,
"denon.previous": lib_denon.previous,
"denon.volume_up": lib_denon.volume_up,
"denon.volume_down": lib_denon.volume_down,
}
@dataclass(frozen=True)
class Device:
name: str
topic: str
event_field: str
events: dict[str, str]
@dataclass(frozen=True)
class Config:
mqtt_host: str
mqtt_port: int
mqtt_username: str | None
mqtt_password: str | None
mqtt_keepalive: int
client_id: str
devices: tuple[Device, ...]
log_file: Path
log_level: str
log_format: str
log_max_bytes: int
log_backup_count: int
include_payload: bool
denon_host: str
denon_port: int
denon_timeout: float
denon_verify_tls: bool
def load_config(path: Path) -> Config:
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise SystemExit(f"Configuration file {path} was not found") from error
except yaml.YAMLError as error:
raise SystemExit(f"Invalid YAML in {path}: {error}") from error
if not isinstance(raw, dict):
raise SystemExit("The YAML root must be a mapping")
mqtt_config = raw.get("mqtt", {})
logging_config = raw.get("logging", {})
denon_config = raw.get("denon", {})
configured_devices = raw.get("devices", [])
if not all(isinstance(section, dict) for section in (mqtt_config, logging_config, denon_config)):
raise SystemExit("mqtt, logging, and denon must be mappings")
if not isinstance(configured_devices, list):
raise SystemExit("devices must be a list")
devices: list[Device] = []
topics: set[str] = set()
for item in configured_devices:
if not isinstance(item, dict):
raise SystemExit("Every device entry must be a mapping")
if not bool(item.get("enabled", True)):
continue
try:
name = str(item["name"]).strip()
topic = str(item["topic"]).strip().strip("/")
except KeyError as error:
raise SystemExit("Every enabled device requires name and topic") from error
event_field = str(item.get("event_field", "action")).strip()
configured_events = item.get("events", {})
if not name or not topic or not event_field:
raise SystemExit("Device name, topic, and event_field cannot be empty")
if not isinstance(configured_events, dict):
raise SystemExit(f"events for device {name!r} must be a mapping")
events: dict[str, str] = {}
for event, function_name in configured_events.items():
event = str(event).strip()
function_name = str(function_name).strip()
if not event or not function_name:
raise SystemExit(f"Event and function names for device {name!r} cannot be empty")
if function_name not in FUNCTIONS:
choices = ", ".join(sorted(FUNCTIONS))
raise SystemExit(
f"Unknown function {function_name!r} for device {name!r}; choose from: {choices}"
)
events[event] = function_name
if "+" in topic or "#" in topic:
raise SystemExit(f"Device topic cannot contain MQTT wildcards: {topic!r}")
if topic in topics:
raise SystemExit(f"Duplicate device topic: {topic!r}")
topics.add(topic)
devices.append(Device(name=name, topic=topic, event_field=event_field, events=events))
if not 1 <= len(devices) <= 5:
raise SystemExit("Configure between 1 and 5 enabled devices")
log_file = Path(logging_config.get("file", "zigbee-events.log")).expanduser()
if not log_file.is_absolute():
log_file = path.parent / log_file
port = int(mqtt_config.get("port", 1883))
keepalive = int(mqtt_config.get("keepalive_seconds", 60))
max_bytes = int(logging_config.get("max_bytes", 5_000_000))
backup_count = int(logging_config.get("backup_count", 3))
denon_host = str(denon_config.get("host", "192.168.178.177")).strip()
denon_port = int(denon_config.get("port", 8080))
denon_timeout = float(denon_config.get("timeout_seconds", 3))
if not 1 <= port <= 65535:
raise SystemExit("mqtt.port must be between 1 and 65535")
if keepalive < 1 or max_bytes < 1 or backup_count < 0:
raise SystemExit("keepalive_seconds/max_bytes must be positive and backup_count non-negative")
if not denon_host or not 1 <= denon_port <= 65535 or denon_timeout <= 0:
raise SystemExit("denon.host must be set, its port valid, and timeout_seconds positive")
return Config(
mqtt_host=str(mqtt_config.get("host", "127.0.0.1")),
mqtt_port=port,
mqtt_username=mqtt_config.get("username"),
mqtt_password=mqtt_config.get("password"),
mqtt_keepalive=keepalive,
client_id=str(mqtt_config.get("client_id", "zigbee-events")),
devices=tuple(devices),
log_file=log_file,
log_level=str(logging_config.get("level", "INFO")).upper(),
log_format=str(logging_config.get("format", "%(asctime)s %(levelname)s %(message)s")),
log_max_bytes=max_bytes,
log_backup_count=backup_count,
include_payload=bool(logging_config.get("include_payload", True)),
denon_host=denon_host,
denon_port=denon_port,
denon_timeout=denon_timeout,
denon_verify_tls=bool(denon_config.get("verify_tls", True)),
)
def configure_logging(config: Config) -> None:
config.log_file.parent.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.RotatingFileHandler(
config.log_file,
maxBytes=config.log_max_bytes,
backupCount=config.log_backup_count,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter(config.log_format))
LOG.setLevel(config.log_level)
LOG.addHandler(handler)
def run(config: Config) -> None:
lib_denon.configure(
config.denon_host,
config.denon_port,
config.denon_timeout,
config.denon_verify_tls,
)
devices_by_topic = {device.topic: device for device in config.devices}
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=config.client_id)
if config.mqtt_username is not None:
client.username_pw_set(config.mqtt_username, config.mqtt_password)
def on_connect(client, userdata, flags, reason_code, properties):
if reason_code != 0:
LOG.error("MQTT connection failed: %s", reason_code)
return
for topic in devices_by_topic:
client.subscribe(topic, qos=0)
LOG.info(
"Connected to MQTT broker %s:%s; listening to %s device(s)",
config.mqtt_host,
config.mqtt_port,
len(devices_by_topic),
)
def on_disconnect(client, userdata, disconnect_flags, reason_code, properties):
if reason_code != 0:
LOG.warning("Unexpected MQTT disconnect (%s); reconnecting", reason_code)
def on_message(client, userdata, message):
device = devices_by_topic.get(message.topic)
if device is None:
return
try:
payload: Any = json.loads(message.payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
LOG.warning("Invalid JSON from device=%s topic=%s: %s", device.name, message.topic, error)
return
if not isinstance(payload, dict) or device.event_field not in payload:
return
event = payload[device.event_field]
details = ""
if config.include_payload:
details = f" payload={json.dumps(payload, ensure_ascii=False, separators=(',', ':'))}"
LOG.info(
"event device=%s topic=%s %s=%s%s",
json.dumps(device.name, ensure_ascii=False),
message.topic,
device.event_field,
json.dumps(event, ensure_ascii=False),
details,
)
function_name = device.events.get(str(event))
if function_name is None:
return
try:
FUNCTIONS[function_name]()
except Exception:
LOG.exception(
"Function failed device=%s event=%s function=%s",
device.name,
event,
function_name,
)
else:
LOG.info(
"called device=%s event=%s function=%s",
json.dumps(device.name, ensure_ascii=False),
json.dumps(event, ensure_ascii=False),
function_name,
)
client.on_connect = on_connect
client.on_disconnect = on_disconnect
client.on_message = on_message
def stop(signum, frame):
LOG.info("Stopping on signal %s", signum)
client.disconnect()
signal.signal(signal.SIGINT, stop)
signal.signal(signal.SIGTERM, stop)
LOG.info("Starting Zigbee event listener")
try:
client.connect(config.mqtt_host, config.mqtt_port, config.mqtt_keepalive)
client.loop_forever(retry_first_connection=True)
except OSError as error:
LOG.error("Could not connect to %s:%s: %s", config.mqtt_host, config.mqtt_port, error)
raise SystemExit(1) from error
finally:
LOG.info("Zigbee event listener stopped")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--config",
type=Path,
default=BASE_DIR / "events.yaml",
help="configuration file (default: events.yaml beside this script)",
)
parser.add_argument("--check-config", action="store_true", help="validate configuration and exit")
args = parser.parse_args()
config = load_config(args.config.resolve())
if args.check_config:
print(f"Configuration valid: {len(config.devices)} enabled device(s)")
return
configure_logging(config)
run(config)
if __name__ == "__main__":
main()