From d17c8dea98196f07b608058398623fd0bb59c250 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 28 Aug 2026 12:56:25 -0700 Subject: [PATCH] doorbell: fake zigbee probe device + E2E validation (no real ring) --- infra/compose.yaml | 18 ++++++ infra/doorbell-listener/doorbell-listener.py | 18 ++++-- infra/doorbell-probe/Dockerfile | 5 ++ infra/doorbell-probe/e2e-check.sh | 27 +++++++++ infra/doorbell-probe/fake-device.py | 63 ++++++++++++++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 infra/doorbell-probe/Dockerfile create mode 100644 infra/doorbell-probe/e2e-check.sh create mode 100644 infra/doorbell-probe/fake-device.py diff --git a/infra/compose.yaml b/infra/compose.yaml index 290db13..ff54efd 100644 --- a/infra/compose.yaml +++ b/infra/compose.yaml @@ -106,11 +106,29 @@ services: MQTT_HOST: 192.168.81.147 MQTT_PORT: "1883" PLUG_TOPIC: "zigbee2mqtt/Squiggle/set" + PROBE_PLUG_TOPIC: "zigbee2mqtt/DoorbellProbe/set" POLL_INTERVAL: "5" MAX_AGE_SECONDS: "180" FLASH_INTERVAL_SECONDS: "1" FLASH_DURATION_SECONDS: "7" TZ: America/Los_Angeles + # Fake Zigbee smart-plug used by the doorbell E2E probe. Echoes TOGGLE + # commands back on its state topic so the chain can be validated without + # flashing a real lamp. No production traffic is routed here. + doorbell-probe: + build: ./doorbell-probe + container_name: doorbell-probe + restart: unless-stopped + network_mode: host + depends_on: + mosquitto: + condition: service_healthy + environment: + MQTT_HOST: 192.168.81.147 + MQTT_PORT: "1883" + SET_TOPIC: "zigbee2mqtt/DoorbellProbe/set" + STATE_TOPIC: "zigbee2mqtt/DoorbellProbe" + volumes: librespot_cache: diff --git a/infra/doorbell-listener/doorbell-listener.py b/infra/doorbell-listener/doorbell-listener.py index f7530c2..1ddb366 100644 --- a/infra/doorbell-listener/doorbell-listener.py +++ b/infra/doorbell-listener/doorbell-listener.py @@ -44,6 +44,10 @@ NTFY_TOPICS = [t.strip() for t in os.environ.get("NTFY_TOPICS", DEFAULT_TOPICS). MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto") MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883")) PLUG_TOPIC = os.environ.get("PLUG_TOPIC", "zigbee2mqtt/Sideboard Lamp/set") +# Canary/"probe" alerts (tagged doorbell-probe) are routed to a fake zigbee +# device instead of the real plug, so E2E tests don't flash a real lamp. +PROBE_PLUG_TOPIC = os.environ.get("PROBE_PLUG_TOPIC", "zigbee2mqtt/DoorbellProbe/set") +PROBE_TAG = os.environ.get("PROBE_TAG", "doorbell-probe") STATE_DIR = Path(os.environ.get("STATE_DIR", "/data/doorbell-listener")) POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "30")) # Drop messages older than this many seconds (replay protection after restart). @@ -85,7 +89,7 @@ def save_last_id(topic: str, msg_id: str) -> None: last_id_path(topic).write_text(msg_id) -def start_flash(client: mqtt.Client, count: int, interval: float) -> None: +def start_flash(client: mqtt.Client, count: int, interval: float, topic: str) -> None: """Spawn a thread that publishes `count` TOGGLEs spaced `interval` seconds. Each alert gets its own thread; overlapping flashes will publish toggles @@ -93,11 +97,11 @@ def start_flash(client: mqtt.Client, count: int, interval: float) -> None: regardless of how many threads are running. """ def run() -> None: - log(f"flash: starting {count} toggles @ {interval}s") + log(f"flash: starting {count} toggles @ {interval}s -> {topic}") for i in range(count): payload = json.dumps({"state": "TOGGLE"}) - client.publish(PLUG_TOPIC, payload) - log(f"flash {i + 1}/{count} -> {PLUG_TOPIC}") + client.publish(topic, payload) + log(f"flash {i + 1}/{count} -> {topic}") if i < count - 1: time.sleep(interval) log("flash: complete") @@ -146,7 +150,11 @@ def poll_topic(client: mqtt.Client, topic: str, last_id: str) -> str: continue log(f"[{topic}] alert: id={msg_id} age={age}s title={title!r} message={message!r}") - start_flash(client, n_flashes, FLASH_INTERVAL_SECONDS) + tags = event.get("tags") or [] + is_probe = PROBE_TAG in tags or event.get("title") == PROBE_TAG + target = PROBE_PLUG_TOPIC if is_probe else PLUG_TOPIC + log(f"[{topic}] probe={is_probe} -> {target}") + start_flash(client, n_flashes, FLASH_INTERVAL_SECONDS, target) return newest_id diff --git a/infra/doorbell-probe/Dockerfile b/infra/doorbell-probe/Dockerfile new file mode 100644 index 0000000..7504be8 --- /dev/null +++ b/infra/doorbell-probe/Dockerfile @@ -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"] diff --git a/infra/doorbell-probe/e2e-check.sh b/infra/doorbell-probe/e2e-check.sh new file mode 100644 index 0000000..9cfba11 --- /dev/null +++ b/infra/doorbell-probe/e2e-check.sh @@ -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 diff --git a/infra/doorbell-probe/fake-device.py b/infra/doorbell-probe/fake-device.py new file mode 100644 index 0000000..7e04425 --- /dev/null +++ b/infra/doorbell-probe/fake-device.py @@ -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()