Compare commits

...

19 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
david da72ab3cba fix: use stable by-id path for dongle, add flashing docs, BL706 firmware v1.00.01 2026-08-01 21:22:45 -07:00
david 9e85a6d538 USB1 not USB0 2026-08-01 11:21:14 -07:00
28 changed files with 1283 additions and 339 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
+45 -83
View File
@@ -11,14 +11,26 @@ services:
- ./mosquitto/data:/mosquitto/data - ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log - ./mosquitto/log:/mosquitto/log
healthcheck: healthcheck:
test: ["CMD", "mosquitto_pub", "-p", "1883", "-t", "healthcheck", "-m", "ok", "-r", "--quiet"] test:
[
"CMD",
"mosquitto_pub",
"-p",
"1883",
"-t",
"healthcheck",
"-m",
"ok",
"-r",
"--quiet",
]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 30s start_period: 30s
zigbee2mqtt: zigbee2mqtt:
image: ghcr.io/pine64/zigbee2mqtt:latest-dev # BLZ fork, NOT koenkk image: ghcr.io/pine64/zigbee2mqtt:latest-dev # BLZ fork, NOT koenkk
container_name: zigbee2mqtt container_name: zigbee2mqtt
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
@@ -28,7 +40,7 @@ services:
volumes: volumes:
- ./zigbee2mqtt:/app/data - ./zigbee2mqtt:/app/data
devices: devices:
- source: /dev/serial/by-path/platform-3f980000.usb-usb-0:1.1.2:1.0-port0 - source: /dev/serial/by-id/usb-1a86_USB_Serial-if00-port0
target: /dev/ttyUSB0 target: /dev/ttyUSB0
permissions: rw permissions: rw
entrypoint: ["/app/data/scripts/entrypoint.sh"] entrypoint: ["/app/data/scripts/entrypoint.sh"]
@@ -59,116 +71,66 @@ services:
ports: ports:
- "1880:1880" - "1880:1880"
volumes: volumes:
- ./nodered:/data # bind: settings.js, flows.json, start.sh, package.json - ./nodered:/data # bind: settings.js, flows.json, start.sh, package.json
- /data/node_modules # anonymous: preserves build-time npm install - /data/node_modules # anonymous: preserves build-time npm install
environment: environment:
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. # 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
+87
View File
@@ -0,0 +1,87 @@
# ThirdReality BL706 USB Dongle — Firmware Flashing Reference
Based on the [ThirdReality Zigbee 3.0 USB Dongle](https://github.com/thirdreality/ThirdReality-Zigbee-3.0-USB-dongle)
repo (cloned at `/opt/david/src/ThirdReality-Zigbee-3.0-USB-dongle/`).
## Hardware
- Chip: Bouffalo Lab **BL706** (NOT BL702 — different firmware!)
- USB bridge: CH340 (`1a86:7523`, appears as `/dev/ttyUSB0`)
- Protocol: **BLZ** (not ZiGate — firmware v1.00+)
- Config: `adapter: blz`, `baudrate: 2000000` in zigbee2mqtt `configuration.yaml`
## CRITICAL: BL702 vs BL706 firmware
The ThirdReality dongle uses a **BL706** chip. The DevCube flashing tool
takes `--chipname=bl702` (flash layout), but the **firmware binary itself**
must be built for BL706, not BL702.
Firmware files named `R3_bl702_dongle.bin` (v1.00.07, v1.00.12) are for
**BL702 chips** and will NOT boot on BL706. Always use firmware files
named `R3_706_dongle*.bin`.
## Working firmware: BLZ v1.00.01 (confirmed on BL706)
Saved in this repo at:
- `infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin`
- `infra/zigbee2mqtt/partition_cfg_2M.toml`
## Entering Bootloader Mode
Hold the **boot button** (pinhole on the dongle) while plugging into USB.
Release after ~1 second. **No LED** when in bootloader mode.
## Flashing
```bash
BFLASH="/opt/david/src/ThirdReality-Zigbee-3.0-USB-dongle/Flashing-Tool
/BouffaloLabDevCube-v1.9.0/bflb_iot_tool-ubuntu"
FIRMWARE="./infra/zigbee2mqtt/R3_706_dongle_v1.00.01.bin"
PARTITION="./infra/zigbee2mqtt/partition_cfg_2M.toml"
$BFLASH \
--chipname=bl702 \
--interface=uart \
--port=/dev/ttyUSB0 \
--baudrate=2000000 \
--xtal=32M \
--firmware=$FIRMWARE \
--pt=$PARTITION
```
### Critical flags
| Flag | Value | Why |
|---|---|---|
| `--chipname` | `bl702` | Flash layout (not chip model — BL706 uses BL702 layout) |
| `--interface` | `uart` | CH340 provides serial passthrough |
| `--baudrate` | `2000000` | Must match the dongle's bootloader rate |
| `--xtal` | `32M` | Crystal frequency for bootheader generation |
| `--pt` | `partition_cfg_2M.toml` | **Must use 2M** — firmware overflows 1M |
## After Flashing
1. Tool prints `[All Success]` with SHA verification
2. **Fully unplug** the dongle (wait 5s for power drain)
3. Replug **without** holding any button
4. LED should light up immediately — chip is running the new firmware
## Firmware Compatibility
| Version | File name | BL706 compatible? |
|---|---|---|
| BLZ v1.00.01 | `R3_706_dongle_v1.00.01.bin` | **YES** (confirmed working) |
| BLZ v1.00.07 | `R3_bl702_dongle.bin` (misnamed) | NO — built for BL702 |
| BLZ v1.00.12 | `R3_bl702_dongle.bin` (misnamed) | NO — built for BL702 |
| Legacy ZiGate v0.00.21 | `R3_706_dongle.bin` | Untested (no BLZ support) |
The v1.00.01 firmware file is in the ThirdReality repo at
`Image/Coordinator/blz/v1.00.01/R3_706_dongle_v1.00.01/`.
## Recurring Issue
After power loss, the BL706 firmware can corrupt itself (reports
`deviceId: 0xBEEF`, `addEndpoint` fails with `frameId:21`). The chip
responds to UART resets but refuses all application-layer commands.
The fix is always to **reflash with BL706-compatible firmware** —
there's no software recovery.
Binary file not shown.
+2 -1
View File
@@ -36,7 +36,6 @@ advanced:
- 25 - 25
- 93 - 93
- 235 - 235
frontend: frontend:
enabled: true enabled: true
port: 8080 port: 8080
@@ -59,3 +58,5 @@ devices:
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": []
}
+83
View File
@@ -0,0 +1,83 @@
[pt_table]
#partition table is 4K in size
address0 = 0x1000
address1 = 0x2000
[[pt_entry]]
type = 0
name = "FW"
device = 0
address0 = 0x3000
size0 = 0x127000
address1 = 0x12A000
size1 = 0xCE000
# compressed image must set len,normal image can left it to 0
len = 0
activeindex = 0
age = 0
[[pt_entry]]
type = 1
name = "mfg"
device = 0
address0 = 0x12A000
size0 = 0xCE000
address1 = 0
size1 = 0
# compressed image must set len,normal image can left it to 0
len = 0
activeindex = 0
age = 0
[[pt_entry]]
type = 2
name = "PSM"
device = 0
address0 = 0x1F8000
size0 = 0x2000
address1 = 0
size1 = 0
# compressed image must set len,normal image can left it to 0
len = 0
activeindex = 0
age = 0
[[pt_entry]]
type = 3
name = "backup"
device = 0
address0 = 0x1FA000
size0 = 0x1000
address1 = 0
size1 = 0
# compressed image must set len,normal image can left it to 0
len = 0
activeindex = 0
age = 0
[[pt_entry]]
type = 4
name = "media"
device = 0
address0 = 0x1FB000
size0 = 0x4000
address1 = 0
size1 = 0
# compressed image must set len,normal image can left it to 0
len = 0
activeindex = 0
age = 0
[[pt_entry]]
type = 5
name = "factory"
device = 0
address0 = 0x1FF000
size0 = 0x1000
address1 = 0
size1 = 0
# compressed image must set len,normal image can left it to 0
len = 0
activeindex = 0
age = 0
+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
}
}
+178 -164
View File
@@ -1,88 +1,6 @@
# nr-flow-validator — Declarative Node-RED flows with conftest validation # nr-flow-validator — Declarative Node-RED flows + infra stack config
set export set export
PATH := "/home/david/.local/share/mise/shims:" + env("PATH")
# Default: generate a flow, validate, and show results
default: validate
# ---- Flow generation ----
# Generate Node-RED flow JSON from a YAML spec
generate FLOW="zigbee-monitor":
@echo "=== Generating {{FLOW}} ==="
python3 scripts/flow2json.py flows/{{FLOW}}.yaml > output/{{FLOW}}.json
@echo "Wrote output/{{FLOW}}.json"
# Generate all flows
generate-all:
@for f in flows/*.yaml; do \
name=$(basename "$f" .yaml); \
just generate "$name"; \
done
# ---- Validation ----
# Validate a generated flow against Rego policies
validate FLOW="zigbee-monitor": generate
@echo "=== Validating {{FLOW}} ==="
conftest test output/{{FLOW}}.json -p policy/ --namespace nodered --no-color
# Validate all flows
validate-all:
@for f in output/*.json; do \
echo "=== Validating $(basename "$f") ==="; \
conftest test "$$f" -p policy/ --namespace nodered --no-color; \
done
# ---- Deployment ----
# Deploy flow to Node-RED instance
deploy FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": generate validate
@echo "=== Deploying {{FLOW}} to {{HOST}} ==="
curl -s -o /dev/null -w "HTTP %{http_code}" \
-X POST {{HOST}}/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
-d @output/{{FLOW}}.json
@echo ""
# Quick deploy (skip validation)
deploy-quick FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": generate
@echo "=== Quick deploy {{FLOW}} to {{HOST}} ==="
curl -s -o /dev/null -w "HTTP %{http_code}" \
-X POST {{HOST}}/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
-d @output/{{FLOW}}.json
@echo ""
# ---- Viewing ----
# Fetch and display current flows from Node-RED
pull HOST="http://192.168.81.147:1880":
curl -s {{HOST}}/flows | python3 -m json.tool
# Open dashboard in browser
dashboard:
xdg-open http://192.168.81.147:1880/dashboard 2>/dev/null || open http://192.168.81.147:1880/dashboard 2>/dev/null || echo "Open: http://192.168.81.147:1880/dashboard"
# ---- Development ----
# Watch for changes and re-validate
watch FLOW="zigbee-monitor":
@while true; do \
inotifywait -q -e modify flows/{{FLOW}}.yaml policy/*.rego; \
clear; just validate {{FLOW}}; \
done
# Create a new flow from template
new NAME:
@cp flows/zigbee-monitor.yaml flows/{{NAME}}.yaml
@echo "Created flows/{{NAME}}.yaml — edit and run 'just validate {{NAME}}'"
# ---- Diagnostics ----
# All commands target the live RPi at $RPI_HOST (default root@192.168.81.147).
# Override with: just RPI_HOST=user@host mqtt-pub topic msg
RPI_HOST := "root@192.168.81.147" RPI_HOST := "root@192.168.81.147"
MQTT_HOST := "192.168.81.147" MQTT_HOST := "192.168.81.147"
@@ -90,124 +8,220 @@ MQTT_PORT := "1883"
RPI_DIR := "/root" RPI_DIR := "/root"
NR_PORT := "1880" NR_PORT := "1880"
# Run a command on the RPi over SSH # Default: generate + validate the zigbee-monitor flow + validate infra config
rpi CMD *ARGS: default: nodered-validate infra-validate
ssh -o ConnectTimeout=5 {{RPI_HOST}} '{{CMD}} {{ARGS}}'
# Publish to MQTT broker on the RPi # ============================================================
mqtt-pub TOPIC PAYLOAD: # NODERED — flow generation, validation, deployment, inspection
uv run --with paho-mqtt python3 scripts/mqtt.py pub --topic '{{TOPIC}}' --payload '{{PAYLOAD}}' # ============================================================
# Subscribe to MQTT broker on the RPi for N seconds # Compile a flow YAML into deployable Node-RED JSON
mqtt-sub TOPIC SECONDS="5": nodered-generate FLOW="zigbee-monitor":
uv run --with paho-mqtt python3 scripts/mqtt.py sub --topic '{{TOPIC}}' --seconds {{SECONDS}} @echo "=== Generating {{FLOW}} ==="
python3 scripts/flow2json.py flows/{{FLOW}}.yaml > output/{{FLOW}}.json
@echo "Wrote output/{{FLOW}}.json"
# Publish a button action and watch what /set topics fire (E2E test) # Compile all flow YAMLs
mqtt-button ACTION="single": nodered-generate-all:
uv run --with paho-mqtt python3 scripts/diag.py mqtt-button --action {{ACTION}} @for f in flows/*.yaml; do \
name=$(basename "$f" .yaml); \
just nodered-generate "$name"; \
done
# Show live Node-RED flow summary (node count, function names, button wires, mqtt-out topics) # Run Rego policy checks against a generated Node-RED flow
nr-show: nodered-validate FLOW="zigbee-monitor": nodered-generate
@echo "=== Validating {{FLOW}} ==="
conftest test output/{{FLOW}}.json -p policy/ --namespace nodered --no-color
# Run Rego checks against all generated flows
nodered-validate-all:
@for f in output/*.json; do \
echo "=== Validating $(basename "$f") ==="; \
conftest test "$$f" -p policy/ --namespace nodered --no-color; \
done
# Deploy a validated flow to the Node-RED server, then validate the compose stack
nodered-deploy FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": nodered-generate nodered-validate
@echo "=== Deploying {{FLOW}} to {{HOST}} ==="
curl -s -o /dev/null -w "HTTP %{http_code}" \
-X POST {{HOST}}/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
-d @output/{{FLOW}}.json
@echo ""
just infra-validate
# Deploy a flow without Rego validation (faster, use after local edits)
nodered-deploy-skip-checks FLOW="zigbee-monitor" HOST="http://192.168.81.147:1880": nodered-generate
@echo "=== Deploying {{FLOW}} to {{HOST}} (skip Rego checks) ==="
curl -s -o /dev/null -w "HTTP %{http_code}" \
-X POST {{HOST}}/flows \
-H "Content-Type: application/json" \
-H "Node-RED-Deployment-Type: full" \
-d @output/{{FLOW}}.json
@echo ""
# Fetch current flows from the Node-RED server (raw JSON)
nodered-pull HOST="http://192.168.81.147:1880":
curl -s {{HOST}}/flows | python3 -m json.tool
# Show live Node-RED flow summary (node types, function names, wire topology)
nodered-show:
python3 scripts/diag.py show python3 scripts/diag.py show
# Diff deployed Node-RED flow against repo (excluding random uuid ids) # Diff the generated flow against what's deployed (ids excluded)
nr-diff FLOW="zigbee-monitor": generate nodered-diff FLOW="zigbee-monitor": nodered-generate
python3 scripts/diag.py diff output/{{FLOW}}.json python3 scripts/diag.py diff output/{{FLOW}}.json
# Tail Node-RED container logs # Tail the Node-RED container logs
nr-logs: nodered-logs:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f nodered' ssh {{RPI_HOST}} 'docker logs --tail 50 -f nodered'
# Tail zigbee2mqtt container logs # Open the Node-RED editor in browser
z2m-logs: nodered-editor:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f zigbee2mqtt' xdg-open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}"
# Tail mosquitto container logs # Open the Node-RED Dashboard 2.0 SPA in browser
mqtt-logs: nodered-dashboard:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f mosquitto' xdg-open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard"
# Restart a service container # Watch flow YAML and Rego policy files; re-validate on change
restart SERVICE: nodered-watch FLOW="zigbee-monitor":
@while true; do \
inotifywait -q -e modify flows/{{FLOW}}.yaml policy/*.rego; \
clear; just nodered-validate {{FLOW}}; \
done
# Scaffold a new flow YAML from the zigbee-monitor template
nodered-new NAME:
@cp flows/zigbee-monitor.yaml flows/{{NAME}}.yaml
@echo "Created flows/{{NAME}}.yaml — edit and run 'just nodered-validate {{NAME}}'"
# ============================================================
# INFRA — git sync, compose validation, remote host management
# ============================================================
# Commit local changes, push to Gitea, pull on RPi, validate compose
infra-deploy MESSAGE="sync infra":
@echo "=== Committing ==="
git add -A
git diff --cached --quiet || git commit -m '{{MESSAGE}}'
@echo "=== Pushing to Gitea ==="
git push
@echo "=== Pulling on RPi ==="
ssh {{RPI_HOST}} "cd {{RPI_DIR}}/nr-flow-validator && git pull"
@echo "=== Validating compose stack ==="
just infra-validate
@echo "=== Done ==="
# Run Rego policy checks against the compose file and zigbee2mqtt config
infra-validate:
conftest test infra/compose.yaml -p policy/ --namespace compose \
--data infra/zigbee2mqtt/configuration.yaml
# Run an arbitrary command on the RPi over SSH
infra-ssh CMD *ARGS:
ssh -o ConnectTimeout=5 {{RPI_HOST}} '{{CMD}} {{ARGS}}'
# Restart any Docker service on the RPi (e.g. just infra-restart mosquitto)
infra-restart SERVICE:
ssh {{RPI_HOST}} 'docker restart {{SERVICE}}' ssh {{RPI_HOST}} 'docker restart {{SERVICE}}'
# Restart the full stack # Restart the full stack on the RPi
restart-all: infra-restart-all:
ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered doorbell-listener' ssh {{RPI_HOST}} 'docker restart mosquitto zigbee2mqtt nodered doorbell-listener'
# Tail doorbell-listener container logs # Tail the mosquitto MQTT broker logs
doorbell-logs: mosquitto-logs:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f doorbell-listener' ssh {{RPI_HOST}} 'docker logs --tail 50 -f mosquitto'
# Restart just the doorbell-listener # ============================================================
restart-doorbell: # ZIGBEE — zigbee2mqtt management
ssh {{RPI_HOST}} 'docker restart doorbell-listener' # ============================================================
# Publish a test alert to ntfy (used for E2E testing the listener). # Tail the zigbee2mqtt container logs
# Usage: just doorbell-publish "Title" "Body" zigbee-logs:
doorbell-publish TITLE BODY="Someone is at the door": ssh {{RPI_HOST}} 'docker logs --tail 50 -f zigbee2mqtt'
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/) # Apply the BLZ adapter bridge.js patch (idempotent).
doorbell-rebuild: # Required after every fresh ghcr.io/pine64/zigbee2mqtt image pull.
ssh {{RPI_HOST}} 'cd {{RPI_DIR}}/nr-flow-validator && docker compose build doorbell-listener && docker compose up -d doorbell-listener' zigbee-patch-bridge:
# Patch zigbee2mqtt's bridge.js for BLZ adapter compatibility.
# The Pine64 latest-dev image crashes on start because Bridge.start() calls
# firstCoordinatorEndpoint().deviceIeeeAddress BEFORE registering the
# onMQTTMessage listener, so permit_join (and every other bridge MQTT
# request) silently no-ops.
#
# Run this after every fresh image pull OR after the zigbee2mqtt container
# is recreated (the patch is lost on image rebuild).
z2m-patch-bridge:
@echo "Copying patch script into zigbee2mqtt container..." @echo "Copying patch script into zigbee2mqtt container..."
ssh {{RPI_HOST}} 'docker cp {{justfile_directory()}}/infra/zigbee2mqtt/scripts/blz-bridge-patch.js zigbee2mqtt:/tmp/blz-bridge-patch.js' ssh {{RPI_HOST}} 'docker cp {{justfile_directory()}}/infra/zigbee2mqtt/scripts/blz-bridge-patch.js zigbee2mqtt:/tmp/blz-bridge-patch.js'
@echo "Applying patch (idempotent)..." @echo "Applying patch (idempotent)..."
ssh {{RPI_HOST}} 'docker exec zigbee2mqtt node /tmp/blz-bridge-patch.js' ssh {{RPI_HOST}} 'docker exec zigbee2mqtt node /tmp/blz-bridge-patch.js'
@echo "Restarting zigbee2mqtt..." @echo "Restarting zigbee2mqtt..."
ssh {{RPI_HOST}} 'docker restart zigbee2mqtt' ssh {{RPI_HOST}} 'docker restart zigbee2mqtt'
@echo "Done. Verify with: just z2m-logs" @echo "Done. Verify with: just zigbee-logs"
# Open permit_join + watch for device_joined events; auto-rename any # Open permit_join and watch for device_joined events; auto-rename rejoining devices
# rejoining device back to its original friendly name. Use after the zigbee-repair TIME="120":
# zigbee2mqtt database has been wiped, to recover from a fresh state
# without manually re-pairing each device via the web UI.
#
# First put each device into pairing mode (ThirdReality smart plugs:
# hold the side button for ~5 seconds until the LED blinks rapidly).
# Then run this and press a device's button within the time window.
z2m-repair TIME="120":
uv run --with paho-mqtt python3 {{justfile_directory()}}/scripts/z2m_repair.py --time {{TIME}} uv run --with paho-mqtt python3 {{justfile_directory()}}/scripts/z2m_repair.py --time {{TIME}}
# Rename a zigbee2mqtt device by IEEE address. Use after a device # Rename a zigbee2mqtt device by its IEEE address
# rejoins with a fresh ieee (or for any rename). zigbee-rename IEEE NAME:
z2m-rename IEEE NAME:
ssh {{RPI_HOST}} 'mosquitto_pub -h localhost \ ssh {{RPI_HOST}} 'mosquitto_pub -h localhost \
-t "zigbee2mqtt/bridge/request/device/rename" \ -t "zigbee2mqtt/bridge/request/device/rename" \
-m "{\"from\":\"{{IEEE}}\",\"to\":\"{{NAME}}\",\"transaction\":\"rename-{{NAME}}\"}"' -m "{\"from\":\"{{IEEE}}\",\"to\":\"{{NAME}}\",\"transaction\":\"rename-{{NAME}}\"}"'
@echo "Renamed {{IEEE}} -> {{NAME}}" @echo "Renamed {{IEEE}} -> {{NAME}}"
# Show paired zigbee devices and their friendly names # List all paired zigbee devices with their friendly names
z2m-devices: zigbee-devices:
ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/nr-flow-validator/infra/zigbee2mqtt/configuration.yaml' > /tmp/z2m.yaml ssh {{RPI_HOST}} 'cat {{RPI_DIR}}/nr-flow-validator/infra/zigbee2mqtt/configuration.yaml' > /tmp/z2m.yaml
python3 scripts/diag.py z2m-devices /tmp/z2m.yaml python3 scripts/diag.py z2m-devices /tmp/z2m.yaml
# Show per-device persisted state from z2m # Show per-device persisted state (on/off, brightness, etc.)
z2m-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
# Open the Node-RED editor # ============================================================
nr-editor: # MQTT — publish, subscribe, watch, simulate devices
xdg-open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}} 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}" # ============================================================
# Open the Dashboard 2.0 SPA # Publish a message to any MQTT topic on the RPi broker
nr-dashboard: mqtt-pub TOPIC PAYLOAD:
xdg-open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || open http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard 2>/dev/null || echo "Open: http://{{MQTT_HOST}}:{{NR_PORT}}/dashboard" uv run --with paho-mqtt python3 scripts/mqtt.py pub --topic '{{TOPIC}}' --payload '{{PAYLOAD}}'
# Watch live MQTT traffic (matches the z2m base_topic by default) # Subscribe to an MQTT topic for N seconds and print messages
mqtt-sub TOPIC SECONDS="5":
uv run --with paho-mqtt python3 scripts/mqtt.py sub --topic '{{TOPIC}}' --seconds {{SECONDS}}
# Simulate a door button press (single|double|hold) and watch the /set response
mqtt-simulate-doorbell ACTION="single":
uv run --with paho-mqtt python3 scripts/diag.py mqtt-button --action {{ACTION}}
# Print every MQTT message matching a topic pattern (default: all zigbee2mqtt traffic)
mqtt-watch TOPIC="zigbee2mqtt/#": mqtt-watch TOPIC="zigbee2mqtt/#":
uv run --with paho-mqtt python3 scripts/mqtt.py watch --topic '{{TOPIC}}' uv run --with paho-mqtt python3 scripts/mqtt.py watch --topic '{{TOPIC}}'
# ============================================================
# DOORBELL — ntfy-to-smart-plug bridge management
# ============================================================
# Tail the doorbell-listener container logs
doorbell-logs:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f doorbell-listener'
# Build and restart the doorbell listener after local edits
doorbell-rebuild:
ssh {{RPI_HOST}} 'cd {{RPI_DIR}}/nr-flow-validator && docker compose build doorbell-listener && docker compose up -d doorbell-listener'
# Send a test ntfy alert to trigger the doorbell listener
doorbell-test 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"
# ============================================================
# MISC — standalone service logs
# ============================================================
# Tail the audio-bridge container logs
audio-bridge-logs:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f audio-bridge'
# Tail the party-lock container logs
party-lock-logs:
ssh {{RPI_HOST}} 'docker logs --tail 50 -f party-lock'
+100
View File
@@ -0,0 +1,100 @@
package compose
import future.keywords.if
import future.keywords.contains
import future.keywords.in
# Every service must have a restart policy
deny_no_restart contains msg if {
some name, svc in input.services
not svc.restart
msg := sprintf("service %s: missing restart policy", [name])
}
# zigbee2mqtt must use the Pine64 BLZ fork, not upstream koenkk
deny_wrong_image contains msg if {
svc := input.services.zigbee2mqtt
not startswith(svc.image, "ghcr.io/pine64/zigbee2mqtt")
msg := sprintf("zigbee2mqtt: image must be ghcr.io/pine64/zigbee2mqtt (got %s)", [svc.image])
}
# zigbee2mqtt must have the correct entrypoint
deny_missing_entrypoint contains msg if {
svc := input.services.zigbee2mqtt
svc.entrypoint != ["/app/data/scripts/entrypoint.sh"]
msg := "zigbee2mqtt: entrypoint must be [\"/app/data/scripts/entrypoint.sh\"]"
}
# zigbee2mqtt must have the correct command (tini wrapper)
deny_wrong_command contains msg if {
svc := input.services.zigbee2mqtt
svc.command != ["/sbin/tini", "--", "node", "index.js"]
msg := sprintf("zigbee2mqtt: command must be /sbin/tini -- node index.js (got %s)", [svc.command])
}
# Device sources should use stable by-path or by-id symlinks, not raw /dev/ttyUSB*
deny_raw_tty contains msg if {
some name, svc in input.services
some dev in svc.devices
startswith(dev.source, "/dev/ttyUSB")
msg := sprintf("service %s: device source %s uses raw tty path (use /dev/serial/by-*)", [name, dev.source])
}
# Only audio-bridge should run privileged
deny_unexpected_privileged contains msg if {
some name, svc in input.services
svc.privileged
name != "audio-bridge"
msg := sprintf("service %s: privileged: true (only audio-bridge should be privileged)", [name])
}
# zigbee2mqtt must depend on mosquitto
deny_missing_dep contains msg if {
svc := input.services.zigbee2mqtt
not svc.depends_on
msg := "zigbee2mqtt: missing depends_on (should depend on mosquitto)"
}
deny_missing_dep contains msg if {
svc := input.services.zigbee2mqtt
svc.depends_on
not "mosquitto" in object.keys(svc.depends_on)
msg := "zigbee2mqtt: must depend on mosquitto"
}
# nodered must depend on mosquitto with condition: service_healthy
deny_nodered_dep_healthy contains msg if {
svc := input.services.nodered
dep := svc.depends_on.mosquitto
dep.condition != "service_healthy"
msg := "nodered: mosquitto dependency must have condition: service_healthy"
}
# zigbee2mqtt should not use host networking
deny_host_network contains msg if {
svc := input.services.zigbee2mqtt
svc.network_mode == "host"
msg := "zigbee2mqtt: should not use host network mode"
}
# zigbee2mqtt must mount zigbee2mqtt/ as /app/data
has_z2m_vol if {
some vol in input.services.zigbee2mqtt.volumes
startswith(vol, "./zigbee2mqtt:/app/data")
}
deny_missing_volume contains msg if {
svc := input.services.zigbee2mqtt
not has_z2m_vol
msg := "zigbee2mqtt: must have volume mapping ./zigbee2mqtt:/app/data"
}
# Cross-reference: zigbee2mqtt device target must match its config port.
# Load configuration.yaml via --data flag:
# conftest test infra/compose.yaml --data infra/zigbee2mqtt/configuration.yaml
deny_port_mismatch contains msg if {
svc := input.services.zigbee2mqtt
some dev in svc.devices
cfg := data.configuration.serial.port
dev.target != cfg
msg := sprintf("zigbee2mqtt: device target %s does not match config port %s", [dev.target, cfg])
}