Compare commits
4 Commits
c1551c3d1e
...
758edc0a9a
| Author | SHA1 | Date | |
|---|---|---|---|
| 758edc0a9a | |||
| 1ef49652b0 | |||
| c9ea2b9393 | |||
| 90927ccc0e |
@@ -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`.
|
||||||
|
|||||||
@@ -53,3 +53,28 @@ 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
|
||||||
|
# On a fresh alert, flash the plug: N toggles spaced FLASH_INTERVAL_SECONDS
|
||||||
|
# so the total runtime is FLASH_DURATION_SECONDS.
|
||||||
|
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"
|
||||||
|
MAX_AGE_SECONDS: "180"
|
||||||
|
FLASH_INTERVAL_SECONDS: "2"
|
||||||
|
FLASH_DURATION_SECONDS: "15"
|
||||||
|
TZ: America/Los_Angeles
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Persisted listener state — last seen ntfy message id
|
||||||
|
state/
|
||||||
|
__pycache__/
|
||||||
@@ -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,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.10"
|
||||||
|
# dependencies = [
|
||||||
|
# "requests",
|
||||||
|
# "paho-mqtt>=2.0",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""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 math
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
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"))
|
||||||
|
# Drop messages older than this many seconds (replay protection after restart).
|
||||||
|
MAX_AGE_SECONDS = int(os.environ.get("MAX_AGE_SECONDS", "180"))
|
||||||
|
# On a fresh alert, flash the plug: TOGGLE every FLASH_INTERVAL_SECONDS,
|
||||||
|
# repeated so the total runtime is FLASH_DURATION_SECONDS. Count is rounded
|
||||||
|
# UP to the nearest even integer so the lamp ends in the state it started
|
||||||
|
# in (an even number of toggles returns to the original state).
|
||||||
|
FLASH_INTERVAL_SECONDS = float(os.environ.get("FLASH_INTERVAL_SECONDS", "2"))
|
||||||
|
FLASH_DURATION_SECONDS = float(os.environ.get("FLASH_DURATION_SECONDS", "15"))
|
||||||
|
|
||||||
|
|
||||||
|
def flash_count() -> int:
|
||||||
|
"""Number of toggles per alert. Always >= 2 and even."""
|
||||||
|
n = max(2, math.ceil(FLASH_DURATION_SECONDS / FLASH_INTERVAL_SECONDS))
|
||||||
|
if n % 2:
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
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 start_flash(client: mqtt.Client, count: int, interval: float) -> None:
|
||||||
|
"""Spawn a thread that publishes `count` TOGGLEs spaced `interval` seconds.
|
||||||
|
|
||||||
|
Each alert gets its own thread; overlapping flashes will publish toggles
|
||||||
|
concurrently but z2m just flips state per toggle, so the lamp alternates
|
||||||
|
regardless of how many threads are running.
|
||||||
|
"""
|
||||||
|
def run() -> None:
|
||||||
|
log(f"flash: starting {count} toggles @ {interval}s")
|
||||||
|
for i in range(count):
|
||||||
|
payload = json.dumps({"state": "TOGGLE"})
|
||||||
|
client.publish(PLUG_TOPIC, payload)
|
||||||
|
log(f"flash {i + 1}/{count} -> {PLUG_TOPIC}")
|
||||||
|
if i < count - 1:
|
||||||
|
time.sleep(interval)
|
||||||
|
log("flash: complete")
|
||||||
|
|
||||||
|
threading.Thread(target=run, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
now = time.time()
|
||||||
|
n_flashes = flash_count()
|
||||||
|
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", "")
|
||||||
|
msg_time = event.get("time", 0)
|
||||||
|
title = event.get("title", "")
|
||||||
|
message = event.get("message", "")
|
||||||
|
|
||||||
|
# Update last_id for every message we see, even stale ones, so
|
||||||
|
# restarts don't replay them.
|
||||||
|
if msg_id:
|
||||||
|
newest_id = msg_id
|
||||||
|
save_last_id(topic, msg_id)
|
||||||
|
|
||||||
|
# Freshness filter: only act on recent messages.
|
||||||
|
age = int(now - msg_time) if msg_time else None
|
||||||
|
if age is not None and age > MAX_AGE_SECONDS:
|
||||||
|
log(f"[{topic}] skip stale: id={msg_id} age={age}s > {MAX_AGE_SECONDS}s")
|
||||||
|
continue
|
||||||
|
|
||||||
|
log(f"[{topic}] alert: id={msg_id} age={age}s title={title!r} message={message!r}")
|
||||||
|
start_flash(client, n_flashes, FLASH_INTERVAL_SECONDS)
|
||||||
|
|
||||||
|
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" max age: {MAX_AGE_SECONDS}s")
|
||||||
|
n_flashes = flash_count()
|
||||||
|
log(f" flash: {n_flashes} toggles @ {FLASH_INTERVAL_SECONDS}s "
|
||||||
|
f"({n_flashes * FLASH_INTERVAL_SECONDS}s total, even -> ends at start)")
|
||||||
|
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
|
||||||
@@ -9,6 +9,34 @@ serial:
|
|||||||
rtscts: false
|
rtscts: false
|
||||||
advanced:
|
advanced:
|
||||||
log_level: debug
|
log_level: debug
|
||||||
|
network_key:
|
||||||
|
- 146
|
||||||
|
- 30
|
||||||
|
- 124
|
||||||
|
- 1
|
||||||
|
- 70
|
||||||
|
- 32
|
||||||
|
- 235
|
||||||
|
- 196
|
||||||
|
- 27
|
||||||
|
- 113
|
||||||
|
- 122
|
||||||
|
- 230
|
||||||
|
- 7
|
||||||
|
- 177
|
||||||
|
- 134
|
||||||
|
- 45
|
||||||
|
pan_id: 3926
|
||||||
|
ext_pan_id:
|
||||||
|
- 225
|
||||||
|
- 105
|
||||||
|
- 42
|
||||||
|
- 213
|
||||||
|
- 37
|
||||||
|
- 25
|
||||||
|
- 93
|
||||||
|
- 235
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
enabled: true
|
enabled: true
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user