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
+74 -8
View File
@@ -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 030°C. Humidity and
battery use the right axis with a default range of 0100%. 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/<friendly_name>` 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)
+61
View File
@@ -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/<friendly_name> 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: {}
+129
View File
@@ -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())
View File
+13
View File
@@ -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
+14
View File
@@ -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
+1 -1
View File
@@ -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
+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()
+1 -1
View File
@@ -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")