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:
parent
dc4457d25e
commit
0ca0ffb0d1
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -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
13
conf/nina.yaml
Normal 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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
version: "0.7.1"
|
version: "0.8.0"
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
name: "MeshDD-Bot"
|
name: "MeshDD-Bot"
|
||||||
|
|
|
||||||
11
main.py
11
main.py
|
|
@ -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
269
meshbot/nina.py
Normal 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,
|
||||||
|
})
|
||||||
|
|
@ -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
12
nina.yaml
Normal 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
|
||||||
|
|
@ -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
206
static/js/nina.js
Normal 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 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 = '<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
187
static/nina.html
Normal 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>
|
||||||
Loading…
Reference in a new issue