From 90927ccc0e2d74102b15980660f994badc23d695 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Wed, 1 Jul 2026 00:30:08 -0700 Subject: [PATCH] =?UTF-8?q?Add=20doorbell-listener:=20ntfy=20=E2=86=92=20z?= =?UTF-8?q?igbee2mqtt=20Sideboard=20Lamp=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 3 +- infra/compose.yaml | 20 +++ infra/doorbell-listener/.gitignore | 3 + infra/doorbell-listener/Dockerfile | 6 + infra/doorbell-listener/doorbell-listener.py | 137 +++++++++++++++++++ justfile | 23 +++- 6 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 infra/doorbell-listener/.gitignore create mode 100644 infra/doorbell-listener/Dockerfile create mode 100644 infra/doorbell-listener/doorbell-listener.py diff --git a/README.md b/README.md index d237a9e..2a2099a 100644 --- a/README.md +++ b/README.md @@ -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 z2m-devices` — paired zigbee devices + group membership - `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 doorbell-publish "Title" "Body"` — publish a test alert to ntfy - `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`. diff --git a/infra/compose.yaml b/infra/compose.yaml index bb83351..13666ef 100644 --- a/infra/compose.yaml +++ b/infra/compose.yaml @@ -53,3 +53,23 @@ services: TZ: America/Los_Angeles DASHBOARD_BASE_HREF: /dashboard/ 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 + + diff --git a/infra/doorbell-listener/.gitignore b/infra/doorbell-listener/.gitignore new file mode 100644 index 0000000..ce13187 --- /dev/null +++ b/infra/doorbell-listener/.gitignore @@ -0,0 +1,3 @@ +# Persisted listener state — last seen ntfy message id +state/ +__pycache__/ diff --git a/infra/doorbell-listener/Dockerfile b/infra/doorbell-listener/Dockerfile new file mode 100644 index 0000000..05ee366 --- /dev/null +++ b/infra/doorbell-listener/Dockerfile @@ -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"] diff --git a/infra/doorbell-listener/doorbell-listener.py b/infra/doorbell-listener/doorbell-listener.py new file mode 100644 index 0000000..f1a7cc0 --- /dev/null +++ b/infra/doorbell-listener/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 diff --git a/justfile b/justfile index 05dd6ce..f640038 100644 --- a/justfile +++ b/justfile @@ -132,7 +132,28 @@ restart SERVICE: # Restart the full stack 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 z2m-devices: