feat: NINA BBK Warn-App Integration (v0.8.0)

Neue NINA-Integration: Automatisches Polling der BBK-Warn-API
(warnung.bund.de/api31) und Weiterleitung von Warnmeldungen ins
Meshtastic-Netz. Separate Admin-Konfigurationsseite (/nina) analog
zum Scheduler.

- meshbot/nina.py: NinaBot – Polling, De-Duplikation, Schweregrad-
  und Quellen-Filterung, WebSocket-Broadcast (nina_alert)
- nina.yaml + conf/nina.yaml: Hot-reload-faehige Konfiguration
- static/nina.html + static/js/nina.js: Konfigurationsseite mit
  AGS-Code-Verwaltung, Quellen-Auswahl und Live-Alerts-Tabelle
- webserver.py: GET/PUT /api/nina/config + GET /nina (Admin-only)
- main.py: NinaBot initialisieren, watch/start/stop im Lifecycle
- app.js: NINA-Sidebar-Eintrag (Admin-only, shield-exclamation)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-19 11:21:01 +01:00
parent dc4457d25e
commit 0ca0ffb0d1
10 changed files with 746 additions and 9 deletions

View file

@ -1,5 +1,24 @@
# 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.
- **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 ## [0.7.1] - 2026-02-18
### Changed ### Changed

13
conf/nina.yaml Normal file
View file

@ -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

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()

269
meshbot/nina.py Normal file
View file

@ -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,
})

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)

12
nina.yaml Normal file
View file

@ -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

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() {

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

@ -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 = '<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('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 = '<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' }) : '';
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><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();

187
static/nina.html Normal file
View file

@ -0,0 +1,187 @@
<!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 toggle -->
<div class="mb-3 d-flex align-items-center gap-3">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch">
<label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</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: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>