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