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:
parent
4c465ed170
commit
2feff2c2c7
15
CHANGELOG.md
15
CHANGELOG.md
|
|
@ -1,5 +1,20 @@
|
|||
# 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
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
enabled: false
|
||||
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
||||
poll_interval: 300
|
||||
resend_interval: 3600 # Aktive Warnungen alle N Sekunden erneut ins Mesh senden
|
||||
channel: 0
|
||||
min_severity: Severe
|
||||
ags_codes:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
version: "0.8.0"
|
||||
version: "0.8.1"
|
||||
|
||||
bot:
|
||||
name: "MeshDD-Bot"
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ DEFAULT_CONFIG = {
|
|||
"enabled": False,
|
||||
"send_to_mesh": True,
|
||||
"poll_interval": 300,
|
||||
"resend_interval": 3600,
|
||||
"channel": 0,
|
||||
"min_severity": "Severe",
|
||||
"ags_codes": [],
|
||||
|
|
@ -88,9 +89,11 @@ class NinaBot:
|
|||
self.ws_manager = ws_manager
|
||||
self.config: dict = {}
|
||||
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._task: asyncio.Task | None = None
|
||||
self._resend_task: asyncio.Task | None = None
|
||||
self._load()
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -149,16 +152,18 @@ class NinaBot:
|
|||
async def start(self):
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._poll_loop())
|
||||
self._resend_task = asyncio.create_task(self._resend_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
|
||||
for task in (self._task, self._resend_task):
|
||||
if task:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# ── Hot-reload ───────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -187,6 +192,20 @@ class NinaBot:
|
|||
interval = max(60, int(self.config.get("poll_interval", 300)))
|
||||
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):
|
||||
min_level = SEVERITY_ORDER.get(self.config.get("min_severity", "Severe"), 2)
|
||||
channel = int(self.config.get("channel", 0))
|
||||
|
|
@ -377,6 +396,21 @@ class NinaBot:
|
|||
text: str,
|
||||
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):
|
||||
await self.send_callback(text, channel)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
enabled: false
|
||||
send_to_mesh: false # true = ins Mesh senden | false = nur Weboberfläche (Monitor-Modus)
|
||||
poll_interval: 300
|
||||
resend_interval: 3600 # Aktive Warnungen alle N Sekunden erneut ins Mesh senden
|
||||
channel: 0
|
||||
min_severity: Severe
|
||||
ags_codes:
|
||||
|
|
|
|||
|
|
@ -8,17 +8,21 @@ initPage({ onAuth: (user) => { currentUser = user; } });
|
|||
// ── AGS code list ────────────────────────────────────────────────────────────
|
||||
|
||||
function renderAgsList() {
|
||||
const container = document.getElementById('agsList');
|
||||
const tbody = document.getElementById('agsList');
|
||||
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;
|
||||
}
|
||||
container.innerHTML = agsCodes.map((code, idx) =>
|
||||
`<span class="badge bg-secondary me-1 mb-1">
|
||||
${escapeHtml(code)}
|
||||
<button type="button" class="btn-close btn-close-white ms-1" style="font-size:.55rem;vertical-align:middle"
|
||||
onclick="removeAgs(${idx})" aria-label="Entfernen"></button>
|
||||
</span>`
|
||||
tbody.innerHTML = agsCodes.map((code, idx) =>
|
||||
`<tr>
|
||||
<td><code>${escapeHtml(code)}</code></td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm py-0 px-1"
|
||||
onclick="removeAgs(${idx})" title="Entfernen">
|
||||
<i class="bi bi-trash" style="font-size:.75rem"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
|
|
@ -66,11 +70,12 @@ async function loadConfig() {
|
|||
}
|
||||
|
||||
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('ninaChannel').value = cfg.channel ?? 0;
|
||||
document.getElementById('minSeverity').value = cfg.min_severity ?? 'Severe';
|
||||
document.getElementById('pollInterval').value = cfg.poll_interval ?? 300;
|
||||
document.getElementById('resendInterval').value = cfg.resend_interval ?? 3600;
|
||||
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();
|
||||
|
|
@ -92,7 +97,7 @@ function updateStatusBadge(cfg) {
|
|||
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 {
|
||||
badge.className = 'badge bg-secondary';
|
||||
badge.className = 'badge bg-secondary text-white';
|
||||
badge.textContent = 'Deaktiviert';
|
||||
}
|
||||
}
|
||||
|
|
@ -101,11 +106,12 @@ function updateStatusBadge(cfg) {
|
|||
|
||||
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,
|
||||
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
|
||||
min_severity: document.getElementById('minSeverity').value,
|
||||
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,
|
||||
channel: parseInt(document.getElementById('ninaChannel').value) || 0,
|
||||
min_severity: document.getElementById('minSeverity').value,
|
||||
ags_codes: [...agsCodes],
|
||||
sources: {
|
||||
katwarn: document.getElementById('srcKatwarn').checked,
|
||||
|
|
@ -147,10 +153,10 @@ document.getElementById('btnSaveNina').addEventListener('click', async () => {
|
|||
// ── Alerts table ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SEV_CLASS = {
|
||||
Extreme: 'danger',
|
||||
Severe: 'warning',
|
||||
Moderate: 'info',
|
||||
Minor: 'secondary',
|
||||
Extreme: { bg: 'danger', text: 'text-white' },
|
||||
Severe: { bg: 'warning', text: 'text-dark' },
|
||||
Moderate: { bg: 'info', text: 'text-white' },
|
||||
Minor: { bg: 'secondary', text: 'text-white' },
|
||||
};
|
||||
|
||||
const SEV_LABEL = {
|
||||
|
|
@ -164,18 +170,20 @@ const SEV_LABEL = {
|
|||
function renderAlerts() {
|
||||
const tbody = document.getElementById('alertsTable');
|
||||
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;
|
||||
}
|
||||
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 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>
|
||||
<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><small class="text-body-secondary">${escapeHtml(a.id?.split('.')[0] ?? '–')}</small></td>
|
||||
<td class="text-center">${meshIcon}</td>
|
||||
|
|
|
|||
|
|
@ -71,13 +71,19 @@
|
|||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-7">
|
||||
<label for="pollInterval" class="form-label">Abfrageintervall (Sek.)</label>
|
||||
<div class="col-5">
|
||||
<label for="pollInterval" class="form-label">Abfrage­intervall (Sek.)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="pollInterval"
|
||||
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 class="col-5">
|
||||
<label for="resendInterval" class="form-label">Wieder­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>
|
||||
<input type="number" class="form-control form-control-sm" id="ninaChannel"
|
||||
min="0" max="7" value="0">
|
||||
|
|
@ -97,12 +103,22 @@
|
|||
<!-- AGS Codes -->
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
<input type="text" class="form-control" id="agsInput"
|
||||
placeholder="z.B. 091620000000 (München)" maxlength="12">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
|
|
@ -177,7 +193,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<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>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue