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/
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
devices:
- source: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
target: /dev/ttyUSB1
target: /dev/ttyUSB0
permissions: rw
entrypoint: ["/app/data/scripts/entrypoint.sh"]
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.
# 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
# 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:
build: ./doorbell-listener
container_name: doorbell-listener
restart: unless-stopped
network_mode: host
depends_on:
mosquitto:
condition: service_healthy
volumes:
- ./doorbell-listener/state:/data/doorbell-listener
env_file:
- ./doorbell-listener/.env
environment:
MQTT_HOST: mosquitto
MQTT_HOST: 192.168.81.147
MQTT_PORT: "1883"
PLUG_TOPIC: "zigbee2mqtt/Sideboard Lamp/set"
POLL_INTERVAL: "5"
PLUG_TOPIC: "zigbee2mqtt/Squiggle/set"
PROBE_PLUG_TOPIC: "zigbee2mqtt/DoorbellProbe/set"
POLL_INTERVAL: "60"
MAX_AGE_SECONDS: "180"
FLASH_INTERVAL_SECONDS: "1"
FLASH_DURATION_SECONDS: "14"
FLASH_DURATION_SECONDS: "7"
TZ: America/Los_Angeles
# Audio router: PipeWire + WirePlumber + PulseAudio bridge + BlueZ.
# Routes audio from librespot / mopidy / phone-Bluetooth to USB DAC (Q5K)
# via ALSA. Falls back to RPi 3.5mm / HDMI if no USB DAC.
# PEQ is applied on the DAC side (Q5K's Qudelix app, 20 bands), not here.
# NOTE: /run/dbus + /run/user/0 use tmpfs (NOT host bind-mount) — the host's
# dbus socket is /run/dbus/system_bus_socket and bind-mounting our container's
# socket overwrites it, breaking dockerd's ability to talk to systemd.
audio-bridge:
build: ./audio-bridge
container_name: audio-bridge
# Fake Zigbee smart-plug used by the doorbell E2E probe. Echoes TOGGLE
# commands back on its state topic so the chain can be validated without
# flashing a real lamp. No production traffic is routed here.
doorbell-probe:
build: ./doorbell-probe
container_name: doorbell-probe
restart: unless-stopped
privileged: true
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:
mosquitto:
condition: service_healthy
volumes:
- ./mosquitto/config:/mosquitto/config
- /var/run/docker.sock:/var/run/docker.sock
environment:
MQTT_HOST: mosquitto
MQTT_HOST: 192.168.81.147
MQTT_PORT: "1883"
TZ: America/Los_Angeles
SET_TOPIC: "zigbee2mqtt/DoorbellProbe/set"
STATE_TOPIC: "zigbee2mqtt/DoorbellProbe"
volumes:
librespot_cache:
+29 -5
View File
@@ -26,13 +26,18 @@ Env vars (all optional):
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"
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 math
import os
import sys
import threading
import time
import urllib.parse
from pathlib import Path
import paho.mqtt.client as mqtt
@@ -40,10 +45,15 @@ 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()]
NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "")
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")
# 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"))
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30"))
# Drop messages older than this many seconds (replay protection after restart).
@@ -68,6 +78,14 @@ def log(msg: str) -> None:
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:
safe = topic.replace("/", "_").replace(" ", "_")
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)
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.
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.
"""
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):
payload = json.dumps({"state": "TOGGLE"})
client.publish(PLUG_TOPIC, payload)
log(f"flash {i + 1}/{count} -> {PLUG_TOPIC}")
client.publish(topic, payload)
log(f"flash {i + 1}/{count} -> {topic}")
if i < count - 1:
time.sleep(interval)
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:
"""Poll one topic. Returns the latest message id seen (or last_id)."""
url = f"https://ntfy.sh/{topic}/json?poll=1"
if auth_param():
url += f"&auth={urllib.parse.quote(auth_param())}"
if last_id:
url += f"&since={last_id}"
log(f"[{topic}] polling {url}")
@@ -146,7 +166,11 @@ def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str:
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)
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
+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
acl_file /mosquitto/config/acl.conf
password_file /mosquitto/config/passwd
persistence true
persistence_location /mosquitto/data
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
server: mqtt://mosquitto:1883
serial:
port: /dev/ttyUSB1
port: /dev/ttyUSB0
adapter: blz
baudrate: 2000000
rtscts: false
@@ -36,26 +36,27 @@ advanced:
- 25
- 93
- 235
frontend:
enabled: true
port: 8080
permit_join: false
groups:
"1":
'1':
friendly_name: all_plugs
devices:
- Squiggle
- Sideboard Lamp
- Bamboo Lights
devices:
"0xffffb40e0604fbd0":
'0xffffb40e0604fbd0':
friendly_name: Squiggle
"0xffffb40e06065536":
'0xffffb40e06065536':
friendly_name: Door Button
"0xffffb40e0603c9e4":
'0xffffb40e0603c9e4':
friendly_name: Sideboard Lamp
"0xffffb40e06050af6":
'0xffffb40e06050af6':
friendly_name: Bamboo Lights
"0xffffb40e0603ef11":
'0xffffb40e0603ef11':
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.)
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