Merge pull request 'feat: NINA BBK Warn-App Integration (v0.8.0)' (#4) from nina_test into main

This commit is contained in:
ppfeiffer 2026-02-19 11:57:07 +01:00
commit 4c465ed170
10 changed files with 907 additions and 9 deletions

View file

@ -1,5 +1,35 @@
# Changelog # 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.
- **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
- 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)
- **„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`).
- **`nina.yaml`** als Hot-reload-fähige Konfigurationsdatei (analog zu `scheduler.yaml`).
## [0.7.1] - 2026-02-18 ## [0.7.1] - 2026-02-18
### Changed ### Changed

18
conf/nina.yaml Normal file
View file

@ -0,0 +1,18 @@
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:
- "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
mowas: true
dwd: true
lhp: true
police: false

View file

@ -1,4 +1,4 @@
version: "0.7.1" version: "0.8.0"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

11
main.py
View file

@ -6,6 +6,7 @@ import threading
from meshbot import config from meshbot import config
from meshbot.database import Database from meshbot.database import Database
from meshbot.bot import MeshBot from meshbot.bot import MeshBot
from meshbot.nina import NinaBot
from meshbot.scheduler import Scheduler from meshbot.scheduler import Scheduler
from meshbot.webserver import WebServer, WebSocketManager from meshbot.webserver import WebServer, WebSocketManager
@ -34,8 +35,11 @@ async def main():
# Scheduler # Scheduler
scheduler = Scheduler(bot, ws_manager) scheduler = Scheduler(bot, ws_manager)
# NINA
nina = NinaBot(bot.send_message, ws_manager)
# Webserver # 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)) runner = await webserver.start(config.get("web.host", "0.0.0.0"), config.get("web.port", 8080))
# Connect Meshtastic in a thread (blocking call) # Connect Meshtastic in a thread (blocking call)
@ -49,6 +53,10 @@ async def main():
asyncio.create_task(scheduler.watch()) asyncio.create_task(scheduler.watch())
asyncio.create_task(scheduler.run()) asyncio.create_task(scheduler.run())
# NINA tasks
asyncio.create_task(nina.watch())
await nina.start()
# Wait for shutdown # Wait for shutdown
stop_event = asyncio.Event() stop_event = asyncio.Event()
@ -63,6 +71,7 @@ async def main():
await stop_event.wait() await stop_event.wait()
finally: finally:
logger.info("Shutting down...") logger.info("Shutting down...")
await nina.stop()
bot.disconnect() bot.disconnect()
await ws_manager.close_all() await ws_manager.close_all()
await runner.cleanup() await runner.cleanup()

393
meshbot/nina.py Normal file
View file

@ -0,0 +1,393 @@
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",
}
# Dashboard-Endpunkt: ID-Präfixe je Quelle (für Quellen-Filterung)
SOURCE_PREFIXES = {
"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",
"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.
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] = {} # normalised_id -> sent (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):
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 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,
session: aiohttp.ClientSession,
ags: str,
min_level: int,
channel: int,
sources: dict,
):
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 dashboard: no data for AGS %s (404)", ags)
return
if resp.status != 200:
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 dashboard: unexpected response type for AGS %s", ags)
return
for item in items:
try:
await self._process_dashboard_item(item, min_level, channel, sources)
except Exception:
logger.exception("NINA dashboard: error processing %s", item.get("id"))
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
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", ""))
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
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 = 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":
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", {
"id": identifier,
"severity": severity,
"msgType": msg_type,
"headline": headline,
"sent": sent,
"monitor_only": not self.config.get("send_to_mesh", True),
})

View file

@ -53,11 +53,12 @@ class WebSocketManager:
class WebServer: 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.db = db
self.ws_manager = ws_manager self.ws_manager = ws_manager
self.bot = bot self.bot = bot
self.scheduler = scheduler self.scheduler = scheduler
self.nina = nina
self.app = web.Application() self.app = web.Application()
setup_session(self.app) setup_session(self.app)
self.app.middlewares.append(auth_middleware) 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_delete("/api/scheduler/jobs/{name}", self._api_scheduler_delete)
self.app.router.add_post("/api/send", self._api_send) 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/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("/login", self._serve_login)
self.app.router.add_get("/register", 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("/admin", self._serve_admin)
self.app.router.add_get("/settings", self._serve_settings) self.app.router.add_get("/settings", self._serve_settings)
self.app.router.add_get("/scheduler", self._serve_scheduler) 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("/map", self._serve_map)
self.app.router.add_get("/packets", self._serve_packets) self.app.router.add_get("/packets", self._serve_packets)
self.app.router.add_get("/", self._serve_index) self.app.router.add_get("/", self._serve_index)
@ -200,6 +204,23 @@ class WebServer:
async def _serve_scheduler(self, request: web.Request) -> web.Response: async def _serve_scheduler(self, request: web.Request) -> web.Response:
return web.FileResponse(os.path.join(STATIC_DIR, "scheduler.html")) 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: async def _api_scheduler_get(self, request: web.Request) -> web.Response:
if not self.scheduler: if not self.scheduler:
return web.json_response([], status=200) return web.json_response([], status=200)

18
nina.yaml Normal file
View file

@ -0,0 +1,18 @@
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:
- "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
mowas: true
dwd: true
lhp: true
police: false

View file

@ -4,12 +4,13 @@
// ── Sidebar definition ──────────────────────────────────────── // ── Sidebar definition ────────────────────────────────────────
const _SIDEBAR_LINKS = [ const _SIDEBAR_LINKS = [
{ href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false }, { href: '/', icon: 'bi-speedometer2', label: 'Dashboard', admin: false },
{ href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true }, { href: '/scheduler', icon: 'bi-clock-history', label: 'Scheduler', admin: true },
{ href: '/map', icon: 'bi-map', label: 'Karte', admin: false }, { href: '/nina', icon: 'bi-shield-exclamation', label: 'NINA', admin: true },
{ href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false }, { href: '/map', icon: 'bi-map', label: 'Karte', admin: false },
{ href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true }, { href: '/packets', icon: 'bi-reception-4', label: 'Pakete', admin: false },
{ href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true }, { href: '/settings', icon: 'bi-gear', label: 'Einstellungen',admin: true },
{ href: '/admin', icon: 'bi-people', label: 'Benutzer', admin: true },
]; ];
function _injectSidebar() { function _injectSidebar() {

213
static/js/nina.js Normal file
View file

@ -0,0 +1,213 @@
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 = '<small class="text-body-secondary">Keine AGS-Codes konfiguriert.</small>';
return;
}
container.innerHTML = agsCodes.map((code, idx) =>
`<span class="badge bg-secondary me-1 mb-1">
${escapeHtml(code)}
<button type="button" class="btn-close btn-close-white ms-1" style="font-size:.55rem;vertical-align:middle"
onclick="removeAgs(${idx})" aria-label="Entfernen"></button>
</span>`
).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 512 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('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';
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;
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';
}
}
// ── Save config ──────────────────────────────────────────────────────────────
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,
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 = '<tr><td colspan="4" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>';
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' }) : '';
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('');
}
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();

195
static/nina.html Normal file
View file

@ -0,0 +1,195 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MeshDD-Bot NINA</title>
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@1.4.0/dist/css/tabler.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body class="antialiased">
<!-- Top Navbar -->
<nav class="top-navbar d-flex align-items-center px-3">
<button class="btn btn-link text-body p-0 me-2 d-lg-none" id="sidebarToggle">
<i class="bi bi-list fs-5"></i>
</button>
<span class="fw-bold me-auto">
<i class="bi bi-broadcast-pin text-info me-1"></i>MeshDD-Bot
<small class="text-body-secondary fw-normal" id="versionLabel"></small>
</span>
<div class="d-flex align-items-center gap-2">
<span id="userMenu" class="d-none">
<small class="text-body-secondary me-1"><i class="bi bi-person-fill me-1"></i><span id="userName"></span></small>
<a href="/auth/logout" class="btn btn-sm btn-outline-secondary py-0 px-1" title="Abmelden">
<i class="bi bi-box-arrow-right" style="font-size:.75rem"></i>
</a>
</span>
<a href="/login" id="loginBtn" class="btn btn-sm btn-outline-info py-0 px-1 d-none">
<i class="bi bi-person" style="font-size:.75rem"></i> Login
</a>
<button class="btn btn-sm btn-outline-secondary py-0 px-1" id="themeToggle" title="Theme wechseln">
<i class="bi bi-sun-fill" id="themeIcon" style="font-size:.75rem"></i>
</button>
</div>
</nav>
<aside class="sidebar" id="sidebar"></aside>
<div class="sidebar-backdrop" id="sidebarBackdrop"></div>
<!-- Content -->
<main class="content-wrapper">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><i class="bi bi-shield-exclamation me-1 text-warning"></i>NINA Warnmeldungen</h6>
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-secondary" id="statusBadge">Lade...</span>
</div>
</div>
<div class="row g-3">
<!-- Settings Card -->
<div class="col-12 col-xl-5">
<div class="card card-outline card-warning h-100">
<div class="card-header">
<h6 class="card-title mb-0"><i class="bi bi-gear me-1"></i>Einstellungen</h6>
</div>
<div class="card-body">
<form id="ninaForm">
<!-- 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">
<div class="col-7">
<label for="pollInterval" class="form-label">Abfrageintervall (Sek.)</label>
<input type="number" class="form-control form-control-sm" id="pollInterval"
min="60" max="3600" step="60" value="300">
<div class="form-text">Min. 60 Sekunden</div>
</div>
<div class="col-5">
<label for="ninaChannel" class="form-label">Kanal</label>
<input type="number" class="form-control form-control-sm" id="ninaChannel"
min="0" max="7" value="0">
</div>
</div>
<div class="mb-3">
<label for="minSeverity" class="form-label">Mindest-Schweregrad</label>
<select class="form-select form-select-sm" id="minSeverity">
<option value="Minor">Gering (Minor)</option>
<option value="Moderate">Mäßig (Moderate)</option>
<option value="Severe" selected>Schwerwiegend (Severe)</option>
<option value="Extreme">Extrem (Extreme)</option>
</select>
</div>
<!-- AGS Codes -->
<div class="mb-3">
<label class="form-label">AGS-Codes (Amtliche Gemeindeschlüssel)</label>
<div id="agsList" class="mb-2"></div>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="agsInput"
placeholder="z.B. 091620000000 (München)" maxlength="12">
<button class="btn btn-outline-secondary" type="button" id="btnAddAgs">
<i class="bi bi-plus-lg"></i>
</button>
</div>
<div class="form-text">
8- oder 12-stelliger AGS-Code des Landkreises/der kreisfreien Stadt.
<a href="https://www.destatis.de/DE/Themen/Laender-Regionen/Regionales/Gemeindeverzeichnis/_inhalt.html"
target="_blank" rel="noopener" class="text-info">AGS-Verzeichnis</a>
</div>
</div>
<!-- Sources -->
<div class="mb-3">
<label class="form-label">Quellen</label>
<div class="row g-2">
<div class="col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="srcKatwarn" checked>
<label class="form-check-label" for="srcKatwarn">Katwarn</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="srcBiwapp" checked>
<label class="form-check-label" for="srcBiwapp">BIWAPP</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="srcMowas" checked>
<label class="form-check-label" for="srcMowas">MoWaS</label>
</div>
</div>
<div class="col-6">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="srcDwd" checked>
<label class="form-check-label" for="srcDwd">DWD (Wetter)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="srcLhp" checked>
<label class="form-check-label" for="srcLhp">LHP (Hochwasser)</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="srcPolice">
<label class="form-check-label" for="srcPolice">Polizei</label>
</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-warning btn-sm" id="btnSaveNina">
<i class="bi bi-floppy me-1"></i>Speichern
</button>
<span id="saveStatus" class="d-none align-self-center small"></span>
</div>
</form>
</div>
</div>
</div>
<!-- Alerts Card -->
<div class="col-12 col-xl-7">
<div class="card card-outline card-danger h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0"><i class="bi bi-bell-fill me-1 text-danger"></i>Letzte Warnmeldungen</h6>
<small class="text-body-secondary">Live via WebSocket</small>
</div>
<div class="card-body p-0 table-responsive">
<table class="table table-hover table-sm table-striped mb-0 align-middle">
<thead class="table-dark">
<tr>
<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>
<tbody id="alertsTable">
<tr><td colspan="4" class="text-center text-body-secondary py-3">Keine Meldungen NINA aktivieren und AGS-Codes konfigurieren.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
<footer id="pageFooter" class="page-footer"></footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/nina.js"></script>
</body>
</html>