feat(nina): Quellenkennung, Schalter nebeneinander, Min.-Intervalle, last_sent (closes #2)

- _format_alert: Präfix [SOURCE@NINA] je nach Quelle
- _send(): _last_sent bei Mesh-Versand setzen
- get_config(): last_sent zurückgeben
- nina.html: Toggles nebeneinander (d-flex gap-4), Intervall-Labels in Min.,
  lastSent-Element ergänzt
- nina.js: applyConfig ÷60 / Speichern ×60, lastSent befüllen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-20 22:19:04 +01:00
parent 76f04105b7
commit 07676a8c96
5 changed files with 49 additions and 26 deletions

View file

@ -1,5 +1,19 @@
# Changelog # Changelog
## [0.08.22] - 2026-02-20
### Changed
- **NINA Quellenkennung** (closes #2 Aufgabe 1): Präfix `[NINA]``[DWD@NINA]`,
`[KATWARN@NINA]` usw. je nach Warnquelle.
- **NINA Schalter nebeneinander** (closes #2 Aufgabe 2): "Aktiviert" und
"Ins Mesh senden" liegen jetzt in einer Zeile nebeneinander.
- **NINA Intervalle in Minuten** (closes #2 Aufgabe 3): UI zeigt und speichert
Abfrage- und Wiederholungsintervall in Minuten (intern weiterhin Sekunden).
### Added
- **NINA Zuletzt gesendet** (closes #2 Aufgabe 4): Neues Feld unter dem
Abfrageintervall zeigt, wann zuletzt eine Meldung ins Mesh gesendet wurde.
## [0.08.21] - 2026-02-20 ## [0.08.21] - 2026-02-20
### Changed ### Changed

View file

@ -1,4 +1,4 @@
version: "0.08.21" version: "0.08.22"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

View file

@ -113,6 +113,7 @@ class NinaBot:
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._resend_task: asyncio.Task | None = None self._resend_task: asyncio.Task | None = None
self._last_poll: str = "" self._last_poll: str = ""
self._last_sent: str = ""
self._load() self._load()
# ── Config ────────────────────────────────────────────────────────────── # ── Config ──────────────────────────────────────────────────────────────
@ -154,7 +155,7 @@ class NinaBot:
logger.exception("Error saving nina.yaml") logger.exception("Error saving nina.yaml")
def get_config(self) -> dict: def get_config(self) -> dict:
return {**self.config, "last_poll": self._last_poll} return {**self.config, "last_poll": self._last_poll, "last_sent": self._last_sent}
def get_active_alerts(self) -> list[dict]: def get_active_alerts(self) -> list[dict]:
return sorted(self._active.values(), key=lambda x: x.get("sent", ""), reverse=True) return sorted(self._active.values(), key=lambda x: x.get("sent", ""), reverse=True)
@ -351,7 +352,7 @@ class NinaBot:
description = data.get("description", "") description = data.get("description", "")
area = AGS_LABELS.get(ags.ljust(12, "0"), ags) area = AGS_LABELS.get(ags.ljust(12, "0"), ags)
text = self._format_alert(msg_type, severity, headline, description, area) text = self._format_alert(msg_type, severity, headline, description, area, src_key or "")
logger.info("NINA dashboard alert: %s (id=%s, area=%s)", headline, identifier, area) logger.info("NINA dashboard alert: %s (id=%s, area=%s)", headline, identifier, area)
await self._send(identifier, severity, msg_type, headline, sent, text, channel, area) await self._send(identifier, severity, msg_type, headline, sent, text, channel, area)
@ -379,11 +380,11 @@ class NinaBot:
for item in items: for item in items:
try: try:
await self._process_map_item(item, min_level, channel) await self._process_map_item(item, min_level, channel, source_key)
except Exception: except Exception:
logger.exception("NINA mapData: error processing %s", item.get("id")) logger.exception("NINA mapData: error processing %s", item.get("id"))
async def _process_map_item(self, item: dict, min_level: int, channel: int): async def _process_map_item(self, item: dict, min_level: int, channel: int, source_key: str = ""):
identifier = item.get("id", "") identifier = item.get("id", "")
if not identifier: if not identifier:
return return
@ -405,19 +406,20 @@ class NinaBot:
i18n = item.get("i18nTitle", {}) i18n = item.get("i18nTitle", {})
headline = i18n.get("de") or i18n.get("en") or identifier headline = i18n.get("de") or i18n.get("en") or identifier
text = self._format_alert(msg_type, severity, headline, "") text = self._format_alert(msg_type, severity, headline, "", "", source_key)
logger.info("NINA mapData alert: %s (id=%s)", headline, identifier) logger.info("NINA mapData alert: %s (id=%s)", headline, identifier)
await self._send(identifier, severity, msg_type, headline, sent, text, channel, "") await self._send(identifier, severity, msg_type, headline, sent, text, channel, "")
# ── Shared helpers ─────────────────────────────────────────────────────── # ── Shared helpers ───────────────────────────────────────────────────────
@staticmethod @staticmethod
def _format_alert(msg_type: str, severity: str, headline: str, description: str, area: str = "") -> str: def _format_alert(msg_type: str, severity: str, headline: str, description: str, area: str = "", source: str = "") -> str:
prefix = f"[{source.upper()}@NINA]" if source else "[NINA]"
area_suffix = f" ({area})" if area else "" area_suffix = f" ({area})" if area else ""
if msg_type == "Cancel": if msg_type == "Cancel":
return f"[NINA] Aufgehoben: {headline}{area_suffix}" return f"{prefix} Aufgehoben: {headline}{area_suffix}"
sev_text = SEVERITY_LABELS.get(severity, severity) sev_text = SEVERITY_LABELS.get(severity, severity)
text = f"[NINA] {sev_text}: {headline}{area_suffix}" text = f"{prefix} {sev_text}: {headline}{area_suffix}"
if description: if description:
short = description.strip()[:120] short = description.strip()[:120]
if len(description.strip()) > 120: if len(description.strip()) > 120:
@ -456,6 +458,7 @@ class NinaBot:
if self.config.get("send_to_mesh", True): if self.config.get("send_to_mesh", True):
await self.send_callback(text, channel) await self.send_callback(text, channel)
self._last_sent = datetime.now(timezone.utc).isoformat()
else: else:
logger.info("NINA monitor-only: Mesh-Versand deaktiviert, nur WebSocket-Broadcast") logger.info("NINA monitor-only: Mesh-Versand deaktiviert, nur WebSocket-Broadcast")

View file

@ -105,8 +105,8 @@ 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('ninaSendToMesh').checked = cfg.send_to_mesh !== false;
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300; document.getElementById('pollInterval').value = Math.round((cfg.poll_interval ?? 300) / 60);
document.getElementById('resendInterval').value = cfg.resend_interval ?? 3600; document.getElementById('resendInterval').value = Math.round((cfg.resend_interval ?? 3600) / 60);
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';
@ -123,12 +123,15 @@ function applyConfig(cfg) {
const lpEl = document.getElementById('lastPoll'); const lpEl = document.getElementById('lastPoll');
if (lpEl) { if (lpEl) {
if (cfg.last_poll) { lpEl.textContent = cfg.last_poll
const d = new Date(cfg.last_poll); ? 'Letzte Abfrage: ' + new Date(cfg.last_poll).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })
lpEl.textContent = 'Letzte Abfrage: ' + d.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' }); : '';
} else { }
lpEl.textContent = ''; const lsEl = document.getElementById('lastSent');
} if (lsEl) {
lsEl.textContent = cfg.last_sent
? 'Zuletzt gesendet: ' + new Date(cfg.last_sent).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })
: '';
} }
} }
@ -151,8 +154,8 @@ 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, send_to_mesh: document.getElementById('ninaSendToMesh').checked,
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300, poll_interval: (parseInt(document.getElementById('pollInterval').value) || 5) * 60,
resend_interval: parseInt(document.getElementById('resendInterval').value) || 3600, resend_interval: (parseInt(document.getElementById('resendInterval').value) || 60) * 60,
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,
ags_codes: [...agsCodes], ags_codes: [...agsCodes],

View file

@ -56,8 +56,8 @@
<div class="card-body"> <div class="card-body">
<form id="ninaForm"> <form id="ninaForm">
<!-- Enable + send toggles --> <!-- Enable + send toggles -->
<div class="mb-3"> <div class="mb-3 d-flex gap-4 flex-wrap">
<div class="form-check form-switch mb-2"> <div class="form-check form-switch">
<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>
@ -72,15 +72,18 @@
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-5"> <div class="col-5">
<label for="pollInterval" class="form-label">Abfrage&shy;intervall (Sek.)</label> <label for="pollInterval" class="form-label">Abfrage&shy;intervall (Min.)</label>
<input type="number" class="form-control form-control-sm" id="pollInterval" <input type="number" class="form-control form-control-sm" id="pollInterval"
min="60" max="3600" step="60" value="300"> min="1" max="60" step="1" value="5">
<div class="form-text">Neue Warnmeldungen<span id="lastPoll" class="d-block text-body-secondary"></span></div> <div class="form-text">Neue Warnmeldungen
<span id="lastPoll" class="d-block text-body-secondary"></span>
<span id="lastSent" class="d-block text-body-secondary"></span>
</div>
</div> </div>
<div class="col-5"> <div class="col-5">
<label for="resendInterval" class="form-label">Wieder&shy;holungsintervall (Sek.)</label> <label for="resendInterval" class="form-label">Wieder&shy;holungsintervall (Min.)</label>
<input type="number" class="form-control form-control-sm" id="resendInterval" <input type="number" class="form-control form-control-sm" id="resendInterval"
min="60" step="60" value="3600"> min="1" step="1" value="60">
<div class="form-text">Aktive Warnungen</div> <div class="form-text">Aktive Warnungen</div>
</div> </div>
<div class="col-2"> <div class="col-2">