feat: NINA send_to_mesh-Schalter + Dresden-AGS-Codes
- send_to_mesh: true/false – trennt Abfrage vom Mesh-Versand. false = Monitor-Modus: Warnmeldungen werden abgerufen und in der Weboberfläche angezeigt, aber NICHT ins Meshtastic-Netz gesendet. WebSocket-Event enthaelt monitor_only-Flag (Anzeige per Icon). - nina.yaml/conf/nina.yaml: send_to_mesh=false als sichere Voreinstellung + 5 AGS-Codes fuer den Raum Dresden vorbelegt: Stadt Dresden (146120000000), LK Meissen (146270000000), LK Saechs. Schweiz-Osterzgebirge (146280000000), LK Bautzen (146250000000), LK Goerlitz (146260000000) - nina.html: zweiter Toggle "Ins Mesh senden" - nina.js: Schalter in load/save + Statusbadge (Mesh+Web / Nur Web) + Mesh-Spalte in Alerts-Tabelle mit broadcast/eye-Icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0ca0ffb0d1
commit
ee3208769c
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -6,6 +6,14 @@
|
||||||
- **NINA-Integration**: Anbindung an die NINA Warn-App des BBK (Bundesamt für Bevölkerungsschutz
|
- **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`
|
und Katastrophenhilfe). Warnmeldungen werden per HTTP-Polling von `warnung.bund.de/api31`
|
||||||
abgerufen und bei neuen Meldungen automatisch ins Meshtastic-Netz gesendet.
|
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-Konfigurationsseite** (`/nina`, Admin-only): Separate Webseite zur Verwaltung der
|
||||||
NINA-Einstellungen – analog zur Scheduler-Seite. Konfigurierbar:
|
NINA-Einstellungen – analog zur Scheduler-Seite. Konfigurierbar:
|
||||||
- Aktivierung / Deaktivierung
|
- Aktivierung / Deaktivierung
|
||||||
|
|
@ -14,6 +22,9 @@
|
||||||
- Mindest-Schweregrad (Gering / Mäßig / Schwerwiegend / Extrem)
|
- Mindest-Schweregrad (Gering / Mäßig / Schwerwiegend / Extrem)
|
||||||
- AGS-Codes (Amtliche Gemeindeschlüssel) der zu überwachenden Landkreise/Städte
|
- AGS-Codes (Amtliche Gemeindeschlüssel) der zu überwachenden Landkreise/Städte
|
||||||
- Quellen-Auswahl (Katwarn, BIWAPP, MoWaS, DWD, LHP, Polizei)
|
- 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
|
- **Live-Anzeige** empfangener NINA-Warnmeldungen in der Weboberfläche via WebSocket
|
||||||
(`nina_alert`-Event).
|
(`nina_alert`-Event).
|
||||||
- **NINA-Sidebar-Eintrag** in allen Seiten (Admin-only, Icon: `bi-shield-exclamation`).
|
- **NINA-Sidebar-Eintrag** in allen Seiten (Admin-only, Icon: `bi-shield-exclamation`).
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
enabled: false
|
enabled: false
|
||||||
|
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
||||||
poll_interval: 300
|
poll_interval: 300
|
||||||
channel: 0
|
channel: 0
|
||||||
min_severity: Severe
|
min_severity: Severe
|
||||||
ags_codes:
|
ags_codes:
|
||||||
- "091620000000" # Beispiel: München
|
- "146120000000" # Stadt Dresden
|
||||||
|
- "146270000000" # Landkreis Meißen
|
||||||
|
- "146280000000" # LK Sächsische Schweiz-Osterzgebirge
|
||||||
|
- "146250000000" # Landkreis Bautzen
|
||||||
|
- "146260000000" # Landkreis Görlitz
|
||||||
sources:
|
sources:
|
||||||
katwarn: true
|
katwarn: true
|
||||||
biwapp: true
|
biwapp: true
|
||||||
|
|
|
||||||
220
meshbot/nina.py
220
meshbot/nina.py
|
|
@ -26,18 +26,37 @@ SEVERITY_LABELS = {
|
||||||
"Minor": "Gering",
|
"Minor": "Gering",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Warning ID prefixes for source filtering
|
# Dashboard-Endpunkt: ID-Präfixe je Quelle (für Quellen-Filterung)
|
||||||
SOURCE_PREFIXES = {
|
SOURCE_PREFIXES = {
|
||||||
"katwarn": "katwarn.",
|
"katwarn": ("katwarn.",),
|
||||||
"biwapp": "biwapp.",
|
"biwapp": ("biwapp.",),
|
||||||
"mowas": "mowas.",
|
"mowas": ("mowas.", "mow."),
|
||||||
"dwd": "dwd.",
|
"dwd": ("dwd.",),
|
||||||
"lhp": "lhp.",
|
"lhp": ("lhp.",),
|
||||||
"police": "police.",
|
"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 = {
|
DEFAULT_CONFIG = {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
|
"send_to_mesh": True,
|
||||||
"poll_interval": 300,
|
"poll_interval": 300,
|
||||||
"channel": 0,
|
"channel": 0,
|
||||||
"min_severity": "Severe",
|
"min_severity": "Severe",
|
||||||
|
|
@ -54,14 +73,22 @@ DEFAULT_CONFIG = {
|
||||||
|
|
||||||
|
|
||||||
class NinaBot:
|
class NinaBot:
|
||||||
"""Polls the NINA BBK warning API and forwards alerts to Meshtastic."""
|
"""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):
|
def __init__(self, send_callback: Callable[[str, int], Awaitable[None]], ws_manager=None):
|
||||||
self.send_callback = send_callback
|
self.send_callback = send_callback
|
||||||
self.ws_manager = ws_manager
|
self.ws_manager = ws_manager
|
||||||
self.config: dict = {}
|
self.config: dict = {}
|
||||||
self._mtime: float = 0.0
|
self._mtime: float = 0.0
|
||||||
self._known: dict[str, str] = {} # id -> sent timestamp (de-dup)
|
self._known: dict[str, str] = {} # normalised_id -> sent (de-dup)
|
||||||
self._running = False
|
self._running = False
|
||||||
self._task: asyncio.Task | None = None
|
self._task: asyncio.Task | None = None
|
||||||
self._load()
|
self._load()
|
||||||
|
|
@ -83,17 +110,11 @@ class NinaBot:
|
||||||
)
|
)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
logger.info("No nina.yaml found – using defaults")
|
logger.info("No nina.yaml found – using defaults")
|
||||||
self.config = {
|
self.config = {**DEFAULT_CONFIG, "sources": dict(DEFAULT_CONFIG["sources"])}
|
||||||
**DEFAULT_CONFIG,
|
|
||||||
"sources": dict(DEFAULT_CONFIG["sources"]),
|
|
||||||
}
|
|
||||||
self._save()
|
self._save()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error loading nina.yaml")
|
logger.exception("Error loading nina.yaml")
|
||||||
self.config = {
|
self.config = {**DEFAULT_CONFIG, "sources": dict(DEFAULT_CONFIG["sources"])}
|
||||||
**DEFAULT_CONFIG,
|
|
||||||
"sources": dict(DEFAULT_CONFIG["sources"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
try:
|
try:
|
||||||
|
|
@ -167,22 +188,51 @@ class NinaBot:
|
||||||
await asyncio.sleep(interval)
|
await asyncio.sleep(interval)
|
||||||
|
|
||||||
async def _check_alerts(self):
|
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)
|
min_level = SEVERITY_ORDER.get(self.config.get("min_severity", "Severe"), 2)
|
||||||
channel = int(self.config.get("channel", 0))
|
channel = int(self.config.get("channel", 0))
|
||||||
sources = self.config.get("sources", DEFAULT_CONFIG["sources"])
|
sources = self.config.get("sources", DEFAULT_CONFIG["sources"])
|
||||||
|
ags_codes = self.config.get("ags_codes", [])
|
||||||
|
|
||||||
async with aiohttp.ClientSession(
|
async with aiohttp.ClientSession(
|
||||||
headers={"User-Agent": "MeshDD-Bot/1.0 (+https://github.com/ppfeiffer/MeshDD-Bot)"},
|
headers={"User-Agent": "MeshDD-Bot/1.0 (+https://github.com/ppfeiffer/MeshDD-Bot)"},
|
||||||
timeout=aiohttp.ClientTimeout(total=30),
|
timeout=aiohttp.ClientTimeout(total=30),
|
||||||
) as session:
|
) as session:
|
||||||
|
# 1. Dashboard: regional filtering per AGS code (server-side, all sources)
|
||||||
for ags in ags_codes:
|
for ags in ags_codes:
|
||||||
try:
|
try:
|
||||||
await self._fetch_dashboard(session, str(ags).strip(), min_level, channel, sources)
|
await self._fetch_dashboard(session, str(ags).strip(), min_level, channel, sources)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("NINA error for AGS %s", ags)
|
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(
|
async def _fetch_dashboard(
|
||||||
self,
|
self,
|
||||||
|
|
@ -192,40 +242,39 @@ class NinaBot:
|
||||||
channel: int,
|
channel: int,
|
||||||
sources: dict,
|
sources: dict,
|
||||||
):
|
):
|
||||||
# Pad AGS/ARS to 12 characters
|
|
||||||
ars = ags.ljust(12, "0")
|
ars = ags.ljust(12, "0")
|
||||||
url = f"{NINA_API_BASE}/dashboard/{ars}.json"
|
url = f"{NINA_API_BASE}/dashboard/{ars}.json"
|
||||||
|
|
||||||
async with session.get(url) as resp:
|
async with session.get(url) as resp:
|
||||||
if resp.status == 404:
|
if resp.status == 404:
|
||||||
logger.warning("NINA: no data for AGS %s (404)", ags)
|
logger.warning("NINA dashboard: no data for AGS %s (404)", ags)
|
||||||
return
|
return
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.warning("NINA API returned status %d for %s", resp.status, url)
|
logger.warning("NINA dashboard: status %d for %s", resp.status, url)
|
||||||
return
|
return
|
||||||
items = await resp.json(content_type=None)
|
items = await resp.json(content_type=None)
|
||||||
|
|
||||||
if not isinstance(items, list):
|
if not isinstance(items, list):
|
||||||
logger.warning("NINA: unexpected response type for AGS %s", ags)
|
logger.warning("NINA dashboard: unexpected response type for AGS %s", ags)
|
||||||
return
|
return
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
try:
|
try:
|
||||||
await self._process_item(item, min_level, channel, sources)
|
await self._process_dashboard_item(item, min_level, channel, sources)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("NINA: error processing item %s", item.get("id"))
|
logger.exception("NINA dashboard: error processing %s", item.get("id"))
|
||||||
|
|
||||||
async def _process_item(self, item: dict, min_level: int, channel: int, sources: dict):
|
async def _process_dashboard_item(
|
||||||
|
self, item: dict, min_level: int, channel: int, sources: dict
|
||||||
|
):
|
||||||
identifier = item.get("id", "")
|
identifier = item.get("id", "")
|
||||||
if not identifier:
|
if not identifier:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Filter by source
|
# Filter by source
|
||||||
for source_key, prefix in SOURCE_PREFIXES.items():
|
src_key = self._source_key_for(identifier)
|
||||||
if identifier.startswith(prefix):
|
if src_key and not sources.get(src_key, True):
|
||||||
if not sources.get(source_key, True):
|
return
|
||||||
return
|
|
||||||
break
|
|
||||||
|
|
||||||
payload = item.get("payload", {})
|
payload = item.get("payload", {})
|
||||||
sent = payload.get("sent", item.get("sent", ""))
|
sent = payload.get("sent", item.get("sent", ""))
|
||||||
|
|
@ -237,27 +286,101 @@ class NinaBot:
|
||||||
if sev_level < min_level and msg_type != "Cancel":
|
if sev_level < min_level and msg_type != "Cancel":
|
||||||
return
|
return
|
||||||
|
|
||||||
# De-duplicate: skip if already processed with same sent timestamp
|
dedup_key = self._normalise_id(identifier)
|
||||||
if identifier in self._known and self._known[identifier] == sent:
|
if dedup_key in self._known and self._known[dedup_key] == sent:
|
||||||
return
|
return
|
||||||
self._known[identifier] = sent
|
self._known[dedup_key] = sent
|
||||||
|
|
||||||
headline = data.get("headline", "Warnung")
|
headline = data.get("headline", "Warnung")
|
||||||
description = data.get("description", "")
|
description = data.get("description", "")
|
||||||
|
|
||||||
if msg_type == "Cancel":
|
text = self._format_alert(msg_type, severity, headline, description)
|
||||||
text = f"[NINA] Aufgehoben: {headline}"
|
logger.info("NINA dashboard alert: %s (id=%s)", headline, identifier)
|
||||||
else:
|
await self._send(identifier, severity, msg_type, headline, sent, text, channel)
|
||||||
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)
|
# ── mapData endpoint ─────────────────────────────────────────────────────
|
||||||
await self.send_callback(text, channel)
|
|
||||||
|
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:
|
if self.ws_manager:
|
||||||
await self.ws_manager.broadcast("nina_alert", {
|
await self.ws_manager.broadcast("nina_alert", {
|
||||||
|
|
@ -266,4 +389,5 @@ class NinaBot:
|
||||||
"msgType": msg_type,
|
"msgType": msg_type,
|
||||||
"headline": headline,
|
"headline": headline,
|
||||||
"sent": sent,
|
"sent": sent,
|
||||||
|
"monitor_only": not self.config.get("send_to_mesh", True),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
enabled: false
|
enabled: false
|
||||||
|
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
||||||
poll_interval: 300
|
poll_interval: 300
|
||||||
channel: 0
|
channel: 0
|
||||||
min_severity: Severe
|
min_severity: Severe
|
||||||
ags_codes: []
|
ags_codes:
|
||||||
|
- "146120000000" # Stadt Dresden
|
||||||
|
- "146270000000" # Landkreis Meißen
|
||||||
|
- "146280000000" # LK Sächsische Schweiz-Osterzgebirge
|
||||||
|
- "146250000000" # Landkreis Bautzen
|
||||||
|
- "146260000000" # Landkreis Görlitz
|
||||||
sources:
|
sources:
|
||||||
katwarn: true
|
katwarn: true
|
||||||
biwapp: true
|
biwapp: true
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ async function loadConfig() {
|
||||||
|
|
||||||
function applyConfig(cfg) {
|
function applyConfig(cfg) {
|
||||||
document.getElementById('ninaEnabled').checked = !!cfg.enabled;
|
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('pollInterval').value = cfg.poll_interval ?? 300;
|
||||||
document.getElementById('ninaChannel').value = cfg.channel ?? 0;
|
document.getElementById('ninaChannel').value = cfg.channel ?? 0;
|
||||||
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
|
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
|
||||||
|
|
@ -87,8 +88,9 @@ function updateStatusBadge(cfg) {
|
||||||
const badge = document.getElementById('statusBadge');
|
const badge = document.getElementById('statusBadge');
|
||||||
if (cfg.enabled) {
|
if (cfg.enabled) {
|
||||||
const codes = Array.isArray(cfg.ags_codes) ? cfg.ags_codes.length : 0;
|
const codes = Array.isArray(cfg.ags_codes) ? cfg.ags_codes.length : 0;
|
||||||
badge.className = 'badge bg-success';
|
const mode = cfg.send_to_mesh !== false ? 'Mesh+Web' : 'Nur Web';
|
||||||
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''}`;
|
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 {
|
} else {
|
||||||
badge.className = 'badge bg-secondary';
|
badge.className = 'badge bg-secondary';
|
||||||
badge.textContent = 'Deaktiviert';
|
badge.textContent = 'Deaktiviert';
|
||||||
|
|
@ -100,6 +102,7 @@ function updateStatusBadge(cfg) {
|
||||||
document.getElementById('btnSaveNina').addEventListener('click', async () => {
|
document.getElementById('btnSaveNina').addEventListener('click', async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
enabled: document.getElementById('ninaEnabled').checked,
|
enabled: document.getElementById('ninaEnabled').checked,
|
||||||
|
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
|
||||||
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
|
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
|
||||||
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
|
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
|
||||||
min_severity: document.getElementById('minSeverity').value,
|
min_severity: document.getElementById('minSeverity').value,
|
||||||
|
|
@ -168,10 +171,14 @@ function renderAlerts() {
|
||||||
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary');
|
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary');
|
||||||
const sevLabel = a.msgType === 'Cancel' ? 'Aufgehoben' : (SEV_LABEL[a.severity] ?? a.severity);
|
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 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>
|
return `<tr>
|
||||||
<td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></td>
|
<td><span class="badge bg-${cls} bg-opacity-85">${escapeHtml(sevLabel)}</span></td>
|
||||||
<td>${escapeHtml(a.headline)}</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">${escapeHtml(a.id?.split('.')[0] ?? '–')}</small></td>
|
||||||
|
<td class="text-center">${meshIcon}</td>
|
||||||
<td><small class="text-body-secondary">${ts}</small></td>
|
<td><small class="text-body-secondary">${ts}</small></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,19 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="ninaForm">
|
<form id="ninaForm">
|
||||||
<!-- Enable toggle -->
|
<!-- Enable + send toggles -->
|
||||||
<div class="mb-3 d-flex align-items-center gap-3">
|
<div class="mb-3">
|
||||||
<div class="form-check form-switch mb-0">
|
<div class="form-check form-switch mb-2">
|
||||||
<input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch">
|
<input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch">
|
||||||
<label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</label>
|
<label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</label>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="row g-3 mb-3">
|
<div class="row g-3 mb-3">
|
||||||
|
|
@ -165,6 +172,7 @@
|
||||||
<th style="width:90px">Schweregrad</th>
|
<th style="width:90px">Schweregrad</th>
|
||||||
<th>Meldung</th>
|
<th>Meldung</th>
|
||||||
<th style="width:110px">Typ</th>
|
<th style="width:110px">Typ</th>
|
||||||
|
<th style="width:60px" class="text-center">Mesh</th>
|
||||||
<th style="width:130px">Zeitstempel</th>
|
<th style="width:130px">Zeitstempel</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue