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
## [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
### Changed

View file

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

View file

@ -113,6 +113,7 @@ class NinaBot:
self._task: asyncio.Task | None = None
self._resend_task: asyncio.Task | None = None
self._last_poll: str = ""
self._last_sent: str = ""
self._load()
# ── Config ──────────────────────────────────────────────────────────────
@ -154,7 +155,7 @@ class NinaBot:
logger.exception("Error saving nina.yaml")
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]:
return sorted(self._active.values(), key=lambda x: x.get("sent", ""), reverse=True)
@ -351,7 +352,7 @@ class NinaBot:
description = data.get("description", "")
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)
await self._send(identifier, severity, msg_type, headline, sent, text, channel, area)
@ -379,11 +380,11 @@ class NinaBot:
for item in items:
try:
await self._process_map_item(item, min_level, channel)
await self._process_map_item(item, min_level, channel, source_key)
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):
async def _process_map_item(self, item: dict, min_level: int, channel: int, source_key: str = ""):
identifier = item.get("id", "")
if not identifier:
return
@ -405,19 +406,20 @@ class NinaBot:
i18n = item.get("i18nTitle", {})
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)
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, 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 ""
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)
text = f"[NINA] {sev_text}: {headline}{area_suffix}"
text = f"{prefix} {sev_text}: {headline}{area_suffix}"
if description:
short = description.strip()[:120]
if len(description.strip()) > 120:
@ -456,6 +458,7 @@ class NinaBot:
if self.config.get("send_to_mesh", True):
await self.send_callback(text, channel)
self._last_sent = datetime.now(timezone.utc).isoformat()
else:
logger.info("NINA monitor-only: Mesh-Versand deaktiviert, nur WebSocket-Broadcast")

View file

@ -105,8 +105,8 @@ async function loadConfig() {
function applyConfig(cfg) {
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('resendInterval').value = cfg.resend_interval ?? 3600;
document.getElementById('pollInterval').value = Math.round((cfg.poll_interval ?? 300) / 60);
document.getElementById('resendInterval').value = Math.round((cfg.resend_interval ?? 3600) / 60);
document.getElementById('ninaChannel').value = cfg.channel ?? 0;
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
@ -123,12 +123,15 @@ function applyConfig(cfg) {
const lpEl = document.getElementById('lastPoll');
if (lpEl) {
if (cfg.last_poll) {
const d = new Date(cfg.last_poll);
lpEl.textContent = 'Letzte Abfrage: ' + d.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' });
} else {
lpEl.textContent = '';
lpEl.textContent = cfg.last_poll
? 'Letzte Abfrage: ' + new Date(cfg.last_poll).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'medium' })
: '';
}
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 = {
enabled: document.getElementById('ninaEnabled').checked,
send_to_mesh: document.getElementById('ninaSendToMesh').checked,
poll_interval: parseInt(document.getElementById('pollInterval').value) || 300,
resend_interval: parseInt(document.getElementById('resendInterval').value) || 3600,
poll_interval: (parseInt(document.getElementById('pollInterval').value) || 5) * 60,
resend_interval: (parseInt(document.getElementById('resendInterval').value) || 60) * 60,
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
min_severity: document.getElementById('minSeverity').value,
ags_codes: [...agsCodes],

View file

@ -56,8 +56,8 @@
<div class="card-body">
<form id="ninaForm">
<!-- Enable + send toggles -->
<div class="mb-3">
<div class="form-check form-switch mb-2">
<div class="mb-3 d-flex gap-4 flex-wrap">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="ninaEnabled" role="switch">
<label class="form-check-label fw-semibold" for="ninaEnabled">Aktiviert</label>
</div>
@ -72,15 +72,18 @@
<div class="row g-3 mb-3">
<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"
min="60" max="3600" step="60" value="300">
<div class="form-text">Neue Warnmeldungen<span id="lastPoll" class="d-block text-body-secondary"></span></div>
min="1" max="60" step="1" value="5">
<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 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"
min="60" step="60" value="3600">
min="1" step="1" value="60">
<div class="form-text">Aktive Warnungen</div>
</div>
<div class="col-2">