feat: NINA send_to_mesh-Schalter + Dresden-AGS-Codes

- send_to_mesh: true/false – trennt Abfrage vom Mesh-Versand.
  false = Monitor-Modus: Warnmeldungen werden abgerufen und in der
  Weboberfläche angezeigt, aber NICHT ins Meshtastic-Netz gesendet.
  WebSocket-Event enthaelt monitor_only-Flag (Anzeige per Icon).
- nina.yaml/conf/nina.yaml: send_to_mesh=false als sichere Voreinstellung
  + 5 AGS-Codes fuer den Raum Dresden vorbelegt:
  Stadt Dresden (146120000000), LK Meissen (146270000000),
  LK Saechs. Schweiz-Osterzgebirge (146280000000),
  LK Bautzen (146250000000), LK Goerlitz (146260000000)
- nina.html: zweiter Toggle "Ins Mesh senden"
- nina.js: Schalter in load/save + Statusbadge (Mesh+Web / Nur Web)
  + Mesh-Spalte in Alerts-Tabelle mit broadcast/eye-Icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-19 11:41:42 +01:00
parent 0ca0ffb0d1
commit ee3208769c
6 changed files with 216 additions and 55 deletions

View file

@ -6,6 +6,14 @@
- **NINA-Integration**: Anbindung an die NINA Warn-App des BBK (Bundesamt für Bevölkerungsschutz - **NINA-Integration**: Anbindung an die NINA Warn-App des BBK (Bundesamt für Bevölkerungsschutz
und Katastrophenhilfe). Warnmeldungen werden per HTTP-Polling von `warnung.bund.de/api31` und Katastrophenhilfe). Warnmeldungen werden per HTTP-Polling von `warnung.bund.de/api31`
abgerufen und bei neuen Meldungen automatisch ins Meshtastic-Netz gesendet. abgerufen und bei neuen Meldungen automatisch ins Meshtastic-Netz gesendet.
- **Duales Polling**: Zwei parallele Abfragestrategien pro Zyklus:
- **Dashboard** (`/dashboard/{AGS}.json`): Regionale Filterung durch den BBK-Server,
deckt alle Quellen für konfigurierte AGS-Codes ab.
- **mapData** (`/{quelle}/mapData.json`): Nationale Abfrage je aktivierter Quelle
(Katwarn, BIWAPP, MoWaS, DWD, LHP, Polizei) mit Schweregrad-Filterung.
Schließt Lücken, die der Dashboard-Endpunkt nicht abdeckt.
- Intelligente quellenübergreifende De-Duplikation via ID-Normalisierung
(z.B. `dwdmap.``dwd.`, `mow.``mowas.`).
- **NINA-Konfigurationsseite** (`/nina`, Admin-only): Separate Webseite zur Verwaltung der - **NINA-Konfigurationsseite** (`/nina`, Admin-only): Separate Webseite zur Verwaltung der
NINA-Einstellungen analog zur Scheduler-Seite. Konfigurierbar: NINA-Einstellungen analog zur Scheduler-Seite. Konfigurierbar:
- Aktivierung / Deaktivierung - Aktivierung / Deaktivierung
@ -14,6 +22,9 @@
- Mindest-Schweregrad (Gering / Mäßig / Schwerwiegend / Extrem) - Mindest-Schweregrad (Gering / Mäßig / Schwerwiegend / Extrem)
- AGS-Codes (Amtliche Gemeindeschlüssel) der zu überwachenden Landkreise/Städte - AGS-Codes (Amtliche Gemeindeschlüssel) der zu überwachenden Landkreise/Städte
- Quellen-Auswahl (Katwarn, BIWAPP, MoWaS, DWD, LHP, Polizei) - Quellen-Auswahl (Katwarn, BIWAPP, MoWaS, DWD, LHP, Polizei)
- **„Ins Mesh senden"**-Schalter: aus = Monitor-Modus (nur Weboberfläche, kein Mesh-Versand)
- **Voreinstellung Raum Dresden**: `nina.yaml` enthält 5 AGS-Codes als Standard:
Stadt Dresden, LK Meißen, LK Sächsische Schweiz-Osterzgebirge, LK Bautzen, LK Görlitz
- **Live-Anzeige** empfangener NINA-Warnmeldungen in der Weboberfläche via WebSocket - **Live-Anzeige** empfangener NINA-Warnmeldungen in der Weboberfläche via WebSocket
(`nina_alert`-Event). (`nina_alert`-Event).
- **NINA-Sidebar-Eintrag** in allen Seiten (Admin-only, Icon: `bi-shield-exclamation`). - **NINA-Sidebar-Eintrag** in allen Seiten (Admin-only, Icon: `bi-shield-exclamation`).

