POC event handler
This commit is contained in:
@@ -350,13 +350,13 @@ python3 -m venv .venv
|
|||||||
.venv/bin/pip install -r requirements.txt
|
.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
|
paths, logging, all six sensors, RRD archives, and graph presentation are
|
||||||
configured in this one file. Replace all five `CHANGE_ME` topics with the
|
configured in this one file. Replace all five `CHANGE_ME` topics with the
|
||||||
friendly names shown by Zigbee2MQTT:
|
friendly names shown by Zigbee2MQTT:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nano config.yaml
|
nano sensors.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Sensor entries whose `name` begins with `CHANGE_ME` are placeholders and are
|
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
|
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
|
```yaml
|
||||||
storage:
|
storage:
|
||||||
@@ -388,7 +388,7 @@ storage:
|
|||||||
Test one collection cycle:
|
Test one collection cycle:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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
|
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
|
labels default to five-degree intervals, and both axes display integers. Since
|
||||||
RRDtool ties the right axis to the left, the corresponding percentage labels
|
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
|
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/
|
http://RASPBERRY_PI_IP/sensors/
|
||||||
```
|
```
|
||||||
|
|
||||||
The page settings are configurable in `config.yaml`:
|
The page settings are configurable in `sensors.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
html:
|
html:
|
||||||
@@ -440,7 +440,7 @@ a replacement.
|
|||||||
Run the ten-minute collection loop in the foreground with:
|
Run the ten-minute collection loop in the foreground with:
|
||||||
|
|
||||||
```bash
|
```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
|
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:
|
Graphs can be regenerated without collecting a new MQTT reading:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
.venv/bin/python read_temperature.py --graph-only
|
.venv/bin/python zigbee_sensors.py --graph-only
|
||||||
```
|
```
|
||||||
|
|
||||||
References:
|
References:
|
||||||
|
|
||||||
- [RRDtool database creation](https://oss.oetiker.ch/rrdtool/doc/rrdcreate.en.html)
|
- [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)
|
- [RRDtool graph elements](https://oss.oetiker.ch/rrdtool/doc/rrdgraph_graph.en.html)
|
||||||
|
|||||||
+61
@@ -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
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -6,7 +6,7 @@ After=network-online.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/opt/service_zigbee
|
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
|
Restart=on-failure
|
||||||
RestartSec=15
|
RestartSec=15
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -452,7 +452,7 @@ def handle_signal(signum, frame) -> None:
|
|||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
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 = parser.add_mutually_exclusive_group()
|
||||||
modes.add_argument("--once", action="store_true", help="collect and graph once")
|
modes.add_argument("--once", action="store_true", help="collect and graph once")
|
||||||
modes.add_argument("--graph-only", action="store_true", help="only regenerate graphs")
|
modes.add_argument("--graph-only", action="store_true", help="only regenerate graphs")
|
||||||
Reference in New Issue
Block a user