Compare commits

..

17 Commits

Author SHA1 Message Date
david 9874b7c25d infra: Pi wiring/runtime config — ttyUSB0, poll 60s, gateway SYS metrics, token via env_file 2026-09-05 00:17:05 +00:00
david 5c747899f6 zigbee: heartbeat uses live RestartCount to detect crash-loops
Old heartbeat trusted the retained zigbee2mqtt/bridge/state topic, which stays
{"state":"online"} even while the container crash-loops (6900+ restarts
went undetected). New logic: RestartCount increase since last run =
authoritative DOWN signal; container running = secondary guard; bridge state
is informational only (z2m publishes it on transition, not continuously, so
missing in a short window is normal).
2026-08-29 23:07:53 -07:00
david f4d6be0364 doorbell: add ntfy token auth to listener poll + authenticated kuma canary
- doorbell-listener.py: poll private ntfy topics with auth= (Bearer token) to stop 403/429; add probe-tag routing to DoorbellProbe
- scripts/kuma-heartbeat.sh: publish health canary to ntfy.sh with Authorization: Bearer (was anonymous -> rate limited -> false down in Kuma); read token from .secrets/ntfy-token
2026-08-28 22:16:52 -07:00
david 0bc38431b9 monitoring: doorbell E2E kuma push heartbeat + systemd timer 2026-08-28 12:58:50 -07:00
david d17c8dea98 doorbell: fake zigbee probe device + E2E validation (no real ring) 2026-08-28 12:56:25 -07:00
david 595796b027 fix: drop flaky ntfy echo check; assert container + publish 200 2026-08-28 12:52:44 -07:00
david d5f5e640bc fix: bound ntfy read-back in doorbell heartbeat (avoid hang) 2026-08-28 10:09:13 -07:00
david 080c7c29bf monitoring: doorbell-listener heartbeat + systemd timer 2026-08-28 10:06:04 -07:00
david 5da59422b3 fix: encode Kuma heartbeat query parameters
Refs #3
2026-08-28 09:42:50 -07:00
david 229e311f68 infra: runtime config fixes + declarative flow updates
Refs #3
2026-08-27 03:15:07 +00:00
david 14581d05dd chore: stop tracking zigbee runtime state + secret files
Refs #3
2026-08-27 03:12:35 +00:00
david 6eccaec7a8 monitoring: kuma heartbeat + systemd timer for zigbee coordinator
Refs #3
2026-08-26 20:09:35 -07:00
david b9c53c1a23 generator: auto-expand declarative plug lists with per-channel mapper
Add build_plugs() to flow2json.py: a flow can declare a plugs: table
({id, name, channels?}) plus single_plugs/hold_plugs lists and the
generator emits all plumbing automatically — state-in node, /set out node,
dashboard widget, and (for multi-channel devices) a mapper function that
toggles each channel (state_1/state_2) instead of the ignored shared
{state:TOGGLE} payload.

Rewrite zigbee-monitor.yaml to the declarative form; Dual Outlet - Couch
is multi-channel (channels: [1,2]). Adding a plug is one line, no manual
node/wire duplication. Single press toggles 5 devices (couch via mapper
verified E2E ON/OFF), hold-release still DJ Booth only.

Refs: david/nr-flow-validator#2
2026-08-15 02:55:29 -07:00
david e5ed67a282 flow: add Dual Outlet - Couch to single-press button automation
Dual plug only responds to per-channel state_1/state_2 commands, not the
shared {state:TOGGLE} payload. Route single-filter through a couch-mapper
that emits {"state_1":"TOGGLE","state_2":"TOGGLE"} so both outlets flip.

Also adds state-in node, /set out node, and dashboard widget showing
state_1/state_2. Verified E2E + 567 Rego checks pass.