View file

@ -1,9 +1,14 @@
enabled: false enabled: false
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
poll_interval: 300 poll_interval: 300
channel: 0 channel: 0
min_severity: Severe min_severity: Severe
ags_codes: ags_codes:
- "091620000000" # Beispiel: München - "146120000000" # Stadt Dresden
- "146270000000" # Landkreis Meißen
- "146280000000" # LK Sächsische Schweiz-Osterzgebirge
- "146250000000" # Landkreis Bautzen
- "146260000000" # Landkreis Görlitz
sources: sources:
katwarn: true katwarn: true
biwapp: true biwapp: true

View file

@ -26,18 +26,37 @@ SEVERITY_LABELS = {
"Minor": "Gering", "Minor": "Gering",
} }
# Warning ID prefixes for source filtering # Dashboard-Endpunkt: ID-Präfixe je Quelle (für Quellen-Filterung)
SOURCE_PREFIXES = { SOURCE_PREFIXES = {
"katwarn": "katwarn.", "katwarn": ("katwarn.",),
"biwapp": "biwapp.", "biwapp": ("biwapp.",),
"mowas": "mowas.", "mowas": ("mowas.", "mow."),
"dwd": "dwd.", "dwd": ("dwd.",),
"lhp": "lhp.", "lhp": ("lhp.",),
"police": "police.", "police": ("police.",),
} }
# mapData-Endpunkte je Quelle
SOURCE_MAP_ENDPOINTS = {
"katwarn": "katwarn",
"biwapp": "biwapp",
"mowas": "mowas",
"dwd": "dwd",
"lhp": "lhp",
"police": "police",
}
# Normalisierung für quellenübergreifende De-Duplikation (mapData-Präfix → Dashboard-Präfix)
ID_NORMALIZATIONS = [
("dwdmap.", "dwd."),
("lhpmap.", "lhp."),
("polmap.", "police."),
("mow.", "mowas."),
]
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"enabled": False, "enabled": False,
"send_to_mesh": True,
"poll_interval": 300, "poll_interval": 300,
"channel": 0, "channel": 0,
"min_severity": "Severe", "min_severity": "Severe",
@ -54,14 +73,22 @@ DEFAULT_CONFIG = {
class NinaBot: class NinaBot:
"""Polls the NINA BBK warning API and forwards alerts to Meshtastic.""" """Polls the NINA BBK warning API and forwards alerts to Meshtastic.
Two polling strategies run in parallel per cycle:
1. Dashboard regional, per AGS code (all sources, geographic filter by BBK server)
2. mapData national, per source (severity-only filter, covers gaps in dashboard)
Cross-strategy de-duplication is achieved by normalising warning IDs before
storing them in _known (e.g. 'dwdmap.' 'dwd.').
"""
def __init__(self, send_callback: Callable[[str, int], Awaitable[None]], ws_manager=None): def __init__(self, send_callback: Callable[[str, int], Awaitable[None]], ws_manager=None):
self.send_callback = send_callback self.send_callback = send_callback
self.ws_manager = ws_manager self.ws_manager = ws_manager
self.config: dict = {} self.config: dict = {}
self._mtime: float = 0.0 self._mtime: float = 0.0
self._known: dict[str, str] = {} # id -> sent timestamp (de-dup) self._known: dict[str, str] = {} # normalised_id -> sent (de-dup)
self._running = False self._running = False
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._load() self._load()
@ -83,17 +110,11 @@ class NinaBot:
) )
except FileNotFoundError: except FileNotFoundError:
logger.info("No nina.yaml found using defaults") logger.info("No nina.yaml found using defaults")
self.config = { self.config = {**DEFAULT_CONFIG, "sources": dict(DEFAULT_CONFIG["sources"])}
**DEFAULT_CONFIG,
"sources": dict(DEFAULT_CONFIG["sources"]),
}
self._save() self._save()
except Exception: except Exception:
logger.exception("Error loading nina.yaml") logger.exception("Error loading nina.yaml")
self.config = { self.config = {**DEFAULT_CONFIG, "sources": dict(DEFAULT_CONFIG["sources"])}
**DEFAULT_CONFIG,
"sources": dict(DEFAULT_CONFIG["sources"]),
}
def _save(self): def _save(self):
try: try:
@ -167,22 +188,51 @@ class NinaBot:
await asyncio.sleep(interval) await asyncio.sleep(interval)
async def _check_alerts(self): async def _check_alerts(self):
ags_codes = self.config.get("ags_codes", [])
if not ags_codes:
return
min_level = SEVERITY_ORDER.get(self.config.get("min_severity", "Severe"), 2) min_level = SEVERITY_ORDER.get(self.config.get("min_severity", "Severe"), 2)
channel = int(self.config.get("channel", 0)) channel = int(self.config.get("channel", 0))
sources = self.config.get("sources", DEFAULT_CONFIG["sources"]) sources = self.config.get("sources", DEFAULT_CONFIG["sources"])
ags_codes = self.config.get("ags_codes", [])
async with aiohttp.ClientSession( async with aiohttp.ClientSession(
headers={"User-Agent": "MeshDD-Bot/1.0 (+https://github.com/ppfeiffer/MeshDD-Bot)"}, headers={"User-Agent": "MeshDD-Bot/1.0 (+https://github.com/ppfeiffer/MeshDD-Bot)"},
timeout=aiohttp.ClientTimeout(total=30), timeout=aiohttp.ClientTimeout(total=30),
) as session: ) as session:
# 1. Dashboard: regional filtering per AGS code (server-side, all sources)
for ags in ags_codes: for ags in ags_codes:
try: try:
await self._fetch_dashboard(session, str(ags).strip(), min_level, channel, sources) await self._fetch_dashboard(session, str(ags).strip(), min_level, channel, sources)
except Exception: except Exception:
logger.exception("NINA error for AGS %s", ags) logger.exception("NINA dashboard error for AGS %s", ags)
# 2. mapData: national per-source polling (severity + source filter only)
for source_key, endpoint in SOURCE_MAP_ENDPOINTS.items():
if not sources.get(source_key, True):
continue
try:
await self._fetch_map_data(session, source_key, endpoint, min_level, channel)
except Exception:
logger.exception("NINA mapData error for source %s", source_key)
# ── De-duplication helper ────────────────────────────────────────────────
@staticmethod
def _normalise_id(identifier: str) -> str:
"""Normalise a warning ID to a canonical form for cross-source de-duplication."""
for old, new in ID_NORMALIZATIONS:
if identifier.startswith(old):
return new + identifier[len(old):]
return identifier
@staticmethod
def _source_key_for(identifier: str) -> str | None:
"""Return the source config key for a given warning ID, or None if unknown."""
for key, prefixes in SOURCE_PREFIXES.items():
for p in prefixes:
if identifier.startswith(p):
return key
return None
# ── Dashboard endpoint ───────────────────────────────────────────────────
async def _fetch_dashboard( async def _fetch_dashboard(
self, self,
@ -192,40 +242,39 @@ class NinaBot:
channel: int, channel: int,
sources: dict, sources: dict,
): ):
# Pad AGS/ARS to 12 characters
ars = ags.ljust(12, "0") ars = ags.ljust(12, "0")
url = f"{NINA_API_BASE}/dashboard/{ars}.json" url = f"{NINA_API_BASE}/dashboard/{ars}.json"
async with session.get(url) as resp: async with session.get(url) as resp:
if resp.status == 404: if resp.status == 404:
logger.warning("NINA: no data for AGS %s (404)", ags) logger.warning("NINA dashboard: no data for AGS %s (404)", ags)
return return
if resp.status != 200: if resp.status != 200:
logger.warning("NINA API returned status %d for %s", resp.status, url) logger.warning("NINA dashboard: status %d for %s", resp.status, url)
return return
items = await resp.json(content_type=None) items = await resp.json(content_type=None)
if not isinstance(items, list): if not isinstance(items, list):
logger.warning("NINA: unexpected response type for AGS %s", ags) logger.warning("NINA dashboard: unexpected response type for AGS %s", ags)
return return
for item in items: for item in items:
try: try:
await self._process_item(item, min_level, channel, sources) await self._process_dashboard_item(item, min_level, channel, sources)
except Exception: except Exception:
logger.exception("NINA: error processing item %s", item.get("id")) logger.exception("NINA dashboard: error processing %s", item.get("id"))
async def _process_item(self, item: dict, min_level: int, channel: int, sources: dict): async def _process_dashboard_item(
self, item: dict, min_level: int, channel: int, sources: dict
):
identifier = item.get("id", "") identifier = item.get("id", "")
if not identifier: if not identifier:
return return
# Filter by source # Filter by source
for source_key, prefix in SOURCE_PREFIXES.items(): src_key = self._source_key_for(identifier)
if identifier.startswith(prefix): if src_key and not sources.get(src_key, True):
if not sources.get(source_key, True): return
return
break
payload = item.get("payload", {}) payload = item.get("payload", {})
sent = payload.get("sent", item.get("sent", "")) sent = payload.get("sent", item.get("sent", ""))
@ -237,27 +286,101 @@ class NinaBot:
if sev_level < min_level and msg_type != "Cancel": if sev_level < min_level and msg_type != "Cancel":
return return
# De-duplicate: skip if already processed with same sent timestamp dedup_key = self._normalise_id(identifier)
if identifier in self._known and self._known[identifier] == sent: if dedup_key in self._known and self._known[dedup_key] == sent:
return return
self._known[identifier] = sent self._known[dedup_key] = sent
headline = data.get("headline", "Warnung") headline = data.get("headline", "Warnung")
description = data.get("description", "") description = data.get("description", "")
if msg_type == "Cancel": text = self._format_alert(msg_type, severity, headline, description)
text = f"[NINA] Aufgehoben: {headline}" logger.info("NINA dashboard alert: %s (id=%s)", headline, identifier)
else: await self._send(identifier, severity, msg_type, headline, sent, text, channel)
sev_text = SEVERITY_LABELS.get(severity, severity)
text = f"[NINA] {sev_text}: {headline}"
if description:
short_desc = description.strip()[:120]
if len(description.strip()) > 120:
short_desc += "..."
text += f"\n{short_desc}"
logger.info("NINA alert forwarded: %s (id=%s)", headline, identifier) # ── mapData endpoint ─────────────────────────────────────────────────────
await self.send_callback(text, channel)
async def _fetch_map_data(
self,
session: aiohttp.ClientSession,
source_key: str,
endpoint: str,
min_level: int,
channel: int,
):
url = f"{NINA_API_BASE}/{endpoint}/mapData.json"
async with session.get(url) as resp:
if resp.status != 200:
logger.warning("NINA mapData %s: status %d", source_key, resp.status)
return
items = await resp.json(content_type=None)
if not isinstance(items, list):
logger.warning("NINA mapData %s: unexpected response type", source_key)
return
for item in items:
try:
await self._process_map_item(item, min_level, channel)
except Exception:
logger.exception("NINA mapData: error processing %s", item.get("id"))
async def _process_map_item(self, item: dict, min_level: int, channel: int):
identifier = item.get("id", "")
if not identifier:
return
severity = item.get("severity", "Unknown")
msg_type = item.get("type", "Alert")
sent = item.get("startDate", item.get("sent", ""))
sev_level = SEVERITY_ORDER.get(severity, -1)
if sev_level < min_level and msg_type != "Cancel":
return
dedup_key = self._normalise_id(identifier)
if dedup_key in self._known and self._known[dedup_key] == sent:
return
self._known[dedup_key] = sent
# Headline aus i18nTitle (Deutsch bevorzugt)
i18n = item.get("i18nTitle", {})
headline = i18n.get("de") or i18n.get("en") or identifier
text = self._format_alert(msg_type, severity, headline, "")
logger.info("NINA mapData alert: %s (id=%s)", headline, identifier)
await self._send(identifier, severity, msg_type, headline, sent, text, channel)
# ── Shared helpers ───────────────────────────────────────────────────────
@staticmethod
def _format_alert(msg_type: str, severity: str, headline: str, description: str) -> str:
if msg_type == "Cancel":
return f"[NINA] Aufgehoben: {headline}"
sev_text = SEVERITY_LABELS.get(severity, severity)
text = f"[NINA] {sev_text}: {headline}"
if description:
short = description.strip()[:120]
if len(description.strip()) > 120:
short += "..."
text += f"\n{short}"
return text
async def _send(
self,
identifier: str,
severity: str,
msg_type: str,
headline: str,
sent: str,
text: str,
channel: int,
):
if self.config.get("send_to_mesh", True):
await self.send_callback(text, channel)
else:
logger.info("NINA monitor-only: Mesh-Versand deaktiviert, nur WebSocket-Broadcast")
if self.ws_manager: if self.ws_manager:
await self.ws_manager.broadcast("nina_alert", { await self.ws_manager.broadcast("nina_alert", {
@ -266,4 +389,5 @@ class NinaBot:
"msgType": msg_type, "msgType": msg_type,
"headline": headline, "headline": headline,
"sent": sent, "sent": sent,
"monitor_only": not self.config.get("send_to_mesh", True),
}) })

