Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9268223
docs: specify logging view design (RAM ring buffer, REST, MQTT export…
stritti Jul 31, 2026
97fec13
docs(spec): research-backed MQTT event entity for logging view
stritti Jul 31, 2026
9ebe3cc
docs(spec): reflect LittleFS-only + dashboard button decisions for lo…
stritti Jul 31, 2026
01462e2
docs(spec): add implementation plan for logging view
stritti Jul 31, 2026
06c34bc
feat(logging): add LogCapture ring buffer core with native tests
stritti Jul 31, 2026
3430659
feat(logging): initialize LogCapture during boot
stritti Jul 31, 2026
7eaabac
refactor(logging): migrate Serial debug output to LogCapture
stritti Jul 31, 2026
bb7828f
fix(test): link LogCapture into relay_safety build
stritti Jul 31, 2026
5ecdffd
feat(logging): add REST /api/logs endpoint with filters
stritti Jul 31, 2026
2361c3f
fix(test): align Update mock errorString() with ESP32 API
stritti Jul 31, 2026
53f38d7
feat(logging): export curated log events via MQTT event entity with H…
stritti Jul 31, 2026
539e348
feat(logging): emit curated logEvent markers from mode/pump/wifi/mqtt…
stritti Jul 31, 2026
5b4f7f7
docs(logging): document log event export (MQTT event + REST API)
stritti Jul 31, 2026
0426430
feat(web): add log console view with level filter and clear
stritti Aug 1, 2026
cf949d3
style: fix clang-format violations and markdown lint errors
stritti Aug 1, 2026
ec0aa9a
fix(logging): initialize LogCapture before context.setup()
stritti Aug 1, 2026
a9a988f
fix(web): return last consumed sequence as logs cursor
stritti Aug 1, 2026
ab6fc9d
fix(mqtt): advance export watermark only through snapshot
stritti Aug 1, 2026
2ce3c22
fix(web): poll log console only when visible, cap DOM
stritti Aug 1, 2026
0349701
fix(logging): keep full message on Serial, retry failed MQTT exports,…
stritti Aug 1, 2026
4e32104
test(relay-safety): fix Serial capture statics link and clang-format
stritti Aug 1, 2026
c3cd457
fix(log): reset stale /api/logs cursor after reboot
stritti Aug 1, 2026
85154c3
fix(monitor): route system-monitor messages through LogCapture
stritti Aug 1, 2026
1b8d730
fix(mqtt): serialize log-event export against concurrent callbacks
stritti Aug 1, 2026
2e17b51
style(test): split one-line if statements for clang-format
stritti Aug 1, 2026
b029ce7
fix(log): add boot epoch to distinguish reboot cursors
stritti Aug 1, 2026
db057db
fix(web): serialize log polls and track boot epoch
stritti Aug 1, 2026
edfe4e2
fix(log): randomize boot epoch and honor omitted boot param
stritti Aug 1, 2026
70bb1ae
fix(log): parse boot as unsigned and preserve pre-publisher warnings
stritti Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 118 additions & 5 deletions data/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function switchTab(tabName) {

// Update bottom tab bar active state.
// Tabs under "More" (wifi, mqtt, system, about) keep "more" highlighted.
const moreTabs = ['wifi', 'mqtt', 'system', 'about'];
const moreTabs = ['logs', 'wifi', 'mqtt', 'system', 'about'];
const barTab = moreTabs.includes(tabName) ? 'more' : tabName;
document.querySelectorAll('.tab-bar-item').forEach(item => {
item.classList.toggle('active', item.dataset.tab === barTab);
Expand Down Expand Up @@ -262,10 +262,10 @@ function updateAuthUI() {
const sensorsTabBtn = document.querySelector('.tab-bar-item[data-tab="sensors"]');
if (sensorsTabBtn) sensorsTabBtn.style.display = isAuthenticated ? '' : 'none';

// More menu: hide admin items (wifi, mqtt, system)
// More menu: hide admin items (wifi, mqtt, system, logs)
for (const item of document.querySelectorAll('.more-sheet-item')) {
const text = item.textContent.trim().toLowerCase();
if (text === 'wifi' || text === 'mqtt' || text.startsWith('system') || text.startsWith('🔒')) {
if (text === 'wifi' || text === 'mqtt' || text.startsWith('system') || text.startsWith('🔒') || text.includes('logs')) {
item.style.display = isAuthenticated ? '' : 'none';
}
}
Expand Down Expand Up @@ -294,12 +294,12 @@ function updateAuthUI() {
}
}

// System / WiFi / MQTT / Sensors tabs: fully hide when not authenticated. Never
// System / WiFi / MQTT / Logs / Sensors tabs: fully hide when not authenticated. Never
// force-show here — that previously used `''` (empty string), which falls back
// to the CSS default `display:block`, making the tab visible again on every 2s
// poll regardless of which tab switchTab() had actually activated (the reported
// "always jumps back to WiFi Settings" bug).
for (const id of ['tab-system', 'tab-wifi', 'tab-mqtt']) {
for (const id of ['tab-system', 'tab-wifi', 'tab-mqtt', 'tab-logs']) {
const el = document.getElementById(id);
if (el && !isAuthenticated) el.style.display = 'none';
Comment thread
stritti marked this conversation as resolved.
}
Expand Down Expand Up @@ -1001,9 +1001,122 @@ async function saveSensorMapping() {
}
}

// ── Log Console ──

var lastLogSeq = 0;
var lastLogBoot = 0;
var logLevelFilter = 'info';
// Generation token: bumped on every request, filter change and clear.
// Responses carrying an older token are discarded, so a slow in-flight
// poll cannot append stale/duplicate entries or overwrite lastLogSeq
// after a newer poll, filter switch or clear has happened.
var logReqToken = 0;
// Serializes polls: fetch() responses taking longer than the 2s tick must not
// start a second concurrent poll (whose response would bump the token and
// discard the first one — leaving the console stuck until the next clear).
var logPollInFlight = false;

function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

function loadLogs() {
// Only poll while the Logs tab is actually visible: the unconditional 2s
// timer used to keep appending DOM nodes (and fetching) in hidden tabs,
// growing the document by tens of thousands of nodes per day.
if (document.visibilityState !== 'visible') return;
var logTab = document.getElementById('tab-logs');
if (!logTab || logTab.style.display === 'none') return;

// Never overlap polls: a slow response would otherwise be superseded by the
// next tick's request (token bump) and discarded, stalling the console until
// a clear or filter change. The next tick resumes after this one settles.
if (logPollInFlight) return;

var wasAtBottom, consoleEl = document.getElementById('logConsole');
if (!consoleEl) return;
wasAtBottom = consoleEl.scrollTop + consoleEl.clientHeight >= consoleEl.scrollHeight - 40;
var token = ++logReqToken;
Comment thread
stritti marked this conversation as resolved.
logPollInFlight = true;
// boot = epoch of the cursor: after a reboot the server forces a full dump
// (entries 1..N) even when the new seq is already past our stored cursor.
fetch('/api/logs?since=' + lastLogSeq + '&boot=' + lastLogBoot + '&count=200&level=' + logLevelFilter)
.then(function(res) { return res.json(); })
.then(function(data) {
if (token !== logReqToken) return; // superseded by a newer poll/filter/clear
var empty = document.getElementById('logConsoleEmpty');
// Boot change: the server re-sent the whole new-boot ring, so the old
// pre-reboot lines are stale — drop them instead of appending on top.
if (data.boot !== lastLogBoot) {
consoleEl.textContent = '';
}
if (!data.entries || data.entries.length === 0) {
if (!consoleEl.hasChildNodes()) empty.style.display = 'block';
return;
}
empty.style.display = 'none';
data.entries.forEach(function(entry) {
var line = document.createElement('div');
line.className = 'log-entry log-' + entry.level;
line.textContent = entry.msg;
consoleEl.appendChild(line);
Comment thread
stritti marked this conversation as resolved.
});
// Evict oldest entries beyond the client-side cap so an always-open
// dashboard cannot grow the log DOM without bound.
while (consoleEl.childNodes.length > 500) {
consoleEl.removeChild(consoleEl.firstChild);
}
lastLogSeq = data.next;
lastLogBoot = data.boot;
if (wasAtBottom && data.entries.length > 0) {
consoleEl.scrollTop = consoleEl.scrollHeight;
}
})
.catch(function() { /* silent */ })
.finally(function() {
logPollInFlight = false;
});
}

function clearLogs() {
logReqToken++; // invalidate any in-flight poll — it must not repopulate the console
fetch('/api/logs/clear', { method: 'POST' }).then(function() {
var c = document.getElementById('logConsole');
if (c) c.textContent = '';
var e = document.getElementById('logConsoleEmpty');
if (e) e.style.display = 'block';
lastLogSeq = 0;
});
}

document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.log-chip').forEach(function(chip) {
chip.addEventListener('click', function() {
logReqToken++; // discard in-flight responses from the previous filter
document.querySelectorAll('.log-chip').forEach(function(c) { c.classList.remove('active'); });
this.classList.add('active');
logLevelFilter = this.dataset.level;
lastLogSeq = 0;
var c = document.getElementById('logConsole');
if (c) c.textContent = '';
var e = document.getElementById('logConsoleEmpty');
if (e) e.style.display = 'none';
loadLogs();
});
});
});

var _origUpdateAuthUI = (typeof updateAuthUI === 'function') ? updateAuthUI : function(){};
updateAuthUI = function() {
_origUpdateAuthUI();
var clearBtn = document.getElementById('btnClearLogs');
if (clearBtn) clearBtn.style.display = isAuthenticated ? 'inline-block' : 'none';
};

// ── Init ──

setInterval(loadTelemetry, 2000);
setInterval(loadLogs, 2000);

window.onload = function() {
loadTelemetry();
Expand Down
15 changes: 15 additions & 0 deletions data/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ <h2 style="color: #00e5ff; text-align: center; margin: 0 0 0.25rem; font-size: 1
<div class="more-sheet-overlay" onclick="toggleMoreMenu()"></div>
<div class="more-sheet-panel">
<div class="more-sheet-handle"></div>
<div class="more-sheet-item" onclick="switchTab('logs')">
<span class="more-sheet-icon">📜</span> Logs
</div>
<div class="more-sheet-item" onclick="switchTab('wifi')">
<span class="more-sheet-icon">📶</span> WiFi
</div>
Expand Down Expand Up @@ -510,6 +513,18 @@ <h2>ℹ️ About</h2>
</div>
</div>

<!-- ── Log Console Tab ── -->
<div id="tab-logs" class="tab-content" style="display:none;">
<div style="display:flex; gap:0.5rem; align-items:center; margin-bottom:0.75rem; flex-wrap:wrap;">
<button class="log-chip active" data-level="info">Alle</button>
<button class="log-chip" data-level="warning">Warnungen</button>
<button class="log-chip" data-level="error">Fehler</button>
<button id="btnClearLogs" style="display:none; margin-left:auto; background:rgba(239,68,68,0.1); border:1px solid rgba(239,68,68,0.3); border-radius:8px; padding:0.35rem 0.9rem; font-size:0.75rem; color:#ef4444; cursor:pointer;" onclick="clearLogs()">🗑 Clear</button>
</div>
<div id="logConsole"></div>
<div id="logConsoleEmpty" style="display:none; text-align:center; padding:2rem; color:var(--text-muted); font-size:0.85rem; opacity:0.5;">— No log entries yet —</div>
</div>

<script src="/app.js"></script>

<!-- Service Worker Registration -->
Expand Down
42 changes: 42 additions & 0 deletions data/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,45 @@ input:focus, select:focus {
input, select { font-size: 0.9rem; padding: 0.6rem 0.75rem; }
.input-hint { font-size: 0.65rem; }
}

/* ── Log Console ── */
#tab-logs {
padding-bottom: 70px; /* leaves room for the fixed bottom nav bar */
}
#logConsole {
background: rgba(0,0,0,0.3);
border: 1px solid var(--panel-border);
border-radius: 10px;
padding: 0.75rem;
height: calc(100dvh - 180px);
overflow-y: auto;
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
font-size: 0.78rem;
line-height: 1.55;
word-break: break-all;
}
.log-entry {
padding: 0.15rem 0;
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.log-debug { color: #6b7b8d; }
.log-info { color: var(--text-muted); }
.log-warning { color: #f59e0b; }
.log-error { color: #ef4444; }
.log-critical { color: #ef4444; font-weight: 600; }
.log-chip {
background: rgba(0,0,0,0.2);
border: 1px solid var(--panel-border);
border-radius: 20px;
padding: 0.3rem 0.8rem;
font-size: 0.75rem;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
}
.log-chip.active {
background: rgba(0, 229, 255, 0.15);
border-color: var(--accent-blue);
color: var(--accent-blue);
}
#btnClearLogs { outline: none; }
2 changes: 1 addition & 1 deletion docs/contactor-guide.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ GND ─────────────────────────
```

**Freilaufdioden 1N4007** parallel zu jeder Schützspule:
- Kathode (Strichseite) an **A1 (+)**
- Kathode (Strichseite) an **A1 (+)**
- Anode an **A2 (GND)**

Die Dioden löschen den Spannungsrückschlag (Back-EMF) der Schützspule beim
Expand Down
49 changes: 48 additions & 1 deletion docs/home-assistant/_index.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Home Assistant Integration
summary: Pool Controller Home Assistant Integration — automatische MQTT Discovery Entitäten, Sensor/Switch/Number/Select/Time-Domänen, Lovelace Dashboard YAML, Migration von alten Konfigurationen
date: "2026-06-06"
lastmod: "2026-06-06"
lastmod: "2026-07-31"
draft: false
toc: true
type: docs
Expand Down Expand Up @@ -60,13 +60,60 @@ ergibt `sensor.<prefix>_pool_temperature`). Ersetze `<prefix>` durch deinen Ger
| `text` | `ntp_server` | config | NTP-Server-Adresse |
| `update` | `firmware` | config | Firmware-Update-Entität |
| `climate` | `pool_thermostat` | config | Pool-Thermostat (HVAC-Modus + Zieltemp.) |
| `event` | `logs` | diagnostic | Log-Ereignis-Stream (MQTT-Event-Entität, siehe [Log-Ereignisse](#log-ereignisse)) |

> **Entity-IDs** in HA werden aus dem `name`-Feld der MQTT Discovery generiert. Die entity_id ist
> `sensor.<device_prefix>_pool_temperature` usw. — wobei `<device_prefix>` in der Regel
> `pool_controller` ist (vom Gerätenamen). Prüfe **Entwickler-Tools → Entitäten** und filtere nach
> "pool" um deine IDs zu finden. Ersetze `pool_controller` im Dashboard-YAML durch deinen
> Geräte-Prefix falls er abweicht.

### Log-Ereignisse

Der Controller stellt eine [MQTT-Event-Entität](https://www.home-assistant.io/integrations/event.mqtt/)
(`event.pool_controller_logs` — Object ID `logs`) bereit, die ihren State bei jedem
protokollwürdigen Ereignis aktualisiert: Betriebsartwechsel, Pumpe ein/aus, WiFi-/MQTT-Verbindung
sowie Warning-/Error-Logeinträge. Die vollständige Topic- und Payload-Referenz findest du unter
[MQTT-Konfiguration → Events (Log-Stream)](../mqtt-configuration.de.md#events-log-stream).

Die Event-Entität hält den letzten Ereignistyp im Attribut `event_type` und übernimmt jedes
zusätzliche Payload-Feld als Attribut (z. B. `message`).

#### Logbuch-Automation

Alle Ereignisse ins HA-Logbuch schreiben:

```yaml
automation:
- alias: "Pool Controller — Ereignisse ins Logbuch"
triggers:
- trigger: event.received
target:
entity_id: event.pool_controller_logs
options:
event_type:
- MODE_CHANGED
- PUMP_ON
- PUMP_OFF
- WIFI_CONNECTED
- WIFI_DISCONNECTED
- MQTT_CONNECTED
- MQTT_DISCONNECTED
- LOG_WARN
- LOG_ERROR
actions:
- action: logbook.log
data:
name: "Pool Controller"
message: "{{ state_attr('event.pool_controller_logs', 'event_type') }}"
entity_id: event.pool_controller_logs
```

Der `event.received`-Trigger (Home Assistant 2026.7+) feuert, wenn die Entität einen passenden
Ereignistyp empfängt; siehe [event.received-Trigger-Dokumentation](https://www.home-assistant.io/triggers/event.received/).
Kürze die `event_type`-Liste auf die gewünschten Ereignisse und passe die Entity-ID an deinen
Geräte-Prefix an, falls er abweicht (Entwickler-Tools → Entitäten, Filter "pool").

## Lovelace Dashboard

Eine vorgefertigte Lovelace-Dashboard-Konfiguration liegt in
Expand Down
49 changes: 48 additions & 1 deletion docs/home-assistant/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
title: Home Assistant Integration
summary: Pool Controller Home Assistant integration — automatic MQTT Discovery entities, sensor/switch/number/select/time domains, Lovelace dashboard YAML, migration from legacy configs
date: "2026-06-06"
lastmod: "2026-06-06"
lastmod: "2026-07-31"
draft: false
toc: true
type: docs
Expand Down Expand Up @@ -59,13 +59,60 @@ produces `sensor.<prefix>_pool_temperature`). Replace `<prefix>` with your devic
| `text` | `ntp_server` | config | NTP server address |
| `update` | `firmware` | config | Firmware update entity |
| `climate` | `pool_thermostat` | config | Pool thermostat (HVAC mode + target temp) |
| `event` | `logs` | diagnostic | Log event stream (MQTT event entity, see [Log Events](#log-events)) |

> **Entity IDs** in HA are generated from the MQTT discovery `name` field. The entity_id will be
> `sensor.<device_prefix>_pool_temperature` etc. — where `<device_prefix>` is typically
> `pool_controller` (from the device name). Check **Developer Tools → Entities** and filter by
> "pool" to find your actual IDs. Replace `pool_controller` in the dashboard YAML with your
> device prefix if it differs.

### Log Events

The controller exposes an [MQTT event entity](https://www.home-assistant.io/integrations/event.mqtt/)
(`event.pool_controller_logs` — object ID `logs`) that updates its state whenever a log-worthy
event occurs: mode changes, pump on/off, WiFi/MQTT connectivity, and warning/error log entries.
See [MQTT Configuration → Events (Log stream)](../mqtt-configuration.md#events-log-stream) for the
full topic and payload reference.

The event entity keeps the last event type in its `event_type` attribute and merges every
additional payload field as an attribute (e.g. `message`).

#### Logbook Automation

Write every event to the HA logbook:

```yaml
automation:
- alias: "Pool Controller — log events to logbook"
triggers:
- trigger: event.received
target:
entity_id: event.pool_controller_logs
options:
event_type:
- MODE_CHANGED
- PUMP_ON
- PUMP_OFF
- WIFI_CONNECTED
- WIFI_DISCONNECTED
- MQTT_CONNECTED
- MQTT_DISCONNECTED
- LOG_WARN
- LOG_ERROR
actions:
- action: logbook.log
data:
name: "Pool Controller"
message: "{{ state_attr('event.pool_controller_logs', 'event_type') }}"
entity_id: event.pool_controller_logs
```

The `event.received` trigger (Home Assistant 2026.7+) fires when the entity receives a matching
event type; see the [event received trigger documentation](https://www.home-assistant.io/triggers/event.received/).
Trim the `event_type` list to the events you care about, and adjust the entity ID to your device
prefix if it differs (Developer Tools → Entities, filter by "pool").

## Lovelace Dashboard

A pre-built Lovelace dashboard configuration is provided in [`dashboard.yaml`](dashboard.yaml).
Expand Down
Loading
Loading