diff --git a/HOWTO.md b/HOWTO.md index beb9c44..b725988 100644 --- a/HOWTO.md +++ b/HOWTO.md @@ -350,13 +350,13 @@ python3 -m venv .venv .venv/bin/pip install -r requirements.txt ``` -Edit the supplied `config.yaml`. MQTT settings, collection timing, storage +Edit the supplied `sensors.yaml`. MQTT settings, collection timing, storage paths, logging, all six sensors, RRD archives, and graph presentation are configured in this one file. Replace all five `CHANGE_ME` topics with the friendly names shown by Zigbee2MQTT: ```bash -nano config.yaml +nano sensors.yaml ``` Sensor entries whose `name` begins with `CHANGE_ME` are placeholders and are @@ -377,7 +377,7 @@ sudo install -d -o "$USER" -g "$USER" /var/lib/rrd sudo install -d -o "$USER" -g "$USER" /var/www/html/sensors ``` -Both paths can be changed under `storage` in `config.yaml`: +Both paths can be changed under `storage` in `sensors.yaml`: ```yaml storage: @@ -388,7 +388,7 @@ storage: Test one collection cycle: ```bash -.venv/bin/python read_temperature.py --once +.venv/bin/python zigbee_sensors.py --once ``` The script creates one RRD file per sensor in the configured data directory and @@ -404,7 +404,7 @@ samples existed before the RRD was created. Temperature uses the left axis with a default range of 0–30°C. Humidity and battery use the right axis with a default range of 0–100%. These ranges and -labels can be changed in `config.yaml` under `graph.axes`. Temperature grid +labels can be changed in `sensors.yaml` under `graph.axes`. Temperature grid labels default to five-degree intervals, and both axes display integers. Since RRDtool ties the right axis to the left, the corresponding percentage labels are rounded to integers. Light-grey reference lines are drawn at 15, 20, and @@ -422,7 +422,7 @@ With a web server serving `/var/www/html`, open: http://RASPBERRY_PI_IP/sensors/ ``` -The page settings are configurable in `config.yaml`: +The page settings are configurable in `sensors.yaml`: ```yaml html: @@ -440,7 +440,7 @@ a replacement. Run the ten-minute collection loop in the foreground with: ```bash -.venv/bin/python read_temperature.py +.venv/bin/python zigbee_sensors.py ``` To run it automatically as a user service, copy the supplied service file. It @@ -462,10 +462,76 @@ journalctl --user -u zigbee-rrd.service -f Graphs can be regenerated without collecting a new MQTT reading: ```bash -.venv/bin/python read_temperature.py --graph-only +.venv/bin/python zigbee_sensors.py --graph-only ``` References: - [RRDtool database creation](https://oss.oetiker.ch/rrdtool/doc/rrdcreate.en.html) + +## 9. Log button and remote-control events + +`zigbee_events.py` runs continuously and logs Zigbee2MQTT messages that contain +the configured event field. Edit `events.yaml` and set the MQTT broker's LAN IP, +credentials if required, and the exact `zigbee2mqtt/` topic for +each device. Between one and five entries may be enabled. + +Validate the configuration without connecting to MQTT: + +```bash +.venv/bin/python zigbee_events.py --check-config +``` + +For a foreground test, start the listener and then press a configured button: + +```bash +.venv/bin/python zigbee_events.py +tail -f zigbee-events.log +``` + +Ordinary state messages, such as battery or link-quality reports, are ignored +unless they also contain the device's `event_field`. For the SONOFF SNZB-01M +Orb, leave that field set to `action`. + +Each device's `events` mapping connects an event value to an allow-listed +function. Unmapped events are logged but do not call a function: + +```yaml +events: + single_button_1: denon.on + single_button_2: denon.off + single_button_3: denon.previous + single_button_4: denon.next +``` + +Volume actions are also available as `denon.volume_up` and +`denon.volume_down`. Each call sends five volume steps to the receiver. + +The Denon receiver connection is also configured in `events.yaml`. Power +actions use Denon's HTTP control endpoint; set **Settings → Network → Network +Control** to **Always On** on the receiver so it remains reachable in standby: + +```yaml +denon: + host: 192.168.178.177 + port: 8080 + timeout_seconds: 3 + verify_tls: true +``` + +The supplied system service assumes the project is installed at +`/opt/service_zigbee`: + +```bash +sudo cp zigbee-events.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now zigbee-events.service +sudo systemctl status zigbee-events.service +``` + +After changing `events.yaml`, restart the listener: + +```bash +sudo systemctl restart zigbee-events.service +``` - [RRDtool graph elements](https://oss.oetiker.ch/rrdtool/doc/rrdgraph_graph.en.html) diff --git a/events.yaml b/events.yaml new file mode 100644 index 0000000..a04dc09 --- /dev/null +++ b/events.yaml @@ -0,0 +1,61 @@ +mqtt: + # The MQTT broker may be on any machine reachable over the local network. + host: 192.168.178.247 + port: 1883 + username: null + password: null + client_id: zigbee-events + keepalive_seconds: 60 + +denon: + host: 192.168.178.177 + # Dedicated Denon HTTP control API (the web interface uses another port). + port: 8080 + timeout_seconds: 3 + verify_tls: true + +logging: + # Relative paths are resolved from this file's directory. + file: zigbee-events.log + level: INFO + format: "%(asctime)s %(levelname)s %(message)s" + include_payload: true + max_bytes: 5000000 + backup_count: 3 + +# Configure 1 to 5 enabled devices. The topic is +# zigbee2mqtt/ as shown in the Zigbee2MQTT UI. +devices: + - name: Radio Orb + topic: zigbee2mqtt/button1_radio + event_field: action + enabled: true + events: + single_button_1: denon.on + single_button_2: denon.off + single_button_3: denon.previous + single_button_4: denon.next + + - name: Device 2 + topic: zigbee2mqtt/CHANGE_ME_DEVICE_2 + event_field: action + enabled: false + events: {} + + - name: Device 3 + topic: zigbee2mqtt/CHANGE_ME_DEVICE_3 + event_field: action + enabled: false + events: {} + + - name: Device 4 + topic: zigbee2mqtt/CHANGE_ME_DEVICE_4 + event_field: action + enabled: false + events: {} + + - name: Device 5 + topic: zigbee2mqtt/CHANGE_ME_DEVICE_5 + event_field: action + enabled: false + events: {} diff --git a/lib_denon.py b/lib_denon.py new file mode 100644 index 0000000..f305ef4 --- /dev/null +++ b/lib_denon.py @@ -0,0 +1,129 @@ +"""Small HTTP client for controlling a Denon receiver.""" + +from __future__ import annotations + +import argparse +import ssl +import sys +from collections.abc import Callable +from urllib.error import URLError +from urllib.parse import quote +from urllib.request import Request, urlopen + + +_host = "192.168.178.177" +_port = 8080 +_timeout = 3.0 +_verify_tls = True + + +def configure( + host: str, + port: int = 8080, + timeout: float = 3.0, + verify_tls: bool = True, +) -> None: + """Configure the receiver used by the parameterless action functions.""" + global _host, _port, _timeout, _verify_tls + _host = host + _port = port + _timeout = timeout + _verify_tls = verify_tls + + +def _command(command: str) -> None: + """Send one command through Denon's HTTP control endpoint.""" + encoded_command = quote(command, safe="") + url = f"http://{_host}:{_port}/goform/formiPhoneAppDirect.xml?{encoded_command}" + request = Request(url, headers={"User-Agent": "service-zigbee/1.0"}) + context = None if _verify_tls else ssl._create_unverified_context() + with urlopen(request, timeout=_timeout, context=context) as response: + # Reading the response completes the request and lets urllib reuse/close + # the connection cleanly. HTTP error responses raise an exception. + response.read() + + +def on() -> None: + """Turn the receiver on.""" + _command("PWON") + print("On") + + +def off() -> None: + """Put the receiver into standby.""" + _command("PWSTANDBY") + + +def next() -> None: + """Select the next preset or source.""" + + +def previous() -> None: + """Select the previous preset or source.""" + + +def volume_up() -> None: + """Increase the volume by 5 points.""" + for _ in range(5): + _command("MVUP") + + +def volume_down() -> None: + """Decrease the volume by 5 points.""" + for _ in range(5): + _command("MVDOWN") + + +def main() -> int: + """Run a receiver action from the command line.""" + parser = argparse.ArgumentParser(description="Control the Denon receiver") + parser.add_argument( + "action", + choices=("on", "off", "next", "previous", "volume-up", "volume-down"), + ) + parser.add_argument("--host", default=_host, help=f"receiver address (default: {_host})") + parser.add_argument("--port", type=int, default=_port, help=f"HTTP port (default: {_port})") + parser.add_argument( + "--timeout", + type=float, + default=_timeout, + help=f"request timeout in seconds (default: {_timeout:g})", + ) + tls_group = parser.add_mutually_exclusive_group() + tls_group.add_argument( + "--verify-tls", + action="store_true", + default=_verify_tls, + help="verify the receiver's HTTPS certificate", + ) + tls_group.add_argument( + "--no-verify-tls", + action="store_false", + dest="verify_tls", + help="accept a self-signed HTTPS certificate", + ) + args = parser.parse_args() + if not 1 <= args.port <= 65535: + parser.error("--port must be between 1 and 65535") + if args.timeout <= 0: + parser.error("--timeout must be positive") + + configure(args.host, args.port, args.timeout, args.verify_tls) + actions: dict[str, Callable[[], None]] = { + "on": on, + "off": off, + "next": next, + "previous": previous, + "volume-up": volume_up, + "volume-down": volume_down, + } + try: + actions[args.action]() + except (OSError, URLError) as error: + print(f"Denon {args.action} failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/config.yaml b/sensors.yaml similarity index 100% rename from config.yaml rename to sensors.yaml diff --git a/zigbee-events.log b/zigbee-events.log new file mode 100644 index 0000000..5888691 --- /dev/null +++ b/zigbee-events.log @@ -0,0 +1,13 @@ +2026-08-22 13:56:20,219 INFO Starting Zigbee event listener +2026-08-22 13:56:20,225 INFO Connected to MQTT broker 192.168.178.247:1883; listening to 1 device(s) +2026-08-22 13:56:28,170 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_1" payload={"action":"single_button_1","battery":100,"linkquality":24,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 13:56:57,457 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_4" payload={"action":"single_button_4","battery":100,"linkquality":21,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 13:57:53,981 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_3" payload={"action":"single_button_3","battery":100,"linkquality":21,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:03:07,201 INFO Stopping on signal 2 +2026-08-22 14:03:07,202 INFO Zigbee event listener stopped +2026-08-22 14:03:08,993 INFO Starting Zigbee event listener +2026-08-22 14:03:09,000 INFO Connected to MQTT broker 192.168.178.247:1883; listening to 1 device(s) +2026-08-22 14:03:19,924 INFO event device="Radio Orb" topic=zigbee2mqtt/button1_radio action="single_button_1" payload={"action":"single_button_1","battery":100,"linkquality":33,"update":{"installed_version":4101,"latest_release_notes":null,"latest_source":"https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/Sonoff/snzb-01m_v1.1.0.ota","latest_version":4352,"state":"available"}} +2026-08-22 14:03:19,925 INFO called device="Radio Orb" event="single_button_1" function=denon.on +2026-08-22 14:10:26,225 INFO Stopping on signal 2 +2026-08-22 14:10:26,226 INFO Zigbee event listener stopped diff --git a/zigbee-events.service b/zigbee-events.service new file mode 100644 index 0000000..42e4ddc --- /dev/null +++ b/zigbee-events.service @@ -0,0 +1,14 @@ +[Unit] +Description=Zigbee MQTT event logger +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/service_zigbee +ExecStart=/opt/service_zigbee/.venv/bin/python /opt/service_zigbee/zigbee_events.py --config /opt/service_zigbee/events.yaml +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/zigbee-sensors.service b/zigbee-sensors.service index 9bff984..448e55f 100644 --- a/zigbee-sensors.service +++ b/zigbee-sensors.service @@ -6,7 +6,7 @@ After=network-online.target [Service] Type=simple WorkingDirectory=/opt/service_zigbee -ExecStart=/opt/service_zigbee/.venv/bin/python /opt/service_zigbee/read_temperature.py +ExecStart=/opt/service_zigbee/.venv/bin/python /opt/service_zigbee/zigbee_sensors.py Restart=on-failure RestartSec=15 diff --git a/zigbee_events.py b/zigbee_events.py new file mode 100644 index 0000000..58fee27 --- /dev/null +++ b/zigbee_events.py @@ -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() diff --git a/read_temperature.py b/zigbee_sensors.py similarity index 99% rename from read_temperature.py rename to zigbee_sensors.py index 152bcea..7844f94 100644 --- a/read_temperature.py +++ b/zigbee_sensors.py @@ -452,7 +452,7 @@ def handle_signal(signum, frame) -> None: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--config", type=Path, default=BASE_DIR / "config.yaml") + parser.add_argument("--config", type=Path, default=BASE_DIR / "sensors.yaml") modes = parser.add_mutually_exclusive_group() modes.add_argument("--once", action="store_true", help="collect and graph once") modes.add_argument("--graph-only", action="store_true", help="only regenerate graphs")