View file

@ -1,8 +1,14 @@
enabled: false enabled: false
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
poll_interval: 300 poll_interval: 300
channel: 0 channel: 0
min_severity: Severe min_severity: Severe
ags_codes: [] ags_codes:
- "146120000000" # Stadt Dresden
- "146270000000" # Landkreis Meißen
- "146280000000" # LK Sächsische Schweiz-Osterzgebirge
- "146250000000" # Landkreis Bautzen
- "146260000000" # Landkreis Görlitz
sources: sources:
katwarn: true katwarn: true
biwapp: true biwapp: true

View file

@ -67,6 +67,7 @@ async function loadConfig() {
function applyConfig(cfg) { function applyConfig(cfg) {
document.getElementById('ninaEnabled').checked = !!cfg.enabled; document.getElementById('ninaEnabled').checked = !!cfg.enabled;
document.getElementById('ninaSendToMesh').checked = cfg.send_to_mesh !== false;
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300; document.getElementById('pollInterval').value = cfg.poll_interval ?? 300;
document.getElementById('ninaChannel').value = cfg.channel ?? 0; document.getElementById('ninaChannel').value = cfg.channel ?? 0;
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe'; document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
@ -87,8 +88,9 @@ function updateStatusBadge(cfg) {
const badge = document.getElementById('statusBadge'); const badge = document.getElementById('statusBadge');
if (cfg.enabled) { if (cfg.enabled) {
const codes = Array.isArray(cfg.ags_codes) ? cfg.ags_codes.length : 0; const codes = Array.isArray(cfg.ags_codes) ? cfg.ags_codes.length : 0;
badge.className = 'badge bg-success'; const mode = cfg.send_to_mesh !== false ? 'Mesh+Web' : 'Nur Web';
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''}`; badge.className = cfg.send_to_mesh !== false ? 'badge bg-success' : 'badge bg-warning text-dark';
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''} · ${mode}`;
} else { } else {
badge.className = 'badge bg-secondary'; badge.className = 'badge bg-secondary';
badge.textContent = 'Deaktiviert'; badge.textContent = 'Deaktiviert';
@ -100,6 +102,7 @@ function updateStatusBadge(cfg) {
document.getElementById('btnSaveNina').addEventListener('click', async () => { document.getElementById('btnSaveNina').addEventListener('click', async () => {
const payload = { const payload = {
enabled: document.getElementById('ninaEnabled').checked, enabled: document.getElementById('ninaEnabled').checked,
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300, poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
channel: parseInt(document.getElementById('ninaChannel').value) || 0, channel: parseInt(document.getElementById('ninaChannel').value) || 0,
min_severity: document.getElementById('minSeverity').value, min_severity: document.getElementById('minSeverity').value,
@ -168,10 +171,14 @@ function renderAlerts() {
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary'); const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary');
const sevLabel = a.msgType === 'Cancel' ? 'Aufgehoben' : (SEV_LABEL[a.severity] ?? a.severity); const sevLabel = a.msgType === 'Cancel' ? 'Aufgehoben' : (SEV_LABEL[a.severity] ?? a.severity);
const ts = a.sent ? new Date(a.sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : ''; const ts = a.sent ? new Date(a.sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
const meshIcon = a.monitor_only
? '<i class="bi bi-eye text-warning" title="Nur Weboberfläche"></i>'
: '<i class="bi bi-broadcast text-success" title="Ins Mesh gesendet"></i>';
return `<tr> return `<tr>
<td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></td> <td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></td>
<td>${escapeHtml(a.headline)}</td> <td>${escapeHtml(a.headline)}</td>
<td><small class="text-body-secondary">${escapeHtml(a.id?.split('.')[0] ?? '')}</small></td> <td><small class="text-body-secondary">${escapeHtml(a.id?.split('.')[0] ?? '')}</small></td>
<td class="text-center">${meshIcon}</td>
<td><small class="text-body-secondary">${ts}</small></td> <td><small class="text-body-secondary">${ts}</small></td>
</tr>`; </tr>`;
}).join(''); }).join('');

View file

@ -55,12 +55,19 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="ninaForm"> <form id="ninaForm">
<!-- Enable toggle --> <!-- Enable + send toggles -->
<div class="mb-3 d-flex align-items-center gap-3"> <div class="mb-3">
<div class="form-check form-switch mb-0"> <div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch"> <input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch">
<label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</label> <label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</label>
</div> </div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="ninaSendToMesh" role="switch">
<label class="form-check-label" for="ninaSendToMesh">
Ins Mesh senden
<small class="text-body-secondary ms-1">(aus = nur Weboberfläche)</small>
</label>
</div>
</div> </div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
@ -165,6 +172,7 @@
<th style="width:90px">Schweregrad</th> <th style="width:90px">Schweregrad</th>
<th>Meldung</th> <th>Meldung</th>
<th style="width:110px">Typ</th> <th style="width:110px">Typ</th>
<th style="width:60px" class="text-center">Mesh</th>
<th style="width:130px">Zeitstempel</th> <th style="width:130px">Zeitstempel</th>
</tr> </tr>
</thead> </thead>