Files
Northern-Thailand-Ping-Rive…/docs/NOTIFICATIONS.md
T
grabowski 777b230baf
CI / Format & lint (push) Successful in 11s
Security / Static analysis (push) Successful in 12s
CI / Test suite (push) Successful in 18s
Docs / Validate documentation (push) Successful in 11s
Security / Dependency vulnerabilities (push) Successful in 1m30s
Security / License report (push) Successful in 47s
feat: public flood notifications over self-hosted ntfy
Anyone can now get push alerts on their phone without an account: the
monitor publishes to an ntfy server (one Go binary, ~30 MB RSS) and
subscribers pick topics in the free iOS/Android/web app.

Semantics are transitions, never state. One message when a gauge crosses
its warning or danger threshold, one all-clear when it drops back (0.10 m
hysteresis), nothing while it sits above. A three-day flood is two
messages; a quiet season is zero. Topics: ping-warning / ping-danger
(basin digest), ping-<station>-warning / -danger, ping-p1-outlook (opt-in:
model P(warning within 24 h) at P.1 rises through 50 %, clears below 25 %,
message says it is experimental), ping-status (feed stale >= 3 h /
recovered). Priority 5 on danger so it rings through Do Not Disturb.

src/notify.py runs once per collection cycle in the API process (leader
only, after the forecast precompute, same data the dashboard shows).
Last-sent state lives in a notification_state table so a restart never
re-sends; a failed publish leaves state untouched so the crossing is
retried next cycle instead of lost. Off unless NTFY_SERVER is set.

Dashboard: a "Get alerts" button (only when configured) opens a panel
with the server, per-topic cards, ntfy:// deep links and web links, app
store links and a disclaimer. EN + TH. GET /api/notifications feeds it.

scripts/install_ntfy.sh: .deb install, server.yml (loopback listen,
anonymous read, token-only write scoped to ping-*, 72 h cache, signup/
login/metrics off, tight visitor limits), systemd, user + token, .env.
Verified against ntfy 2.28.0: anon publish 403, token publish 200, token
on foreign topic 403, anon read 200, and a seeded crossing through the
real _notify_transitions path arrived in the topic with priority, tags,
click and action button. docs/NOTIFICATIONS.md has the deployment and
reverse-proxy notes. Tests: 10 for the state machine (159 total).
2026-09-12 00:18:38 +02:00

5.7 KiB

Flood notifications (ntfy)

Public push notifications for threshold crossings, without accounts, mailing lists or app-store review: the monitor publishes to a self-hosted ntfy server, and anyone subscribes to the topics they care about from the free ntfy app (iOS, Android, F-Droid) or a browser tab.

ntfy is one Go binary with a sqlite cache: ~30 MB RSS idle, negligible CPU. It runs on the same VPS as the monitor.

What subscribers get

Every message is a transition, never a state. Crossing up into a level sends one message; dropping back below it (with 0.10 m hysteresis) sends one all-clear. A river that sits at 3.9 m for three days produces two messages, not seventy-two. In a quiet season a subscriber hears nothing.

Topic Trigger Priority
ping-warning any gauge crosses its warning threshold; levels falling back 4 (high) / 2
ping-danger any gauge crosses its danger threshold 5 (max, breaks Do-Not-Disturb)
ping-<station>-warning that gauge crosses warning; back to normal 4 / 2
ping-<station>-danger that gauge crosses danger; back below danger 5 / 3
ping-p1-outlook model P(warning within 24 h) at P.1 rises through 50 % (clears below 25 %) 4 / 2
ping-status gauge feed stale ≥ 3 h; feed recovered 3 / 2

Station slugs are the code lowercased without the dot: p1, p103, p67. Thresholds are the ones in src/ml/features.py (THRESHOLDS): P.1 3.70 / 4.20 m, P.103 5.95 / 6.75 m, and so on.

The outlook topic is opt-in for a reason: it is model output, and the message says so. Observed-crossing topics only ever report a gauge reading.

Each message carries a click-through and an "Open dashboard" action button to the public dashboard.

How it runs

src/notify.py is called once per collection cycle inside the API process (leader only), right after the forecast precompute, so it sees exactly the readings and forecasts the dashboard shows. Per-key last-sent state is stored in the notification_state table of the monitor's own database, so a restart or redeploy never re-sends and never misses a crossing that happened while the service was down (the next cycle compares against the persisted state).

If ntfy is unreachable the transition is not recorded, so it is retried on the next cycle rather than silently lost. Any other failure in the notify step is logged and never reaches the collection loop.

The dashboard's "🔔 Get alerts" button appears only when NTFY_SERVER is set; it reads GET /api/notifications and renders subscribe links (ntfy:// deep links for the app, https links for the web UI).

Deployment

On the monitor VPS, as root:

cd /opt/thailand-water-monitor
NTFY_DOMAIN=ntfy.buildfor.life bash scripts/install_ntfy.sh

This installs the ntfy .deb, writes /etc/ntfy/server.yml (listen on 127.0.0.1:2586, anonymous read, token-only write, 72 h message cache, signup/login/metrics off, tight visitor limits), enables the systemd unit, creates the monitor user with write-only access to ping-*, mints a token, and appends NTFY_SERVER / NTFY_TOPIC_PREFIX / NTFY_TOKEN to .env if they are not there yet. Then:

systemctl restart water-monitor
journalctl -u water-monitor -n 20 | grep ntfy      # "ntfy notifications: https://... topics ping-*"
curl -s 'https://ntfy.buildfor.life/ping-status/json?poll=1'   # anonymous read works

Put https://ntfy.buildfor.life in front of 127.0.0.1:2586 with whatever already terminates TLS for water.buildfor.life. Subscribers hold a long-lived connection, so the proxy needs websockets on and no short read timeout:

ntfy.buildfor.life {
    reverse_proxy 127.0.0.1:2586
}

Cloudflare tunnel: add a public hostname ntfy.buildfor.lifehttp://127.0.0.1:2586. Cloudflare proxies websockets by default; nothing else to set.

Nothing about the message pipeline needs the domain to be public before you test: with NTFY_SERVER=http://127.0.0.1:2586 in .env the monitor publishes locally and curl .../ping-status/json?poll=1 shows what went out.

Configuration

Variable Default Meaning
NTFY_SERVER (empty = off) base URL of the ntfy server the monitor publishes to
NTFY_TOPIC_PREFIX ping first segment of every topic
NTFY_TOKEN (empty) bearer token if the server requires auth to publish (it does, see above)
PUBLIC_URL https://water.buildfor.life/ click-through target in messages

Tunables in src/notify.py: CLEAR_MARGIN_M (0.10), OUTLOOK_ON / OUTLOOK_OFF (0.50 / 0.25), stale feed threshold (3 h, argument to evaluate).

Testing

tests/test_notify.py covers the state machine: quiet river sends nothing; crossing once, then silence while above, then all-clear; hysteresis on the way down; escalation to danger and back; basin digest grouping; outlook on/off; heuristic forecasts ignored; stale feed and recovery; state survives a restart through sqlite; a failed publish is retried next cycle.

To exercise the real path against a real ntfy locally: run ntfy serve (any platform, same binary), set NTFY_SERVER/NTFY_TOKEN, seed readings, and poll the topic JSON. scripts/e2e_notify.py does exactly that if you want a template.

Why ntfy and not …

  • Matrix (src/alerting.py, still there): needs a homeserver account per subscriber and a room invite; fine for a team, wrong for the public.
  • Gotify: also self-hosted and light, but Android-only client and one account per subscriber.
  • Email / SMS: deliverability work, cost per message, no priority semantics; ntfy can forward to email per subscription if someone wants it.
  • Telegram / LINE bots: platform lock-in and a bot token in the loop; can be added later as ntfy→webhook fan-out without touching the monitor.