290 lines
10 KiB
Python
290 lines
10 KiB
Python
#!/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()
|