Files
hub_fixes/watchdog/README.md
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

131 lines
4.9 KiB
Markdown

# 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
```bash
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
```bash
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.