Add doorbell-listener: ntfy → zigbee2mqtt Sideboard Lamp toggle

Polls ntfy.sh for ALERT_klubhaus_topic(_test) and publishes
{state: TOGGLE} to zigbee2mqtt/Sideboard Lamp/set on each new
message event. Dual-topic subscription so switching from test to
production requires no code change; sender still uses test topic.

- infra/doorbell-listener/: uv-based Docker service (arm64)
- compose: depends_on mosquitto, persists last_id per topic
- justfile: doorbell-publish, doorbell-logs, doorbell-rebuild,
  restart-all now includes doorbell-listener
This commit is contained in:
2026-07-01 00:30:08 -07:00
parent c1551c3d1e
commit 90927ccc0e
6 changed files with 190 additions and 2 deletions
+2 -1
View File
@@ -32,8 +32,9 @@ All diagnostic commands live in the justfile. Run `just --list` to see them.
- `just mqtt-button [action]` — E2E test: publish a button action and watch which `/set` topics fire - `just mqtt-button [action]` — E2E test: publish a button action and watch which `/set` topics fire
- `just z2m-devices` — paired zigbee devices + group membership - `just z2m-devices` — paired zigbee devices + group membership
- `just mqtt-pub TOPIC PAYLOAD` / `just mqtt-sub TOPIC` / `just mqtt-watch` — raw MQTT - `just mqtt-pub TOPIC PAYLOAD` / `just mqtt-sub TOPIC` / `just mqtt-watch` — raw MQTT
- `just nr-logs` / `just z2m-logs` / `just mqtt-logs` — container log tails - `just nr-logs` / `just z2m-logs` / `just mqtt-logs` / `just doorbell-logs` — container log tails
- `just restart SERVICE` / `just restart-all` — docker restart - `just restart SERVICE` / `just restart-all` — docker restart
- `just doorbell-publish "Title" "Body"` — publish a test alert to ntfy
- `just rpi CMD` — run any command on the RPi over SSH - `just rpi CMD` — run any command on the RPi over SSH
`scripts/mqtt.py` and `scripts/diag.py` are the underlying helpers. Override the target host with `just RPI_HOST=user@host mqtt-button single`. `scripts/mqtt.py` and `scripts/diag.py` are the underlying helpers. Override the target host with `just RPI_HOST=user@host mqtt-button single`.
+20
View File
@@ -53,3 +53,23 @@ services:
TZ: America/Los_Angeles TZ: America/Los_Angeles
DASHBOARD_BASE_HREF: /dashboard/ DASHBOARD_BASE_HREF: /dashboard/
command: ["sh", "/data/start.sh"] command: ["sh", "/data/start.sh"]
# Listens to ntfy.sh for doorbell events and toggles a smart plug on each alert.
# Topic: ALERT_klubhaus_topic_test (matches ESP32 firmware DEBUG_MODE suffix)
# Action: TOGGLE on zigbee2mqtt/Sideboard Lamp/set
doorbell-listener:
build: ./doorbell-listener
container_name: doorbell-listener
restart: unless-stopped
depends_on:
- mosquitto
volumes:
- ./doorbell-listener/state:/data/doorbell-listener
environment:
MQTT_HOST: mosquitto
MQTT_PORT: "1883"
PLUG_TOPIC: "zigbee2mqtt/Sideboard Lamp/set"
POLL_INTERVAL: "30"
TZ: America/Los_Angeles
+3
View File
@@ -0,0 +1,3 @@
# Persisted listener state — last seen ntfy message id
state/
__pycache__/
+6
View File
@@ -0,0 +1,6 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
COPY doorbell-listener.py .
CMD ["uv", "run", "--no-cache", "doorbell-listener.py"]
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Doorbell ntfy listener.
Polls one or more ntfy topics and toggles a smart plug via zigbee2mqtt every
time a new "message" event arrives.
Why polling: matches the doorbell firmware's poll-with-since pattern, trivial
to persist last-seen message id per topic for restart resilience, no need for
long-lived HTTP connections inside Docker.
Topics: ALERT_klubhaus_topic_test (development / smoke tests)
ALERT_klubhaus_topic (production)
Action: publish {"state": "TOGGLE"} to zigbee2mqtt/Sideboard Lamp/set
Env vars (all optional):
MQTT_HOST default "mosquitto"
MQTT_PORT default 1883
PLUG_TOPIC default "zigbee2mqtt/Sideboard Lamp/set"
POLL_INTERVAL seconds between polls, default 30
STATE_DIR default "/data/doorbell-listener"
NTFY_TOPICS comma-separated override, default "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic"
"""
import json
import os
import sys
import time
from pathlib import Path
import paho.mqtt.client as mqtt
import requests
DEFAULT_TOPICS = "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic"
NTFY_TOPICS = [t.strip() for t in os.environ.get("NTFY_TOPICS", DEFAULT_TOPICS).split(",") if t.strip()]
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
PLUG_TOPIC = os.environ.get("PLUG_TOPIC", "zigbee2mqtt/Sideboard Lamp/set")
STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/doorbell-listener"))
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
def log(msg: str) -> None:
print(msg, flush=True)
def last_id_path(topic: str) -> Path:
safe = topic.replace("/", "_").replace(" ", "_")
return STATE_DIR / f"last_id_{safe}"
def load_last_id(topic: str) -> str:
p = last_id_path(topic)
if p.exists():
return p.read_text().strip()
return ""
def save_last_id(topic: str, msg_id: str) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
last_id_path(topic).write_text(msg_id)
def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str:
"""Poll one topic. Returns the latest message id seen (or last_id)."""
url = f"https://ntfy.sh/{topic}/json?poll=1"
if last_id:
url += f"&since={last_id}"
log(f"[{topic}] polling {url}")
r = requests.get(url, stream=True, timeout=(10, 65))
r.raise_for_status()
newest_id = last_id
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("event") != "message":
continue
msg_id = event.get("id", "")
title = event.get("title", "")
message = event.get("message", "")
log(f"[{topic}] alert: id={msg_id} title={title!r} message={message!r}")
payload = json.dumps({"state": "TOGGLE"})
info = client.publish(PLUG_TOPIC, payload)
info.wait_for_publish(timeout=5)
log(f"published TOGGLE on {PLUG_TOPIC}")
if msg_id:
newest_id = msg_id
save_last_id(topic, msg_id)
return newest_id
def main() -> None:
log("doorbell-listener starting")
log(f" ntfy topics: {NTFY_TOPICS}")
log(f" mqtt broker: {MQTT_HOST}:{MQTT_PORT}")
log(f" plug topic: {PLUG_TOPIC}")
log(f" poll interval: {POLL_INTERVAL}s")
log(f" state dir: {STATE_DIR}")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
while True:
try:
client.connect(MQTT_HOST, MQTT_PORT, 60)
break
except Exception as e:
log(f"mqtt connect failed: {e}; retrying in 5s")
time.sleep(5)
client.loop_start()
last_ids = {topic: load_last_id(topic) for topic in NTFY_TOPICS}
for topic, lid in last_ids.items():
log(f" [{topic}] resume from last_id: {lid or '(none)'}")
while True:
for topic in NTFY_TOPICS:
try:
last_ids[topic] = poll_topic(client, topic, last_ids[topic])
except requests.exceptions.RequestException as e:
log(f"[{topic}] http error: {e}")
except Exception as e:
log(f"[{topic}] error: {e!r}")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
+22 -1
View File
@@ -132,7 +132,28 @@ restart SERVICE:
# Restart the full stack # Restart the full stack
restart-all: restart-all:
ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered' ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered doorbell-listener'
# Tail doorbell-listener container logs
doorbell-logs:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f doorbell-listener'
# Restart just the doorbell-listener
restart-doorbell:
ssh {{RPI_HOST}} 'docker restart doorbell-listener'
# Publish a test alert to ntfy (used for E2E testing the listener).
# Usage: just doorbell-publish "Title" "Body"
doorbell-publish TITLE BODY="Someone is at the door":
curl -sS -X POST "https://ntfy.sh/ALERT_klubhaus_topic_test" \
-H "Title: {{TITLE}}" \
-H "Priority: high" \
-d "{{BODY}}" \
-w "\nHTTP %{http_code}\n"
# Build + (re)start the listener (after edits to infra/doorbell-listener/)
doorbell-rebuild:
ssh {{RPI_HOST}} 'cd {{RPI_DIR}}/nr-flow-validator && docker compose build doorbell-listener && docker compose up -d doorbell-listener'
# Show paired zigbee devices and their friendly names # Show paired zigbee devices and their friendly names
z2m-devices: z2m-devices: