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
und Katastrophenhilfe). Warnmeldungen werden per HTTP-Polling von `warnung.bund.de/api31`
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-Einstellungen analog zur Scheduler-Seite. Konfigurierbar:
- Aktivierung / Deaktivierung
@ -14,6 +22,9 @@
- Mindest-Schweregrad (Gering / Mäßig / Schwerwiegend / Extrem)
- AGS-Codes (Amtliche Gemeindeschlüssel) der zu überwachenden Landkreise/Städte
- 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
(`nina_alert`-Event).
- **NINA-Sidebar-Eintrag** in allen Seiten (Admin-only, Icon: `bi-shield-exclamation`).

View file

@ -1,9 +1,14 @@
enabled: false
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
poll_interval: 300
channel: 0
min_severity: Severe
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:
katwarn: true
biwapp: true

View file

@ -26,18 +26,37 @@ SEVERITY_LABELS = {
"Minor": "Gering",
}
# Warning ID prefixes for source filtering
# Dashboard-Endpunkt: ID-Präfixe je Quelle (für Quellen-Filterung)
SOURCE_PREFIXES = {
"katwarn": "katwarn.",
"biwapp": "biwapp.",
"mowas": "mowas.",
"dwd": "dwd.",
"lhp": "lhp.",
"police": "police.",
"katwarn": ("katwarn.",),
"biwapp": ("biwapp.",),
"mowas": ("mowas.", "mow."),
"dwd": ("dwd.",),
"lhp": ("lhp.",),
"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 = {
"enabled": False,
"send_to_mesh": True,
"poll_interval": 300,
"channel": 0,
"min_severity": "Severe",
@ -54,14 +73,22 @@ DEFAULT_CONFIG = {
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):
self.send_callback = send_callback
self.ws_manager = ws_manager
self.config: dict = {}
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._task: asyncio.Task | None = None
self._load()
@ -83,17 +110,11 @@ class NinaBot:
)
except FileNotFoundError:
logger.info("No nina.yaml found using defaults")
self.config = {
**DEFAULT_CONFIG,
"sources": dict(DEFAULT_CONFIG["sources"]),
}
self.config = {**DEFAULT_CONFIG, "sources": dict(DEFAULT_CONFIG["sources"])}
self._save()
except Exception:
logger.exception("Error loading nina.yaml")
self.config = {
**DEFAULT_CONFIG,
"sources": dict(DEFAULT_CONFIG["sources"]),
}
self.config = {**DEFAULT_CONFIG, "sources": dict(DEFAULT_CONFIG["sources"])}
def _save(self):
try:
@ -167,22 +188,51 @@ class NinaBot:
await asyncio.sleep(interval)
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)
channel = int(self.config.get("channel", 0))
sources = self.config.get("sources", DEFAULT_CONFIG["sources"])
ags_codes = self.config.get("ags_codes", [])
async with aiohttp.ClientSession(
headers={"User-Agent": "MeshDD-Bot/1.0 (+https://github.com/ppfeiffer/MeshDD-Bot)"},
timeout=aiohttp.ClientTimeout(total=30),
) as session:
# 1. Dashboard: regional filtering per AGS code (server-side, all sources)
for ags in ags_codes:
try:
await self._fetch_dashboard(session, str(ags).strip(), min_level, channel, sources)
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(
self,
@ -192,40 +242,39 @@ class NinaBot:
channel: int,
sources: dict,
):
# Pad AGS/ARS to 12 characters
ars = ags.ljust(12, "0")
url = f"{NINA_API_BASE}/dashboard/{ars}.json"
async with session.get(url) as resp:
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
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
items = await resp.json(content_type=None)
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
for item in items:
try:
await self._process_item(item, min_level, channel, sources)
await self._process_dashboard_item(item, min_level, channel, sources)
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", "")
if not identifier:
return
# Filter by source
for source_key, prefix in SOURCE_PREFIXES.items():
if identifier.startswith(prefix):
if not sources.get(source_key, True):
src_key = self._source_key_for(identifier)
if src_key and not sources.get(src_key, True):
return
break
payload = item.get("payload", {})
sent = payload.get("sent", item.get("sent", ""))
@ -237,27 +286,101 @@ class NinaBot:
if sev_level < min_level and msg_type != "Cancel":
return
# De-duplicate: skip if already processed with same sent timestamp
if identifier in self._known and self._known[identifier] == sent:
dedup_key = self._normalise_id(identifier)
if dedup_key in self._known and self._known[dedup_key] == sent:
return
self._known[identifier] = sent
self._known[dedup_key] = sent
headline = data.get("headline", "Warnung")
description = data.get("description", "")
text = self._format_alert(msg_type, severity, headline, description)
logger.info("NINA dashboard alert: %s (id=%s)", headline, identifier)
await self._send(identifier, severity, msg_type, headline, sent, text, channel)
# ── mapData endpoint ─────────────────────────────────────────────────────
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":
text = f"[NINA] Aufgehoben: {headline}"
else:
return f"[NINA] Aufgehoben: {headline}"
sev_text = SEVERITY_LABELS.get(severity, severity)
text = f"[NINA] {sev_text}: {headline}"
if description:
short_desc = description.strip()[:120]
short = description.strip()[:120]
if len(description.strip()) > 120:
short_desc += "..."
text += f"\n{short_desc}"
short += "..."
text += f"\n{short}"
return text
logger.info("NINA alert forwarded: %s (id=%s)", headline, identifier)
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:
await self.ws_manager.broadcast("nina_alert", {
@ -266,4 +389,5 @@ class NinaBot:
"msgType": msg_type,
"headline": headline,
"sent": sent,
"monitor_only": not self.config.get("send_to_mesh", True),
})

View file

@ -1,8 +1,14 @@
enabled: false
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
poll_interval: 300
channel: 0
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:
katwarn: true
biwapp: true

View file

@ -67,6 +67,7 @@ async function loadConfig() {
function applyConfig(cfg) {
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('ninaChannel').value = cfg.channel ?? 0;
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
@ -87,8 +88,9 @@ function updateStatusBadge(cfg) {
const badge = document.getElementById('statusBadge');
if (cfg.enabled) {
const codes = Array.isArray(cfg.ags_codes) ? cfg.ags_codes.length : 0;
badge.className = 'badge bg-success';
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''}`;
const mode = cfg.send_to_mesh !== false ? 'Mesh+Web' : 'Nur Web';
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 {
badge.className = 'badge bg-secondary';
badge.textContent = 'Deaktiviert';
@ -100,6 +102,7 @@ function updateStatusBadge(cfg) {
document.getElementById('btnSaveNina').addEventListener('click', async () => {
const payload = {
enabled: document.getElementById('ninaEnabled').checked,
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
min_severity: document.getElementById('minSeverity').value,
@ -168,10 +171,14 @@ function renderAlerts() {
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary');
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 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>
<td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></td>
<td>${escapeHtml(a.headline)}</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>
</tr>`;
}).join('');

View file

@ -55,12 +55,19 @@
</div>
<div class="card-body">
<form id="ninaForm">
<!-- Enable toggle -->
<div class="mb-3 d-flex align-items-center gap-3">
<div class="form-check form-switch mb-0">
<!-- Enable + send toggles -->
<div class="mb-3">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch">
<label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</label>
</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 class="row g-3 mb-3">
@ -165,6 +172,7 @@
<th style="width:90px">Schweregrad</th>
<th>Meldung</th>
<th style="width:110px">Typ</th>
<th style="width:60px" class="text-center">Mesh</th>
<th style="width:130px">Zeitstempel</th>
</tr>
</thead>