feat: public flood notifications over self-hosted ntfy
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
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
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).
This commit is contained in:
@@ -38,6 +38,12 @@ class Config:
|
||||
TARGET_URL = "https://hyd-app-db.rid.go.th/hydro1h.html"
|
||||
API_URL = "https://hyd-app-db.rid.go.th/webservice/getGroupHourlyWaterLevelReportAllHL.ashx"
|
||||
THAIWATER_API_KEY = os.getenv("THAIWATER_API_KEY")
|
||||
|
||||
# Public flood notifications (ntfy). Off unless NTFY_SERVER is set.
|
||||
NTFY_SERVER = os.getenv("NTFY_SERVER", "").strip()
|
||||
NTFY_TOPIC_PREFIX = os.getenv("NTFY_TOPIC_PREFIX", "ping").strip()
|
||||
NTFY_TOKEN = os.getenv("NTFY_TOKEN", "").strip() # publish token if ACL enabled
|
||||
PUBLIC_URL = os.getenv("PUBLIC_URL", "https://water.buildfor.life/").strip()
|
||||
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30"))
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
|
||||
+422
@@ -0,0 +1,422 @@
|
||||
"""Public flood notifications over ntfy.
|
||||
|
||||
Runs once per collection cycle inside the API process (leader only), right
|
||||
after the forecast precompute, so it sees the same readings and forecasts the
|
||||
dashboard shows. Publishes to a self-hosted ntfy server; anyone subscribes to
|
||||
a topic from the free app or a browser, no account needed.
|
||||
|
||||
Topics (all under one configurable prefix, default "ping"):
|
||||
|
||||
{prefix}-{station}-warning observed level crossed the station's warning threshold
|
||||
{prefix}-{station}-danger observed level crossed the danger threshold
|
||||
{prefix}-warning any station crossed warning (basin-wide digest)
|
||||
{prefix}-danger any station crossed danger
|
||||
{prefix}-p1-outlook model early warning for Chiang Mai city: P.1's 24 h
|
||||
warning probability crossed the alert level (opt-in;
|
||||
the forecast is experimental and says so)
|
||||
{prefix}-status feed/monitor health: data stale, recovered
|
||||
|
||||
Each notification is a TRANSITION, not a state: crossing UP into a level sends
|
||||
one message; dropping back below (with hysteresis) sends an all-clear. While
|
||||
the river sits above a threshold nothing is repeated, so a subscriber in a
|
||||
flood gets a handful of messages, not one an hour. The per-topic state is
|
||||
persisted (notification_state table) so a restart never re-sends.
|
||||
|
||||
Everything is fail-safe: ntfy unreachable, table missing, malformed
|
||||
reading -> a logged warning, never an exception into the collection loop.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from .ml import features
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hysteresis: an all-clear needs the level this far BELOW the threshold, so a
|
||||
# river bobbing around 3.70 m does not toggle warning/clear every hour.
|
||||
CLEAR_MARGIN_M = 0.10
|
||||
# Outlook alert fires when p_warning(24h) rises through ON, clears below OFF.
|
||||
OUTLOOK_ON = 0.50
|
||||
OUTLOOK_OFF = 0.25
|
||||
# Below this the outlook is not announced at all (avoid "5 % chance" noise).
|
||||
OUTLOOK_HORIZON = 24
|
||||
|
||||
STATION_NAMES: Dict[str, str] = {
|
||||
"P.1": "Nawarat Bridge, Chiang Mai city",
|
||||
"P.103": "Ring Road Bridge 3, Chiang Mai",
|
||||
"P.67": "Ban Tae (Mae Taeng)",
|
||||
"P.21": "Ban Rim Tai (Mae Rim)",
|
||||
"P.75": "Ban Chai Lat",
|
||||
"P.92": "Ban Muang Aut",
|
||||
"P.20": "Ban Chiang Dao",
|
||||
"P.4A": "Ban Mae Taeng",
|
||||
"P.5": "Tha Nang Bridge (downstream)",
|
||||
"P.81": "Ban Pong (downstream)",
|
||||
"P.82": "Ban Sob Win",
|
||||
"P.84": "Ban Panton",
|
||||
"P.87": "Ban Pa Sang",
|
||||
"P.77": "Ban Sop Mae Sapuat",
|
||||
"P.85": "Ban Lai Kaew",
|
||||
"P.76": "Ban Mae I Hai",
|
||||
}
|
||||
|
||||
|
||||
def _slug(code: str) -> str:
|
||||
return code.lower().replace(".", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
topic: str
|
||||
title: str
|
||||
message: str
|
||||
priority: int = 3 # ntfy: 1 min .. 5 max
|
||||
tags: Optional[List[str]] = None
|
||||
click: Optional[str] = None
|
||||
|
||||
|
||||
class NtfyPublisher:
|
||||
def __init__(
|
||||
self,
|
||||
server: str,
|
||||
prefix: str = "ping",
|
||||
token: Optional[str] = None,
|
||||
dashboard_url: str = "https://water.buildfor.life/",
|
||||
timeout: int = 10,
|
||||
):
|
||||
self.server = server.rstrip("/")
|
||||
self.prefix = prefix
|
||||
self.token = token
|
||||
self.dashboard_url = dashboard_url
|
||||
self.timeout = timeout
|
||||
|
||||
def topic(self, *parts: str) -> str:
|
||||
return "-".join([self.prefix, *parts])
|
||||
|
||||
def publish(self, n: Notification) -> bool:
|
||||
headers = {
|
||||
"Title": n.title,
|
||||
"Priority": str(n.priority),
|
||||
"Click": n.click or self.dashboard_url,
|
||||
"Actions": f"view, Open dashboard, {n.click or self.dashboard_url}",
|
||||
}
|
||||
if n.tags:
|
||||
headers["Tags"] = ",".join(n.tags)
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.server}/{n.topic}",
|
||||
data=n.message.encode("utf-8"),
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if r.status_code >= 300:
|
||||
logger.warning(f"ntfy {n.topic}: HTTP {r.status_code} {r.text[:120]}")
|
||||
return False
|
||||
return True
|
||||
except Exception as error:
|
||||
logger.warning(f"ntfy {n.topic}: {error}")
|
||||
return False
|
||||
|
||||
|
||||
class NotificationState:
|
||||
"""Per-key last-sent state, in the monitor's own SQL database."""
|
||||
|
||||
def __init__(self, engine, db_type: str):
|
||||
self.engine = engine
|
||||
self.db_type = db_type
|
||||
self._ensure()
|
||||
|
||||
def _ensure(self) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE IF NOT EXISTS notification_state ("
|
||||
"key VARCHAR(64) PRIMARY KEY, state VARCHAR(16) NOT NULL, "
|
||||
"value NUMERIC(8,3), updated_at TIMESTAMP NOT NULL)"
|
||||
)
|
||||
)
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
from sqlalchemy import text
|
||||
|
||||
with self.engine.connect() as conn:
|
||||
row = conn.execute(
|
||||
text("SELECT state FROM notification_state WHERE key = :k"), {"k": key}
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def set(self, key: str, state: str, value: Optional[float] = None) -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
now = datetime.datetime.now()
|
||||
with self.engine.begin() as conn:
|
||||
if self.db_type == "mysql":
|
||||
sql = (
|
||||
"INSERT INTO notification_state (key, state, value, updated_at) "
|
||||
"VALUES (:k, :s, :v, :t) ON DUPLICATE KEY UPDATE "
|
||||
"state = VALUES(state), value = VALUES(value), updated_at = VALUES(updated_at)"
|
||||
)
|
||||
else:
|
||||
sql = (
|
||||
"INSERT INTO notification_state (key, state, value, updated_at) "
|
||||
"VALUES (:k, :s, :v, :t) ON CONFLICT (key) DO UPDATE SET "
|
||||
"state = EXCLUDED.state, value = EXCLUDED.value, updated_at = EXCLUDED.updated_at"
|
||||
)
|
||||
conn.execute(text(sql), {"k": key, "s": state, "v": value, "t": now})
|
||||
|
||||
|
||||
class InMemoryState(NotificationState):
|
||||
"""For tests and when no SQL engine is available (loses state on restart)."""
|
||||
|
||||
def __init__(self): # noqa: D107 - intentionally skips the SQL parent
|
||||
self._d: Dict[str, str] = {}
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
return self._d.get(key)
|
||||
|
||||
def set(self, key: str, state: str, value: Optional[float] = None) -> None:
|
||||
self._d[key] = state
|
||||
|
||||
|
||||
def _level_state(level: float, warn: float, danger: float, prev: Optional[str]) -> str:
|
||||
"""'clear' | 'warning' | 'danger', with hysteresis on the way down."""
|
||||
if level >= danger:
|
||||
return "danger"
|
||||
if level >= warn:
|
||||
# from danger: stay 'danger' until below danger - margin
|
||||
if prev == "danger" and level >= danger - CLEAR_MARGIN_M:
|
||||
return "danger"
|
||||
return "warning"
|
||||
if prev in ("warning", "danger") and level >= warn - CLEAR_MARGIN_M:
|
||||
return "warning"
|
||||
return "clear"
|
||||
|
||||
|
||||
def evaluate(
|
||||
readings: Iterable[dict],
|
||||
forecasts: Iterable[dict],
|
||||
state: NotificationState,
|
||||
publisher: NtfyPublisher,
|
||||
stale_after_h: float = 3.0,
|
||||
now: Optional[datetime.datetime] = None,
|
||||
) -> List[Notification]:
|
||||
"""Compare current readings/forecasts with last-sent state; publish transitions.
|
||||
|
||||
readings: rows with station_code, water_level, timestamp (latest per station)
|
||||
forecasts: /forecast rows (station_code, horizon_hours, p_warning, predicted_max_level)
|
||||
Returns the notifications that were published (for logs/tests).
|
||||
"""
|
||||
now = now or datetime.datetime.now()
|
||||
sent: List[Notification] = []
|
||||
|
||||
def emit(n: Notification) -> bool:
|
||||
ok = publisher.publish(n)
|
||||
if ok:
|
||||
sent.append(n)
|
||||
return ok
|
||||
|
||||
# ---- observed levels, per station, plus basin-wide fan-out
|
||||
basin_changes: Dict[str, List[str]] = {"warning": [], "danger": [], "clear": []}
|
||||
latest_ts: Optional[datetime.datetime] = None
|
||||
for r in readings:
|
||||
code = r.get("station_code")
|
||||
level = r.get("water_level")
|
||||
if not code or level is None:
|
||||
continue
|
||||
try:
|
||||
level = float(level)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
ts = r.get("timestamp")
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
ts = datetime.datetime.fromisoformat(ts)
|
||||
except ValueError:
|
||||
ts = None
|
||||
if isinstance(ts, datetime.datetime) and (latest_ts is None or ts > latest_ts):
|
||||
latest_ts = ts
|
||||
warn, danger = features.get_thresholds(code)
|
||||
key = f"level:{code}"
|
||||
prev = state.get(key) or "clear"
|
||||
cur = _level_state(level, warn, danger, prev)
|
||||
if cur == prev:
|
||||
continue
|
||||
name = STATION_NAMES.get(code, code)
|
||||
slug = _slug(code)
|
||||
when = (
|
||||
ts.strftime("%d %b %H:%M") if isinstance(ts, datetime.datetime) else "now"
|
||||
)
|
||||
if cur == "danger":
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic(slug, "danger"),
|
||||
f"DANGER level at {code}",
|
||||
f"{name}: {level:.2f} m at {when}, above the danger level of {danger:.2f} m.",
|
||||
priority=5,
|
||||
tags=["rotating_light", code],
|
||||
)
|
||||
)
|
||||
basin_changes["danger"].append(f"{code} {level:.2f} m")
|
||||
elif cur == "warning":
|
||||
if prev == "danger":
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic(slug, "danger"),
|
||||
f"{code} back below danger level",
|
||||
f"{name}: {level:.2f} m at {when}; still above the warning level of {warn:.2f} m.",
|
||||
priority=3,
|
||||
tags=["arrow_down", code],
|
||||
)
|
||||
)
|
||||
basin_changes["clear"].append(f"{code} below danger ({level:.2f} m)")
|
||||
else:
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic(slug, "warning"),
|
||||
f"Warning level at {code}",
|
||||
f"{name}: {level:.2f} m at {when}, above the warning level of {warn:.2f} m.",
|
||||
priority=4,
|
||||
tags=["warning", code],
|
||||
)
|
||||
)
|
||||
basin_changes["warning"].append(f"{code} {level:.2f} m")
|
||||
else: # clear
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic(slug, "warning"),
|
||||
f"{code} back to normal",
|
||||
f"{name}: {level:.2f} m at {when}, below the warning level of {warn:.2f} m.",
|
||||
priority=2,
|
||||
tags=["white_check_mark", code],
|
||||
)
|
||||
)
|
||||
basin_changes["clear"].append(f"{code} normal ({level:.2f} m)")
|
||||
# Only remember the transition once it was actually delivered: if ntfy
|
||||
# was down, the next cycle retries instead of silently swallowing a
|
||||
# flood crossing.
|
||||
if ok:
|
||||
state.set(key, cur, level)
|
||||
|
||||
if basin_changes["danger"]:
|
||||
emit(
|
||||
Notification(
|
||||
publisher.topic("danger"),
|
||||
"Ping River: danger level reached",
|
||||
"; ".join(basin_changes["danger"]),
|
||||
priority=5,
|
||||
tags=["rotating_light"],
|
||||
)
|
||||
)
|
||||
if basin_changes["warning"]:
|
||||
emit(
|
||||
Notification(
|
||||
publisher.topic("warning"),
|
||||
"Ping River: warning level reached",
|
||||
"; ".join(basin_changes["warning"]),
|
||||
priority=4,
|
||||
tags=["warning"],
|
||||
)
|
||||
)
|
||||
if basin_changes["clear"]:
|
||||
emit(
|
||||
Notification(
|
||||
publisher.topic("warning"),
|
||||
"Ping River: levels falling",
|
||||
"; ".join(basin_changes["clear"]),
|
||||
priority=2,
|
||||
tags=["white_check_mark"],
|
||||
)
|
||||
)
|
||||
|
||||
# ---- model outlook for the city gauge (opt-in topic, experimental)
|
||||
p1 = next(
|
||||
(
|
||||
f
|
||||
for f in forecasts
|
||||
if f.get("station_code") == "P.1"
|
||||
and f.get("horizon_hours") == OUTLOOK_HORIZON
|
||||
and f.get("source") == "model"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if p1 and p1.get("p_warning") is not None:
|
||||
p = float(p1["p_warning"])
|
||||
key = "outlook:P.1"
|
||||
prev = state.get(key) or "off"
|
||||
cur = (
|
||||
"on" if (p >= OUTLOOK_ON or (prev == "on" and p >= OUTLOOK_OFF)) else "off"
|
||||
)
|
||||
if cur != prev:
|
||||
peak = p1.get("predicted_max_level")
|
||||
warn, _ = features.get_thresholds("P.1")
|
||||
if cur == "on":
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic("p1-outlook"),
|
||||
"Early warning: Chiang Mai flood risk rising",
|
||||
f"The forecast model gives a {p * 100:.0f}% chance that Nawarat Bridge (P.1) "
|
||||
f"reaches {warn:.2f} m within 24 h"
|
||||
+ (
|
||||
f" (expected peak {float(peak):.2f} m)"
|
||||
if peak is not None
|
||||
else ""
|
||||
)
|
||||
+ ". Experimental model output, not an official warning; "
|
||||
"follow ThaiWater/TMD for official alerts.",
|
||||
priority=4,
|
||||
tags=["crystal_ball"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic("p1-outlook"),
|
||||
"Chiang Mai flood risk easing",
|
||||
f"The model's 24 h probability of reaching {warn:.2f} m at P.1 has dropped to {p * 100:.0f}%.",
|
||||
priority=2,
|
||||
tags=["crystal_ball"],
|
||||
)
|
||||
)
|
||||
if ok:
|
||||
state.set(key, cur, p)
|
||||
|
||||
# ---- feed health
|
||||
if latest_ts is not None:
|
||||
age_h = (now - latest_ts).total_seconds() / 3600.0
|
||||
key = "feed"
|
||||
prev = state.get(key) or "ok"
|
||||
cur = "stale" if age_h >= stale_after_h else "ok"
|
||||
if cur != prev:
|
||||
if cur == "stale":
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic("status"),
|
||||
"Ping River monitor: gauge feed stale",
|
||||
f"No new readings for {age_h:.0f} h (last {latest_ts:%d %b %H:%M}). "
|
||||
"Levels and forecasts on the dashboard are not current.",
|
||||
priority=3,
|
||||
tags=["hourglass"],
|
||||
)
|
||||
)
|
||||
else:
|
||||
ok = emit(
|
||||
Notification(
|
||||
publisher.topic("status"),
|
||||
"Ping River monitor: feed recovered",
|
||||
f"Readings are current again (latest {latest_ts:%d %b %H:%M}).",
|
||||
priority=2,
|
||||
tags=["white_check_mark"],
|
||||
)
|
||||
)
|
||||
if ok:
|
||||
state.set(key, cur, age_h)
|
||||
return sent
|
||||
@@ -113,6 +113,25 @@
|
||||
.lang-toggle { padding: 9px 12px; font-size: .78rem; font-weight: 800; white-space: nowrap; }
|
||||
.lang-toggle[data-active-lang="th"] { background: var(--mint); border-color: var(--mint-border); color: var(--mint-ink); }
|
||||
.theme-toggle { padding: 9px 11px; font-size: .95rem; line-height: 1; }
|
||||
.alerts-panel { margin-bottom: 14px; padding: 18px 20px; border-radius: 16px; background: var(--card); border: 1px solid var(--border); box-shadow: var(--shadow); }
|
||||
.alerts-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.alerts-head h2 { margin: 0; font-size: 1.15rem; }
|
||||
.alerts-close { background: transparent; border: 0; color: var(--muted); font-size: 1.1rem; cursor: pointer; padding: 4px 8px; }
|
||||
.alerts-intro { color: var(--muted); font-size: .92rem; line-height: 1.5; margin: 8px 0 12px; }
|
||||
.alerts-server { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: .9rem; margin-bottom: 12px; }
|
||||
.alerts-server code { background: var(--surface); border: 1px solid var(--border); padding: 4px 8px; border-radius: 8px; font-size: .9rem; }
|
||||
.alerts-server button { padding: 4px 10px; font-size: .8rem; }
|
||||
.alerts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 10px; }
|
||||
.alerts-topic { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; background: var(--surface); display: flex; flex-direction: column; gap: 4px; }
|
||||
.alerts-topic .name { font-weight: 600; font-size: .95rem; }
|
||||
.alerts-topic .desc { color: var(--muted); font-size: .82rem; line-height: 1.4; }
|
||||
.alerts-topic .row { display: flex; align-items: center; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
|
||||
.alerts-topic code { font-size: .82rem; background: var(--card); border: 1px solid var(--border); padding: 2px 6px; border-radius: 6px; }
|
||||
.alerts-topic a { font-size: .82rem; }
|
||||
.alerts-topic.danger { border-color: rgba(220, 38, 38, .45); }
|
||||
.alerts-topic.outlook { border-style: dashed; }
|
||||
.alerts-foot { color: var(--muted); font-size: .82rem; margin: 12px 0 0; line-height: 1.6; }
|
||||
.alerts-disclaimer { display: block; margin-top: 4px; }
|
||||
.leaflet-popup-content-wrapper, .leaflet-popup-tip { background: var(--card); color: var(--ink); }
|
||||
.leaflet-container a.leaflet-popup-close-button { color: var(--muted); }
|
||||
.leaflet-bar a, .leaflet-control-attribution { background: var(--surface); color: var(--ink); border-color: var(--border); }
|
||||
@@ -348,10 +367,33 @@
|
||||
<button id="refresh-button" type="button" data-i18n="action.refresh">↻ Refresh</button>
|
||||
<button id="lang-toggle" class="lang-toggle" type="button" data-active-lang="en" aria-label="Switch to Thai">ไทย</button>
|
||||
<button id="theme-toggle" class="theme-toggle" type="button" data-i18n-aria="theme.toggle" aria-label="Switch to dark mode" title="Switch to dark mode">🌙</button>
|
||||
<button id="alerts-button" type="button" data-i18n="alerts.button" style="display:none">🔔 Get alerts</button>
|
||||
<button id="replay-2024" type="button" data-i18n="replay.start">▶ Replay Oct 2024 flood</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="alerts-panel" class="alerts-panel" style="display:none" aria-labelledby="alerts-title">
|
||||
<div class="alerts-head">
|
||||
<h2 id="alerts-title" data-i18n="alerts.title">Flood alerts on your phone</h2>
|
||||
<button type="button" class="alerts-close" id="alerts-close" data-i18n-aria="alerts.close" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<p class="alerts-intro" data-i18n="alerts.intro">Free push notifications when a gauge crosses its warning or danger level, and an all-clear when it drops back. No account: install the ntfy app (iOS / Android / any browser), add the server, subscribe to the topics you want. You get a message only when something changes: a few per flood, none in a quiet season.</p>
|
||||
<div class="alerts-server">
|
||||
<span data-i18n="alerts.server">Server</span>
|
||||
<code id="alerts-server-url"></code>
|
||||
<button type="button" id="alerts-copy" data-i18n="alerts.copy">Copy</button>
|
||||
</div>
|
||||
<div class="alerts-grid" id="alerts-topics"></div>
|
||||
<p class="alerts-foot">
|
||||
<span data-i18n="alerts.apps">Apps:</span>
|
||||
<a href="https://apps.apple.com/us/app/ntfy/id1625396347" target="_blank" rel="noopener">iOS</a> ·
|
||||
<a href="https://play.google.com/store/apps/details?id=io.heckel.ntfy" target="_blank" rel="noopener">Android</a> ·
|
||||
<a href="https://f-droid.org/en/packages/io.heckel.ntfy/" target="_blank" rel="noopener">F-Droid</a> ·
|
||||
<a id="alerts-web-link" href="#" target="_blank" rel="noopener" data-i18n="alerts.web">Web (no install)</a>
|
||||
<span class="alerts-disclaimer" data-i18n="alerts.disclaimer">Unofficial community service, best effort. For official warnings follow ThaiWater / TMD / your district office.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="flood-verdict" role="status" aria-live="polite" style="display:none;margin-bottom:14px;padding:15px 18px;border-radius:16px;border:1px solid;display:none">
|
||||
<div style="display:flex;gap:12px;align-items:baseline;flex-wrap:wrap">
|
||||
<strong id="verdict-icon" style="font-size:1.2rem"></strong>
|
||||
@@ -585,6 +627,31 @@
|
||||
'skill.worse': (v, prev, d) => `Current model ${v} has a higher peak error than ${prev} so far (+${d} cm).`,
|
||||
'skill.caveat.quiet': 'All verified hours so far were below 2 m: this measures quiet-river accuracy only. The model is built and judged for flood onset (lead time before 3.70 m), which no quiet week can test — see the backtests in the documentation.',
|
||||
'skill.caveat.regime': 'Versions served different weeks; the ≥ 2 m column compares them on the hours that matter.',
|
||||
'alerts.button': '🔔 Get alerts',
|
||||
'alerts.title': 'Flood alerts on your phone',
|
||||
'alerts.intro': 'Free push notifications when a gauge crosses its warning or danger level, and an all-clear when it drops back. No account: install the ntfy app (iOS / Android / any browser), add the server, subscribe to the topics you want. You get a message only when something changes: a few per flood, none in a quiet season.',
|
||||
'alerts.server': 'Server',
|
||||
'alerts.copy': 'Copy',
|
||||
'alerts.copied': 'Copied',
|
||||
'alerts.close': 'Close',
|
||||
'alerts.apps': 'Apps:',
|
||||
'alerts.web': 'Web (no install)',
|
||||
'alerts.disclaimer': 'Unofficial community service, best effort. For official warnings follow ThaiWater / TMD / your district office.',
|
||||
'alerts.subscribe': 'Subscribe in app',
|
||||
'alerts.t.warning': 'Any gauge: warning level',
|
||||
'alerts.t.warning.d': 'One message when any Ping River gauge crosses its warning level, and when levels fall back. The one to pick if unsure.',
|
||||
'alerts.t.danger': 'Any gauge: danger level',
|
||||
'alerts.t.danger.d': 'Only the serious crossings, basin-wide. Highest priority: rings through Do Not Disturb on most phones.',
|
||||
'alerts.t.p1.warning': 'Chiang Mai city (P.1) warning',
|
||||
'alerts.t.p1.warning.d': 'Nawarat Bridge crosses 3.70 m (stage 1: low-lying riverside areas), and the all-clear.',
|
||||
'alerts.t.p1.danger': 'Chiang Mai city (P.1) danger',
|
||||
'alerts.t.p1.danger.d': 'Nawarat Bridge crosses 4.20 m (stage 5: inner city districts).',
|
||||
'alerts.t.p103.warning': 'Ring Road 3 (P.103) warning',
|
||||
'alerts.t.p103.warning.d': 'Downstream city gauge crosses 5.95 m.',
|
||||
'alerts.t.outlook': 'Early warning (model forecast)',
|
||||
'alerts.t.outlook.d': 'Experimental: the forecast model gives a ≥ 50 % chance that P.1 reaches its warning level within 24 h. Up to ~13 h earlier than the gauge, but it can be wrong.',
|
||||
'alerts.t.status': 'Monitor status',
|
||||
'alerts.t.status.d': 'Gauge feed stale / recovered. For people who rely on the dashboard.',
|
||||
'skill.single': (v) => `Only ${v} has enough verified hours yet; the next retrain adds a row to compare.`,
|
||||
'skill.young': (v, n, min) => `${v} has ${n} verified hours; a comparison needs ${min}.`,
|
||||
'skill.none': 'No verified forecasts yet — the first appear 24 h after a model starts serving.',
|
||||
@@ -781,6 +848,31 @@
|
||||
'skill.worse': (v, prev, d) => `โมเดลปัจจุบัน ${v} มีค่าคลาดเคลื่อนสูงกว่า ${prev} (+${d} ซม.)`,
|
||||
'skill.caveat.quiet': 'ชั่วโมงที่ตรวจสอบทั้งหมดอยู่ต่ำกว่า 2 ม.: วัดได้เพียงความแม่นยำช่วงน้ำปกติ โมเดลถูกสร้างและประเมินสำหรับช่วงน้ำเริ่มท่วม (เวลาเตือนล่วงหน้าก่อน 3.70 ม.) ซึ่งสัปดาห์ปกติทดสอบไม่ได้ — ดูผลทดสอบย้อนหลังในเอกสาร',
|
||||
'skill.caveat.regime': 'แต่ละเวอร์ชันให้บริการคนละช่วงเวลา คอลัมน์ ≥ 2 ม. เปรียบเทียบเฉพาะชั่วโมงที่สำคัญ',
|
||||
'alerts.button': '🔔 รับการแจ้งเตือน',
|
||||
'alerts.title': 'แจ้งเตือนน้ำท่วมบนมือถือของคุณ',
|
||||
'alerts.intro': 'การแจ้งเตือนฟรีเมื่อระดับน้ำที่สถานีใดข้ามระดับเฝ้าระวังหรือระดับอันตราย และแจ้งเมื่อกลับสู่ปกติ ไม่ต้องสมัครสมาชิก: ติดตั้งแอป ntfy (iOS / Android / เบราว์เซอร์) เพิ่มเซิร์ฟเวอร์ แล้วเลือกหัวข้อที่ต้องการ คุณจะได้รับข้อความเฉพาะเมื่อมีการเปลี่ยนแปลง: ไม่กี่ข้อความต่อเหตุการณ์น้ำท่วม และไม่มีเลยในช่วงปกติ',
|
||||
'alerts.server': 'เซิร์ฟเวอร์',
|
||||
'alerts.copy': 'คัดลอก',
|
||||
'alerts.copied': 'คัดลอกแล้ว',
|
||||
'alerts.close': 'ปิด',
|
||||
'alerts.apps': 'แอป:',
|
||||
'alerts.web': 'เว็บ (ไม่ต้องติดตั้ง)',
|
||||
'alerts.disclaimer': 'บริการชุมชนอย่างไม่เป็นทางการ พยายามอย่างดีที่สุด สำหรับคำเตือนอย่างเป็นทางการโปรดติดตาม ThaiWater / กรมอุตุนิยมวิทยา / สำนักงานอำเภอของคุณ',
|
||||
'alerts.subscribe': 'สมัครในแอป',
|
||||
'alerts.t.warning': 'สถานีใดก็ได้: ระดับเฝ้าระวัง',
|
||||
'alerts.t.warning.d': 'หนึ่งข้อความเมื่อสถานีใดในแม่น้ำปิงข้ามระดับเฝ้าระวัง และเมื่อระดับน้ำลดลง หากไม่แน่ใจให้เลือกอันนี้',
|
||||
'alerts.t.danger': 'สถานีใดก็ได้: ระดับอันตราย',
|
||||
'alerts.t.danger.d': 'เฉพาะการข้ามระดับที่ร้ายแรง ทั้งลุ่มน้ำ ความสำคัญสูงสุด: ดังผ่านโหมดห้ามรบกวนในโทรศัพท์ส่วนใหญ่',
|
||||
'alerts.t.p1.warning': 'เมืองเชียงใหม่ (P.1) ระดับเฝ้าระวัง',
|
||||
'alerts.t.p1.warning.d': 'สะพานนวรัฐข้าม 3.70 ม. (ระยะที่ 1: พื้นที่ริมน้ำที่ต่ำ) และแจ้งเมื่อกลับสู่ปกติ',
|
||||
'alerts.t.p1.danger': 'เมืองเชียงใหม่ (P.1) ระดับอันตราย',
|
||||
'alerts.t.p1.danger.d': 'สะพานนวรัฐข้าม 4.20 ม. (ระยะที่ 5: ย่านใจกลางเมือง)',
|
||||
'alerts.t.p103.warning': 'ถนนวงแหวน 3 (P.103) ระดับเฝ้าระวัง',
|
||||
'alerts.t.p103.warning.d': 'สถานีท้ายเมืองข้าม 5.95 ม.',
|
||||
'alerts.t.outlook': 'เตือนล่วงหน้า (แบบจำลองพยากรณ์)',
|
||||
'alerts.t.outlook.d': 'ทดลอง: แบบจำลองพยากรณ์ให้โอกาส ≥ 50% ที่ P.1 จะถึงระดับเฝ้าระวังภายใน 24 ชม. เร็วกว่าสถานีวัดได้ถึง ~13 ชม. แต่อาจผิดพลาดได้',
|
||||
'alerts.t.status': 'สถานะระบบ',
|
||||
'alerts.t.status.d': 'ข้อมูลสถานีล่าช้า / กลับมาปกติ สำหรับผู้ที่พึ่งพาแดชบอร์ด',
|
||||
'skill.single': (v) => `มีเพียง ${v} ที่มีข้อมูลตรวจสอบเพียงพอ การฝึกครั้งถัดไปจะเพิ่มแถวให้เปรียบเทียบ`,
|
||||
'skill.young': (v, n, min) => `${v} มีข้อมูลตรวจสอบ ${n} ชั่วโมง ต้องการอย่างน้อย ${min} เพื่อเปรียบเทียบ`,
|
||||
'skill.none': 'ยังไม่มีพยากรณ์ที่ตรวจสอบได้ — จะเริ่มมี 24 ชม. หลังโมเดลเริ่มทำงาน',
|
||||
@@ -905,6 +997,60 @@
|
||||
return typeof value === 'function' ? value(...args) : value;
|
||||
}
|
||||
|
||||
// ---- public push notifications (ntfy) -------------------------------------
|
||||
let ALERTS_CFG = null;
|
||||
const ALERT_TOPICS = [
|
||||
{ key: 'warning', topic: 'warning', cls: '' },
|
||||
{ key: 'danger', topic: 'danger', cls: 'danger' },
|
||||
{ key: 'p1.warning', topic: 'p1-warning', cls: '' },
|
||||
{ key: 'p1.danger', topic: 'p1-danger', cls: 'danger' },
|
||||
{ key: 'p103.warning', topic: 'p103-warning', cls: '' },
|
||||
{ key: 'outlook', topic: 'p1-outlook', cls: 'outlook' },
|
||||
{ key: 'status', topic: 'status', cls: '' },
|
||||
];
|
||||
async function loadAlertsConfig() {
|
||||
try {
|
||||
const r = await fetch('/api/notifications');
|
||||
if (!r.ok) return;
|
||||
const cfg = await r.json();
|
||||
if (!cfg.enabled || !cfg.server) return;
|
||||
ALERTS_CFG = cfg;
|
||||
$('alerts-button').style.display = '';
|
||||
renderAlertsPanel();
|
||||
} catch (e) { /* no notifications configured */ }
|
||||
}
|
||||
function renderAlertsPanel() {
|
||||
if (!ALERTS_CFG) return;
|
||||
const server = ALERTS_CFG.server.replace(/\/$/, '');
|
||||
const host = server.replace(/^https?:\/\//, '');
|
||||
$('alerts-server-url').textContent = host;
|
||||
$('alerts-web-link').href = server + '/' + ALERTS_CFG.prefix + '-warning';
|
||||
$('alerts-topics').innerHTML = ALERT_TOPICS.map(tp => {
|
||||
const full = ALERTS_CFG.prefix + '-' + tp.topic;
|
||||
const url = server + '/' + full;
|
||||
return `<div class="alerts-topic ${tp.cls}">
|
||||
<div class="name">${esc(t('alerts.t.' + tp.key))}</div>
|
||||
<div class="desc">${esc(t('alerts.t.' + tp.key + '.d'))}</div>
|
||||
<div class="row"><code>${esc(full)}</code> <a href="ntfy://${esc(host)}/${esc(full)}">${esc(t('alerts.subscribe'))}</a> · <a href="${esc(url)}" target="_blank" rel="noopener">web</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
function esc(x) { return String(x).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||||
$('alerts-button').addEventListener('click', () => {
|
||||
const p = $('alerts-panel');
|
||||
const open = p.style.display === 'none';
|
||||
p.style.display = open ? '' : 'none';
|
||||
if (open) { renderAlertsPanel(); p.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||
});
|
||||
$('alerts-close').addEventListener('click', () => { $('alerts-panel').style.display = 'none'; });
|
||||
$('alerts-copy').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ALERTS_CFG ? ALERTS_CFG.server : '');
|
||||
$('alerts-copy').textContent = t('alerts.copied');
|
||||
setTimeout(() => { $('alerts-copy').textContent = t('alerts.copy'); }, 1500);
|
||||
} catch (e) { /* clipboard blocked */ }
|
||||
});
|
||||
|
||||
function applyTranslations() {
|
||||
document.documentElement.lang = state.lang;
|
||||
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
||||
@@ -959,6 +1105,7 @@
|
||||
function cssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
|
||||
|
||||
function setLang(lang) {
|
||||
setTimeout(renderAlertsPanel, 0);
|
||||
state.lang = lang;
|
||||
try { localStorage.setItem(LANG_KEY, lang); } catch (e) { /* private mode */ }
|
||||
applyTranslations();
|
||||
@@ -2052,6 +2199,7 @@
|
||||
: t('forecast.expand', stations.length);
|
||||
card.style.display = 'block';
|
||||
loadSkill(); // non-blocking; panel stays hidden until there is verified data
|
||||
loadAlertsConfig(); // shows the "Get alerts" button only when ntfy is configured
|
||||
} catch (error) {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -175,6 +175,37 @@ async def lifespan(app: FastAPI):
|
||||
app_state["forecast_store"] = None
|
||||
logger.error(f"Forecast history store init failed: {e}")
|
||||
|
||||
# Public flood notifications over ntfy (leader only, transitions only).
|
||||
# Off unless NTFY_SERVER is set; then state lives in notification_state
|
||||
# next to the measurements so a restart never re-sends.
|
||||
app_state["notify"] = None
|
||||
if Config.NTFY_SERVER:
|
||||
try:
|
||||
from . import notify as notify_mod
|
||||
|
||||
store = app_state.get("forecast_store")
|
||||
if store and not store.engine:
|
||||
store.connect()
|
||||
state = (
|
||||
notify_mod.NotificationState(store.engine, store.db_type)
|
||||
if store and store.engine
|
||||
else notify_mod.InMemoryState()
|
||||
)
|
||||
app_state["notify"] = (
|
||||
notify_mod.NtfyPublisher(
|
||||
Config.NTFY_SERVER,
|
||||
prefix=Config.NTFY_TOPIC_PREFIX,
|
||||
token=Config.NTFY_TOKEN or None,
|
||||
dashboard_url=Config.PUBLIC_URL,
|
||||
),
|
||||
state,
|
||||
)
|
||||
logger.info(
|
||||
f"ntfy notifications: {Config.NTFY_SERVER} topics {Config.NTFY_TOPIC_PREFIX}-*"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"ntfy init failed (notifications off): {e}")
|
||||
|
||||
# Initialize HII/ThaiWater collector (rainfall + backup water level)
|
||||
try:
|
||||
from .hii_collector import create_collector_from_config
|
||||
@@ -296,6 +327,33 @@ async def _persist_rain():
|
||||
logger.warning(f"rain persistence failed: {e}")
|
||||
|
||||
|
||||
async def _notify_transitions():
|
||||
"""Publish flood/outlook/feed transitions to ntfy (leader only, fail-safe)."""
|
||||
cfg = app_state.get("notify")
|
||||
if not cfg:
|
||||
return
|
||||
publisher, state = cfg
|
||||
try:
|
||||
from . import notify as notify_mod
|
||||
|
||||
scraper = app_state["scraper"]
|
||||
readings = await asyncio.to_thread(
|
||||
scraper.db_adapter.get_latest_measurements, 200
|
||||
)
|
||||
with FORECAST_CACHE_LOCK:
|
||||
cached = FORECAST_CACHE.get("all")
|
||||
forecasts = cached[1] if cached else []
|
||||
sent = await asyncio.to_thread(
|
||||
notify_mod.evaluate, readings, forecasts, state, publisher
|
||||
)
|
||||
if sent:
|
||||
logger.info(
|
||||
"ntfy: published " + ", ".join(f"{n.topic}: {n.title}" for n in sent)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"ntfy notify cycle failed: {e}")
|
||||
|
||||
|
||||
async def _precompute_forecasts():
|
||||
"""Refresh the forecast cache and persist the issued forecasts (leader only)."""
|
||||
try:
|
||||
@@ -402,6 +460,10 @@ async def background_scraping_task():
|
||||
# evaluation.
|
||||
await _precompute_forecasts()
|
||||
|
||||
# Push notifications for threshold crossings (uses the
|
||||
# forecasts just computed; no-op unless NTFY_SERVER set).
|
||||
await _notify_transitions()
|
||||
|
||||
app_state["is_scraping"] = False
|
||||
|
||||
# Calculate next run time
|
||||
@@ -1230,6 +1292,28 @@ async def get_forecast_history(
|
||||
return await asyncio.to_thread(store.fetch, station_code, start_dt, end_dt, horizon)
|
||||
|
||||
|
||||
@app.get("/api/notifications")
|
||||
async def get_notifications_config():
|
||||
"""Public ntfy settings so the dashboard can offer subscribe links."""
|
||||
server = Config.NTFY_SERVER
|
||||
if not server:
|
||||
return {"enabled": False}
|
||||
prefix = Config.NTFY_TOPIC_PREFIX
|
||||
return {
|
||||
"enabled": True,
|
||||
"server": server,
|
||||
"prefix": prefix,
|
||||
"topics": {
|
||||
"warning": f"{prefix}-warning",
|
||||
"danger": f"{prefix}-danger",
|
||||
"p1_outlook": f"{prefix}-p1-outlook",
|
||||
"status": f"{prefix}-status",
|
||||
"station_pattern": f"{prefix}-<station>-warning | {prefix}-<station>-danger (station code lowercase, no dot: p1, p103)",
|
||||
},
|
||||
"semantics": "transitions only: one message on crossing up, one all-clear on the way down (0.10 m hysteresis)",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/forecast/skill")
|
||||
async def get_forecast_skill(
|
||||
response: Response,
|
||||
|
||||
Reference in New Issue
Block a user