#!/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, "denon.volume_mute": lib_denon.volume_mute, } @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()