Files
Sam Rolfe 02d8815e53 Directive 1: voice intake watchdog (layer attribution, daily repeat, Apprise)
Build the Hub Fixes Directive 1 watchdog as a small standalone service on .13.

- Watch voice/audio_stream (paho); 15 min silence = 3 missed heartbeats.
- Attribute failures in order: broker -> voice_bridge -> ESP32/no-publisher.
  The ESP32 is never blamed when the broker or the bridge broke.
- Alert via Apprise on .35:8210 (tag sam_ntfy; daily repeat escalates to sam).
- Persist state; repeat once a day while broken; recovery notice; alerts stop.
- No auto-heal of any kind. Notification only.
- Require publisher evidence (heartbeat or retained presence) before treating
  silence as a failure, so the unflashed Directive 3 firmware cannot cause the
  cry-wolf false alarms the spec warns about.
- Docker: host network, read-only Docker socket, state volume, unless-stopped.
- 23 unit tests; live evidence captured in watchdog/README.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-14 11:10:30 +11:00
..

Voice intake watchdog (Hub Fixes — Directive 1)

Watches the house voice path and alerts Sam through Apprise when intake dies. It never heals anything — no smart plug, no power-cycle, no restart. Notification only. Sam fixes the failed layer by hand.

This is the fix for the 2026-10-13 outage: the .13 broker restarted, voice_bridge went deaf and the ESP32 stopped publishing, and nobody noticed for two days.

What it watches

"Voice intake alive" means all three layers are healthy, checked in this order:

Layer Signal Failure message says
broker .13:1883 reachable (live MQTT session or TCP probe) check the Mosquitto container; do not power-cycle the ESP32
bridge voice_bridge container running and subscribed to voice/audio_stream (Docker socket) start the bridge; do not power-cycle the ESP32
publisher a message on voice/audio_stream within SILENCE_TIMEOUT_SECONDS, or retained voice/status presence "Likely the ESP32 — try a power-cycle."

Layer attribution is the point: if something else broke, the ESP32 is not blamed.

Why 15 minutes is meaningful

The Directive 3 firmware publishes a heartbeat on voice/audio_stream every 5 minutes, so 15 minutes of silence is three missed beats — not "nobody spoke". The firmware also sets a retained voice/status presence (online on connect, offline as a Last Will) for immediate attribution.

Until a heartbeat or presence has been seen, the watchdog does not treat silence as a publisher failure (that state means the heartbeat firmware is not flashed yet, and silence is normal). This is controlled by REQUIRE_PUBLISHER_EVIDENCE (default 1).

Repeat policy

  • First failure → alert immediately.
  • Same layer still down → repeat once per REPEAT_INTERVAL_SECONDS (default one day), for as long as it stays broken.
  • A different layer fails → that is a new alert (immediate).
  • Recovery → one recovery notice, then the state is cleared and alerts stop.
  • State is persisted in a Docker volume, so a watchdog restart neither re-spams nor forgets an ongoing outage.

First alerts use tag APPRISE_TAG (sam_ntfy); daily repeats escalate to APPRISE_TAG_REPEAT (sam). Both live in the Apprise config on .35.

Files

watchdog/
  watchdog.py            the service (paho + stdlib only)
  Dockerfile             python:3.12-slim + paho-mqtt==2.1.0
  docker-compose.yml     host network, read-only Docker socket, state volume
  start_watchdog.sh      sources ~/.config/environment.d/10-secrets.conf, then compose up
  tests/test_watchdog.py unit tests for classification, repeats, messages

Deploy

cd /home/sam/paseo/projects/hub_fixes/watchdog
./start_watchdog.sh                      # normal operation
DRY_RUN=1 ./start_watchdog.sh            # log alerts, send nothing
SILENCE_TIMEOUT_SECONDS=60 CHECK_INTERVAL_SECONDS=5 ./start_watchdog.sh   # test mode

It runs as container voice_intake_watchdog, network_mode: host, no listening port, restart: unless-stopped. The Docker socket is mounted read-only and only ever read (/containers/voice_bridge/json and its logs).

Test

python3 -m unittest discover -s watchdog/tests -v

Verify (evidence)

All commands below were run on .13 on 2026-10-14.

1. Stop voice_bridge → alert arrives within 15 minutes

$ docker stop voice_bridge
$ # watchdog, real Apprise, 5 s checks
WARNING ALERT kind=alert layer=bridge -> tag=sam_ntfy
INFO apprise notify OK HTTP 200 tag=sam_ntfy: Voice intake DOWN — voice_bridge is not running

The bridge layer is checked directly, so the alert fires on the next check (seconds), well inside the 15-minute budget.

2. Start it again → alerts stop

$ docker start voice_bridge
WARNING ALERT kind=recovery layer=bridge -> tag=sam_ntfy
INFO apprise notify OK HTTP 200 tag=sam_ntfy: Voice intake recovered
$ # no further alerts once healthy

3. Broker down → names the broker, not the ESP32

$ # watchdog pointed at an unreachable broker address
WARNING ALERT kind=alert layer=broker -> tag=sam_ntfy
TITLE: Voice intake DOWN — .13 MQTT broker unreachable
... Do NOT power-cycle the ESP32 — the broker layer failed, not the device.

4. Daily repeat (shortened interval)

$ # SILENCE_TIMEOUT=1 REPEAT_INTERVAL=6 CHECK_INTERVAL=2, dry-run
ALERT kind=alert  layer=publisher -> tag=sam_ntfy   (first)
ALERT kind=repeat layer=publisher -> tag=sam       (after the interval)

The unit tests also drive three consecutive daily repeats (test_daily_repeats_keep_firing).

Not in scope / boundaries

  • No auto-heal of any kind.
  • The .13:1883 plane is untouched (plain, anonymous). The watchdog connects as a normal subscriber and publishes nothing.
  • The broker was not stopped to prove layer attribution; an unreachable broker address exercises the same code path without disturbing n8n or the kids' 8883 devices.