Compare commits
19 Commits
97c30acfdd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9874b7c25d | |||
| 5c747899f6 | |||
| f4d6be0364 | |||
| 0bc38431b9 | |||
| d17c8dea98 | |||
| 595796b027 | |||
| d5f5e640bc | |||
| 080c7c29bf | |||
| 5da59422b3 | |||
| 229e311f68 | |||
| 14581d05dd | |||
| 6eccaec7a8 | |||
| b9c53c1a23 | |||
| e5ed67a282 | |||
| 1f77cf3762 | |||
| 110279ae17 | |||
| bb2bb50962 | |||
| da72ab3cba | |||
| 9e85a6d538 |
+25
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
topic readwrite msh/#
|
||||||
|
topic read $SYS/#
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run zigbee coordinator heartbeat every minute
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* *:*:00
|
||||||
|
AccuracySec=5s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -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.
@@ -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
|
||||||
|
|||||||
@@ -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": []
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|
||||||
Executable
+47
@@ -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"
|
||||||
@@ -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,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'
|
||||||
|
|||||||
@@ -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])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user