feat: NINA Wiederholungsintervall, AGS-Tabelle, Badge-Fix (v0.8.1)

- resend_interval: zweiter Intervall fuer erneutes Senden aktiver
  Warnmeldungen ins Mesh (nur bei send_to_mesh=true). Standard 3600s.
  NinaBot._resend_loop() laeuft als eigener asyncio-Task; _active-Dict
  haelt aktive Warnungen vor (Cancel entfernt Eintraege).
- AGS-Code-Verwaltung als Tabelle mit Trash-Button je Zeile statt
  Badges mit X-Button.
- Severity-Badges: explizite text-white/text-dark, kein bg-opacity-85
  mehr - lesbar in beiden Themes.
- colspan-Fix: leere Alerts-Zeile auf 5 Spalten korrigiert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ppfeiffer 2026-02-19 12:44:29 +01:00
parent 4c465ed170
commit 2feff2c2c7
7 changed files with 114 additions and 39 deletions

View file

@ -1,5 +1,20 @@
# Changelog # Changelog
## [0.8.1] - 2026-02-19
### Added
- **NINA Wiederholungsintervall** (`resend_interval`): Zweiter konfigurierbarer Intervall,
der aktive Warnmeldungen in regelmäßigen Abständen erneut ins Mesh sendet
(nur wenn `send_to_mesh=true`). Standard: 3600 Sekunden (1 Stunde).
- **NINA AGS-Code-Tabelle**: AGS-Codes werden jetzt in einer Tabelle mit Lösch-Button
je Zeile angezeigt übersichtlicher als die bisherigen Badge-Einträge.
### Fixed
- **Badge-Lesbarkeit**: Severity-Badges in der Alerts-Tabelle haben jetzt explizite
Textfarben (`text-white` / `text-dark`) ohne `bg-opacity`, damit der Text auf allen
Hintergründen und in beiden Themes lesbar bleibt.
- **colspan**: Leere Zeile in der Alerts-Tabelle korrekt auf 5 Spalten gesetzt.
## [0.8.0] - 2026-02-19 ## [0.8.0] - 2026-02-19
### Added ### Added

View file

@ -1,6 +1,7 @@
enabled: false enabled: false
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus) send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
poll_interval: 300 poll_interval: 300
resend_interval: 3600 # Aktive Warnungen alle N Sekunden erneut ins Mesh senden
channel: 0 channel: 0
min_severity: Severe min_severity: Severe
ags_codes: ags_codes:

View file

@ -1,4 +1,4 @@
version: "0.8.0" version: "0.8.1"
bot: bot:
name: "MeshDD-Bot" name: "MeshDD-Bot"

View file

@ -58,6 +58,7 @@ DEFAULT_CONFIG = {
"enabled": False, "enabled": False,
"send_to_mesh": True, "send_to_mesh": True,
"poll_interval": 300, "poll_interval": 300,
"resend_interval": 3600,
"channel": 0, "channel": 0,
"min_severity": "Severe", "min_severity": "Severe",
"ags_codes": [], "ags_codes": [],
@ -88,9 +89,11 @@ class NinaBot:
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] = {} # normalised_id -> sent (de-dup) self._known: dict[str, str] = {} # normalised_id -> sent (de-dup)
self._active: dict[str, dict] = {} # normalised_id -> {text, channel, headline, severity, id, sent}
self._running = False self._running = False
self._task: asyncio.Task | None = None self._task: asyncio.Task | None = None
self._resend_task: asyncio.Task | None = None
self._load() self._load()
# ── Config ────────────────────────────────────────────────────────────── # ── Config ──────────────────────────────────────────────────────────────
@ -149,16 +152,18 @@ class NinaBot:
async def start(self): async def start(self):
self._running = True self._running = True
self._task = asyncio.create_task(self._poll_loop()) self._task = asyncio.create_task(self._poll_loop())
self._resend_task = asyncio.create_task(self._resend_loop())
logger.info("NinaBot started") logger.info("NinaBot started")
async def stop(self): async def stop(self):
self._running = False self._running = False
if self._task: for task in (self._task, self._resend_task):
self._task.cancel() if task:
try: task.cancel()
await self._task try:
except asyncio.CancelledError: await task
pass except asyncio.CancelledError:
pass
# ── Hot-reload ─────────────────────────────────────────────────────────── # ── Hot-reload ───────────────────────────────────────────────────────────
@ -187,6 +192,20 @@ class NinaBot:
interval = max(60, int(self.config.get("poll_interval", 300))) interval = max(60, int(self.config.get("poll_interval", 300)))
await asyncio.sleep(interval) await asyncio.sleep(interval)
async def _resend_loop(self):
"""Re-broadcast all active warnings at resend_interval when send_to_mesh is enabled."""
while self._running:
interval = max(60, int(self.config.get("resend_interval", 3600)))
await asyncio.sleep(interval)
try:
if self.config.get("enabled") and self.config.get("send_to_mesh", True):
if self._active:
logger.info("NINA resend: %d aktive Warnmeldungen", len(self._active))
for entry in list(self._active.values()):
await self.send_callback(entry["text"], entry["channel"])
except Exception:
logger.exception("NINA resend error")
async def _check_alerts(self): async def _check_alerts(self):
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))
@ -377,6 +396,21 @@ class NinaBot:
text: str, text: str,
channel: int, channel: int,
): ):
dedup_key = self._normalise_id(identifier)
# Keep _active up to date for re-broadcast
if msg_type == "Cancel":
self._active.pop(dedup_key, None)
else:
self._active[dedup_key] = {
"text": text,
"channel": channel,
"headline": headline,
"severity": severity,
"id": identifier,
"sent": sent,
}
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)
else: else:

View file

@ -1,6 +1,7 @@
enabled: false enabled: false
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus) send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
poll_interval: 300 poll_interval: 300
resend_interval: 3600 # Aktive Warnungen alle N Sekunden erneut ins Mesh senden
channel: 0 channel: 0
min_severity: Severe min_severity: Severe
ags_codes: ags_codes:

View file

@ -8,17 +8,21 @@ initPage({ onAuth: (user) => { currentUser = user; } });
// ── AGS code list ──────────────────────────────────────────────────────────── // ── AGS code list ────────────────────────────────────────────────────────────
function renderAgsList() { function renderAgsList() {
const container = document.getElementById('agsList'); const tbody = document.getElementById('agsList');
if (agsCodes.length === 0) { if (agsCodes.length === 0) {
container.innerHTML = '<small class="text-body-secondary">Keine AGS-Codes konfiguriert.</small>'; tbody.innerHTML = '<tr><td colspan="2" class="text-center text-body-secondary py-2"><small>Keine AGS-Codes konfiguriert.</small></td></tr>';
return; return;
} }
container.innerHTML = agsCodes.map((code, idx) => tbody.innerHTML = agsCodes.map((code, idx) =>
`<span class="badge bg-secondary me-1 mb-1"> `<tr>
${escapeHtml(code)} <td><code>${escapeHtml(code)}</code></td>
<button type="button" class="btn-close btn-close-white ms-1" style="font-size:.55rem;vertical-align:middle" <td class="text-end">
onclick="removeAgs(${idx})" aria-label="Entfernen"></button> <button type="button" class="btn btn-outline-danger btn-sm py-0 px-1"
</span>` onclick="removeAgs(${idx})" title="Entfernen">
<i class="bi bi-trash" style="font-size:.75rem"></i>
</button>
</td>
</tr>`
).join(''); ).join('');
} }
@ -66,11 +70,12 @@ 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 = cfg.poll_interval ?? 300;
document.getElementById('ninaChannel').value = cfg.channel ?? 0; document.getElementById('resendInterval').value = cfg.resend_interval ?? 3600;
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe'; document.getElementById('ninaChannel').value = cfg.channel ?? 0;
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
agsCodes = Array.isArray(cfg.ags_codes) ? [...cfg.ags_codes] : []; agsCodes = Array.isArray(cfg.ags_codes) ? [...cfg.ags_codes] : [];
renderAgsList(); renderAgsList();
@ -92,7 +97,7 @@ function updateStatusBadge(cfg) {
badge.className = cfg.send_to_mesh !== false ? 'badge bg-success' : 'badge bg-warning text-dark'; badge.className = cfg.send_to_mesh !== false ? 'badge bg-success' : 'badge bg-warning text-dark';
badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''} · ${mode}`; badge.textContent = `Aktiv · ${codes} Region${codes !== 1 ? 'en' : ''} · ${mode}`;
} else { } else {
badge.className = 'badge bg-secondary'; badge.className = 'badge bg-secondary text-white';
badge.textContent = 'Deaktiviert'; badge.textContent = 'Deaktiviert';
} }
} }
@ -101,11 +106,12 @@ 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, 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, resend_interval: parseInt(document.getElementById('resendInterval').value) || 3600,
min_severity: document.getElementById('minSeverity').value, channel: parseInt(document.getElementById('ninaChannel').value) || 0,
min_severity: document.getElementById('minSeverity').value,
ags_codes: [...agsCodes], ags_codes: [...agsCodes],
sources: { sources: {
katwarn: document.getElementById('srcKatwarn').checked, katwarn: document.getElementById('srcKatwarn').checked,
@ -147,10 +153,10 @@ document.getElementById('btnSaveNina').addEventListener('click', async () => {
// ── Alerts table ───────────────────────────────────────────────────────────── // ── Alerts table ─────────────────────────────────────────────────────────────
const SEV_CLASS = { const SEV_CLASS = {
Extreme: 'danger', Extreme: { bg: 'danger', text: 'text-white' },
Severe: 'warning', Severe: { bg: 'warning', text: 'text-dark' },
Moderate: 'info', Moderate: { bg: 'info', text: 'text-white' },
Minor: 'secondary', Minor: { bg: 'secondary', text: 'text-white' },
}; };
const SEV_LABEL = { const SEV_LABEL = {
@ -164,18 +170,20 @@ const SEV_LABEL = {
function renderAlerts() { function renderAlerts() {
const tbody = document.getElementById('alertsTable'); const tbody = document.getElementById('alertsTable');
if (alerts.length === 0) { if (alerts.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>'; tbody.innerHTML = '<tr><td colspan="5" class="text-center text-body-secondary py-3">Keine Meldungen empfangen.</td></tr>';
return; return;
} }
tbody.innerHTML = alerts.map(a => { tbody.innerHTML = alerts.map(a => {
const cls = a.msgType === 'Cancel' ? 'secondary' : (SEV_CLASS[a.severity] ?? 'secondary'); const sev = a.msgType === 'Cancel' ? null : (SEV_CLASS[a.severity] ?? { bg: 'secondary', text: 'text-white' });
const bgCls = a.msgType === 'Cancel' ? 'secondary' : sev.bg;
const txCls = a.msgType === 'Cancel' ? 'text-white' : sev.text;
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 const meshIcon = a.monitor_only
? '<i class="bi bi-eye text-warning" title="Nur Weboberfläche"></i>' ? '<i class="bi bi-eye text-warning" title="Nur Weboberfläche"></i>'
: '<i class="bi bi-broadcast text-success" title="Ins Mesh gesendet"></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-${bgCls} ${txCls}">${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 class="text-center">${meshIcon}</td>

View file

@ -71,13 +71,19 @@
</div> </div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-7"> <div class="col-5">
<label for="pollInterval" class="form-label">Abfrageintervall (Sek.)</label> <label for="pollInterval" class="form-label">Abfrage&shy;intervall (Sek.)</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="60" max="3600" step="60" value="300">
<div class="form-text">Min. 60 Sekunden</div> <div class="form-text">Neue Warnmeldungen</div>
</div> </div>
<div class="col-5"> <div class="col-5">
<label for="resendInterval" class="form-label">Wieder&shy;holungsintervall (Sek.)</label>
<input type="number" class="form-control form-control-sm" id="resendInterval"
min="60" step="60" value="3600">
<div class="form-text">Aktive Warnungen</div>
</div>
<div class="col-2">
<label for="ninaChannel" class="form-label">Kanal</label> <label for="ninaChannel" class="form-label">Kanal</label>
<input type="number" class="form-control form-control-sm" id="ninaChannel" <input type="number" class="form-control form-control-sm" id="ninaChannel"
min="0" max="7" value="0"> min="0" max="7" value="0">
@ -97,12 +103,22 @@
<!-- AGS Codes --> <!-- AGS Codes -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">AGS-Codes (Amtliche Gemeindeschlüssel)</label> <label class="form-label">AGS-Codes (Amtliche Gemeindeschlüssel)</label>
<div id="agsList" class="mb-2"></div> <div class="table-responsive mb-2" style="max-height:160px;overflow-y:auto">
<table class="table table-sm table-hover table-striped mb-0 align-middle">
<thead class="table-dark">
<tr>
<th>AGS-Code</th>
<th style="width:42px"></th>
</tr>
</thead>
<tbody id="agsList"></tbody>
</table>
</div>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="text" class="form-control" id="agsInput" <input type="text" class="form-control" id="agsInput"
placeholder="z.B. 091620000000 (München)" maxlength="12"> placeholder="z.B. 091620000000 (München)" maxlength="12">
<button class="btn btn-outline-secondary" type="button" id="btnAddAgs"> <button class="btn btn-outline-secondary" type="button" id="btnAddAgs">
<i class="bi bi-plus-lg"></i> <i class="bi bi-plus-lg"></i> Hinzufügen
</button> </button>
</div> </div>
<div class="form-text"> <div class="form-text">
@ -177,7 +193,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="alertsTable"> <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> <tr><td colspan="5" class="text-center text-body-secondary py-3">Keine Meldungen NINA aktivieren und AGS-Codes konfigurieren.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>