POC event handler
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user