diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f25ca9..5a890e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [0.8.0] - 2026-02-19 + +### Added +- **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. +- **NINA-Konfigurationsseite** (`/nina`, Admin-only): Separate Webseite zur Verwaltung der + NINA-Einstellungen – analog zur Scheduler-Seite. Konfigurierbar: + - Aktivierung / Deaktivierung + - Abfrageintervall (Sekunden, min. 60) + - Meshtastic-Kanal für Warnmeldungen + - 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) +- **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`). +- **`nina.yaml`** als Hot-reload-fähige Konfigurationsdatei (analog zu `scheduler.yaml`). + ## [0.7.1] - 2026-02-18 ### Changed diff --git a/conf/nina.yaml b/conf/nina.yaml new file mode 100644 index 0000000..e6259e8 --- /dev/null +++ b/conf/nina.yaml @@ -0,0 +1,13 @@ +enabled: false +poll_interval: 300 +channel: 0 +min_severity: Severe +ags_codes: + - "091620000000" # Beispiel: München +sources: + katwarn: true + biwapp: true + mowas: true + dwd: true + lhp: true + police: false diff --git a/config.yaml b/config.yaml index 8b3af68..6295f5f 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -version: "0.7.1" +version: "0.8.0" bot: name: "MeshDD-Bot" diff --git a/main.py b/main.py index d9ddb46..8779c0d 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ import threading from meshbot import config from meshbot.database import Database from meshbot.bot import MeshBot +from meshbot.nina import NinaBot from meshbot.scheduler import Scheduler from meshbot.webserver import WebServer, WebSocketManager @@ -34,8 +35,11 @@ async def main(): # Scheduler scheduler = Scheduler(bot, ws_manager) + # NINA + nina = NinaBot(bot.send_message, ws_manager) + # Webserver - webserver = WebServer(db, ws_manager, bot, scheduler) + webserver = WebServer(db, ws_manager, bot, scheduler, nina) runner = await webserver.start(config.get("web.host", "0.0.0.0"), config.get("web.port", 8080)) # Connect Meshtastic in a thread (blocking call) @@ -49,6 +53,10 @@ async def main(): asyncio.create_task(scheduler.watch()) asyncio.create_task(scheduler.run()) + # NINA tasks + asyncio.create_task(nina.watch()) + await nina.start() + # Wait for shutdown stop_event = asyncio.Event() @@ -63,6 +71,7 @@ async def main(): await stop_event.wait() finally: logger.info("Shutting down...") + await nina.stop() bot.disconnect() await ws_manager.close_all() await runner.cleanup() diff --git a/meshbot/nina.py b/meshbot/nina.py new file mode 100644 index 0000000..ca71aeb --- /dev/null +++ b/meshbot/nina.py @@ -0,0 +1,269 @@ +import asyncio +import logging +import os +from typing import Callable, Awaitable + +import aiohttp +import yaml + +logger = logging.getLogger(__name__) + +NINA_API_BASE = "https://warnung.bund.de/api31" +NINA_CONFIG_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "nina.yaml") + +SEVERITY_ORDER = { + "Unknown": -1, + "Minor": 0, + "Moderate": 1, + "Severe": 2, + "Extreme": 3, +} + +SEVERITY_LABELS = { + "Extreme": "EXTREM", + "Severe": "Schwerwiegend", + "Moderate": "Maessig", + "Minor": "Gering", +} + +# Warning ID prefixes for source filtering +SOURCE_PREFIXES = { + "katwarn": "katwarn.", + "biwapp": "biwapp.", + "mowas": "mowas.", + "dwd": "dwd.", + "lhp": "lhp.", + "police": "police.", +} + +DEFAULT_CONFIG = { + "enabled": False, + "poll_interval": 300, + "channel": 0, + "min_severity": "Severe", + "ags_codes": [], + "sources": { + "katwarn": True, + "biwapp": True, + "mowas": True, + "dwd": True, + "lhp": True, + "police": False, + }, +} + + +class NinaBot: + """Polls the NINA BBK warning API and forwards alerts to Meshtastic.""" + + 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._running = False + self._task: asyncio.Task | None = None + self._load() + + # ── Config ────────────────────────────────────────────────────────────── + + def _load(self): + try: + with open(NINA_CONFIG_PATH) as f: + data = yaml.safe_load(f) or {} + self.config = {**DEFAULT_CONFIG, **data} + if "sources" in data: + self.config["sources"] = {**DEFAULT_CONFIG["sources"], **data["sources"]} + self._mtime = os.path.getmtime(NINA_CONFIG_PATH) + logger.info( + "NINA config loaded (enabled=%s, codes=%s)", + self.config.get("enabled"), + self.config.get("ags_codes"), + ) + except FileNotFoundError: + logger.info("No nina.yaml found – using defaults") + 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"]), + } + + def _save(self): + try: + with open(NINA_CONFIG_PATH, "w") as f: + yaml.dump( + self.config, + f, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + self._mtime = os.path.getmtime(NINA_CONFIG_PATH) + logger.info("NINA config saved") + except Exception: + logger.exception("Error saving nina.yaml") + + def get_config(self) -> dict: + return self.config + + def update_config(self, updates: dict) -> dict: + if "sources" in updates: + self.config["sources"] = { + **self.config.get("sources", {}), + **updates.pop("sources"), + } + self.config.update(updates) + self._save() + return self.config + + # ── Lifecycle ──────────────────────────────────────────────────────────── + + async def start(self): + self._running = True + self._task = asyncio.create_task(self._poll_loop()) + logger.info("NinaBot started") + + async def stop(self): + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + # ── Hot-reload ─────────────────────────────────────────────────────────── + + async def watch(self, interval: float = 5.0): + """Reload nina.yaml when the file changes on disk.""" + while True: + await asyncio.sleep(interval) + try: + current_mtime = os.path.getmtime(NINA_CONFIG_PATH) + if current_mtime != self._mtime: + self._load() + except FileNotFoundError: + pass + except Exception: + logger.exception("Error watching nina.yaml") + + # ── Polling ────────────────────────────────────────────────────────────── + + async def _poll_loop(self): + while self._running: + try: + if self.config.get("enabled"): + await self._check_alerts() + except Exception: + logger.exception("NINA polling error") + interval = max(60, int(self.config.get("poll_interval", 300))) + 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"]) + + async with aiohttp.ClientSession( + headers={"User-Agent": "MeshDD-Bot/1.0 (+https://github.com/ppfeiffer/MeshDD-Bot)"}, + timeout=aiohttp.ClientTimeout(total=30), + ) as session: + 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) + + async def _fetch_dashboard( + self, + session: aiohttp.ClientSession, + ags: str, + min_level: int, + 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) + return + if resp.status != 200: + logger.warning("NINA API returned 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) + return + + for item in items: + try: + await self._process_item(item, min_level, channel, sources) + except Exception: + logger.exception("NINA: error processing item %s", item.get("id")) + + async def _process_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 + + payload = item.get("payload", {}) + sent = payload.get("sent", item.get("sent", "")) + data = payload.get("data", {}) + msg_type = payload.get("msgType", data.get("msgType", "Alert")) + severity = data.get("severity", "Unknown") + + sev_level = SEVERITY_ORDER.get(severity, -1) + 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: + return + self._known[identifier] = 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}" + + logger.info("NINA alert forwarded: %s (id=%s)", headline, identifier) + await self.send_callback(text, channel) + + if self.ws_manager: + await self.ws_manager.broadcast("nina_alert", { + "id": identifier, + "severity": severity, + "msgType": msg_type, + "headline": headline, + "sent": sent, + }) diff --git a/meshbot/webserver.py b/meshbot/webserver.py index 02080a8..454740a 100644 --- a/meshbot/webserver.py +++ b/meshbot/webserver.py @@ -53,11 +53,12 @@ class WebSocketManager: class WebServer: - def __init__(self, db: Database, ws_manager: WebSocketManager, bot=None, scheduler=None): + def __init__(self, db: Database, ws_manager: WebSocketManager, bot=None, scheduler=None, nina=None): self.db = db self.ws_manager = ws_manager self.bot = bot self.scheduler = scheduler + self.nina = nina self.app = web.Application() setup_session(self.app) self.app.middlewares.append(auth_middleware) @@ -76,11 +77,14 @@ class WebServer: self.app.router.add_delete("/api/scheduler/jobs/{name}", self._api_scheduler_delete) self.app.router.add_post("/api/send", self._api_send) self.app.router.add_get("/api/node/config", self._api_node_config) + self.app.router.add_get("/api/nina/config", self._api_nina_get) + self.app.router.add_put("/api/nina/config", self._api_nina_update) self.app.router.add_get("/login", self._serve_login) self.app.router.add_get("/register", self._serve_login) self.app.router.add_get("/admin", self._serve_admin) self.app.router.add_get("/settings", self._serve_settings) self.app.router.add_get("/scheduler", self._serve_scheduler) + self.app.router.add_get("/nina", self._serve_nina) self.app.router.add_get("/map", self._serve_map) self.app.router.add_get("/packets", self._serve_packets) self.app.router.add_get("/", self._serve_index) @@ -200,6 +204,23 @@ class WebServer: async def _serve_scheduler(self, request: web.Request) -> web.Response: return web.FileResponse(os.path.join(STATIC_DIR, "scheduler.html")) + async def _serve_nina(self, request: web.Request) -> web.Response: + return web.FileResponse(os.path.join(STATIC_DIR, "nina.html")) + + async def _api_nina_get(self, request: web.Request) -> web.Response: + require_admin_api(request) + if not self.nina: + return web.json_response({"error": "NINA not available"}, status=503) + return web.json_response(self.nina.get_config()) + + async def _api_nina_update(self, request: web.Request) -> web.Response: + require_admin_api(request) + if not self.nina: + return web.json_response({"error": "NINA not available"}, status=503) + updates = await request.json() + cfg = self.nina.update_config(updates) + return web.json_response(cfg) + async def _api_scheduler_get(self, request: web.Request) -> web.Response: if not self.scheduler: return web.json_response([], status=200) diff --git a/nina.yaml b/nina.yaml new file mode 100644 index 0000000..bb676ec --- /dev/null +++ b/nina.yaml @@ -0,0 +1,12 @@ +enabled: false +poll_interval: 300 +channel: 0 +min_severity: Severe +ags_codes: [] +sources: + katwarn: true + biwapp: true + mowas: true + dwd: true + lhp: true + police: false diff --git a/static/js/app.js b/static/js/app.js index ec0c11a..f82bf9f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -4,12 +4,13 @@ // ── Sidebar definition ──────────────────────────────────────── const _SIDEBAR_LINKS = [ - { href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false }, - { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true }, - { href: '/map', icon: 'bi-map', label: 'Karte', admin: false }, - { href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false }, - { href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true }, - { href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true }, + { href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false }, + { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true }, + { href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', admin: true }, + { href: '/map', icon: 'bi-map', label: 'Karte', admin: false }, + { href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false }, + { href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true }, + { href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true }, ]; function _injectSidebar() { diff --git a/static/js/nina.js b/static/js/nina.js new file mode 100644 index 0000000..b07f3a7 --- /dev/null +++ b/static/js/nina.js @@ -0,0 +1,206 @@ +let currentUser = null; +let agsCodes = []; +const MAX_ALERTS = 50; +const alerts = []; + +initPage({ onAuth: (user) => { currentUser = user; } }); + +// ── AGS code list ──────────────────────────────────────────────────────────── + +function renderAgsList() { + const container = document.getElementById('agsList'); + if (agsCodes.length === 0) { + container.innerHTML = 'Keine AGS-Codes konfiguriert.'; + return; + } + container.innerHTML = agsCodes.map((code, idx) => + ` + ${escapeHtml(code)} + + ` + ).join(''); +} + +function removeAgs(idx) { + agsCodes.splice(idx, 1); + renderAgsList(); +} + +document.getElementById('btnAddAgs').addEventListener('click', () => { + const input = document.getElementById('agsInput'); + const code = input.value.trim().replace(/\D/g, ''); + if (!code) return; + if (code.length < 5 || code.length > 12) { + input.setCustomValidity('AGS-Code muss 5–12 Stellen haben.'); + input.reportValidity(); + return; + } + input.setCustomValidity(''); + if (!agsCodes.includes(code)) { + agsCodes.push(code); + renderAgsList(); + } + input.value = ''; +}); + +document.getElementById('agsInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + document.getElementById('btnAddAgs').click(); + } +}); + +// ── Load config ────────────────────────────────────────────────────────────── + +async function loadConfig() { + try { + const resp = await fetch('/api/nina/config'); + if (!resp.ok) return; + const cfg = await resp.json(); + applyConfig(cfg); + updateStatusBadge(cfg); + } catch (e) { + console.error('NINA config load failed:', e); + } +} + +function applyConfig(cfg) { + document.getElementById('ninaEnabled').checked = !!cfg.enabled; + document.getElementById('pollInterval').value = cfg.poll_interval ?? 300; + document.getElementById('ninaChannel').value = cfg.channel ?? 0; + document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe'; + + agsCodes = Array.isArray(cfg.ags_codes) ? [...cfg.ags_codes] : []; + renderAgsList(); + + const src = cfg.sources ?? {}; + document.getElementById('srcKatwarn').checked = src.katwarn !== false; + document.getElementById('srcBiwapp').checked = src.biwapp !== false; + document.getElementById('srcMowas').checked = src.mowas !== false; + document.getElementById('srcDwd').checked = src.dwd !== false; + document.getElementById('srcLhp').checked = src.lhp !== false; + document.getElementById('srcPolice').checked = !!src.police; +} + +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' : ''}`; + } else { + badge.className = 'badge bg-secondary'; + badge.textContent = 'Deaktiviert'; + } +} + +// ── Save config ────────────────────────────────────────────────────────────── + +document.getElementById('btnSaveNina').addEventListener('click', async () => { + const payload = { + enabled: document.getElementById('ninaEnabled').checked, + poll_interval: parseInt(document.getElementById('pollInterval').value) || 300, + channel: parseInt(document.getElementById('ninaChannel').value) || 0, + min_severity: document.getElementById('minSeverity').value, + ags_codes: [...agsCodes], + sources: { + katwarn: document.getElementById('srcKatwarn').checked, + biwapp: document.getElementById('srcBiwapp').checked, + mowas: document.getElementById('srcMowas').checked, + dwd: document.getElementById('srcDwd').checked, + lhp: document.getElementById('srcLhp').checked, + police: document.getElementById('srcPolice').checked, + }, + }; + + const statusEl = document.getElementById('saveStatus'); + try { + const resp = await fetch('/api/nina/config', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (resp.ok) { + const cfg = await resp.json(); + updateStatusBadge(cfg); + statusEl.textContent = 'Gespeichert ✓'; + statusEl.className = 'align-self-center small text-success'; + statusEl.classList.remove('d-none'); + setTimeout(() => statusEl.classList.add('d-none'), 3000); + } else { + statusEl.textContent = 'Fehler beim Speichern'; + statusEl.className = 'align-self-center small text-danger'; + statusEl.classList.remove('d-none'); + } + } catch (e) { + console.error('Save failed:', e); + statusEl.textContent = 'Netzwerkfehler'; + statusEl.className = 'align-self-center small text-danger'; + statusEl.classList.remove('d-none'); + } +}); + +// ── Alerts table ───────────────────────────────────────────────────────────── + +const SEV_CLASS = { + Extreme: 'danger', + Severe: 'warning', + Moderate: 'info', + Minor: 'secondary', +}; + +const SEV_LABEL = { + Extreme: 'EXTREM', + Severe: 'Schwerwiegend', + Moderate: 'Mäßig', + Minor: 'Gering', + Unknown: 'Unbekannt', +}; + +function renderAlerts() { + const tbody = document.getElementById('alertsTable'); + if (alerts.length === 0) { + tbody.innerHTML = 'Keine Meldungen empfangen.'; + return; + } + tbody.innerHTML = alerts.map(a => { + 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' }) : '–'; + return ` + ${escapeHtml(sevLabel)} + ${escapeHtml(a.headline)} + ${escapeHtml(a.id?.split('.')[0] ?? '–')} + ${ts} + `; + }).join(''); +} + +function addAlert(alert) { + alerts.unshift(alert); + if (alerts.length > MAX_ALERTS) alerts.pop(); + renderAlerts(); +} + +// ── WebSocket ───────────────────────────────────────────────────────────────── + +function connectWebSocket() { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const ws = new WebSocket(`${proto}//${location.host}/ws`); + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data); + if (msg.type === 'nina_alert') { + addAlert(msg.data); + } + }; + + ws.onclose = () => { setTimeout(connectWebSocket, 3000); }; + ws.onerror = () => { ws.close(); }; +} + +// ── Init ────────────────────────────────────────────────────────────────────── + +loadConfig(); +connectWebSocket(); diff --git a/static/nina.html b/static/nina.html new file mode 100644 index 0000000..09dde05 --- /dev/null +++ b/static/nina.html @@ -0,0 +1,187 @@ + + + + + + MeshDD-Bot NINA + + + + + + + + + + + + +
+
+
NINA Warnmeldungen
+
+ Lade... +
+
+ +
+ +
+
+
+
Einstellungen
+
+
+
+ +
+
+ + +
+
+ +
+
+ + +
Min. 60 Sekunden
+
+
+ + +
+
+ +
+ + +
+ + +
+ +
+
+ + +
+
+ 8- oder 12-stelliger AGS-Code des Landkreises/der kreisfreien Stadt. + AGS-Verzeichnis +
+
+ + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+
+
+ + +
+
+
+
Letzte Warnmeldungen
+ Live via WebSocket +
+
+ + + + + + + + + + + + +
SchweregradMeldungTypZeitstempel
Keine Meldungen – NINA aktivieren und AGS-Codes konfigurieren.
+
+
+
+
+
+ + + + + + + +