Refs: david/nr-flow-validator#2
2026-08-15 02:48:45 -07:00
david 1f77cf3762 infra: register Dual Outlet - Couch, fix Node-RED double-toggle, add meshtastic mqtt 2026-08-11 00:42:15 -07:00
david 110279ae17 doorbell: switch to Squiggle plug, host networking for ntfy access 2026-08-01 21:51:43 -07:00
david bb2bb50962 doorbell: switch plug from Sideboard Lamp to Squiggle 2026-08-01 21:36:56 -07:00
24 changed files with 826 additions and 176 deletions
+25
View File
@@ -1,2 +1,27 @@
output/ output/
secrets.h secrets.h
# zigbee coordinator -> uptime kuma push token
infra/.secrets/
# runtime state & node-red/mosquitto secrets - not versioned
infra/zigbee2mqtt/state.json
infra/zigbee2mqtt/coordinator_backup.json
infra/zigbee2mqtt/database.db
infra/zigbee2mqtt/database.db.*
infra/zigbee2mqtt/log/
infra/mosquitto/data/
infra/mosquitto/log/
infra/mosquitto/config/passwd
infra/nodered/.npm/
infra/nodered/*.backup
infra/nodered/.config.nodes.json
infra/nodered/.config.runtime.json
infra/nodered/.config.users.json
infra/nodered/flows_cred.json
# operator tool pin
mise.toml
# per-service local secrets (env_file) - never commit
infra/**/.env
+28 -76
View File
@@ -41,7 +41,7 @@ services:
- ./zigbee2mqtt:/app/data - ./zigbee2mqtt:/app/data
devices: devices:
- source: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0 - source: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
target: /dev/ttyUSB1 target: /dev/ttyUSB0
permissions: rw permissions: rw
entrypoint: ["/app/data/scripts/entrypoint.sh"] entrypoint: ["/app/data/scripts/entrypoint.sh"]
command: ["/sbin/tini", "--", "node", "index.js"] command: ["/sbin/tini", "--", "node", "index.js"]
@@ -80,105 +80,57 @@ services:
# Listens to ntfy.sh for doorbell events and toggles a smart plug on each alert. # 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) # Topic: ALERT_klubhaus_topic_test (matches ESP32 firmware DEBUG_MODE suffix)
# Action: TOGGLE on zigbee2mqtt/Sideboard Lamp/set # Action: TOGGLE on zigbee2mqtt/Squiggle/set
# On a fresh alert, flash the plug: N toggles spaced FLASH_INTERVAL_SECONDS # On a fresh alert, flash the plug: N toggles spaced FLASH_INTERVAL_SECONDS
# so the total runtime is FLASH_DURATION_SECONDS. # so the total runtime is FLASH_DURATION_SECONDS.
meshtastic-mqtt:
image: eclipse-mosquitto:latest
container_name: meshtastic-mqtt
restart: unless-stopped
ports:
- "1884:1883"
volumes:
- ./meshtastic-mqtt:/mosquitto/config:ro
doorbell-listener: doorbell-listener:
build: ./doorbell-listener build: ./doorbell-listener
container_name: doorbell-listener container_name: doorbell-listener
restart: unless-stopped restart: unless-stopped
network_mode: host
depends_on: depends_on:
mosquitto: mosquitto:
condition: service_healthy condition: service_healthy
volumes: volumes:
- ./doorbell-listener/state:/data/doorbell-listener - ./doorbell-listener/state:/data/doorbell-listener
env_file:
- ./doorbell-listener/.env
environment: environment:
MQTT_HOST: mosquitto MQTT_HOST: 192.168.81.147
MQTT_PORT: "1883" MQTT_PORT: "1883"
PLUG_TOPIC: "zigbee2mqtt/Sideboard Lamp/set" PLUG_TOPIC: "zigbee2mqtt/Squiggle/set"
POLL_INTERVAL: "5" PROBE_PLUG_TOPIC: "zigbee2mqtt/DoorbellProbe/set"
POLL_INTERVAL: "60"
MAX_AGE_SECONDS: "180" MAX_AGE_SECONDS: "180"
FLASH_INTERVAL_SECONDS: "1" FLASH_INTERVAL_SECONDS: "1"
FLASH_DURATION_SECONDS: "14" FLASH_DURATION_SECONDS: "7"
TZ: America/Los_Angeles TZ: America/Los_Angeles
# Audio router: PipeWire + WirePlumber + PulseAudio bridge + BlueZ. # Fake Zigbee smart-plug used by the doorbell E2E probe. Echoes TOGGLE
# Routes audio from librespot / mopidy / phone-Bluetooth to USB DAC (Q5K) # commands back on its state topic so the chain can be validated without
# via ALSA. Falls back to RPi 3.5mm / HDMI if no USB DAC. # flashing a real lamp. No production traffic is routed here.
# PEQ is applied on the DAC side (Q5K's Qudelix app, 20 bands), not here. doorbell-probe:
# NOTE: /run/dbus + /run/user/0 use tmpfs (NOT host bind-mount) — the host's build: ./doorbell-probe
# dbus socket is /run/dbus/system_bus_socket and bind-mounting our container's container_name: doorbell-probe
# socket overwrites it, breaking dockerd's ability to talk to systemd.
audio-bridge:
build: ./audio-bridge
container_name: audio-bridge
restart: unless-stopped restart: unless-stopped
privileged: true
network_mode: host network_mode: host
devices:
- /dev/snd:/dev/snd
tmpfs:
- /run/dbus:size=1M
- /run/user/0:size=1M
cap_add:
- SYS_ADMIN # bluetoothd needs CAP_SYS_ADMIN for HCI
- NET_ADMIN
# Spotify Connect: phone's Spotify app shows "Klubhaus" as a Connect target.
# Outputs audio to audio-bridge via PulseAudio (TCP localhost:4713).
# Built locally on RPi (cross-compile is too slow under qemu).
librespot:
build: ./librespot
image: infra-librespot:latest
container_name: librespot
restart: unless-stopped
depends_on:
- audio-bridge
network_mode: host
# CMD is set in Dockerfile; do not override here.
volumes:
- librespot_cache:/var/cache/librespot
# Mopidy: Qobuz + local files + internet radio. Web UI on http://rpi:6680.
# Outputs audio to audio-bridge via PulseAudio (localhost:4713).
# Get Qobuz app_id + secret from https://developer.qobuz.com/api/v1/oauth
# Built locally from python:3.12-slim + pip install (no ghcr.io image).
mopidy:
build: ./mopidy
image: infra-mopidy:latest
container_name: mopidy
restart: unless-stopped
depends_on:
- audio-bridge
network_mode: host
ports:
- "6680:6680"
volumes:
- ./mopidy/data:/var/lib/mopidy
- ./mopidy/music:/music # drop FLACs in infra/mopidy/music/
environment:
TZ: America/Los_Angeles
QOBUZ_APP_ID: "${QOBUZ_APP_ID:-}"
QOBUZ_APP_SECRET: "${QOBUZ_APP_SECRET:-}"
QOBUZ_USERNAME: "${QOBUZ_USERNAME:-}"
QOBUZ_PASSWORD: "${QOBUZ_PASSWORD:-}"
# Party lock: blocks state changes to DJ Booth plug when locked.
# Watches party-lock/dj-booth/set and swaps mosquitto ACL file.
party-lock:
build: ./party-lock
container_name: party-lock
restart: unless-stopped
depends_on: depends_on:
mosquitto: mosquitto:
condition: service_healthy condition: service_healthy
volumes:
- ./mosquitto/config:/mosquitto/config
- /var/run/docker.sock:/var/run/docker.sock
environment: environment:
MQTT_HOST: mosquitto MQTT_HOST: 192.168.81.147
MQTT_PORT: "1883" MQTT_PORT: "1883"
TZ: America/Los_Angeles SET_TOPIC: "zigbee2mqtt/DoorbellProbe/set"
STATE_TOPIC: "zigbee2mqtt/DoorbellProbe"
volumes: volumes:
librespot_cache: librespot_cache:
+29 -5
View File
@@ -26,13 +26,18 @@ Env vars (all optional):
POLL_INTERVAL seconds between polls, default 30 POLL_INTERVAL seconds between polls, default 30
STATE_DIR default "/data/doorbell-listener" STATE_DIR default "/data/doorbell-listener"
NTFY_TOPICS comma-separated override, default "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic" NTFY_TOPICS comma-separated override, default "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic"
NTFY_TOKEN ntfy access token (tk_...). Adds the auth= query param the
ntfy.sh topics require; without it, polling the private
topics gets 403/429 rate-limited by ntfy.sh.
""" """
import base64
import json import json
import math import math
import os import os
import sys import sys
import threading import threading
import time import time
import urllib.parse
from pathlib import Path from pathlib import Path
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@@ -40,10 +45,15 @@ import requests
DEFAULT_TOPICS = "ALERT_klubhaus_topic_test,ALERT_klubhaus_topic" 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()] NTFY_TOPICS = [t.strip() for t in os.environ.get("NTFY_TOPICS", DEFAULT_TOPICS).split(",") if t.strip()]
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto") MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
PLUG_TOPIC = os.environ.get("PLUG_TOPIC", "zigbee2mqtt/Sideboard Lamp/set") PLUG_TOPIC = os.environ.get("PLUG_TOPIC", "zigbee2mqtt/Sideboard Lamp/set")
# Canary/"probe" alerts (tagged doorbell-probe) are routed to a fake zigbee
# device instead of the real plug, so E2E tests don't flash a real lamp.
PROBE_PLUG_TOPIC = os.environ.get("PROBE_PLUG_TOPIC", "zigbee2mqtt/DoorbellProbe/set")
PROBE_TAG = os.environ.get("PROBE_TAG", "doorbell-probe")
STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/doorbell-listener")) STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/doorbell-listener"))
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30")) POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
# Drop messages older than this many seconds (replay protection after restart). # Drop messages older than this many seconds (replay protection after restart).
@@ -68,6 +78,14 @@ def log(msg: str) -> None:
print(msg, flush=True) print(msg, flush=True)
def auth_param() -> str:
"""Raw base64 of 'Bearer <token>' with no trailing '=' — mirrors the
doorbell frontend's buildAuthParam(). Empty string when no token set."""
if not NTFY_TOKEN:
return ""
return base64.b64encode(f"Bearer {NTFY_TOKEN}".encode()).decode().rstrip("=")
def last_id_path(topic: str) -> Path: def last_id_path(topic: str) -> Path:
safe = topic.replace("/", "_").replace(" ", "_") safe = topic.replace("/", "_").replace(" ", "_")
return STATE_DIR / f"last_id_{safe}" return STATE_DIR / f"last_id_{safe}"
@@ -85,7 +103,7 @@ def save_last_id(topic: str, msg_id: str) -> None:
last_id_path(topic).write_text(msg_id) last_id_path(topic).write_text(msg_id)
def start_flash(client: mqtt.Client, count: int, interval: float) -> None: def start_flash(client: mqtt.Client, count: int, interval: float, topic: str) -> None:
"""Spawn a thread that publishes `count` TOGGLEs spaced `interval` seconds. """Spawn a thread that publishes `count` TOGGLEs spaced `interval` seconds.
Each alert gets its own thread; overlapping flashes will publish toggles Each alert gets its own thread; overlapping flashes will publish toggles
@@ -93,11 +111,11 @@ def start_flash(client: mqtt.Client, count: int, interval: float) -> None:
regardless of how many threads are running. regardless of how many threads are running.
""" """
def run() -> None: def run() -> None:
log(f"flash: starting {count} toggles @ {interval}s") log(f"flash: starting {count} toggles @ {interval}s -> {topic}")
for i in range(count): for i in range(count):
payload = json.dumps({"state": "TOGGLE"}) payload = json.dumps({"state": "TOGGLE"})
client.publish(PLUG_TOPIC, payload) client.publish(topic, payload)
log(f"flash {i + 1}/{count} -> {PLUG_TOPIC}") log(f"flash {i + 1}/{count} -> {topic}")
if i < count - 1: if i < count - 1:
time.sleep(interval) time.sleep(interval)
log("flash: complete") log("flash: complete")
@@ -108,6 +126,8 @@ def start_flash(client: mqtt.Client, count: int, interval: float) -> None:
def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str: def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str:
"""Poll one topic. Returns the latest message id seen (or last_id).""" """Poll one topic. Returns the latest message id seen (or last_id)."""
url = f"https://ntfy.sh/{topic}/json?poll=1" url = f"https://ntfy.sh/{topic}/json?poll=1"
if auth_param():
url += f"&auth={urllib.parse.quote(auth_param())}"
if last_id: if last_id:
url += f"&since={last_id}" url += f"&since={last_id}"
log(f"[{topic}] polling {url}") log(f"[{topic}] polling {url}")
@@ -146,7 +166,11 @@ def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str:
continue continue
log(f"[{topic}] alert: id={msg_id} age={age}s title={title!r} message={message!r}") log(f"[{topic}] alert: id={msg_id} age={age}s title={title!r} message={message!r}")
start_flash(client, n_flashes, FLASH_INTERVAL_SECONDS) tags = event.get("tags") or []
is_probe = PROBE_TAG in tags or event.get("title") == PROBE_TAG
target = PROBE_PLUG_TOPIC if is_probe else PLUG_TOPIC
log(f"[{topic}] probe={is_probe} -> {target}")
start_flash(client, n_flashes, FLASH_INTERVAL_SECONDS, target)
return newest_id return newest_id
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
set -u
KUMA_BASE="${KUMA_BASE:-https://up.notsosm.art}"
TOKEN_FILE="${KUMA_TOKEN_FILE:-/root/nr-flow-validator/infra/.secrets/doorbell-kuma-push-token}"
CANARY_TOPIC="${CANARY_TOPIC:-ALERT_klubhaus_health}"
CONTAINER="${DB_CONTAINER:-doorbell-listener}"
[ -s "$TOKEN_FILE" ] || exit 1
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
NTFY_TOKEN="${NTFY_TOKEN:-$(tr -d '[:space:]' < /root/nr-flow-validator/infra/.secrets/ntfy-token 2>/dev/null || true)}"
reason=""
# 1. listener container liveness
running=$(docker inspect "$CONTAINER" --format '{{.State.Running}}' 2>/dev/null)
if [ "$running" != "true" ]; then
reason="container_running=${running:-unknown}"
else
# 2. ntfy.sh round-trip: publish a unique canary, read it back.
# Proves the ntfy.sh leg we depend on (publish + subscribe) works,
# without us owning the service.
# Publish a canary. A 200 proves ntfy.sh (which we don't own) is
# reachable and accepting our messages. We deliberately do NOT read it
# back: ntfy.sh's cache/echo is unreliable for an always-on probe and
# caused false "down" alerts. The subscribe/deliver leg is continuously
# exercised by the production listener consuming real alerts.
url="https://ntfy.sh/${CANARY_TOPIC}"
if ! curl -fsS -m 10 -o /dev/null -X POST "$url" -H "Authorization: Bearer ${NTFY_TOKEN}" -d "canary-$(date +%s)" 2>/dev/null; then
reason="${reason}ntfy_publish_failed"
fi
fi
if [ -n "$reason" ]; then
status="down"
msg="doorbell: ${reason}"
else
status="up"
msg="doorbell ok"
fi
curl -fsS -m 10 -o /dev/null --get \
--data-urlencode "status=$status" \
--data-urlencode "msg=$msg" \
"$KUMA_BASE/api/push/$TOKEN"
+5
View File
@@ -0,0 +1,5 @@
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
WORKDIR /app
COPY fake-device.py .
CMD ["uv", "run", "--no-cache", "fake-device.py"]
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Validate the full doorbell chain WITHOUT flashing a real lamp:
# ntfy.sh -> doorbell-listener -> MQTT -> fake zigbee device
# Publishes a probe canary (tagged "doorbell-probe") to the ntfy test topic,
# then waits for the fake device to echo a TOGGLE on its state topic.
set -u
NTFY_TOPIC="${NTFY_TOPIC:-ALERT_klubhaus_topic_test}"
PROBE_STATE="${PROBE_STATE:-zigbee2mqtt/DoorbellProbe}"
MQTT_HOST="${MQTT_HOST:-127.0.0.1}"
TIMEOUT="${TIMEOUT:-25}"
marker="e2e-$(date +%s)-$$"
echo "publishing probe canary to ntfy.sh/$NTFY_TOPIC"
curl -fsS -m 10 -o /dev/null -X POST "https://ntfy.sh/$NTFY_TOPIC" \
-H "Title: doorbell-probe" \
-H "Tags: doorbell-probe" \
-d "$marker" || { echo "E2E FAIL: ntfy publish failed"; exit 2; }
echo "waiting for TOGGLE echo on $PROBE_STATE (<=${TIMEOUT}s)"
if timeout "$TIMEOUT" docker exec mosquitto mosquitto_sub -h 127.0.0.1 -t "$PROBE_STATE" -C 1 -W "$TIMEOUT" 2>/dev/null | grep -q TOGGLE; then
echo "E2E OK"
exit 0
else
echo "E2E FAIL: no TOGGLE echo on $PROBE_STATE within ${TIMEOUT}s"
exit 1
fi
@@ -0,0 +1,41 @@
#!/bin/bash
set -u
KUMA_BASE="${KUMA_BASE:-https://up.notsosm.art}"
TOKEN_FILE="${KUMA_TOKEN_FILE:-/root/nr-flow-validator/infra/.secrets/doorbell-e2e-kuma-push-token}"
NTFY_TOPIC="${NTFY_TOPIC:-ALERT_klubhaus_topic_test}"
PROBE_STATE="${PROBE_STATE:-zigbee2mqtt/DoorbellProbe}"
TIMEOUT="${TIMEOUT:-25}"
[ -s "$TOKEN_FILE" ] || exit 1
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
reason=""
marker="e2e-$(date +%s)-$$"
# 1. publish probe canary (tagged doorbell-probe -> routes to fake device)
if ! curl -fsS -m 10 -o /dev/null -X POST "https://ntfy.sh/$NTFY_TOPIC" \
-H "Title: doorbell-probe" -H "Tags: doorbell-probe" -d "$marker" 2>/dev/null; then
reason="ntfy_publish_failed"
else
# 2. wait for the fake device to echo a TOGGLE on its state topic
if timeout "$TIMEOUT" docker exec mosquitto mosquitto_sub -h 127.0.0.1 \
-t "$PROBE_STATE" -C 1 -W "$TIMEOUT" 2>/dev/null | grep -q TOGGLE; then
: # ok
else
reason="no_echo_within_${TIMEOUT}s"
fi
fi
if [ -n "$reason" ]; then
status="down"
msg="doorbell-e2e: ${reason}"
else
status="up"
msg="doorbell e2e ok"
fi
curl -fsS -m 10 -o /dev/null --get \
--data-urlencode "status=$status" \
--data-urlencode "msg=$msg" \
"$KUMA_BASE/api/push/$TOKEN"
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "paho-mqtt>=2.0",
# ]
# ///
"""Fake Zigbee device for doorbell E2E validation.
Subscribes to a "set" topic and echoes a TOGGLE back to a "state" topic
(retained), emulating a Zigbee smart plug at the MQTT layer. This lets the
doorbell E2E probe confirm the full chain (ntfy.sh -> listener -> MQTT -> device)
without toggling a real lamp.
Env vars:
MQTT_HOST default "mosquitto"
MQTT_PORT default 1883
SET_TOPIC default "zigbee2mqtt/DoorbellProbe/set"
STATE_TOPIC default "zigbee2mqtt/DoorbellProbe"
"""
import json
import os
import paho.mqtt.client as mqtt
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
SET_TOPIC = os.environ.get("SET_TOPIC", "zigbee2mqtt/DoorbellProbe/set")
STATE_TOPIC = os.environ.get("STATE_TOPIC", "zigbee2mqtt/DoorbellProbe")
def log(msg: str) -> None:
print(msg, flush=True)
def on_connect(client, userdata, flags, reason_code, properties=None):
log(f"connected to {MQTT_HOST}:{MQTT_PORT} (rc={reason_code})")
client.subscribe(SET_TOPIC)
def on_message(client, userdata, msg):
log(f"echo -> {STATE_TOPIC} (from {msg.topic})")
client.publish(STATE_TOPIC, json.dumps({"state": "TOGGLE", "echo": True}), retain=True)
def main() -> None:
log(f"fake-device: {SET_TOPIC} -> {STATE_TOPIC}")
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
while True:
try:
client.connect(MQTT_HOST, MQTT_PORT, 60)
break
except Exception as e:
log(f"connect failed: {e}; retry in 5s")
import time
time.sleep(5)
client.loop_forever()
if __name__ == "__main__":
main()
+2
View File
@@ -0,0 +1,2 @@
topic readwrite msh/#
topic read $SYS/#
+7
View File
@@ -0,0 +1,7 @@
listener 1883
allow_anonymous true
acl_file /mosquitto/config/acl.conf
persistence false
log_dest stdout
sys_interval 10
Regular → Executable
View File
+3
View File
@@ -2,7 +2,10 @@ listener 1883
allow_anonymous true allow_anonymous true
acl_file /mosquitto/config/acl.conf acl_file /mosquitto/config/acl.conf
password_file /mosquitto/config/passwd
persistence true persistence true
persistence_location /mosquitto/data persistence_location /mosquitto/data
log_dest file /mosquitto/log/mosquitto.log log_dest file /mosquitto/log/mosquitto.log
sys_interval 10
+440
View File
@@ -0,0 +1,440 @@
[
{
"id": "brd05c91e3",
"type": "mqtt-broker",
"name": "mosquitto",
"broker": "mosquitto",
"port": "1883",
"clientid": "",
"autoConnect": true,
"usetls": false,
"protocolVersion": "4",
"keepalive": "60",
"cleansession": true,
"birthTopic": "",
"birthQos": "0",
"birthPayload": "",
"closeTopic": "",
"closeQos": "0",
"closePayload": "",
"willTopic": "",
"willQos": "0",
"willPayload": ""
},
{
"id": "uib31315d0b",
"type": "ui-base",
"name": "Dashboard",
"path": "/dashboard",
"includeClientData": true,
"acceptsClientConfig": [
"ui-notification",
"ui-control"
],
"showPathInSidebar": false,
"navigationStyle": "default",
"titleBarStyle": "default"
},
{
"id": "uit1f2d1d17",
"type": "ui-theme",
"name": "Default Theme",
"colors": {
"surface": "#ffffff",
"primary": "#0094CE",
"bgPage": "#eeeeee",
"groupBg": "#ffffff",
"groupOutline": "#cccccc"
},
"sizes": {
"pagePadding": "12px",
"groupGap": "12px",
"groupBorderRadius": "4px",
"widgetGap": "12px"
}
},
{
"id": "uipc7a69acb",
"type": "ui-page",
"name": "Home",
"ui": "uib31315d0b",
"icon": "home",
"path": "/home",
"theme": "uit1f2d1d17",
"layout": "flex",
"order": 0,
"className": "",
"visible": true,
"disabled": false
},
{
"id": "uig32ecccd2",
"type": "ui-group",
"name": "Plugs",
"page": "uipc7a69acb",
"width": "6",
"height": "1",
"order": 1,
"showTitle": true,
"className": "",
"visible": true,
"disabled": false
},
{
"id": "f810d3244",
"type": "tab",
"label": "Zigbee Monitor",
"disabled": false,
"info": "Button dispatches single vs hold-release to different plug sets. Plug plumbing (state-in, /set out, dashboard widget, per-channel mapper) is generated by flow2json.py from the plugs lists below.",
"env": []
},
{
"id": "zigbee-state-squiggle",
"type": "mqtt in",
"wires": [
[
"text-squiggle"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Squiggle",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 80
},
{
"id": "text-squiggle",
"type": "ui-text",
"wires": [],
"name": "Squiggle",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 1,
"label": "Squiggle",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 150
},
{
"id": "plug-out-squiggle",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Squiggle/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 220
},
{
"id": "zigbee-state-sideboard",
"type": "mqtt in",
"wires": [
[
"text-sideboard"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Sideboard Lamp",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 290
},
{
"id": "text-sideboard",
"type": "ui-text",
"wires": [],
"name": "Sideboard Lamp",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 2,
"label": "Sideboard Lamp",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 360
},
{
"id": "plug-out-sideboard",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Sideboard Lamp/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 430
},
{
"id": "zigbee-state-bamboo",
"type": "mqtt in",
"wires": [
[
"text-bamboo"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Bamboo Lights",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 500
},
{
"id": "text-bamboo",
"type": "ui-text",
"wires": [],
"name": "Bamboo Lights",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 3,
"label": "Bamboo Lights",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 570
},
{
"id": "plug-out-bamboo",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Bamboo Lights/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 640
},
{
"id": "zigbee-state-couch",
"type": "mqtt in",
"wires": [
[
"text-couch"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Dual Outlet - Couch",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 710
},
{
"id": "text-couch",
"type": "ui-text",
"wires": [],
"name": "Dual Outlet - Couch",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 4,
"label": "Dual Outlet - Couch",
"format": "{{msg.payload.state_1}} / {{msg.payload.state_2}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 780
},
{
"id": "mapper-couch",
"type": "function",
"wires": [
[
"plug-out-couch"
]
],
"name": "Toggle each outlet",
"func": "msg.payload = {state_1: 'TOGGLE', state_2: 'TOGGLE'};\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"z": "f810d3244",
"x": 450,
"y": 850
},
{
"id": "plug-out-couch",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Dual Outlet - Couch/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 920
},
{
"id": "zigbee-state-djbooth",
"type": "mqtt in",
"wires": [
[
"text-djbooth"
]
],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/DJ Booth",
"qos": "0",
"datatype": "auto",
"z": "f810d3244",
"x": 170,
"y": 990
},
{
"id": "text-djbooth",
"type": "ui-text",
"wires": [],
"name": "DJ Booth",
"group": "uig32ecccd2",
"width": "6",
"height": "2",
"order": 5,
"label": "DJ Booth",
"format": "{{msg.payload.state}}",
"layout": "row-spread",
"className": "",
"z": "f810d3244",
"x": 770,
"y": 1060
},
{
"id": "plug-out-djbooth",
"type": "mqtt out",
"wires": [],
"name": "",
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/DJ Booth/set",
"qos": "2",
"retain": false,
"z": "f810d3244",
"x": 770,
"y": 1130
},
{
"id": "button-in",
"type": "mqtt in",
"wires": [
[
"single-filter",
"hold-filter"
]
],
"name": "",
"x": 170,
"y": 80,
"broker": "brd05c91e3",
"topic": "zigbee2mqtt/Door Button",
"qos": "0",
"datatype": "auto",
"z": "f810d3244"
},
{
"id": "single-filter",
"type": "function",
"wires": [
[
"plug-out-squiggle",
"plug-out-sideboard",
"plug-out-bamboo",
"mapper-couch"
]
],
"name": "Single click",
"x": 450,
"y": 80,
"func": "const now = Date.now();\nconst last = context.get('lastTime') || 0;\nif (now - last < 50) return null;\ncontext.set('lastTime', now);\ntry {\n const p = JSON.parse(msg.payload);\n if (p.action === 'single') {\n msg.payload = { state: 'TOGGLE' };\n return msg;\n }\n} catch (e) {}\nreturn null;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"z": "f810d3244"
},
{
"id": "hold-filter",
"type": "function",
"wires": [
[
"plug-out-djbooth"
]
],
"name": "Hold release",
"x": 450,
"y": 80,
"func": "const now = Date.now();\nconst last = context.get('lastTime') || 0;\nif (now - last < 50) return null;\ncontext.set('lastTime', now);\ntry {\n const p = JSON.parse(msg.payload);\n if (p.action === 'release') {\n msg.payload = { state: 'TOGGLE' };\n return msg;\n }\n} catch (e) {}\nreturn null;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"z": "f810d3244"
},
{
"id": "permit-btn",
"type": "ui-button",
"wires": [
[
"mqtt-out"
]
],
"name": "Permit Join (60s)",
"x": 170,
"y": 220,
"group": "uig32ecccd2",
"payload": "{\"value\": true, \"time\": 60}",
"payloadType": "json",
"topic": "zigbee2mqtt/bridge/request/permit_join",
"width": "3",
"height": "1",
"order": 9,
"className": "",
"z": "f810d3244",
"icon": "",
"label": "Permit Join (60s)"
},
{
"id": "mqtt-out",
"type": "mqtt out",
"wires": [],
"name": "",
"x": 770,
"y": 220,
"broker": "brd05c91e3",
"topic": "",
"qos": "2",
"retain": false,
"z": "f810d3244"
}
]
@@ -0,0 +1,8 @@
[Unit]
Description=Doorbell E2E heartbeat to Uptime Kuma
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/root/nr-flow-validator/infra/doorbell-probe/e2e-kuma-heartbeat.sh
@@ -0,0 +1,10 @@
[Unit]
Description=Run doorbell E2E heartbeat every minute
[Timer]
OnCalendar=*-*-* *:*:00
AccuracySec=5s
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,8 @@
[Unit]
Description=Doorbell listener heartbeat to Uptime Kuma
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/root/nr-flow-validator/infra/doorbell-listener/scripts/kuma-heartbeat.sh
@@ -0,0 +1,10 @@
[Unit]
Description=Run doorbell listener heartbeat every minute
[Timer]
OnCalendar=*-*-* *:*:00
AccuracySec=5s
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,8 @@
[Unit]
Description=Zigbee coordinator heartbeat to Uptime Kuma
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
ExecStart=/root/nr-flow-validator/infra/zigbee2mqtt/scripts/kuma-heartbeat.sh
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Run zigbee coordinator heartbeat every minute
[Timer]
OnCalendar=*-*-* *:*:00
AccuracySec=5s
Persistent=true
[Install]
WantedBy=timers.target
+9 -8
View File
@@ -3,7 +3,7 @@ mqtt:
base_topic: zigbee2mqtt base_topic: zigbee2mqtt
server: mqtt://mosquitto:1883 server: mqtt://mosquitto:1883
serial: serial:
port: /dev/ttyUSB1 port: /dev/ttyUSB0
adapter: blz adapter: blz
baudrate: 2000000 baudrate: 2000000
rtscts: false rtscts: false
@@ -36,26 +36,27 @@ advanced:
- 25 - 25
- 93 - 93
- 235 - 235
frontend: frontend:
enabled: true enabled: true
port: 8080 port: 8080
permit_join: false permit_join: false
groups: groups:
"1": '1':
friendly_name: all_plugs friendly_name: all_plugs
devices: devices:
- Squiggle - Squiggle
- Sideboard Lamp - Sideboard Lamp
- Bamboo Lights - Bamboo Lights
devices: devices:
"0xffffb40e0604fbd0": '0xffffb40e0604fbd0':
friendly_name: Squiggle friendly_name: Squiggle
"0xffffb40e06065536": '0xffffb40e06065536':
friendly_name: Door Button friendly_name: Door Button
"0xffffb40e0603c9e4": '0xffffb40e0603c9e4':
friendly_name: Sideboard Lamp friendly_name: Sideboard Lamp
"0xffffb40e06050af6": '0xffffb40e06050af6':
friendly_name: Bamboo Lights friendly_name: Bamboo Lights
"0xffffb40e0603ef11": '0xffffb40e0603ef11':
friendly_name: DJ Booth friendly_name: DJ Booth
'0x4ce175523f710000':
friendly_name: Dual Outlet - Couch
-26
View File
@@ -1,26 +0,0 @@
{
"metadata": {
"format": "zigpy/open-coordinator-backup",
"version": 1,
"source": "zigbee-herdsman@10.5.0",
"internal": {
"date": "2026-07-01T05:51:05.408Z"
}
},
"stack_specific": {},
"coordinator_ieee": "0000000000000000",
"pan_id": "1a62",
"extended_pan_id": "dddddddddddddddd",
"nwk_update_id": 0,
"security_level": 5,
"channel": 11,
"channel_mask": [
11
],
"network_key": {
"key": "01030507090b0d0f00020406080a0c0d",
"sequence_number": 0,
"frame_counter": 57407
},
"devices": []
}
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -u
KUMA_BASE="${KUMA_BASE:-https://up.notsosm.art}"
TOKEN_FILE="/root/nr-flow-validator/infra/.secrets/kuma-push-token"
STATE_FILE="/root/nr-flow-validator/infra/.secrets/.zb-restart-count"
[ -s "$TOKEN_FILE" ] || exit 1
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
reason=""
# 1. Container liveness.
running=$(docker inspect zigbee2mqtt --format '{{.State.Running}}' 2>/dev/null)
[ "$running" = "true" ] || reason="container_running=${running:-unknown}"
# 2. Crash-loop detection: RestartCount climbing since last run.
rc=$(docker inspect zigbee2mqtt --format '{{.RestartCount}}' 2>/dev/null | tr -d '[:space:]')
if [ -n "$rc" ] && [ -f "$STATE_FILE" ]; then
prev=$(tr -d '[:space:]' < "$STATE_FILE")
if [ "${prev:-0}" -lt "$rc" ] 2>/dev/null; then
reason="${reason:+$reason }crashed_recently(restarts=$rc from $prev)"
fi
fi
printf '%s' "${rc:-0}" > "$STATE_FILE"
# 3. Bridge state — informational only. z2m publishes /bridge/state on
# transitions, NOT continuously, so a missing message in a short window
# is expected during steady operation and must not cause a false DOWN.
# The restart-count check above is the authoritative crash-loop signal.
state=$(docker exec mosquitto mosquitto_sub -R -t 'zigbee2mqtt/bridge/state' -C 1 -W 6 2>/dev/null | tr -d '[:space:]')
if [ -z "$reason" ] && [ "$state" != '{"state":"online"}' ]; then
note="bridge_idle_or_down"
fi
if [ -n "$reason" ]; then
status="down"
msg="coordinator: ${reason}"
else
status="up"
msg="coordinator ok${note:+ ($note)}"
fi
curl -fsS -m 10 -o /dev/null --get \
--data-urlencode "status=$status" \
--data-urlencode "msg=$msg" \
"$KUMA_BASE/api/push/$TOKEN"
-60
View File
@@ -1,60 +0,0 @@
{
"0xffffb40e0604fbd0": {
"state": "ON",
"power_on_behavior": "off",
"update": {
"state": "available",
"installed_version": 268513372,
"latest_version": 268513381,
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null
},
"linkquality": 255
},
"0xffffb40e06065536": {
"battery": 83,
"update": {
"state": "available",
"installed_version": 35,
"latest_version": 47,
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/Button_PROD_OTA_V47_v1.00.47.ota",
"latest_release_notes": null
}
},
"0xffffb40e0603c9e4": {
"state": "ON",
"power_on_behavior": "off",
"update": {
"state": "available",
"installed_version": 268513372,
"latest_version": 268513381,
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null
},
"linkquality": 255
},
"0xffffb40e06050af6": {
"state": "ON",
"power_on_behavior": "off",
"update": {
"state": "available",
"installed_version": 268513372,
"latest_version": 268513381,
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null
},
"linkquality": 140
},
"0xffffb40e0603ef11": {
"state": "OFF",
"power_on_behavior": "off",
"update": {
"state": "available",
"installed_version": 268513372,
"latest_version": 268513381,
"latest_source": "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/images/ThirdReality/SmartPlug_Zigbee_PROD_OTA_V101_1.01.01.ota",
"latest_release_notes": null
},
"linkquality": 255
}
}
+1 -1
View File
@@ -172,7 +172,7 @@ zigbee-devices:
# Show per-device persisted state (on/off, brightness, etc.) # Show per-device persisted state (on/off, brightness, etc.)
zigbee-state: zigbee-state:
ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/zigbee2mqtt/state.json' | python3 -m json.tool | head -60 ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/nr-flow-validator/infra/zigbee2mqtt/state.json' | python3 -m json.tool | head -60
# ============================================================ # ============================================================
# MQTT — publish, subscribe, watch, simulate devices # MQTT — publish, subscribe, watch, simulate devices