diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a890e3..08b1fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`). diff --git a/conf/nina.yaml b/conf/nina.yaml index e6259e8..c66f842 100644 --- a/conf/nina.yaml +++ b/conf/nina.yaml @@ -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 diff --git a/meshbot/nina.py b/meshbot/nina.py index ca71aeb..e872dfa 100644 --- a/meshbot/nina.py +++ b/meshbot/nina.py @@ -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): - return - break + src_key = self._source_key_for(identifier) + if src_key and not sources.get(src_key, True): + return 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", "") - if msg_type == "Cancel": - text = f"[NINA] Aufgehoben: {headline}" - else: - 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}" + 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) - logger.info("NINA alert forwarded: %s (id=%s)", headline, identifier) - await self.send_callback(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": + 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: 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), }) diff --git a/nina.yaml b/nina.yaml index bb676ec..c66f842 100644 --- a/nina.yaml +++ b/nina.yaml @@ -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 diff --git a/static/js/nina.js b/static/js/nina.js index b07f3a7..3d28b65 100644 --- a/static/js/nina.js +++ b/static/js/nina.js @@ -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 + ? '' + : ''; return `