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