From c95df6eb1760e798629e1e7d1e717bac85f69988 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:44:34 +0200 Subject: [PATCH 01/25] build(deps): bump actions/setup-python from 6 to 7 (#167) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/native-tests.yml | 2 +- .github/workflows/pio-dependency-check.yml | 2 +- .github/workflows/plaform.io.yml | 2 +- .github/workflows/platformio-update-check.yml | 2 +- .github/workflows/release.yml | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index daa26aed..8554be60 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -43,7 +43,7 @@ jobs: # Build the project using PlatformIO - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Cache PlatformIO diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index c53a2e94..5c66dbd5 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -44,7 +44,7 @@ jobs: ${{ runner.os }}-pio- - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.x" diff --git a/.github/workflows/pio-dependency-check.yml b/.github/workflows/pio-dependency-check.yml index 2a4c6df9..3d6e5b79 100644 --- a/.github/workflows/pio-dependency-check.yml +++ b/.github/workflows/pio-dependency-check.yml @@ -21,7 +21,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" diff --git a/.github/workflows/plaform.io.yml b/.github/workflows/plaform.io.yml index b3416352..84b95a6d 100644 --- a/.github/workflows/plaform.io.yml +++ b/.github/workflows/plaform.io.yml @@ -57,7 +57,7 @@ jobs: ${{ runner.os }}-pio- - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.x" diff --git a/.github/workflows/platformio-update-check.yml b/.github/workflows/platformio-update-check.yml index 7b14621c..a0c9736e 100644 --- a/.github/workflows/platformio-update-check.yml +++ b/.github/workflows/platformio-update-check.yml @@ -13,7 +13,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.10" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be96db3f..35c9fc31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,7 @@ jobs: ${{ runner.os }}-pio- - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.x" From ea47a3a62ffd3db2d65c79f008ad4bc7ae8d63bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:44:54 +0200 Subject: [PATCH 02/25] build(deps): bump github/codeql-action from 4 to 4.37.3 (#168) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8554be60..fe47cc78 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -37,7 +37,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.3 with: languages: ${{ matrix.language }} @@ -70,7 +70,7 @@ jobs: # The reliable fix is to post-process the SARIF and strip results # under .pio/libdeps/ before uploading. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.3 with: output: sarif-results upload: never @@ -87,6 +87,6 @@ jobs: done - name: Upload CodeQL results - uses: github/codeql-action/upload-sarif@v4 + uses: github/codeql-action/upload-sarif@v4.37.3 with: sarif_file: sarif-results From 93cc7a0c875a2006902e2e8aa625faddaa0201c0 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 2 Aug 2026 15:59:04 +0200 Subject: [PATCH 03/25] feat(logging): add log console view (LogCapture ring buffer, REST /api/logs, MQTT event export) (#169) --- data/web/app.js | 123 ++++- data/web/index.html | 15 + data/web/style.css | 42 ++ docs/contactor-guide.de.md | 2 +- docs/home-assistant/_index.de.md | 49 +- docs/home-assistant/_index.md | 49 +- docs/mqtt-configuration.de.md | 25 +- docs/mqtt-configuration.md | 24 +- docs/software-guide.de.md | 34 +- docs/software-guide.md | 34 +- .../plans/2026-07-31-logging-view.md | 391 ++++++++++++++++ .../specs/2026-07-31-logging-view-design.md | 166 +++++++ platformio.ini | 2 + src/ConfigManager.cpp | 23 +- src/DallasTemperatureNode.cpp | 43 +- src/DegradationManager.cpp | 28 +- src/ESP32TemperatureNode.cpp | 5 +- src/LogCapture.cpp | 233 ++++++++++ src/LogCapture.hpp | 118 +++++ src/MqttPublisher.cpp | 250 +++++++++-- src/MqttPublisher.hpp | 16 + src/NetworkManager.cpp | 37 +- src/Nodes/Logger.cpp | 20 - src/Nodes/Logger.hpp | 53 --- src/NorviButtonHandler.cpp | 15 +- src/NorviOledDisplay.cpp | 17 +- src/OperationModeNode.cpp | 51 ++- src/OtaUpdater.cpp | 67 +-- src/PoolController.cpp | 53 +-- src/RelayModuleNode.cpp | 14 +- src/RuleAuto.cpp | 36 +- src/RuleBoost.cpp | 20 +- src/RuleManu.cpp | 3 +- src/RuleTimer.cpp | 3 +- src/StatusLed.cpp | 19 +- src/SystemMonitor.hpp | 16 +- src/TimeClientHelper.cpp | 5 +- src/Timer.cpp | 3 +- src/WebPortal.cpp | 128 +++++- src/WebPortal.hpp | 12 + src/WpsProvisioner.cpp | 21 +- src/main.cpp | 6 + test/native/CMakeLists.txt | 3 + test/native/mocks/Arduino.h | 9 + test/native/mocks/NetworkManager.cpp | 3 + test/native/mocks/NetworkManager.hpp | 26 +- test/native/mocks/Update.h | 2 +- test/native/mocks/captures.cpp | 5 + test/native/relay_safety/CMakeLists.txt | 1 + test/native/relay_safety/stubs.cpp | 5 + test/native/tests/test_logcapture.cpp | 420 ++++++++++++++++++ test/native/tests/test_main.cpp | 4 + test/native/tests/test_mqttpublisher.cpp | 420 ++++++++++++++++++ test/native/tests/test_webportal_logs.cpp | 226 ++++++++++ 54 files changed, 3045 insertions(+), 350 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-31-logging-view.md create mode 100644 docs/superpowers/specs/2026-07-31-logging-view-design.md create mode 100644 src/LogCapture.cpp create mode 100644 src/LogCapture.hpp delete mode 100644 src/Nodes/Logger.cpp delete mode 100644 src/Nodes/Logger.hpp create mode 100644 test/native/tests/test_logcapture.cpp create mode 100644 test/native/tests/test_webportal_logs.cpp diff --git a/data/web/app.js b/data/web/app.js index 7f556266..1997d9cf 100644 --- a/data/web/app.js +++ b/data/web/app.js @@ -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); @@ -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'; } } @@ -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'; } @@ -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,'&').replace(//g,'>').replace(/"/g,'"'); +} + +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; + 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); + }); + // 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(); diff --git a/data/web/index.html b/data/web/index.html index 1583c2fe..09cccd07 100644 --- a/data/web/index.html +++ b/data/web/index.html @@ -68,6 +68,9 @@

+
+ 📜 Logs +
đŸ“¶ WiFi
@@ -510,6 +513,18 @@

â„č About

+ + + diff --git a/data/web/style.css b/data/web/style.css index fcd2b2a1..62700e84 100644 --- a/data/web/style.css +++ b/data/web/style.css @@ -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; } diff --git a/docs/contactor-guide.de.md b/docs/contactor-guide.de.md index 5cc1b7a3..3298f6ee 100644 --- a/docs/contactor-guide.de.md +++ b/docs/contactor-guide.de.md @@ -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 diff --git a/docs/home-assistant/_index.de.md b/docs/home-assistant/_index.de.md index dc506e81..19061cbd 100644 --- a/docs/home-assistant/_index.de.md +++ b/docs/home-assistant/_index.de.md @@ -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 @@ -60,6 +60,7 @@ ergibt `sensor._pool_temperature`). Ersetze `` 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._pool_temperature` usw. — wobei `` in der Regel @@ -67,6 +68,52 @@ ergibt `sensor._pool_temperature`). Ersetze `` durch deinen Ger > "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 diff --git a/docs/home-assistant/_index.md b/docs/home-assistant/_index.md index 559e541d..679dd7f2 100644 --- a/docs/home-assistant/_index.md +++ b/docs/home-assistant/_index.md @@ -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 @@ -59,6 +59,7 @@ produces `sensor._pool_temperature`). Replace `` 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._pool_temperature` etc. — where `` is typically @@ -66,6 +67,52 @@ produces `sensor._pool_temperature`). Replace `` with your devic > "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). diff --git a/docs/mqtt-configuration.de.md b/docs/mqtt-configuration.de.md index 19c660b1..ffcd9a0d 100644 --- a/docs/mqtt-configuration.de.md +++ b/docs/mqtt-configuration.de.md @@ -2,7 +2,7 @@ title: MQTT-Konfiguration summary: Home Assistant MQTT Discovery Konfiguration, Entity-Referenztabelle und Migration von Homie fĂŒr den ESP32 Pool Controller date: "2026-06-11" -lastmod: "2026-06-11" +lastmod: "2026-07-31" draft: false toc: true type: docs @@ -108,6 +108,29 @@ Der Controller veröffentlicht die folgenden EntitĂ€ten ĂŒber MQTT Discovery, gr | Firmware | `update/firmware-update` | `config` | `homeassistant/update/pool-controller/firmware-update/set` | | Pool-Thermostat | `climate/thermostat` | `config` | (Modus + Temperatur via Climate-Topics) | +### Events (Log-Stream) + +Der Controller veröffentlicht **kuratierte Log-Ereignisse** als HA-`event`-EntitĂ€t +(fĂŒr Automatisierungen und Benachrichtigungen) sowie einen **Roh-Log-Stream** +fĂŒr externe Tools: + +| Funktion | HA-Komponente/Objekt-ID | Entity Category | Topic | +| ------------------ | ----------------------- | --------------- | ---------------------------------------------------------------------- | +| Kuratierte Log-Events | `event/logs` | `diagnostic` | `homeassistant/event/pool-controller/logs/state` (Discovery `.../config`) | + +- **Discovery-Topic**: `homeassistant/event/pool-controller/logs/config` + (`"platform": "event"`) mit `event_types`: + `LOG_WARN`, `LOG_ERROR`, `MODE_CHANGED`, `PUMP_ON`, `PUMP_OFF`, + `WIFI_CONNECTED`, `WIFI_DISCONNECTED`, `MQTT_CONNECTED`, `MQTT_DISCONNECTED` +- **State-Payload** (nur bei Änderung veröffentlicht, per Log-Sequenz dedupliziert): + ```json + {"event_type": "MODE_CHANGED", "message": "Mode switched to auto"} + ``` +- **Roh-Stream** `pool-controller/log` (JSON Lines, nur WARN/ERROR): + ```json + {"seq": 42, "t": 123456, "level": "warning", "msg": "..."} + ``` + ### Entity-Category-Referenz | Kategorie | Beschreibung | diff --git a/docs/mqtt-configuration.md b/docs/mqtt-configuration.md index beedf75c..59ed0a18 100644 --- a/docs/mqtt-configuration.md +++ b/docs/mqtt-configuration.md @@ -2,7 +2,7 @@ title: MQTT Configuration summary: Home Assistant MQTT Discovery configuration, entity reference table, and migration from Homie for the ESP32 Pool Controller date: "2026-06-07" -lastmod: "2026-06-07" +lastmod: "2026-07-31" draft: false toc: true type: docs @@ -108,6 +108,28 @@ The controller publishes the following entities via MQTT Discovery, grouped by t | Firmware | `update/firmware-update` | `config` | `homeassistant/update/pool-controller/firmware-update/set` | | Pool Thermostat | `climate/thermostat` | `config` | (mode + temperature via climate topics) | +### Events (Log stream) + +The controller publishes **curated log events** as an HA `event` entity (for +automations and notifications) plus a **raw log stream** for external tools: + +| Function | HA component/object-id | Entity Category | Topic | +| ------------------ | ---------------------- | --------------- | ---------------------------------------------------------------------- | +| Curated log events | `event/logs` | `diagnostic` | `homeassistant/event/pool-controller/logs/state` (discovery `.../config`) | + +- **Discovery topic**: `homeassistant/event/pool-controller/logs/config` + (`"platform": "event"`) with `event_types`: + `LOG_WARN`, `LOG_ERROR`, `MODE_CHANGED`, `PUMP_ON`, `PUMP_OFF`, + `WIFI_CONNECTED`, `WIFI_DISCONNECTED`, `MQTT_CONNECTED`, `MQTT_DISCONNECTED` +- **State payload** (published on change only, deduplicated by log sequence): + ```json + {"event_type": "MODE_CHANGED", "message": "Mode switched to auto"} + ``` +- **Raw stream** `pool-controller/log` (JSON Lines, WARN/ERROR only): + ```json + {"seq": 42, "t": 123456, "level": "warning", "msg": "..."} + ``` + ### Entity Category Reference | Category | Description | diff --git a/docs/software-guide.de.md b/docs/software-guide.de.md index f14ca08c..19d4db60 100644 --- a/docs/software-guide.de.md +++ b/docs/software-guide.de.md @@ -2,7 +2,7 @@ title: Software-Entwicklung summary: Software-Entwicklungsleitfaden fĂŒr den Pool Controller — PlatformIO Build-Umgebung, Library-AbhĂ€ngigkeiten, REST-API-Referenz, WeboberflĂ€che und Code-Architektur date: "2020-05-28" -lastmod: "2026-06-11" +lastmod: "2026-07-31" draft: false toc: true type: docs @@ -71,6 +71,8 @@ vollstĂ€ndiges Verwaltungs-Dashboard bereitstellt. Er lĂ€uft in zwei Modi: | `GET /api/restart` | Ja | ESP32 neu starten | | `GET /api/factory_reset` | Ja | Konfigurationsdatei löschen, Neustart im AP-Modus | | `POST /api/update` | Ja | OTA-Firmware-Update (signiertes .bin hochladen) | +| `GET /api/logs` | ❌ Nein | Log-EintrĂ€ge aus dem Ringpuffer (`since`/`count`/`level`-Filter) | +| `POST /api/logs/clear` | Ja | Log-Ringpuffer leeren | ### REST-API direkt nutzen @@ -93,6 +95,36 @@ curl -b "session=$SESSION" -X POST \ http:///api/config ``` +#### Log-Ansicht-API + +Der Controller hĂ€lt einen Ringpuffer der letzten Log-EintrĂ€ge und stellt sie ĂŒber REST bereit: + +```bash +# Log-EintrĂ€ge abrufen (keine Authentifizierung nötig) +curl "http:///api/logs?since=0&count=200&level=info" +``` + +| Parameter | Standard | Beschreibung | +| --------- | -------- | ----------------------------------------------------- | +| `since` | `0` | Nur EintrĂ€ge mit `seq` grĂ¶ĂŸer als dieser Wert | +| `count` | `200` | Maximale Anzahl EintrĂ€ge (1–500) | +| `level` | `info` | Mindest-Loglevel: `debug`, `info`, `warning`, `error`, `critical` | + +Antwort: + +```json +{ + "next": 43, + "entries": [ + {"seq": 42, "t": 123456, "level": "warning", "msg": "SAFE MODE — ignoring relay ON request"} + ] +} +``` + +- `next` ist die nĂ€chste Sequenznummer, die als `since` fĂŒr inkrementelles Polling ĂŒbergeben wird +- `t` ist die Betriebszeit des Eintrags in Millisekunden +- `POST /api/logs/clear` (authentifiziert) leert den Puffer und liefert `{"ok": true}` + ### Authentifizierung - Im **AP-Modus** ist die WeboberflĂ€che ungeschĂŒtzt (absichtlich fĂŒr die Einrichtung) diff --git a/docs/software-guide.md b/docs/software-guide.md index 942334c8..10485c67 100644 --- a/docs/software-guide.md +++ b/docs/software-guide.md @@ -2,7 +2,7 @@ title: Software Guide summary: Software development guide for the Pool Controller — PlatformIO build environment, library dependencies, REST API reference, web interface, and code architecture overview date: "2020-05-28" -lastmod: "2020-06-02" +lastmod: "2026-07-31" draft: false toc: true type: docs @@ -71,6 +71,8 @@ management dashboard. It runs in two modes: | `GET /api/restart` | Yes | Reboot the ESP32 | | `GET /api/factory_reset` | Yes | Wipe config file, reboot into AP setup mode | | `POST /api/update` | Yes | OTA firmware update (signed .bin upload) | +| `GET /api/logs` | ❌ No | Ring-buffered log entries (`since`/`count`/`level` filters) | +| `POST /api/logs/clear` | Yes | Clear the log ring buffer | ### Using the REST API Directly @@ -93,6 +95,36 @@ curl -b "session=$SESSION" -X POST \ http:///api/config ``` +#### Log View API + +The controller keeps a ring buffer of recent log entries and exposes them over REST: + +```bash +# Read log entries (no authentication needed) +curl "http:///api/logs?since=0&count=200&level=info" +``` + +| Parameter | Default | Description | +| --------- | ------- | -------------------------------------------------- | +| `since` | `0` | Only entries with `seq` greater than this value | +| `count` | `200` | Maximum number of entries (1–500) | +| `level` | `info` | Minimum log level: `debug`, `info`, `warning`, `error`, `critical` | + +Response: + +```json +{ + "next": 43, + "entries": [ + {"seq": 42, "t": 123456, "level": "warning", "msg": "SAFE MODE — ignoring relay ON request"} + ] +} +``` + +- `next` is the next sequence number to pass as `since` for incremental polling +- `t` is the entry uptime in milliseconds +- `POST /api/logs/clear` (authenticated) empties the buffer and returns `{"ok": true}` + ### Authentication - In **AP mode** the web interface is unprotected (intentional for initial setup) diff --git a/docs/superpowers/plans/2026-07-31-logging-view.md b/docs/superpowers/plans/2026-07-31-logging-view.md new file mode 100644 index 00000000..4f9653d0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-logging-view.md @@ -0,0 +1,391 @@ +# Implementierungsplan: Logging View (Web-Log-Konsole) + +Datum: 2026-07-31 +Basis: `docs/superpowers/specs/2026-07-31-logging-view-design.md` (approved, Ansatz A) +Branch: `fix/relay-r4-solar-pump` +Commit-Historie: `db3a970` (Spec-Update) → `2e40262` (research-backed MQTT event entity) → `0783fd8` (Spec-Initial) + +--- + +## Goal + +Dem Pool-Controller eine zentrale Log-Capture-Architektur geben: + +1. **`LogCapture`** — RAM-Ringbuffer, ersetzt die 253 direkten `Serial.*`-Aufrufe in 21 Dateien. +2. **REST** — unauthentifiziertes `GET /api/logs` (mit `since`-Polling), authentifiziertes `POST /api/logs/clear`. +3. **MQTT** — HA-„event"-Entity-Discovery (research-backed, `event_types`) + Export von WARN/ERROR + kuratierten Events. +4. **UI** — „📜 Logs"-Button auf dem Dashboard (unauthentifizierter Zugriffspfad) + Eintrag im More-MenĂŒ; Konsole mit 2s-Polling, Level-Filtern, Pause-on-Scroll, Auth-Gated-Clear. +5. **Docs** — `mqtt-configuration.md`, `software-guide.md`, Home-Assistant-Sektion (Logbook-Automation-Blueprint). + +**Explizite Vorgaben (User-Entscheidungen, in Spec committed):** +- **LittleFS only** — kein PROGMEM-Fallback fĂŒr die neue View (`WebPortal.cpp:58`: „PROGMEM fallbacks removed"). +- **Platzierung**: Dashboard-Button **und** More-MenĂŒ-Eintrag — Grund: Tab-Bar + More-MenĂŒ sind ohne Login unsichtbar (`app.js:262-263` `tabBar.style.display = isAuthenticated ? '' : 'none'`); der Dashboard-Button ist der einzige unauthentifizierte Einstieg. +- **Kein @librarian-Einsatz** — HA-Recherche ist abgeschlossen und committed (2e40262). Ergebnis: HA-**event**-Component (`platform: event`, `event_types`), kein Sensor/Device-Tracker. + +--- + +## Architektur + +``` +Serial.*-Calls (21 Dateien, 253 Stellen) + │ migriert zu + â–Œ +LogCapture::log(level, fmt, ...) ──â–ș RAM-Ringbuffer (static, kein Heap) + │ (mit LogToSerial-Flag: Mirror auf Serial, Byte-identisch) + ├──â–ș WebPortal::apiGetLogs() GET /api/logs?since=&count=&level= (unauthentifiziert) + │ apiClearLogs() POST /api/logs/clear (handleAuthentication) + └──â–ș MqttPublisher homeassistant/event/.../config (Discovery, platform: event) + homeassistant/event/.../state ({"event_type": ..., "message": ...}) + pool-controller/log (Raw, WARN/ERROR) +``` + +**Design-Entscheidungen:** +- `LogCapture` ist eine static-Klasse (Codebase-Konvention, vgl. `StateManager`, `DegradationManager`, `SystemMonitor`). +- Ringbuffer: statisches `std::array`-artiges C-Array, **kein Heap** (IoT-QualitĂ€tsgate, `cpp-memory-opt`-Skill: keine String-Klassen, keine Allokation in Hot-Paths). +- Thread-Safety: `portMUX_TYPE`-Guard, nur unter `#ifdef ARDUINO` aktiv — in Native-Tests (kein portMUX-Mock) kompiliert er zu no-op. Aufrufer sind Loop-Task + WebServer-Handler (Loop) + MQTT-Callbacks (WiFi-Task) → Guard notwendig. +- Serial-Mirror bleibt **standardmĂ€ĂŸig an** (`Flags::LogToSerial`-Semantik aus Logger-Stub): bestehendes Serial-Debugging (pio monitor) funktioniert unverĂ€ndert weiter; Verifikations-Invariante „Serial-Ausgabe byte-identisch" wird so erfĂŒllbar. +- `LogLevel`-Enum bleibt namensgleich zum Logger-Stub (`Debug=0, Info, Warning, Critical, Error`) — kein Consumer außer `Logger.cpp` selbst (verifiziert: kein Include von `Logger.hpp` in `src/` oder `test/native/`), Stub wird gelöscht. + +--- + +## Tech Stack + +- C++17, PlatformIO/ESP32 (Arduino-Framework), ArduinoJson, WebServer (async-frei, Loop-gepollt). +- Native Tests: CMake (`test/native`), `./build/test_runner` mit ASAN, `test/native/relay_safety` (separater Build). +- CI: `.github/workflows/native-tests.yml` (cmake build → test_runner → relay_safety → lcov → PR-Kommentar). +- Frontend: vanilla HTML/CSS/JS auf LittleFS (`data/web/`), 2s-Polling-Muster analog `loadTelemetry` (`app.js:1005`). + +--- + +## Global Constraints + +1. **Kein Heap in LogCapture** — statischer Ringbuffer, feste Entry-GrĂ¶ĂŸe, `vsnprintf` in Entry-Buffer. +2. **Serial-Ausgabe bleibt byte-identisch** — `LogCapture::log` formatiert exakt das ĂŒbergebene Format (inkl. `\n`) und spiegelt es 1:1 auf Serial. +3. **Keine String-Klasse** in LogCapture — `char[]` + `vsnprintf` (Clean-Code/Heap-Regeln). +4. **Unauthentifizierte Endpoints nur lesend** — `GET /api/logs` (Limit/Cap), Schreibzugriff (`clear`) ausschließlich hinter `handleAuthentication()`. +5. **XSS-Sicherheit in der UI** — Log-Messages sind Fremdtext; im Frontend per `textContent`/Escaping rendern, nie per `innerHTML`. +6. **Native Tests mĂŒssen grĂŒn bleiben** — `test_runner` **und** `relay_safety` (separater Build), ASAN-Optionen wie CI. +7. **Dokumentation EN+DE** — alle Docs-Dateien existieren als `.md` + `.de.md`. +8. **LittleFS-only** — keine PROGMEM-Spiegel fĂŒr neue Assets. +9. Jeder Task endet mit Build + Test + Commit (Conventional Commits, Scope `logging`). + +--- + +## Tasks + +### Task 1 — LogCapture-Kern (Ringbuffer + API) + +**Dateien:** `src/LogCapture.hpp` (neu), `src/LogCapture.cpp` (neu), `src/Nodes/Logger.{hpp,cpp}` (löschen) + +**Schritte:** +1. `src/LogCapture.hpp` anlegen: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include + +namespace PoolController { + +enum class LogLevel : std::uint8_t { Debug = 0, Info, Warning, Critical, Error }; + +struct LogEntry { + std::uint32_t seq; // monoton steigend, fĂŒr since-Polling + std::uint32_t uptimeMs; // millis() zum Zeitpunkt des Eintrags + LogLevel level; + char message[LOG_MSG_SIZE]; +}; + +class LogCapture final { +public: + static constexpr std::size_t LOG_BUFFER_ENTRIES = LOG_BUFFER_SIZE / LOG_MSG_SIZE; + static void begin(); + static void log(LogLevel level, const char *fmt, ...); + static void logEvent(const char *eventType, const char *fmt, ...); + static std::size_t getEntries(std::uint32_t sinceSeq, std::size_t maxCount, + LogLevel minLevel, LogEntry *out, std::size_t outCapacity); + static std::uint32_t lastSeq(); + static void clear(); + static const char *levelName(LogLevel level); + static LogLevel parseLevel(const char *name); // "info"|"warning"|"error" → Level, sonst Info + static bool isLogToSerial(); + static void setLogToSerial(bool enabled); +}; + +} // namespace PoolController + +#define LOG_DEBUG(...) PoolController::LogCapture::log(PoolController::LogLevel::Debug, __VA_ARGS__) +#define LOG_INFO(...) PoolController::LogCapture::log(PoolController::LogLevel::Info, __VA_ARGS__) +#define LOG_WARN(...) PoolController::LogCapture::log(PoolController::LogLevel::Warning, __VA_ARGS__) +#define LOG_ERROR(...) PoolController::LogCapture::log(PoolController::LogLevel::Error, __VA_ARGS__) +``` + +2. `src/LogCapture.cpp`: + - Defaults: `LOG_BUFFER_SIZE` = 8192, `LOG_MSG_SIZE` = 96 (platformio.ini `build_flags`: `-DLOG_BUFFER_SIZE=...` ĂŒberschreibbar — bestehende `build_flags`-Blöcke `platformio.ini:33,56` ergĂ€nzen, Default in Header). + - Statischer Ring: `static LogEntry s_buffer[LOG_BUFFER_ENTRIES];`, `static std::size_t s_head;` (nĂ€chster freier Slot), `static std::uint32_t s_seq;` + - `log()`: `va_list` → `vsnprintf(message, sizeof, fmt, args)` → in Ring schreiben → falls `s_logToSerial`: `Serial.print(entry.message)`. + - `logEvent(eventType, ...)`: Entry mit `level=Info` und `message` = `"[eventType] "` — Marker fĂŒr den MQTT-Export (Task 4), Serial-Mirror identisch. + - `getEntries`: vom Ă€ltesten Eintrag ≄ `sinceSeq` (Ring durchlaufen, seq-Vergleich) bis `maxCount`, filter `>= minLevel`, Kopie nach `out`. + - `lastSeq()`: aktueller `s_seq`-Stand (fĂŒr `next` im REST-Payload). + - `clear()`: Ring leeren, `s_seq` NICHT zurĂŒcksetzen (sonst brechen Polling-Clients), s_head=0. + - Guard: `portMUX_TYPE s_mux;` nur `#ifdef ARDUINO` (`portMUX_INITIALIZER_UNLOCKED`), in `log/getEntries/clear` `portENTER_CRITICAL(&s_mux)` 
 `portEXIT_CRITICAL(&s_mux)`. Native Build (kein portMUX) → no-op ohne `#ifdef`-Zweig. + - `begin()`: `s_head=0; s_seq=0; s_logToSerial=true;` (init Mux). + +3. `src/Nodes/Logger.hpp` + `Logger.cpp` löschen (`git rm`). Kein anderer Referenzpunkt existiert (verifiziert). + +**Test (TDD — Test zuerst schreiben):** +- `test/native/tests/test_logcapture.cpp` (neu), Funktion `run_logcapture_tests()`: + - Ring-Wraparound: `LOG_BUFFER_ENTRIES + 5` EintrĂ€ge loggen → `getEntries(0, 4096, Debug, ...)` liefert letzte `LOG_BUFFER_ENTRIES`, Ă€lteste seq > 0. + - seq-Monotonie: nach N Logs ist `lastSeq() == N`; EintrĂ€ge haben strikt steigende seq. + - since-Filter: `getEntries(sinceSeq=N, ...)` liefert nur seq > N. + - Level-Filter: nur `>= Warning` bei `minLevel=Warning`. + - `clear()`: danach `getEntries(0, ...) == 0`, `lastSeq()` unverĂ€ndert. + - Truncation: Message > `LOG_MSG_SIZE` wird gekappt, kein Overflow (ASAN-frei). + - Kein Heap: statische Buffer (kein dynamisches Verhalten testbar; ASAN/leak-check im CI). + - `logEvent`: Message beginnt mit `[` und enthĂ€lt eventType. +- `test/native/CMakeLists.txt`: `${PROJ_ROOT}/src/LogCapture.cpp` zu `SERVICE_SOURCES` (Zeile 52-59), `tests/test_logcapture.cpp` zu `TEST_SOURCES` (Zeile 69). +- `test/native/tests/test_main.cpp`: `extern int run_logcapture_tests();` (Zeile ~102) + `total += run_logcapture_tests();` (Zeile ~116). + +**Verify:** +```bash +cmake -B build -S . && cmake --build build && ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 ./build/test_runner +cmake -B relay_safety/build -S relay_safety && cmake --build relay_safety/build && ./relay_safety/build/test_relay_safety +pio run # Firmware kompiliert (LogCapture wird in setup() noch nicht benutzt — siehe Task 2) +``` + +**Commit:** `feat(logging): add LogCapture ring buffer core with native tests` + +--- + +### Task 2 — Integration in PoolController-Setup + +**Dateien:** `src/PoolController.cpp` (setup: ~Zeile 268), `platformio.ini` (build_flags 33, 56) + +**Schritte:** +1. `#include "LogCapture.hpp"` ergĂ€nzen. +2. In `setup()` unmittelbar nach `Serial.begin(...)` (vor `StateManager::begin()` Zeile 268): `LogCapture::begin();` — Ring muss vor allen Logging-Seiten stehen. +3. `platformio.ini`: `-DLOG_BUFFER_SIZE=8192` zu den `build_flags`-Blöcken (Zeile 33, 56) hinzufĂŒgen (Default steht im Header; explizit machen fĂŒr OTA-Konsistenz beider Envs). + +**Verify:** `pio run` grĂŒn; `test_runner` + `relay_safety` grĂŒn (keine LogCapture-Nutzung außer begin → keine VerhaltensĂ€nderung). + +**Commit:** `feat(logging): initialize LogCapture during boot` + +--- + +### Task 3 — Serial-Migration (21 Dateien, 253 Stellen) + +**Level-Inferenz-Regeln (mechanisch):** + +| Marker im String | Level | +|---|---| +| `✖`, `ERROR`, `FAIL`, `CRITICAL` | `LOG_ERROR` | +| `⚠`, `WARN`, `WARNING` | `LOG_WARN` | +| `✔`, `✓`, sonst nichts | `LOG_INFO` | +| rein technische Poll-Diagnostik | `LOG_DEBUG` (nur wenn erkennbar Debug-Charakter, z. B. Rohwerte-Dumps in Schleifen) | + +**Mechanik:** +- `Serial.printf("...", args)` → `LOG_INFO("...", args)` (bzw. Level nach Tabelle) — Format inkl. `\n` unverĂ€ndert (Serial-Mirror ist byte-identisch). +- `Serial.println("...")` → `LOG_INFO("...\n")` (println hĂ€ngt `\n` an; Mirror muss identisch sein). +- `Serial.print("...")` (ohne `\n`) → `LOG_INFO("...")`. +- Mehrteilige Chains (`Serial.print(a); Serial.print(b); Serial.println(c);`) → zu **einer** `LOG_*`-Zeile mit kombiniertem Format mergen (gleiche Bytefolge: `"ab" + c + "\n"`), wo trivial; sonst je Fragment eine Zeile beibehalten. +- `Serial.println()` ohne Argument (Leerzeile) → `LOG_INFO("")`. +- `Update.printError(Serial)` in OtaUpdater → `Serial.print(Update.errorString())`-Ersatz via `LOG_ERROR("%s", Update.errorString())` NUR wo die Updater-API es erlaubt (printError ist library-intern auf Serial verdrahtet — **unverĂ€ndert lassen**, kommentieren). + +**Reihenfolge** (klein → groß, jeder Datei-Task einzeln committen; Call-Zahlen verifiziert): +1. `src/Timer.cpp` (1) · 2. `src/RuleTimer.cpp` (1) · 3. `src/RuleManu.cpp` (1) · 4. `src/TimeClientHelper.cpp` (2) · 5. `src/ESP32TemperatureNode.cpp` (2) · + 6. `src/StatusLed.cpp` (5) · 7. `src/RelayModuleNode.cpp` (5) · 8. `src/NorviButtonHandler.cpp` (7) · 9. `src/NorviOledDisplay.cpp` (8) · + 10. `src/WpsProvisioner.cpp` (10) · 11. `src/ConfigManager.cpp` (11) · 12. `src/RuleBoost.cpp` (12) · 13. `src/WebPortal.cpp` (13) · + 14. `src/NetworkManager.cpp` (15) · 15. `src/DegradationManager.cpp` (15) · 16. `src/RuleAuto.cpp` (16) · 17. `src/DallasTemperatureNode.cpp` (20) · + 18. `src/OperationModeNode.cpp` (23) · 19. `src/PoolController.cpp` (26) · 20. `src/MqttPublisher.cpp` (27) · 21. `src/OtaUpdater.cpp` (33) + +**SonderfĂ€lle:** +- `NorviOledDisplay`/`NorviButtonHandler`: Serial nur als Debug-Pfad — Flags/Guard-Bedingungen (z. B. `if (DEBUG)`) beibehalten, nur die Ausgabe ersetzen. +- `DallasTemperatureNode`: Poll-Schleifen-Logging → `LOG_DEBUG` (wĂŒrde sonst Ringbuffer in Sekunden fluten). +- `OperationModeNode`: `✖ UNDEFINED Mode` → `LOG_ERROR`, `⚠ NTP time sync failed` → `LOG_WARN`. +- `MqttPublisher`/`NetworkManager`/`OtaUpdater`/`WebPortal`: kuratierte Events (siehe Task 4, Schritt 3) hier gleichzeitig als `logEvent` statt `log` setzen, wo Mode-/Pump-/Wifi-/MQTT-/OTA-ÜbergĂ€nge geloggt werden (kein zweiter Durchlauf). + +**Per-Task Verify:** +```bash +pio run && cmake -B build -S . && cmake --build build && ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 ./build/test_runner && cmake -B relay_safety/build -S relay_safety && cmake --build relay_safety/build && ./relay_safety/build/test_relay_safety +``` +Invarianz-Check je Datei: `git diff` auf reine Call-Substitution prĂŒfen (keine LogikĂ€nderung). + +**Commit je Datei:** `refactor(logging): migrate Serial calls in to LogCapture` (bis zu 21 Commits; kleine Gruppen ĂĄ 3-4 Dateien erlaubt, wenn diff klar bleibt). + +--- + +### Task 4 — REST-Endpoint `/api/logs` + +**Dateien:** `src/WebPortal.cpp` (setupRoutes:180, apiGetStatus:436 als Muster), `src/WebPortal.hpp` + +**Schritte:** +1. `WebPortal.hpp`: `static void apiGetLogs();` + `static void apiClearLogs();` (private, wie apiGetStatus). +2. `setupRoutes()` ergĂ€nzen (Muster Zeile 193 / 199-208): +```cpp +// Log view — GET unauthenticated (read-only), clear requires login +server_.on("/api/logs", HTTP_GET, apiGetLogs); +server_.on("/api/logs/clear", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiClearLogs(); +}); +``` +3. `apiGetLogs()`: + - Query: `since` (seq, default 0), `count` (default 200, Cap 500), `level` (`parseLevel`, default `Info` — damit Debug nicht im Web landet; `level=debug` explizit möglich). + - JSON: `{"ok":true, "next":, "entries":[{"seq":
,"t":,"level":"info|warning|error","msg":"
"}, 
]}` + - `JsonDocument doc;` + `serializeJson` → `server_.send(200, "application/json", payload)` — exakt wie `apiGetStatus` (Zeile 436ff, Payload-Buffer-Muster). +4. `apiClearLogs()`: `LogCapture::clear();` → `{"ok":true}`. +5. Keine PROGMEM-Anteile (LittleFS-only-Vorgabe). + +**Test (TDD):** +- Neues Muster anlehnen an `test_webportal_json.cpp` (WebServer-Mock fĂ€ngt `send()` via `wsCapture`). Da `apiGetStatus` private ist und der bestehende Test + inline-JSON baut, wird fĂŒr `apiGetLogs` derselbe Weg genutzt: **Test-Hook** — `LogCapture` selbst unit-testen (Task 1) + JSON-Serialisierung ĂŒber einen + **public static Test-Hook** testen, z. B. `WebPortal::buildLogsJson(uint32_t since, size_t count, LogLevel minLevel, char *buf, size_t bufSize) -> size_t` + (public for testing, Muster: „Rate limiting helpers (public for testing)" existiert bereits in `WebPortal.hpp`). `apiGetLogs` ruft den Hook auf und sendet. +- TestfĂ€lle: since-Filter wirkt, count-Cap, Level-Filter, `next` = lastSeq+1, leeres Ergebnis → `entries: []`. +- Registrierung: `test_webportal_json.cpp` erweitern oder neue Testdatei + `test_main.cpp`-Eintrag. + +**Verify:** test_runner + relay_safety grĂŒn; `pio run` grĂŒn. + +**Commit:** `feat(logging): add unauthenticated /api/logs endpoint and authenticated clear` + +--- + +### Task 5 — MQTT-Event-Export + +**Dateien:** `src/MqttPublisher.cpp`/`.hpp`, `src/LogCapture.hpp` (falls Event-Zugriff nötig) + +**Schritte:** +1. `publishEventDiscovery()` — Muster `publishTextDiscovery` (Zeile 212) / `publishNumberDiscovery` (Zeile ~200), TopicBuilder (`homeassistant//pool-controller//config`, `cfgTopic.build("event", objectId, "/config")`): +```cpp +void MqttPublisher::publishEventDiscovery(const char *objectId, const char *name, const char *icon) { + TopicBuilder cfgTopic, stateTopic; + JsonDocument doc; + doc["name"] = name; + doc["unique_id"] = deviceId_ + "_" + objectId; + doc["state_topic"] = stateTopic.build("event", objectId, "/state"); + doc["availability_topic"] = "homeassistant/sensor/pool-controller/availability"; + doc["platform"] = "event"; // research-backed: HA event component + JsonArray eventTypes = doc["event_types"].to(); + eventTypes.add("LOG_WARN"); eventTypes.add("LOG_ERROR"); + eventTypes.add("MODE_CHANGED"); eventTypes.add("PUMP_ON"); eventTypes.add("PUMP_OFF"); + eventTypes.add("WIFI_CONNECTED"); eventTypes.add("WIFI_DISCONNECTED"); + eventTypes.add("MQTT_CONNECTED"); eventTypes.add("MQTT_DISCONNECTED"); + if (icon) doc["icon"] = icon; + addDeviceInfo(doc); + char payloadBuf[1024]; + serializeJson(doc, payloadBuf, sizeof(payloadBuf)); + NetworkManager::publish(cfgTopic.build("event", objectId, "/config"), payloadBuf, true); +} +``` +2. In `publishDiscovery()` (Zeile ~430, nach `isMqttConnected()`-Guard): `publishEventDiscovery("logs", "Pool Controller Logs", "mdi:clipboard-text-outline");` +3. **Export-Pumpe** — in `publishStates()` (wird im Loop 2× aufgerufen): neue EintrĂ€ge seit `s_lastExportedSeq`: + - `LogCapture::logEvent`-Marker `[EVENT]` bzw. die Event-Marker (Mode/Pump/Wifi/MQTT/OTA — in Task 3 als `logEvent` gesetzt) → `{"event_type": "", "message": ""}` auf `stateTopic` (`event`-Component) — **nur** bei Änderung (dedup: gleiche seq nicht erneut). + - WARN/ERROR-EintrĂ€ge ohne Event-Marker → `{"event_type": "LOG_WARN"|"LOG_ERROR", "message": "..."}` auf denselben `stateTopic`. + - **Raw-Topic** `pool-controller/log` (via `getBaseTopic()`): WARN/ERROR als JSON-Lines `{"seq":
,"t":
,"level":
,"msg":
}` fĂŒr externe Tools — mit `s_lastExportedSeq` dedupliziert. + - `s_lastExportedSeq = LogCapture::lastSeq();` am Ende (Volumen-Kontrolle: keine Info/Debug ĂŒber MQTT). +4. **Kuratierte Event-Trigger** (setzen als `logEvent` beim Übergang, Referenz aus Task 3): + - Mode-Wechsel: `OperationModeNode`/`PoolController` (mode set) → `logEvent("MODE_CHANGED", ...)` + - Pumpe: `PoolController::togglePoolPump/toggleSolarPump` → `PUMP_ON`/`PUMP_OFF` + - WLAN: `NetworkManager` connect/disconnect (Zeile ~256/268) → `WIFI_CONNECTED`/`WIFI_DISCONNECTED` + - MQTT: `MqttPublisher::onMqttConnect/onMqttDisconnect` → `MQTT_CONNECTED`/`MQTT_DISCONNECTED` + +**Test (TDD):** +- `test_mqttpublisher.cpp` erweitern (Muster `mqttCapture.published`): + - Nach `begin()`: Discovery-Payload fĂŒr `homeassistant/event/pool-controller/logs/config` enthĂ€lt `"platform":"event"` und `event_types` mit `LOG_WARN`. + - Export-Pumpe: WARN-Entry in LogCapture → nach `publishStates()` ist `{"event_type":"LOG_WARN",...}` auf dem event-state-Topic publiziert; Info-Entry wird NICHT publiziert. + - Dedup: zweiter `publishStates()`-Aufruf ohne neue EintrĂ€ge publiziert nichts Neues. + - `logEvent("MODE_CHANGED", ...)` → `event_type` = `MODE_CHANGED`. +- MqttPublisher ist WRAPPER_SOURCE (wrapper generiert `wrappers/MqttPublisher.cpp` mit `mqttCapture`) — neue Publikationen laufen ĂŒber `NetworkManager::publish` (Mock, `_mqttConnected=true`). + +**Verify:** test_runner + relay_safety grĂŒn; `pio run` grĂŒn. + +**Commit:** `feat(logging): export log events via MQTT event entity with HA discovery` + +--- + +### Task 6 — Web-UI: Dashboard-Button, More-Eintrag, Log-Konsole + +**Dateien:** `data/web/index.html`, `data/web/app.js`, `data/web/style.css` (LittleFS-Deployment via `uploadfs`) + +**Schritte:** +1. `index.html`: + - Dashboard-Header (bei Login-Banner-Zeile ~146): `` — sichtbar **immer** (unauthentifizierter Einstieg). + - More-MenĂŒ (`moreMenu`, Zeile ~67-83): `📜 Logs` vor `wifi`. + - Neuer `
` (Muster `tab-wifi` etc.): + - Kopf: Filter-Chips `Alle` / `Warnungen` / `Fehler` + `Logs löschen`-Button (`hidden` ohne Login). + - `
` (Scroll-Container) + `
`. +2. `app.js`: + - `moreTabs = ['logs', 'wifi', 'mqtt', 'system', 'about']` (Zeile 15) — `logs` wird ĂŒber das More-MenĂŒ erreicht, `barTab`-Mapping greift. + - `switchTab('logs')` zeigt `#tab-logs` (bestehende Mechanik reicht). + - `loadLogs()`: `fetch('/api/logs?since=' + lastSeq + '&count=200&level=' + levelFilter)` → EintrĂ€ge an `#logConsole` anhĂ€ngen (`textContent`-basiert, keine innerHTML mit Fremdtext!), `lastSeq = data.next`; Auto-Scroll ans Ende **nur wenn** User nicht hochgescrollt hat (`scrollTop + clientHeight >= scrollHeight - 40`); relative Zeit (Uptime-ms → `h:mm:ss`). + - `setInterval(loadLogs, 2000)` (parallel zu `loadTelemetry`, Zeile ~1005); beim Tab-Wechsel zu `logs` sofort einmal laden. + - Filter-Chips: Klick setzt `levelFilter` (`info|warning|error`), leert Konsole, `lastSeq=0`, sofort `loadLogs()`. + - Clear-Button: nur sichtbar wenn `isAuthenticated` (`updateAuthUI()`-Erweiterung, Zeile ~225); Klick → `fetch('/api/logs/clear', {method:'POST'})` → Konsole leeren, `lastSeq=0`. + - Escape-Hilfe: `function escapeHtml(s)` (ersetze `<`, `>`, `&`, `"`) — verwenden fĂŒr `msg`. +3. `style.css`: `#logConsole` (monospace, `overflow-y: auto`, max-height), Level-Farben (`.log-debug` grau, `.log-info` default, `.log-warn` amber, `.log-error` rot), Chips, Empty-State. + +**Hinweis:** UI-Arbeit wird beim AusfĂŒhren an `@designer` gegeben (Layout/Feel) — Copy danach vom Orchestrator geprĂŒft. Design-Absicht (Polling, Pause-on-Scroll, Filter, Auth-Gating) bleibt fix. + +**Verify:** `pio run` + `uploadfs`-Deployment auf GerĂ€t (oder lokale Inspektion); manuell: Button ohne Login erreichbar, Konsole fĂŒllt sich, Scroll-Pause, Filter, Clear nur mit Login, XSS-Check (Sonderzeichen in Logs rendern sicher). + +**Commit:** `feat(logging): add web log console with polling, filters and auth-gated clear` + +--- + +### Task 7 — Dokumentation (EN + DE) + +**Dateien:** `docs/mqtt-configuration.md` + `.de.md`, `docs/software-guide.md` + `.de.md`, `docs/home-assistant/_index.md` + `.de.md` + +**Inhalt:** +1. `mqtt-configuration.*`: neues Topic `homeassistant/event/pool-controller/logs/config` (Discovery, `platform: event`, `event_types`-Liste), State-Topic, Raw-Topic `pool-controller/log` (JSON-Lines, WARN/ERROR), Beispiel-Payload. +2. `software-guide.*`: REST-API `GET /api/logs` (Parameter `since`/`count`/`level`, Beispiel-Response), `POST /api/logs/clear` (Auth), Web-Log-Konsole (Platzierung: Dashboard-Button + More-MenĂŒ, Filter, Auto-Polling). +3. `home-assistant/_index.*`: **Logbook-Automation-Blueprint** (dokumentiert, nicht im Firmware) — YAML-Snippet, das `event`-Entity-Events in das HA-Logbook schreibt (Trigger `event`, Bedingung event_type in Liste, `logbook.log`-Action). + +**Verify:** Markdown-Validierung (Dateien existieren EN+DE, Links konsistent), kein Build-Einfluss. + +**Commit:** `docs(logging): document log view API, MQTT event entity and HA logbook blueprint` + +--- + +### Task 8 — Gesamt-Verifikation & Review + +**Schritte:** +1. VollstĂ€ndiger CI-Lauf lokal: `pio run` + `test_runner` (ASAN) + `relay_safety`. +2. Serial-Invarianz-Stichprobe: vor/nach Diff der formatierten Strings (kein Zeichenverlust, `\n`-Semantik println→`\n`). +3. Review durch @oracle (Risiko: Ringbuffer-Corner-Cases, Thread-Safety, XSS, MQTT-Volumen) — optional, wenn Task 1-6 ohne AuffĂ€lligkeiten. +4. Finale Commit-Historie prĂŒfen (Conventional Commits, Scope `logging`). +5. Deployment-Vorbereitung: `pio run`-Binary fĂŒr `uploadfs` + OTA (Deploy-Skill folgt separat, nicht Teil dieses Plans). + +**Commit:** ggf. `fix(logging): ...` fĂŒr Review-FundstĂŒcke. + +--- + +## Selbst-Review gegen Spec + +| Spec-Anforderung | Plan-Abdeckung | +|---|---| +| Ansatz A: zentrale Capture + Migration | Task 1 + 3 (253 Calls / 21 Dateien, verifizierte Zahlen) | +| RAM-Ringbuffer, kein Heap | Task 1 (statisch, vsnprintf, IoT-Gates) | +| Unauthentifiziertes `/api/logs` (Polling `since`) | Task 4 (since/count/level, Cap 500) | +| Clear nur mit Login | Task 4 (`handleAuthentication`) | +| MQTT-Export WARN/ERROR + kuratierte Events | Task 5 (event-Entity + Raw-Topic, dedup via lastExportedSeq) | +| HA-Discovery research-backed (event-Typ) | Task 5 (committed Ergebnis 2e40262, `platform: event`) | +| UI: Konsole, 2s-Polling, Filter, Pause-on-Scroll | Task 6 (Polling-Muster wie loadTelemetry) | +| Platzierung: Dashboard-Button + More-MenĂŒ | Task 6 (Dashboard = unauthentifizierter Einstieg, More fĂŒr eingeloggte) | +| LittleFS-only, kein PROGMEM | Task 4/6 (keine PROGMEM-Anteile) | +| Docs EN+DE | Task 7 (alle 3 Docs-Paare) | +| Native Tests + relay_safety grĂŒn | jeder Task (Verify-Block) | +| Ohne @librarian | eingehalten — Recherche committed, keine externe Recherche nötig | + +## Risiken / Offene Punkte + +1. **Ringbuffer-Volumen**: Poll-Schleifen-Logging (DallasTemperature) muss auf `LOG_DEBUG` — sonst ist der 8KB-Ring in Sekunden ĂŒberschrieben und die Web-Konsole zeigt nur Hot-Path-Logs. Task 3 SonderfĂ€lle deckt das ab; Review-Schritt 8.3 validiert. +2. **`Update.printError(Serial)`** bleibt serial-gebunden (library-intern) — Web/MQTT sehen OTA-Fehler nur ĂŒber den kuratierten `OTA_FAILED`-Event (Task 3, SonderfĂ€lle). +3. **Event-Dedup ĂŒber Reconnect**: `s_lastExportedSeq` ist RAM-only — nach Reboot werden Ă€ltere seqs nicht re-exportiert (gewollt; MQTT-retained nur Discovery, nicht Events). +4. **XSS**: Log-Console rendert nur via `textContent`/`escapeHtml` — Fremdtexte (Mode-Namen, WLAN-SSIDs) sind potenzielle Payloads. diff --git a/docs/superpowers/specs/2026-07-31-logging-view-design.md b/docs/superpowers/specs/2026-07-31-logging-view-design.md new file mode 100644 index 00000000..b2d6a240 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-logging-view-design.md @@ -0,0 +1,166 @@ +# Design: Logging View for Pool Controller + +**Date:** 2026-07-31 +**Status:** Approved (brainstorming) +**Scope:** New "Logs" view in the web UI with RAM ring buffer log capture, REST endpoint, MQTT export, and UI console. + +## Motivation + +The pool controller currently logs exclusively to the serial interface (253 +`Serial.printf/print/println` calls across 21 files). There is no way to inspect +logs from the web UI, which makes remote debugging difficult on a 24/7 device. + +This feature adds a web-based logging view: the device captures log lines into a +RAM ring buffer, serves them via an unauthenticated read-only REST endpoint, and +optionally exports warnings/errors to MQTT (Home Assistant). The UI shows the log +as a console with level filters, auto-refresh, and auto-scroll. + +## Requirements (from brainstorming) + +1. **Content:** Full system log + filtered event view (level/category filters). +2. **Persistence:** RAM ring buffer only (no flash wear). Optional MQTT export for + warnings/errors + curated events so logs survive reboot in Home Assistant. +3. **Placement:** "📜 Logs" button on the dashboard plus an entry in the More + bottom-sheet menu (not a bottom tab). The dashboard button is the unauthenticated + access path — the tab bar and More menu are hidden entirely without login. +4. **Access:** Read-only without login (like dashboard telemetry); clear operation + requires login. +5. **Updates:** Auto-polling every 2 s while the tab is visible (same pattern as + dashboard telemetry). + +## Approach (chosen: A — central capture + migration) + +Full migration of all `Serial.printf/print/println` calls to a central logging API +that continues to write to Serial (behavior unchanged) and additionally appends to +the ring buffer. + +## Architecture + +### 1. LogCapture module (`src/LogCapture.{hpp,cpp}`) + +Replaces the current stub `src/Nodes/Logger.{hpp,cpp}`. + +- **Static ring buffer**: fixed size (default 8 KB, configurable via build flag + `-DLOG_BUFFER_SIZE=`). No heap allocation per log line → no fragmentation. + Estimated ~80–100 bytes/entry → ~80–100 lines in the buffer. +- **Entry format**: `{seq, level, uptime_ms, msg}`. + - `seq`: monotonically increasing sequence number (uint32) for incremental polling. + - `level`: enum `Debug, Info, Warning, Critical, Error` (reuse existing + `Logger::LogLevel` enum names). + - `uptime_ms`: milliseconds since boot (survives in the entry; absolute time is + derived in the UI when NTP is synced). + - `msg`: null-terminated formatted string. +- **API**: + - `LogCapture::log(Level level, const char *fmt, ...)` — variadic snprintf into a + static buffer, write to Serial AND append to ring buffer. + - `LogCapture::begin()` — initialize (called from `setup()`). + - `LogCapture::getEntries(uint32_t since, size_t maxCount, Level minLevel, ...)` + — returns entries after `since`, capped, filtered by level (for REST handler). + - `LogCapture::clear()` — reset ring buffer. +- **Thread safety**: short critical section (portMUX or mutex) — writes come from + both the WebServer task and the main loop task. +- **Level filtering at capture time**: configurable; DEBUG included by default at + build, standard capture from INFO up (configurable via build flag if needed). +- Remove the stub `Nodes/Logger` (or fold its enum/flags into `LogCapture`). + +### 2. Log capture: migration of 276 Serial calls + +- New macros `LOG_DEBUG/LOG_INFO/LOG_WARN/LOG_ERROR(...)` that expand to + `LogCapture::log(Level::..., __VA_ARGS__)`. +- **Mechanical migration** via ast-grep: + - `Serial.printf("...", ...)` → `LOG_INFO("...", ...)` (level inferred from + message content: "WARNING"/"ERROR"/"CRITICAL" prefixes → respective level, + else INFO). + - Multi-part `Serial.print` chains: combine into a single `LOG_*` call where + mechanical; remaining special cases (e.g. `Update.printError(Serial)`, + library-internal prints) handled manually. +- **Invariant:** serial output stays byte-identical after migration. Verify by + comparing serial output before/after on representative paths. + +### 3. REST API (`WebPortal.cpp`) + +- `GET /api/logs?level=INFO&count=200&since=` — **no auth** (read-only). + - Response: `{"entries": [{"seq": 42, "level": "INFO", "uptime": 3610, "msg": "..."}], "next_seq": 43}` + - `level` ∈ {DEBUG, INFO, WARN, ERROR} (server-side filter; WARN includes + Warning+Critical+Error, ERROR includes Error). + - `count` default 200, max 500. +- `POST /api/logs/clear` — **auth required** (clears ring buffer; for debugging). + +### 4. MQTT export (`MqttPublisher`) + +Follow existing HA Discovery patterns (`publishTextDiscovery`, `getBaseTopic`). +Research-backed format (official `event.mqtt` docs + HA Core sources): the HA +**MQTT event entity** is the idiomatic representation for discrete device +events; a text/sensor entity would only show a "last line" without history. + +- **Raw topic** `pool/log` (JSON lines, QoS 0, non-retained) — for external + tools. Payload: `{"level":"WARN","uptime":3610,"msg":"..."}`. +- **HA event entity "Pool Controller Event"** via MQTT Discovery + (`platform: event`) on topic `pool/event`: + - Discovery topic `homeassistant/event//pool_controller_event/config` + (retained), payload `{"platform":"event","name":"Pool Controller Event", + "unique_id":"pool_controller_event","state_topic":"pool/event", + "event_types":["mode_changed","pump_on","pump_off","wifi_connected", + "wifi_disconnected","mqtt_connected","mqtt_disconnected","ota_started", + "ota_success","ota_failed","factory_reset","warning","error"], + "entity_category":"diagnostic","device":{...same block as sensors...}}`. + - State payload (JSON, **must** contain `event_type` ∈ `event_types`): + `{"event_type":"pump_on","message":"Pump turned on","level":"INFO","uptime":3610}` + — extra keys (`message`, `level`, `uptime`) become entity attributes. + Events are stateless; replayed retained messages are discarded by HA. + - Separate `pool/event` topic (instead of reusing `pool/log`) keeps the + event_type always present without a fragile `value_template` on the raw log + stream. The `value_template` is intentionally omitted. +- **Volume control:** only WARN/ERROR + curated events (mode changes, pump + toggles, WiFi/MQTT connect/disconnect, OTA, factory reset) are published to + MQTT — not every INFO line. +- **Logbook (documented, not firmware):** event entities do *not* create + message-bearing logbook entries (`event_type: LOGBOOK_ENTRY` is a known + misconception). Users who want pool events in the HA logbook get a documented + automation blueprint: MQTT trigger on `pool/event` → `logbook.log` service + with `name`, `message: "{{ trigger.payload_json.event_type }}: {{ ... }}"` + and `entity_id`. This stays in docs, never in firmware. +- **ESPHome precedent (documented):** ESPHome forwards device logs into the HA + core log (default level WARNING, no MQTT equivalent of log_stream); our event + entity is the MQTT-only analog. + +### 5. Web UI (`data/web/index.html`, `data/web/app.js`, `data/web/style.css`) + +- **More menu**: new entry `📜 Logs` → `switchTab('logs')`. +- **Tab** `#tab-logs`: + - Console view: monospace, level colors (Info blue, Warn yellow, Error red), + auto-scroll, pause on manual scroll-up. + - Filter chips: All / Info / Warn / Error (frontend filter on loaded entries). + - Auto-polling every 2 s via `/api/logs` with `since` while tab visible; stop + when hidden (same pattern as `loadTelemetry`). + - Timestamps: relative (`+1h 02m 33s` since boot); absolute time when NTP synced + (compare with `/api/status` `local_time`). + - "Clear" button — only visible when authenticated. +- **LittleFS only**: web assets are served from LittleFS exclusively — + `WebPortal.cpp:58` documents "PROGMEM fallbacks removed". No PROGMEM mirror + for the new view (previous plan draft's fallback is obsolete by decision). + +## Error handling + +- Ring buffer overflow: oldest entries overwritten (ring semantics); API always + returns entries + `next_seq` so the client can resume. +- Reboot: logs lost (RAM-only by design); MQTT export compensates partially. +- WebServer task vs loop task: single critical section guards append. +- JSON serialization: use existing `JsonDocument` pattern; cap response size. + +## Testing + +- Unit tests for ring buffer: wrap-around, `since` pagination, level filter + (existing test setup, native build). +- Migration verification: firmware builds; serial output on representative paths + (boot, mode change, MQTT connect) is byte-identical before/after. +- Manual: `pio run --target uploadfs` + browser; verify tab in LittleFS mode + (the only mode — no PROGMEM fallback exists). +- MQTT: mosquitto subscribe `pool/log`, HA sensor appears via discovery. + +## Out of scope + +- Persistent flash logging (rejected: flash wear on 24/7 device). +- Real-time streaming (SSE/WebSocket) — 2 s polling is sufficient. +- Log download/export to file in UI. +- Per-module log level configuration at runtime. diff --git a/platformio.ini b/platformio.ini index 6242481a..861dce9a 100644 --- a/platformio.ini +++ b/platformio.ini @@ -36,6 +36,7 @@ build_flags = '-D GITHUB_REPO="smart-swimmingpool/pool-controller"' -D SERIAL_SPEED=${common.serial_speed} -D NORVI_AE01_R + -D LOG_BUFFER_SIZE=8192 -std=c++17 -Wno-deprecated-declarations build_unflags = -Werror=reorder @@ -58,6 +59,7 @@ build_flags = '-D FW_VERSION="4.2.1"' # x-release-please-version '-D GITHUB_REPO="smart-swimmingpool/pool-controller"' -D SERIAL_SPEED=${common.serial_speed} + -D LOG_BUFFER_SIZE=8192 -std=c++17 -Wno-deprecated-declarations ; Suppress deprecation warnings from libraries build_unflags = -Werror=reorder diff --git a/src/ConfigManager.cpp b/src/ConfigManager.cpp index c5896aaa..63c90930 100644 --- a/src/ConfigManager.cpp +++ b/src/ConfigManager.cpp @@ -9,6 +9,7 @@ #include "ConfigManager.hpp" #include "Version.h" +#include "LogCapture.hpp" #include #include @@ -71,14 +72,14 @@ static constexpr const char *kCfgConfigured = "cfg_configured"; // ── Lifecycle ── bool ConfigManager::begin() { - Serial.println("✓ NVS config namespace opened"); + LOG_INFO("✓ NVS config namespace opened\n"); return load(); } bool ConfigManager::load() { Preferences prefs; if (!prefs.begin(kNvsNamespace, true)) { // read-only mode - Serial.println("✖ Failed to open NVS config namespace"); + LOG_ERROR("✖ Failed to open NVS config namespace\n"); reset(); return false; } @@ -111,14 +112,14 @@ bool ConfigManager::load() { prefs.end(); - Serial.println("✓ Configuration loaded from NVS"); + LOG_INFO("✓ Configuration loaded from NVS\n"); return true; } bool ConfigManager::save() { Preferences prefs; if (!prefs.begin(kNvsNamespace, false)) { // read-write mode - Serial.println("✖ Failed to open NVS config namespace for writing"); + LOG_ERROR("✖ Failed to open NVS config namespace for writing\n"); return false; } @@ -150,7 +151,7 @@ bool ConfigManager::save() { prefs.end(); - Serial.println("✓ Configuration saved to NVS"); + LOG_INFO("✓ Configuration saved to NVS\n"); return true; } @@ -169,7 +170,7 @@ void ConfigManager::reset() { adminPasswordHash_ = kDefaultPasswordHash; // Reset to default "admin" password configured_ = false; - Serial.println("✓ Configuration reset to factory defaults"); + LOG_INFO("✓ Configuration reset to factory defaults\n"); } // ── Boot Version Tracking ── @@ -183,16 +184,16 @@ void ConfigManager::logOtaTransition() { if (previousVersion.isEmpty()) { // First boot ever — nothing to compare - Serial.printf("â„č First boot — firmware version %s\n", runningVersion.c_str()); + LOG_INFO("â„č First boot — firmware version %s\n", runningVersion.c_str()); } else if (previousVersion != runningVersion) { // Version changed — OTA update just happened - Serial.printf("◉ OTA UPDATE DETECTED: %s → %s\n", previousVersion.c_str(), runningVersion.c_str()); + LOG_INFO("◉ OTA UPDATE DETECTED: %s → %s\n", previousVersion.c_str(), runningVersion.c_str()); // Update stored version to match running version prefs.putString("fw_version", runningVersion); } else { // Normal boot — same version - Serial.printf("â„č Normal boot — firmware %s (no OTA change)\n", runningVersion.c_str()); + LOG_INFO("â„č Normal boot — firmware %s (no OTA change)\n", runningVersion.c_str()); } prefs.end(); @@ -220,10 +221,10 @@ void ConfigManager::saveSensorMapping(const uint8_t solarAddr[8], const uint8_t char buf[17]; snprintf(buf, sizeof(buf), "%02X%02X%02X%02X%02X%02X%02X%02X", solarAddr[0], solarAddr[1], solarAddr[2], solarAddr[3], solarAddr[4], solarAddr[5], solarAddr[6], solarAddr[7]); - Serial.printf("✓ Sensor mapping saved: Solar [%s]", buf); + LOG_INFO("✓ Sensor mapping saved: Solar [%s]", buf); snprintf(buf, sizeof(buf), "%02X%02X%02X%02X%02X%02X%02X%02X", poolAddr[0], poolAddr[1], poolAddr[2], poolAddr[3], poolAddr[4], poolAddr[5], poolAddr[6], poolAddr[7]); - Serial.printf(", Pool [%s]\n", buf); + LOG_INFO(", Pool [%s]\n", buf); } bool ConfigManager::loadSensorMapping(uint8_t solarAddr[8], uint8_t poolAddr[8]) { diff --git a/src/DallasTemperatureNode.cpp b/src/DallasTemperatureNode.cpp index 06a675c3..14f88eb9 100644 --- a/src/DallasTemperatureNode.cpp +++ b/src/DallasTemperatureNode.cpp @@ -13,6 +13,7 @@ #include "SystemMonitor.hpp" #include "DegradationManager.hpp" #include "Utils.hpp" +#include "LogCapture.hpp" // ── Dedicated bus constructor ────────────────────────────────────────────── @@ -88,14 +89,14 @@ bool DallasTemperatureNode::resolveFilter() { _sensorFound = true; char adr[18]; address2String(addr, adr, sizeof(adr)); - Serial.printf(" ◩ %s: filter resolved → device %d [%s] ✓\n", _id, i, adr); + LOG_INFO(" ◩ %s: filter resolved → device %d [%s] ✓\n", _id, i, adr); return true; } } } // Filter address not found on bus — fall back to deviceIndex_ - Serial.printf(" ✖ %s: filter address not found on bus! " - "Falling back to device index %d\n", + LOG_ERROR(" ✖ %s: filter address not found on bus! " + "Falling back to device index %d\n", _id, deviceIndex_); if (activeSensor->getAddress(deviceAddress_, deviceIndex_)) { _sensorFound = true; @@ -111,11 +112,11 @@ bool DallasTemperatureNode::resolveFilter() { _sensorFound = true; char adr[18]; address2String(deviceAddress_, adr, sizeof(adr)); - Serial.printf(" ◩ %s: no filter → device %d [%s]", _id, deviceIndex_, adr); if (sharedSensor_) { - Serial.print(" (shared bus)"); + LOG_INFO(" ◩ %s: no filter → device %d [%s] (shared bus)\n", _id, deviceIndex_, adr); + } else { + LOG_INFO(" ◩ %s: no filter → device %d [%s]\n", _id, deviceIndex_, adr); } - Serial.println(); return true; } } @@ -160,11 +161,11 @@ void DallasTemperatureNode::begin() { // Shared mode: sensor->begin() was already called externally — just scan numberOfDevices = activeSensor->getDeviceCount(); - Serial.printf("‱ DallasTemperature: Parasite power is: %d\n", activeSensor->isParasitePowerMode()); + LOG_INFO("‱ DallasTemperature: Parasite power is: %d\n", activeSensor->isParasitePowerMode()); if (numberOfDevices > 0) { uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; - Serial.printf(" ◩ %d devices found on PIN %d\n", numberOfDevices, displayPin); + LOG_INFO(" ◩ %d devices found on PIN %d\n", numberOfDevices, displayPin); // ── Print all detected devices ────────────────────────────────────── for (uint8_t i = 0; i < numberOfDevices; i++) { @@ -172,7 +173,7 @@ void DallasTemperatureNode::begin() { if (activeSensor->getAddress(addr, i)) { char adr[18]; address2String(addr, adr, sizeof(adr)); - Serial.printf(" ◩ PIN %d: Device %d address: %s\n", displayPin, i, adr); + LOG_INFO(" ◩ PIN %d: Device %d address: %s\n", displayPin, i, adr); } } @@ -188,7 +189,7 @@ void DallasTemperatureNode::begin() { } } else { uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; - Serial.printf("✖ No Dallas sensors found on pin %d\n", displayPin); + LOG_ERROR("✖ No Dallas sensors found on pin %d\n", displayPin); _sensorFound = false; PoolController::DegradationManager::reportSensorStatus(_id, false); } @@ -205,7 +206,7 @@ void DallasTemperatureNode::loop() { // ── Shared bus mode ──────────────────────────────────────────────── // The master (deviceIndex 0) drives the conversion for all sensors. if (isBusMaster_) { - Serial.printf("ă€œ Reading Dallas sensors (shared bus)\n"); + LOG_DEBUG("ă€œ Reading Dallas sensors (shared bus)\n"); PoolController::SystemMonitor::feedWatchdog(); activeSensor->requestTemperatures(); @@ -214,7 +215,7 @@ void DallasTemperatureNode::loop() { // Master reads its own sensor float newTemp = activeSensor->getTempC(deviceAddress_); if (newTemp == DEVICE_DISCONNECTED_C) { - Serial.println(" ✖ Solar sensor disconnected - setting to NaN"); + LOG_ERROR(" ✖ Solar sensor disconnected - setting to NaN\n"); _temperature = NAN; _sensorFound = false; PoolController::DegradationManager::reportSensorStatus(_id, false); @@ -222,13 +223,13 @@ void DallasTemperatureNode::loop() { _temperature = newTemp; _sensorFound = true; PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◩ Solar Temp = %.1f°C\n", _temperature); + LOG_DEBUG(" ◩ Solar Temp = %.1f°C\n", _temperature); } } else { // Slave: read from the conversion the master already triggered float newTemp = activeSensor->getTempC(deviceAddress_); if (newTemp == DEVICE_DISCONNECTED_C) { - Serial.println(" ✖ Pool sensor disconnected - setting to NaN"); + LOG_ERROR(" ✖ Pool sensor disconnected - setting to NaN\n"); _temperature = NAN; _sensorFound = false; PoolController::DegradationManager::reportSensorStatus(_id, false); @@ -236,12 +237,12 @@ void DallasTemperatureNode::loop() { _temperature = newTemp; _sensorFound = true; PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◩ Pool Temp = %.1f°C\n", _temperature); + LOG_DEBUG(" ◩ Pool Temp = %.1f°C\n", _temperature); } } } else if (numberOfDevices > 0) { // ── Dedicated bus mode (standard) ────────────────────────────────── - Serial.printf("ă€œ Reading Dallas sensor: %s\n", _id); + LOG_DEBUG("ă€œ Reading Dallas sensor: %s\n", _id); PoolController::SystemMonitor::feedWatchdog(); activeSensor->requestTemperatures(); @@ -252,7 +253,7 @@ void DallasTemperatureNode::loop() { if (activeSensor->getAddress(tempDeviceAddress, i)) { float newTemp = activeSensor->getTempC(tempDeviceAddress); if (newTemp == DEVICE_DISCONNECTED_C) { - Serial.println(" ✖ Sensor disconnected - setting to NaN"); + LOG_ERROR(" ✖ Sensor disconnected - setting to NaN\n"); _temperature = NAN; _sensorFound = false; PoolController::DegradationManager::reportSensorStatus(_id, false); @@ -260,13 +261,13 @@ void DallasTemperatureNode::loop() { _temperature = newTemp; _sensorFound = true; PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◩ Temp = %.1f°C\n", _temperature); + LOG_DEBUG(" ◩ Temp = %.1f°C\n", _temperature); } } } } else { // ── No sensor found — rescan ────────────────────────────────────── - Serial.println("No Sensor found on bus! Rescanning..."); + LOG_INFO("No Sensor found on bus! Rescanning...\n"); PoolController::DegradationManager::reportSensorStatus(_id, false); if (sharedSensor_) { @@ -277,13 +278,13 @@ void DallasTemperatureNode::loop() { activeSensor->getAddress(deviceAddress_, deviceIndex_); _sensorFound = true; PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◩ %d device(s) found after rescan\n", numberOfDevices); + LOG_INFO(" ◩ %d device(s) found after rescan\n", numberOfDevices); } } else { activeSensor->begin(); numberOfDevices = activeSensor->getDeviceCount(); if (numberOfDevices > 0) { - Serial.printf(" ◩ %d device(s) found after rescan\n", numberOfDevices); + LOG_INFO(" ◩ %d device(s) found after rescan\n", numberOfDevices); _sensorFound = true; } } diff --git a/src/DegradationManager.cpp b/src/DegradationManager.cpp index bd8c8d02..9d02df64 100644 --- a/src/DegradationManager.cpp +++ b/src/DegradationManager.cpp @@ -9,6 +9,7 @@ */ #include "DegradationManager.hpp" +#include "LogCapture.hpp" #include "NetworkManager.hpp" #include "Nodes.hpp" #include "SystemMonitor.hpp" @@ -43,7 +44,7 @@ void DegradationManager::begin() { forcedSafeMode_ = false; lastEvaluationMs_ = 0; - Serial.println(F("✓ DegradationManager initialized")); + LOG_INFO("✓ DegradationManager initialized\n"); } void DegradationManager::evaluate() { @@ -133,35 +134,32 @@ void DegradationManager::unforceSafeMode() { void DegradationManager::onTransition() { // Log the transition - Serial.print(F("⚙ Degradation: ")); - Serial.print(levelToString(previousLevel_)); - Serial.print(F(" → ")); - Serial.println(levelToString(currentLevel_)); + LOG_INFO("⚙ Degradation: %s → %s\n", levelToString(previousLevel_), levelToString(currentLevel_)); // Additional per-level actions switch (currentLevel_) { case DegradationLevel::NORMAL: - Serial.println(F("✓ All systems nominal")); + LOG_INFO("✓ All systems nominal\n"); break; case DegradationLevel::NO_WIFI: - Serial.println(F("⚠ WiFi/MQTT disconnected — operating offline")); - Serial.println(F(" All control rules still active")); + LOG_WARN("⚠ WiFi/MQTT disconnected — operating offline\n"); + LOG_WARN(" All control rules still active\n"); break; case DegradationLevel::NO_TIME: - Serial.println(F("⚠ NTP time sync lost — timer scheduling degraded")); - Serial.println(F(" Timer mode falls back to auto mode")); + LOG_WARN("⚠ NTP time sync lost — timer scheduling degraded\n"); + LOG_WARN(" Timer mode falls back to auto mode\n"); break; case DegradationLevel::NO_SENSOR: - Serial.println(F("⚠ Temperature sensor fault — using cautious defaults")); - Serial.println(F(" Auto mode may not function correctly")); + LOG_WARN("⚠ Temperature sensor fault — using cautious defaults\n"); + LOG_WARN(" Auto mode may not function correctly\n"); break; case DegradationLevel::CRITICAL: - Serial.println(F("✖ CRITICAL: Multiple system failures detected!")); - Serial.println(F(" Entering safe mode — all relays off")); + LOG_ERROR("✖ CRITICAL: Multiple system failures detected!\n"); + LOG_ERROR(" Entering safe mode — all relays off\n"); // De-energize both relays immediately (P1 review fix) poolPumpNode.setSwitch(false); solarPumpNode.setSwitch(false); @@ -171,7 +169,7 @@ void DegradationManager::onTransition() { // MQTT notification — best-effort, no retry. // Full publish is handled by MqttPublisher on its next publish cycle. if (NetworkManager::isMqttConnected()) { - Serial.println(F(" Degradation state will be published via MQTT")); + LOG_INFO(" Degradation state will be published via MQTT\n"); } } diff --git a/src/ESP32TemperatureNode.cpp b/src/ESP32TemperatureNode.cpp index fc9fb9ab..a9ebb66a 100644 --- a/src/ESP32TemperatureNode.cpp +++ b/src/ESP32TemperatureNode.cpp @@ -9,6 +9,7 @@ #include "ESP32TemperatureNode.hpp" #include "Utils.hpp" +#include "LogCapture.hpp" ESP32TemperatureNode::ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval) { _id = id; @@ -19,7 +20,7 @@ ESP32TemperatureNode::ESP32TemperatureNode(const char *id, const char *name, con } void ESP32TemperatureNode::begin() { - Serial.printf("‱ ESP32 Internal Temp sensor '%s' initialized.\n", _id); + LOG_INFO("‱ ESP32 Internal Temp sensor '%s' initialized.\n", _id); } void ESP32TemperatureNode::loop() { @@ -34,6 +35,6 @@ void ESP32TemperatureNode::loop() { // standard conversion is: C = (F - 32) / 1.8 _temperature = (temp_farenheit - 32.0f) / 1.8f; - Serial.printf("ă€œ ESP32 internal temp: %f °C\n", _temperature); + LOG_DEBUG("ă€œ ESP32 internal temp: %f °C\n", _temperature); } } diff --git a/src/LogCapture.cpp b/src/LogCapture.cpp new file mode 100644 index 00000000..e074e719 --- /dev/null +++ b/src/LogCapture.cpp @@ -0,0 +1,233 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file LogCapture.cpp + * @brief Central ring-buffer logger implementation. + * + * Static RAM ring buffer (no heap), optionally mirrored to Serial. + * On ESP32 the ring is guarded by a portMUX critical section so it is safe + * from loop, WebServer handlers, and WiFi/MQTT callbacks. Native tests + * (no ESP32 macros) compile the guard to a no-op. + */ + +#include "LogCapture.hpp" + +#include +#include +#include +#include + +// The mock Arduino.h defines ARDUINO but no ESP32 macro, so the guard keys +// off the ESP32 macros like the rest of the codebase (OtaUpdater.cpp:537). +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +#include +#include +#include // esp_random() for the per-boot epoch +static portMUX_TYPE s_logMux = portMUX_INITIALIZER_UNLOCKED; +#define LOG_CRITICAL_ENTER() portENTER_CRITICAL(&s_logMux) +#define LOG_CRITICAL_EXIT() portEXIT_CRITICAL(&s_logMux) +#else +#define LOG_CRITICAL_ENTER() ((void)0) +#define LOG_CRITICAL_EXIT() ((void)0) +#endif + +namespace PoolController { + +// Static storage — fixed size, no heap. +LogEntry LogCapture::s_buffer[LogCapture::LOG_BUFFER_ENTRIES]; +std::size_t LogCapture::s_head = 0; +std::uint32_t LogCapture::s_seq = 0; +std::uint32_t LogCapture::s_clearedSeq = 0; +std::uint32_t LogCapture::s_epoch = 0; +bool LogCapture::s_logToSerial = true; + +void LogCapture::begin() { + LOG_CRITICAL_ENTER(); + s_head = 0; + s_seq = 0; + s_clearedSeq = 0; +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) + // A static RAM counter would restart at the same value after a real reboot + // (reinitialized to 0, then incremented to 1 — the same as the previous + // boot), so it cannot distinguish physical boots. Randomize instead: + // a reboot virtually never reuses the previous epoch, so persisted client + // cursors are reliably detected as stale even while the sequence catches up. + s_epoch = esp_random(); +#else + ++s_epoch; // native tests: deterministic monotonic increment per begin() +#endif + s_logToSerial = true; + LOG_CRITICAL_EXIT(); +} + +void LogCapture::log(LogLevel level, const char *fmt, ...) { + if (fmt == nullptr) { + return; + } + // Format into the full-size buffer so the Serial mirror (in store()) + // receives the complete message; only the ring copy is truncated. + char message[LOG_FORMAT_SIZE]; + va_list args; + va_start(args, fmt); + vsnprintf(message, sizeof(message), fmt, args); + va_end(args); + message[LOG_FORMAT_SIZE - 1] = '\0'; + store(level, message); +} + +void LogCapture::logEvent(const char *eventType, const char *fmt, ...) { + if (eventType == nullptr || fmt == nullptr) { + return; + } + char body[LOG_FORMAT_SIZE]; + va_list args; + va_start(args, fmt); + vsnprintf(body, sizeof(body), fmt, args); + va_end(args); + body[LOG_FORMAT_SIZE - 1] = '\0'; + + char message[LOG_FORMAT_SIZE + 64]; // room for the "[TYPE] " prefix + snprintf(message, sizeof(message), "[%s] %s", eventType, body); + store(LogLevel::Info, message); +} + +void LogCapture::store(LogLevel level, const char *message) { + const bool mirror = s_logToSerial; + + LOG_CRITICAL_ENTER(); + ++s_seq; + LogEntry &entry = s_buffer[s_head]; + entry.seq = s_seq; + entry.uptimeMs = millis(); + entry.level = level; + strncpy(entry.message, message, LOG_MSG_SIZE - 1); + entry.message[LOG_MSG_SIZE - 1] = '\0'; + s_head = (s_head + 1) % LOG_BUFFER_ENTRIES; + LOG_CRITICAL_EXIT(); + + // Mirror after leaving the critical section — Serial.print can block. + if (mirror) { + Serial.print(message); + } +} + +std::size_t LogCapture::getEntries( + std::uint32_t sinceSeq, std::uint32_t epoch, std::size_t maxCount, LogLevel minLevel, LogEntry *out, std::size_t outCapacity) { + const std::size_t cap = (maxCount < outCapacity) ? maxCount : outCapacity; + if (out == nullptr || cap == 0) { + return 0; + } + + std::size_t written = 0; + LOG_CRITICAL_ENTER(); + // A cursor is only trusted when it belongs to the current boot. Two cases + // make it stale and force a full re-read (effectiveSince = 0): + // 1. epoch mismatch: the client's cursor was persisted across a reboot + // (begin() restarted s_seq at 0 AND incremented s_epoch). A seq-only + // clamp misses this when the new boot has already produced more entries + // than the old cursor (sinceSeq <= s_seq but the entries 1..sinceSeq of + // the new boot would be skipped). + // 2. sinceSeq > s_seq within the same boot (ring wraparound past the + // cursor, or a cursor from before this feature existed with epoch 0). + const std::uint32_t effectiveSince = (epoch != s_epoch || sinceSeq > s_seq) ? 0 : sinceSeq; + // Entries written since the last clear (watermark hides pre-clear entries). + const std::uint32_t postClear = s_seq - s_clearedSeq; + const std::size_t visible = (postClear < LOG_BUFFER_ENTRIES) ? static_cast(postClear) : LOG_BUFFER_ENTRIES; + // After clear() the ring restarts at slot 0; once wrapped, s_head is oldest. + const std::size_t oldest = (postClear < LOG_BUFFER_ENTRIES) ? 0 : s_head; + + for (std::size_t i = 0; i < visible && written < cap; ++i) { + const LogEntry &entry = s_buffer[(oldest + i) % LOG_BUFFER_ENTRIES]; + if (entry.seq <= effectiveSince) { + continue; + } + if (entry.level < minLevel) { + continue; + } + out[written++] = entry; + } + LOG_CRITICAL_EXIT(); + return written; +} + +std::uint32_t LogCapture::lastSeq() { + LOG_CRITICAL_ENTER(); + const std::uint32_t seq = s_seq; + LOG_CRITICAL_EXIT(); + return seq; +} + +std::uint32_t LogCapture::epoch() { + LOG_CRITICAL_ENTER(); + const std::uint32_t epoch = s_epoch; + LOG_CRITICAL_EXIT(); + return epoch; +} + +void LogCapture::clear() { + LOG_CRITICAL_ENTER(); + // Hide everything written so far. s_seq is NOT reset so polling clients + // (which track seq) keep working; new entries continue above the watermark. + s_clearedSeq = s_seq; + s_head = 0; + LOG_CRITICAL_EXIT(); +} + +const char *LogCapture::levelName(LogLevel level) { + switch (level) { + case LogLevel::Debug: + return "debug"; + case LogLevel::Info: + return "info"; + case LogLevel::Warning: + return "warning"; + case LogLevel::Critical: + return "critical"; + case LogLevel::Error: + return "error"; + } + return "info"; +} + +LogLevel LogCapture::parseLevel(const char *name) { + if (name == nullptr) { + return LogLevel::Info; + } + // Case-insensitive compare without relying on platform string.h. + auto iequals = [](const char *a, const char *b) { + while (*a != '\0' && *b != '\0') { + if (std::tolower(static_cast(*a)) != std::tolower(static_cast(*b))) { + return false; + } + ++a; + ++b; + } + return *a == *b; + }; + + if (iequals(name, "debug")) { + return LogLevel::Debug; + } + if (iequals(name, "warning")) { + return LogLevel::Warning; + } + if (iequals(name, "error")) { + return LogLevel::Error; + } + if (iequals(name, "critical")) { + return LogLevel::Critical; + } + return LogLevel::Info; +} + +bool LogCapture::isLogToSerial() { + return s_logToSerial; +} + +void LogCapture::setLogToSerial(bool enabled) { + s_logToSerial = enabled; +} + +} // namespace PoolController diff --git a/src/LogCapture.hpp b/src/LogCapture.hpp new file mode 100644 index 00000000..584496f7 --- /dev/null +++ b/src/LogCapture.hpp @@ -0,0 +1,118 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file LogCapture.hpp + * @brief Central ring-buffer logger — replaces direct Serial.* calls. + * + * All firmware logging flows through LogCapture: entries are stored in a + * fixed-size RAM ring buffer (no heap) and optionally mirrored to Serial + * (byte-identical, preserving existing serial debugging). REST and MQTT + * consumers read from the same buffer. + * + * Thread safety: on ESP32 the buffer is guarded by a portMUX critical + * section (callable from loop, WebServer handlers, and WiFi/MQTT callbacks). + * Native tests (no ESP32 macros) compile the guard to a no-op. + */ + +#pragma once + +#include +#include +#include + +// Configurable via PlatformIO build_flags (-DLOG_BUFFER_SIZE=...). +#ifndef LOG_BUFFER_SIZE +#define LOG_BUFFER_SIZE 8192 +#endif + +#ifndef LOG_MSG_SIZE +#define LOG_MSG_SIZE 96 +#endif + +// Formatting buffer for a single log call. Larger than LOG_MSG_SIZE so the +// Serial mirror keeps the complete formatted message (e.g. a full OTA URL) +// while only the ring copy is truncated to LOG_MSG_SIZE-1 in store(). +#ifndef LOG_FORMAT_SIZE +#define LOG_FORMAT_SIZE 512 +#endif + +namespace PoolController { + +/** + * Log levels, ordered by severity (higher = more important). + */ +enum class LogLevel : std::uint8_t { Debug = 0, Info, Warning, Critical, Error }; + +/** + * A single captured log entry. Fixed size — no heap. + */ +struct LogEntry { + std::uint32_t seq; //!< monotonically increasing, used for since-polling + std::uint32_t uptimeMs; //!< millis() at capture time + LogLevel level; //!< severity + char message[LOG_MSG_SIZE]; //!< formatted message (null-terminated) +}; + +/** + * Central logging service with a static RAM ring buffer. + */ +class LogCapture final { +public: + static constexpr std::size_t LOG_BUFFER_ENTRIES = LOG_BUFFER_SIZE / LOG_MSG_SIZE; + + /** Resets the ring and state. Called once at boot after Serial.begin(). */ + static void begin(); + /** Formats and stores an entry; mirrors to Serial when enabled. */ + static void log(LogLevel level, const char *fmt, ...); + /** Logs a curated event (Info level) with a "[TYPE] message" marker for MQTT export. */ + static void logEvent(const char *eventType, const char *fmt, ...); + /** + * Copies up to min(maxCount, outCapacity) entries newer than sinceSeq with + * level >= minLevel into out. Returns the number of entries written. + * + * The cursor (sinceSeq, epoch) is only trusted when it belongs to the + * current boot: a stale cursor — epoch != epoch() (it was persisted across a + * reboot, where begin() restarted the sequence at 0) or sinceSeq > lastSeq() + * — is treated as 0, so the whole currently-visible ring is returned + * instead of nothing or a partial new-boot log. + */ + static std::size_t getEntries( + std::uint32_t sinceSeq, std::uint32_t epoch, std::size_t maxCount, LogLevel minLevel, LogEntry *out, std::size_t outCapacity); + /** Sequence number of the last assigned entry (0 if none yet). */ + static std::uint32_t lastSeq(); + /** + * Boot epoch identifying the current boot. On ESP32 begin() assigns a fresh + * random value per boot (a RAM counter would restart at the same value after + * a real reboot and could not distinguish physical boots); native test builds + * use a deterministic monotonic increment. Clients echo it back so a reboot + * is detected even when the new sequence is still below their stored cursor. + */ + static std::uint32_t epoch(); + /** Empties the ring. s_seq is NOT reset so polling clients keep working. */ + static void clear(); + static const char *levelName(LogLevel level); + /** Parses "debug"|"info"|"warning"|"error" (case-insensitive) — unknown → Info. */ + static LogLevel parseLevel(const char *name); + static bool isLogToSerial(); + static void setLogToSerial(bool enabled); + +private: + /** Writes an already-formatted message into the ring (guarded) and mirrors to Serial. */ + static void store(LogLevel level, const char *message); + + static LogEntry s_buffer[LOG_BUFFER_ENTRIES]; + static std::size_t s_head; //!< next free slot + static std::uint32_t s_seq; //!< last assigned sequence number + static std::uint32_t s_clearedSeq; //!< watermark: entries <= this are hidden after clear() + static std::uint32_t s_epoch; //!< boot epoch, incremented by begin() + static bool s_logToSerial; +}; + +} // namespace PoolController + +#define LOG_DEBUG(...) PoolController::LogCapture::log(PoolController::LogLevel::Debug, __VA_ARGS__) +#define LOG_INFO(...) PoolController::LogCapture::log(PoolController::LogLevel::Info, __VA_ARGS__) +#define LOG_WARN(...) PoolController::LogCapture::log(PoolController::LogLevel::Warning, __VA_ARGS__) +#define LOG_ERROR(...) PoolController::LogCapture::log(PoolController::LogLevel::Error, __VA_ARGS__) diff --git a/src/MqttPublisher.cpp b/src/MqttPublisher.cpp index 4504d354..999f28c0 100644 --- a/src/MqttPublisher.cpp +++ b/src/MqttPublisher.cpp @@ -24,10 +24,27 @@ #include "RelayModuleNode.hpp" #include "TimeClientHelper.hpp" #include "Version.h" +#include "LogCapture.hpp" namespace PoolController { String MqttPublisher::deviceId_ = ""; +std::uint32_t MqttPublisher::s_lastExportedSeq = 0; + +// Reentrancy guard for the log-event export (see exportLogEvents). AsyncMqttClient +// callbacks (handleMqttMessage → publishStates) run on the AsyncTCP task, which can +// preempt the loop task mid-export; both paths share the static snapshot buffer and +// s_lastExportedSeq. Native tests (no ESP32 macros) compile the guard to a no-op. +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +#include +#include +static portMUX_TYPE s_exportMux = portMUX_INITIALIZER_UNLOCKED; +#define EXPORT_CRITICAL_ENTER() portENTER_CRITICAL(&s_exportMux) +#define EXPORT_CRITICAL_EXIT() portEXIT_CRITICAL(&s_exportMux) +#else +#define EXPORT_CRITICAL_ENTER() ((void)0) +#define EXPORT_CRITICAL_EXIT() ((void)0) +#endif void MqttPublisher::begin() { // Generate unique MAC-based device identifier @@ -37,7 +54,15 @@ void MqttPublisher::begin() { snprintf(macStr, sizeof(macStr), "pool_controller_%02x%02x%02x", mac[3], mac[4], mac[5]); deviceId_ = String(macStr); - Serial.printf("✓ HA Discovery Device ID set to: %s\n", deviceId_.c_str()); + // Start the log-event export watermark at the beginning of the current boot + // (seq 0). exportLogEvents() already filters Info/Debug chatter, so starting + // here does not flood the bus — but it DOES preserve WARN/ERROR and curated + // events emitted before this point (boot-loop detection, ConfigManager, + // NetworkManager/WPS/SSID warnings), which a lastSeq() watermark would + // permanently drop. + s_lastExportedSeq = 0; + + LOG_INFO("✓ HA Discovery Device ID set to: %s\n", deviceId_.c_str()); // Register callback in NetworkManager NetworkManager::setMqttCallback(handleMqttMessage); @@ -68,6 +93,17 @@ struct TopicBuilder { return buf; } }; + +// Curated event whitelist — MUST match the event_types in publishEventDiscovery(). +bool isKnownEventType(const char *type) { + static const char *const kEventTypes[] = {"LOG_WARN", "LOG_ERROR", "MODE_CHANGED", "PUMP_ON", "PUMP_OFF", "WIFI_CONNECTED", + "WIFI_DISCONNECTED", "MQTT_CONNECTED", "MQTT_DISCONNECTED"}; + for (const char *known : kEventTypes) { + if (strcmp(known, type) == 0) + return true; + } + return false; +} } // namespace void MqttPublisher::publishSensorDiscovery(const char *objectId, const char *name, const char *deviceClass, const char *unit, @@ -231,6 +267,36 @@ void MqttPublisher::publishTextDiscovery(const char *objectId, const char *name, NetworkManager::publish(cfgTopic.build("text", objectId, "/config"), payloadBuf, true); } +void MqttPublisher::publishEventDiscovery(const char *objectId, const char *name, const char *icon) { + TopicBuilder cfgTopic, stateTopic; + JsonDocument doc; + doc["name"] = name; + doc["unique_id"] = deviceId_ + "_" + objectId; + doc["state_topic"] = stateTopic.build("event", objectId, "/state"); + doc["availability_topic"] = "homeassistant/sensor/pool-controller/availability"; + doc["platform"] = "event"; + + // Whitelist MUST match the curated event types parsed by exportLogEvents(). + JsonArray eventTypes = doc["event_types"].to(); + eventTypes.add("LOG_WARN"); + eventTypes.add("LOG_ERROR"); + eventTypes.add("MODE_CHANGED"); + eventTypes.add("PUMP_ON"); + eventTypes.add("PUMP_OFF"); + eventTypes.add("WIFI_CONNECTED"); + eventTypes.add("WIFI_DISCONNECTED"); + eventTypes.add("MQTT_CONNECTED"); + eventTypes.add("MQTT_DISCONNECTED"); + + if (icon) + doc["icon"] = icon; + addDeviceInfo(doc); + + char payloadBuf[1024]; + serializeJson(doc, payloadBuf, sizeof(payloadBuf)); + NetworkManager::publish(cfgTopic.build("event", objectId, "/config"), payloadBuf, true); +} + void MqttPublisher::publishTimeDiscovery(const char *objectId, const char *name, const char *icon, const char *entityCategory) { TopicBuilder cfgTopic, stateTopic, cmdTopic; @@ -431,7 +497,10 @@ void MqttPublisher::publishDiscovery() { if (!NetworkManager::isMqttConnected()) return; - Serial.println("Publishing HA Discovery Payloads..."); + LOG_INFO("Publishing HA Discovery Payloads...\n"); + + // ── Log event entity (HA "event" component) ── + publishEventDiscovery("logs", "Pool Controller Logs", "mdi:clipboard-text-outline"); // ── Primary Sensors (no entity_category — shown on device front page) ── publishSensorDiscovery("pool-temp", "Pool Temperature", "temperature", "°C", "mdi:pool", nullptr, "measurement"); @@ -548,7 +617,7 @@ void MqttPublisher::publishDiscovery() { NetworkManager::publish(topic, "", true); // empty retained → HA removes entity } - Serial.println("✓ HA Discovery Payloads & Subscriptions complete"); + LOG_INFO("✓ HA Discovery Payloads & Subscriptions complete\n"); } void MqttPublisher::publishStates() { @@ -735,9 +804,136 @@ void MqttPublisher::publishStates() { getBaseTopic(topic, sizeof(topic), "binary_sensor", "mqtt-status"); strlcat(topic, "/state", sizeof(topic)); NetworkManager::publish(topic, NetworkManager::isMqttConnected() ? "ON" : "OFF", true); + + // Export new log entries as MQTT events (WARN/ERROR + curated logEvent markers) + exportLogEvents(); } } +// ═══════════════════════════════════════════════════════════════════════ +// Log-event export pump (WARN/ERROR + curated logEvent markers) +// ═══════════════════════════════════════════════════════════════════════ + +void MqttPublisher::exportLogEvents() { + // Only one export may run at a time: AsyncMqttClient callbacks (handleMqttMessage + // → publishStates) run on the AsyncTCP task and can preempt the loop task's + // periodic export. Both share the static snapshot buffer and the watermark, so a + // concurrent entry would clobber the snapshot or regress s_lastExportedSeq and + // duplicate MQTT events. The losing export defers to the next pass — it returns + // before touching the watermark, so its pending entries are not lost. + static bool s_exportRunning = false; + EXPORT_CRITICAL_ENTER(); + if (s_exportRunning) { + EXPORT_CRITICAL_EXIT(); + return; + } + s_exportRunning = true; + EXPORT_CRITICAL_EXIT(); + + // Batch snapshot of the ring (static: keeps ~9 KB off the loop stack). + static LogEntry entries[LogCapture::LOG_BUFFER_ENTRIES]; + const size_t count = LogCapture::getEntries(s_lastExportedSeq, LogCapture::epoch(), LogCapture::LOG_BUFFER_ENTRIES, + LogLevel::Info, entries, LogCapture::LOG_BUFFER_ENTRIES); + if (count == 0) { + EXPORT_CRITICAL_ENTER(); + s_exportRunning = false; + EXPORT_CRITICAL_EXIT(); + return; + } + + TopicBuilder stateTopic; + // Watermark only advances through entries whose required publishes were + // successfully queued. When MQTT disconnects mid-burst or the client + // refuses to enqueue (NetworkManager::publish() == false), the watermark + // stays before the failed entry so it is retried on the next export pass + // instead of being dropped. + uint32_t lastOkSeq = s_lastExportedSeq; + for (size_t i = 0; i < count; ++i) { + const LogEntry &entry = entries[i]; + const char *body = entry.message; + const char *eventType = nullptr; + char typeBuf[32]; + + // Parse the "[TYPE] message" marker written by LogCapture::logEvent(). + if (body[0] == '[') { + const char *close = strchr(body, ']'); + if (close != nullptr && close > body + 1) { + const size_t len = static_cast(close - body - 1); + if (len < sizeof(typeBuf)) { + memcpy(typeBuf, body + 1, len); + typeBuf[len] = '\0'; + if (isKnownEventType(typeBuf)) { + eventType = typeBuf; + body = close + 1; + while (*body == ' ') + ++body; + } + } + } + } + + // WARN/ERROR entries without a curated marker → LOG_WARN/LOG_ERROR. + // Info/Debug chatter is skipped (volume control — nothing on MQTT). + if (eventType == nullptr) { + switch (entry.level) { + case LogLevel::Warning: + eventType = "LOG_WARN"; + break; + case LogLevel::Error: + eventType = "LOG_ERROR"; + break; + default: + // Skipped by design — no publish required, so the watermark may + // advance past it. + lastOkSeq = entry.seq; + continue; + } + } + + // HA event-entity state: {"event_type": "...", "message": "..."} + JsonDocument doc; + doc["event_type"] = eventType; + doc["message"] = body; + char payload[256]; + serializeJson(doc, payload, sizeof(payload)); + bool ok = NetworkManager::publish(stateTopic.build("event", "logs", "/state"), payload, false); + + // Raw JSON-line for external tools (WARN/ERROR only — not Info-level events). + if (entry.level == LogLevel::Warning || entry.level == LogLevel::Error) { + JsonDocument rawDoc; + rawDoc["seq"] = entry.seq; + rawDoc["t"] = entry.uptimeMs; + rawDoc["level"] = LogCapture::levelName(entry.level); + rawDoc["msg"] = entry.message; + char rawBuf[256]; + serializeJson(rawDoc, rawBuf, sizeof(rawBuf)); + ok = NetworkManager::publish("pool-controller/log", rawBuf, false) && ok; + } + + // A required publish failed (e.g. AsyncMqttClient refused to enqueue + // during the discovery/state burst right after reconnect): keep the + // watermark before this entry so the whole tail is retried next pass. + // Do not drop the event by marking it exported without a successful queue. + if (!ok) + break; + + // This entry's required publishes were all successfully queued. + lastOkSeq = entry.seq; + } + + // Advance the watermark only through entries whose required publishes were + // successfully queued. Using LogCapture::lastSeq() here would mark entries + // appended concurrently by async WiFi/MQTT callbacks as exported even though + // they were never processed — their HA events would be permanently lost. + // Entries after a failed publish (or appended during the snapshot) are + // picked up by the next export pass instead. + s_lastExportedSeq = lastOkSeq; + + EXPORT_CRITICAL_ENTER(); + s_exportRunning = false; + EXPORT_CRITICAL_EXIT(); +} + // ═══════════════════════════════════════════════════════════════════════ // Sensor mapping select-entity discovery (published after bus scan) // ═══════════════════════════════════════════════════════════════════════ @@ -793,7 +989,7 @@ void MqttPublisher::publishSensorMappingDiscovery() { NetworkManager::subscribe("homeassistant/select/pool-controller/solar-sensor/set"); NetworkManager::subscribe("homeassistant/select/pool-controller/pool-sensor/set"); - Serial.printf("‱ HA: Sensor mapping select entities published (%u options)\n", solarOptCount); + LOG_INFO("‱ HA: Sensor mapping select entities published (%u options)\n", solarOptCount); } // Helper function to check if MQTT authentication is configured @@ -836,19 +1032,19 @@ void MqttPublisher::handleMqttMessage( if (top.endsWith("/firmware-update/set")) { // MQTT authentication is optional, but if configured, we check it if (shouldEnforceMqttAuth() && !isMqttAuthenticated()) { - Serial.println("MQTT: Firmware update command rejected - MQTT authentication required"); + LOG_WARN("MQTT: Firmware update command rejected - MQTT authentication required\n"); return; } // Always validate command value for security static const char *validFirmwareCommands[] = {"INSTALL"}; if (!MqttPublisher::isValidCommand(value, validFirmwareCommands, 1)) { - Serial.printf("MQTT: Invalid firmware command: %s\n", value.c_str()); + LOG_WARN("MQTT: Invalid firmware command: %s\n", value.c_str()); return; } if (value == "INSTALL") { - Serial.println("MQTT: Firmware update triggered from Home Assistant"); + LOG_INFO("MQTT: Firmware update triggered from Home Assistant\n"); OtaUpdater::startUpdate(); } return; @@ -866,11 +1062,11 @@ void MqttPublisher::handleMqttMessage( else if (value == "boost") poolMode = "boost"; else { - Serial.printf("MQTT: Unknown preset \"%s\" — ignoring\n", value.c_str()); + LOG_WARN("MQTT: Unknown preset \"%s\" — ignoring\n", value.c_str()); publishStates(); return; } - Serial.printf("MQTT: Climate preset → pool mode \"%s\"\n", poolMode.c_str()); + LOG_INFO("MQTT: Climate preset → pool mode \"%s\"\n", poolMode.c_str()); operationModeNode.setMode(poolMode.c_str()); ConfigManager::getSettings().opMode = poolMode; ConfigManager::save(); @@ -887,11 +1083,11 @@ void MqttPublisher::handleMqttMessage( else if (value == "heat") poolMode = "boost"; else { - Serial.printf("MQTT: Unknown climate mode \"%s\" — ignoring\n", value.c_str()); + LOG_WARN("MQTT: Unknown climate mode \"%s\" — ignoring\n", value.c_str()); publishStates(); return; } - Serial.printf("MQTT: Climate mode → pool mode \"%s\"\n", poolMode.c_str()); + LOG_INFO("MQTT: Climate mode → pool mode \"%s\"\n", poolMode.c_str()); operationModeNode.setMode(poolMode.c_str()); ConfigManager::getSettings().opMode = poolMode; ConfigManager::save(); @@ -901,7 +1097,7 @@ void MqttPublisher::handleMqttMessage( if (top.endsWith("/thermostat/temperature/set")) { float val = value.toFloat(); - Serial.printf("MQTT: Climate target temperature → %.1f\n", val); + LOG_INFO("MQTT: Climate target temperature → %.1f\n", val); operationModeNode.setPoolMaxTemperature(val); ConfigManager::getSettings().tempMaxPool = val; ConfigManager::save(); @@ -912,7 +1108,7 @@ void MqttPublisher::handleMqttMessage( if (top.endsWith("/pool-pump/set") || top.endsWith("/solar-pump/set")) { // MQTT authentication is optional, but if configured, we check it if (shouldEnforceMqttAuth() && !isMqttAuthenticated()) { - Serial.println("MQTT: Pump command rejected - MQTT authentication required"); + LOG_WARN("MQTT: Pump command rejected - MQTT authentication required\n"); publishStates(); return; } @@ -920,14 +1116,14 @@ void MqttPublisher::handleMqttMessage( // Always validate payload for security static const char *validPumpCommands[] = {"ON", "OFF"}; if (!MqttPublisher::isValidCommand(value, validPumpCommands, 2)) { - Serial.printf("MQTT: Invalid pump command: %s\n", value.c_str()); + LOG_WARN("MQTT: Invalid pump command: %s\n", value.c_str()); publishStates(); return; } // Only allow pump control from HA in manual mode if (operationModeNode.getMode() != "manu") { - Serial.printf("MQTT: Ignoring pump command — not in manual mode (current: %s)\n", operationModeNode.getMode().c_str()); + LOG_WARN("MQTT: Ignoring pump command — not in manual mode (current: %s)\n", operationModeNode.getMode().c_str()); publishStates(); return; } @@ -939,7 +1135,7 @@ void MqttPublisher::handleMqttMessage( } else if (top.endsWith("/mode/set")) { // MQTT authentication is optional, but if configured, we check it if (shouldEnforceMqttAuth() && !isMqttAuthenticated()) { - Serial.println("MQTT: Mode command rejected - MQTT authentication required"); + LOG_WARN("MQTT: Mode command rejected - MQTT authentication required\n"); publishStates(); return; } @@ -947,7 +1143,7 @@ void MqttPublisher::handleMqttMessage( // Always validate mode value for security static const char *validModes[] = {"auto", "manu", "boost", "timer"}; if (!MqttPublisher::isValidCommand(value, validModes, 4)) { - Serial.printf("MQTT: Invalid mode command: %s\n", value.c_str()); + LOG_WARN("MQTT: Invalid mode command: %s\n", value.c_str()); publishStates(); return; } @@ -958,7 +1154,7 @@ void MqttPublisher::handleMqttMessage( } else if (top.endsWith("/pool-max-temp/set")) { // MQTT authentication is optional, but if configured, we check it if (shouldEnforceMqttAuth() && !isMqttAuthenticated()) { - Serial.println("MQTT: Config command rejected - MQTT authentication required"); + LOG_WARN("MQTT: Config command rejected - MQTT authentication required\n"); publishStates(); return; } @@ -966,7 +1162,7 @@ void MqttPublisher::handleMqttMessage( float val = value.toFloat(); // Always validate range for security if (val < 0.0f || val > 40.0f) { - Serial.printf("MQTT: Invalid pool-max-temp value: %.1f\n", val); + LOG_WARN("MQTT: Invalid pool-max-temp value: %.1f\n", val); publishStates(); return; } @@ -976,7 +1172,7 @@ void MqttPublisher::handleMqttMessage( } else if (top.endsWith("/solar-min-temp/set")) { // MQTT authentication is optional, but if configured, we check it if (shouldEnforceMqttAuth() && !isMqttAuthenticated()) { - Serial.println("MQTT: Config command rejected - MQTT authentication required"); + LOG_WARN("MQTT: Config command rejected - MQTT authentication required\n"); publishStates(); return; } @@ -984,7 +1180,7 @@ void MqttPublisher::handleMqttMessage( float val = value.toFloat(); // Always validate range for security if (val < 0.0f || val > 100.0f) { - Serial.printf("MQTT: Invalid solar-min-temp value: %.1f\n", val); + LOG_WARN("MQTT: Invalid solar-min-temp value: %.1f\n", val); publishStates(); return; } @@ -994,7 +1190,7 @@ void MqttPublisher::handleMqttMessage( } else if (top.endsWith("/hysteresis/set")) { // MQTT authentication is optional, but if configured, we check it if (shouldEnforceMqttAuth() && !isMqttAuthenticated()) { - Serial.println("MQTT: Config command rejected - MQTT authentication required"); + LOG_WARN("MQTT: Config command rejected - MQTT authentication required\n"); publishStates(); return; } @@ -1002,7 +1198,7 @@ void MqttPublisher::handleMqttMessage( float val = value.toFloat(); // Always validate range for security if (val < 0.0f || val > 10.0f) { - Serial.printf("MQTT: Invalid hysteresis value: %.1f\n", val); + LOG_WARN("MQTT: Invalid hysteresis value: %.1f\n", val); publishStates(); return; } @@ -1049,7 +1245,7 @@ void MqttPublisher::handleMqttMessage( } else if (top.endsWith("/timezone/set")) { int idx = getTimezoneIndexFromLabel(value); if (idx < 0) { - Serial.printf("MQTT: Unknown timezone label \"%s\" — ignoring\n", value.c_str()); + LOG_WARN("MQTT: Unknown timezone label \"%s\" — ignoring\n", value.c_str()); publishStates(); return; } @@ -1076,7 +1272,7 @@ void MqttPublisher::handleMqttMessage( char *end = nullptr; unsigned long val = strtoul(byteStr, &end, 16); if (end != byteStr + 2) { - Serial.printf("MQTT: Invalid hex in sensor selection — ignoring\n"); + LOG_WARN("MQTT: Invalid hex in sensor selection — ignoring\n"); publishStates(); return; } @@ -1094,14 +1290,14 @@ void MqttPublisher::handleMqttMessage( solarTemperatureNode.setAddressFilter(addr); else solarTemperatureNode.clearAddressFilter(); - Serial.printf("MQTT: Solar sensor %s via HA\n", hasAddr ? "assigned" : "cleared"); + LOG_INFO("MQTT: Solar sensor %s via HA\n", hasAddr ? "assigned" : "cleared"); } else { prefs.putBytes("pool_adr", addr, 8); if (hasAddr) poolTemperatureNode.setAddressFilter(addr); else poolTemperatureNode.clearAddressFilter(); - Serial.printf("MQTT: Pool sensor %s via HA\n", hasAddr ? "assigned" : "cleared"); + LOG_INFO("MQTT: Pool sensor %s via HA\n", hasAddr ? "assigned" : "cleared"); } prefs.end(); } diff --git a/src/MqttPublisher.hpp b/src/MqttPublisher.hpp index 41a16ae7..4dbc4b33 100644 --- a/src/MqttPublisher.hpp +++ b/src/MqttPublisher.hpp @@ -69,6 +69,20 @@ class MqttPublisher { static void publishClimateDiscovery(); /** @brief Publish select-entity discovery for sensor-to-role mapping (detected addresses as options). */ static void publishSensorMappingDiscovery(); + /** + * @brief Publish HA MQTT "event" component discovery for curated log events. + * @param objectId Entity object id (e.g. "logs"). + * @param name Display name. + * @param icon Optional MDI icon. + */ + static void publishEventDiscovery(const char *objectId, const char *name, const char *icon = nullptr); + /** + * @brief Export new LogCapture entries as MQTT events (WARN/ERROR + curated + * logEvent markers). Called from publishStates(); deduplicated via + * s_lastExportedSeq and guarded against reentrancy from the AsyncTCP + * callback task (handleMqttMessage → publishStates). + */ + static void exportLogEvents(); static void getBaseTopic(char *buf, size_t bufSize, const char *component, const char *objectId); static void addDeviceInfo(JsonDocument &doc); @@ -76,6 +90,8 @@ class MqttPublisher { static void publishClimateState(); static String deviceId_; + /** @brief Sequence watermark for the MQTT log-event export pump (LogCapture::seq). */ + static std::uint32_t s_lastExportedSeq; }; } // namespace PoolController diff --git a/src/NetworkManager.cpp b/src/NetworkManager.cpp index 3ae9dd98..7512756f 100644 --- a/src/NetworkManager.cpp +++ b/src/NetworkManager.cpp @@ -15,6 +15,7 @@ #include "ConfigManager.hpp" #include "WpsProvisioner.hpp" +#include "LogCapture.hpp" namespace PoolController { @@ -41,15 +42,18 @@ bool NetworkManager::begin() { // Set up MQTT event handlers (one-time, async) mqttClient_.onConnect([](bool sessionPresent) { - Serial.println("✓ MQTT connected!"); + LOG_INFO("✓ MQTT connected!\n"); + LogCapture::logEvent("MQTT_CONNECTED", "MQTT broker connected"); // Publish online to LWT topic immediately (async, non-blocking) mqttClient_.publish("homeassistant/sensor/pool-controller/availability", 1, true, "online"); }); - mqttClient_.onDisconnect( - [](AsyncMqttClientDisconnectReason reason) { Serial.printf("✖ MQTT disconnected, reason=%d\n", static_cast(reason)); }); + mqttClient_.onDisconnect([](AsyncMqttClientDisconnectReason reason) { + LOG_ERROR("✖ MQTT disconnected, reason=%d\n", static_cast(reason)); + LogCapture::logEvent("MQTT_DISCONNECTED", "MQTT disconnected, reason %d", static_cast(reason)); + }); if (ConfigManager::getWiFi().ssid.length() == 0) { - Serial.println("⚠ No WiFi SSID configured! Starting AP mode."); + LOG_WARN("⚠ No WiFi SSID configured! Starting AP mode.\n"); startAPMode(); return true; } @@ -66,13 +70,13 @@ void NetworkManager::loop() { uint32_t now = millis(); if (now - lastWiFiRetryTime_ >= kWiFiRetryIntervalMs) { lastWiFiRetryTime_ = now; - Serial.println("🔄 AP mode: retrying WiFi connection with saved credentials..."); + LOG_INFO("🔄 AP mode: retrying WiFi connection with saved credentials...\n"); WiFi.mode(WIFI_MODE_APSTA); connectWiFi(); } if (WiFi.status() == WL_CONNECTED) { - Serial.println("✓ AP mode: WiFi reconnected! Switching back to normal mode."); + LOG_INFO("✓ AP mode: WiFi reconnected! Switching back to normal mode.\n"); WiFi.mode(WIFI_MODE_STA); apModeActive_ = false; connectionStartTime_ = 0; @@ -92,7 +96,7 @@ void NetworkManager::loop() { uint32_t now = millis(); if (now - connectionStartTime_ >= 20000) { - Serial.println("⚠ WiFi connection timeout (20s). Falling back to AP Setup Mode!"); + LOG_WARN("⚠ WiFi connection timeout (20s). Falling back to AP Setup Mode!\n"); startAPMode(); return; } @@ -124,7 +128,7 @@ void NetworkManager::loop() { statusStr = "UNKNOWN"; break; } - Serial.printf("🔄 WiFi retry... status=%s (%d), elapsed=%ums\n", statusStr, status, now - connectionStartTime_); + LOG_INFO("🔄 WiFi retry... status=%s (%d), elapsed=%ums\n", statusStr, status, now - connectionStartTime_); connectWiFi(); } return; @@ -138,7 +142,7 @@ void NetworkManager::loop() { uint32_t now = millis(); if (now - lastMqttRetryTime_ >= kMqttRetryIntervalMs) { lastMqttRetryTime_ = now; - Serial.println("🔄 MQTT disconnected, retrying..."); + LOG_INFO("🔄 MQTT disconnected, retrying...\n"); connectMqtt(); } } @@ -164,13 +168,12 @@ void NetworkManager::startAPMode() { // Setup standard open AP named 'Pool-Controller-Setup' WiFi.softAP("Pool-Controller-Setup"); - Serial.print("🚀 AP Mode active. SSID: 'Pool-Controller-Setup'. IP: "); - Serial.println(WiFi.softAPIP()); + LOG_INFO("🚀 AP Mode active. SSID: 'Pool-Controller-Setup'. IP: %s\n", WiFi.softAPIP().toString().c_str()); } void NetworkManager::connectWiFi() { const String &ssid = ConfigManager::getWiFi().ssid; - Serial.printf("📡 Connecting to WiFi: %s ...\n", ssid.c_str()); + LOG_INFO("📡 Connecting to WiFi: %s ...\n", ssid.c_str()); WiFi.begin(ssid.c_str(), ConfigManager::getWiFi().password.c_str()); } @@ -253,19 +256,21 @@ void NetworkManager::restart() { void NetworkManager::handleWiFiEvent(WiFiEvent_t event) { switch (event) { case ARDUINO_EVENT_WIFI_STA_GOT_IP: - Serial.printf("✓ WiFi connected! SSID: \"%s\", IP: %s, RSSI: %d dBm, Channel: %d\n", WiFi.SSID().c_str(), + LOG_INFO("✓ WiFi connected! SSID: \"%s\", IP: %s, RSSI: %d dBm, Channel: %d\n", WiFi.SSID().c_str(), WiFi.localIP().toString().c_str(), WiFi.RSSI(), WiFi.channel()); + LogCapture::logEvent("WIFI_CONNECTED", "WiFi connected to %s", WiFi.SSID().c_str()); apModeActive_ = false; // Start mDNS responder so the device is reachable as pool-controller.local if (MDNS.begin("pool-controller")) { MDNS.addService("http", "tcp", 80); - Serial.println("✓ mDNS: pool-controller.local"); + LOG_INFO("✓ mDNS: pool-controller.local\n"); } else { - Serial.println("✖ mDNS responder setup failed"); + LOG_ERROR("✖ mDNS responder setup failed\n"); } break; case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: - Serial.printf("✖ WiFi disconnected. Status: %d (SSID: \"%s\")\n", WiFi.status(), WiFi.SSID().c_str()); + LOG_ERROR("✖ WiFi disconnected. Status: %d (SSID: \"%s\")\n", WiFi.status(), WiFi.SSID().c_str()); + LogCapture::logEvent("WIFI_DISCONNECTED", "WiFi disconnected, status %d", WiFi.status()); break; default: break; diff --git a/src/Nodes/Logger.cpp b/src/Nodes/Logger.cpp deleted file mode 100644 index c3243b10..00000000 --- a/src/Nodes/Logger.cpp +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// -// SPDX-License-Identifier: MIT - -/** - * @file Logger.cpp - * @brief Logger implementation — currently a stub; Logger is a plain struct. - */ - -#include "Logger.hpp" - -namespace PoolController { -namespace Nodes { - -// Logger is now a plain struct — the former Node-based constructor -// was removed as part of the migration to standalone MQTT via -// MqttPublisher + NetworkManager. - -} // namespace Nodes -} // namespace PoolController diff --git a/src/Nodes/Logger.hpp b/src/Nodes/Logger.hpp deleted file mode 100644 index eb66c7f9..00000000 --- a/src/Nodes/Logger.hpp +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// -// SPDX-License-Identifier: MIT - -/** - * @file Logger.hpp - * @brief Lightweight serial logger — log levels, flag-based output control. - */ - -#pragma once - -#include -#include -#include - -namespace PoolController { -namespace Nodes { - -/** - * Lightweight serial logger — replaces the former Node-based Logger. - * All output goes to Serial. The struct is kept for API compatibility with - * sites that previously used Logger::LogLevel / Logger::Flags. - */ -struct Logger final { - Logger() = default; - - enum struct LogLevel : std::size_t { Debug = 0, Info, Warning, Critical, Error }; - - struct Flags final { - using Bits = std::uint8_t; - enum $ : Bits { None = 0, LogToSerial = 1 << 0, LogToJson = 1 << 1, FlushLog = 1 << 2 }; - }; - - LogLevel CurrentLogLevel{LogLevel::Info}; - Flags::Bits CurrentFlags{Flags::LogToSerial}; - - [[nodiscard]] auto operator[](LogLevel logLevel) const noexcept -> const char * { - return *(LOG_LEVEL_NAMES + static_cast(logLevel)); - } - [[nodiscard]] auto operator*() const noexcept -> Flags::Bits { return this->CurrentFlags; } - [[nodiscard]] auto operator*() noexcept -> Flags::Bits & { return this->CurrentFlags; } - [[nodiscard]] explicit operator bool() const noexcept { return (**this & Flags::LogToSerial) != 0; } - inline auto AddFlags(const Flags::Bits x) noexcept -> Flags::Bits { return **this |= x; } - inline auto RemoveFlags(const Flags::Bits x) noexcept -> Flags::Bits { return **this &= ~x; } - inline auto ToggleFlags(const Flags::Bits x) noexcept -> Flags::Bits { return **this ^= x; } - inline auto ClearFlags() noexcept -> Flags::Bits { return **this ^= **this; } - -private: - static constexpr const char *LOG_LEVEL_NAMES[]{"Debug", "Info", "Warning", "Critical", "Error"}; -}; - -} // namespace Nodes -} // namespace PoolController diff --git a/src/NorviButtonHandler.cpp b/src/NorviButtonHandler.cpp index 34f93271..78c7e177 100644 --- a/src/NorviButtonHandler.cpp +++ b/src/NorviButtonHandler.cpp @@ -19,6 +19,7 @@ #include #include "Config.hpp" +#include "LogCapture.hpp" namespace PoolController { @@ -43,7 +44,7 @@ uint32_t NorviButtonHandler::pressStartMs_ = 0; // ═══════════════════════════════════════════════════════════════════════════ void NorviButtonHandler::begin() { - Serial.printf("‱ NorviButtonHandler initializing on ADC GPIO%d...\n", PIN_BUTTON_ADC); + LOG_INFO("‱ NorviButtonHandler initializing on ADC GPIO%d...\n", PIN_BUTTON_ADC); pinMode(PIN_BUTTON_ADC, INPUT); @@ -52,11 +53,11 @@ void NorviButtonHandler::begin() { delay(10); lastRaw_ = analogRead(PIN_BUTTON_ADC); - Serial.printf(" ◩ ADC initial value: %u\n", lastRaw_); - Serial.printf(" ◩ Button 1 ADC range: %u–%u\n", THRESH_BTN1_MIN, THRESH_BTN1_MAX); - Serial.printf(" ◩ Button 2 ADC range: %u–%u\n", THRESH_BTN2_MIN, THRESH_BTN2_MAX); - Serial.printf(" ◩ Button 3 ADC range: %u–%u\n", THRESH_BTN3_MIN, THRESH_BTN3_MAX); - Serial.println("✓ NorviButtonHandler initialized"); + LOG_INFO(" ◩ ADC initial value: %u\n", lastRaw_); + LOG_INFO(" ◩ Button 1 ADC range: %u–%u\n", THRESH_BTN1_MIN, THRESH_BTN1_MAX); + LOG_INFO(" ◩ Button 2 ADC range: %u–%u\n", THRESH_BTN2_MIN, THRESH_BTN2_MAX); + LOG_INFO(" ◩ Button 3 ADC range: %u–%u\n", THRESH_BTN3_MIN, THRESH_BTN3_MAX); + LOG_INFO("✓ NorviButtonHandler initialized\n"); } // ═══════════════════════════════════════════════════════════════════════════ @@ -77,7 +78,7 @@ void NorviButtonHandler::loop() { // Add debug logging for ADC changes static uint16_t lastDebugAdc_ = 0xFFFF; if (abs(static_cast(lastRaw_) - static_cast(lastDebugAdc_)) > 50) { - Serial.printf("ADC: %u → %d\n", lastDebugAdc_, static_cast(detected)); + LOG_DEBUG("ADC: %u → %d\n", lastDebugAdc_, static_cast(detected)); lastDebugAdc_ = lastRaw_; } diff --git a/src/NorviOledDisplay.cpp b/src/NorviOledDisplay.cpp index 34d6d74a..b96a0a7d 100644 --- a/src/NorviOledDisplay.cpp +++ b/src/NorviOledDisplay.cpp @@ -28,6 +28,7 @@ #include "Utils.hpp" #include "NetworkManager.hpp" #include "Nodes.hpp" +#include "LogCapture.hpp" #include "SystemMonitor.hpp" #include "TimeClientHelper.hpp" #include "ConfigManager.hpp" @@ -261,16 +262,16 @@ static void drawScrollingText(int16_t x, int16_t y, const __FlashStringHelper *t // ═══════════════════════════════════════════════════════════════════════════ void NorviOledDisplay::begin() { - Serial.println("‱ NorviOledDisplay initializing on I2C GPIO16(SDA)/GPIO17(SCL)..."); + LOG_INFO("‱ NorviOledDisplay initializing on I2C GPIO16(SDA)/GPIO17(SCL)...\n"); Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL); if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { - Serial.println("✖ NorviOledDisplay: SSD1306 allocation failed — display disabled"); + LOG_ERROR("✖ NorviOledDisplay: SSD1306 allocation failed — display disabled\n"); return; } - Serial.println("✓ NorviOledDisplay initialized (128×64, address 0x3C)"); + LOG_INFO("✓ NorviOledDisplay initialized (128×64, address 0x3C)\n"); // ── Splash screen ────────────────────────────────────────────────────── display.clearDisplay(); @@ -287,10 +288,10 @@ void NorviOledDisplay::begin() { // ── Determine starting page based on first-boot state ────────────────── if (needsWiFiSetup()) { currentPage_ = Page::WIFI_SETUP; - Serial.println("→ First boot: no WiFi configured — showing WIFI_SETUP page"); + LOG_INFO("→ First boot: no WiFi configured — showing WIFI_SETUP page\n"); } else if (needsSensorMapping()) { currentPage_ = Page::SENSOR_SETUP; - Serial.println("→ First boot: sensors not mapped — showing SENSOR_SETUP page"); + LOG_INFO("→ First boot: sensors not mapped — showing SENSOR_SETUP page\n"); } else { currentPage_ = Page::MAIN; firstBootDone_ = true; @@ -414,7 +415,7 @@ void NorviOledDisplay::confirmAction() { setupStep_ = SetupStep::IDLE; // Check if both done if (setupSolarDone_ && setupPoolDone_) { - Serial.println("→ Both sensors assigned — save mapping via long-press S3"); + LOG_INFO("→ Both sensors assigned — save mapping via long-press S3\n"); } forceRedraw_ = true; } @@ -1313,7 +1314,7 @@ bool NorviOledDisplay::setupApplyAssignment() { } memcpy(setupSolarAddr_, addr, 8); setupSolarDone_ = true; - Serial.println("→ Sensor assigned as Solar"); + LOG_INFO("→ Sensor assigned as Solar\n"); } else { // If this address is already assigned as Solar, clear that if (setupSolarDone_ && memcmp(addr, setupSolarAddr_, 8) == 0) { @@ -1322,7 +1323,7 @@ bool NorviOledDisplay::setupApplyAssignment() { } memcpy(setupPoolAddr_, addr, 8); setupPoolDone_ = true; - Serial.println("→ Sensor assigned as Pool"); + LOG_INFO("→ Sensor assigned as Pool\n"); } forceRedraw_ = true; diff --git a/src/OperationModeNode.cpp b/src/OperationModeNode.cpp index aa063f20..18b7e2d4 100644 --- a/src/OperationModeNode.cpp +++ b/src/OperationModeNode.cpp @@ -13,6 +13,7 @@ #include "RuleBoost.hpp" #include "Utils.hpp" #include "StateManager.hpp" +#include "LogCapture.hpp" // Static member definition bool OperationModeNode::_suppressPersist = false; @@ -85,11 +86,11 @@ void OperationModeNode::addRule(Rule *rule) { } Rule *OperationModeNode::getRule() { - Serial.printf("getRule: mode = %s\n", _mode.c_str()); + LOG_DEBUG("getRule: mode = %s\n", _mode.c_str()); for (size_t i = 0; i < _ruleVec.size(); i++) { if (_mode.equals(_ruleVec[i]->getMode())) { - Serial.printf("getRule: Active Rule: %s\n", _ruleVec[i]->getMode()); + LOG_DEBUG("getRule: Active Rule: %s\n", _ruleVec[i]->getMode()); // Update ruleset properties _ruleVec[i]->setPoolMaxTemperature(getPoolMaxTemperature()); @@ -117,34 +118,38 @@ bool OperationModeNode::setMode(String mode) { for (auto &rule : _ruleVec) { rule->resetTemperatureExtension(); } + // Curated log event for MQTT event entity — only on actual mode change + if (!_mode.equals(mode)) { + PoolController::LogCapture::logEvent("MODE_CHANGED", "Mode switched to %s", mode.c_str()); + } _mode = mode; - Serial.printf("set mode: %s\n", _mode.c_str()); + LOG_DEBUG("set mode: %s\n", _mode.c_str()); if (!_suppressPersist) saveState(); return true; } else { - Serial.printf("✖ UNDEFINED Mode: %s. Current unchanged mode: %s\n", mode.c_str(), _mode.c_str()); + LOG_ERROR("✖ UNDEFINED Mode: %s. Current unchanged mode: %s\n", mode.c_str(), _mode.c_str()); return false; } } void OperationModeNode::begin() { - Serial.printf("‱ OperationMode Node '%s' initialized.\n", _id); + LOG_INFO("‱ OperationMode Node '%s' initialized.\n", _id); } void OperationModeNode::loop() { if (Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); - Serial.println("ă€œ OperationalMode update rule"); + LOG_DEBUG("ă€œ OperationalMode update rule\n"); // Check time synchronization status static bool lastTimeSyncState = isTimeSyncValid(); bool currentTimeSyncState = isTimeSyncValid(); if (!currentTimeSyncState && lastTimeSyncState) { - Serial.println(" ⚠ WARNING: NTP time sync failed! Using cached estimate."); + LOG_WARN(" ⚠ WARNING: NTP time sync failed! Using cached estimate.\n"); } else if (currentTimeSyncState && !lastTimeSyncState) { - Serial.println(" ✓ NTP time sync recovered."); + LOG_INFO(" ✓ NTP time sync recovered.\n"); } lastTimeSyncState = currentTimeSyncState; @@ -153,7 +158,7 @@ void OperationModeNode::loop() { if (rule != nullptr) { rule->loop(); } else { - Serial.printf(" ✖ no rule defined for mode: %s. Falling back to manual.\n", _mode.c_str()); + LOG_ERROR(" ✖ no rule defined for mode: %s. Falling back to manual.\n", _mode.c_str()); _mode = STATUS_MANU; saveState(); } @@ -161,7 +166,7 @@ void OperationModeNode::loop() { } bool OperationModeNode::handleHomeAssistantCommand(const char *property, const char *value) { - Serial.printf(" ◩ HA command -> property '%s' value = %s\n", property, value); + LOG_DEBUG(" ◩ HA command -> property '%s' value = %s\n", property, value); bool retval = applyProperty(String(property), String(value)); _lastMeasurement = 0; // Trigger instant loop evaluation return retval; @@ -171,10 +176,10 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu bool retval = false; if (property.equalsIgnoreCase("mode")) { - Serial.printf(" ✔ set operational mode: %s\n", value.c_str()); + LOG_INFO(" ✔ set operational mode: %s\n", value.c_str()); retval = this->setMode(value); } else if (property.equalsIgnoreCase("hysteresis")) { - Serial.printf(" ✔ hysteresis: %s\n", value.c_str()); + LOG_INFO(" ✔ hysteresis: %s\n", value.c_str()); float newValue; if (parseFloat(value, newValue, 0.0f, 10.0f)) { if (newValue != _hysteresis) { @@ -183,10 +188,10 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid hysteresis value (must be 0-10): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid hysteresis value (must be 0-10): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("solar-min-temp")) { - Serial.printf(" ✔ solar min temp: %s\n", value.c_str()); + LOG_INFO(" ✔ solar min temp: %s\n", value.c_str()); float newValue; if (parseFloat(value, newValue, 0.0f, 60.0f)) { if (newValue != _solarMinTemp) { @@ -195,10 +200,10 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid solar min temp (must be 0-60°C): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid solar min temp (must be 0-60°C): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("pool-max-temp")) { - Serial.printf(" ✔ pool max temp: %s\n", value.c_str()); + LOG_INFO(" ✔ pool max temp: %s\n", value.c_str()); float newValue; if (parseFloat(value, newValue, 0.0f, 40.0f)) { if (newValue != _poolMaxTemp) { @@ -207,7 +212,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid pool max temp (must be 0-60°C): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid pool max temp (must be 0-60°C): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("timer-start-h")) { TimerSetting timerSetting = getTimerSetting(); @@ -219,7 +224,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid start hour (must be 0-23): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid start hour (must be 0-23): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("timer-start-min")) { TimerSetting timerSetting = getTimerSetting(); @@ -231,7 +236,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid start minutes (must be 0-59): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid start minutes (must be 0-59): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("timer-end-h")) { TimerSetting timerSetting = getTimerSetting(); @@ -243,7 +248,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid end hour (must be 0-23): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid end hour (must be 0-23): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("timer-end-min")) { TimerSetting timerSetting = getTimerSetting(); @@ -255,7 +260,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu } retval = true; } else { - Serial.printf(" ✖ Invalid end minutes (must be 0-59): %s\n", value.c_str()); + LOG_ERROR(" ✖ Invalid end minutes (must be 0-59): %s\n", value.c_str()); } } else if (property.equalsIgnoreCase("timezone")) { int tzIndex = value.toInt(); @@ -263,7 +268,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu setTimezoneIndex(tzIndex); retval = true; } else { - Serial.printf(" ✖ Invalid timezone index: %d\n", tzIndex); + LOG_ERROR(" ✖ Invalid timezone index: %d\n", tzIndex); } } @@ -287,7 +292,7 @@ void OperationModeNode::loadState() { _timerSetting.timerEndHour = StateManager::loadInt("timerEndH", 17); _timerSetting.timerEndMinutes = StateManager::loadInt("timerEndM", 30); - Serial.println("✓ Operational mode state loaded from persistent storage."); + LOG_INFO("✓ Operational mode state loaded from persistent storage.\n"); } void OperationModeNode::saveState() { diff --git a/src/OtaUpdater.cpp b/src/OtaUpdater.cpp index 8bdf55e5..76594480 100644 --- a/src/OtaUpdater.cpp +++ b/src/OtaUpdater.cpp @@ -20,6 +20,7 @@ #include "NetworkManager.hpp" #include "SystemMonitor.hpp" #include "TimeClientHelper.hpp" +#include "LogCapture.hpp" namespace PoolController { @@ -72,7 +73,7 @@ uint8_t OtaUpdater::clockSyncFailCount_ = 0; // ── Public API ── void OtaUpdater::begin() { - Serial.printf("✓ OTA Updater initialized (current: %s)\n", currentVersion_.c_str()); + LOG_INFO("✓ OTA Updater initialized (current: %s)\n", currentVersion_.c_str()); statusMessage_ = "Idle"; } @@ -168,11 +169,11 @@ bool OtaUpdater::checkForUpdate() { return false; statusMessage_ = "Checking for updates..."; - Serial.println("OTA: Checking for firmware update..."); + LOG_INFO("OTA: Checking for firmware update...\n"); if (!fetchLatestRelease()) { statusMessage_ = "Check failed"; - Serial.println("OTA: Update check failed"); + LOG_ERROR("OTA: Update check failed\n"); return false; } @@ -180,13 +181,13 @@ bool OtaUpdater::checkForUpdate() { if (isNewerVersion(currentVersion_, latestVersion_)) { updateAvailable_ = true; statusMessage_ = "Update available: v" + getLatestVersion(); - Serial.printf("OTA: New version available: %s (current: %s)\n", latestVersion_.c_str(), currentVersion_.c_str()); + LOG_INFO("OTA: New version available: %s (current: %s)\n", latestVersion_.c_str(), currentVersion_.c_str()); return true; } updateAvailable_ = false; statusMessage_ = "Up to date (v" + currentVersion_ + ")"; - Serial.println("OTA: Firmware is up to date"); + LOG_INFO("OTA: Firmware is up to date\n"); return false; } @@ -194,12 +195,12 @@ bool OtaUpdater::checkForUpdate() { bool OtaUpdater::startUpdate() { if (updateInProgress_) { - Serial.println("OTA: Update already in progress"); + LOG_WARN("OTA: Update already in progress\n"); return false; } if (!updateAvailable_ || downloadUrl_.length() == 0) { statusMessage_ = "No update available"; - Serial.println("OTA: No update package available"); + LOG_WARN("OTA: No update package available\n"); return false; } if (!NetworkManager::isWiFiConnected()) { @@ -219,7 +220,7 @@ bool OtaUpdater::startUpdate() { const size_t estimatedFirmwareSize = 600 * 1024; // 600KB estimate if (!hasSufficientSpace(estimatedFirmwareSize)) { - Serial.println("OTA: Cannot start update: insufficient flash space"); + LOG_ERROR("OTA: Cannot start update: insufficient flash space\n"); return false; } @@ -229,13 +230,13 @@ bool OtaUpdater::startUpdate() { progress_ = 0; statusMessage_ = "Downloading... 0%"; - Serial.printf("OTA: Starting download from %s\n", downloadUrl_.c_str()); + LOG_INFO("OTA: Starting download from %s\n", downloadUrl_.c_str()); bool ok = downloadAndApply(downloadUrl_); if (!ok) { updateInProgress_ = false; statusMessage_ = "Update failed!"; - Serial.println("OTA: Update failed!"); + LOG_ERROR("OTA: Update failed!\n"); // updateAvailable_ stays true so the user can retry } else { // On success, clear the flag before reboot @@ -258,7 +259,7 @@ bool OtaUpdater::fetchLatestRelease() { // Only sync if time is not already set (time(0) > 100000 means year 2000+) time_t now = time(nullptr); if (now < 100000) { // Time not set yet - Serial.println("OTA: Syncing time before GitHub TLS verification..."); + LOG_INFO("OTA: Syncing time before GitHub TLS verification...\n"); // Ensure NTP client is set up timeClientSetup(ConfigManager::getNtp().server.c_str()); // Sync system clock from NTP (sets settimeofday for mbedTLS) @@ -267,11 +268,11 @@ bool OtaUpdater::fetchLatestRelease() { delay(2000); now = time(nullptr); if (now < 100000) { - Serial.println("OTA: Time sync failed, TLS verification may fail"); + LOG_WARN("OTA: Time sync failed, TLS verification may fail\n"); // Continue anyway - the TLS verification might still work // if the device has a valid time from a previous sync } else { - Serial.printf("OTA: Time synced: %ld\n", now); + LOG_INFO("OTA: Time synced: %ld\n", now); } } @@ -289,7 +290,7 @@ bool OtaUpdater::fetchLatestRelease() { // Build API URL String url = String("https://api.github.com/repos/") + GITHUB_REPO + "/releases/latest"; - Serial.printf("OTA: Fetching %s\n", url.c_str()); + LOG_DEBUG("OTA: Fetching %s\n", url.c_str()); HTTPClient http; http.begin(client, url); @@ -298,7 +299,7 @@ bool OtaUpdater::fetchLatestRelease() { int httpCode = http.GET(); if (httpCode != 200) { - Serial.printf("OTA: GitHub API returned HTTP %d\n", httpCode); + LOG_ERROR("OTA: GitHub API returned HTTP %d\n", httpCode); http.end(); return false; } @@ -309,14 +310,14 @@ bool OtaUpdater::fetchLatestRelease() { http.end(); if (err) { - Serial.printf("OTA: JSON parse error: %s\n", err.c_str()); + LOG_ERROR("OTA: JSON parse error: %s\n", err.c_str()); return false; } // Extract tag name (e.g. "v3.2.0") const char *tag = doc["tag_name"]; if (!tag || strlen(tag) == 0) { - Serial.println("OTA: No tag_name in response"); + LOG_ERROR("OTA: No tag_name in response\n"); return false; } latestVersion_ = String(tag); @@ -361,7 +362,7 @@ bool OtaUpdater::fetchLatestRelease() { const char *url = asset["browser_download_url"]; if (url) { downloadUrl_ = String(url); - Serial.println("OTA: Board-specific asset not found, using generic firmware.bin"); + LOG_WARN("OTA: Board-specific asset not found, using generic firmware.bin\n"); break; } } @@ -369,11 +370,11 @@ bool OtaUpdater::fetchLatestRelease() { } if (downloadUrl_.length() == 0) { - Serial.println("OTA: No firmware binary found in release assets"); + LOG_ERROR("OTA: No firmware binary found in release assets\n"); return false; } - Serial.printf("OTA: Found %s → %s\n", latestVersion_.c_str(), downloadUrl_.c_str()); + LOG_INFO("OTA: Found %s → %s\n", latestVersion_.c_str(), downloadUrl_.c_str()); return true; } @@ -431,14 +432,14 @@ bool OtaUpdater::downloadAndApply(const String &url) { int httpCode = http.GET(); if (httpCode != 200) { - Serial.printf("OTA: Download returned HTTP %d\n", httpCode); + LOG_ERROR("OTA: Download returned HTTP %d\n", httpCode); http.end(); return false; } int totalSize = http.getSize(); if (totalSize <= 0) { - Serial.println("OTA: Invalid content size"); + LOG_ERROR("OTA: Invalid content size\n"); http.end(); return false; } @@ -449,14 +450,14 @@ bool OtaUpdater::downloadAndApply(const String &url) { const size_t kMinFirmwareSize = 50 * 1024; // 50KB minimum if (static_cast(totalSize) > kMaxFirmwareSize) { - Serial.printf("OTA: Firmware too large: %d bytes (max %u)\n", totalSize, kMaxFirmwareSize); + LOG_ERROR("OTA: Firmware too large: %d bytes (max %u)\n", totalSize, kMaxFirmwareSize); statusMessage_ = "Error: Firmware too large"; http.end(); return false; } if (static_cast(totalSize) < kMinFirmwareSize) { - Serial.printf("OTA: Firmware too small: %d bytes (min %u)\n", totalSize, kMinFirmwareSize); + LOG_ERROR("OTA: Firmware too small: %d bytes (min %u)\n", totalSize, kMinFirmwareSize); statusMessage_ = "Error: Firmware too small"; http.end(); return false; @@ -464,15 +465,15 @@ bool OtaUpdater::downloadAndApply(const String &url) { // Verify we have sufficient space for this specific firmware size if (!hasSufficientSpace(static_cast(totalSize))) { - Serial.println("OTA: Insufficient space for this firmware"); + LOG_ERROR("OTA: Insufficient space for this firmware\n"); http.end(); return false; } - Serial.printf("OTA: Download size: %d bytes\n", totalSize); + LOG_DEBUG("OTA: Download size: %d bytes\n", totalSize); if (!Update.begin(totalSize)) { - Serial.printf("OTA: Update.begin() failed: %s\n", Update.errorString()); + LOG_ERROR("OTA: Update.begin() failed: %s\n", Update.errorString()); http.end(); return false; } @@ -500,7 +501,7 @@ bool OtaUpdater::downloadAndApply(const String &url) { size_t written = Update.write(buffer, read); if (written != read) { - Serial.printf("OTA: Write error at byte %d: %s\n", totalRead, Update.errorString()); + LOG_ERROR("OTA: Write error at byte %d: %s\n", totalRead, Update.errorString()); Update.end(false); http.end(); return false; @@ -514,17 +515,17 @@ bool OtaUpdater::downloadAndApply(const String &url) { http.end(); if (totalRead != totalSize) { - Serial.printf("OTA: Incomplete download (%d / %d)\n", totalRead, totalSize); + LOG_ERROR("OTA: Incomplete download (%d / %d)\n", totalRead, totalSize); Update.end(false); return false; } if (!Update.end(true)) { - Serial.printf("OTA: Update.end() failed: %s\n", Update.errorString()); + LOG_ERROR("OTA: Update.end() failed: %s\n", Update.errorString()); return false; } - Serial.println("OTA: Update successful! Rebooting..."); + LOG_INFO("OTA: Update successful! Rebooting...\n"); statusMessage_ = "Update successful! Rebooting..."; Serial.flush(); NetworkManager::restart(); @@ -556,12 +557,12 @@ bool OtaUpdater::hasSufficientSpace(size_t firmwareSize) { requiredSpace = std::max(requiredSpace, kMinFreeSpace); if (availableSpace < requiredSpace) { - Serial.printf("OTA: Insufficient flash space. Need %u bytes, have %u bytes\n", requiredSpace, availableSpace); + LOG_ERROR("OTA: Insufficient flash space. Need %u bytes, have %u bytes\n", requiredSpace, availableSpace); statusMessage_ = "Error: Insufficient flash space"; return false; } - Serial.printf("OTA: Sufficient space available (%u bytes free, %u bytes required)\n", availableSpace, requiredSpace); + LOG_INFO("OTA: Sufficient space available (%u bytes free, %u bytes required)\n", availableSpace, requiredSpace); return true; } diff --git a/src/PoolController.cpp b/src/PoolController.cpp index ea5338db..868676ba 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -34,6 +34,7 @@ #include "OtaUpdater.hpp" #include "Utils.hpp" #include "WpsProvisioner.hpp" +#include "LogCapture.hpp" #include "StatusLed.hpp" @@ -115,21 +116,21 @@ static void loadSensorAddressMapping() { solarTemperatureNode.setAddressFilter(solarAddr); char buf[17]; addressToString(solarAddr, buf, sizeof(buf)); - Serial.printf("‱ Sensor mapping: Solar address loaded [%s]\n", buf); + LOG_INFO("‱ Sensor mapping: Solar address loaded [%s]\n", buf); } if (plen == 8 && !isAddressZero(poolAddr)) { poolTemperatureNode.setAddressFilter(poolAddr); char buf[17]; addressToString(poolAddr, buf, sizeof(buf)); - Serial.printf("‱ Sensor mapping: Pool address loaded [%s]\n", buf); + LOG_INFO("‱ Sensor mapping: Pool address loaded [%s]\n", buf); } if ((slen == 8 && !isAddressZero(solarAddr)) || (plen == 8 && !isAddressZero(poolAddr))) { - Serial.println("‱ Sensor mapping: address filters applied (one or both sensors)"); + LOG_INFO("‱ Sensor mapping: address filters applied (one or both sensors)\n"); } else { - Serial.println("‱ Sensor mapping: no addresses configured — using default device indices"); - Serial.println(" â„č To configure: long-press Button 1 → Sensor Setup page → assign sensors"); + LOG_WARN("‱ Sensor mapping: no addresses configured — using default device indices\n"); + LOG_INFO(" â„č To configure: long-press Button 1 → Sensor Setup page → assign sensors\n"); } } @@ -166,33 +167,33 @@ auto PoolControllerContext::initializeController() -> void { continue; } #endif - Serial.printf("✖ PIN CONFLICT: %s (pin %d) and %s (pin %d) use same pin!\n", pinNames[i], pins[i], pinNames[j], pins[j]); + LOG_ERROR("✖ PIN CONFLICT: %s (pin %d) and %s (pin %d) use same pin!\n", pinNames[i], pins[i], pinNames[j], pins[j]); pinConflict = true; } } } if (pinConflict) { - Serial.println("✖ FATAL: Pin configuration conflicts detected!"); - Serial.println(" System will reboot in 5 seconds to try and recover..."); + LOG_ERROR("✖ FATAL: Pin configuration conflicts detected!\n"); + LOG_WARN(" System will reboot in 5 seconds to try and recover...\n"); Serial.flush(); delay(5000); ESP.restart(); // F27 Fix! Clean restart instead of blocking WDT loop } else { - Serial.println("✓ Pin configuration validated - no conflicts (optimierte Belegung)"); - Serial.printf(" Solar Temp (DS18B20): GPIO %d\n", PIN_DS_SOLAR); - Serial.printf(" Pool Temp (DS18B20): GPIO %d", PIN_DS_POOL); + LOG_INFO("✓ Pin configuration validated - no conflicts (optimierte Belegung)\n"); + LOG_INFO(" Solar Temp (DS18B20): GPIO %d\n", PIN_DS_SOLAR); #ifdef NORVI_AE01_R - Serial.print(" (shared bus via GPIO25)"); + LOG_INFO(" Pool Temp (DS18B20): GPIO %d (shared bus via GPIO25)\n", PIN_DS_POOL); +#else + LOG_INFO(" Pool Temp (DS18B20): GPIO %d\n", PIN_DS_POOL); #endif - Serial.println(); - Serial.printf(" Pool Pump (Relay): GPIO %d\n", PIN_RELAY_POOL); - Serial.printf(" Solar Pump (Relay): GPIO %d\n", PIN_RELAY_SOLAR); - Serial.printf(" Status LED: GPIO %d", PIN_LED_STATUS); + LOG_INFO(" Pool Pump (Relay): GPIO %d\n", PIN_RELAY_POOL); + LOG_INFO(" Solar Pump (Relay): GPIO %d\n", PIN_RELAY_SOLAR); #ifdef LED_BUILTIN - Serial.print(" (LED_BUILTIN)"); + LOG_INFO(" Status LED: GPIO %d (LED_BUILTIN)\n", PIN_LED_STATUS); +#else + LOG_INFO(" Status LED: GPIO %d\n", PIN_LED_STATUS); #endif - Serial.println(); } // Set measurement intervals and propagate to all nodes @@ -333,13 +334,13 @@ auto PoolControllerContext::setup() -> void { } else { operationModeNode.setMode("auto"); } - Serial.printf("→ Mode switched to: %s\n", operationModeNode.getMode().c_str()); + LOG_INFO("→ Mode switched to: %s\n", operationModeNode.getMode().c_str()); break; } case NorviOledDisplay::MenuItem::PUMP: // Toggle pool pump poolPumpNode.setSwitch(!poolPumpNode.getSwitch()); - Serial.printf("→ Pump toggled: %s\n", poolPumpNode.getSwitch() ? "ON" : "OFF"); + LOG_INFO("→ Pump toggled: %s\n", poolPumpNode.getSwitch() ? "ON" : "OFF"); break; case NorviOledDisplay::MenuItem::EXIT: // No action — just exit @@ -361,7 +362,7 @@ auto PoolControllerContext::setup() -> void { uint8_t solarAddr[8], poolAddr[8]; NorviOledDisplay::getMapping(solarAddr, poolAddr); ConfigManager::saveSensorMapping(solarAddr, poolAddr); - Serial.println("→ Sensor mapping saved — rebooting..."); + LOG_INFO("→ Sensor mapping saved — rebooting...\n"); NetworkManager::restart(); return true; } @@ -372,7 +373,7 @@ auto PoolControllerContext::setup() -> void { // --- Boot-loop detection --- bootLoopDetected_ = SystemMonitor::detectBootLoop(); if (bootLoopDetected_) { - Serial.println("✖ SAFE MODE ACTIVE — all relays forced OFF"); + LOG_ERROR("✖ SAFE MODE ACTIVE — all relays forced OFF\n"); DegradationManager::forceSafeMode(); // Clear stored relay states @@ -407,9 +408,9 @@ auto PoolControllerContext::setup() -> void { if (solarTemperatureNode.hasAddressFilter() || poolTemperatureNode.hasAddressFilter()) { char buf[17]; solarTemperatureNode.getDeviceAddressString(buf, sizeof(buf)); - Serial.printf(" ◩ Solar node → device [%s] (status: %s)\n", buf, solarTemperatureNode.isSensorFound() ? "✓" : "✖"); + LOG_INFO(" ◩ Solar node → device [%s] (status: %s)\n", buf, solarTemperatureNode.isSensorFound() ? "✓" : "✖"); poolTemperatureNode.getDeviceAddressString(buf, sizeof(buf)); - Serial.printf(" ◩ Pool node → device [%s] (status: %s)\n", buf, poolTemperatureNode.isSensorFound() ? "✓" : "✖"); + LOG_INFO(" ◩ Pool node → device [%s] (status: %s)\n", buf, poolTemperatureNode.isSensorFound() ? "✓" : "✖"); } OperationModeNode::suppressPersist(false); @@ -420,7 +421,7 @@ auto PoolControllerContext::setup() -> void { // OTA safety: detect version transition and verify config integrity ConfigManager::logOtaTransition(); - Serial.printf("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); + LOG_INFO("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); } /** @@ -451,7 +452,7 @@ auto PoolControllerContext::loop() -> void { bootCounterCleared = true; SystemMonitor::clearBootLoopCounter(); if (bootLoopDetected_) { - Serial.println("→ Safe-mode: 5 min stable — boot-loop counter cleared"); + LOG_INFO("→ Safe-mode: 5 min stable — boot-loop counter cleared\n"); DegradationManager::unforceSafeMode(); bootLoopDetected_ = false; } diff --git a/src/RelayModuleNode.cpp b/src/RelayModuleNode.cpp index 0ee3d595..b79344ed 100644 --- a/src/RelayModuleNode.cpp +++ b/src/RelayModuleNode.cpp @@ -11,6 +11,7 @@ #include "RelayModuleNode.hpp" #include "Utils.hpp" #include "DegradationManager.hpp" +#include "LogCapture.hpp" RelayModuleNode::RelayModuleNode(const char *id, const char *name, const uint8_t pin) : RelayModuleNode(id, name, pin, true) {} @@ -24,7 +25,7 @@ RelayModuleNode::RelayModuleNode(const char *id, const char *name, const uint8_t } void RelayModuleNode::begin() { - Serial.printf("‱ RelayModule Node '%s' initializing on PIN %d...\n", _id, _pin); + LOG_INFO("‱ RelayModule Node '%s' initializing on PIN %d...\n", _id, _pin); // ── Safe-start sequence ─────────────────────────────────────────────── // @@ -55,7 +56,7 @@ void RelayModuleNode::begin() { // 3. Apply polarity and transition to persisted state (OFF→ON if needed) digitalWrite(_pin, _currentState == _activeLow ? LOW : HIGH); - Serial.printf(" ◩ Relay restored to state: %s\n", _currentState ? "ON" : "OFF"); + LOG_INFO(" ◩ Relay restored to state: %s\n", _currentState ? "ON" : "OFF"); } void RelayModuleNode::setSwitch(const bool state) { @@ -67,7 +68,7 @@ void RelayModuleNode::setSwitch(const bool state) { // a relay to switch ON. Only OFF transitions are permitted so relays default // to the safe OFF state. if (state && PoolController::DegradationManager::isSafe()) { - Serial.println(" ⚠ SAFE MODE — ignoring relay ON request"); + LOG_WARN(" ⚠ SAFE MODE — ignoring relay ON request\n"); return; } @@ -76,12 +77,15 @@ void RelayModuleNode::setSwitch(const bool state) { digitalWrite(_pin, state == _activeLow ? LOW : HIGH); _currentState = state; + // Curated log event for MQTT event entity (only on real state change) + PoolController::LogCapture::logEvent(state ? "PUMP_ON" : "PUMP_OFF", "%s %s", getId(), state ? "ON" : "OFF"); + // Persist relay state via Preferences (NVS) preferences.begin(_id, false); preferences.putBool("switch", state); preferences.end(); - Serial.printf(" ◩ Relay '%s' switched to: %s\n", _id, state ? "ON" : "OFF"); + LOG_INFO(" ◩ Relay '%s' switched to: %s\n", _id, state ? "ON" : "OFF"); } bool RelayModuleNode::getSwitch() { @@ -91,6 +95,6 @@ bool RelayModuleNode::getSwitch() { void RelayModuleNode::loop() { if (Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); - Serial.printf("ă€œ Relay '%s' status: %s\n", _id, getSwitch() ? "ON" : "OFF"); + LOG_DEBUG("ă€œ Relay '%s' status: %s\n", _id, getSwitch() ? "ON" : "OFF"); } } diff --git a/src/RuleAuto.cpp b/src/RuleAuto.cpp index 87a4dd0f..24e77463 100644 --- a/src/RuleAuto.cpp +++ b/src/RuleAuto.cpp @@ -10,23 +10,25 @@ #include "RuleAuto.hpp" #include // for isnan() +#include "LogCapture.hpp" + RuleAuto::RuleAuto(RelayModuleNode *solarRelay, RelayModuleNode *poolRelay) { _solarRelay = solarRelay; _poolRelay = poolRelay; } void RuleAuto::loop() { - Serial.println("§ RuleAuto: loop"); + LOG_INFO("§ RuleAuto: loop\n"); // Validate temperature readings before making decisions float poolTemp = getPoolTemperature(); float solarTemp = getSolarTemperature(); if (std::isnan(poolTemp) || std::isnan(solarTemp)) { - Serial.println(" ⚠ RuleAuto: Invalid temperature readings detected"); - Serial.printf(" Pool temp: %f\n", poolTemp); - Serial.printf(" Solar temp: %f\n", solarTemp); - Serial.println(" Turning off solar pump for safety"); + LOG_WARN(" ⚠ RuleAuto: Invalid temperature readings detected\n"); + LOG_WARN(" Pool temp: %f\n", poolTemp); + LOG_WARN(" Solar temp: %f\n", solarTemp); + LOG_WARN(" Turning off solar pump for safety\n"); // Turn off solar pump for safety, but keep pool pump running on timer _solarRelay->setSwitch(false); @@ -44,40 +46,40 @@ void RuleAuto::loop() { if (_solarRelay->getSwitch()) { // solar is on if (getSolarTemperature() < (getSolarMinTemperature() - hyst)) { - Serial.printf(" § RuleAuto: Solar below min. required solar temp. (%f). Switch solar off\n", getSolarMinTemperature()); + LOG_INFO(" § RuleAuto: Solar below min. required solar temp. (%f). Switch solar off\n", getSolarMinTemperature()); _solarRelay->setSwitch(false); } else if (getPoolTemperature() >= (getSolarTemperature() + hyst)) { - Serial.printf(" § RuleAuto: Pool temp. (%f) reaches solar temp (%f). Switch solar off\n", getPoolTemperature(), + LOG_INFO(" § RuleAuto: Pool temp. (%f) reaches solar temp (%f). Switch solar off\n", getPoolTemperature(), getSolarTemperature()); _solarRelay->setSwitch(false); } else if (getPoolTemperature() >= getPoolMaxTemperature()) { - Serial.printf(" § RuleAuto: Pool temp. (%f) reached max. temperature (%f). Switch solar off\n", getPoolTemperature(), + LOG_INFO(" § RuleAuto: Pool temp. (%f) reached max. temperature (%f). Switch solar off\n", getPoolTemperature(), getPoolMaxTemperature()); _solarRelay->setSwitch(false); } else { - Serial.println(" § RuleAuto: Solar on -> no change"); + LOG_INFO(" § RuleAuto: Solar on -> no change\n"); } } else { // solar is off if ((getPoolTemperature() <= (getPoolMaxTemperature() - hyst)) && (getPoolTemperature() <= (getSolarTemperature() - hyst)) && ((getSolarMinTemperature() + hyst) <= getSolarTemperature())) { - Serial.printf(" § RuleAuto: Pool temp (%f) below max temp minus hysteresis (%f). Switch solar on\n", - getPoolTemperature(), getPoolMaxTemperature() - hyst); + LOG_INFO(" § RuleAuto: Pool temp (%f) below max temp minus hysteresis (%f). Switch solar on\n", getPoolTemperature(), + getPoolMaxTemperature() - hyst); _solarRelay->setSwitch(true); } else { - Serial.println(" § RuleAuto: Solar off -> no change"); + LOG_INFO(" § RuleAuto: Solar off -> no change\n"); } } } else { if (_solarRelay->getSwitch()) { - Serial.println(" § RuleAuto: pool pump is disabled. Switch solar off"); + LOG_INFO(" § RuleAuto: pool pump is disabled. Switch solar off\n"); _solarRelay->setSwitch(false); } } - Serial.printf(" § RuleAuto: Pool temp. : %f\n", getPoolTemperature()); - Serial.printf(" § RuleAuto: max. Pool temp.: %f\n", getPoolMaxTemperature()); - Serial.printf(" § RuleAuto: Solar temp. : %f\n", getSolarTemperature()); - Serial.printf(" § RuleAuto: min. Solar temp.: %f\n", getSolarMinTemperature()); + LOG_INFO(" § RuleAuto: Pool temp. : %f\n", getPoolTemperature()); + LOG_INFO(" § RuleAuto: max. Pool temp.: %f\n", getPoolMaxTemperature()); + LOG_INFO(" § RuleAuto: Solar temp. : %f\n", getSolarTemperature()); + LOG_INFO(" § RuleAuto: min. Solar temp.: %f\n", getSolarMinTemperature()); } diff --git a/src/RuleBoost.cpp b/src/RuleBoost.cpp index c808b70f..d20dd7b2 100644 --- a/src/RuleBoost.cpp +++ b/src/RuleBoost.cpp @@ -11,14 +11,15 @@ #include #include // For isnan() +#include "LogCapture.hpp" + RuleBoost::RuleBoost(RelayModuleNode *solarRelay, RelayModuleNode *poolRelay) { _solarRelay = solarRelay; _poolRelay = poolRelay; } void RuleBoost::loop() { - Serial.print(cIndent); - Serial.println(F("§ RuleBoost: loop")); + LOG_INFO("%s§ RuleBoost: loop\n", cIndent); // Check for invalid temperatures (NaN from sensor disconnect) float poolTemp = getPoolTemperature(); @@ -27,8 +28,7 @@ void RuleBoost::loop() { // Safety: Turn off solar if any temperature is invalid if (isnan(poolTemp) || isnan(solarTemp)) { if (_solarRelay->getSwitch()) { - Serial.print(cIndent); - Serial.println(F("§ RuleBoost: Invalid temperature sensor. Switch solar off for safety")); + LOG_INFO("%s§ RuleBoost: Invalid temperature sensor. Switch solar off for safety\n", cIndent); _solarRelay->setSwitch(false); } return; @@ -38,26 +38,22 @@ void RuleBoost::loop() { if (_solarRelay->getSwitch()) { // solar is ON — check if it should turn OFF if (poolTemp > getPoolMaxTemperature()) { - Serial.print(cIndent); - Serial.println(F("§ RuleBoost: Maximum pool temp reached. Switch solar off")); + LOG_INFO("%s§ RuleBoost: Maximum pool temp reached. Switch solar off\n", cIndent); _solarRelay->setSwitch(false); } else if (poolTemp > (solarTemp + getTemperatureHysteresis())) { - Serial.print(cIndent); - Serial.println(F("§ RuleBoost: Pool temp reaches solar temp. Switch solar off")); + LOG_INFO("%s§ RuleBoost: Pool temp reaches solar temp. Switch solar off\n", cIndent); _solarRelay->setSwitch(false); } } else { // solar is OFF — check if it should turn ON if ((poolTemp < (getPoolMaxTemperature() - getTemperatureHysteresis())) && (poolTemp < (solarTemp - getTemperatureHysteresis()))) { - Serial.print(cIndent); - Serial.println(F("§ RuleBoost: below max. Temperature. Switch solar on")); + LOG_INFO("%s§ RuleBoost: below max. Temperature. Switch solar on\n", cIndent); _solarRelay->setSwitch(true); } } } else { - Serial.print(cIndent); - Serial.println(F("§ RuleBoost: pool pump is disabled.")); + LOG_INFO("%s§ RuleBoost: pool pump is disabled.\n", cIndent); if (_solarRelay->getSwitch()) { _solarRelay->setSwitch(false); } diff --git a/src/RuleManu.cpp b/src/RuleManu.cpp index e32042a9..bbad2f97 100644 --- a/src/RuleManu.cpp +++ b/src/RuleManu.cpp @@ -9,10 +9,11 @@ #include "RuleManu.hpp" #include +#include "LogCapture.hpp" RuleManu::RuleManu() {} void RuleManu::loop() { // no ruling if manual - Serial.println(F(" ◩ § RuleManu: loop")); + LOG_INFO(" ◩ § RuleManu: loop\n"); } diff --git a/src/RuleTimer.cpp b/src/RuleTimer.cpp index e11e2e69..435d0c07 100644 --- a/src/RuleTimer.cpp +++ b/src/RuleTimer.cpp @@ -8,6 +8,7 @@ */ #include "RuleTimer.hpp" +#include "LogCapture.hpp" RuleTimer::RuleTimer(RelayModuleNode *solarRelay, RelayModuleNode *poolRelay) { _solarRelay = solarRelay; @@ -15,7 +16,7 @@ RuleTimer::RuleTimer(RelayModuleNode *solarRelay, RelayModuleNode *poolRelay) { } void RuleTimer::loop() { - Serial.println("§ RuleTimer: loop"); + LOG_INFO("§ RuleTimer: loop\n"); _poolRelay->setSwitch(checkPoolPumpTimer(getPoolTemperature())); diff --git a/src/StatusLed.cpp b/src/StatusLed.cpp index 6c52d19c..b28f904b 100644 --- a/src/StatusLed.cpp +++ b/src/StatusLed.cpp @@ -16,6 +16,7 @@ #include #include "Config.hpp" +#include "LogCapture.hpp" namespace PoolController { @@ -32,10 +33,18 @@ void StatusLed::begin() { // ModellunabhĂ€ngiger Pin — ĂŒberschreibe Config.hpp-Default, falls die // Platform einen spezifischen LED_BUILTIN definiert. #ifdef LED_BUILTIN - ledPin_ = static_cast(LED_BUILTIN); - Serial.printf("‱ StatusLed using LED_BUILTIN (GPIO %d)\n", ledPin_); + // Override Config.hpp default only when it uses the generic value (GPIO2), + // meaning no board-specific config set a different pin. This lets NORVI + // (external LED on GPIO27) keep its explicit assignment while standard + // ESP32 dev boards still get LED_BUILTIN (typically also GPIO2). + if (PIN_LED_STATUS == 2) { + ledPin_ = static_cast(LED_BUILTIN); + LOG_INFO("‱ StatusLed using LED_BUILTIN (GPIO %d)\n", ledPin_); + } else { + LOG_INFO("‱ StatusLed using config pin GPIO %d (board-specific, LED_BUILTIN overridden)\n", ledPin_); + } #else - Serial.printf("‱ StatusLed using default GPIO %d (no LED_BUILTIN defined)\n", ledPin_); + LOG_INFO("‱ StatusLed using config default GPIO %d (no LED_BUILTIN)\n", ledPin_); #endif pinMode(ledPin_, OUTPUT); @@ -46,10 +55,10 @@ void StatusLed::begin() { if (warnPin_ >= 0) { pinMode(static_cast(warnPin_), OUTPUT); digitalWrite(static_cast(warnPin_), LOW); - Serial.printf("‱ StatusLed WARN pin enabled on GPIO %d\n", warnPin_); + LOG_INFO("‱ StatusLed WARN pin enabled on GPIO %d\n", warnPin_); } - Serial.println("✓ StatusLed initialized"); + LOG_INFO("✓ StatusLed initialized\n"); } // ── Pattern setzen ───────────────────────────────────────────────────────── diff --git a/src/SystemMonitor.hpp b/src/SystemMonitor.hpp index 466b2dc0..b87d6a1f 100644 --- a/src/SystemMonitor.hpp +++ b/src/SystemMonitor.hpp @@ -23,6 +23,8 @@ #include #include +#include "LogCapture.hpp" + namespace PoolController { /** @@ -92,7 +94,7 @@ class SystemMonitor { // Critical memory — reboot immediately if (freeHeap < CRITICAL_MEMORY_THRESHOLD) { - Serial.printf("CRITICAL: Free heap %d bytes < %d bytes. Rebooting...\n", freeHeap, CRITICAL_MEMORY_THRESHOLD); + LOG_ERROR("CRITICAL: Free heap %d bytes < %d bytes. Rebooting...\n", freeHeap, CRITICAL_MEMORY_THRESHOLD); Serial.flush(); delay(1000); ESP.restart(); @@ -100,8 +102,8 @@ class SystemMonitor { // Low memory — log warning if (freeHeap < LOW_MEMORY_THRESHOLD && !lowMemoryWarning) { - Serial.printf("WARNING: Low memory detected. Free heap: %d bytes " - "(min: %d)\n", + LOG_WARN("WARNING: Low memory detected. Free heap: %d bytes " + "(min: %d)\n", freeHeap, minFreeHeap); lowMemoryWarning = true; } else if (freeHeap >= LOW_MEMORY_THRESHOLD && lowMemoryWarning) { @@ -117,7 +119,7 @@ class SystemMonitor { /** Force a reboot */ static void reboot() { - Serial.println("System reboot requested"); + LOG_INFO("System reboot requested\n"); Serial.flush(); delay(1000); ESP.restart(); @@ -154,12 +156,12 @@ class SystemMonitor { int bootCount = prefs.getInt("bootCount", 0) + 1; - Serial.printf(" Boot counter: %d\n", bootCount); + LOG_INFO(" Boot counter: %d\n", bootCount); bool isBootLoop = (bootCount >= BOOT_LOOP_MAX_COUNT); if (isBootLoop) { - Serial.printf("✖ BOOT-LOOP DETECTED (%d consecutive boots)\n", bootCount); - Serial.println(" Entering safe mode — all relays OFF"); + LOG_ERROR("✖ BOOT-LOOP DETECTED (%d consecutive boots)\n", bootCount); + LOG_ERROR(" Entering safe mode — all relays OFF\n"); } prefs.putInt("bootCount", bootCount); diff --git a/src/TimeClientHelper.cpp b/src/TimeClientHelper.cpp index ec36eb57..aefee3ea 100644 --- a/src/TimeClientHelper.cpp +++ b/src/TimeClientHelper.cpp @@ -9,6 +9,7 @@ #include // For settimeofday +#include "LogCapture.hpp" #include "NetworkManager.hpp" #include "TimeClientHelper.hpp" @@ -150,9 +151,9 @@ void syncSystemClock() { tv.tv_sec = ntpTime; tv.tv_usec = 0; settimeofday(&tv, nullptr); - Serial.printf("Time: System clock set to %ld\n", ntpTime); + LOG_INFO("Time: System clock set to %ld\n", ntpTime); } else { - Serial.println("Time: Cannot set system clock - NTP time not valid"); + LOG_WARN("Time: Cannot set system clock - NTP time not valid\n"); } } diff --git a/src/Timer.cpp b/src/Timer.cpp index b3db18a4..1571f04e 100644 --- a/src/Timer.cpp +++ b/src/Timer.cpp @@ -10,6 +10,7 @@ #include "Timer.hpp" #include "TimeClientHelper.hpp" #include "ConfigManager.hpp" +#include "LogCapture.hpp" /** * Get current date/time, with validation @@ -87,7 +88,7 @@ uint16_t calculateEffectiveEndMinutes(uint16_t baseStartMinutes, uint16_t baseEn // Calculate new end minutes (add runtime to start, can wrap past midnight) uint16_t extended = baseStartMinutes + totalRuntime; - Serial.printf(" → TempCirc: %.1f°C, base=%umin, extra=%umin, total=%umin, end=%02d:%02d\n", poolTemp, baseRuntime, extra, + LOG_INFO(" → TempCirc: %.1f°C, base=%umin, extra=%umin, total=%umin, end=%02d:%02d\n", poolTemp, baseRuntime, extra, totalRuntime, (extended / 60) % 24, extended % 60); return extended; diff --git a/src/WebPortal.cpp b/src/WebPortal.cpp index 73ca8d13..d8529d8b 100644 --- a/src/WebPortal.cpp +++ b/src/WebPortal.cpp @@ -38,6 +38,7 @@ #include "SystemMonitor.hpp" #include "TimeClientHelper.hpp" #include "Version.h" +#include "LogCapture.hpp" namespace PoolController { @@ -60,7 +61,7 @@ constexpr uint16_t WebPortal::kDnsPort; bool WebPortal::begin() { if (!LittleFS.begin(false)) { - Serial.println("✖ LittleFS mount failed — static web assets may be unavailable"); + LOG_ERROR("✖ LittleFS mount failed — static web assets may be unavailable\n"); } setupRoutes(); @@ -69,12 +70,12 @@ bool WebPortal::begin() { if (NetworkManager::isApMode()) { dnsServer_.setErrorReplyCode(DNSReplyCode::NoError); dnsServer_.start(kDnsPort, "*", WiFi.softAPIP()); - Serial.println("✓ Captive Portal DNS running."); + LOG_INFO("✓ Captive Portal DNS running.\n"); dnsServerStarted_ = true; } server_.begin(); - Serial.println("✓ Web Server running on port 80."); + LOG_INFO("✓ Web Server running on port 80.\n"); // Initialize CSRF token generateCsrfToken(); @@ -87,7 +88,7 @@ void WebPortal::loop() { if (!dnsServerStarted_) { dnsServer_.setErrorReplyCode(DNSReplyCode::NoError); dnsServer_.start(kDnsPort, "*", WiFi.softAPIP()); - Serial.println("✓ Captive Portal DNS running."); + LOG_INFO("✓ Captive Portal DNS running.\n"); dnsServerStarted_ = true; } dnsServer_.processNextRequest(); @@ -96,7 +97,7 @@ void WebPortal::loop() { // Session timeout checking if (activeSessionToken_.length() > 0 && (millis() - sessionStartTime_ > kSessionTimeoutMs)) { - Serial.println("Session timed out."); + LOG_WARN("Session timed out.\n"); activeSessionToken_ = ""; } } @@ -256,6 +257,14 @@ void WebPortal::setupRoutes() { apiSaveSensorMapping(); }); + // Log view — GET is unauthenticated (read-only), clear remains authenticated. + server_.on("/api/logs", HTTP_GET, apiGetLogs); + server_.on("/api/logs/clear", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiClearLogs(); + }); + // LittleFS file upload (for OTA-safe web asset deployment) server_.on( "/api/fs/upload", HTTP_POST, @@ -281,7 +290,7 @@ void WebPortal::setupRoutes() { return; HTTPUpload &upload = server_.upload(); if (upload.status == UPLOAD_FILE_START) { - Serial.printf("Signed OTA Update Starting: %s\n", upload.filename.c_str()); + LOG_INFO("Signed OTA Update Starting: %s\n", upload.filename.c_str()); if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { Update.printError(Serial); } @@ -291,7 +300,7 @@ void WebPortal::setupRoutes() { } } else if (upload.status == UPLOAD_FILE_END) { if (Update.end(true)) { - Serial.printf("Signed OTA Update Success: %u bytes\n", upload.totalSize); + LOG_INFO("Signed OTA Update Success: %u bytes\n", upload.totalSize); } else { Update.printError(Serial); } @@ -509,6 +518,99 @@ void WebPortal::apiGetStatus() { } } +// ── Log view (REST /api/logs) ────────────────────────────────────────────── + +size_t WebPortal::buildLogsJson(uint32_t since, uint32_t epoch, size_t count, LogLevel minLevel, char *buf, size_t bufSize) { + if (buf == nullptr || bufSize == 0) { + return 0; + } + + // Copy entries out of the ring into a fixed array (snapshot consistency — + // getEntries reads under the log mutex). Static: no stack pressure. + static LogEntry entries[LogCapture::LOG_BUFFER_ENTRIES]; + size_t n = LogCapture::getEntries(since, epoch, count, minLevel, entries, LogCapture::LOG_BUFFER_ENTRIES); + + JsonDocument doc; + doc["ok"] = true; + // The client echoes the boot epoch it last saw; a mismatch with the current + // boot tells it to discard its cursor even when the new sequence has already + // grown past it. Always report the CURRENT epoch, not the requested one. + doc["boot"] = LogCapture::epoch(); + // getEntries() treats `since` as an exclusive cursor (skips entry.seq <= since), + // so next must be the highest sequence actually consumed, not lastSeq()+1: + // a cursor of lastSeq()+1 would skip the entry whose seq equals that value on + // the next poll, and truncated responses would jump past unreturned entries. + // With no entries, keep the previous cursor (no progress, nothing skipped). + doc["next"] = (n > 0) ? entries[n - 1].seq : since; + + JsonArray arr = doc["entries"].to(); + for (size_t i = 0; i < n; ++i) { + JsonObject obj = arr.add(); + obj["seq"] = entries[i].seq; + obj["t"] = entries[i].uptimeMs; + obj["level"] = LogCapture::levelName(entries[i].level); + obj["msg"] = entries[i].message; + } + + size_t jsonLength = serializeJson(doc, buf, bufSize); + // Signal truncation (buffer too small) instead of returning broken JSON + if (jsonLength >= bufSize) { + return 0; + } + return jsonLength; +} + +void WebPortal::apiGetLogs() { + uint32_t since = 0; + // Omitted boot parameter = current boot: the documented since-polling flow + // keeps working incrementally. Only clients that explicitly send a boot epoch + // get reboot detection (a stale epoch forces a full dump). + uint32_t epoch = LogCapture::epoch(); + size_t count = 200; + LogLevel minLevel = LogLevel::Info; + + if (server_.hasArg("since")) { + since = static_cast(atol(server_.arg("since").c_str())); + } + if (server_.hasArg("boot")) { + // Parse as unsigned 32-bit: esp_random() produces values above LONG_MAX + // on roughly half of boots, which atol() would overflow (→ stale epoch → + // full ring + duplicate entries on every poll). strtoul reconstructs the + // full uint32 range; on invalid input we keep the current boot (safe + // fallback that preserves incremental since-polling). + char *end = nullptr; + const unsigned long v = strtoul(server_.arg("boot").c_str(), &end, 10); + if (end != server_.arg("boot").c_str() && v <= UINT32_MAX) { + epoch = static_cast(v); + } + } + if (server_.hasArg("count")) { + long c = atol(server_.arg("count").c_str()); + if (c > 0 && c <= 500) { + count = static_cast(c); + } + } + if (server_.hasArg("level")) { + minLevel = LogCapture::parseLevel(server_.arg("level").c_str()); + } + + // Serialize directly to a pre-allocated buffer to minimize String usage. + // Worst case: LOG_BUFFER_ENTRIES entries x ~140 bytes each + envelope. + // Use a static buffer to avoid heap fragmentation. + static char jsonBuffer[16384]; + size_t jsonLength = buildLogsJson(since, epoch, count, minLevel, jsonBuffer, sizeof(jsonBuffer)); + if (jsonLength > 0) { + server_.send(200, "application/json", jsonBuffer); + } else { + server_.send(500, "text/plain", "JSON serialization error"); + } +} + +void WebPortal::apiClearLogs() { + LogCapture::clear(); + server_.send(200, "application/json", "{\"ok\":true}"); +} + void WebPortal::apiScanWiFi() { int n = WiFi.scanNetworks(); JsonDocument doc; @@ -1116,19 +1218,19 @@ void WebPortal::handleFsUploadStream() { path = upload.filename; } if (path.length() == 0) { - Serial.println("FS Upload: missing path argument — aborting"); + LOG_WARN("FS Upload: missing path argument — aborting\n"); return; } // Security: only allow files under /web/ if (!path.startsWith("/web/")) { - Serial.printf("FS Upload: path \"%s\" not under /web/ — rejected\n", path.c_str()); + LOG_WARN("FS Upload: path \"%s\" not under /web/ — rejected\n", path.c_str()); return; } // Security: prevent path traversal if (path.indexOf("..") != -1) { - Serial.printf("FS Upload: path traversal detected: \"%s\"\n", path.c_str()); + LOG_WARN("FS Upload: path traversal detected: \"%s\"\n", path.c_str()); return; } @@ -1139,11 +1241,11 @@ void WebPortal::handleFsUploadStream() { fsUploadFile = LittleFS.open(path, "w"); if (!fsUploadFile) { - Serial.printf("FS Upload: failed to open \"%s\" for writing\n", path.c_str()); + LOG_ERROR("FS Upload: failed to open \"%s\" for writing\n", path.c_str()); return; } - Serial.printf("FS Upload: started \"%s\" (%u bytes)\n", path.c_str(), upload.totalSize); + LOG_INFO("FS Upload: started \"%s\" (%u bytes)\n", path.c_str(), upload.totalSize); } else if (upload.status == UPLOAD_FILE_WRITE) { if (fsUploadFile) { @@ -1153,7 +1255,7 @@ void WebPortal::handleFsUploadStream() { } else if (upload.status == UPLOAD_FILE_END) { if (fsUploadFile) { fsUploadFile.close(); - Serial.printf("FS Upload: finished \"%s\" (%u bytes)\n", upload.filename.c_str(), upload.totalSize); + LOG_INFO("FS Upload: finished \"%s\" (%u bytes)\n", upload.filename.c_str(), upload.totalSize); } } } diff --git a/src/WebPortal.hpp b/src/WebPortal.hpp index 262f143c..0831097b 100644 --- a/src/WebPortal.hpp +++ b/src/WebPortal.hpp @@ -13,6 +13,8 @@ #include #include +#include "LogCapture.hpp" + namespace PoolController { /** @@ -59,6 +61,12 @@ class WebPortal { /** @brief Get login lockout duration in milliseconds. */ static uint32_t getLoginLockoutMs() { return kLoginLockoutMs; } + // ── Log view helper (public for testing) ── + /** @brief Serialize LogCapture entries as the /api/logs JSON payload. + * @param epoch boot epoch of the client's cursor (see LogCapture::epoch()). + * @return bytes written (0 on error / empty buffer). */ + static size_t buildLogsJson(uint32_t since, uint32_t epoch, size_t count, LogLevel minLevel, char *buf, size_t bufSize); + private: /** @brief Register all HTTP routes, handlers, and static asset paths. */ static void setupRoutes(); @@ -86,6 +94,10 @@ class WebPortal { // ── REST API Handlers ── /** @brief GET /api/status — return JSON with all telemetry data. */ static void apiGetStatus(); + /** @brief GET /api/logs — return JSON with captured log entries (unauthenticated, read-only). */ + static void apiGetLogs(); + /** @brief POST /api/logs/clear — empty the LogCapture ring buffer (authenticated). */ + static void apiClearLogs(); /** @brief GET /api/wifi/scan — return JSON list of visible WiFi networks. */ static void apiScanWiFi(); /** @brief GET /api/config — return current configuration as JSON. */ diff --git a/src/WpsProvisioner.cpp b/src/WpsProvisioner.cpp index 57593109..0d842ea7 100644 --- a/src/WpsProvisioner.cpp +++ b/src/WpsProvisioner.cpp @@ -17,6 +17,7 @@ #include #include "ConfigManager.hpp" +#include "LogCapture.hpp" namespace { constexpr gpio_num_t WPS_TRIGGER_PIN{GPIO_NUM_0}; @@ -41,7 +42,7 @@ static WpsProvisionState wpsProvisionState{}; auto stopWps() -> void { const esp_err_t disableErr = esp_wifi_wps_disable(); if (disableErr != ESP_OK && disableErr != ESP_ERR_WIFI_WPS_SM) { - Serial.printf("WPS disable failed: 0x%x (%s)\n", static_cast(disableErr), esp_err_to_name(disableErr)); + LOG_ERROR("WPS disable failed: 0x%x (%s)\n", static_cast(disableErr), esp_err_to_name(disableErr)); } } @@ -60,13 +61,13 @@ auto startWps() -> bool { modelNumberLen < 0 || static_cast(modelNumberLen) >= sizeof(config.factory_info.model_number) || modelNameLen < 0 || static_cast(modelNameLen) >= sizeof(config.factory_info.model_name) || deviceNameLen < 0 || static_cast(deviceNameLen) >= sizeof(config.factory_info.device_name)) { - Serial.println(F("WPS: factory-info string truncated")); + LOG_INFO("WPS: factory-info string truncated\n"); return false; } const esp_err_t enableErr = esp_wifi_wps_enable(&config); if (enableErr != ESP_OK) { - Serial.printf("WPS enable failed: 0x%x (%s)\n", static_cast(enableErr), esp_err_to_name(enableErr)); + LOG_ERROR("WPS enable failed: 0x%x (%s)\n", static_cast(enableErr), esp_err_to_name(enableErr)); return false; } @@ -76,7 +77,7 @@ auto startWps() -> bool { const esp_err_t startErr = esp_wifi_wps_start(0); #endif if (startErr != ESP_OK) { - Serial.printf("WPS start failed: 0x%x (%s)\n", static_cast(startErr), esp_err_to_name(startErr)); + LOG_ERROR("WPS start failed: 0x%x (%s)\n", static_cast(startErr), esp_err_to_name(startErr)); stopWps(); return false; } @@ -93,7 +94,7 @@ auto persistWpsWifiCredentials() -> bool { WiFi.SSID().toCharArray(connectedSsid, sizeof(connectedSsid)); if (connectedSsid[0] == '\0') { - Serial.println(F("WPS: no SSID after successful pairing")); + LOG_INFO("WPS: no SSID after successful pairing\n"); return false; } @@ -102,11 +103,11 @@ auto persistWpsWifiCredentials() -> bool { PoolController::ConfigManager::setConfigured(true); // P1: Mark device as configured if (!PoolController::ConfigManager::save()) { - Serial.println(F("WPS: failed to persist WiFi credentials to config")); + LOG_ERROR("WPS: failed to persist WiFi credentials to config\n"); return false; } - Serial.printf("WPS: persisted WiFi credentials for SSID '%s'\n", connectedSsid); + LOG_INFO("WPS: persisted WiFi credentials for SSID '%s'\n", connectedSsid); return true; } @@ -165,7 +166,7 @@ auto WpsProvisioner::runIfRequested() -> void { return; } - Serial.println(F("WPS: trigger button held, starting WPS provisioning")); + LOG_INFO("WPS: trigger button held, starting WPS provisioning\n"); wpsProvisionState.success.store(false); wpsProvisionState.failed.store(false); @@ -190,10 +191,10 @@ auto WpsProvisioner::runIfRequested() -> void { if (wpsProvisionState.success.load() && waitForWifiConnected(WPS_CONNECT_TIMEOUT_MS)) { const bool persisted = persistWpsWifiCredentials(); if (!persisted) { - Serial.println(F("WPS: connected, but credentials were not persisted")); + LOG_INFO("WPS: connected, but credentials were not persisted\n"); } } else { - Serial.println(F("WPS: provisioning failed or timed out")); + LOG_ERROR("WPS: provisioning failed or timed out\n"); stopWps(); // Retry with previously stored WiFi credentials. WiFi.begin(); diff --git a/src/main.cpp b/src/main.cpp index 5813e69d..56bf860b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -11,6 +11,7 @@ */ #include +#include "LogCapture.hpp" #include "PoolController.hpp" /** @brief Singleton context owning all controller subsystems. */ @@ -33,6 +34,11 @@ auto setup() -> void { delay(10); } + // Central logging service — must start before context.setup() so boot-time + // LOG_* entries (WiFi/MQTT init, sensor scans) are captured and the MQTT + // export watermark sees the pre-boot sequence. Serial is already up here. + PoolController::LogCapture::begin(); + context.setup(); } diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index d3518b6f..5cf49be1 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -54,6 +54,7 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/DegradationManager.cpp ${PROJ_ROOT}/src/SystemMonitor.cpp ${PROJ_ROOT}/src/OtaUpdater.cpp + ${PROJ_ROOT}/src/LogCapture.cpp ) # Mock sources (compiled once) @@ -75,6 +76,8 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_security.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_state_manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_timer.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_logcapture.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_webportal_logs.cpp ) add_executable(test_runner diff --git a/test/native/mocks/Arduino.h b/test/native/mocks/Arduino.h index 309f4ca0..fa6f110d 100644 --- a/test/native/mocks/Arduino.h +++ b/test/native/mocks/Arduino.h @@ -121,8 +121,17 @@ class String { // ---- Serial mock ---- class SerialClass { public: + // Optional stdout capture for tests that need to assert on Serial output. + // Disabled by default so existing tests are unaffected. + static std::string s_capture; + static bool s_captureEnabled; + static void enableCapture() { s_captureEnabled = true; s_capture.clear(); } + static void disableCapture() { s_captureEnabled = false; s_capture.clear(); } + static const std::string &capture() { return s_capture; } + void begin(int) {} void print(const char *s) { + if (s_captureEnabled) s_capture += s; fprintf(stdout, "%s", s); fflush(stdout); } diff --git a/test/native/mocks/NetworkManager.cpp b/test/native/mocks/NetworkManager.cpp index 73fd3f75..fb729157 100644 --- a/test/native/mocks/NetworkManager.cpp +++ b/test/native/mocks/NetworkManager.cpp @@ -7,5 +7,8 @@ bool NetworkManager::_wifiConnected = true; bool NetworkManager::_mqttConnected = true; bool NetworkManager::_apMode = false; int NetworkManager::_wifiRssi = -65; +bool NetworkManager::_publishOk = true; +std::function NetworkManager::_publishHook; +std::string NetworkManager::_publishHookTopic; } // namespace PoolController diff --git a/test/native/mocks/NetworkManager.hpp b/test/native/mocks/NetworkManager.hpp index a9588e60..0ea7562b 100644 --- a/test/native/mocks/NetworkManager.hpp +++ b/test/native/mocks/NetworkManager.hpp @@ -20,7 +20,15 @@ class NetworkManager { static String getLocalIP() { return String("192.168.1.100"); } static bool publish(const char *topic, const char *payload, bool retained = false) { - return _mqttClient.publish(topic, 1, retained, payload) > 0; + if (!_publishOk) return false; + bool ok = _mqttClient.publish(topic, 1, retained, payload) > 0; + if (_publishHook && _publishHookTopic == topic) { + auto hook = std::move(_publishHook); // one-shot: prevents recursion + _publishHook = nullptr; + _publishHookTopic.clear(); + hook(); + } + return ok; } static bool subscribe(const char *topic) { return _mqttClient.subscribe(topic) > 0; } @@ -37,6 +45,19 @@ class NetworkManager { static void setMqttConnected(bool v) { _mqttConnected = v; } static void setApMode(bool v) { _apMode = v; } static void setWiFiRSSI(int rssi) { _wifiRssi = rssi; } + // Simulate AsyncMqttClient refusing to enqueue (publish returns false). + static void setPublishOk(bool ok) { _publishOk = ok; } + // One-shot hook fired when the next publish() targets `topic` — lets a test + // simulate a reentrant publishStates() from the AsyncMqttClient callback + // path while an export on that topic is already in progress. + static void setPublishHook(const char *topic, std::function cb) { + _publishHookTopic = topic ? topic : ""; + _publishHook = std::move(cb); + } + static void clearPublishHook() { + _publishHook = nullptr; + _publishHookTopic.clear(); + } static AsyncMqttClient &getClient() { return _mqttClient; } @@ -46,6 +67,9 @@ class NetworkManager { static bool _mqttConnected; static bool _apMode; static int _wifiRssi; + static bool _publishOk; + static std::function _publishHook; + static std::string _publishHookTopic; }; } // namespace PoolController diff --git a/test/native/mocks/Update.h b/test/native/mocks/Update.h index 3e5c820e..d4d69276 100644 --- a/test/native/mocks/Update.h +++ b/test/native/mocks/Update.h @@ -25,7 +25,7 @@ class UpdateClass { void printError(Print &p) {} void printError(int) {} void printError(SerialClass &s) {} - String errorString() { return String(""); } + const char* errorString() { return ""; } void abort() {} }; static UpdateClass Update; diff --git a/test/native/mocks/captures.cpp b/test/native/mocks/captures.cpp index 937db47a..d14ac18d 100644 --- a/test/native/mocks/captures.cpp +++ b/test/native/mocks/captures.cpp @@ -1,11 +1,16 @@ // Global capture instances for test assertions #include #include +#include "Arduino.h" #include "WebServer.h" #include "AsyncMqttClient.h" WebServerCapture wsCapture; MqttClientCapture mqttCapture; +// Serial mock capture state (disabled by default) +std::string SerialClass::s_capture; +bool SerialClass::s_captureEnabled = false; + // Preferences static storage (shared across all instances like real NVS) std::map Preferences::s_data; diff --git a/test/native/relay_safety/CMakeLists.txt b/test/native/relay_safety/CMakeLists.txt index 6007c4fd..45aeb9a1 100644 --- a/test/native/relay_safety/CMakeLists.txt +++ b/test/native/relay_safety/CMakeLists.txt @@ -32,6 +32,7 @@ set(PRODUCTION_SOURCES ${PROJ_ROOT}/src/RuleBoost.cpp ${PROJ_ROOT}/src/RuleTimer.cpp ${PROJ_ROOT}/src/Timer.cpp + ${PROJ_ROOT}/src/LogCapture.cpp ) set(STUB_SOURCES diff --git a/test/native/relay_safety/stubs.cpp b/test/native/relay_safety/stubs.cpp index d6fbb7ed..c7360ac9 100644 --- a/test/native/relay_safety/stubs.cpp +++ b/test/native/relay_safety/stubs.cpp @@ -25,6 +25,11 @@ // Static storage backing the mock Preferences (NVS) key/value store. std::map Preferences::s_data; +// Serial mock capture state (disabled by default) — LogCapture.cpp's +// Serial mirror references these statics via Arduino.h. +std::string SerialClass::s_capture; +bool SerialClass::s_captureEnabled = false; + // ── ConfigManager: only the static settings_ member is referenced (via // getSettings(), which Timer.cpp's calculateEffectiveEndMinutes() calls). // Default member initializers in ControllerSettings give sane defaults. ── diff --git a/test/native/tests/test_logcapture.cpp b/test/native/tests/test_logcapture.cpp new file mode 100644 index 00000000..a03b1046 --- /dev/null +++ b/test/native/tests/test_logcapture.cpp @@ -0,0 +1,420 @@ +/** + * @file test_logcapture.cpp + * @brief Unit tests for LogCapture ring buffer — wrapping, seq, filters, clear, truncation. + */ + +#include +#include + +// Mock includes (picked up via -I mocks/) +#include "Arduino.h" +#include "LogCapture.hpp" + +// Test framework +extern void test_begin(const char *suite, const char *name); +extern void test_pass(const char *file, int line); +extern void test_fail(const char *file, int line, const char *msg); +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + test_fail(__FILE__, __LINE__, "Expected true: " #cond); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +#define ASSERT_FALSE(cond) ASSERT_TRUE(!(cond)) + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + char _msg[256]; \ + snprintf(_msg, sizeof(_msg), "Expected %s == %s: got %lld vs %lld", #a, #b, (long long)_a, (long long)_b); \ + test_fail(__FILE__, __LINE__, _msg); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +using PoolController::LogCapture; +using PoolController::LogEntry; +using PoolController::LogLevel; + +int run_logcapture_tests() { + int passed = 0, failed = 0; + int rc; + + // Keep test output clean — mirror goes to stdout via the mock Serial. + LogCapture::setLogToSerial(false); + + // ── Test: ring wraparound keeps newest entries ── + { + test_begin("LogCapture", "wraparound keeps newest entries"); + LogCapture::begin(); + const size_t N = LogCapture::LOG_BUFFER_ENTRIES + 5; + for (size_t i = 0; i < N; ++i) { + LogCapture::log(LogLevel::Info, "entry %zu", i); + } + LogEntry entries[LogCapture::LOG_BUFFER_ENTRIES]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), 4096, LogLevel::Debug, entries, LogCapture::LOG_BUFFER_ENTRIES); + rc = (got == LogCapture::LOG_BUFFER_ENTRIES) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected %u entries, got %u", (unsigned)LogCapture::LOG_BUFFER_ENTRIES, (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::wraparound", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + + test_begin("LogCapture", "wraparound oldest seq > 0"); + rc = (entries[0].seq > 0) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Oldest kept entry should have seq > 0"); + failed++; + } + test_suite_end("LogCapture::wraparound_oldest", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: seq monotonicity ── + { + test_begin("LogCapture", "lastSeq equals number of logs"); + LogCapture::begin(); + const size_t N = 5; + for (size_t i = 0; i < N; ++i) { + LogCapture::log(LogLevel::Info, "log %zu", i); + } + rc = (LogCapture::lastSeq() == N) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected lastSeq %u, got %u", (unsigned)N, (unsigned)LogCapture::lastSeq()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::lastseq", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + + test_begin("LogCapture", "entries have strictly increasing seq"); + LogEntry entries[N]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), N, LogLevel::Debug, entries, N); + bool increasing = (got == N); + for (size_t i = 1; increasing && i < got; ++i) { + increasing = (entries[i].seq > entries[i - 1].seq); + } + rc = increasing ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Entries must have strictly increasing seq"); + failed++; + } + test_suite_end("LogCapture::seq_increasing", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: since-filter returns only seq > sinceSeq ── + { + test_begin("LogCapture", "since filter returns only newer entries"); + LogCapture::begin(); + for (size_t i = 0; i < 5; ++i) { + LogCapture::log(LogLevel::Info, "log %zu", i); + } + LogEntry entries[8]; + size_t got = LogCapture::getEntries(3, LogCapture::epoch(), 8, LogLevel::Debug, entries, 8); + rc = (got == 2 && entries[0].seq == 4 && entries[1].seq == 5) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 2 entries seq 4..5, got %u", (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::since", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: stale cursor from before a reboot is clamped to 0 ── + { + test_begin("LogCapture", "stale pre-reboot cursor returns whole ring"); + LogCapture::begin(); + for (size_t i = 0; i < 5; ++i) { + LogCapture::log(LogLevel::Info, "boot %zu", i); + } + // begin() restarts the sequence at 0, so a cursor persisted across the + // reboot (higher than every new seq) must not suppress the whole ring. + LogEntry entries[8]; + size_t got = LogCapture::getEntries(1000, LogCapture::epoch(), 8, LogLevel::Debug, entries, 8); + rc = (got == 5 && entries[0].seq == 1 && entries[4].seq == 5) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 5 entries seq 1..5, got %u", (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::stale_cursor", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: level filter ── + { + test_begin("LogCapture", "level filter >= Warning"); + LogCapture::begin(); + LogCapture::log(LogLevel::Debug, "d"); + LogCapture::log(LogLevel::Info, "i"); + LogCapture::log(LogLevel::Warning, "w"); + LogCapture::log(LogLevel::Error, "e"); + LogEntry entries[8]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), 8, LogLevel::Warning, entries, 8); + rc = (got == 2 && entries[0].level == LogLevel::Warning && entries[1].level == LogLevel::Error) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 2 warn/error entries, got %u", (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::level", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: clear hides entries but keeps seq ── + { + test_begin("LogCapture", "clear empties ring"); + LogCapture::begin(); + LogCapture::log(LogLevel::Info, "a"); + LogCapture::log(LogLevel::Info, "b"); + LogCapture::log(LogLevel::Info, "c"); + LogCapture::clear(); + LogEntry entries[8]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), 8, LogLevel::Debug, entries, 8); + rc = (got == 0) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 0 entries after clear, got %u", (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::clear_empty", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + + test_begin("LogCapture", "clear keeps lastSeq"); + rc = (LogCapture::lastSeq() == 3) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected lastSeq 3 after clear, got %u", (unsigned)LogCapture::lastSeq()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::clear_seq", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + + test_begin("LogCapture", "post-clear entries continue above watermark"); + LogCapture::log(LogLevel::Info, "d"); + size_t got2 = LogCapture::getEntries(0, LogCapture::epoch(), 8, LogLevel::Debug, entries, 8); + rc = (got2 == 1 && entries[0].seq == 4) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 1 entry seq 4 after clear, got %u", (unsigned)got2); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::clear_watermark", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: truncation of oversized messages ── + { + test_begin("LogCapture", "long message is truncated safely"); + LogCapture::begin(); + char longMsg[LOG_MSG_SIZE * 2]; + memset(longMsg, 'A', sizeof(longMsg) - 1); + longMsg[sizeof(longMsg) - 1] = '\0'; + LogCapture::log(LogLevel::Info, "%s", longMsg); + LogEntry entries[2]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), 2, LogLevel::Debug, entries, 2); + size_t len = strnlen(entries[0].message, sizeof(entries[0].message)); + rc = (got == 1 && len == LOG_MSG_SIZE - 1 && entries[0].message[len] == '\0') ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected truncated len %d, got %u", LOG_MSG_SIZE - 1, (unsigned)len); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::truncation", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: Serial mirror keeps the full message even when the ring truncates ── + { + test_begin("LogCapture", "serial mirror keeps full message when ring truncates"); + LogCapture::begin(); + LogCapture::setLogToSerial(true); + SerialClass::enableCapture(); + + // Message longer than a ring slot: the ring copy must be truncated to + // LOG_MSG_SIZE-1, but the Serial mirror must receive the full text. + char longMsg[LOG_MSG_SIZE * 3]; + memset(longMsg, 'C', sizeof(longMsg) - 1); + longMsg[sizeof(longMsg) - 1] = '\0'; + LogCapture::log(LogLevel::Info, "%s", longMsg); + + const std::string serialOut = SerialClass::capture(); + SerialClass::disableCapture(); + LogCapture::setLogToSerial(false); + + LogEntry entries[2]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), 2, LogLevel::Debug, entries, 2); + size_t ringLen = strnlen(entries[0].message, sizeof(entries[0].message)); + + rc = (got == 1 && ringLen == LOG_MSG_SIZE - 1 && serialOut.size() == sizeof(longMsg) - 1 && + memcmp(serialOut.data(), longMsg, sizeof(longMsg) - 1) == 0) + ? 0 + : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "ring len %u (want %d), serial len %zu (want %zu)", (unsigned)ringLen, LOG_MSG_SIZE - 1, + serialOut.size(), sizeof(longMsg) - 1); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::serial_full_mirror", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: logEvent prefix marker ── + { + test_begin("LogCapture", "logEvent prefixes event type"); + LogCapture::begin(); + LogCapture::logEvent("MODE_CHANGED", "to auto"); + LogEntry entries[2]; + size_t got = LogCapture::getEntries(0, LogCapture::epoch(), 2, LogLevel::Debug, entries, 2); + bool hasPrefix = (got == 1 && entries[0].message[0] == '[' && strstr(entries[0].message, "MODE_CHANGED") != nullptr); + rc = hasPrefix ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected '[MODE_CHANGED] ...', got '%s'", got == 1 ? entries[0].message : "(none)"); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::logevent", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: parseLevel ── + { + test_begin("LogCapture", "parseLevel case-insensitive"); + rc = (LogCapture::parseLevel("warning") == LogLevel::Warning && LogCapture::parseLevel("ERROR") == LogLevel::Error && + LogCapture::parseLevel("Debug") == LogLevel::Debug && LogCapture::parseLevel("bogus") == LogLevel::Info) + ? 0 + : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "parseLevel mapping wrong"); + failed++; + } + test_suite_end("LogCapture::parselevel", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: reboot epoch distinguishes stale cursors ── + { + test_begin("LogCapture", "boot epoch changes across reboots"); + LogCapture::begin(); + const uint32_t e1 = LogCapture::epoch(); + LogCapture::begin(); + const uint32_t e2 = LogCapture::epoch(); + rc = (e2 != e1) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "epoch must change after begin()"); + failed++; + } + test_suite_end("LogCapture::reboot_epoch::changes", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + { + test_begin("LogCapture", "stale pre-reboot cursor with new seq <= since returns whole ring"); + // Scenario from the review: boot 1 fills seq 1..20, client cursor since=20. + // Reboot (begin) restarts seq at 0; by the first poll the new boot already + // reached s_seq == 40. The old seq-only clamp (sinceSeq > s_seq) would NOT + // trigger (20 <= 40) and silently skip new-boot entries 1..20. The epoch + // mismatch must force a full re-read instead. + LogCapture::begin(); + const uint32_t boot1 = LogCapture::epoch(); + for (uint32_t i = 0; i < 20; ++i) { + LogCapture::log(LogLevel::Info, "boot1 %u", i); + } + LogCapture::begin(); + for (uint32_t i = 0; i < 40; ++i) { + LogCapture::log(LogLevel::Info, "boot2 %u", i); + } + LogEntry entries[64]; + size_t got = LogCapture::getEntries(20, boot1, 64, LogLevel::Debug, entries, 64); + rc = (got == 40 && entries[0].seq == 1 && entries[39].seq == 40) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 40 entries seq 1..40, got %u", (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::reboot_epoch::stale_cursor", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + { + test_begin("LogCapture", "current-boot cursor still filters by since"); + // Same ring as above, but the client now echoes the CURRENT epoch: the + // cursor since=20 is trusted and only entries 21..40 are returned. + LogCapture::begin(); + for (uint32_t i = 0; i < 40; ++i) { + LogCapture::log(LogLevel::Info, "boot %u", i); + } + LogEntry entries[64]; + size_t got = LogCapture::getEntries(20, LogCapture::epoch(), 64, LogLevel::Debug, entries, 64); + rc = (got == 20 && entries[0].seq == 21 && entries[19].seq == 40) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "Expected 20 entries seq 21..40, got %u", (unsigned)got); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("LogCapture::reboot_epoch::current_cursor", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + return passed + failed; +} diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index dfc0725f..83580bc1 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -106,6 +106,8 @@ extern int run_mqttpublisher_tests(); extern int run_security_tests(); extern int run_state_manager_tests(); extern int run_timer_tests(); +extern int run_logcapture_tests(); +extern int run_webportal_logs_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -120,6 +122,8 @@ int main() { total += run_security_tests(); total += run_state_manager_tests(); total += run_timer_tests(); + total += run_logcapture_tests(); + total += run_webportal_logs_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); diff --git a/test/native/tests/test_mqttpublisher.cpp b/test/native/tests/test_mqttpublisher.cpp index 1810bb84..c0a0b636 100644 --- a/test/native/tests/test_mqttpublisher.cpp +++ b/test/native/tests/test_mqttpublisher.cpp @@ -11,6 +11,7 @@ #include "AsyncMqttClient.h" #include "MqttPublisher.hpp" +#include "LogCapture.hpp" #include "ConfigManager.hpp" #include "NetworkManager.hpp" #include "DallasTemperatureNode.hpp" @@ -505,5 +506,424 @@ int run_mqttpublisher_tests() { test_suite_end("MqttPublisher::preset_state_none", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); } + // ── Test: Event-entity discovery payload ── + // Task 5: publishEventDiscovery("logs", ...) must announce the HA MQTT + // "event" component with the curated event_types whitelist. + { + test_begin("MqttPublisher::publishEventDiscovery", "event entity with platform and event_types"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + MqttPublisher::begin(); + MqttPublisher::publishDiscovery(); + + const MqttMessage *cfg = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/config"); + int missing = 0; + if (cfg == nullptr) { + test_fail(__FILE__, __LINE__, "Missing event discovery config topic"); + missing++; + } else { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, cfg->payload); + if (err) { + char buf[128]; + snprintf(buf, sizeof(buf), "Event config is not valid JSON: %s", err.c_str()); + test_fail(__FILE__, __LINE__, buf); + missing++; + } else { + // platform must be "event" (HA event component) + if (strcmp(doc["platform"] | "", "event") != 0) { + test_fail(__FILE__, __LINE__, "event config platform != 'event'"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + // state_topic must point at the event state topic + if (strcmp(doc["state_topic"] | "", "homeassistant/event/pool-controller/logs/state") != 0) { + test_fail(__FILE__, __LINE__, "event config state_topic mismatch"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + if (!doc.containsKey("availability_topic")) { + test_fail(__FILE__, __LINE__, "event config missing availability_topic"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + // event_types must contain the curated whitelist (LOG_WARN + MODE_CHANGED probe) + JsonArray types = doc["event_types"]; + bool hasWarn = false; + bool hasMode = false; + for (JsonVariant t : types) { + if (strcmp(t | "", "LOG_WARN") == 0) + hasWarn = true; + if (strcmp(t | "", "MODE_CHANGED") == 0) + hasMode = true; + } + if (!hasWarn) { + test_fail(__FILE__, __LINE__, "event_types missing LOG_WARN"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + if (!hasMode) { + test_fail(__FILE__, __LINE__, "event_types missing MODE_CHANGED"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + if (!cfg->retained) { + test_fail(__FILE__, __LINE__, "event config must be retained"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + } + } + + rc = (missing == 0) ? 0 : 1; + if (rc == 0) + passed++; + else + failed++; + test_suite_end("MqttPublisher::publishEventDiscovery", missing == 0 ? 1 : 0, missing); + } + + // ── Test: Export pump exports WARN entries as LOG_WARN events ── + // Task 5: WARN/ERROR entries without marker → {"event_type":"LOG_WARN",...} + // on the event state topic, plus a JSON-line on the raw pool-controller/log topic. + { + test_begin("MqttPublisher::publishStates", "exports WARN entry as LOG_WARN event"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + LogCapture::begin(); + MqttPublisher::begin(); + + LogCapture::log(LogLevel::Warning, "solar pump overheated"); + MqttPublisher::publishStates(); + + int missing = 0; + const MqttMessage *ev = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + if (ev == nullptr) { + test_fail(__FILE__, __LINE__, "No event state published for WARN entry"); + missing++; + } else { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, ev->payload); + if (err) { + test_fail(__FILE__, __LINE__, "Event state is not valid JSON"); + missing++; + } else { + if (strcmp(doc["event_type"] | "", "LOG_WARN") != 0) { + test_fail(__FILE__, __LINE__, "event_type != LOG_WARN for WARN entry"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + const char *msg = doc["message"]; + if (msg == nullptr || strstr(msg, "overheated") == nullptr) { + test_fail(__FILE__, __LINE__, "event message missing WARN body"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + } + } + + // Raw topic for external tools: {"seq":
,"t":
,"level":
,"msg":
} + const MqttMessage *raw = mqttCapture.findPublished("pool-controller/log"); + if (raw == nullptr) { + test_fail(__FILE__, __LINE__, "Raw pool-controller/log topic not published"); + missing++; + } else { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, raw->payload); + if (err) { + test_fail(__FILE__, __LINE__, "Raw log line is not valid JSON"); + missing++; + } else { + if ((doc["seq"] | 0u) == 0u) { + test_fail(__FILE__, __LINE__, "Raw log line missing seq"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + if (strcmp(doc["level"] | "", "warning") != 0) { + test_fail(__FILE__, __LINE__, "Raw log line level != warning"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + const char *msg = doc["msg"]; + if (msg == nullptr || strstr(msg, "overheated") == nullptr) { + test_fail(__FILE__, __LINE__, "Raw log line missing message"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + } + } + + rc = (missing == 0) ? 0 : 1; + if (rc == 0) + passed++; + else + failed++; + test_suite_end("MqttPublisher::export_warn_event", missing == 0 ? 1 : 0, missing); + } + + // ── Test: WARN/ERROR emitted BEFORE MqttPublisher::begin() is preserved ── + // The export watermark starts at seq 0 (beginning of the current boot), so + // boot-loop errors, ConfigManager failures, and WPS/SSID warnings logged + // before the publisher starts are still exported — not permanently dropped. + { + test_begin("MqttPublisher::publishStates", "pre-publisher WARN is exported"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + LogCapture::begin(); + LogCapture::log(LogLevel::Warning, "no SSID configured"); + MqttPublisher::begin(); + + MqttPublisher::publishStates(); + + const MqttMessage *ev = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + const MqttMessage *raw = mqttCapture.findPublished("pool-controller/log"); + + rc = (ev != nullptr && raw != nullptr) ? 0 : 1; + if (rc == 0) { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, ev->payload); + if (err || strcmp(doc["event_type"] | "", "LOG_WARN") != 0) { + test_fail(__FILE__, __LINE__, "pre-publisher WARN not exported as LOG_WARN"); + rc = 1; + } else + test_pass(__FILE__, __LINE__); // NOLINT + } else { + test_fail(__FILE__, __LINE__, "pre-publisher WARN must be exported via MQTT"); + } + if (rc == 0) + passed++; + else + failed++; + test_suite_end("MqttPublisher::export_pre_publisher_warn", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: Info entries are NOT exported ── + // Task 5 volume control: only WARN/ERROR and curated events cross MQTT. + { + test_begin("MqttPublisher::publishStates", "Info entries are not exported"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + LogCapture::begin(); + MqttPublisher::begin(); + + LogCapture::log(LogLevel::Info, "normal heartbeat"); + MqttPublisher::publishStates(); + + const MqttMessage *ev = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + const MqttMessage *raw = mqttCapture.findPublished("pool-controller/log"); + + rc = (ev == nullptr && raw == nullptr) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Info entry must not be exported via MQTT"); + failed++; + } + test_suite_end("MqttPublisher::export_no_info", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: Export pump dedup — second call without new entries publishes nothing ── + { + test_begin("MqttPublisher::publishStates", "no duplicate export on second call"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + LogCapture::begin(); + MqttPublisher::begin(); + + LogCapture::log(LogLevel::Error, "boom"); + MqttPublisher::publishStates(); + + int firstCount = 0; + for (const auto &m : mqttCapture.published) { + if (m.topic == "homeassistant/event/pool-controller/logs/state") + firstCount++; + } + + mqttCapture.clear(); + MqttPublisher::publishStates(); + const MqttMessage *ev2 = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + const MqttMessage *raw2 = mqttCapture.findPublished("pool-controller/log"); + + rc = (firstCount == 1 && ev2 == nullptr && raw2 == nullptr) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "publishStates re-exported already-exported entries"); + failed++; + } + test_suite_end("MqttPublisher::export_dedup", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: failed publish keeps the watermark → entry is retried next pass ── + // Task 5: NetworkManager::publish() can return false (MQTT queue refused / + // disconnected mid-burst). The watermark must NOT advance past such an + // entry, otherwise the event is dropped permanently. + { + test_begin("MqttPublisher::publishStates", "failed publish does not advance watermark"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + NetworkManager::setPublishOk(false); // simulate AsyncMqttClient refusing to enqueue + LogCapture::begin(); + MqttPublisher::begin(); + + LogCapture::log(LogLevel::Error, "queued when broker back"); + MqttPublisher::publishStates(); + + // No event may be marked exported while publish fails. + const MqttMessage *ev = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + bool clean = (ev == nullptr); + + // Broker accepts again → the same entry must now be exported (retry). + NetworkManager::setPublishOk(true); + mqttCapture.clear(); + MqttPublisher::publishStates(); + const MqttMessage *ev2 = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + const MqttMessage *raw2 = mqttCapture.findPublished("pool-controller/log"); + + rc = (clean && ev2 != nullptr && raw2 != nullptr) ? 0 : 1; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[160]; + snprintf(msg, sizeof(msg), "clean=%d ev2=%s raw2=%s (no retry)", clean ? 1 : 0, + ev2 != nullptr ? ev2->payload.c_str() : "NULL", raw2 != nullptr ? raw2->payload.c_str() : "NULL"); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + test_suite_end("MqttPublisher::export_retry_publish_fail", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: reentrant export (AsyncTCP callback path) is skipped by the guard ── + // handleMqttMessage → publishStates() can run on the AsyncTCP task while the + // loop task is mid-exportLogEvents(). Both share the static snapshot buffer + // and the watermark; the guard must make the nested export a no-op so the + // event is published exactly once and the watermark is not regressed. + { + test_begin("MqttPublisher::publishStates", "reentrant export during publish is skipped"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + LogCapture::begin(); + MqttPublisher::begin(); + + LogCapture::log(LogLevel::Warning, "reentrant guard probe"); + // Fire one nested publishStates() from inside the export's event-state + // publish — simulates the AsyncTCP callback task preempting the loop task. + bool hookFired = false; + NetworkManager::setPublishHook("homeassistant/event/pool-controller/logs/state", [&hookFired]() { + hookFired = true; + MqttPublisher::publishStates(); + }); + MqttPublisher::publishStates(); + + int missing = 0; + if (!hookFired) { + test_fail(__FILE__, __LINE__, "publish hook did not fire mid-export"); + missing++; + } + + // The WARN event must be exported exactly once (nested call deferred). + size_t evCount = 0, rawCount = 0; + for (const auto &m : mqttCapture.published) { + if (m.topic == "homeassistant/event/pool-controller/logs/state") + evCount++; + if (m.topic == "pool-controller/log") + rawCount++; + } + if (evCount != 1) { + test_fail(__FILE__, __LINE__, "event state published != 1 times (nested export interleaved)"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + if (rawCount != 1) { + test_fail(__FILE__, __LINE__, "raw log topic published != 1 times (nested export interleaved)"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + + // Watermark must not have regressed: a follow-up pass publishes nothing new. + NetworkManager::clearPublishHook(); + MqttPublisher::publishStates(); + size_t evAfter = 0; + for (const auto &m : mqttCapture.published) + if (m.topic == "homeassistant/event/pool-controller/logs/state") + evAfter++; + if (evAfter != 1) { + test_fail(__FILE__, __LINE__, "watermark regressed — follow-up pass re-exported event"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + + rc = (missing == 0) ? 0 : 1; + if (rc == 0) + passed++; + else + failed++; + test_suite_end("MqttPublisher::export_reentrancy_guard", missing == 0 ? 1 : 0, missing); + } + + // ── Test: logEvent marker becomes event_type on the event state topic ── + // Task 5: LogCapture::logEvent("[MODE_CHANGED] ...") → event_type=MODE_CHANGED. + // Info-level events are NOT mirrored to the raw topic (raw is WARN/ERROR only). + { + test_begin("MqttPublisher::publishStates", "logEvent marker exported as event_type"); + + mqttCapture.clear(); + NetworkManager::setMqttConnected(true); + LogCapture::begin(); + MqttPublisher::begin(); + + LogCapture::logEvent("MODE_CHANGED", "switched to auto"); + MqttPublisher::publishStates(); + + int missing = 0; + const MqttMessage *ev = mqttCapture.findPublished("homeassistant/event/pool-controller/logs/state"); + if (ev == nullptr) { + test_fail(__FILE__, __LINE__, "No event state published for logEvent"); + missing++; + } else { + JsonDocument doc; + DeserializationError err = deserializeJson(doc, ev->payload); + if (err) { + test_fail(__FILE__, __LINE__, "Event state is not valid JSON"); + missing++; + } else { + if (strcmp(doc["event_type"] | "", "MODE_CHANGED") != 0) { + test_fail(__FILE__, __LINE__, "event_type != MODE_CHANGED"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + if (strcmp(doc["message"] | "", "switched to auto") != 0) { + test_fail(__FILE__, __LINE__, "event message mismatch"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + } + } + + const MqttMessage *raw = mqttCapture.findPublished("pool-controller/log"); + if (raw != nullptr) { + test_fail(__FILE__, __LINE__, "Info-level event must not be mirrored to raw topic"); + missing++; + } else + test_pass(__FILE__, __LINE__); // NOLINT + + rc = (missing == 0) ? 0 : 1; + if (rc == 0) + passed++; + else + failed++; + test_suite_end("MqttPublisher::export_event_marker", missing == 0 ? 1 : 0, missing); + } + return passed + failed; } diff --git a/test/native/tests/test_webportal_logs.cpp b/test/native/tests/test_webportal_logs.cpp new file mode 100644 index 00000000..6c65b70d --- /dev/null +++ b/test/native/tests/test_webportal_logs.cpp @@ -0,0 +1,226 @@ +/** + * @file test_webportal_logs.cpp + * @brief Tests for WebPortal::buildLogsJson — the /api/logs JSON builder hook. + * + * buildLogsJson is a public static test hook (pattern: "Rate limiting + * helpers (public for testing)") that serializes LogCapture entries into + * the JSON payload served by apiGetLogs. These tests verify the payload + * shape and the since/count/level filters without needing the WebServer. + */ + +#include +#include +#include +#include "Arduino.h" +#include "ArduinoJson.h" + +// Mock WebPortal dependencies +#include "WebServer.h" +#include "DNSServer.h" +#include "WebPortal.hpp" +#include "LogCapture.hpp" + +using namespace PoolController; // NOLINT(build/namespaces) + +// Global test helpers +extern WebServerCapture wsCapture; + +extern void test_begin(const char *suite, const char *name); +extern void test_pass(const char *file, int line); +extern void test_fail(const char *file, int line, const char *msg); +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + test_fail(__FILE__, __LINE__, "Expected true: " #cond); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + char _msg[256]; \ + snprintf(_msg, sizeof(_msg), "Expected %s == %s: got %lld vs %lld", #a, #b, (long long)_a, (long long)_b); \ + test_fail(__FILE__, __LINE__, _msg); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +namespace { + +// Serializes via the hook and parses the result. Returns true on success. +bool buildAndParse(uint32_t since, uint32_t epoch, size_t count, LogLevel minLevel, JsonDocument &doc, size_t &payloadLen) { + char buf[8192]; + payloadLen = WebPortal::buildLogsJson(since, epoch, count, minLevel, buf, sizeof(buf)); + if (payloadLen == 0) { + return false; + } + DeserializationError err = deserializeJson(doc, buf); + return err == DeserializationError::Ok; +} + +} // namespace + +int run_webportal_logs_tests() { + int passed = 0, failed = 0; + int rc; + + // Keep test output clean — no Serial mirror. + LogCapture::setLogToSerial(false); + + // ── Test: full dump — ok, next, entries with all fields ── + { + test_begin("WebPortal::buildLogsJson", "full dump has ok, next, entries"); + LogCapture::begin(); + LogCapture::log(LogLevel::Info, "boot msg %d", 1); + LogCapture::log(LogLevel::Info, "boot msg %d", 2); + + JsonDocument doc; + size_t len = 0; + rc = buildAndParse(0, LogCapture::epoch(), 200, LogLevel::Debug, doc, len) ? 0 : 1; + if (rc == 0) { + // next = highest consumed seq (2), NOT lastSeq()+1 (3): the client sends + // next back as the exclusive `since` cursor, so seq 3 must not be skipped. + rc = (doc["ok"] == true && doc["next"] == 2 && doc["boot"] == LogCapture::epoch()) ? 0 : 1; + } + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + rc = (arr.size() == 2) ? 0 : 1; + } + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + rc = (arr[0]["seq"] == 1 && arr[0]["level"] == "info" && strcmp(arr[0]["msg"], "boot msg 1") == 0 && + arr[0]["t"].is()) + ? 0 + : 1; + } + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + rc = (arr[1]["seq"] == 2 && strcmp(arr[1]["msg"], "boot msg 2") == 0) ? 0 : 1; + } + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "buildLogsJson full dump malformed"); + failed++; + } + test_suite_end("WebPortal::buildLogsJson::full", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: since filter returns only newer entries ── + { + test_begin("WebPortal::buildLogsJson", "since filter returns newer entries"); + LogCapture::begin(); + for (int i = 1; i <= 5; ++i) { + LogCapture::log(LogLevel::Info, "msg %d", i); + } + + JsonDocument doc; + size_t len = 0; + rc = buildAndParse(3, LogCapture::epoch(), 200, LogLevel::Debug, doc, len) ? 0 : 1; + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + rc = (arr.size() == 2 && arr[0]["seq"] == 4 && arr[1]["seq"] == 5) ? 0 : 1; + } + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "since=3 should yield seq 4 and 5"); + failed++; + } + test_suite_end("WebPortal::buildLogsJson::since", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: count cap limits entry count ── + { + test_begin("WebPortal::buildLogsJson", "count cap limits entries"); + LogCapture::begin(); + for (int i = 1; i <= 5; ++i) { + LogCapture::log(LogLevel::Info, "msg %d", i); + } + + JsonDocument doc; + size_t len = 0; + rc = buildAndParse(0, LogCapture::epoch(), 2, LogLevel::Debug, doc, len) ? 0 : 1; + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + rc = (arr.size() == 2 && arr[0]["seq"] == 1 && arr[1]["seq"] == 2) ? 0 : 1; + } + if (rc == 0) { + // Truncated response must NOT jump to lastSeq+1 (6): next = last consumed + // seq (2), so the client's next poll with since=2 fetches 3..5. + rc = (doc["next"] == 2) ? 0 : 1; + } + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "count=2 should yield seq 1 and 2"); + failed++; + } + test_suite_end("WebPortal::buildLogsJson::count", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: level filter excludes lower severities ── + { + test_begin("WebPortal::buildLogsJson", "level filter excludes lower severities"); + LogCapture::begin(); + LogCapture::log(LogLevel::Debug, "debug line"); + LogCapture::log(LogLevel::Info, "info line"); + LogCapture::log(LogLevel::Warning, "warn line"); + LogCapture::log(LogLevel::Error, "error line"); + + JsonDocument doc; + size_t len = 0; + rc = buildAndParse(0, LogCapture::epoch(), 200, LogLevel::Warning, doc, len) ? 0 : 1; + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + rc = (arr.size() == 2 && arr[0]["level"] == "warning" && arr[1]["level"] == "error") ? 0 : 1; + } + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "level=warning should yield warning and error only"); + failed++; + } + test_suite_end("WebPortal::buildLogsJson::level", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + // ── Test: empty result yields entries: [] ── + { + test_begin("WebPortal::buildLogsJson", "empty result yields empty entries"); + LogCapture::begin(); + LogCapture::log(LogLevel::Info, "a"); + LogCapture::log(LogLevel::Info, "b"); + LogCapture::clear(); + + JsonDocument doc; + size_t len = 0; + rc = buildAndParse(0, LogCapture::epoch(), 200, LogLevel::Debug, doc, len) ? 0 : 1; + if (rc == 0) { + JsonArray arr = doc["entries"].as(); + // No entries consumed → cursor stays at the requested since (0), so the + // client does not skip anything when new entries arrive. + rc = (arr.size() == 0 && doc["ok"] == true && doc["next"] == 0 && doc["boot"] == LogCapture::epoch()) ? 0 : 1; + } + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "after clear entries should be empty, next stays at since"); + failed++; + } + test_suite_end("WebPortal::buildLogsJson::empty", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + } + + return passed + failed; +} From 229e31c95095dabbddfa601ee9c3571727fb2eb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:41:59 +0200 Subject: [PATCH 04/25] build(deps): bump github/codeql-action from 4.37.3 to 4.37.4 (#171) --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index fe47cc78..f88dc34d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -37,7 +37,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.3 + uses: github/codeql-action/init@v4.37.4 with: languages: ${{ matrix.language }} @@ -70,7 +70,7 @@ jobs: # The reliable fix is to post-process the SARIF and strip results # under .pio/libdeps/ before uploading. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.3 + uses: github/codeql-action/analyze@v4.37.4 with: output: sarif-results upload: never @@ -87,6 +87,6 @@ jobs: done - name: Upload CodeQL results - uses: github/codeql-action/upload-sarif@v4.37.3 + uses: github/codeql-action/upload-sarif@v4.37.4 with: sarif_file: sarif-results From dd91530253f2752808bf3f6b646dbfc8b199a43b Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Mon, 10 Aug 2026 14:41:22 +0200 Subject: [PATCH 05/25] fix(logging): trace operation mode command sources (#174) --- src/MqttPublisher.cpp | 6 +-- src/OperationModeNode.cpp | 40 ++++++++++------ src/OperationModeNode.hpp | 1 + src/PoolController.cpp | 10 ++-- src/WebPortal.cpp | 4 +- test/native/mocks/OperationModeNode.hpp | 21 +++++++-- test/native/tests/test_mqttpublisher.cpp | 59 ++++++++++++++++++++++-- 7 files changed, 106 insertions(+), 35 deletions(-) diff --git a/src/MqttPublisher.cpp b/src/MqttPublisher.cpp index 999f28c0..e2f42903 100644 --- a/src/MqttPublisher.cpp +++ b/src/MqttPublisher.cpp @@ -1067,7 +1067,7 @@ void MqttPublisher::handleMqttMessage( return; } LOG_INFO("MQTT: Climate preset → pool mode \"%s\"\n", poolMode.c_str()); - operationModeNode.setMode(poolMode.c_str()); + operationModeNode.setMode(poolMode.c_str(), "mqtt:thermostat/preset"); ConfigManager::getSettings().opMode = poolMode; ConfigManager::save(); publishStates(); @@ -1088,7 +1088,7 @@ void MqttPublisher::handleMqttMessage( return; } LOG_INFO("MQTT: Climate mode → pool mode \"%s\"\n", poolMode.c_str()); - operationModeNode.setMode(poolMode.c_str()); + operationModeNode.setMode(poolMode.c_str(), "mqtt:thermostat/mode"); ConfigManager::getSettings().opMode = poolMode; ConfigManager::save(); publishStates(); @@ -1148,7 +1148,7 @@ void MqttPublisher::handleMqttMessage( return; } - operationModeNode.setMode(value.c_str()); + operationModeNode.setMode(value.c_str(), "mqtt:mode/set"); ConfigManager::getSettings().opMode = value; ConfigManager::save(); } else if (top.endsWith("/pool-max-temp/set")) { diff --git a/src/OperationModeNode.cpp b/src/OperationModeNode.cpp index 18b7e2d4..8caefe0f 100644 --- a/src/OperationModeNode.cpp +++ b/src/OperationModeNode.cpp @@ -113,22 +113,31 @@ Rule *OperationModeNode::getRule() { } bool OperationModeNode::setMode(String mode) { + return setMode(mode, "unspecified"); +} + +bool OperationModeNode::setMode(String mode, const char *source) { + if (source == nullptr) { + source = "unspecified"; + } if (mode.equals(STATUS_AUTO) || mode.equals(STATUS_MANU) || mode.equals(STATUS_BOOST) || mode.equals(STATUS_TIMER)) { - // Reset temperature-based runtime extension on mode change - for (auto &rule : _ruleVec) { - rule->resetTemperatureExtension(); - } - // Curated log event for MQTT event entity — only on actual mode change if (!_mode.equals(mode)) { - PoolController::LogCapture::logEvent("MODE_CHANGED", "Mode switched to %s", mode.c_str()); + // Reset temperature-based runtime extension on mode change + for (auto &rule : _ruleVec) { + rule->resetTemperatureExtension(); + } + PoolController::LogCapture::logEvent("MODE_CHANGED", "Mode changed %s -> %s (source=%s, persist=%s)", _mode.c_str(), + mode.c_str(), source, _suppressPersist ? "no" : "yes"); + _mode = mode; + LOG_DEBUG("set mode: %s (source=%s)\n", _mode.c_str(), source); + if (!_suppressPersist) + saveState(); + } else { + LOG_INFO("Mode set requested: %s -> %s (source=%s, changed=no, persist=no)\n", _mode.c_str(), mode.c_str(), source); } - _mode = mode; - LOG_DEBUG("set mode: %s\n", _mode.c_str()); - if (!_suppressPersist) - saveState(); return true; } else { - LOG_ERROR("✖ UNDEFINED Mode: %s. Current unchanged mode: %s\n", mode.c_str(), _mode.c_str()); + LOG_ERROR("✖ UNDEFINED Mode: %s. Current unchanged mode: %s (source=%s)\n", mode.c_str(), _mode.c_str(), source); return false; } } @@ -158,9 +167,8 @@ void OperationModeNode::loop() { if (rule != nullptr) { rule->loop(); } else { - LOG_ERROR(" ✖ no rule defined for mode: %s. Falling back to manual.\n", _mode.c_str()); - _mode = STATUS_MANU; - saveState(); + LOG_ERROR(" ✖ no rule defined for mode: %s. Falling back to manual (source=rule-fallback:no-rule).\n", _mode.c_str()); + setMode(STATUS_MANU, "rule-fallback:no-rule"); } } } @@ -177,7 +185,7 @@ bool OperationModeNode::applyProperty(const String &property, const String &valu if (property.equalsIgnoreCase("mode")) { LOG_INFO(" ✔ set operational mode: %s\n", value.c_str()); - retval = this->setMode(value); + retval = this->setMode(value, "ha:command"); } else if (property.equalsIgnoreCase("hysteresis")) { LOG_INFO(" ✔ hysteresis: %s\n", value.c_str()); float newValue; @@ -283,6 +291,8 @@ void OperationModeNode::loadState() { _mode = savedMode; } + LOG_INFO("✓ Operational mode loaded from persistent storage: %s (source=loadState)\n", _mode.c_str()); + _poolMaxTemp = StateManager::loadFloat("poolMaxTemp", 28.5f); _solarMinTemp = StateManager::loadFloat("solarMinTemp", 55.0f); _hysteresis = StateManager::loadFloat("hysteresis", 1.0f); diff --git a/src/OperationModeNode.hpp b/src/OperationModeNode.hpp index 1b4cbaf4..dab949e1 100644 --- a/src/OperationModeNode.hpp +++ b/src/OperationModeNode.hpp @@ -34,6 +34,7 @@ class OperationModeNode { uint32_t getMeasurementInterval() const { return _measurementInterval; } bool setMode(String mode); + bool setMode(String mode, const char *source); String getMode() const { return _mode; } void addRule(Rule *rule); diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 868676ba..fdca09e4 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -231,7 +231,7 @@ auto PoolControllerContext::initializeController() -> void { operationModeNode.begin(); // Load properties into Operation Mode - operationModeNode.setMode(ConfigManager::getSettings().opMode.c_str()); + operationModeNode.setMode(ConfigManager::getSettings().opMode.c_str(), "boot:config"); operationModeNode.setPoolMaxTemperature(ConfigManager::getSettings().tempMaxPool); operationModeNode.setSolarMinTemperature(ConfigManager::getSettings().tempMinSolar); operationModeNode.setTemperatureHysteresis(ConfigManager::getSettings().tempHysteresis); @@ -326,13 +326,13 @@ auto PoolControllerContext::setup() -> void { // Cycle operation mode const String ¤tMode = operationModeNode.getMode(); if (currentMode == "auto") { - operationModeNode.setMode("manu"); + operationModeNode.setMode("manu", "button:S3/menu"); } else if (currentMode == "manu") { - operationModeNode.setMode("boost"); + operationModeNode.setMode("boost", "button:S3/menu"); } else if (currentMode == "boost") { - operationModeNode.setMode("timer"); + operationModeNode.setMode("timer", "button:S3/menu"); } else { - operationModeNode.setMode("auto"); + operationModeNode.setMode("auto", "button:S3/menu"); } LOG_INFO("→ Mode switched to: %s\n", operationModeNode.getMode().c_str()); break; diff --git a/src/WebPortal.cpp b/src/WebPortal.cpp index d8529d8b..c49c2b8e 100644 --- a/src/WebPortal.cpp +++ b/src/WebPortal.cpp @@ -772,7 +772,7 @@ void WebPortal::apiSaveConfig() { operationModeNode.setMeasurementInterval(ConfigManager::getSettings().loopInterval); // Propagate changes directly into runtime parameters - operationModeNode.setMode(ConfigManager::getSettings().opMode.c_str()); + operationModeNode.setMode(ConfigManager::getSettings().opMode.c_str(), "web:settings"); operationModeNode.setPoolMaxTemperature(ConfigManager::getSettings().tempMaxPool); operationModeNode.setSolarMinTemperature(ConfigManager::getSettings().tempMinSolar); operationModeNode.setTemperatureHysteresis(ConfigManager::getSettings().tempHysteresis); @@ -844,7 +844,7 @@ void WebPortal::apiSetMode() { } String mode = server_.arg("mode"); - if (operationModeNode.setMode(mode)) { + if (operationModeNode.setMode(mode, "web:apiSetMode")) { ConfigManager::getSettings().opMode = mode; ConfigManager::save(); server_.send(200, "application/json", "{\"status\":\"ok\",\"mode\":\"" + mode + "\"}"); diff --git a/test/native/mocks/OperationModeNode.hpp b/test/native/mocks/OperationModeNode.hpp index 59df0a91..d7ae8e96 100644 --- a/test/native/mocks/OperationModeNode.hpp +++ b/test/native/mocks/OperationModeNode.hpp @@ -19,13 +19,23 @@ class OperationModeNode { void loop() {} String getMode() const { return String(_mode.c_str()); } - bool setMode(String mode) { _mode = mode.c_str(); return true; } + bool setMode(String mode) { return setMode(mode, "unspecified"); } + bool setMode(String mode, const char *source) { + _lastModeSource = source != nullptr ? source : "unspecified"; + _mode = mode.c_str(); + return true; + } + const char *getLastModeSource() const { return _lastModeSource.c_str(); } Rule *getRule() { - if (_mode == "auto") return &_autoRule; - if (_mode == "manu") return &_manuRule; - if (_mode == "boost") return &_boostRule; - if (_mode == "timer") return &_timerRule; + if (_mode == "auto") + return &_autoRule; + if (_mode == "manu") + return &_manuRule; + if (_mode == "boost") + return &_boostRule; + if (_mode == "timer") + return &_timerRule; return nullptr; } @@ -48,6 +58,7 @@ class OperationModeNode { private: std::string _mode = "auto"; + std::string _lastModeSource = ""; TimerSetting _timer; unsigned long _measurementInterval = 300; RuleAuto _autoRule; diff --git a/test/native/tests/test_mqttpublisher.cpp b/test/native/tests/test_mqttpublisher.cpp index c0a0b636..2ca946ca 100644 --- a/test/native/tests/test_mqttpublisher.cpp +++ b/test/native/tests/test_mqttpublisher.cpp @@ -316,19 +316,68 @@ int run_mqttpublisher_tests() { MqttPublisher::handleMqttMessage(const_cast("homeassistant/select/pool-controller/mode/set"), const_cast("boost"), AsyncMqttClientMessageProperties{0, false, false}, 5, 0, 5); - // The mode should have been set to "boost" + // The mode should have been set to "boost" and tagged with its MQTT source. std::string mode = operationModeNode.getMode().c_str(); - rc = (mode == "boost") ? 0 : 1; - if (rc == 0) { + int missing = 0; + if (mode == "boost") { test_pass(__FILE__, __LINE__); - passed++; } else { char msg[64]; snprintf(msg, sizeof(msg), "Expected mode=boost, got %s", mode.c_str()); test_fail(__FILE__, __LINE__, msg); + missing++; + } + if (strcmp(operationModeNode.getLastModeSource(), "mqtt:mode/set") == 0) { + test_pass(__FILE__, __LINE__); + } else { + test_fail(__FILE__, __LINE__, "Mode source not tagged as mqtt:mode/set"); + missing++; + } + + rc = (missing == 0) ? 0 : 1; + if (rc == 0) + passed++; + else failed++; + test_suite_end("MqttPublisher::handle_mode_command", missing == 0 ? 1 : 0, missing); + } + + // ── Test: Handle MQTT climate commands with mode source tags ── + { + test_begin("MqttPublisher", "handle climate mode/preset commands tags source"); + + operationModeNode.setMode("auto"); + AsyncMqttClientMessageProperties props{0, false, false}; + + char modeTopic[] = "homeassistant/climate/pool-controller/thermostat/mode/set"; + char modePayload[] = "heat"; + MqttPublisher::handleMqttMessage(modeTopic, modePayload, props, strlen(modePayload), 0, strlen(modePayload)); + + int missing = 0; + if (strcmp(operationModeNode.getLastModeSource(), "mqtt:thermostat/mode") == 0) { + test_pass(__FILE__, __LINE__); + } else { + test_fail(__FILE__, __LINE__, "Climate mode source not tagged as mqtt:thermostat/mode"); + missing++; + } + + char presetTopic[] = "homeassistant/climate/pool-controller/thermostat/preset/set"; + char presetPayload[] = "schedule"; + MqttPublisher::handleMqttMessage(presetTopic, presetPayload, props, strlen(presetPayload), 0, strlen(presetPayload)); + + if (strcmp(operationModeNode.getLastModeSource(), "mqtt:thermostat/preset") == 0) { + test_pass(__FILE__, __LINE__); + } else { + test_fail(__FILE__, __LINE__, "Climate preset source not tagged as mqtt:thermostat/preset"); + missing++; } - test_suite_end("MqttPublisher::handle_mode_command", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); + + rc = (missing == 0) ? 0 : 1; + if (rc == 0) + passed++; + else + failed++; + test_suite_end("MqttPublisher::handle_climate_mode_sources", missing == 0 ? 1 : 0, missing); } // ── Test: Handle MQTT pump command in manual mode ── From 81ba7d801e708bd0b2f5f3c67e306358e00e2bdb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:41:45 +0200 Subject: [PATCH 06/25] build(deps): bump github/codeql-action from 4.37.4 to 4.37.6 (#172) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f88dc34d..9f869a30 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -37,7 +37,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.6 with: languages: ${{ matrix.language }} @@ -70,7 +70,7 @@ jobs: # The reliable fix is to post-process the SARIF and strip results # under .pio/libdeps/ before uploading. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.6 with: output: sarif-results upload: never @@ -87,6 +87,6 @@ jobs: done - name: Upload CodeQL results - uses: github/codeql-action/upload-sarif@v4.37.4 + uses: github/codeql-action/upload-sarif@v4.37.6 with: sarif_file: sarif-results From 2777cc960a9d41afd77fe8edf43ec1fd54df1f02 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Tue, 11 Aug 2026 15:23:40 +0200 Subject: [PATCH 07/25] feat: add Olimex ESP32-C6 local UI (#175) * docs: add olimex c6 local ui concept * docs: refine olimex local ui display concept * docs: verify olimex local ui power compatibility * docs: make olimex display configuration flexible * docs: add olimex local ui implementation plan * docs: adjust olimex implementation pin defaults * build: add olimex esp32-c6 board variant * build: use pioarduino for olimex c6 arduino * build: pin onewire for esp32-c6 compatibility * build: use adafruit tft libraries for c6 * fix(build): add explicit cstdint include * fix(build): support esp32-c6 arduino compatibility * build: pin pioarduino platform release * feat(ui): add local settings menu state machine * feat(ui): add ky040 encoder input handling * feat(ui): add olimex tft display wrapper * fix(ui): use adafruit tft wrapper on olimex * fix(ui): initialize olimex tft spi pins * fix(ui): use st7789 init for olimex tft * feat(ui): wire olimex local tft interface * fix(ui): include olimex local ui wiring * docs: add olimex local ui bring-up guide * docs: clarify olimex local ui bring-up * chore: remove sdd scratch report * docs: add olimex board reference links * docs: add olimex references to skills * style: format olimex local ui sources * fix(ui): address olimex local ui review comments * ci: add olimex c6 platformio matrices * fix(ui): implement local menu actions * fix(ui): refresh olimex live pages safely * fix(ui): show active mode and ap address --- .github/workflows/plaform.io.yml | 18 +- .github/workflows/release.yml | 16 +- .opencode/skills/deploy/SKILL.md | 33 +- .opencode/skills/hugo-docs/SKILL.md | 3 + .opencode/skills/platformio-env/SKILL.md | 11 + .opencode/skills/platformio-workflow/SKILL.md | 11 + .opencode/skills/web-ui/SKILL.md | 4 + docs/olimex-esp32-c6-evb.md | 69 + ...08-10-olimex-c6-local-ui-implementation.md | 1345 +++++++++++++++++ .../2026-08-09-olimex-c6-local-ui-design.md | 429 ++++++ platformio.ini | 30 +- src/Config.hpp | 42 + src/ConfigManager.cpp | 6 +- src/ESP32TemperatureNode.cpp | 50 + src/ESP32TemperatureNode.hpp | 3 + src/Ky040Decoder.cpp | 59 + src/Ky040Decoder.hpp | 23 + src/LocalSettingsMenu.cpp | 106 ++ src/LocalSettingsMenu.hpp | 38 + src/LocalUiTypes.hpp | 58 + src/MqttPublisher.hpp | 1 + src/NetworkManager.hpp | 2 +- src/OlimexEncoderHandler.cpp | 45 + src/OlimexEncoderHandler.hpp | 21 + src/OlimexLocalUi.cpp | 101 ++ src/OlimexLocalUi.hpp | 12 + src/OlimexTftDisplay.cpp | 199 +++ src/OlimexTftDisplay.hpp | 22 + src/PoolController.cpp | 16 + src/WpsProvisioner.cpp | 4 +- test/native/CMakeLists.txt | 5 + test/native/tests/test_ky040_decoder.cpp | 69 + .../native/tests/test_local_settings_menu.cpp | 107 ++ test/native/tests/test_main.cpp | 4 + 34 files changed, 2937 insertions(+), 25 deletions(-) create mode 100644 docs/olimex-esp32-c6-evb.md create mode 100644 docs/superpowers/plans/2026-08-10-olimex-c6-local-ui-implementation.md create mode 100644 docs/superpowers/specs/2026-08-09-olimex-c6-local-ui-design.md create mode 100644 src/Ky040Decoder.cpp create mode 100644 src/Ky040Decoder.hpp create mode 100644 src/LocalSettingsMenu.cpp create mode 100644 src/LocalSettingsMenu.hpp create mode 100644 src/LocalUiTypes.hpp create mode 100644 src/OlimexEncoderHandler.cpp create mode 100644 src/OlimexEncoderHandler.hpp create mode 100644 src/OlimexLocalUi.cpp create mode 100644 src/OlimexLocalUi.hpp create mode 100644 src/OlimexTftDisplay.cpp create mode 100644 src/OlimexTftDisplay.hpp create mode 100644 test/native/tests/test_ky040_decoder.cpp create mode 100644 test/native/tests/test_local_settings_menu.cpp diff --git a/.github/workflows/plaform.io.yml b/.github/workflows/plaform.io.yml index 84b95a6d..9a39cbb0 100644 --- a/.github/workflows/plaform.io.yml +++ b/.github/workflows/plaform.io.yml @@ -40,7 +40,13 @@ jobs: strategy: matrix: - environment: [esp32dev, norvi_ae01_r] + include: + - environment: esp32dev + pio-core-dir: ~/.platformio + - environment: norvi_ae01_r + pio-core-dir: ~/.platformio + - environment: olimex_esp32_c6_evb + pio-core-dir: ~/.platformio-olimex-c6 steps: - name: Checkout @@ -50,11 +56,11 @@ jobs: uses: actions/cache@v6 with: path: | - ~/.platformio + ${{ matrix.pio-core-dir }} .pio - key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }} + key: ${{ runner.os }}-pio-${{ matrix.environment }}-${{ hashFiles('**/platformio.ini') }} restore-keys: | - ${{ runner.os }}-pio- + ${{ runner.os }}-pio-${{ matrix.environment }}- - name: Set up Python uses: actions/setup-python@v7 @@ -67,9 +73,13 @@ jobs: pip install platformio intelhex - name: Run PlatformIO Check + env: + PLATFORMIO_CORE_DIR: ${{ matrix.pio-core-dir }} run: > platformio check --environment ${{ matrix.environment }} --skip-packages - name: Build PlatformIO Project + env: + PLATFORMIO_CORE_DIR: ${{ matrix.pio-core-dir }} run: platformio run --environment ${{ matrix.environment }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35c9fc31..b14b3fe4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,13 @@ jobs: strategy: matrix: - environment: [esp32dev, norvi_ae01_r] + include: + - environment: esp32dev + pio-core-dir: ~/.platformio + - environment: norvi_ae01_r + pio-core-dir: ~/.platformio + - environment: olimex_esp32_c6_evb + pio-core-dir: ~/.platformio-olimex-c6 steps: - name: Checkout @@ -55,11 +61,11 @@ jobs: uses: actions/cache@v6 with: path: | - ~/.platformio + ${{ matrix.pio-core-dir }} .pio - key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }} + key: ${{ runner.os }}-pio-${{ matrix.environment }}-${{ hashFiles('**/platformio.ini') }} restore-keys: | - ${{ runner.os }}-pio- + ${{ runner.os }}-pio-${{ matrix.environment }}- - name: Set up Python uses: actions/setup-python@v7 @@ -72,6 +78,8 @@ jobs: pip install platformio intelhex - name: Build firmware + env: + PLATFORMIO_CORE_DIR: ${{ matrix.pio-core-dir }} run: platformio run --environment ${{ matrix.environment }} - name: Upload firmware to release diff --git a/.opencode/skills/deploy/SKILL.md b/.opencode/skills/deploy/SKILL.md index f541760f..a2925c0b 100644 --- a/.opencode/skills/deploy/SKILL.md +++ b/.opencode/skills/deploy/SKILL.md @@ -22,6 +22,8 @@ keywords: - version bump - ausrollen - flashen + - olimex esp32 c6 evb + - pioarduino --- # Deploy — Pool Controller @@ -38,13 +40,19 @@ serial flash, web filesystem upload, OTA update, and semver release management. │ - format │ │ -e norvi │ │ Serial: │ │ - version check│ │ - version │ │ │ │ flash + uploadfs │ │ - web UI │ └─────────────┘ └──────────────┘ │ OTA: │ └─────────────────┘ - │ 1. flash firmware │ - │ 2. upload web fs │ - │ via /api/fs/ │ - │ upload (6 files)│ - └──────────────────────┘ + │ 1. flash firmware │ + │ 2. upload web fs │ + │ via /api/fs/ │ + │ upload (6 files)│ + └──────────────────────┘ ``` +## Olimex local UI note + +- The Olimex ESP32-C6-EVB local TFT UI is separate from the browser/LittleFS web UI. +- For C6 build/deploy work, use the pinned pioarduino environment and a separate `PLATFORMIO_CORE_DIR` from upstream ESP32/NORVI builds. +- The QR page points to `http://pool-controller.local`; do not confuse it with the web dashboard assets in `data/web/`. + ## Prerequisites ```bash @@ -261,6 +269,7 @@ curl -b /tmp/ota-cookie.txt -X POST http:///api/fs/upload \ ``` Response `200 OK` means the file was written to LittleFS. The endpoint: + - Requires authentication (valid session cookie) - Only allows paths under `/web/` (security) - Blocks path traversal (`..`) @@ -272,13 +281,13 @@ Response `200 OK` means the file was written to LittleFS. The endpoint: ### OTA troubleshooting -| Symptom | Fix | -| ------------------------------------ | ------------------------------------------------------ | -| `HTTP 401` on upload | Session expired — re-login (cookie valid 10 min) | -| `HTTP 429` on login | Too many failed attempts — wait for lockout to expire | -| `curl: (7) Failed to connect` | Device unreachable — check WiFi, ping the IP/hostname | -| Upload succeeds but device stays off | Wait ~30s for reboot, then check `/api/status` uptime | -| Device doesn't boot after OTA | Serial flash a known-good firmware (see Rollback) | +| Symptom | Fix | +| ------------------------------------ | ----------------------------------------------------- | +| `HTTP 401` on upload | Session expired — re-login (cookie valid 10 min) | +| `HTTP 429` on login | Too many failed attempts — wait for lockout to expire | +| `curl: (7) Failed to connect` | Device unreachable — check WiFi, ping the IP/hostname | +| Upload succeeds but device stays off | Wait ~30s for reboot, then check `/api/status` uptime | +| Device doesn't boot after OTA | Serial flash a known-good firmware (see Rollback) | ## CI/CD Pipeline (GitHub Actions) diff --git a/.opencode/skills/hugo-docs/SKILL.md b/.opencode/skills/hugo-docs/SKILL.md index c6aa33f4..3d1fa6c1 100644 --- a/.opencode/skills/hugo-docs/SKILL.md +++ b/.opencode/skills/hugo-docs/SKILL.md @@ -22,6 +22,7 @@ keywords: - doc update - seo - canonical url + - olimex esp32 c6 evb --- # Hugo Documentation — Pool Controller @@ -60,6 +61,8 @@ smart-swimmingpool/website/ # Hugo site build → smart-swimmingpool.com ## Project-Specific Hugo Conventions +For Olimex local-UI hardware notes, keep the source-of-truth checklist in `docs/olimex-esp32-c6-evb.md` aligned with implementation and `src/Config.hpp` pin names. + ### Frontmatter All documentation files use Hugo frontmatter with these fields: diff --git a/.opencode/skills/platformio-env/SKILL.md b/.opencode/skills/platformio-env/SKILL.md index 85c67c40..f017c9d1 100644 --- a/.opencode/skills/platformio-env/SKILL.md +++ b/.opencode/skills/platformio-env/SKILL.md @@ -18,6 +18,9 @@ keywords: - pio cli - ci integration - platformio check + - olimex esp32 c6 evb + - pioarduino + - platformio core dir --- # PlatformIO Environment — Pool Controller @@ -27,6 +30,14 @@ covers the build system, platform configuration, library management, and environment setup — complementing the `platformio-workflow` skill for operational commands. +## Olimex ESP32-C6-EVB Notes + +- Use the `olimex_esp32_c6_evb` environment for the Olimex board. +- ESP32-C6 Arduino builds require the pinned pioarduino platform; keep a separate `PLATFORMIO_CORE_DIR` for C6/pioarduino builds versus upstream ESP32/NORVI builds. +- Native USB CDC is the serial console path; keep `ARDUINO_USB_CDC_ON_BOOT=1` enabled. +- Local UI wiring lives in `src/Config.hpp`; treat it as the source of truth for TFT, encoder, relay, and sensor pins. +- Current Olimex local UI pin highlights: TFT MOSI/SCLK/CS/DC = GPIO18/19/21/7, TFT MISO = -1 (write-only), KY-040 CLK/DT/SW = GPIO4/5/16, DS18B20 pool = GPIO20, relays = GPIO10/11, status LED = GPIO8. + ## Architecture Overview ```text diff --git a/.opencode/skills/platformio-workflow/SKILL.md b/.opencode/skills/platformio-workflow/SKILL.md index 4314d872..e8298aa7 100644 --- a/.opencode/skills/platformio-workflow/SKILL.md +++ b/.opencode/skills/platformio-workflow/SKILL.md @@ -14,6 +14,10 @@ keywords: - pio device monitor - dependency management - library dependency + - olimex build + - pioarduino + - native usb cdc + - olimex_esp32_c6_evb --- # PlatformIO Workflow — Pool Controller @@ -32,6 +36,13 @@ Build, flash, monitor, and OTA operations for the ESP32 pool-controller. - **Serial speed**: 115200 baud - **Upload speed**: 230400 baud +### Olimex C6 variant + +- Build target: `olimex_esp32_c6_evb` +- Use the pinned pioarduino platform and a separate `PLATFORMIO_CORE_DIR` when building C6, so the pioarduino packages do not interfere with upstream ESP32/NORVI builds. +- Native USB CDC is the serial console path on this board; keep `ARDUINO_USB_CDC_ON_BOOT=1` enabled. +- Olimex local UI hardware smoke order: build, boot without peripherals, wire TFT (3.3 V logic, reset tied high), then KY-040 on GPIO 4/5/16. + ## Build Commands ```bash diff --git a/.opencode/skills/web-ui/SKILL.md b/.opencode/skills/web-ui/SKILL.md index d1147463..fe2e4747 100644 --- a/.opencode/skills/web-ui/SKILL.md +++ b/.opencode/skills/web-ui/SKILL.md @@ -22,12 +22,16 @@ keywords: - pool dashboard - telemetry - hot reload + - olimex local tft ui + - qrcode page --- # Web UI — Pool Controller Web interface development for the ESP32 pool-controller. The UI lives in two layers: **LittleFS** (editable at runtime) and **PROGMEM** (embedded fallback in the firmware binary). +> **Note:** The Olimex local TFT UI is a separate on-device interface (Adafruit GFX + ILI9341/ST7789) and is not the browser/LittleFS dashboard. + > **🔍 Code Search**: Use `semble search "LittleFS.open"` or `semble search "kPortalPageHtml"` to find the fallback architecture. See `Agents.md` §7 for full `semble` usage. --- diff --git a/docs/olimex-esp32-c6-evb.md b/docs/olimex-esp32-c6-evb.md new file mode 100644 index 00000000..0c73ee90 --- /dev/null +++ b/docs/olimex-esp32-c6-evb.md @@ -0,0 +1,69 @@ +# Olimex ESP32-C6-EVB Variant + +## Hardware + +- Board: Olimex ESP32-C6-EVB +- Display: Adafruit GFX + Adafruit ILI9341/ST7789 on a configurable 320x240 SPI TFT, 2.8" preferred, 2.4" acceptable +- Display controller selection: set by `TFT_DRIVER_ILI9341` / `TFT_DRIVER_ST7789` in `src/Config.hpp` +- Input: KY-040 rotary encoder with push button +- Logic level: 3.3 V only + +## Olimex Reference Documentation + +Use the official Olimex files as the source of truth for board-level details: + +- Product page: +- Repository: +- User manual: +- Schematic Rev. A: +- Board dimensions Rev. A: +- Olimex Arduino full demo and pin reference: + +## Olimex Board Notes + +- Native USB CDC is the expected serial console path. Keep `ARDUINO_USB_CDC_ON_BOOT=1` enabled for this environment. +- Olimex demo pins to avoid for the external local UI when possible: GPIO8 user LED, GPIO9 user button, GPIO10/11/22/23 relay outputs, and GPIO1/2/3/15 optocoupler inputs. +- The Olimex optocoupler inputs are intended for higher-voltage field signals; the SPI TFT and KY-040 wiring are not. Keep TFT and encoder signals at 3.3 V. +- Check the official schematic and user manual before changing any pin assignment or adding UEXT wiring. + +## Current Pool Controller Pin Map + +Configured in `src/Config.hpp` for `OLIMEX_ESP32_C6_EVB`: + +| Function | GPIO | Notes | +| ------------- | ---: | ----------------------------------------------------------------------------- | +| TFT MOSI | 18 | SPI write data | +| TFT SCLK | 19 | SPI clock | +| TFT MISO | -1 | Write-only TFT; leave SDO/MISO unconnected | +| TFT CS | 21 | TFT chip select | +| TFT DC | 7 | TFT data/command | +| TFT RST | -1 | Display reset is tied high, not GPIO-controlled | +| TFT backlight | -1 | Fixed 3.3 V backlight or external MOSFET/current limiting | +| KY-040 CLK | 4 | Encoder A/CLK, internal pull-up | +| KY-040 DT | 5 | Encoder B/DT, internal pull-up | +| KY-040 SW | 16 | Push button, internal pull-up | +| DS18B20 solar | 6 | Temperature bus | +| DS18B20 pool | 20 | Shared with the (unused) TFT MISO/readback pin if that pin is ever re-enabled | +| Pool relay | 10 | Olimex relay output | +| Solar relay | 11 | Olimex relay output | +| Status LED | 8 | Olimex user LED | + +## Safety Rules + +- Do not feed 5 V into ESP32-C6 GPIOs. +- Do not drive the TFT backlight directly from an ESP32 GPIO. +- Power the TFT logic and KY-040 from 3.3 V. +- Verify display pinout, backlight polarity, and current limiting before wiring. + +## Bring-Up Steps + +1. Build `olimex_esp32_c6_evb`. +2. Power the board without TFT/encoder and verify serial boot over native USB CDC. +3. Connect TFT VCC/GND/SPI/DC; tie TFT RST high unless you update `PIN_TFT_RST` in `src/Config.hpp`. +4. Verify the TFT boot screen. +5. Connect KY-040 CLK/DT/SW to GPIO 4 / 5 / 16 from `src/Config.hpp`. +6. Verify rotate clockwise/counter-clockwise changes pages. +7. Verify short press opens menu. +8. Verify long press returns to overview. +9. Open QR page and scan `http://pool-controller.local`. +10. Only after UI validation, connect real relay loads and opto inputs. diff --git a/docs/superpowers/plans/2026-08-10-olimex-c6-local-ui-implementation.md b/docs/superpowers/plans/2026-08-10-olimex-c6-local-ui-implementation.md new file mode 100644 index 00000000..82aac549 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-olimex-c6-local-ui-implementation.md @@ -0,0 +1,1345 @@ +# Olimex C6 Local UI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an Olimex ESP32-C6-EVB hardware variant with a configurable 320x240 SPI TFT local display and KY-040 rotary encoder navigation. + +**Architecture:** Keep pool-control logic unchanged and add a board-specific local UI path behind compile-time flags. Put the host-testable menu/event logic in small generic modules, then wrap Olimex-specific display and encoder hardware behind `OlimexLocalUi` so `PoolController.cpp` only wires `begin()` and `loop()`. + +**Tech Stack:** Arduino ESP32 / PlatformIO, C++17, TFT_eSPI or Adafruit GFX display driver selected by implementation, existing `ConfigManager`/NVS settings, existing native CMake test runner. + +## Global Constraints + +- Board variant macro: `OLIMEX_ESP32_C6_EVB`. +- Local TFT feature macro: `HAS_LOCAL_TFT_UI`. +- Display: configurable 320x240 SPI TFT; 2.8" preferred, 2.4" acceptable. +- Driver configuration must support ST7789 or ILI9341-class controllers without hard-coding physical diagonal size. +- Display config constants: `DISPLAY_WIDTH = 320`, `DISPLAY_HEIGHT = 240`, `DISPLAY_DRIVER = ILI9341 or ST7789`, `DISPLAY_SIZE_CLASS = compact or normal`. +- Use 3.3 V logic only. ESP32-C6 GPIOs are not 5 V tolerant. +- TFT backlight must never be sourced directly from an ESP32 GPIO. GPIO may only control a transistor/MOSFET for PWM dimming. +- No full 320x240 RGB framebuffer requirement; use partial drawing/library-managed buffering. +- QR-code screen must use a dedicated page with black-on-white rendering and short URL payload. +- Existing `esp32dev` and `norvi_ae01_r` builds must keep compiling. +- No blocking UI loops; `PoolControllerContext::loop()` must keep reaching watchdog feeding. +- Do not persist menu cursor movement to NVS; only persist confirmed setting changes. +- Keep `graphify-out/` untracked and out of commits. + +--- + +## File Structure + +Create these focused modules: + +- `src/LocalUiTypes.hpp` — shared UI enums/value objects (`LocalUiEvent`, `LocalUiPage`, `DisplayDriver`, `DisplaySizeClass`, `TftDisplayConfig`). No hardware includes. +- `src/LocalSettingsMenu.hpp` / `src/LocalSettingsMenu.cpp` — host-testable menu state machine. Consumes encoder-style UI events and produces page/menu state. No Arduino display code. +- `src/Ky040Decoder.hpp` / `src/Ky040Decoder.cpp` — host-testable KY-040 quadrature and button press decoding. No direct GPIO reads. +- `src/OlimexEncoderHandler.hpp` / `src/OlimexEncoderHandler.cpp` — Arduino GPIO wrapper for KY-040. Reads pins from `Config.hpp`, feeds `Ky040Decoder`, emits `LocalUiEvent` callbacks. +- `src/OlimexTftDisplay.hpp` / `src/OlimexTftDisplay.cpp` — TFT rendering wrapper for overview, menu, status, and QR pages. Compiled only under `OLIMEX_ESP32_C6_EVB` or `HAS_LOCAL_TFT_UI`. +- `src/OlimexLocalUi.hpp` / `src/OlimexLocalUi.cpp` — composes `OlimexEncoderHandler`, `LocalSettingsMenu`, and `OlimexTftDisplay`; exposes `begin()`, `loop()`, and `requestRedraw()`. + +Modify these existing files: + +- `src/Config.hpp` — add Olimex board pin mapping and display config constants. +- `platformio.ini` — add `olimex_esp32_c6_evb` environment and TFT/QR dependencies. +- `src/PoolController.cpp` — initialize and loop the Olimex local UI behind compile-time flags. +- `test/native/CMakeLists.txt` — add host-testable new `.cpp` files and test sources. +- `test/native/tests/test_main.cpp` — register new test suites. + +Add these tests: + +- `test/native/tests/test_local_settings_menu.cpp` — menu navigation, page switching, confirm/cancel behavior, QR page navigation. +- `test/native/tests/test_ky040_decoder.cpp` — clockwise/counter-clockwise quadrature, debounce, short press, long press. + +--- + +### Task 1: Board Variant and Display Configuration + +**Files:** + +- Modify: `src/Config.hpp:75-151` +- Modify: `platformio.ini:29-88` + +**Interfaces:** + +- Produces: `OLIMEX_ESP32_C6_EVB`, `HAS_LOCAL_TFT_UI`, `PIN_TFT_*`, `PIN_ENCODER_*`, `TFT_DISPLAY_WIDTH`, `TFT_DISPLAY_HEIGHT`, `TFT_DISPLAY_SIZE_CLASS_COMPACT`, `TFT_DRIVER_ILI9341`, `TFT_DRIVER_ST7789` constants for later tasks. + +- [ ] **Step 1: Add Olimex pin mapping in `src/Config.hpp`** + +Insert an `#elif defined(OLIMEX_ESP32_C6_EVB)` block between the NORVI block and the default ESP32 block. Use UEXT SPI for the display and free GPIOs for encoder/button. Keep final pin choices easy to adjust after schematic/hardware verification. + +```cpp +#elif defined(OLIMEX_ESP32_C6_EVB) + +/** @brief DS18B20 data pin — solar collector temperature sensor. */ +constexpr std::uint8_t PIN_DS_SOLAR{6}; +/** @brief DS18B20 data pin — pool water temperature sensor. */ +constexpr std::uint8_t PIN_DS_POOL{20}; +/** @brief Relay control pin — pool circulation pump (Olimex relay). */ +constexpr std::uint8_t PIN_RELAY_POOL{10}; +/** @brief Relay control pin — solar heating pump (Olimex relay). */ +constexpr std::uint8_t PIN_RELAY_SOLAR{11}; +/** @brief Status LED — Olimex user LED. */ +constexpr std::uint8_t PIN_LED_STATUS{8}; +/** @brief Optional warning LED — not used on Olimex. */ +constexpr std::int8_t PIN_LED_WARN{-1}; + +// ── Olimex local UI pins ──────────────────────────────────────────────────── +constexpr std::uint8_t PIN_TFT_MOSI{18}; +constexpr std::uint8_t PIN_TFT_SCLK{19}; +constexpr std::uint8_t PIN_TFT_MISO{20}; +constexpr std::uint8_t PIN_TFT_CS{21}; +constexpr std::uint8_t PIN_TFT_DC{7}; +constexpr std::int8_t PIN_TFT_RST{-1}; // -1 = display reset tied high +constexpr std::int8_t PIN_TFT_BACKLIGHT{-1}; // -1 = fixed 3.3 V backlight + +constexpr std::uint8_t PIN_ENCODER_CLK{4}; +constexpr std::uint8_t PIN_ENCODER_DT{5}; +constexpr std::uint8_t PIN_ENCODER_SW{0}; + +constexpr std::uint16_t TFT_DISPLAY_WIDTH{320}; +constexpr std::uint16_t TFT_DISPLAY_HEIGHT{240}; +constexpr bool TFT_DISPLAY_SIZE_CLASS_COMPACT{false}; +constexpr bool TFT_DRIVER_ILI9341{true}; +constexpr bool TFT_DRIVER_ST7789{false}; +``` + +Do not use GPIO1/2/3/15 for UI; those are Olimex opto inputs. Do not use GPIO10/11/22/23 except for relays. GPIO12/13/14 are not useful defaults because they are not exposed on the standard EVB headers. The defaults above intentionally keep the encoder on UEXT UART1 pins GPIO4/GPIO5 plus EXT1 GPIO0, keep TFT SPI on UEXT SPI, and avoid display reset by requiring the concrete TFT module to tie reset high or expose a reset pad that can be wired later. + +- [ ] **Step 2: Add static assertions for display driver selection** + +At the end of the namespace, before `} // namespace PoolController`, add: + +```cpp +#if defined(OLIMEX_ESP32_C6_EVB) +static_assert(TFT_DISPLAY_WIDTH == 320, "Olimex local UI expects a 320 px wide display"); +static_assert(TFT_DISPLAY_HEIGHT == 240, "Olimex local UI expects a 240 px high display"); +static_assert(TFT_DRIVER_ILI9341 != TFT_DRIVER_ST7789, "Select exactly one TFT driver"); +static_assert(PIN_TFT_RST < 0 || PIN_TFT_RST != PIN_TFT_CS, "TFT reset pin must not conflict with TFT CS"); +static_assert(PIN_TFT_BACKLIGHT < 0 || PIN_TFT_BACKLIGHT != PIN_TFT_CS, "Backlight control pin must not conflict with TFT CS"); +#endif +``` + +- [ ] **Step 3: Add PlatformIO environment** + +Append this environment to `platformio.ini`: + +```ini +[env:olimex_esp32_c6_evb] +platform = espressif32 @ 7.0.1 +board = esp32-c6-devkitc-1 +framework = arduino +build_flags = + ; FW_VERSION is auto-maintained by release-please — do not edit manually + '-D FW_VERSION="4.2.1"' # x-release-please-version + '-D GITHUB_REPO="smart-swimmingpool/pool-controller"' + -D SERIAL_SPEED=${common.serial_speed} + -D OLIMEX_ESP32_C6_EVB + -D HAS_LOCAL_TFT_UI + -D LOG_BUFFER_SIZE=8192 + -std=c++17 + -Wno-deprecated-declarations +build_unflags = -Werror=reorder +lib_deps = + ${common_env_data.lib_deps} + bodmer/TFT_eSPI @ ^2.5.43 + ricmoo/QRCode @ ^0.0.1 +monitor_speed = ${common.serial_speed} +monitor_filters = esp32_exception_decoder, log2file, time, default +upload_speed = 115200 +board_build.filesystem = littlefs +``` + +If PlatformIO rejects `esp32-c6-devkitc-1`, replace it with the closest board listed by `venv/bin/pio boards espressif32 | grep -i c6`, then document the replacement in the task commit message. + +- [ ] **Step 4: Verify config syntax by building existing environments** + +Run: + +```bash +venv/bin/pio run -e esp32dev +venv/bin/pio run -e norvi_ae01_r +``` + +Expected: both builds pass. If either fails due unrelated existing dependency cache, run `venv/bin/pio pkg install` once and retry. + +- [ ] **Step 5: Try the new Olimex environment** + +Run: + +```bash +venv/bin/pio run -e olimex_esp32_c6_evb +``` + +Expected: compile may fail because UI files do not exist yet. Acceptable failure for this task: missing `OlimexLocalUi`/display classes only. Not acceptable: unknown board, syntax errors, or missing shared libraries. + +- [ ] **Step 6: Commit** + +```bash +git add src/Config.hpp platformio.ini +git commit -m "build: add olimex esp32-c6 board variant" +``` + +--- + +### Task 2: Shared Local UI Types and Menu State Machine + +**Files:** + +- Create: `src/LocalUiTypes.hpp` +- Create: `src/LocalSettingsMenu.hpp` +- Create: `src/LocalSettingsMenu.cpp` +- Create: `test/native/tests/test_local_settings_menu.cpp` +- Modify: `test/native/CMakeLists.txt:69-88` +- Modify: `test/native/tests/test_main.cpp:101-126` + +**Interfaces:** + +- Produces: `enum class LocalUiEvent`, `enum class LocalUiPage`, `enum class LocalMenuItem`, `class LocalSettingsMenu`. +- Consumes: no hardware; later tasks call `LocalSettingsMenu::handleEvent(LocalUiEvent)`. + +- [ ] **Step 1: Create `src/LocalUiTypes.hpp`** + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace PoolController { + +enum class LocalUiEvent : std::uint8_t { + NONE = 0, + ROTATE_CLOCKWISE, + ROTATE_COUNTER_CLOCKWISE, + SHORT_PRESS, + LONG_PRESS, +}; + +enum class LocalUiPage : std::uint8_t { + OVERVIEW = 0, + NETWORK, + SYSTEM, + QRCODE, + MENU, +}; + +enum class LocalMenuItem : std::uint8_t { + MODE = 0, + PUMP, + NETWORK_STATUS, + QR_CODE, + EXIT, +}; + +enum class DisplayDriver : std::uint8_t { + ILI9341 = 0, + ST7789, +}; + +enum class DisplaySizeClass : std::uint8_t { + COMPACT = 0, + NORMAL, +}; + +struct TftDisplayConfig { + std::uint16_t width; + std::uint16_t height; + DisplayDriver driver; + DisplaySizeClass sizeClass; +}; + +} // namespace PoolController +``` + +- [ ] **Step 2: Write failing menu tests** + +Create `test/native/tests/test_local_settings_menu.cpp`: + +```cpp +#include "LocalSettingsMenu.hpp" + +#include + +#define ASSERT_TRUE(expr) \ + do { \ + if (!(expr)) { \ + printf(" ✗ %s:%d expected true: %s\n", __FILE__, __LINE__, #expr); \ + return 1; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::LocalMenuItem; +using PoolController::LocalSettingsMenu; +using PoolController::LocalUiEvent; +using PoolController::LocalUiPage; + +static int test_short_press_opens_menu() { + LocalSettingsMenu menu; + ASSERT_EQ(menu.currentPage(), LocalUiPage::OVERVIEW); + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::MENU); + ASSERT_TRUE(menu.isMenuActive()); + ASSERT_EQ(menu.currentMenuItem(), LocalMenuItem::MODE); + return 0; +} + +static int test_rotate_pages_on_overview() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::NETWORK); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::SYSTEM); + menu.handleEvent(LocalUiEvent::ROTATE_COUNTER_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::NETWORK); + return 0; +} + +static int test_menu_navigation_and_qr_selection() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentMenuItem(), LocalMenuItem::QR_CODE); + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::QRCODE); + ASSERT_TRUE(!menu.isMenuActive()); + return 0; +} + +static int test_long_press_returns_to_overview() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::NETWORK); + menu.handleEvent(LocalUiEvent::LONG_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::OVERVIEW); + return 0; +} + +int run_local_settings_menu_tests() { + int failures = 0; + failures += test_short_press_opens_menu(); + failures += test_rotate_pages_on_overview(); + failures += test_menu_navigation_and_qr_selection(); + failures += test_long_press_returns_to_overview(); + if (failures == 0) { + printf(" LocalSettingsMenu Tests: 4 passed, 0 failed\n"); + } + return failures; +} +``` + +- [ ] **Step 3: Register the failing test suite** + +In `test/native/tests/test_main.cpp`, add: + +```cpp +extern int run_local_settings_menu_tests(); +``` + +and call it after `run_webportal_logs_tests()`: + +```cpp +total += run_local_settings_menu_tests(); +``` + +In `test/native/CMakeLists.txt`, add to `SERVICE_SOURCES`: + +```cmake + ${PROJ_ROOT}/src/LocalSettingsMenu.cpp +``` + +and add to `TEST_SOURCES`: + +```cmake + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_local_settings_menu.cpp +``` + +- [ ] **Step 4: Run test and verify it fails** + +Run: + +```bash +cmake --build test/native/build && test/native/build/test_runner +``` + +Expected: compilation fails because `LocalSettingsMenu.hpp` does not exist, or link fails because `LocalSettingsMenu` methods are not defined. + +- [ ] **Step 5: Create `src/LocalSettingsMenu.hpp`** + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "LocalUiTypes.hpp" + +namespace PoolController { + +class LocalSettingsMenu { +public: + LocalSettingsMenu() = default; + + void handleEvent(LocalUiEvent event); + + LocalUiPage currentPage() const { return currentPage_; } + LocalMenuItem currentMenuItem() const { return currentMenuItem_; } + bool isMenuActive() const { return currentPage_ == LocalUiPage::MENU; } + bool needsRedraw() const { return needsRedraw_; } + void clearRedraw() { needsRedraw_ = false; } + +private: + void nextPage(); + void previousPage(); + void nextMenuItem(); + void previousMenuItem(); + void selectMenuItem(); + void returnToOverview(); + + LocalUiPage currentPage_{LocalUiPage::OVERVIEW}; + LocalMenuItem currentMenuItem_{LocalMenuItem::MODE}; + bool needsRedraw_{true}; +}; + +} // namespace PoolController +``` + +- [ ] **Step 6: Create `src/LocalSettingsMenu.cpp`** + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +#include "LocalSettingsMenu.hpp" + +namespace PoolController { +namespace { + +constexpr std::uint8_t kPageCount{4}; // OVERVIEW, NETWORK, SYSTEM, QRCODE +constexpr std::uint8_t kMenuItemCount{5}; // MODE, PUMP, NETWORK_STATUS, QR_CODE, EXIT + +} // namespace + +void LocalSettingsMenu::handleEvent(LocalUiEvent event) { + if (event == LocalUiEvent::NONE) { + return; + } + + if (event == LocalUiEvent::LONG_PRESS) { + returnToOverview(); + return; + } + + if (currentPage_ == LocalUiPage::MENU) { + if (event == LocalUiEvent::ROTATE_CLOCKWISE) { + nextMenuItem(); + } else if (event == LocalUiEvent::ROTATE_COUNTER_CLOCKWISE) { + previousMenuItem(); + } else if (event == LocalUiEvent::SHORT_PRESS) { + selectMenuItem(); + } + return; + } + + if (event == LocalUiEvent::SHORT_PRESS) { + currentPage_ = LocalUiPage::MENU; + currentMenuItem_ = LocalMenuItem::MODE; + needsRedraw_ = true; + } else if (event == LocalUiEvent::ROTATE_CLOCKWISE) { + nextPage(); + } else if (event == LocalUiEvent::ROTATE_COUNTER_CLOCKWISE) { + previousPage(); + } +} + +void LocalSettingsMenu::nextPage() { + const auto page = static_cast(currentPage_); + const auto next = static_cast((page + 1U) % kPageCount); + currentPage_ = static_cast(next); + needsRedraw_ = true; +} + +void LocalSettingsMenu::previousPage() { + const auto page = static_cast(currentPage_); + const auto prev = static_cast((page + kPageCount - 1U) % kPageCount); + currentPage_ = static_cast(prev); + needsRedraw_ = true; +} + +void LocalSettingsMenu::nextMenuItem() { + const auto item = static_cast(currentMenuItem_); + const auto next = static_cast((item + 1U) % kMenuItemCount); + currentMenuItem_ = static_cast(next); + needsRedraw_ = true; +} + +void LocalSettingsMenu::previousMenuItem() { + const auto item = static_cast(currentMenuItem_); + const auto prev = static_cast((item + kMenuItemCount - 1U) % kMenuItemCount); + currentMenuItem_ = static_cast(prev); + needsRedraw_ = true; +} + +void LocalSettingsMenu::selectMenuItem() { + switch (currentMenuItem_) { + case LocalMenuItem::NETWORK_STATUS: + currentPage_ = LocalUiPage::NETWORK; + break; + case LocalMenuItem::QR_CODE: + currentPage_ = LocalUiPage::QRCODE; + break; + case LocalMenuItem::EXIT: + currentPage_ = LocalUiPage::OVERVIEW; + break; + case LocalMenuItem::MODE: + case LocalMenuItem::PUMP: + currentPage_ = LocalUiPage::OVERVIEW; + break; + } + needsRedraw_ = true; +} + +void LocalSettingsMenu::returnToOverview() { + currentPage_ = LocalUiPage::OVERVIEW; + needsRedraw_ = true; +} + +} // namespace PoolController +``` + +- [ ] **Step 7: Run tests and verify pass** + +Run: + +```bash +cmake --build test/native/build && test/native/build/test_runner +``` + +Expected: all existing suites pass and `LocalSettingsMenu Tests: 4 passed, 0 failed` appears. + +- [ ] **Step 8: Commit** + +```bash +git add src/LocalUiTypes.hpp src/LocalSettingsMenu.hpp src/LocalSettingsMenu.cpp test/native/CMakeLists.txt test/native/tests/test_main.cpp test/native/tests/test_local_settings_menu.cpp +git commit -m "feat(ui): add local settings menu state machine" +``` + +--- + +### Task 3: KY-040 Decoder and Olimex Encoder Handler + +**Files:** + +- Create: `src/Ky040Decoder.hpp` +- Create: `src/Ky040Decoder.cpp` +- Create: `src/OlimexEncoderHandler.hpp` +- Create: `src/OlimexEncoderHandler.cpp` +- Create: `test/native/tests/test_ky040_decoder.cpp` +- Modify: `test/native/CMakeLists.txt` +- Modify: `test/native/tests/test_main.cpp` + +**Interfaces:** + +- Consumes: `LocalUiEvent` from Task 2, `PIN_ENCODER_*` from Task 1. +- Produces: `Ky040Decoder::update(bool clk, bool dt, bool swPressed, std::uint32_t nowMs) -> LocalUiEvent`; `OlimexEncoderHandler::begin()`, `loop()`, `onEvent()`. + +- [ ] **Step 1: Write failing decoder tests** + +Create `test/native/tests/test_ky040_decoder.cpp` with these cases: + +```cpp +#include "Ky040Decoder.hpp" + +#include + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::Ky040Decoder; +using PoolController::LocalUiEvent; + +static int test_clockwise_rotation() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, false, 0), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, true, false, 5), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, false, false, 10), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, false, false, 15), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 20), LocalUiEvent::ROTATE_CLOCKWISE); + return 0; +} + +static int test_counter_clockwise_rotation() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, false, 0), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, false, false, 5), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, false, false, 10), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, true, false, 15), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 20), LocalUiEvent::ROTATE_COUNTER_CLOCKWISE); + return 0; +} + +static int test_short_press_on_release() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, true, 100), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, true, 300), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 350), LocalUiEvent::SHORT_PRESS); + return 0; +} + +static int test_long_press_on_release() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, true, 100), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, true, 2300), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 2350), LocalUiEvent::LONG_PRESS); + return 0; +} + +int run_ky040_decoder_tests() { + int failures = 0; + failures += test_clockwise_rotation(); + failures += test_counter_clockwise_rotation(); + failures += test_short_press_on_release(); + failures += test_long_press_on_release(); + if (failures == 0) { + printf(" KY-040 Decoder Tests: 4 passed, 0 failed\n"); + } + return failures; +} +``` + +- [ ] **Step 2: Register and run failing test** + +Add `test_ky040_decoder.cpp` to `TEST_SOURCES`, add `Ky040Decoder.cpp` to `SERVICE_SOURCES`, and register `run_ky040_decoder_tests()` in `test_main.cpp`. Run: + +```bash +cmake --build test/native/build && test/native/build/test_runner +``` + +Expected: compile fails because `Ky040Decoder.hpp` does not exist. + +- [ ] **Step 3: Create `src/Ky040Decoder.hpp`** + +```cpp +#pragma once + +#include "LocalUiTypes.hpp" + +#include + +namespace PoolController { + +class Ky040Decoder { +public: + LocalUiEvent update(bool clkHigh, bool dtHigh, bool swPressed, std::uint32_t nowMs); + +private: + static constexpr std::uint32_t LONG_PRESS_MS{2000}; + std::uint8_t lastState_{0x03}; + std::int8_t delta_{0}; + bool buttonWasPressed_{false}; + std::uint32_t buttonPressStartMs_{0}; +}; + +} // namespace PoolController +``` + +- [ ] **Step 4: Create `src/Ky040Decoder.cpp`** + +```cpp +#include "Ky040Decoder.hpp" + +namespace PoolController { + +LocalUiEvent Ky040Decoder::update(bool clkHigh, bool dtHigh, bool swPressed, std::uint32_t nowMs) { + const std::uint8_t state = static_cast((clkHigh ? 0x02 : 0x00) | (dtHigh ? 0x01 : 0x00)); + const std::uint8_t transition = static_cast((lastState_ << 2U) | state); + lastState_ = state; + + switch (transition) { + case 0b1110: + case 0b1000: + case 0b0001: + case 0b0111: + ++delta_; + break; + case 0b1101: + case 0b0100: + case 0b0010: + case 0b1011: + --delta_; + break; + default: + break; + } + + if (state == 0x03 && delta_ >= 4) { + delta_ = 0; + return LocalUiEvent::ROTATE_CLOCKWISE; + } + if (state == 0x03 && delta_ <= -4) { + delta_ = 0; + return LocalUiEvent::ROTATE_COUNTER_CLOCKWISE; + } + + if (swPressed && !buttonWasPressed_) { + buttonWasPressed_ = true; + buttonPressStartMs_ = nowMs; + return LocalUiEvent::NONE; + } + + if (!swPressed && buttonWasPressed_) { + buttonWasPressed_ = false; + const std::uint32_t heldMs = nowMs - buttonPressStartMs_; + return heldMs >= LONG_PRESS_MS ? LocalUiEvent::LONG_PRESS : LocalUiEvent::SHORT_PRESS; + } + + return LocalUiEvent::NONE; +} + +} // namespace PoolController +``` + +- [ ] **Step 5: Create `src/OlimexEncoderHandler.hpp`** + +```cpp +#pragma once + +#include "LocalUiTypes.hpp" + +#include + +namespace PoolController { + +class OlimexEncoderHandler { +public: + using EventCallback = std::function; + + static void begin(); + static void loop(); + static void onEvent(EventCallback cb) { eventCallback_ = cb; } + +private: + static EventCallback eventCallback_; +}; + +} // namespace PoolController +``` + +- [ ] **Step 6: Create `src/OlimexEncoderHandler.cpp`** + +```cpp +#include "OlimexEncoderHandler.hpp" + +#if defined(OLIMEX_ESP32_C6_EVB) + +#include "Config.hpp" +#include "Ky040Decoder.hpp" + +#include + +namespace PoolController { +namespace { + +Ky040Decoder decoder; +constexpr std::uint32_t SAMPLE_INTERVAL_MS{2}; +std::uint32_t lastSampleMs{0}; + +} // namespace + +OlimexEncoderHandler::EventCallback OlimexEncoderHandler::eventCallback_{}; + +void OlimexEncoderHandler::begin() { + pinMode(PIN_ENCODER_CLK, INPUT_PULLUP); + pinMode(PIN_ENCODER_DT, INPUT_PULLUP); + pinMode(PIN_ENCODER_SW, INPUT_PULLUP); +} + +void OlimexEncoderHandler::loop() { + const std::uint32_t nowMs = millis(); + if (nowMs - lastSampleMs < SAMPLE_INTERVAL_MS) { + return; + } + lastSampleMs = nowMs; + + const bool clkHigh = digitalRead(PIN_ENCODER_CLK) == HIGH; + const bool dtHigh = digitalRead(PIN_ENCODER_DT) == HIGH; + const bool swPressed = digitalRead(PIN_ENCODER_SW) == LOW; + const LocalUiEvent event = decoder.update(clkHigh, dtHigh, swPressed, nowMs); + if (event != LocalUiEvent::NONE && eventCallback_) { + eventCallback_(event); + } +} + +} // namespace PoolController + +#endif +``` + +- [ ] **Step 7: Run native tests** + +Run: + +```bash +cmake --build test/native/build && test/native/build/test_runner +``` + +Expected: all suites pass and `KY-040 Decoder Tests: 4 passed, 0 failed` appears. + +- [ ] **Step 8: Commit** + +```bash +git add src/Ky040Decoder.hpp src/Ky040Decoder.cpp src/OlimexEncoderHandler.hpp src/OlimexEncoderHandler.cpp test/native/CMakeLists.txt test/native/tests/test_main.cpp test/native/tests/test_ky040_decoder.cpp +git commit -m "feat(ui): add ky040 encoder input handling" +``` + +--- + +### Task 4: Olimex TFT Display Wrapper + +**Files:** + +- Create: `src/OlimexTftDisplay.hpp` +- Create: `src/OlimexTftDisplay.cpp` +- Modify: `platformio.ini` if TFT_eSPI needs build flags for pins/driver. + +**Interfaces:** + +- Consumes: `LocalUiPage`, `LocalMenuItem`, `TftDisplayConfig` from Task 2. +- Produces: `OlimexTftDisplay::begin()`, `drawOverview()`, `drawMenu()`, `drawNetwork()`, `drawSystem()`, `drawQrCode()`. + +- [ ] **Step 1: Create `src/OlimexTftDisplay.hpp`** + +```cpp +#pragma once + +#include "LocalUiTypes.hpp" + +namespace PoolController { + +class OlimexTftDisplay { +public: + static void begin(); + static void drawPage(LocalUiPage page, LocalMenuItem menuItem); + static void requestRedraw() { forceRedraw_ = true; } + +private: + static void drawOverview(); + static void drawMenu(LocalMenuItem menuItem); + static void drawNetwork(); + static void drawSystem(); + static void drawQrCode(); + static bool forceRedraw_; +}; + +} // namespace PoolController +``` + +- [ ] **Step 2: Create `src/OlimexTftDisplay.cpp` with safe compile guards** + +Use compile guards so non-Olimex builds do not pull TFT libraries: + +```cpp +#include "OlimexTftDisplay.hpp" + +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + +#include "Config.hpp" +#include "ConfigManager.hpp" +#include "Version.h" + +#include +#include +#include + +namespace PoolController { +namespace { + +TFT_eSPI tft; + +void drawHeader(const char *left, const char *right) { + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextSize(TFT_DISPLAY_SIZE_CLASS_COMPACT ? 1 : 2); + tft.setCursor(8, 6); + tft.print(left); + tft.setCursor(240, 6); + tft.print(right); +} + +} // namespace + +bool OlimexTftDisplay::forceRedraw_{true}; + +void OlimexTftDisplay::begin() { + tft.init(); + tft.setRotation(1); + tft.fillScreen(TFT_BLACK); + drawHeader("POOL", "BOOT"); + tft.setCursor(8, 40); + tft.print("Starting..."); + forceRedraw_ = true; +} + +void OlimexTftDisplay::drawPage(LocalUiPage page, LocalMenuItem menuItem) { + if (!forceRedraw_) { + return; + } + forceRedraw_ = false; + tft.fillScreen(TFT_BLACK); + switch (page) { + case LocalUiPage::OVERVIEW: + drawOverview(); + break; + case LocalUiPage::NETWORK: + drawNetwork(); + break; + case LocalUiPage::SYSTEM: + drawSystem(); + break; + case LocalUiPage::QRCODE: + drawQrCode(); + break; + case LocalUiPage::MENU: + drawMenu(menuItem); + break; + } +} + +void OlimexTftDisplay::drawOverview() { + drawHeader("POOL", ConfigManager::getSettings().opMode.c_str()); + tft.setTextSize(TFT_DISPLAY_SIZE_CLASS_COMPACT ? 3 : 4); + tft.setCursor(8, 38); + tft.print("--.- C"); + tft.setTextSize(2); + tft.setCursor(8, 92); + tft.print("Pumpe: --"); + tft.drawFastHLine(0, 120, TFT_DISPLAY_WIDTH, TFT_DARKGREY); + drawHeader("SOLAR", "OK"); + tft.setTextSize(TFT_DISPLAY_SIZE_CLASS_COMPACT ? 3 : 4); + tft.setCursor(8, 148); + tft.print("--.- C"); + tft.setTextSize(2); + tft.setCursor(8, 204); + tft.print("Ventil: --"); +} + +void OlimexTftDisplay::drawMenu(LocalMenuItem menuItem) { + drawHeader("MENU", "OK=Select"); + const char *items[] = {"Mode", "Pump", "Network", "QR Code", "Exit"}; + for (std::uint8_t i = 0; i < 5; ++i) { + tft.setCursor(20, 42 + (i * 32)); + tft.setTextSize(2); + tft.setTextColor(i == static_cast(menuItem) ? TFT_BLACK : TFT_WHITE, + i == static_cast(menuItem) ? TFT_YELLOW : TFT_BLACK); + tft.print(items[i]); + } +} + +void OlimexTftDisplay::drawNetwork() { + drawHeader("NETWORK", "STATUS"); + tft.setTextSize(2); + tft.setCursor(8, 48); + tft.print("WiFi/MQTT status"); +} + +void OlimexTftDisplay::drawSystem() { + drawHeader("SYSTEM", FW_VERSION); + tft.setTextSize(2); + tft.setCursor(8, 48); + tft.print("Heap / uptime"); +} + +void OlimexTftDisplay::drawQrCode() { + drawHeader("QR", "WEB UI"); + const char *url = "http://pool-controller.local"; + QRCode qrcode; + std::uint8_t qrcodeData[qrcode_getBufferSize(3)]; + qrcode_initText(&qrcode, qrcodeData, 3, ECC_LOW, url); + const std::uint8_t scale = 6; + const std::uint16_t offsetX = 70; + const std::uint16_t offsetY = 35; + tft.fillRect(offsetX - 8, offsetY - 8, (qrcode.size * scale) + 16, (qrcode.size * scale) + 16, TFT_WHITE); + for (std::uint8_t y = 0; y < qrcode.size; ++y) { + for (std::uint8_t x = 0; x < qrcode.size; ++x) { + if (qrcode_getModule(&qrcode, x, y)) { + tft.fillRect(offsetX + (x * scale), offsetY + (y * scale), scale, scale, TFT_BLACK); + } + } + } +} + +} // namespace PoolController + +#endif +``` + +- [ ] **Step 3: Configure TFT_eSPI pins if needed** + +If `TFT_eSPI` requires compile-time user setup, add build flags under `[env:olimex_esp32_c6_evb]` rather than editing library files: + +```ini + -D USER_SETUP_LOADED + -D ILI9341_DRIVER + -D TFT_WIDTH=240 + -D TFT_HEIGHT=320 + -D TFT_MOSI=18 + -D TFT_MISO=20 + -D TFT_SCLK=19 + -D TFT_CS=21 + -D TFT_DC=7 + -D TFT_RST=6 + -D LOAD_GLCD + -D SPI_FREQUENCY=27000000 +``` + +If the selected hardware is ST7789, replace `ILI9341_DRIVER` with the matching `ST7789_DRIVER` define documented by TFT_eSPI. + +- [ ] **Step 4: Verify compile guards** + +Run: + +```bash +venv/bin/pio run -e esp32dev +venv/bin/pio run -e norvi_ae01_r +``` + +Expected: both builds pass without TFT library dependency leakage into non-Olimex environments. + +- [ ] **Step 5: Build Olimex environment** + +Run: + +```bash +venv/bin/pio run -e olimex_esp32_c6_evb +``` + +Expected: build may still fail until Task 5 wires `OlimexLocalUi`; it must not fail due TFT_eSPI pin setup or QRCode dependency. + +- [ ] **Step 6: Commit** + +```bash +git add src/OlimexTftDisplay.hpp src/OlimexTftDisplay.cpp platformio.ini +git commit -m "feat(ui): add olimex tft display renderer" +``` + +--- + +### Task 5: Olimex Local UI Composition and PoolController Wiring + +**Files:** + +- Create: `src/OlimexLocalUi.hpp` +- Create: `src/OlimexLocalUi.cpp` +- Modify: `src/PoolController.cpp:267-516` + +**Interfaces:** + +- Consumes: `OlimexEncoderHandler`, `LocalSettingsMenu`, `OlimexTftDisplay`. +- Produces: `OlimexLocalUi::begin()`, `OlimexLocalUi::loop()`, `OlimexLocalUi::requestRedraw()`. + +- [ ] **Step 1: Create `src/OlimexLocalUi.hpp`** + +```cpp +#pragma once + +namespace PoolController { + +class OlimexLocalUi { +public: + static void begin(); + static void loop(); + static void requestRedraw(); +}; + +} // namespace PoolController +``` + +- [ ] **Step 2: Create `src/OlimexLocalUi.cpp`** + +```cpp +#include "OlimexLocalUi.hpp" + +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + +#include "LocalSettingsMenu.hpp" +#include "OlimexEncoderHandler.hpp" +#include "OlimexTftDisplay.hpp" + +namespace PoolController { +namespace { + +LocalSettingsMenu menu; + +} // namespace + +void OlimexLocalUi::begin() { + OlimexTftDisplay::begin(); + OlimexEncoderHandler::onEvent([](LocalUiEvent event) { + menu.handleEvent(event); + OlimexTftDisplay::requestRedraw(); + }); + OlimexEncoderHandler::begin(); + OlimexTftDisplay::drawPage(menu.currentPage(), menu.currentMenuItem()); +} + +void OlimexLocalUi::loop() { + OlimexEncoderHandler::loop(); + if (menu.needsRedraw()) { + OlimexTftDisplay::requestRedraw(); + menu.clearRedraw(); + } + OlimexTftDisplay::drawPage(menu.currentPage(), menu.currentMenuItem()); +} + +void OlimexLocalUi::requestRedraw() { + OlimexTftDisplay::requestRedraw(); +} + +} // namespace PoolController + +#else + +namespace PoolController { + +void OlimexLocalUi::begin() {} +void OlimexLocalUi::loop() {} +void OlimexLocalUi::requestRedraw() {} + +} // namespace PoolController + +#endif +``` + +- [ ] **Step 3: Wire setup in `PoolController.cpp`** + +Add include near existing NORVI includes: + +```cpp +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) +#include "OlimexLocalUi.hpp" +#endif +``` + +In `PoolControllerContext::setup()`, after `StatusLed::begin();`, add: + +```cpp +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + OlimexLocalUi::begin(); +#endif +``` + +In `PoolControllerContext::loop()`, near the existing `NorviButtonHandler::loop()` / display loop location, add: + +```cpp +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + OlimexLocalUi::loop(); +#endif +``` + +Keep the call non-blocking. Do not put display updates inside delays or `while` loops. + +- [ ] **Step 4: Build all environments** + +Run: + +```bash +venv/bin/pio run -e esp32dev +venv/bin/pio run -e norvi_ae01_r +venv/bin/pio run -e olimex_esp32_c6_evb +``` + +Expected: all three builds pass. If `olimex_esp32_c6_evb` fails due specific board definition, fix the `board =` value in `platformio.ini` and re-run. + +- [ ] **Step 5: Run native tests** + +Run: + +```bash +cmake --build test/native/build && test/native/build/test_runner +``` + +Expected: all suites pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/OlimexLocalUi.hpp src/OlimexLocalUi.cpp src/PoolController.cpp +git commit -m "feat(ui): wire olimex local tft interface" +``` + +--- + +### Task 6: Safe First Hardware Smoke Checklist and Docs + +**Files:** + +- Create: `docs/olimex-esp32-c6-evb.md` +- Modify: `docs/superpowers/specs/2026-08-09-olimex-c6-local-ui-design.md` only if implementation discoveries change the concept. + +**Interfaces:** + +- Consumes: actual pin/config choices from Tasks 1-5. +- Produces: a safe bring-up checklist for hardware validation. + +- [ ] **Step 1: Create hardware bring-up doc** + +Create `docs/olimex-esp32-c6-evb.md` with: + +```markdown +# Olimex ESP32-C6-EVB Variant + +## Hardware + +- Board: Olimex ESP32-C6-EVB +- Display: configurable 320x240 SPI TFT, 2.8" preferred, 2.4" acceptable +- Input: KY-040 rotary encoder with push button +- Logic level: 3.3 V only + +## Safety Rules + +- Do not feed 5 V into ESP32-C6 GPIOs. +- Do not drive the TFT backlight directly from an ESP32 GPIO. +- Power the TFT logic and KY-040 from 3.3 V. +- Verify display pinout, backlight polarity, and current limiting before wiring. + +## Bring-Up Steps + +1. Build `olimex_esp32_c6_evb`. +2. Power the board without TFT/encoder and verify serial boot. +3. Connect TFT VCC/GND/SPI/DC/RST with backlight fixed to 3.3 V if current-limited. +4. Verify the TFT boot screen. +5. Connect KY-040 CLK/DT/SW to the configured GPIOs. +6. Verify rotate clockwise/counter-clockwise changes pages. +7. Verify short press opens menu. +8. Verify long press returns to overview. +9. Open QR page and scan `http://pool-controller.local`. +10. Only after UI validation, connect real relay loads and opto inputs. +``` + +- [ ] **Step 2: Verify Markdown formatting** + +Run: + +```bash +npx prettier@3.5.3 --check "docs/olimex-esp32-c6-evb.md" +``` + +Expected: Prettier reports the doc is formatted. + +- [ ] **Step 3: Commit** + +```bash +git add docs/olimex-esp32-c6-evb.md docs/superpowers/specs/2026-08-09-olimex-c6-local-ui-design.md +git commit -m "docs: add olimex local ui bring-up guide" +``` + +--- + +### Task 7: Final Verification and PR Preparation + +**Files:** + +- No new source files; verifies whole branch. + +**Interfaces:** + +- Consumes all previous tasks. +- Produces final evidence for PR body. + +- [ ] **Step 1: Inspect branch contents** + +Run: + +```bash +git status --short --branch +git diff --stat origin/main...HEAD +git log --oneline origin/main..HEAD +``` + +Expected: only intended source, test, and docs files are committed. `graphify-out/` may remain untracked but must not be committed. + +- [ ] **Step 2: Run source formatting checks** + +Run: + +```bash +git diff --check origin/main...HEAD +npx prettier@3.5.3 --check "docs/**/*.md" +``` + +Expected: both pass. + +- [ ] **Step 3: Run native tests** + +Run: + +```bash +cmake --build test/native/build && test/native/build/test_runner +``` + +Expected: all native suites pass, including `LocalSettingsMenu` and `KY-040 Decoder`. + +- [ ] **Step 4: Run firmware builds** + +Run: + +```bash +venv/bin/pio run -e esp32dev +venv/bin/pio run -e norvi_ae01_r +venv/bin/pio run -e olimex_esp32_c6_evb +``` + +Expected: all three builds pass. + +- [ ] **Step 5: Update PR body** + +Use `gh pr edit` or `gh api --method PATCH` to include: + +```markdown +## Summary + +- add Olimex ESP32-C6-EVB board variant +- add configurable 320x240 SPI TFT local UI path +- add KY-040 encoder input handling +- add QR-code setup/diagnostic page support +- keep NORVI and esp32dev builds working + +## Verification + +- cmake --build test/native/build && test/native/build/test_runner +- venv/bin/pio run -e esp32dev +- venv/bin/pio run -e norvi_ae01_r +- venv/bin/pio run -e olimex_esp32_c6_evb +- git diff --check origin/main...HEAD +- npx prettier@3.5.3 --check "docs/\*_/_.md" +``` + +- [ ] **Step 6: Push branch** + +```bash +git push +``` + +Expected: existing PR updates cleanly. diff --git a/docs/superpowers/specs/2026-08-09-olimex-c6-local-ui-design.md b/docs/superpowers/specs/2026-08-09-olimex-c6-local-ui-design.md new file mode 100644 index 00000000..a16aa315 --- /dev/null +++ b/docs/superpowers/specs/2026-08-09-olimex-c6-local-ui-design.md @@ -0,0 +1,429 @@ +# Design: Olimex ESP32-C6-EVB Local Settings Variant + +**Date:** 2026-08-09 +**Status:** Draft for user review +**Scope:** Add a hardware concept for an Olimex ESP32-C6-EVB based pool-controller variant with a configurable 320x240 SPI TFT and KY-040 rotary encoder for local settings navigation. + +## Motivation + +The project currently supports a generic ESP32 development board and a NORVI +AE01-R variant. The NORVI path already contains the project's local-control +pattern: onboard IO, display, buttons, and settings persisted through +`ConfigManager`/NVS. + +The Olimex ESP32-C6-EVB is a plausible second integrated-controller variant +because it provides relays, opto-isolated inputs, wide-range DC input, and open +hardware documentation. The requested local settings interface should avoid +using scarce ADC pins and should remain robust in a pool equipment environment. + +## Hardware Decision + +Chosen combination: + +```text +Board: Olimex ESP32-C6-EVB +Display: 320x240 SPI TFT, 2.8" recommended, 2.4" acceptable +Driver: configurable ST7789 or ILI9341-class controller +Input: KY-040 rotary encoder with push button +Settings: existing ConfigManager / Preferences / NVS +``` + +Rejected from the initial idea: + +- **Analog joystick module**: technically possible, but it consumes two ADC pins, + needs center/dead-zone calibration, can drift, and is less natural for menu + navigation. + +## Why This Combination Is Sensible + +### Olimex ESP32-C6-EVB + +The board matches the pool-controller use case well: + +- 4 onboard relays for switched loads. +- 4 opto-isolated digital inputs. +- 8-50 V DC board supply option. +- ESP32-C6 with Wi-Fi 6, BLE, and 802.15.4 capability. +- Open-source hardware with documented connectors. + +Main risks: + +- ESP32-C6 is a single-core RISC-V target, unlike the original ESP32 target. +- PlatformIO/Arduino support must be verified by a real build environment. +- Several GPIOs are already consumed by relays, opto inputs, UEXT, LED, and + button functions. + +### Compatibility and Power Verification + +The planned setup is compatible when all external modules are wired as a 3.3 V +system: + +- ESP32-C6 GPIOs are **not 5 V tolerant**; all display and encoder signals must + stay at 3.3 V logic level. +- ST7789/ILI9341-class TFT controllers are suitable for 3.3 V SPI operation when + the concrete display module exposes 3.3 V-safe logic pins. +- The KY-040 encoder should be powered from 3.3 V so its CLK/DT/SW pull-ups also + resolve to 3.3 V. +- Relays and opto inputs remain on the Olimex board's existing circuits; the + local UI does not alter their power domain. + +Power budget from the Olimex hardware analysis: + +```text +Olimex supply path: + 8-50 V input -> TX4138 buck -> 5 V rail + 5 V rail -> SY8089 buck -> 3.3 V rail + +3.3 V rail: + ESP32-C6 module + UEXT 3.3 V pins + EXT1 3.3 V pin +``` + +The 3.3 V rail is generated by a SY8089 buck regulator. The BOM/netlist analysis +indicates the practical limit is set by the 2.2 ”H inductor at about 1.5 A +saturation current. For design margin, the local UI should keep the total 3.3 V +load below about 1.2 A. + +Estimated added load: + +```text +2.4"/2.8" SPI TFT logic + backlight: 60-100 mA typical +KY-040 encoder: <1 mA +``` + +Together with normal ESP32-C6 Wi-Fi peaks, this remains comfortably below the +available 3.3 V rail budget. A separate display power supply is not required for +the proposed TFT + encoder setup. + +Required wiring constraints: + +- Power display logic, display backlight, and KY-040 from 3.3 V, not 5 V. +- Do **not** drive the TFT backlight directly from an ESP32 GPIO. Use either the + 3.3 V rail directly when the display module includes current limiting, or use a + GPIO-controlled transistor/MOSFET for PWM dimming. +- Add local decoupling near the display connector, at least 100 nF between 3.3 V + and GND. +- Verify the concrete TFT module's pinout, backlight polarity, and whether it has + onboard current limiting or level shifting before connecting it. + +No-go conditions: + +- A display module that cannot be confirmed safe for 3.3 V logic. +- Any wiring path that can pull ESP32-C6 GPIOs above 3.3 V. +- A later expansion that raises total 3.3 V rail load close to the conservative + 1.2 A budget without a renewed power check. + +### Configurable 320x240 SPI TFT + +The initial 2" display idea is technically usable, but it is not ideal for the +desired combined Pool + Solar overview. To avoid repeating the cramped NORVI +OLED experience, the preferred display size is 2.8". A 2.4" display is +acceptable if enclosure space or available parts require it. + +The display is appropriate for status pages and a simple settings menu when it +has at least 2.4" diagonal size: + +- 320x240 is enough for readable values and menus at 2.4"/2.8". +- SPI display libraries are widely available. +- A full framebuffer should be avoided; use partial drawing or library-managed + small buffers. + +The UI should stay simple: one combined overview screen, short lists, edit +fields, and confirmation dialogs. A heavy LVGL-style animated UI is out of +scope for this variant. + +The implementation should not hard-code the physical diagonal size. It should be +configured around display properties that affect code behavior: + +```text +DISPLAY_WIDTH = 320 +DISPLAY_HEIGHT = 240 +DISPLAY_DRIVER = ILI9341 or ST7789 +DISPLAY_SIZE_CLASS = compact or normal +``` + +This allows the same UI architecture to support both a 2.4" and 2.8" 320x240 +module. The 2.4" layout can use the `compact` size class with tighter spacing; +the 2.8" layout can use the `normal` size class for better visual breathing +room. + +### KY-040 Rotary Encoder + +The encoder is preferred over the joystick because it: + +- uses digital GPIOs instead of ADC pins, +- has no analog center drift, +- maps naturally to menu navigation, +- allows operation with one knob: rotate, press, long-press, +- preserves ADC pins for possible future sensors. + +## User Interaction Model + +```text +Rotate clockwise -> next menu item / increase value +Rotate counter-clockwise -> previous menu item / decrease value +Short press -> select / confirm +Long press -> back / leave menu +Idle timeout -> return to status screen +``` + +The local UI should expose only safe, essential settings. Full configuration +remains available through the web UI. + +Recommended local menu scope: + +1. Status overview: water temperature, pump state, mode, Wi-Fi/MQTT state. +2. Operation mode: auto/manual/off as already supported by the controller. +3. Pump/manual actions where safe. +4. Network/MQTT status read-only. +5. Sensor assignment/status where this can reuse existing `ConfigManager` data. + +Potentially dangerous operations should use explicit confirmation screens. + +## Main Screen Layout + +The main screen should show Pool and Solar at the same time. This is the key +reason to prefer a 2.8" display over the originally considered 2" display. + +Example structure: + +```text +┌────────────────────────────┐ +│ POOL AUTO │ +│ 27.4 °C │ +│ Pumpe: AN │ +├───────────────────────────── +│ SOLAR OK │ +│ 35.1 °C │ +│ Ventil: SOLAR │ +└────────────────────────────┘ +``` + +Layout rules: + +- Pool and Solar each get a clear half-screen block. +- Temperatures are the largest text on the screen. +- Mode/status labels stay short: `AUTO`, `MAN`, `AUS`, `OK`, `ERR`. +- No dense tables and no more than three secondary lines per block. +- Detailed values and settings move to subpages selected with the encoder. + +## QR Code Display + +The TFT should be able to show QR codes for setup and diagnostics. Useful QR +targets include: + +- the local web UI, for example `http://pool-controller.local`, +- a Wi-Fi/setup captive portal address, +- a Home Assistant or documentation link, +- a short diagnostic URL or support page. + +QR rendering requirements: + +- Render black-on-white with a quiet border around the code. +- Keep the encoded payload short; prefer URLs over large JSON/config blobs. +- Target at least about 160x160 px, with 200x200 px preferred on 320x240. +- Use a dedicated QR screen rather than trying to fit the QR code beside the + Pool/Solar overview. +- Keep screen brightness high while the QR page is open. + +On a 2.8" 320x240 TFT this should be comfortably scannable. On a 2.4" 320x240 +TFT it should still work when shown as a dedicated full-screen QR page. + +## Local Control Flow + +The rotary encoder should behave consistently across the whole local UI: + +```text +Status screen: + rotate -> switch between overview/detail pages + short press -> open main menu + long press -> refresh/return to overview + +Menu list: + rotate -> move selection up/down + short press -> enter selected item + long press -> go back + +Value editing: + rotate -> increase/decrease value or change option + short press -> save/confirm + long press -> cancel without saving + +Confirmation screen: + rotate -> choose No/Yes + short press -> confirm current choice + long press -> cancel +``` + +This gives users a predictable mental model: rotate changes focus or value, +short press accepts, and long press exits or cancels. + +## Architecture + +The new variant should not add more board-specific branching directly into +`PoolController.cpp`. Instead, introduce a small local-UI boundary and adapt the +existing NORVI path to it over time. + +Proposed structure: + +```text +LocalUi + begin(...) + loop() + showStatus(...) + notifyEvent(...) + +Implementations: + NorviLocalUi -> existing OLED + button flow + OlimexTftEncoderUi -> ST7789 TFT + KY-040 encoder +``` + +Supporting modules: + +```text +OlimexTftDisplay -> drawing primitives, pages, menu rendering +OlimexEncoderHandler -> encoder decode, debounce, short/long press events +LocalSettingsMenu -> shared menu state machine using ConfigManager +``` + +`ConfigManager` remains the source of truth for persisted settings. The local UI +is only another frontend for reading and changing selected settings. + +## Build and Board Variant + +Add a dedicated PlatformIO environment rather than overloading `esp32dev`: + +```ini +[env:olimex_esp32_c6_evb] +platform = espressif32 +board = esp32-c6-devkitc-1 ; or a custom Olimex board definition if needed +framework = arduino +build_flags = + ${common.build_flags} + -D OLIMEX_ESP32_C6_EVB + -D HAS_LOCAL_TFT_UI +``` + +The exact `board` value is an implementation-time decision. If PlatformIO has no +matching Olimex board definition, use the closest ESP32-C6 board or add a custom +board JSON after verifying flash, upload, and serial settings. + +## Initial Pin-Planning Direction + +Use the EVB's onboard functions for the controller IO: + +```text +Relays: Olimex onboard relay GPIOs +Opto inputs: Olimex onboard opto input GPIOs +Display SPI: UEXT SPI pins where possible +Encoder: free digital GPIOs, preferably not ADC-only constrained pins +Backlight: fixed 3.3 V or one PWM-capable GPIO if dimming is required +``` + +Pin assignment must be validated against the Olimex schematic before +implementation. The concept intentionally avoids assigning final GPIO numbers in +the design, because the implementation should verify connector exposure, +boot-strapping constraints, and PlatformIO board support first. + +For the backlight, “PWM-capable GPIO” means the GPIO only controls a transistor +or MOSFET. The GPIO must not source the backlight current directly. + +## Error Handling and Reliability + +- Encoder input must be debounced and tolerant of missed transitions. +- Long-press detection should be time-based and non-blocking. +- Display failures must not stop pool control logic. +- Local UI drawing must never block pump/sensor/control loops for long periods. +- Unsafe actions require confirmation. +- If local UI initialization fails, firmware should continue with web/MQTT + control where possible. + +## Testing and Verification + +Minimum meaningful verification for implementation: + +1. Build the existing `esp32dev`/NORVI environments to prove no regression. +2. Build the new `olimex_esp32_c6_evb` environment. +3. Unit-test or host-test the menu state machine where feasible. +4. Hardware smoke test: + - TFT initializes and renders a status page. + - Main page shows Pool and Solar together without cramped text. + - Encoder rotation creates up/down events. + - Short press selects. + - Long press backs out. + - Existing controller loop continues while navigating menus. +5. Validate relays and opto inputs against the Olimex schematic before connecting + real pool equipment. +6. Measure or inspect the 3.3 V rail under TFT full-brightness + Wi-Fi load; the + expected added display/encoder load is about 60-100 mA and should remain well + below the conservative 1.2 A rail budget. +7. Confirm that no external module feeds 5 V into ESP32-C6 GPIOs. + +## Alternatives Considered + +### A. Olimex + configurable 320x240 TFT + KY-040 Encoder — chosen + +Best balance of robustness, pin usage, and menu usability. The implementation +should support both 2.4" and 2.8" 320x240 modules by configuration. + +### A2. Olimex + 2.4" TFT + KY-040 Encoder — acceptable minimum + +Works if enclosure space is limited, but gives less visual breathing room for +the combined Pool + Solar overview. + +### B. Olimex + 2" TFT + KY-040 Encoder + +Technically works, but it is too close to the cramped NORVI experience when +Pool and Solar must be visible together on the main screen. + +### C. Olimex + TFT + Joystick + +Works electrically if powered from 3.3 V, but consumes ADC pins and creates more +software/UX complexity. + +### D. Olimex + Web UI only + +Simplest and lowest-risk implementation. Rejected for this concept because the +goal is explicit local settings control. + +### E. Touch TFT + +Most comfortable local UI, but higher complexity, more pins, more display work, +and more mechanical/front-panel considerations. + +## Out of Scope + +- Replacing the web UI. +- Building a complex graphical dashboard with animations. +- Zigbee/Thread feature use on ESP32-C6. +- Final production enclosure design. +- Final certified mains wiring design. +- Direct implementation before board support and pin mapping are verified. + +## Open Implementation Questions + +1. Which PlatformIO board definition works best for the Olimex ESP32-C6-EVB? +2. Which exact GPIOs are safe and exposed for TFT DC/RST/CS/backlight and encoder + A/B/SW? +3. Which exact 320x240 SPI TFT module is available, what physical size is it, + and does it use ST7789, ILI9341, or another supported controller? +4. Should display backlight be fixed-on or PWM-dimmable? +5. Which settings are safe enough for local editing in the first release? +6. Should the NORVI OLED/button implementation be adapted to the same `LocalUi` + interface immediately, or should the abstraction be introduced only for the + new Olimex variant first? + +## Verification Sources + +- Olimex ESP32-C6-EVB hardware repository, schematic/BOM/netlist: confirms the + 8-50 V -> 5 V -> 3.3 V regulator chain, UEXT 3.3 V rail connection, and relay + separation from the 3.3 V rail. +- ESP32-C6 datasheet: confirms 3.3 V GPIO domain and non-5 V-tolerant pins. +- SY8089 regulator documentation and Olimex BOM: supports the 3.3 V rail margin + estimate, with the inductor saturation current used as the conservative limit. +- TFT controller/module documentation: ST7789/ILI9341-class controllers support + 3.3 V SPI operation, but the exact display module board must still be checked + for level shifters, onboard regulator, backlight polarity, and current limit. +- KY-040 module documentation: encoder outputs follow the module VCC pull-up, + therefore use 3.3 V VCC for ESP32-safe levels. diff --git a/platformio.ini b/platformio.ini index 861dce9a..1dcc64a1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -18,7 +18,7 @@ serial_speed = 115200 lib_deps = DallasTemperature Wire - paulstoffregen/OneWire + https://github.com/PaulStoffregen/OneWire.git#800f26f3ee6eb446a72e013785cac3700e54cc13 Adafruit Unified Sensor NTPClient @ 3.2.1 TimeZone @ 1.2.6 @@ -50,6 +50,34 @@ monitor_filters = esp32_exception_decoder, log2file, time, default upload_speed = 115200 board_build.filesystem = littlefs +[env:olimex_esp32_c6_evb] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip +board = esp32-c6-devkitc-1 +framework = arduino +build_flags = + ; FW_VERSION is auto-maintained by release-please — do not edit manually + '-D FW_VERSION="4.2.1"' # x-release-please-version + '-D GITHUB_REPO="smart-swimmingpool/pool-controller"' + -D SERIAL_SPEED=${common.serial_speed} + -D OLIMEX_ESP32_C6_EVB + -D HAS_LOCAL_TFT_UI + -D LOG_BUFFER_SIZE=8192 + -std=c++17 + -Wno-deprecated-declarations +build_unflags = -Werror=reorder +lib_deps = + ${common_env_data.lib_deps} + adafruit/Adafruit GFX Library @ ^1.12.0 + adafruit/Adafruit ILI9341 @ ^1.6.1 + adafruit/Adafruit ST7735 and ST7789 Library @ ^1.11.0 + adafruit/Adafruit BusIO @ ^1.17.0 + ricmoo/QRCode @ ^0.0.1 +monitor_speed = ${common.serial_speed} +monitor_filters = esp32_exception_decoder, log2file, time, default +upload_speed = 115200 +board_build.filesystem = littlefs +board_build.partitions = default_8MB.csv + [env:esp32dev] platform = espressif32 @ 7.0.1 board = esp32dev diff --git a/src/Config.hpp b/src/Config.hpp index e3569c79..49bc4cd4 100644 --- a/src/Config.hpp +++ b/src/Config.hpp @@ -122,6 +122,40 @@ constexpr std::uint8_t PIN_OLED_SCL{17}; /** @brief Analog input pin for the three front-panel buttons. */ constexpr std::uint8_t PIN_BUTTON_ADC{32}; +#elif defined(OLIMEX_ESP32_C6_EVB) + +/** @brief DS18B20 data pin — solar collector temperature sensor. */ +constexpr std::uint8_t PIN_DS_SOLAR{6}; +/** @brief DS18B20 data pin — pool water temperature sensor. */ +constexpr std::uint8_t PIN_DS_POOL{20}; +/** @brief Relay control pin — pool circulation pump (Olimex relay). */ +constexpr std::uint8_t PIN_RELAY_POOL{10}; +/** @brief Relay control pin — solar heating pump (Olimex relay). */ +constexpr std::uint8_t PIN_RELAY_SOLAR{11}; +/** @brief Status LED — Olimex user LED. */ +constexpr std::uint8_t PIN_LED_STATUS{8}; +/** @brief Optional warning LED — not used on Olimex. */ +constexpr std::int8_t PIN_LED_WARN{-1}; + +// ── Olimex local UI pins ──────────────────────────────────────────────────── +constexpr std::uint8_t PIN_TFT_MOSI{18}; +constexpr std::uint8_t PIN_TFT_SCLK{19}; +constexpr std::int8_t PIN_TFT_MISO{-1}; // write-only TFT; leave SDO/MISO unconnected +constexpr std::uint8_t PIN_TFT_CS{21}; +constexpr std::uint8_t PIN_TFT_DC{7}; +constexpr std::int8_t PIN_TFT_RST{-1}; // -1 = display reset tied high +constexpr std::int8_t PIN_TFT_BACKLIGHT{-1}; // -1 = fixed 3.3 V backlight + +constexpr std::uint8_t PIN_ENCODER_CLK{4}; +constexpr std::uint8_t PIN_ENCODER_DT{5}; +constexpr std::uint8_t PIN_ENCODER_SW{16}; + +constexpr std::uint16_t TFT_DISPLAY_WIDTH{320}; +constexpr std::uint16_t TFT_DISPLAY_HEIGHT{240}; +constexpr bool TFT_DISPLAY_SIZE_CLASS_COMPACT{false}; +constexpr bool TFT_DRIVER_ILI9341{true}; +constexpr bool TFT_DRIVER_ST7789{false}; + // ═══════════════════════════════════════════════════════════════════════════ // Standard ESP32 Dev Board (default) // ═══════════════════════════════════════════════════════════════════════════ @@ -150,4 +184,12 @@ constexpr std::int8_t PIN_LED_WARN{-1}; #endif +#if defined(OLIMEX_ESP32_C6_EVB) +static_assert(TFT_DISPLAY_WIDTH == 320, "Olimex local UI expects a 320 px wide display"); +static_assert(TFT_DISPLAY_HEIGHT == 240, "Olimex local UI expects a 240 px high display"); +static_assert(TFT_DRIVER_ILI9341 != TFT_DRIVER_ST7789, "Select exactly one TFT driver"); +static_assert(PIN_TFT_RST < 0 || PIN_TFT_RST != PIN_TFT_CS, "TFT reset pin must not conflict with TFT CS"); +static_assert(PIN_TFT_BACKLIGHT < 0 || PIN_TFT_BACKLIGHT != PIN_TFT_CS, "Backlight control pin must not conflict with TFT CS"); +#endif + } // namespace PoolController diff --git a/src/ConfigManager.cpp b/src/ConfigManager.cpp index 63c90930..a50b6078 100644 --- a/src/ConfigManager.cpp +++ b/src/ConfigManager.cpp @@ -8,11 +8,13 @@ */ #include "ConfigManager.hpp" -#include "Version.h" -#include "LogCapture.hpp" + #include #include +#include "LogCapture.hpp" +#include "Version.h" + namespace PoolController { WiFiConfig ConfigManager::wifi_; diff --git a/src/ESP32TemperatureNode.cpp b/src/ESP32TemperatureNode.cpp index a9ebb66a..1c754818 100644 --- a/src/ESP32TemperatureNode.cpp +++ b/src/ESP32TemperatureNode.cpp @@ -11,6 +11,47 @@ #include "Utils.hpp" #include "LogCapture.hpp" +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) +#include + +namespace { +auto readInternalTemperatureCelsius(float &temperatureCelsius) -> bool { + temperature_sensor_config_t config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); + temperature_sensor_handle_t sensor = nullptr; + + esp_err_t err = temperature_sensor_install(&config, &sensor); + if (err != ESP_OK) { + LOG_ERROR("ESP32 internal temp sensor install failed: 0x%x (%s)\n", static_cast(err), esp_err_to_name(err)); + return false; + } + + err = temperature_sensor_enable(sensor); + if (err == ESP_OK) { + err = temperature_sensor_get_celsius(sensor, &temperatureCelsius); + } + + const esp_err_t disableErr = temperature_sensor_disable(sensor); + if (disableErr != ESP_OK) { + LOG_ERROR( + "ESP32 internal temp sensor disable failed: 0x%x (%s)\n", static_cast(disableErr), esp_err_to_name(disableErr)); + } + + const esp_err_t uninstallErr = temperature_sensor_uninstall(sensor); + if (uninstallErr != ESP_OK) { + LOG_ERROR("ESP32 internal temp sensor uninstall failed: 0x%x (%s)\n", static_cast(uninstallErr), + esp_err_to_name(uninstallErr)); + } + + if (err != ESP_OK) { + LOG_ERROR("ESP32 internal temp sensor read failed: 0x%x (%s)\n", static_cast(err), esp_err_to_name(err)); + return false; + } + + return true; +} +} // namespace +#endif + ESP32TemperatureNode::ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval) { _id = id; _name = name; @@ -27,6 +68,14 @@ void ESP32TemperatureNode::loop() { if (Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) + float temperatureCelsius = NAN; + if (readInternalTemperatureCelsius(temperatureCelsius)) { + _temperature = temperatureCelsius; + } else { + _temperature = NAN; + } +#else // Read internal temp of ESP32 (returns Fahrenheit) const uint8_t temp_farenheit = temprature_sens_read(); @@ -34,6 +83,7 @@ void ESP32TemperatureNode::loop() { // Note: If the sensor reads 0 or is uncalibrated, it may return a value of 128 // standard conversion is: C = (F - 32) / 1.8 _temperature = (temp_farenheit - 32.0f) / 1.8f; +#endif LOG_DEBUG("ă€œ ESP32 internal temp: %f °C\n", _temperature); } diff --git a/src/ESP32TemperatureNode.hpp b/src/ESP32TemperatureNode.hpp index 899d3a17..23cd8913 100644 --- a/src/ESP32TemperatureNode.hpp +++ b/src/ESP32TemperatureNode.hpp @@ -10,10 +10,13 @@ #pragma once #include +#include +#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 0, 0) extern "C" { uint8_t temprature_sens_read(); } +#endif /** * @brief Reads the ESP32 internal chip temperature sensor. diff --git a/src/Ky040Decoder.cpp b/src/Ky040Decoder.cpp new file mode 100644 index 00000000..b9edba09 --- /dev/null +++ b/src/Ky040Decoder.cpp @@ -0,0 +1,59 @@ +#include "Ky040Decoder.hpp" + +namespace PoolController { + +LocalUiEvent Ky040Decoder::update(bool clkHigh, bool dtHigh, bool swPressed, std::uint32_t nowMs) { + const std::uint8_t state = static_cast((clkHigh ? 0x02 : 0x00) | (dtHigh ? 0x01 : 0x00)); + const std::uint8_t transition = static_cast((lastState_ << 2U) | state); + lastState_ = state; + + switch (transition) { + case 0b1110: + case 0b1000: + case 0b0001: + case 0b0111: + --delta_; + break; + case 0b1101: + case 0b0100: + case 0b0010: + case 0b1011: + ++delta_; + break; + default: + break; + } + + if (state == 0x03 && delta_ >= 4) { + delta_ = 0; + return LocalUiEvent::ROTATE_CLOCKWISE; + } + if (state == 0x03 && delta_ <= -4) { + delta_ = 0; + return LocalUiEvent::ROTATE_COUNTER_CLOCKWISE; + } + + if (swPressed && !buttonWasPressed_) { + if (nowMs - buttonLastChangeMs_ < DEBOUNCE_MS) { + return LocalUiEvent::NONE; + } + buttonWasPressed_ = true; + buttonPressStartMs_ = nowMs; + buttonLastChangeMs_ = nowMs; + return LocalUiEvent::NONE; + } + + if (!swPressed && buttonWasPressed_) { + if (nowMs - buttonLastChangeMs_ < DEBOUNCE_MS) { + return LocalUiEvent::NONE; + } + buttonWasPressed_ = false; + buttonLastChangeMs_ = nowMs; + const std::uint32_t heldMs = nowMs - buttonPressStartMs_; + return heldMs >= LONG_PRESS_MS ? LocalUiEvent::LONG_PRESS : LocalUiEvent::SHORT_PRESS; + } + + return LocalUiEvent::NONE; +} + +} // namespace PoolController diff --git a/src/Ky040Decoder.hpp b/src/Ky040Decoder.hpp new file mode 100644 index 00000000..0b39f669 --- /dev/null +++ b/src/Ky040Decoder.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "LocalUiTypes.hpp" + +namespace PoolController { + +class Ky040Decoder { +public: + LocalUiEvent update(bool clkHigh, bool dtHigh, bool swPressed, std::uint32_t nowMs); + +private: + static constexpr std::uint32_t LONG_PRESS_MS{2000}; + static constexpr std::uint32_t DEBOUNCE_MS{50}; + std::uint8_t lastState_{0x03}; + std::int8_t delta_{0}; + bool buttonWasPressed_{false}; + std::uint32_t buttonPressStartMs_{0}; + std::uint32_t buttonLastChangeMs_{0}; +}; + +} // namespace PoolController diff --git a/src/LocalSettingsMenu.cpp b/src/LocalSettingsMenu.cpp new file mode 100644 index 00000000..7fcc8695 --- /dev/null +++ b/src/LocalSettingsMenu.cpp @@ -0,0 +1,106 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +#include "LocalSettingsMenu.hpp" + +namespace PoolController { +namespace { + +constexpr std::uint8_t kPageCount{4}; +constexpr std::uint8_t kMenuItemCount{5}; + +} // namespace + +void LocalSettingsMenu::handleEvent(LocalUiEvent event) { + if (event == LocalUiEvent::NONE) { + return; + } + if (event == LocalUiEvent::LONG_PRESS) { + returnToOverview(); + return; + } + if (currentPage_ == LocalUiPage::MENU) { + if (event == LocalUiEvent::ROTATE_CLOCKWISE) { + nextMenuItem(); + } else if (event == LocalUiEvent::ROTATE_COUNTER_CLOCKWISE) { + previousMenuItem(); + } else if (event == LocalUiEvent::SHORT_PRESS) { + selectMenuItem(); + } + return; + } + if (event == LocalUiEvent::SHORT_PRESS) { + currentPage_ = LocalUiPage::MENU; + currentMenuItem_ = LocalMenuItem::MODE; + needsRedraw_ = true; + } else if (event == LocalUiEvent::ROTATE_CLOCKWISE) { + nextPage(); + } else if (event == LocalUiEvent::ROTATE_COUNTER_CLOCKWISE) { + previousPage(); + } +} + +void LocalSettingsMenu::nextPage() { + const auto page = static_cast(currentPage_); + const auto next = static_cast((page + 1U) % kPageCount); + currentPage_ = static_cast(next); + needsRedraw_ = true; +} + +void LocalSettingsMenu::previousPage() { + const auto page = static_cast(currentPage_); + const auto prev = static_cast((page + kPageCount - 1U) % kPageCount); + currentPage_ = static_cast(prev); + needsRedraw_ = true; +} + +void LocalSettingsMenu::nextMenuItem() { + const auto item = static_cast(currentMenuItem_); + const auto next = static_cast((item + 1U) % kMenuItemCount); + currentMenuItem_ = static_cast(next); + needsRedraw_ = true; +} + +void LocalSettingsMenu::previousMenuItem() { + const auto item = static_cast(currentMenuItem_); + const auto prev = static_cast((item + kMenuItemCount - 1U) % kMenuItemCount); + currentMenuItem_ = static_cast(prev); + needsRedraw_ = true; +} + +void LocalSettingsMenu::selectMenuItem() { + switch (currentMenuItem_) { + case LocalMenuItem::NETWORK_STATUS: + currentPage_ = LocalUiPage::NETWORK; + break; + case LocalMenuItem::QR_CODE: + currentPage_ = LocalUiPage::QRCODE; + break; + case LocalMenuItem::MODE: + pendingAction_ = LocalMenuAction::CYCLE_MODE; + currentPage_ = LocalUiPage::OVERVIEW; + break; + case LocalMenuItem::PUMP: + pendingAction_ = LocalMenuAction::TOGGLE_PUMP; + currentPage_ = LocalUiPage::OVERVIEW; + break; + case LocalMenuItem::EXIT: + currentPage_ = LocalUiPage::OVERVIEW; + break; + } + needsRedraw_ = true; +} + +LocalMenuAction LocalSettingsMenu::consumePendingAction() { + const auto action = pendingAction_; + pendingAction_ = LocalMenuAction::NONE; + return action; +} + +void LocalSettingsMenu::returnToOverview() { + currentPage_ = LocalUiPage::OVERVIEW; + needsRedraw_ = true; +} + +} // namespace PoolController diff --git a/src/LocalSettingsMenu.hpp b/src/LocalSettingsMenu.hpp new file mode 100644 index 00000000..985741c7 --- /dev/null +++ b/src/LocalSettingsMenu.hpp @@ -0,0 +1,38 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "LocalUiTypes.hpp" + +namespace PoolController { + +class LocalSettingsMenu { +public: + LocalSettingsMenu() = default; + + void handleEvent(LocalUiEvent event); + + LocalUiPage currentPage() const { return currentPage_; } + LocalMenuItem currentMenuItem() const { return currentMenuItem_; } + LocalMenuAction consumePendingAction(); + bool isMenuActive() const { return currentPage_ == LocalUiPage::MENU; } + bool needsRedraw() const { return needsRedraw_; } + void clearRedraw() { needsRedraw_ = false; } + +private: + void nextPage(); + void previousPage(); + void nextMenuItem(); + void previousMenuItem(); + void selectMenuItem(); + void returnToOverview(); + + LocalUiPage currentPage_{LocalUiPage::OVERVIEW}; + LocalMenuItem currentMenuItem_{LocalMenuItem::MODE}; + LocalMenuAction pendingAction_{LocalMenuAction::NONE}; + bool needsRedraw_{true}; +}; + +} // namespace PoolController diff --git a/src/LocalUiTypes.hpp b/src/LocalUiTypes.hpp new file mode 100644 index 00000000..7fc70491 --- /dev/null +++ b/src/LocalUiTypes.hpp @@ -0,0 +1,58 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace PoolController { + +enum class LocalUiEvent : std::uint8_t { + NONE = 0, + ROTATE_CLOCKWISE, + ROTATE_COUNTER_CLOCKWISE, + SHORT_PRESS, + LONG_PRESS, +}; + +enum class LocalUiPage : std::uint8_t { + OVERVIEW = 0, + NETWORK, + SYSTEM, + QRCODE, + MENU, +}; + +enum class LocalMenuItem : std::uint8_t { + MODE = 0, + PUMP, + NETWORK_STATUS, + QR_CODE, + EXIT, +}; + +enum class LocalMenuAction : std::uint8_t { + NONE = 0, + CYCLE_MODE, + TOGGLE_PUMP, +}; + +enum class DisplayDriver : std::uint8_t { + ILI9341 = 0, + ST7789, +}; + +enum class DisplaySizeClass : std::uint8_t { + COMPACT = 0, + NORMAL, +}; + +struct TftDisplayConfig { + std::uint16_t width; + std::uint16_t height; + DisplayDriver driver; + DisplaySizeClass sizeClass; +}; + +} // namespace PoolController diff --git a/src/MqttPublisher.hpp b/src/MqttPublisher.hpp index 4dbc4b33..1003e104 100644 --- a/src/MqttPublisher.hpp +++ b/src/MqttPublisher.hpp @@ -12,6 +12,7 @@ #include #include #include +#include namespace PoolController { diff --git a/src/NetworkManager.hpp b/src/NetworkManager.hpp index 1d273892..35a5f9ef 100644 --- a/src/NetworkManager.hpp +++ b/src/NetworkManager.hpp @@ -61,7 +61,7 @@ class NetworkManager { // Getters for status monitoring static int getWiFiRSSI() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0; } - static String getLocalIP() { return WiFi.localIP().toString(); } + static String getLocalIP() { return isApMode() ? WiFi.softAPIP().toString() : WiFi.localIP().toString(); } private: static void handleWiFiEvent(WiFiEvent_t event); diff --git a/src/OlimexEncoderHandler.cpp b/src/OlimexEncoderHandler.cpp new file mode 100644 index 00000000..fcabe0d0 --- /dev/null +++ b/src/OlimexEncoderHandler.cpp @@ -0,0 +1,45 @@ +#include "OlimexEncoderHandler.hpp" + +#if defined(OLIMEX_ESP32_C6_EVB) + +#include "Config.hpp" +#include "Ky040Decoder.hpp" + +#include + +namespace PoolController { +namespace { + +Ky040Decoder decoder; +constexpr std::uint32_t SAMPLE_INTERVAL_MS{2}; +std::uint32_t lastSampleMs{0}; + +} // namespace + +OlimexEncoderHandler::EventCallback OlimexEncoderHandler::eventCallback_{}; + +void OlimexEncoderHandler::begin() { + pinMode(PIN_ENCODER_CLK, INPUT_PULLUP); + pinMode(PIN_ENCODER_DT, INPUT_PULLUP); + pinMode(PIN_ENCODER_SW, INPUT_PULLUP); +} + +void OlimexEncoderHandler::loop() { + const std::uint32_t nowMs = millis(); + if (nowMs - lastSampleMs < SAMPLE_INTERVAL_MS) { + return; + } + lastSampleMs = nowMs; + + const bool clkHigh = digitalRead(PIN_ENCODER_CLK) == HIGH; + const bool dtHigh = digitalRead(PIN_ENCODER_DT) == HIGH; + const bool swPressed = digitalRead(PIN_ENCODER_SW) == LOW; + const LocalUiEvent event = decoder.update(clkHigh, dtHigh, swPressed, nowMs); + if (event != LocalUiEvent::NONE && eventCallback_) { + eventCallback_(event); + } +} + +} // namespace PoolController + +#endif diff --git a/src/OlimexEncoderHandler.hpp b/src/OlimexEncoderHandler.hpp new file mode 100644 index 00000000..6e4858ef --- /dev/null +++ b/src/OlimexEncoderHandler.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include "LocalUiTypes.hpp" + +namespace PoolController { + +class OlimexEncoderHandler { +public: + using EventCallback = std::function; + + static void begin(); + static void loop(); + static void onEvent(EventCallback cb) { eventCallback_ = cb; } + +private: + static EventCallback eventCallback_; +}; + +} // namespace PoolController diff --git a/src/OlimexLocalUi.cpp b/src/OlimexLocalUi.cpp new file mode 100644 index 00000000..33925632 --- /dev/null +++ b/src/OlimexLocalUi.cpp @@ -0,0 +1,101 @@ +#include "OlimexLocalUi.hpp" + +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + +#include "LocalSettingsMenu.hpp" +#include "OlimexEncoderHandler.hpp" +#include "OlimexTftDisplay.hpp" +#include "Nodes.hpp" + +namespace PoolController { +namespace { + +LocalSettingsMenu menu; +std::uint32_t lastLiveRedrawMs{0}; + +bool isLivePage(LocalUiPage page) { + return page == LocalUiPage::OVERVIEW || page == LocalUiPage::NETWORK || page == LocalUiPage::SYSTEM; +} + +} // namespace + +namespace { + +void cycleOperationMode() { + const String currentMode = operationModeNode.getMode(); + if (currentMode == "auto") { + operationModeNode.setMode("manu"); + } else if (currentMode == "manu") { + operationModeNode.setMode("boost"); + } else if (currentMode == "boost") { + operationModeNode.setMode("timer"); + } else { + operationModeNode.setMode("auto"); + } +} + +void executePendingAction(LocalMenuAction action) { + switch (action) { + case LocalMenuAction::CYCLE_MODE: + cycleOperationMode(); + break; + case LocalMenuAction::TOGGLE_PUMP: + if (operationModeNode.getMode() != "manu") { + operationModeNode.setMode("manu"); + } + poolPumpNode.setSwitch(!poolPumpNode.getSwitch()); + break; + case LocalMenuAction::NONE: + break; + } +} + +} // namespace + +void OlimexLocalUi::begin() { + OlimexTftDisplay::begin(); + lastLiveRedrawMs = millis(); + OlimexEncoderHandler::onEvent([](LocalUiEvent event) { + menu.handleEvent(event); + OlimexTftDisplay::requestRedraw(); + }); + OlimexEncoderHandler::begin(); + OlimexTftDisplay::drawPage(menu.currentPage(), menu.currentMenuItem()); +} + +void OlimexLocalUi::loop() { + OlimexEncoderHandler::loop(); + const auto pendingAction = menu.consumePendingAction(); + if (pendingAction != LocalMenuAction::NONE) { + executePendingAction(pendingAction); + OlimexTftDisplay::requestRedraw(); + } + const auto now = millis(); + if (isLivePage(menu.currentPage()) && (now - lastLiveRedrawMs >= 1000UL)) { + lastLiveRedrawMs = now; + OlimexTftDisplay::requestRedraw(); + } + if (menu.needsRedraw()) { + OlimexTftDisplay::requestRedraw(); + menu.clearRedraw(); + } + OlimexTftDisplay::drawPage(menu.currentPage(), menu.currentMenuItem()); +} + +void OlimexLocalUi::requestRedraw() { + OlimexTftDisplay::requestRedraw(); +} + +} // namespace PoolController + +#else + +namespace PoolController { + +void OlimexLocalUi::begin() {} +void OlimexLocalUi::loop() {} +void OlimexLocalUi::requestRedraw() {} + +} // namespace PoolController + +#endif diff --git a/src/OlimexLocalUi.hpp b/src/OlimexLocalUi.hpp new file mode 100644 index 00000000..a60b84e3 --- /dev/null +++ b/src/OlimexLocalUi.hpp @@ -0,0 +1,12 @@ +#pragma once + +namespace PoolController { + +class OlimexLocalUi { +public: + static void begin(); + static void loop(); + static void requestRedraw(); +}; + +} // namespace PoolController diff --git a/src/OlimexTftDisplay.cpp b/src/OlimexTftDisplay.cpp new file mode 100644 index 00000000..376b0802 --- /dev/null +++ b/src/OlimexTftDisplay.cpp @@ -0,0 +1,199 @@ +#include "OlimexTftDisplay.hpp" + +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + +#include + +#include +#include +#include +#include + +#include "Config.hpp" +#include "ConfigManager.hpp" +#include "NetworkManager.hpp" +#include "Nodes.hpp" +#include "Utils.hpp" +#include "Version.h" + +namespace PoolController { +namespace { + +Adafruit_ILI9341 tftIli9341(&SPI, PIN_TFT_DC, PIN_TFT_CS, PIN_TFT_RST); +Adafruit_ST7789 tftSt7789(&SPI, PIN_TFT_CS, PIN_TFT_DC, PIN_TFT_RST); + +Adafruit_SPITFT &activeTft() { + return TFT_DRIVER_ST7789 ? static_cast(tftSt7789) : static_cast(tftIli9341); +} + +std::uint16_t rgb(std::uint8_t r, std::uint8_t g, std::uint8_t b) { + return TFT_DRIVER_ST7789 ? tftSt7789.color565(r, g, b) : tftIli9341.color565(r, g, b); +} + +void drawHeader(const char *left, const char *right, std::uint16_t y) { + activeTft().setTextColor(rgb(255, 255, 255), rgb(0, 0, 0)); + activeTft().setTextSize(TFT_DISPLAY_SIZE_CLASS_COMPACT ? 1 : 2); + activeTft().setCursor(8, y); + activeTft().print(left); + activeTft().setCursor(240, y); + activeTft().print(right); +} + +void drawStatusLine(std::uint16_t x, std::uint16_t y, const char *label, const char *value) { + activeTft().setTextSize(2); + activeTft().setCursor(x, y); + activeTft().print(label); + activeTft().print(value); +} + +} // namespace + +bool OlimexTftDisplay::forceRedraw_{true}; + +void OlimexTftDisplay::begin() { + SPI.begin(PIN_TFT_SCLK, PIN_TFT_MISO, PIN_TFT_MOSI, PIN_TFT_CS); + if constexpr (TFT_DRIVER_ST7789) { + tftSt7789.init(TFT_DISPLAY_HEIGHT, TFT_DISPLAY_WIDTH); + } else { + tftIli9341.begin(27000000); + } + activeTft().setRotation(1); + activeTft().fillScreen(rgb(0, 0, 0)); + drawHeader("POOL", "BOOT", 6); + activeTft().setCursor(8, 40); + activeTft().print("Starting..."); + forceRedraw_ = true; +} + +void OlimexTftDisplay::drawPage(LocalUiPage page, LocalMenuItem menuItem) { + if (!forceRedraw_) { + return; + } + forceRedraw_ = false; + activeTft().fillScreen(rgb(0, 0, 0)); + switch (page) { + case LocalUiPage::OVERVIEW: + drawOverview(); + break; + case LocalUiPage::NETWORK: + drawNetwork(); + break; + case LocalUiPage::SYSTEM: + drawSystem(); + break; + case LocalUiPage::QRCODE: + drawQrCode(); + break; + case LocalUiPage::MENU: + drawMenu(menuItem); + break; + } +} + +void OlimexTftDisplay::drawOverview() { + drawHeader("POOL", operationModeNode.getMode().c_str(), 6); + char buf[16]; + Utils::floatToString(poolTemperatureNode.getTemperature(), buf, sizeof(buf), 1); + activeTft().setTextSize(TFT_DISPLAY_SIZE_CLASS_COMPACT ? 3 : 4); + activeTft().setCursor(8, 38); + activeTft().print(buf); + activeTft().print(" C"); + drawStatusLine(8, 92, "Pump: ", poolPumpNode.getSwitch() ? "ON" : "OFF"); + activeTft().drawFastHLine(0, 120, TFT_DISPLAY_WIDTH, rgb(64, 64, 64)); + drawHeader("SOLAR", solarPumpNode.getSwitch() ? "ON" : "OFF", 126); + Utils::floatToString(solarTemperatureNode.getTemperature(), buf, sizeof(buf), 1); + activeTft().setTextSize(TFT_DISPLAY_SIZE_CLASS_COMPACT ? 3 : 4); + activeTft().setCursor(8, 148); + activeTft().print(buf); + activeTft().print(" C"); + drawStatusLine(8, 204, "Valve: ", solarPumpNode.getSwitch() ? "OPEN" : "CLOSED"); +} + +void OlimexTftDisplay::drawMenu(LocalMenuItem menuItem) { + drawHeader("MENU", "OK=Select", 6); + const char *items[] = {"Mode", "Pump", "Network", "QR Code", "Exit"}; + for (std::uint8_t i = 0; i < 5; ++i) { + activeTft().setCursor(20, 42 + (i * 32)); + activeTft().setTextSize(2); + activeTft().setTextColor(i == static_cast(menuItem) ? rgb(0, 0, 0) : rgb(255, 255, 255), + i == static_cast(menuItem) ? rgb(255, 255, 0) : rgb(0, 0, 0)); + activeTft().print(items[i]); + } +} + +void OlimexTftDisplay::drawNetwork() { + drawHeader("NETWORK", NetworkManager::isApMode() ? "AP" : "STA", 6); + char buf[32]; + activeTft().setTextSize(2); + activeTft().setCursor(8, 48); + activeTft().print(NetworkManager::isWiFiConnected() ? "WiFi: ON " : "WiFi: OFF"); + activeTft().print("MQTT: "); + activeTft().print(NetworkManager::isMqttConnected() ? "ON" : "OFF"); + activeTft().setCursor(8, 80); + snprintf(buf, sizeof(buf), "RSSI: %d dBm", NetworkManager::getWiFiRSSI()); + activeTft().print(buf); + activeTft().setCursor(8, 112); + activeTft().print("IP: "); + activeTft().print(NetworkManager::getLocalIP()); +} + +void OlimexTftDisplay::drawSystem() { + drawHeader("SYSTEM", FW_VERSION, 6); + char buf[32]; + activeTft().setTextSize(2); + activeTft().setCursor(8, 48); + snprintf(buf, sizeof(buf), "Heap: %u B", ESP.getFreeHeap()); + activeTft().print(buf); + activeTft().setCursor(8, 80); + snprintf(buf, sizeof(buf), "Uptime: %lus", millis() / 1000UL); + activeTft().print(buf); +} + +void OlimexTftDisplay::drawQrCode() { + drawHeader("QR", "WEB UI", 6); + const char *url = "http://pool-controller.local"; + QRCode qrcode; + constexpr std::uint8_t kQrVersion{3}; + constexpr std::uint16_t kQrBufferSize{106}; + std::uint8_t qrcodeData[kQrBufferSize]; + qrcode_initText(&qrcode, qrcodeData, kQrVersion, ECC_LOW, url); + const std::uint8_t scale = 6; + const std::uint16_t offsetX = 70; + const std::uint16_t offsetY = 35; + const std::uint16_t quiet = static_cast(4U * scale); + activeTft().fillRect(offsetX - quiet, offsetY - quiet, (qrcode.size * scale) + (2U * quiet), + (qrcode.size * scale) + (2U * quiet), rgb(255, 255, 255)); + for (std::uint8_t y = 0; y < qrcode.size; ++y) { + for (std::uint8_t x = 0; x < qrcode.size; ++x) { + if (qrcode_getModule(&qrcode, x, y)) { + activeTft().fillRect(offsetX + (x * scale), offsetY + (y * scale), scale, scale, rgb(0, 0, 0)); + } + } + } +} + +} // namespace PoolController + +#else + +namespace PoolController { + +bool OlimexTftDisplay::forceRedraw_{true}; + +void OlimexTftDisplay::begin() {} + +void OlimexTftDisplay::drawPage(LocalUiPage, LocalMenuItem) {} + +void OlimexTftDisplay::drawOverview() {} + +void OlimexTftDisplay::drawMenu(LocalMenuItem) {} + +void OlimexTftDisplay::drawNetwork() {} + +void OlimexTftDisplay::drawSystem() {} + +void OlimexTftDisplay::drawQrCode() {} + +} // namespace PoolController + +#endif diff --git a/src/OlimexTftDisplay.hpp b/src/OlimexTftDisplay.hpp new file mode 100644 index 00000000..4f3559e7 --- /dev/null +++ b/src/OlimexTftDisplay.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "LocalUiTypes.hpp" + +namespace PoolController { + +class OlimexTftDisplay { +public: + static void begin(); + static void drawPage(LocalUiPage page, LocalMenuItem menuItem); + static void requestRedraw() { forceRedraw_ = true; } + +private: + static void drawOverview(); + static void drawMenu(LocalMenuItem menuItem); + static void drawNetwork(); + static void drawSystem(); + static void drawQrCode(); + static bool forceRedraw_; +}; + +} // namespace PoolController diff --git a/src/PoolController.cpp b/src/PoolController.cpp index fdca09e4..64129244 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -43,6 +43,10 @@ #include "NorviButtonHandler.hpp" #endif +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) +#include "OlimexLocalUi.hpp" +#endif + #include "Config.hpp" namespace PoolController { @@ -62,6 +66,10 @@ ESP32TemperatureNode ctrlTemperatureNode("controller-temp", "Controller Temperat // NORVI AE01-R uses active-HIGH relays (HIGH = relay ON, LOW = relay OFF) RelayModuleNode poolPumpNode("pool-pump", "Pool Pump", PIN_RELAY_POOL, false); RelayModuleNode solarPumpNode("solar-pump", "Solar Pump", PIN_RELAY_SOLAR, false); +#elif defined(OLIMEX_ESP32_C6_EVB) +// Olimex relay outputs are active-HIGH +RelayModuleNode poolPumpNode("pool-pump", "Pool Pump", PIN_RELAY_POOL, false); +RelayModuleNode solarPumpNode("solar-pump", "Solar Pump", PIN_RELAY_SOLAR, false); #else // Standard external relay modules use active-LOW (LOW = relay ON, HIGH = relay OFF) RelayModuleNode poolPumpNode("pool-pump", "Pool Pump", PIN_RELAY_POOL); @@ -418,6 +426,10 @@ auto PoolControllerContext::setup() -> void { // Load operational settings from NVS Preferences operationModeNode.loadState(); +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + OlimexLocalUi::begin(); +#endif + // OTA safety: detect version transition and verify config integrity ConfigManager::logOtaTransition(); @@ -480,6 +492,10 @@ auto PoolControllerContext::loop() -> void { } StatusLed::loop(); +#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) + OlimexLocalUi::loop(); +#endif + #ifdef NORVI_AE01_R // Update NORVI OLED display and read front-panel buttons NorviOledDisplay::loop(); diff --git a/src/WpsProvisioner.cpp b/src/WpsProvisioner.cpp index 0d842ea7..cefa6523 100644 --- a/src/WpsProvisioner.cpp +++ b/src/WpsProvisioner.cpp @@ -71,7 +71,9 @@ auto startWps() -> bool { return false; } -#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0) + const esp_err_t startErr = esp_wifi_wps_start(0); +#elif ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) const esp_err_t startErr = esp_wifi_wps_start(); #else const esp_err_t startErr = esp_wifi_wps_start(0); diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index 5cf49be1..8454fc4d 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -55,6 +55,9 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/SystemMonitor.cpp ${PROJ_ROOT}/src/OtaUpdater.cpp ${PROJ_ROOT}/src/LogCapture.cpp + ${PROJ_ROOT}/src/LocalSettingsMenu.cpp + ${PROJ_ROOT}/src/Ky040Decoder.cpp + ${PROJ_ROOT}/src/OlimexEncoderHandler.cpp ) # Mock sources (compiled once) @@ -78,6 +81,8 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_timer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_logcapture.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_webportal_logs.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_local_settings_menu.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ky040_decoder.cpp ) add_executable(test_runner diff --git a/test/native/tests/test_ky040_decoder.cpp b/test/native/tests/test_ky040_decoder.cpp new file mode 100644 index 00000000..d62f5e5a --- /dev/null +++ b/test/native/tests/test_ky040_decoder.cpp @@ -0,0 +1,69 @@ +#include + +#include "Ky040Decoder.hpp" + +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::Ky040Decoder; +using PoolController::LocalUiEvent; + +static int test_clockwise_rotation() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, false, 0), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, true, false, 5), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, false, false, 10), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, false, false, 15), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 20), LocalUiEvent::ROTATE_CLOCKWISE); + return 0; +} + +static int test_counter_clockwise_rotation() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, false, 0), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, false, false, 5), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, false, false, 10), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(false, true, false, 15), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 20), LocalUiEvent::ROTATE_COUNTER_CLOCKWISE); + return 0; +} + +static int test_short_press_on_release() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, true, 100), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, true, 300), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 350), LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(decoder.update(true, true, true, 360), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 370), LocalUiEvent::NONE); + return 0; +} + +static int test_long_press_on_release() { + Ky040Decoder decoder; + ASSERT_EQ(decoder.update(true, true, true, 100), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, true, 2300), LocalUiEvent::NONE); + ASSERT_EQ(decoder.update(true, true, false, 2350), LocalUiEvent::LONG_PRESS); + return 0; +} + +int run_ky040_decoder_tests() { + int failures = 0; + failures += test_clockwise_rotation(); + failures += test_counter_clockwise_rotation(); + failures += test_short_press_on_release(); + failures += test_long_press_on_release(); + test_suite_end("KY-040 Decoder", 4 - failures, failures); + if (failures == 0) { + printf(" KY-040 Decoder Tests: 4 passed, 0 failed\n"); + } + return failures; +} diff --git a/test/native/tests/test_local_settings_menu.cpp b/test/native/tests/test_local_settings_menu.cpp new file mode 100644 index 00000000..6d107c75 --- /dev/null +++ b/test/native/tests/test_local_settings_menu.cpp @@ -0,0 +1,107 @@ +#include + +#include "LocalSettingsMenu.hpp" + +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_TRUE(expr) \ + do { \ + if (!(expr)) { \ + printf(" ✗ %s:%d expected true: %s\n", __FILE__, __LINE__, #expr); \ + return 1; \ + } \ + } while (0) + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::LocalMenuItem; +using PoolController::LocalMenuAction; +using PoolController::LocalSettingsMenu; +using PoolController::LocalUiEvent; +using PoolController::LocalUiPage; + +static int test_short_press_opens_menu() { + LocalSettingsMenu menu; + ASSERT_EQ(menu.currentPage(), LocalUiPage::OVERVIEW); + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::MENU); + ASSERT_TRUE(menu.isMenuActive()); + ASSERT_EQ(menu.currentMenuItem(), LocalMenuItem::MODE); + return 0; +} + +static int test_rotate_pages_on_overview() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::NETWORK); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::SYSTEM); + menu.handleEvent(LocalUiEvent::ROTATE_COUNTER_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::NETWORK); + return 0; +} + +static int test_menu_navigation_and_qr_selection() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentMenuItem(), LocalMenuItem::QR_CODE); + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::QRCODE); + ASSERT_TRUE(!menu.isMenuActive()); + return 0; +} + +static int test_long_press_returns_to_overview() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + ASSERT_EQ(menu.currentPage(), LocalUiPage::NETWORK); + menu.handleEvent(LocalUiEvent::LONG_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::OVERVIEW); + return 0; +} + +static int test_mode_selection_requests_action() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::OVERVIEW); + ASSERT_EQ(menu.consumePendingAction(), LocalMenuAction::CYCLE_MODE); + ASSERT_EQ(menu.consumePendingAction(), LocalMenuAction::NONE); + return 0; +} + +static int test_pump_selection_requests_action() { + LocalSettingsMenu menu; + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + menu.handleEvent(LocalUiEvent::ROTATE_CLOCKWISE); + menu.handleEvent(LocalUiEvent::SHORT_PRESS); + ASSERT_EQ(menu.currentPage(), LocalUiPage::OVERVIEW); + ASSERT_EQ(menu.consumePendingAction(), LocalMenuAction::TOGGLE_PUMP); + return 0; +} + +int run_local_settings_menu_tests() { + int failures = 0; + failures += test_short_press_opens_menu(); + failures += test_rotate_pages_on_overview(); + failures += test_menu_navigation_and_qr_selection(); + failures += test_long_press_returns_to_overview(); + failures += test_mode_selection_requests_action(); + failures += test_pump_selection_requests_action(); + test_suite_end("LocalSettingsMenu", 6 - failures, failures); + if (failures == 0) { + printf(" LocalSettingsMenu Tests: 6 passed, 0 failed\n"); + } + return failures; +} diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index 83580bc1..e5f33827 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -108,6 +108,8 @@ extern int run_state_manager_tests(); extern int run_timer_tests(); extern int run_logcapture_tests(); extern int run_webportal_logs_tests(); +extern int run_local_settings_menu_tests(); +extern int run_ky040_decoder_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -124,6 +126,8 @@ int main() { total += run_timer_tests(); total += run_logcapture_tests(); total += run_webportal_logs_tests(); + total += run_local_settings_menu_tests(); + total += run_ky040_decoder_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); From 86063e6e98df455a3b76a935c576cbcdb06b9d46 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Thu, 13 Aug 2026 15:52:32 +0200 Subject: [PATCH 08/25] chore(ci): optimize GitHub Actions workflows with caching and dedup (#178) * chore(ci): optimize GitHub Actions workflows with caching and dedup - Add composite action .github/actions/setup-platformio centralizing Python setup, PlatformIO install, and cache restore/save - Use composite action in PlatformIO CI, native tests, CodeQL, dependency check, and release workflows (removes 6x duplicated setup) - Merge platformio-update-check into pio-dependency-check with cache and working issue creation (steps.outdated.outcome instead of never-triggering if: failure()) - Remove unnecessary checkout from auto-merge-release (gh pr merge needs no repository) - Add concurrency cancel-in-progress to MegaLinter workflow - Drop redundant job-level permissions (already at workflow level) * fix(ci): supply repository context to gh pr merge in auto-merge workflow Without a checkout there is no git remote for gh to resolve the repository. Pass --repo explicitly so auto-merge works without re-adding the checkout step. --- .github/actions/setup-platformio/action.yml | 64 +++++++++++++++++++ .github/workflows/auto-merge-release.yml | 6 +- .github/workflows/codeql-analysis.yml | 17 +---- .github/workflows/linter.yml | 5 ++ .github/workflows/native-tests.yml | 21 +----- .github/workflows/pio-dependency-check.yml | 45 +++++++++---- .github/workflows/plaform.io.yml | 30 ++------- .github/workflows/platformio-update-check.yml | 55 ---------------- .github/workflows/release.yml | 25 ++------ 9 files changed, 118 insertions(+), 150 deletions(-) create mode 100644 .github/actions/setup-platformio/action.yml delete mode 100644 .github/workflows/platformio-update-check.yml diff --git a/.github/actions/setup-platformio/action.yml b/.github/actions/setup-platformio/action.yml new file mode 100644 index 00000000..a8b3842b --- /dev/null +++ b/.github/actions/setup-platformio/action.yml @@ -0,0 +1,64 @@ +--- +########################### +## Setup PlatformIO ## +########################### +## Composite action that centralizes the repeated PlatformIO setup: +## Python, pip install, and cache restore/save (tools + libdeps). +## +## All PlatformIO-based workflows should use this instead of +## duplicating the setup steps. +## +## Inputs: +## python-version Python version for PlatformIO (default 3.11) +## pio-core-dir PlatformIO core directory; exported as +## PLATFORMIO_CORE_DIR for subsequent steps (default ~/.platformio) +## cache-key Full cache key for the PlatformIO cache +## restore-key Restore key prefix for the PlatformIO cache +name: "Set up PlatformIO" +description: "Cache and install PlatformIO CLI with Python" + +inputs: + python-version: + description: "Python version to use" + required: false + default: "3.11" + pio-core-dir: + description: "PlatformIO core directory (exported as PLATFORMIO_CORE_DIR)" + required: false + default: "~/.platformio" + cache-key: + description: "Cache key for the PlatformIO cache" + required: false + default: "${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }}" + restore-key: + description: "Restore key prefix for the PlatformIO cache" + required: false + default: "${{ runner.os }}-pio-" + +runs: + using: "composite" + steps: + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: ${{ inputs.python-version }} + + - name: Cache PlatformIO + uses: actions/cache@v6 + with: + path: | + ${{ inputs.pio-core-dir }} + .pio + key: ${{ inputs.cache-key }} + restore-keys: | + ${{ inputs.restore-key }} + + - name: Export PLATFORMIO_CORE_DIR + shell: bash + run: echo "PLATFORMIO_CORE_DIR=${{ inputs.pio-core-dir }}" >> "$GITHUB_ENV" + + - name: Install PlatformIO + shell: bash + run: | + python -m pip install --upgrade pip + pip install platformio intelhex diff --git a/.github/workflows/auto-merge-release.yml b/.github/workflows/auto-merge-release.yml index 5fabfb43..348c73a4 100644 --- a/.github/workflows/auto-merge-release.yml +++ b/.github/workflows/auto-merge-release.yml @@ -36,15 +36,11 @@ jobs: if: startsWith(github.event.pull_request.head.ref, 'release-please--') runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - name: Enable auto-merge via gh CLI env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh pr merge "${{ github.event.pull_request.number }}" \ + --repo "${{ github.repository }}" \ --auto --merge \ --subject "chore(main): release" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 9f869a30..0f2f58d6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,23 +42,10 @@ jobs: languages: ${{ matrix.language }} # Build the project using PlatformIO - - name: Set up Python - uses: actions/setup-python@v7 + - name: Set up PlatformIO + uses: ./.github/actions/setup-platformio with: python-version: "3.11" - - name: Cache PlatformIO - uses: actions/cache@v6 - with: - path: | - ~/.platformio - .pio - key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }} - restore-keys: | - ${{ runner.os }}-pio- - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install platformio intelhex - name: Run PlatformIO run: platformio run diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 73cb76e8..91cf9415 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -2,6 +2,11 @@ name: MegaLinter on: [push, pull_request] +# Cancel in-progress runs for the same workflow+ref when new commits are pushed +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 5c66dbd5..eb2a9f90 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -33,25 +33,8 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Cache PlatformIO - uses: actions/cache@v6 - with: - path: | - ~/.platformio - .pio - key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini') }} - restore-keys: | - ${{ runner.os }}-pio- - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.x" - - - name: Install PlatformIO - run: | - python -m pip install --upgrade pip - pip install platformio + - name: Set up PlatformIO + uses: ./.github/actions/setup-platformio - name: Install project dependencies (ArduinoJson etc.) run: pio pkg install --environment esp32dev diff --git a/.github/workflows/pio-dependency-check.yml b/.github/workflows/pio-dependency-check.yml index 3d6e5b79..038f7247 100644 --- a/.github/workflows/pio-dependency-check.yml +++ b/.github/workflows/pio-dependency-check.yml @@ -5,12 +5,14 @@ on: - cron: "0 8 * * 1" workflow_dispatch: +permissions: + contents: read + issues: write + jobs: check-dependencies: name: Check PlatformIO Library Dependencies (${{ matrix.environment }}) runs-on: ubuntu-latest - permissions: - contents: read strategy: matrix: @@ -20,16 +22,37 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.11" - - - name: Install PlatformIO - run: | - python -m pip install --upgrade pip - pip install platformio intelhex + - name: Set up PlatformIO + uses: ./.github/actions/setup-platformio - name: Check outdated packages (${{ matrix.environment }}) + id: outdated run: pio pkg outdated --environment ${{ matrix.environment }} continue-on-error: true + + - name: Create issue if updates are available + if: steps.outdated.outcome == 'failure' + uses: actions/github-script@v9 + with: + script: | + const issueTitle = 'PlatformIO Updates Available'; + const issueBody = 'PlatformIO-AbhĂ€ngigkeiten benötigen Updates. Bitte ĂŒberprĂŒfen.'; + + // ÜberprĂŒfen, ob bereits ein Issue mit diesem Titel existiert + const existingIssues = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + title: issueTitle + }); + + if (existingIssues.data.length === 0) { + // Erstelle ein neues Issue + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: issueTitle, + body: issueBody, + labels: ['enhancement', 'dependencies'] + }); + } diff --git a/.github/workflows/plaform.io.yml b/.github/workflows/plaform.io.yml index 9a39cbb0..54eb1783 100644 --- a/.github/workflows/plaform.io.yml +++ b/.github/workflows/plaform.io.yml @@ -35,9 +35,6 @@ jobs: build: runs-on: ubuntu-latest - permissions: - contents: read - strategy: matrix: include: @@ -52,34 +49,17 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Cache PlatformIO - uses: actions/cache@v6 - with: - path: | - ${{ matrix.pio-core-dir }} - .pio - key: ${{ runner.os }}-pio-${{ matrix.environment }}-${{ hashFiles('**/platformio.ini') }} - restore-keys: | - ${{ runner.os }}-pio-${{ matrix.environment }}- - - - name: Set up Python - uses: actions/setup-python@v7 + - name: Set up PlatformIO + uses: ./.github/actions/setup-platformio with: - python-version: "3.x" - - - name: Install PlatformIO - run: | - python -m pip install --upgrade pip - pip install platformio intelhex + pio-core-dir: ${{ matrix.pio-core-dir }} + cache-key: ${{ runner.os }}-pio-${{ matrix.environment }}-${{ hashFiles('**/platformio.ini') }} + restore-key: ${{ runner.os }}-pio-${{ matrix.environment }}- - name: Run PlatformIO Check - env: - PLATFORMIO_CORE_DIR: ${{ matrix.pio-core-dir }} run: > platformio check --environment ${{ matrix.environment }} --skip-packages - name: Build PlatformIO Project - env: - PLATFORMIO_CORE_DIR: ${{ matrix.pio-core-dir }} run: platformio run --environment ${{ matrix.environment }} diff --git a/.github/workflows/platformio-update-check.yml b/.github/workflows/platformio-update-check.yml deleted file mode 100644 index a0c9736e..00000000 --- a/.github/workflows/platformio-update-check.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: PlatformIO Update Check - -on: - schedule: - - cron: "0 0 * * *" # TĂ€glich um Mitternacht ausfĂŒhren - workflow_dispatch: # Ermöglicht manuelles Auslösen - -jobs: - check-updates: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.10" - - - name: Install PlatformIO - run: pip install platformio intelhex - - - name: Check for updates - run: | - # Hier kannst du ein Skript einfĂŒgen, das nach Updates fĂŒr deine PlatformIO-AbhĂ€ngigkeiten sucht - # Zum Beispiel: - pio pkg update - pio pkg outdated - - - name: Create issue if updates are available - if: failure() # Nur ausfĂŒhren, wenn das vorherige Schritt fehlschlĂ€gt (Updates verfĂŒgbar) - uses: actions/github-script@v9 - with: - script: | - const issueTitle = 'PlatformIO Updates Available'; - const issueBody = 'PlatformIO-AbhĂ€ngigkeiten benötigen Updates. Bitte ĂŒberprĂŒfen.'; - - // ÜberprĂŒfen, ob bereits ein Issue mit diesem Titel existiert - const existingIssues = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - title: issueTitle - }); - - if (existingIssues.data.length === 0) { - // Erstelle ein neues Issue - await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: issueTitle, - body: issueBody, - labels: ['enhancement', 'dependencies'] - }); - } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b14b3fe4..4c87a18e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -57,29 +57,14 @@ jobs: - name: Checkout uses: actions/checkout@v7 - - name: Cache PlatformIO - uses: actions/cache@v6 + - name: Set up PlatformIO + uses: ./.github/actions/setup-platformio with: - path: | - ${{ matrix.pio-core-dir }} - .pio - key: ${{ runner.os }}-pio-${{ matrix.environment }}-${{ hashFiles('**/platformio.ini') }} - restore-keys: | - ${{ runner.os }}-pio-${{ matrix.environment }}- - - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: "3.x" - - - name: Install PlatformIO - run: | - python -m pip install --upgrade pip - pip install platformio intelhex + pio-core-dir: ${{ matrix.pio-core-dir }} + cache-key: ${{ runner.os }}-pio-${{ matrix.environment }}-${{ hashFiles('**/platformio.ini') }} + restore-key: ${{ runner.os }}-pio-${{ matrix.environment }}- - name: Build firmware - env: - PLATFORMIO_CORE_DIR: ${{ matrix.pio-core-dir }} run: platformio run --environment ${{ matrix.environment }} - name: Upload firmware to release From d6c2dc1825b26d45c043593f3fd95de98c39a9ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:53:31 +0200 Subject: [PATCH 09/25] build(deps): bump oxsecurity/megalinter from 9.6.0 to 10.0.0 (#176) Bumps [oxsecurity/megalinter](https://github.com/oxsecurity/megalinter) from 9.6.0 to 10.0.0. - [Release notes](https://github.com/oxsecurity/megalinter/releases) - [Changelog](https://github.com/oxsecurity/megalinter/blob/main/CHANGELOG.md) - [Commits](https://github.com/oxsecurity/megalinter/compare/v9.6.0...v10.0.0) --- updated-dependencies: - dependency-name: oxsecurity/megalinter dependency-version: 10.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/linter.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 91cf9415..aae409c5 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -14,4 +14,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v7 - - uses: oxsecurity/megalinter/flavors/c_cpp@v9.6.0 + - uses: oxsecurity/megalinter/flavors/c_cpp@v10.0.0 From e7ac809bf0a24219843f29c21689e924692d14c6 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Thu, 13 Aug 2026 16:13:42 +0200 Subject: [PATCH 10/25] feat(system): log concrete reason when entering safe mode (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(system): log concrete reason when entering safe mode Safe-mode entry now logs the specific cause instead of a generic message: boot-loop (forced), critically low free heap, or a breakdown of the concurrent failures (WiFi, NTP, sensor). Priority mirrors evaluateLevel() decision logic. * style(system): fix clang-format line join in safe-mode reason log * fix(system): gate NTP reason on wifi availability in safe-mode log NTP loss while WiFi is down is a consequence, not an independent failure — evaluateLevel() only counts it when WiFi is up. Mirror that condition in the safe-mode reason breakdown so the diagnosis matches the actual failure accounting. Addresses PR #179 review comment. Reviewed-by: Codex --- src/DegradationManager.cpp | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/DegradationManager.cpp b/src/DegradationManager.cpp index 9d02df64..f5998c82 100644 --- a/src/DegradationManager.cpp +++ b/src/DegradationManager.cpp @@ -158,7 +158,26 @@ void DegradationManager::onTransition() { break; case DegradationLevel::CRITICAL: - LOG_ERROR("✖ CRITICAL: Multiple system failures detected!\n"); + // Log the concrete reason for entering safe mode. Priority mirrors + // evaluateLevel(): forced (boot-loop) > low memory > multiple failures. + if (forcedSafeMode_) { + LOG_ERROR("✖ SAFE MODE — reason: boot-loop detected (safe mode forced)\n"); + } else if (!SystemMonitor::isHealthy()) { + LOG_ERROR("✖ SAFE MODE — reason: critically low free heap (%.1f KB)\n", ESP.getFreeHeap() / 1024.0f); + } else { + LOG_ERROR("✖ SAFE MODE — reason: multiple concurrent failures\n"); + if (!NetworkManager::isWiFiConnected()) { + LOG_ERROR(" - WiFi/MQTT disconnected\n"); + } + // NTP loss while WiFi is down is a consequence, not an independent + // failure — mirror the counting logic in evaluateLevel(). + if (NetworkManager::isWiFiConnected() && getTimeDegradation() == TimeDegradation::RED) { + LOG_ERROR(" - NTP time sync lost\n"); + } + if (sensorsEverReported_ && !(poolSensorOk_ && solarSensorOk_)) { + LOG_ERROR(" - temperature sensor fault\n"); + } + } LOG_ERROR(" Entering safe mode — all relays off\n"); // De-energize both relays immediately (P1 review fix) poolPumpNode.setSwitch(false); From 5f8b964894c9e639b1b3c8aa923b14727668243c Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Thu, 13 Aug 2026 18:42:49 +0200 Subject: [PATCH 11/25] fix: move solar pump relay to R2 (GPIO13), add RC snubber docs (#165) --- .gitignore | 1 + docs/contactor-guide.de.md | 63 ++++++++++++++++---------- docs/contactor-guide.md | 70 ++++++++++++++++++----------- docs/norvi-ae01-r.de.md | 92 +++++++++++++++++++------------------- docs/norvi-ae01-r.md | 86 ++++++++++++++++++----------------- docs/wiring-contactor.svg | 18 ++++---- src/Config.hpp | 29 +++--------- src/StatusLed.cpp | 5 ++- 8 files changed, 191 insertions(+), 173 deletions(-) diff --git a/.gitignore b/.gitignore index 5e838119..d8aaceac 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,4 @@ test/native/relay_safety/build/ # Frontend (separate JS project) — ignore lockfiles and node_modules test/frontend/ +graphify-out/ diff --git a/docs/contactor-guide.de.md b/docs/contactor-guide.de.md index 3298f6ee..14418139 100644 --- a/docs/contactor-guide.de.md +++ b/docs/contactor-guide.de.md @@ -12,6 +12,21 @@ menu: weight: 65 --- +> **⚠ Hinweis:** ECM-Pumpen (elektronisch kommutierte Motoren <100W) +> haben große Eingangskondensatoren, die beim Schließen des Relais +> kurzzeitig einen hohen Ladestrom (10–20A fĂŒr Mikrosekunden) ziehen. +> Über viele Schaltzyklen kann dieser kapazitive Einschaltstrom +> Relaiskontakte mikro-verschweißen. Ein **RC-Snubber** (100nF + 100Ω, +> **X2-Kondensator**) parallel zum Relaiskontakt dĂ€mpft Abschalt- +> Transienten/Lichtbögen, begrenzt aber **nicht** den Einschaltstrom +> beim Schließen — er kann das Verschweißen durch kapazitive Lasten +> nicht verhindern. FĂŒr ECM-Pumpen die **SchĂŒtz-Schaltung** unten +> verwenden oder einen Einschaltstrombegrenzer (NTC) / ein fĂŒr +> kapazitive Lasten ausgelegtes Relais. +> +> Die SchĂŒtz-Lösung ist fĂŒr grĂ¶ĂŸere Pumpen (>300W) oder induktive +> Lasten >2A Dauerstrom vorgesehen. + ## Übersicht Dieses Dokument erklĂ€rt, warum die eingebauten Relais des NORVI AE01-R (und @@ -49,14 +64,14 @@ trennt nicht mehr — die Pumpe lĂ€uft **dauerhaft**, auch wenn der Controller ### Feldbericht -In einer realen Installation trat dieser Fehler nacheinander an zwei -verschiedenen RelaisausgĂ€ngen (zuerst R1, dann R5) desselben NORVI-Moduls auf, -nachdem diese jeweils die Solarpumpe (300–600W) schalteten. Der -gemeinsame Faktor war **nicht** das Relais oder der Treiber, sondern die -**Pumpe selbst** als Last. Das baugleiche R0 (Poolpumpe) zeigte keine -AusfĂ€lle — likely weil die Poolpumpe anders aufgebaut ist -(Kondensatormotor mit Sanftanlauf, seltener geschaltet, oder geringere -Leistung). +In einer Feldinstallation trat dieser Fehler an zwei verschiedenen +RelaisausgĂ€ngen (R1, dann R5) desselben NORVI AE01-R auf, beide mit +derselben Solarpumpe. Die Pumpe entpuppte sich als **6–28W ECM-Typ** +(Lowara Artiga ECO AUTO+) — weit innerhalb der Relais-Spezifikation, trotzdem +verschweißten die Kontakte. Ursache ist der kapazitive Einschaltstrom der +Eingangskondensatoren (~10–20A Spitze fĂŒr Mikrosekunden pro Schaltvorgang). +Das baugleiche R0 (Poolpumpe, Speck BADU Magic II/6 450W Asynchronmotor) +zeigte keinen Ausfall. --- @@ -67,17 +82,19 @@ robustes Relais in Hutschienenbauweise. Das NORVI-Relais schaltet nur noch die **Steuerspule des SchĂŒtzes** (einige mA bei 24V DC) — die Hauptkontakte des SchĂŒtzes schalten die 230V-Last. -``` - NORVI-Relais SchĂŒtz (extern) Pumpe - ┌──────────┐ ┌──────────────────┐ ┌────────┐ - │ │ 24V DC │ A1 (+) │ │ │ - │ COM ─────┌─────────── (Spule) │ │ │ - │ │ │ A2 (-) ──┌── GND│ │ - │ NO ─────┌──────┐ │ │ │ │ - └──────────┘ │ │ 1 (L-Eingang) ─────── 2 │ │ - │ └──────────────────┘ │ L-N │ - 230V L ───────────┘ └────────┘ - 230V N ───────────────────────────────────────────┘ + ``` + NORVI-Relais SchĂŒtz (extern) Pumpe + ┌──────────┐ ┌──────────────────┐ ┌────────┐ + │ │ 24V DC │ A1 (+) │ │ │ + │ COM ─────┌─────────── (Spule) │ │ │ + │ │ │ A2 (-) ──┌── GND│ │ + │ NO ─────┌──────┐ │ │ │ │ + └──────────┘ │ │ 1 (L-Eingang) ─────── 2 │ │ + │ └──────────────────┘ │ L-N │ + 24V DC (+) ───────┘ └────────┘ + 230V L (via SchĂŒtz) ──────────────────────────────┘ + 230V N ──────────────────────────────────────────────┘ + GND ───────────────────────────────────────────────────┘ ``` ### Vorteile @@ -136,10 +153,10 @@ Wenn du maximale Platzersparnis brauchst: ``` 24V DC (+) ──┬── NORVI R0 COM ── NO ──┬── SchĂŒtz Pool A1 ── A2 ──┬── GND │ │ │ - ├── NORVI R5 COM ── NO ───── SchĂŒtz Solar A1 ── A2 ─── + ├── NORVI R2 COM ── NO ───── SchĂŒtz Solar A1 ── A2 ─── │ │ │ └───── NORVI 24V IN ─────┘ │ - │ + │ GND ─────────────────────────────────────────────────────┘ ``` @@ -198,9 +215,9 @@ Amazon. **Schritt 1: 24V-Steuerkreis** 1. **24V DC (+) an NORVI-Relais COM** — BrĂŒcken von NORVI-24V-Klemme zu - COM-R0 und COM-R5 + COM-R0 und COM-R2 2. **NORVI-Relais NO an SchĂŒtz-A1** — jeweils R0-NO → SchĂŒtz Pool A1, - R5-NO → SchĂŒtz Solar A1 + R2-NO → SchĂŒtz Solar A1 3. **SchĂŒtz-A2 an GND** — beide A2 auf GND-Sammelschiene 4. **Freilaufdiode** — 1N4007 parallel zu A1/A2 jedes SchĂŒtzes (Kathode an A1, Anode an A2) diff --git a/docs/contactor-guide.md b/docs/contactor-guide.md index 504ff157..9877862c 100644 --- a/docs/contactor-guide.md +++ b/docs/contactor-guide.md @@ -12,6 +12,19 @@ menu: weight: 65 --- +> **⚠ Note:** ECM pumps (electronically commutated motors <100W) have +> large input capacitors that cause a brief high charging peak (10–20A +> for microseconds) each time the relay closes. Over many cycles this +> capacitive inrush can micro-weld relay contacts. An **RC snubber** +> (100nF + 100Ω, **X2-rated capacitor**) across the relay contact +> suppresses turn-off transients/arcs, but it does **not** limit the +> closing inrush — it cannot prevent contact welding on capacitive +> loads. For ECM pumps use the **contactor** wiring below, or an +> inrush limiter (NTC) / a relay rated for capacitive loads. +> +> The contactor solution below is intended for larger pumps (>300W) or +> inductive loads where running current exceeds 2A. + ## Overview This document explains why the built-in relays of the NORVI AE01-R (and other @@ -48,12 +61,13 @@ the controller showing OFF. ### Field Report -In one field installation this failure occurred sequentially on two different -relay outputs (R1, then R5) of the same NORVI unit, both switching the same -solar pump (300–600W). The common factor was **the pump load**, not the relay -or driver circuit. The identical R0 channel (pool pump) showed no failure — -likely because the pool pump is a different type (capacitor-start with soft -start, switched less frequently, or lower power). +In one field installation this failure occurred on two different relay outputs +(R1, then R5) of the same NORVI AE01-R, both switching the same solar pump. +The pump turned out to be a **6–28W ECM type** (Lowara Artiga ECO AUTO+) — +well within the relay rating, yet contacts welded. The cause is capacitive +inrush from the ECM's input capacitors (~10–20A peak for microseconds each +switch-on). The identical R0 channel (pool pump, Speck BADU Magic II/6 450W +asynchronous motor) showed no failure. --- @@ -63,17 +77,19 @@ A **contactor** is a heavy-duty relay built for motor loads. The NORVI relay switches only the **contactor's coil** (a few mA at 24V DC) — the contactor's main contacts handle the 230V AC load. -``` - NORVI Relay Contactor (external) Pump - ┌──────────┐ ┌────────────────────┐ ┌────────┐ - │ │ 24V DC │ A1 (+) │ │ │ - │ COM ─────┌─────────── (coil) │ │ │ - │ │ │ A2 (-) ───┌── GND│ │ - │ NO ─────┌──────┐ │ │ │ │ - └──────────┘ │ │ 1 (L-input) ─────────── 2 │ │ - │ └────────────────────┘ │ L-N │ - 230V L ───────────┘ └────────┘ - 230V N ─────────────────────────────────────────────┘ + ``` + NORVI Relay Contactor (external) Pump + ┌──────────┐ ┌────────────────────┐ ┌────────┐ + │ │ 24V DC │ A1 (+) │ │ │ + │ COM ─────┌─────────── (coil) │ │ │ + │ │ │ A2 (-) ───┌── GND│ │ + │ NO ─────┌──────┐ │ │ │ │ + └──────────┘ │ │ 1 (L-input) ─────────── 2 │ │ + │ └────────────────────┘ │ L-N │ + 24V DC (+) ───────┘ └────────┘ + 230V L (via contactor) ─────────────────────────────┘ + 230V N ─────────────────────────────────────────────────┘ + GND ────────────────────────────────────────────────────┘ ``` ### Benefits @@ -129,13 +145,13 @@ For maximum space saving: ### 24V Control Circuit ``` -24V DC (+) ──┬── NORVI R0 COM ── NO ──┬── Contactor Pool A1 ── A2 ──┬── GND - │ │ │ - ├── NORVI R5 COM ── NO ───── Contactor Solar A1 ── A2 ─── - │ │ │ - └───── NORVI 24V IN ─────┘ │ - │ -GND ───────────────────────────────────────────────────────────────┘ + 24V DC (+) ──┬── NORVI R0 COM ── NO ──┬── Contactor Pool A1 ── A2 ──┬── GND + │ │ │ + ├── NORVI R2 COM ── NO ───── Contactor Solar A1 ── A2 ─── + │ │ │ + └───── NORVI 24V IN ─────┘ │ + │ + GND ───────────────────────────────────────────────────────────────┘ ``` **Flyback diodes 1N4007** across each contactor coil: @@ -184,12 +200,12 @@ Always protect the 230V side with an RCD (30mA) and MCB (B10A or appropriate). ### 2. Wiring -**Step 1: 24V Control Circuit** + **Step 1: 24V Control Circuit** 1. **24V DC (+) to NORVI relay COM** — bridge from NORVI 24V terminal to - COM-R0 and COM-R5 + COM-R0 and COM-R2 2. **NORVI relay NO to contactor A1** — R0-NO → Pool contactor A1, - R5-NO → Solar contactor A1 + R2-NO → Solar contactor A1 3. **Contactor A2 to GND** — both A2 to GND bus 4. **Flyback diode** — 1N4007 across A1/A2 of each contactor (cathode to A1, anode to A2) diff --git a/docs/norvi-ae01-r.de.md b/docs/norvi-ae01-r.de.md index 9d41b3d8..14a95921 100644 --- a/docs/norvi-ae01-r.de.md +++ b/docs/norvi-ae01-r.de.md @@ -59,7 +59,7 @@ Pinbelegung ausgewĂ€hlt, die die belegten und reinen-Eingang-Pins vermeidet: | `PIN_DS_SOLAR` | **GPIO25** | DS18B20 — Solar-Kollektortemperatur | Erweiterungsport Pin 1 | | `PIN_DS_POOL` | **GPIO25** | DS18B20 — Pool-Wassertemperatur | Shared Bus auf GPIO25 (Erweiterungsport) | | `PIN_RELAY_POOL` | **GPIO14** (R0) | Relais — Pool-UmwĂ€lzpumpe | Relaisausgang 0 | -| `PIN_RELAY_SOLAR` | **GPIO33** (R5) | Relais — Solarheizungspumpe | Relaisausgang 5 | +| `PIN_RELAY_SOLAR` | **GPIO13** (R2) | Relais — Solarheizungspumpe (R1→R5→R2) | Relaisausgang 2 | | `PIN_LED_STATUS` | **GPIO27** (T0.1) | Status-LED (extern, ĂŒber Transistorausgang) | Transistorausgang 0.1 | | `PIN_OLED_SDA` | **GPIO16** | I2C SDA — eingebautes 0,96" OLED (SSD1306) | Intern (I2C) | | `PIN_OLED_SCL` | **GPIO17** | I2C SCL — eingebautes 0,96" OLED (SSD1306) | Intern (I2C) | @@ -210,33 +210,39 @@ kommt zum Einsatz. Ein 4,7kΩ-Widerstand zwischen DATA (Pin 1) und 3,3V (Pin 7) reicht. Dupont-Kabel: 3× Female-Female (rot/gelb/schwarz). - ─── RELAISAUSGÄNGE (eingebaut) ─────────────────────────────────────── + ─── RELAISAUSGÄNGE (eingebaut) ─────────────────────────────────────── ╔══════════════════════════════════════════════════════════════════════╗ - ║ ⚠ WICHTIG — Grenze bei Motorlast ║ + ║ ⚠ WICHTIG — Motorlast beachten ║ ║ ║ - ║ Die NORVI-Bordrelais sind fĂŒr **5A ohmsche Last** ausgelegt ║ - ║ (GlĂŒhlampen, Heizungen, Signallasten). Eine **Pumpe ist eine ║ - ║ induktive Motorlast** mit 5–10× höherem Einschaltstrom, der die ║ - ║ Relaiskontakte nach einigen hundert Schaltspielen verschweißt. ║ + ║ Die NORVI-Bordrelais sind fĂŒr **5A ohmsche Last** ausgelegt. ║ + ║ Eine Pumpe ist eine Motorlast mit Einschaltstrom, der Relais- ║ + ║ kontakte nach wiederholtem Schalten verschweißen kann. ║ ║ ║ - ║ **Standard-Konfiguration:** Die NORVI-Relais ausschließlich als ║ - ║ **Steuerrelais fĂŒr externe Hutschienen-SchĂŒtze** verwenden. Das ║ - ║ SchĂŒtz schaltet die 230V-Pumpe; das NORVI-Relais schaltet nur die ║ - ║ 24V-Spule des SchĂŒtzes (~20mA). Das ist verscheißfrei fĂŒr die ║ - ║ gesamte Lebensdauer der Anlage. ║ + ║ **Standard-Konfiguration:** NORVI-Relais als **Steuerrelais fĂŒr ║ + ║ externe Hutschienen-SchĂŒtze** verwenden (siehe SchĂŒtz-Schaltung ║ + ║ unten). Das ist verscheißfrei fĂŒr die gesamte Lebensdauer. ║ + ║ ║ + ║ **FĂŒr Kleinstpumpen (<100W, z. B. ECM-Solarpumpen):** direkt ║ + ║ verdrahten mit **RC-Snubber** (100nF + 100Ω, X2) dĂ€mpft Abschalt- ║ + ║ Lichtbögen, begrenzt aber **nicht** den Einschaltstrom — zur ║ + ║ Vermeidung von Verschweißung das **SchĂŒtz** (oder NTC) verwenden. ║ ║ ║ ║ Siehe → **[SchĂŒtz-Schaltung fĂŒr Pumpen](contactor-guide.de.md)** ║ ╚══════════════════════════════════════════════════════════════════════╝ - NORVI Relaisausgang 0 (GPIO14): Steuersignal → SchĂŒtz Pool A1 - NORVI Relaisausgang 5 (GPIO33): Steuersignal → SchĂŒtz Solar A1 + NORVI Relaisausgang 0 (GPIO14): Steuerung → Pool-Pumpe (via SchĂŒtz oder direkt <100W) + NORVI Relaisausgang 2 (GPIO13): Steuerung → Solar-Pumpe (via SchĂŒtz oder direkt mit Einschaltstrombegrenzer) + + L (Außenleiter) ─── RCD ─── MCB ──┬── Relais COM0 ── NO0 ── Pumpe L + └── Relais COM2 ── NO2 ── Pumpe L + N (Neutral) ──────────────────────────── Neutralleiter ── Pumpen N ── Standard-Verdrahtung (mit SchĂŒtzen) ───────────────────────────── 24V DC (+) ──┬── NORVI R0 COM ── NO ──┬── Ext. SchĂŒtz Pool A1 ── A2 ──┬── GND │ │ │ - ├── NORVI R5 COM ── NO ───── Ext. SchĂŒtz Solar A1 ── A2 ─── + ├── NORVI R2 COM ── NO ───── Ext. SchĂŒtz Solar A1 ── A2 ─── │ │ │ └───── NORVI 24V IN ─────┘ │ │ @@ -249,40 +255,32 @@ kommt zum Einsatz. └── SchĂŒtz Pool 1-2 ─── Poolpumpe L N (Neutral) ──────────────────────────── Neutralleiter ── Beide Pumpen N - ── Alternative: Direktverdrahtung (nur ohmsche Last) ──────────────── + ── Alternative: Direktverdrahtung (ohmsche / Kleinstlast) ────────── - Falls du **keine Motorlast** schaltest (HeizstĂ€be, LED-Beleuchtung, - Ventile, Signallampen), können die NORVI-Relais direkt verdrahtet - werden. Sie sind Schließer (SPST), ausgelegt fĂŒr 5A/250V AC. + FĂŒr **Nicht-Motorlasten** (HeizstĂ€be, Ventile, Signallampen) oder + **Kleinst-EcmPumpen (<100W) mit Einschaltstrombegrenzer** können die + NORVI-Relais direkt verdrahtet werden. Sie sind Schließer (SPST), 5A/250V AC. - L (Außenleiter) ─── RCD ─── MCB ──┬── NORVI COM0 ── NO0 ── Ohmsche Last - └── NORVI COM5 ── NO5 ── Ohmsche Last + L (Außenleiter) ─── RCD ─── MCB ──┬── NORVI COM0 ── NO0 ── Last + └── NORVI COM2 ── NO2 ── Last N (Neutral) ──────────────────────────── Neutralleiter ── Last N - > **Schließe Pumpen oder andere induktive Motorlasten niemals direkt - > an die NORVI-Relais an.** Die Kontakte werden unter dem - > Einschaltstrom versagen (verschweißen). Verwende externe SchĂŒtze - > wie oben gezeigt. + > **FĂŒr Pumpen (Motorlasten):** Externe SchĂŒtze wie oben verwenden. + > Direktverdrahtung von Motorlasten fĂŒhrt zu Kontaktverschweißung; + > ein RC-Snubber verhindert das nicht. ── Feldbericht ────────────────────────────────────────────────────── - In einer Feldinstallation wurde eine Solarpumpe (300–600W) zuerst an - Relaisausgang 1 (GPIO12, R1) betrieben. Nach einigen hundert - SchaltvorgĂ€ngen hatte R1 einen verschweißten Kontakt — das Relais - klickte hörbar, aber der NO-Kontakt öffnete nie. Die Pumpe wurde - auf Relaisausgang 5 (GPIO33, R5) umgeklemmt, der Wochen spĂ€ter - denselben Fehler zeigte. Relaisausgang 0 (GPIO14, Poolpumpe) - arbeitete wĂ€hrenddessen fehlerfrei, weil die Poolpumpe andere - Motoreigenschaften hat (geringerer Einschaltstrom, Kondensator- - oder Sanftanlauf). - - **Gemeinsamer Faktor: die Motorlast der Solarpumpe, nicht die - Relais-Hardware.** - - Siehe die [SchĂŒtz-Schaltung fĂŒr Pumpen](contactor-guide.de.md) fĂŒr - die dauerhaft zuverlĂ€ssige Lösung. - - ⚡ Relais-PolaritĂ€t: Im Gegensatz zu externen Relaismodulen (die typischerweise + In einer Feldinstallation wurde eine Solarpumpe an Relaisausgang 1 + (GPIO12, R1) betrieben. Nach einigen hundert Schaltspielen hatte R1 + einen verschweißten Kontakt — Relais hörbar, aber NO-Kontakt öffnete + nie. Umverdrahtung auf R5 (GPIO33) zeigte denselben Fehler. R0 + (GPIO14, Poolpumpe) lief weiter (andere Motorkennlinie). Die + Solarpumpe ist jetzt auf R2 (GPIO13) konfiguriert. FĂŒr diese Last ist + ein **SchĂŒtz** (oder Einschaltstrombegrenzer) empfohlen — ein RC-Snubber + parallel zu den R2-Kontakten verhindert das Verschweißen nicht. + Im Gegensatz zu externen Relaismodulen (die + typischerweise active-LOW sind: LOW = EIN, HIGH = AUS) sind die eingebauten NORVI-Relais **active-HIGH**: `HIGH` auf dem GPIO-Pin erregt die Relaisspule (Schließer schließt), `LOW` entregt sie (Schließer öffnet). @@ -381,7 +379,7 @@ der ESP32 werden gemeinsam aus dieser Spannung versorgt. | **Relais-Strom** | 5V an Relaismodul | Integriert (24V → Relaisspulen) | | **Relais-PolaritĂ€t** | Active-LOW (LOW = EIN) | **Active-HIGH** (HIGH = EIN) | | **Sensor-Pins** | GPIO32, GPIO33 | GPIO25 (Exp Port), GPIO5 (löten) | -| **Relais-Pins** | GPIO25, GPIO26 | GPIO14 (R0), GPIO33 (R5) | +| **Relais-Pins** | GPIO25, GPIO26 | GPIO14 (R0), GPIO13 (R2) | | **Status-LED** | Eingebaut (GPIO2) | Extern ĂŒber GPIO27 (T0.1) | | **OLED-Display** | Keines | Eingebaut 0,96" (SSD1306) — 4 Info-Seiten + QR-Code | | **Drucktaster** | BOOT-Taster (GPIO0) | 3 Fronttaster — Seiten & Modi | @@ -437,12 +435,12 @@ pio run -e norvi_ae01_r -t uploadfs | :----: | ------------------------------------------ | :--------------------------: | | 0 | NRST (PWM beim Boot) | ❌ | | 1 | RS-485 TX (mit USB geteilt) | ❌ | -| 2 | Relaisausgang 4 / Eingebaute LED | ❌ | +| **2** | **Relaisausgang 4 / Eingebaute LED** | ❌ | | 3 | RS-485 RX (mit USB geteilt) | ❌ | | 4 | RS-485 Flusskontrolle | ❌ | | **5** | **— (freier GPIO, kein NORVI-Peripherie)** | ✅ **DS18B20 Pool (löten)** | -| 12 | Relaisausgang 1 (vermeiden — siehe Hinweis oben) | ❌ | -| 13 | Relaisausgang 2 | ❌ | +| 12 | Relaisausgang 1 (verschweißt — R2 verwenden) | ❌ | +| **13** | **Relaisausgang 2** | ✅ **Solar-Pumpe (R2)** | | 14 | **Relaisausgang 0** | ✅ Pool-Pumpe | | 15 | Relaisausgang 3 | ❌ | | 16 | **I2C SDA** (OLED-Display) | ✅ OLED-Display | @@ -457,7 +455,7 @@ pio run -e norvi_ae01_r -t uploadfs | 26 | Transistorausgang 0.0 | ❌ (frei) | | 27 | **Transistorausgang 0.1** | ✅ Status-LED (extern) | | **32** | **Analogeingang / Taster** | ✅ 3 Fronttaster | -| **33** | **Relaisausgang 5** | ✅ Solar-Pumpe | +| 33 | Relaisausgang 5 | ❌ | | 34 | Digitaleingang 2 (nur Eingang) | ❌ | | 35 | Digitaleingang 3 (nur Eingang) | ❌ | | 38 | — | ❌ | diff --git a/docs/norvi-ae01-r.md b/docs/norvi-ae01-r.md index 3ede5040..a38f43d5 100644 --- a/docs/norvi-ae01-r.md +++ b/docs/norvi-ae01-r.md @@ -58,7 +58,7 @@ pin mapping that avoids the occupied and input-only pins: | `PIN_DS_SOLAR` | **GPIO25** | DS18B20 — Solar collector temperature | Expansion Port Pin 1 | | `PIN_DS_POOL` | **GPIO25** | DS18B20 — Pool water temperature | Shared bus on GPIO25 (expansion) | | `PIN_RELAY_POOL` | **GPIO14** (R0) | Relay — Pool circulation pump | Relay Output 0 | -| `PIN_RELAY_SOLAR` | **GPIO33** (R5) | Relay — Solar heating pump | Relay Output 5 | +| `PIN_RELAY_SOLAR` | **GPIO13** (R2) | Relay — Solar heating pump (moved: R1→R5→R2) | Relay Output 2 | | `PIN_LED_STATUS` | **GPIO27** (T0.1) | Status LED (external, via transistor output) | Transistor Output 0.1 | | `PIN_OLED_SDA` | **GPIO16** | I2C SDA — built-in 0.96" OLED (SSD1306) | Internal (I2C) | | `PIN_OLED_SCL` | **GPIO17** | I2C SCL — built-in 0.96" OLED (SSD1306) | Internal (I2C) | @@ -211,33 +211,39 @@ to the default device index. One 4.7kΩ resistor between DATA (Pin 1) and 3.3V (Pin 7) is enough. Dupont cables: 3× Female-Female (red/yellow/black). - ─── RELAY OUTPUTS (built-in) ───────────────────────────────────────── + ─── RELAY OUTPUTS (built-in) ───────────────────────────────────────── ╔══════════════════════════════════════════════════════════════════════╗ - ║ ⚠ IMPORTANT — Motor Load Limitation ║ + ║ ⚠ IMPORTANT — Motor Load Consideration ║ ║ ║ - ║ The NORVI built-in relays are rated for **5A resistive load** ║ - ║ (light bulbs, heaters, signal loads). A **pump is an inductive ║ - ║ motor load** with 5–10× higher inrush current that can weld the ║ - ║ relay contacts after a few hundred switching cycles. ║ + ║ The NORVI built-in relays are rated for **5A resistive load**. ║ + ║ A pump is a motor load with inrush current that can weld relay ║ + ║ contacts after repeated switching. ║ ║ ║ - ║ **Standard configuration:** Use the NORVI relays exclusively as ║ - ║ **control relays for external DIN-rail contactors.** The contactor ║ - ║ switches the 230V pump load; the NORVI relay switches only the ║ - ║ contactor's 24V DC coil (~20mA). This is zero-wear on the NORVI ║ - ║ relays and fully reliable for the life of the installation. ║ + ║ **Standard configuration:** Use the NORVI relays as **control ║ + ║ relays for external DIN-rail contactors** (see Contactor Wiring ║ + ║ Guide below). This is zero-wear on the NORVI relays. ║ + ║ ║ + ║ **For low-power pumps (<100W, e.g. ECM solar pumps):** direct ║ + ║ wiring with an **RC snubber** (100nF + 100Ω, X2-rated) suppresses ║ + ║ turn-off arcs but does **not** limit the closing inrush — use the ║ + ║ **contactor** solution (or an inrush limiter) to avoid welding. ║ ║ ║ ║ See → **[Contactor Wiring Guide](contactor-guide.md)** ║ ╚══════════════════════════════════════════════════════════════════════╝ - NORVI Relay Output 0 (GPIO14): Control signal → Pool contactor coil - NORVI Relay Output 5 (GPIO33): Control signal → Solar contactor coil + NORVI Relay Output 0 (GPIO14): Control → Pool pump (via contactor or direct <100W) + NORVI Relay Output 2 (GPIO13): Control → Solar pump (via contactor or direct with inrush limiter) + + L (mains) ─── RCD ─── MCB ──┬── Relay COM0 ── NO0 ── Pump L + └── Relay COM2 ── NO2 ── Pump L + N (neutral) ───────────────────────── Neutral bar ── Pump N ── Standard wiring (contactors) ───────────────────────────────────── 24V DC (+) ──┬── NORVI R0 COM ── NO ──┬── Ext. Contactor Pool A1 ── A2 ──┬── GND │ │ │ - ├── NORVI R5 COM ── NO ───── Ext. Contactor Solar A1 ── A2 ─── + ├── NORVI R2 COM ── NO ───── Ext. Contactor Solar A1 ── A2 ─── │ │ │ └───── NORVI 24V IN ─────┘ │ │ @@ -250,35 +256,31 @@ to the default device index. └── Contactor Pool 1-2 ─── Pool Pump L N (neutral) ───────────────────────── Neutral bar ── Both Pumps N - ── Alternative: direct wiring (resistive loads only) ───────────────── + ── Alternative: direct wiring (resistive/low-power loads) ─────────── - If you are switching **non-motor loads** (heating elements, LED - lighting, valves, signal lamps), the NORVI relays can be wired - directly. They are Normally Open (SPST), rated 5A/250V AC. + For **non-motor loads** (heating elements, valves, signal lamps) or + **low-power pumps with low inrush** (e.g. 6–28W ECM with an inrush + limiter), the NORVI relays can be wired directly. They are Normally + Open (SPST), rated 5A/250V AC. - L (mains) ─── RCD ─── MCB ──┬── NORVI COM0 ── NO0 ── Resistive Load - └── NORVI COM5 ── NO5 ── Resistive Load + L (mains) ─── RCD ─── MCB ──┬── NORVI COM0 ── NO0 ── Load + └── NORVI COM2 ── NO2 ── Load N (neutral) ───────────────────────── Neutral bar ── Load N - > **Do not wire pumps or any inductive motor load directly to the - > NORVI relays.** The contacts will fail (weld closed) under the - > inrush current. Use external contactors as shown above. + > **For pumps (motor loads):** Use external contactors as shown above. + > Direct wiring of motor loads will eventually weld the relay + > contacts; an RC snubber does not prevent this. ── Field report ───────────────────────────────────────────────────── - In one field installation, a solar pump (300–600W) was first connected - to Relay Output 1 (GPIO12, R1). After a few hundred switching cycles, - R1 developed a welded contact — the relay clicked audibly but the NO - contact never opened. The pump was then rewired to Relay Output 5 - (GPIO33, R5), which had the same failure weeks later. Relay Output 0 - (GPIO14, pool pump) continued working throughout because the pool - pump has a different motor characteristic (lower inrush, capacitor - start, or soft-start). - - **Common factor: the solar pump motor load, not the relay hardware.** - - See the [Contactor Wiring Guide](contactor-guide.md) for the - permanently reliable solution. + In one field installation, a solar pump was connected to Relay Output 1 + (GPIO12, R1). After a few hundred switching cycles, R1 developed a + welded contact — the relay clicked audibly but the NO contact never + opened. Subsequent rewiring to R5 (GPIO33) failed identically. R0 + (GPIO14, pool pump) continued working (different motor characteristic). + The pump is now configured for R2 (GPIO13). A contactor (or inrush + limiter) is recommended for this load — an RC snubber across the + relay contacts does not prevent capacitive-inrush welding. ⚡ Relay polarity: Unlike standard external relay modules (which are typically active-LOW: LOW = ON, HIGH = OFF), the NORVI AE01-R built-in @@ -376,7 +378,7 @@ ESP32 are both powered from this single supply. | **Relay power** | 5V to relay module | Integrated (24V → relay coils) | | **Relay polarity** | Active-LOW (LOW = ON) | **Active-HIGH** (HIGH = ON) | | **Sensor pins** | GPIO32, GPIO33 | GPIO25 (Exp Port), GPIO5 (solder) | -| **Relay pins** | GPIO25, GPIO26 | GPIO14 (R0), GPIO33 (R5) | +| **Relay pins** | GPIO25, GPIO26 | GPIO14 (R0), GPIO13 (R2) | | **Status LED** | Built-in (GPIO2) | External via GPIO27 (T0.1) | | **OLED display** | None | Built-in 0.96" (SSD1306) — 4 info pages + QR code | | **Push buttons** | BOOT button (GPIO0) | 3 front buttons — cycle pages & modes | @@ -431,12 +433,12 @@ pio run -e norvi_ae01_r -t uploadfs | :----: | -------------------------------------- | :--------------------------: | | 0 | NRST (outputs PWM at boot) | ❌ | | 1 | RS-485 TX (shared with USB) | ❌ | -| 2 | Relay Output 4 / Built-in LED | ❌ | +| **2** | **Relay Output 4 / Built-in LED** | ❌ | | 3 | RS-485 RX (shared with USB) | ❌ | | 4 | RS-485 Flow Control | ❌ | | **5** | **— (free GPIO, no NORVI peripheral)** | ✅ **DS18B20 Pool (solder)** | -| 12 | Relay Output 1 (avoid — see note above) | ❌ | -| 13 | Relay Output 2 | ❌ | +| 12 | Relay Output 1 (welded — use R2 instead) | ❌ | +| **13** | **Relay Output 2** | ✅ **Solar pump (R2)** | | 14 | **Relay Output 0** | ✅ Pool pump | | 15 | Relay Output 3 | ❌ | | 16 | **I2C SDA** (OLED display) | ✅ OLED display | @@ -451,7 +453,7 @@ pio run -e norvi_ae01_r -t uploadfs | 26 | Transistor Output 0.0 | ❌ (free) | | 27 | **Transistor Output 0.1** | ✅ Status LED (external) | | **32** | **Analog Input / Buttons** | ✅ 3 front-panel buttons | -| **33** | **Relay Output 5** | ✅ Solar pump | +| 33 | Relay Output 5 | ❌ | | 34 | Digital Input 2 (input only) | ❌ | | 35 | Digital Input 3 (input only) | ❌ | | 38 | — | ❌ | diff --git a/docs/wiring-contactor.svg b/docs/wiring-contactor.svg index 1f74aa88..9a353f97 100644 --- a/docs/wiring-contactor.svg +++ b/docs/wiring-contactor.svg @@ -51,7 +51,7 @@ GPIO14 (R0) - GPIO33 (R5) + GPIO13 (R2) 24V IN @@ -62,23 +62,23 @@ Interne NORVI-Relais (SPST-NO, active-HIGH) R0 = GPIO14 → Pool - R5 = GPIO33 → Solar + R2 = GPIO13 → Solar - + - + 24V+ - R5 COM + R2 COM - + - R5 NO + R2 NO @@ -177,7 +177,7 @@ - + @@ -203,7 +203,7 @@ SOLAR PUMPE - 300–600W + 6–28W ECM diff --git a/src/Config.hpp b/src/Config.hpp index 49bc4cd4..3cae31df 100644 --- a/src/Config.hpp +++ b/src/Config.hpp @@ -83,31 +83,14 @@ constexpr std::uint8_t PIN_RELAY_POOL{14}; /** * @brief Relay control pin — solar heating pump. * - * Moved from Relay Output 1 (GPIO12) to Relay Output 5 (GPIO33): a field - * unit's R1 channel was found to be permanently conducting (relay audibly - * clicks on setSwitch(), but COM1/NO1 never opens) — a hardware fault - * (welded/fused contact or NO/NC miswiring), not a firmware issue: R0 uses - * the identical RelayModuleNode logic and switches correctly. R5/GPIO33 is - * otherwise unused on NORVI builds. - * - * @note When changing the relay output for the solar pump (e.g. from R1 to R5), - * the NVS state ("solar-pump"/"switch") from the previous relay pin - * carries over and may cause the new relay to energize at boot. The - * RelayModuleNode::begin() safe-start sequence mitigates this by forcing - * the GPIO to OFF before enabling output, then transitioning to the - * persisted state. If the pump runs despite the controller showing OFF - * after a pin change, clear NVS manually: - * pio run -e norvi_ae01_r -t exec -- prefs erase solar-pump - * Or add a one-time NVS clear by calling preferences.clear() on the - * "solar-pump" namespace during the migration boot. + * Relay Output 2 (GPIO13, R2) — moved from R5 (GPIO33) to keep R5 free. + * R1 (GPIO12) is blocked by static_assert: that channel had a welded + * contact under this pump load (capacitive inrush from ECM pump). */ -constexpr std::uint8_t PIN_RELAY_SOLAR{33}; +constexpr std::uint8_t PIN_RELAY_SOLAR{13}; static_assert(PIN_RELAY_SOLAR != 12, - "PIN_RELAY_SOLAR must not be reverted to GPIO12 (Relay Output 1) without " - "confirming the field hardware fault is resolved — see the doc comment " - "above and docs/norvi-ae01-r.md for context. R1 was found permanently " - "conducting (welded/fused contact or NO/NC miswiring) on at least one " - "field unit; Relay Output 0 uses identical firmware logic and works."); + "PIN_RELAY_SOLAR must not be reverted to GPIO12 (Relay Output 1) — " + "that channel had a welded contact under this pump load."); /** @brief Status LED — external LED via transistor output 0.1 (open-collector, 100 mA max). */ constexpr std::uint8_t PIN_LED_STATUS{27}; /** @brief Optional warning LED — not used on NORVI. */ diff --git a/src/StatusLed.cpp b/src/StatusLed.cpp index b28f904b..19c2c655 100644 --- a/src/StatusLed.cpp +++ b/src/StatusLed.cpp @@ -30,8 +30,9 @@ int8_t StatusLed::warnPin_ = PIN_LED_WARN; // ── Initialisierung ──────────────────────────────────────────────────────── void StatusLed::begin() { - // ModellunabhĂ€ngiger Pin — ĂŒberschreibe Config.hpp-Default, falls die - // Platform einen spezifischen LED_BUILTIN definiert. + // Reload from config — handles board-specific overrides (e.g. NORVI GPIO27). + ledPin_ = PIN_LED_STATUS; + #ifdef LED_BUILTIN // Override Config.hpp default only when it uses the generic value (GPIO2), // meaning no board-specific config set a different pin. This lets NORVI From 206954babbe94858c5de4b51cceebfb70d78307b Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Thu, 13 Aug 2026 19:07:23 +0200 Subject: [PATCH 12/25] fix(button): Add ADC filtering and improved debouncing for NORVI front panel buttons (#177) --- src/NorviButtonHandler.cpp | 260 +++++++++++++++++++++++++++++-------- src/NorviButtonHandler.hpp | 17 +++ 2 files changed, 222 insertions(+), 55 deletions(-) diff --git a/src/NorviButtonHandler.cpp b/src/NorviButtonHandler.cpp index 78c7e177..bdbcaeb6 100644 --- a/src/NorviButtonHandler.cpp +++ b/src/NorviButtonHandler.cpp @@ -31,6 +31,15 @@ uint32_t NorviButtonHandler::lastChangeMs_ = 0; uint32_t NorviButtonHandler::lastSampleMs_ = 0; uint16_t NorviButtonHandler::lastRaw_ = 0; +// ADC Filtering: moving average over 5 samples (250ms window) with +// fast-attack re-initialization — a genuine press/release jumps the +// window immediately instead of waiting for the average to catch up. +static constexpr uint8_t ADC_FILTER_SIZE = 5; +static uint16_t adcSamples_[ADC_FILTER_SIZE] = {0}; +static uint8_t adcSampleIndex_ = 0; +static uint32_t lastFilteredAdcTime_ = 0; +static uint16_t lastFilteredAdc_ = 0; + NorviButtonHandler::ButtonCallback NorviButtonHandler::cbButton1_ = nullptr; NorviButtonHandler::ButtonCallback NorviButtonHandler::cbButton2_ = nullptr; NorviButtonHandler::ButtonCallback NorviButtonHandler::cbButton3_ = nullptr; @@ -40,6 +49,7 @@ NorviButtonHandler::ButtonLongPressCallback NorviButtonHandler::cbButton2Long_ = NorviButtonHandler::ButtonLongPressCallback NorviButtonHandler::cbButton3Long_ = nullptr; uint32_t NorviButtonHandler::pressStartMs_ = 0; +uint32_t NorviButtonHandler::releasePendingMs_ = 0; // ═══════════════════════════════════════════════════════════════════════════ @@ -72,14 +82,133 @@ void NorviButtonHandler::loop() { lastSampleMs_ = now; // Read ADC - lastRaw_ = analogRead(PIN_BUTTON_ADC); - Button detected = detectButton(lastRaw_); + uint16_t rawAdc = analogRead(PIN_BUTTON_ADC); + + // Enhanced ADC stability check: only accept values that are stable for 150ms + static uint16_t lastStableAdc_ = 0; + static uint32_t lastStableTime_ = 0; + + // Fast-attack: a genuine press/release changes the button range — even + // when the ADC delta is small (no-press 3800 → S3 3700 = 100). Base the + // re-initialization on button-range transitions so every valid press is + // caught within one sample instead of a full moving-average window. The + // transition also restarts the stability window, so a single-sample glitch + // crossing an adjacent range boundary (e.g. 3800 → 3700, delta 100) cannot + // be accepted immediately — it must hold for the full stability period. + Button rawButton = detectButton(rawAdc); + if (rawButton != detectButton(lastFilteredAdc_)) { + for (uint8_t i = 0; i < ADC_FILTER_SIZE; i++) { + adcSamples_[i] = rawAdc; + } + adcSampleIndex_ = 0; + lastStableTime_ = now; + + if (releasePendingMs_ != 0) { + // A press transition interrupts a pending release. If the release + // already persisted for the debounce interval, commit it so the new + // press is tracked as a fresh tap; otherwise treat it as bounce. + if (rawButton != Button::NONE && (now - releasePendingMs_ >= DEBOUNCE_MS)) { + currentButton_ = Button::NONE; + lastChangeMs_ = now; + pressStartMs_ = 0; + LOG_DEBUG("Button released: %d\n", static_cast(currentButton_)); + } + releasePendingMs_ = 0; + } + + // Release observation: a confirmed press released into the no-press + // range is committed after DEBOUNCE_MS instead of the full stability + // window, so rapid consecutive taps are not merged into one press. + if (currentButton_ != Button::NONE && rawButton == Button::NONE) { + releasePendingMs_ = now; + } + } + + // Commit a confirmed release once it has persisted for the debounce + // interval. Runs before the stability gates so it is not delayed by the + // press-stability window. + if (releasePendingMs_ != 0 && (now - releasePendingMs_ >= DEBOUNCE_MS)) { + currentButton_ = Button::NONE; + lastChangeMs_ = now; + pressStartMs_ = 0; + releasePendingMs_ = 0; + LOG_DEBUG("Button released: %d\n", static_cast(currentButton_)); + } + + // Store sample for filtering + adcSamples_[adcSampleIndex_] = rawAdc; + adcSampleIndex_ = (adcSampleIndex_ + 1) % ADC_FILTER_SIZE; + + // Calculate moving average + uint32_t sum = 0; + for (uint8_t i = 0; i < ADC_FILTER_SIZE; i++) { + sum += adcSamples_[i]; + } + uint16_t filteredAdc = static_cast(sum / ADC_FILTER_SIZE); + lastFilteredAdc_ = filteredAdc; + lastFilteredAdcTime_ = now; + + if (abs(static_cast(filteredAdc) - static_cast(lastStableAdc_)) > 100) { + // Significant change detected - reset stability timer + lastStableAdc_ = filteredAdc; + lastStableTime_ = now; + lastRaw_ = filteredAdc; + Button detected = detectButton(filteredAdc); + + // Debug logging for ADC changes + static uint16_t lastDebugAdc_ = 0xFFFF; + if (abs(static_cast(filteredAdc) - static_cast(lastDebugAdc_)) > 100) { + LOG_DEBUG("ADC: raw=%u filtered=%u → %d | THRESH: BTN1=%u-%u BTN2=%u-%u BTN3=%u-%u NO_PRESS=%u\n", rawAdc, filteredAdc, + static_cast(detected), THRESH_BTN1_MIN, THRESH_BTN1_MAX, THRESH_BTN2_MIN, THRESH_BTN2_MAX, THRESH_BTN3_MIN, + THRESH_BTN3_MAX, THRESH_NO_PRESS); + lastDebugAdc_ = filteredAdc; + } + // A pending short press may complete even while the signal changes — + // evaluate the debounce on this reading too. A long-press callback may + // also be due, so check it first (it consumes the press on success). + if (evaluateLongPress(now)) { + return; + } + evaluateShortPress(now); + return; + } + + if (now - lastStableTime_ < 150) { + // Not stable yet - ignore this reading + lastRaw_ = filteredAdc; + Button detected = Button::NONE; + + // Debug logging for unstable ADC + static uint16_t lastDebugAdc_ = 0xFFFF; + if (abs(static_cast(filteredAdc) - static_cast(lastDebugAdc_)) > 100) { + LOG_DEBUG("ADC UNSTABLE: filtered=%u (waiting for stability)\n", filteredAdc); + lastDebugAdc_ = filteredAdc; + } + // Let a pending debounce complete while the ADC re-stabilizes (e.g. + // after a release) — otherwise a short press never fires its callback. + // Evaluate long presses here too: a held button spends most samples in + // this wait, and a release at the LONG_PRESS_MS mark must not swallow + // the callback (e.g. S3 save-and-reboot after a full 2s hold). + if (evaluateLongPress(now)) { + return; + } + evaluateShortPress(now); + return; + } + + // ADC is stable - use filtered value + lastStableAdc_ = filteredAdc; + lastStableTime_ = now; + lastRaw_ = filteredAdc; + Button detected = detectButton(filteredAdc); - // Add debug logging for ADC changes + // Enhanced debug logging for ADC changes static uint16_t lastDebugAdc_ = 0xFFFF; - if (abs(static_cast(lastRaw_) - static_cast(lastDebugAdc_)) > 50) { - LOG_DEBUG("ADC: %u → %d\n", lastDebugAdc_, static_cast(detected)); - lastDebugAdc_ = lastRaw_; + if (abs(static_cast(filteredAdc) - static_cast(lastDebugAdc_)) > 100) { + LOG_DEBUG("ADC: raw=%u filtered=%u → %d | THRESH: BTN1=%u-%u BTN2=%u-%u BTN3=%u-%u NO_PRESS=%u\n", rawAdc, filteredAdc, + static_cast(detected), THRESH_BTN1_MIN, THRESH_BTN1_MAX, THRESH_BTN2_MIN, THRESH_BTN2_MAX, THRESH_BTN3_MIN, + THRESH_BTN3_MAX, THRESH_NO_PRESS); + lastDebugAdc_ = filteredAdc; } // ── Press start tracking ───────────────────────────────────────────── @@ -89,61 +218,24 @@ void NorviButtonHandler::loop() { if (detected != Button::NONE) { pressStartMs_ = now; // Button was just pressed + LOG_DEBUG("Button pressed: %d\n", static_cast(detected)); } else { pressStartMs_ = 0; // Button released + LOG_DEBUG("Button released: %d\n", static_cast(currentButton_)); } return; } // ── Long-press detection (fire once after LONG_PRESS_MS) ───────────── - if (currentButton_ != Button::NONE && pressStartMs_ > 0 && (now - pressStartMs_ >= LONG_PRESS_MS)) { - pressStartMs_ = 0; // Prevent re-firing - - // Fire long-press callbacks; skip short-press if callback consumed it - switch (currentButton_) { - case Button::ONE: - if (cbButton1Long_ && cbButton1Long_()) { - return; - } - break; - case Button::TWO: - if (cbButton2Long_ && cbButton2Long_()) { - return; - } - break; - case Button::THREE: - if (cbButton3Long_ && cbButton3Long_()) { - return; - } - break; - default: - break; - } + if (evaluateLongPress(now)) { + return; // Long-press callback consumed the press } // ── Short-press detection ──────────────────────────────────────────── - // Debounce: only register a change if it persists for DEBOUNCE_MS - if (stableButton_ != currentButton_ && (now - lastChangeMs_ >= DEBOUNCE_MS)) { - stableButton_ = currentButton_; - - // Fire callback on press (not release) - switch (stableButton_) { - case Button::ONE: - if (cbButton1_) - cbButton1_(); - break; - case Button::TWO: - if (cbButton2_) - cbButton2_(); - break; - case Button::THREE: - if (cbButton3_) - cbButton3_(); - break; - default: - break; - } - } + // Debounce: only register a change if it persists for DEBOUNCE_MS. + // Also evaluated on unstable/significant-change readings (see above) so + // a release during re-stabilization cannot swallow the callback. + evaluateShortPress(now); } // ═══════════════════════════════════════════════════════════════════════════ @@ -164,25 +256,83 @@ float NorviButtonHandler::getLongPressProgress() { // ═══════════════════════════════════════════════════════════════════════════ NorviButtonHandler::Button NorviButtonHandler::detectButton(uint16_t raw) { - if (raw >= THRESH_NO_PRESS) { + // Use the supplied ADC value for detection. + uint16_t adcValue = raw; + + if (adcValue >= THRESH_NO_PRESS) { return Button::NONE; } - if (raw >= THRESH_BTN1_MIN && raw <= THRESH_BTN1_MAX) { + if (adcValue >= THRESH_BTN1_MIN && adcValue <= THRESH_BTN1_MAX) { return Button::ONE; } - if (raw >= THRESH_BTN2_MIN && raw <= THRESH_BTN2_MAX) { + if (adcValue >= THRESH_BTN2_MIN && adcValue <= THRESH_BTN2_MAX) { return Button::TWO; } - if (raw >= THRESH_BTN3_MIN && raw <= THRESH_BTN3_MAX) { + if (adcValue >= THRESH_BTN3_MIN && adcValue <= THRESH_BTN3_MAX) { return Button::THREE; } return Button::NONE; } +void NorviButtonHandler::evaluateShortPress(uint32_t now) { + // Debounce: only register a change if it persists for DEBOUNCE_MS. + if (stableButton_ != currentButton_ && (now - lastChangeMs_ >= DEBOUNCE_MS)) { + stableButton_ = currentButton_; + + // Fire callback on press (not release) + switch (stableButton_) { + case Button::ONE: + if (cbButton1_) + cbButton1_(); + break; + case Button::TWO: + if (cbButton2_) + cbButton2_(); + break; + case Button::THREE: + if (cbButton3_) + cbButton3_(); + break; + default: + break; + } + } +} + +bool NorviButtonHandler::evaluateLongPress(uint32_t now) { + // Fire the long-press callback once after LONG_PRESS_MS. + if (currentButton_ != Button::NONE && pressStartMs_ > 0 && (now - pressStartMs_ >= LONG_PRESS_MS)) { + pressStartMs_ = 0; // Prevent re-firing + + // Fire long-press callbacks; report whether the callback consumed the + // press (short press must then be skipped). + switch (currentButton_) { + case Button::ONE: + if (cbButton1Long_ && cbButton1Long_()) { + return true; + } + break; + case Button::TWO: + if (cbButton2Long_ && cbButton2Long_()) { + return true; + } + break; + case Button::THREE: + if (cbButton3Long_ && cbButton3Long_()) { + return true; + } + break; + default: + break; + } + } + return false; +} + } // namespace PoolController #endif // NORVI_AE01_R diff --git a/src/NorviButtonHandler.hpp b/src/NorviButtonHandler.hpp index 3998d0e4..0cc53775 100644 --- a/src/NorviButtonHandler.hpp +++ b/src/NorviButtonHandler.hpp @@ -117,6 +117,18 @@ class NorviButtonHandler { */ static Button detectButton(uint16_t raw); + /** + * @brief Fire the short-press callback once the debounce interval elapsed. + * Runs on every sample once a press is pending — including while the ADC + * re-stabilizes after a release — so a short press is not swallowed by + * the release stabilization path. + */ + static void evaluateShortPress(uint32_t now); + + /// Fires the long-press callback once after LONG_PRESS_MS. + /// @return true if the callback consumed the press (skip short press). + static bool evaluateLongPress(uint32_t now); + /// Debounce interval (ms) — ignores samples within this window. static constexpr uint32_t DEBOUNCE_MS{80}; @@ -154,6 +166,11 @@ class NorviButtonHandler { /// Timestamp when the current button was first pressed. static uint32_t pressStartMs_; + /// Timestamp when a confirmed press was first observed released (ms). + /// Non-zero while the release is being debounced; the release is + /// committed to `currentButton_` after DEBOUNCE_MS. + static uint32_t releasePendingMs_; + // ── ADC thresholds (12-bit, 0–4095) ────────────────────────────────── // These are typical ranges for the NORVI AE01-R resistor ladder. // Adjust if needed based on serial debug output. From cdf4083098adbc2ee9034d6755a89e3d93e3276e Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 23:28:54 +0200 Subject: [PATCH 13/25] fix(button): stop phantom NORVI operation-mode switches (#181) --- data/web/app.js | 42 ++++++++++++++++++++++++- data/web/index.html | 48 +++++++++++++++++++++++++++++ src/ConfigManager.cpp | 21 +++++++++++++ src/ConfigManager.hpp | 9 +++++- src/NorviButtonHandler.cpp | 28 ++++++++++++++++- src/NorviButtonHandler.hpp | 45 ++++++++++++++++++--------- src/WebPortal.cpp | 31 +++++++++++++++++++ test/native/CMakeLists.txt | 1 + test/native/mocks/ConfigManager.hpp | 7 +++++ 9 files changed, 214 insertions(+), 18 deletions(-) diff --git a/data/web/app.js b/data/web/app.js index 1997d9cf..80777e2b 100644 --- a/data/web/app.js +++ b/data/web/app.js @@ -553,6 +553,13 @@ function validateSettings() { { id: 'tempCircThreshold', name: 'Circ. Temp Threshold', min: 0, max: 40, type: 'float' }, { id: 'tempCircFactor', name: 'Circ. Temp Factor', min: 0, max: 120, type: 'int' }, { id: 'tempCircMaxRuntime', name: 'Circ. Max Runtime', min: 60, max: 1440, type: 'int' }, + { id: 'btn1Min', name: 'Button 1 Min ADC', min: 0, max: 4095, type: 'int' }, + { id: 'btn1Max', name: 'Button 1 Max ADC', min: 0, max: 4095, type: 'int' }, + { id: 'btn2Min', name: 'Button 2 Min ADC', min: 0, max: 4095, type: 'int' }, + { id: 'btn2Max', name: 'Button 2 Max ADC', min: 0, max: 4095, type: 'int' }, + { id: 'btn3Min', name: 'Button 3 Min ADC', min: 0, max: 4095, type: 'int' }, + { id: 'btn3Max', name: 'Button 3 Max ADC', min: 0, max: 4095, type: 'int' }, + { id: 'btnNoPress', name: 'No-Press Threshold', min: 0, max: 4096, type: 'int' }, ]; for (const f of fields) { const el = document.getElementById(f.id); @@ -572,6 +579,25 @@ function validateSettings() { return false; } } + // Button ADC thresholds must form coherent, non-overlapping ranges + const btnVal = (id) => parseInt(document.getElementById(id).value, 10); + const b1Min = btnVal('btn1Min'), b1Max = btnVal('btn1Max'); + const b2Min = btnVal('btn2Min'), b2Max = btnVal('btn2Max'); + const b3Min = btnVal('btn3Min'), b3Max = btnVal('btn3Max'); + if (b1Min >= b1Max || b2Min >= b2Max || b3Min >= b3Max) { + alert('Each button Min must be less than its Max.'); + return false; + } + if (b1Max > b2Min || b2Max > b3Min) { + alert('Button ADC ranges must not overlap.'); + return false; + } + // No-press threshold must sit above every button range, otherwise + // detectButton() checks THRESH_NO_PRESS first and masks those readings as NONE. + if (btnVal('btnNoPress') <= b3Max) { + alert('No-Press Threshold must be above all button ranges.'); + return false; + } return true; } @@ -589,6 +615,13 @@ async function saveControllerSettings() { const circFactor = document.getElementById('tempCircFactor').value; const circMaxRuntime = document.getElementById('tempCircMaxRuntime').value; const tz = document.getElementById('timezone').value; + const btn1Min = document.getElementById('btn1Min').value; + const btn1Max = document.getElementById('btn1Max').value; + const btn2Min = document.getElementById('btn2Min').value; + const btn2Max = document.getElementById('btn2Max').value; + const btn3Min = document.getElementById('btn3Min').value; + const btn3Max = document.getElementById('btn3Max').value; + const btnNoPress = document.getElementById('btnNoPress').value; // Validate time fields included alongside pool fields const green = parseInt(document.getElementById('timeLossGreen').value, 10); @@ -608,7 +641,7 @@ async function saveControllerSettings() { const res = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: 'type=settings&mode=' + mode + '&interval=' + interval + '&max_pool=' + maxPool + '&min_solar=' + minSolar + '&hysteresis=' + hysteresis + '&circ_threshold=' + circThreshold + '&circ_factor=' + circFactor + '&circ_max_runtime=' + circMaxRuntime + '&timezone=' + tz + '&green=' + green + '&red=' + red + timerParams() + '&ntp_server=' + ntpServer + body: 'type=settings&mode=' + mode + '&interval=' + interval + '&max_pool=' + maxPool + '&min_solar=' + minSolar + '&hysteresis=' + hysteresis + '&circ_threshold=' + circThreshold + '&circ_factor=' + circFactor + '&circ_max_runtime=' + circMaxRuntime + '&timezone=' + tz + '&green=' + green + '&red=' + red + timerParams() + '&ntp_server=' + ntpServer + '&btn1_min=' + btn1Min + '&btn1_max=' + btn1Max + '&btn2_min=' + btn2Min + '&btn2_max=' + btn2Max + '&btn3_min=' + btn3Min + '&btn3_max=' + btn3Max + '&btn_no_press=' + btnNoPress }); if (res.status === 200) { document.getElementById('poolThreshold').textContent = 'max ' + parseFloat(maxPool).toFixed(1) + '°C'; @@ -729,6 +762,13 @@ async function loadConfig() { document.getElementById('ntpServer').value = data.ntp.server; document.getElementById('timeLossGreen').value = data.settings.time_loss_green_hours; document.getElementById('timeLossRed').value = data.settings.time_loss_red_hours; + document.getElementById('btn1Min').value = data.settings.btn1_min; + document.getElementById('btn1Max').value = data.settings.btn1_max; + document.getElementById('btn2Min').value = data.settings.btn2_min; + document.getElementById('btn2Max').value = data.settings.btn2_max; + document.getElementById('btn3Min').value = data.settings.btn3_min; + document.getElementById('btn3Max').value = data.settings.btn3_max; + document.getElementById('btnNoPress').value = data.settings.btn_no_press; const pad2 = (n) => n.toString().padStart(2, '0'); document.getElementById('timerStart').value = pad2(data.settings.timer_start_hour) + ':' + pad2(data.settings.timer_start_min); document.getElementById('timerEnd').value = pad2(data.settings.timer_end_hour) + ':' + pad2(data.settings.timer_end_min); diff --git a/data/web/index.html b/data/web/index.html index 09cccd07..2e2d309e 100644 --- a/data/web/index.html +++ b/data/web/index.html @@ -307,6 +307,54 @@

đŸŒĄïž Temperature-Based Circulation

+

🔘 Button Thresholds (NORVI)

+
+
+ + + Lower bound of Button 1 range · Measured: ~3275–3456 +
+
+ + + Upper bound of Button 1 range +
+
+
+
+ + + Lower bound of Button 2 range · Measured: ~3579–3765 +
+
+ + + Upper bound of Button 2 range +
+
+
+
+ + + Lower bound of Button 3 range · Measured: ~4095 (full scale) +
+
+ + + Upper bound of Button 3 range +
+
+
+
+ + + Values above this are ignored · 4096 = no-op (S3 reads full scale) +
+ +
+

⏱ Timer Schedule

diff --git a/src/ConfigManager.cpp b/src/ConfigManager.cpp index a50b6078..0c557be1 100644 --- a/src/ConfigManager.cpp +++ b/src/ConfigManager.cpp @@ -68,6 +68,13 @@ static constexpr const char *kSetRed = "set_red"; static constexpr const char *kSetCircThresh = "set_circth"; static constexpr const char *kSetCircFactor = "set_circfa"; static constexpr const char *kSetCircMax = "set_circmx"; +static constexpr const char *kSetBtn1Min = "set_btn1min"; +static constexpr const char *kSetBtn1Max = "set_btn1max"; +static constexpr const char *kSetBtn2Min = "set_btn2min"; +static constexpr const char *kSetBtn2Max = "set_btn2max"; +static constexpr const char *kSetBtn3Min = "set_btn3min"; +static constexpr const char *kSetBtn3Max = "set_btn3max"; +static constexpr const char *kSetBtnNoPress = "set_btnnop"; static constexpr const char *kAdmPass = "adm_pass"; static constexpr const char *kCfgConfigured = "cfg_configured"; @@ -108,6 +115,13 @@ bool ConfigManager::load() { settings_.tempCircThreshold = prefs.getDouble(kSetCircThresh, 24.0); settings_.tempCircFactor = prefs.getUShort(kSetCircFactor, 30); settings_.tempCircMaxRuntime = prefs.getUShort(kSetCircMax, 720); + settings_.btn1Min = prefs.getUShort(kSetBtn1Min, 3100); + settings_.btn1Max = prefs.getUShort(kSetBtn1Max, 3520); + settings_.btn2Min = prefs.getUShort(kSetBtn2Min, 3520); + settings_.btn2Max = prefs.getUShort(kSetBtn2Max, 3880); + settings_.btn3Min = prefs.getUShort(kSetBtn3Min, 3880); + settings_.btn3Max = prefs.getUShort(kSetBtn3Max, 4095); + settings_.btnNoPress = prefs.getUShort(kSetBtnNoPress, 4096); adminPasswordHash_ = prefs.getString(kAdmPass, kDefaultPasswordHash); configured_ = prefs.getBool(kCfgConfigured, false); @@ -147,6 +161,13 @@ bool ConfigManager::save() { prefs.putDouble(kSetCircThresh, settings_.tempCircThreshold); prefs.putUShort(kSetCircFactor, settings_.tempCircFactor); prefs.putUShort(kSetCircMax, settings_.tempCircMaxRuntime); + prefs.putUShort(kSetBtn1Min, settings_.btn1Min); + prefs.putUShort(kSetBtn1Max, settings_.btn1Max); + prefs.putUShort(kSetBtn2Min, settings_.btn2Min); + prefs.putUShort(kSetBtn2Max, settings_.btn2Max); + prefs.putUShort(kSetBtn3Min, settings_.btn3Min); + prefs.putUShort(kSetBtn3Max, settings_.btn3Max); + prefs.putUShort(kSetBtnNoPress, settings_.btnNoPress); prefs.putString(kAdmPass, adminPasswordHash_); prefs.putBool(kCfgConfigured, configured_); diff --git a/src/ConfigManager.hpp b/src/ConfigManager.hpp index 4865bd2d..4849432a 100644 --- a/src/ConfigManager.hpp +++ b/src/ConfigManager.hpp @@ -45,7 +45,14 @@ struct ControllerSettings { String opMode = "auto"; long timeLossGreenHours = 1; long timeLossRedHours = 24; - int timezoneIndex = 0; ///< Index into TimeClientHelper timezone table + int timezoneIndex = 0; ///< Index into TimeClientHelper timezone table + uint16_t btn1Min = 3100; ///< NORVI button 1 ADC range min + uint16_t btn1Max = 3520; ///< NORVI button 1 ADC range max + uint16_t btn2Min = 3520; ///< NORVI button 2 ADC range min + uint16_t btn2Max = 3880; ///< NORVI button 2 ADC range max + uint16_t btn3Min = 3880; ///< NORVI button 3 ADC range min + uint16_t btn3Max = 4095; ///< NORVI button 3 ADC range max + uint16_t btnNoPress = 4096; ///< NORVI no-press threshold (no-op, > ADC max) }; /** diff --git a/src/NorviButtonHandler.cpp b/src/NorviButtonHandler.cpp index bdbcaeb6..d6a6a681 100644 --- a/src/NorviButtonHandler.cpp +++ b/src/NorviButtonHandler.cpp @@ -19,6 +19,7 @@ #include #include "Config.hpp" +#include "ConfigManager.hpp" #include "LogCapture.hpp" namespace PoolController { @@ -51,6 +52,16 @@ NorviButtonHandler::ButtonLongPressCallback NorviButtonHandler::cbButton3Long_ = uint32_t NorviButtonHandler::pressStartMs_ = 0; uint32_t NorviButtonHandler::releasePendingMs_ = 0; +// ADC thresholds — defaults are the 2026-08-16 calibrated values; begin() +// overwrites them from ConfigManager (NVS) so they are user-configurable. +uint16_t NorviButtonHandler::THRESH_BTN1_MIN{3100}; +uint16_t NorviButtonHandler::THRESH_BTN1_MAX{3520}; +uint16_t NorviButtonHandler::THRESH_BTN2_MIN{3520}; +uint16_t NorviButtonHandler::THRESH_BTN2_MAX{3880}; +uint16_t NorviButtonHandler::THRESH_BTN3_MIN{3880}; +uint16_t NorviButtonHandler::THRESH_BTN3_MAX{4095}; +uint16_t NorviButtonHandler::THRESH_NO_PRESS{4096}; + // ═══════════════════════════════════════════════════════════════════════════ void NorviButtonHandler::begin() { @@ -58,6 +69,8 @@ void NorviButtonHandler::begin() { pinMode(PIN_BUTTON_ADC, INPUT); + applySettings(); + // Take an initial sample to let the ADC stabilise analogRead(PIN_BUTTON_ADC); delay(10); @@ -72,6 +85,19 @@ void NorviButtonHandler::begin() { // ═══════════════════════════════════════════════════════════════════════════ +void NorviButtonHandler::applySettings() { + // Load configurable ADC thresholds from NVS (defaults = calibrated values) + THRESH_BTN1_MIN = ConfigManager::getSettings().btn1Min; + THRESH_BTN1_MAX = ConfigManager::getSettings().btn1Max; + THRESH_BTN2_MIN = ConfigManager::getSettings().btn2Min; + THRESH_BTN2_MAX = ConfigManager::getSettings().btn2Max; + THRESH_BTN3_MIN = ConfigManager::getSettings().btn3Min; + THRESH_BTN3_MAX = ConfigManager::getSettings().btn3Max; + THRESH_NO_PRESS = ConfigManager::getSettings().btnNoPress; +} + +// ═══════════════════════════════════════════════════════════════════════════ + void NorviButtonHandler::loop() { const uint32_t now = millis(); @@ -89,7 +115,7 @@ void NorviButtonHandler::loop() { static uint32_t lastStableTime_ = 0; // Fast-attack: a genuine press/release changes the button range — even - // when the ADC delta is small (no-press 3800 → S3 3700 = 100). Base the + // when the ADC delta is small (no-press ~2700 → S1 ~3400 = 700). Base the // re-initialization on button-range transitions so every valid press is // caught within one sample instead of a full moving-average window. The // transition also restarts the stability window, so a single-sample glitch diff --git a/src/NorviButtonHandler.hpp b/src/NorviButtonHandler.hpp index 0cc53775..fd2ab3b8 100644 --- a/src/NorviButtonHandler.hpp +++ b/src/NorviButtonHandler.hpp @@ -36,11 +36,11 @@ namespace PoolController { * ~50 ms intervals, applies hysteresis for noise rejection, and fires * callbacks on press events. * - * Default thresholds (12-bit ADC, 0–4095): - * Button 1: 200 – 1200 - * Button 2: 1400 – 2500 - * Button 3: 2700 – 3700 - * No press: > 3800 + * Calibrated thresholds (12-bit ADC, 0–4095) from live measurements: + * No press: < 3100 (resting level oscillates ~2610–2990) + * Button 1: 3100 – 3520 + * Button 2: 3520 – 3880 + * Button 3: 3880 – 4095 */ class NorviButtonHandler { public: @@ -62,6 +62,13 @@ class NorviButtonHandler { */ static void begin(); + /** + * @brief Reload ADC thresholds from ConfigManager (NVS). + * Called after settings changes so new thresholds apply to the running + * handler without a reboot. + */ + static void applySettings(); + /** * @brief Sample and debounce buttons. * Must be called regularly from PoolController::loop(). @@ -172,16 +179,24 @@ class NorviButtonHandler { static uint32_t releasePendingMs_; // ── ADC thresholds (12-bit, 0–4095) ────────────────────────────────── - // These are typical ranges for the NORVI AE01-R resistor ladder. - // Adjust if needed based on serial debug output. - - static constexpr uint16_t THRESH_BTN1_MIN{200}; - static constexpr uint16_t THRESH_BTN1_MAX{1200}; - static constexpr uint16_t THRESH_BTN2_MIN{1400}; - static constexpr uint16_t THRESH_BTN2_MAX{2500}; - static constexpr uint16_t THRESH_BTN3_MIN{2700}; - static constexpr uint16_t THRESH_BTN3_MAX{3700}; - static constexpr uint16_t THRESH_NO_PRESS{3800}; + // Calibrated from live measurements on the NORVI AE01-R (2026-08-16): + // No press: ~2610–2990 (oscillates; must map to NONE) + // Button 1: ~3275–3456 + // Button 2: ~3579–3765 + // Button 3: ~4095 (full scale) + // The resistor ladder pulls the ADC pin UP toward VCC on press. + // Boundaries sit at the midpoints between adjacent levels. + // THRESH_NO_PRESS is a no-op (4096 > ADC max) — S3 reads full scale. + // Values are loaded from ConfigManager (NVS) in begin(); the defaults + // below are the calibrated values and can be overridden via the web UI. + + static uint16_t THRESH_BTN1_MIN; + static uint16_t THRESH_BTN1_MAX; + static uint16_t THRESH_BTN2_MIN; + static uint16_t THRESH_BTN2_MAX; + static uint16_t THRESH_BTN3_MIN; + static uint16_t THRESH_BTN3_MAX; + static uint16_t THRESH_NO_PRESS; }; } // namespace PoolController diff --git a/src/WebPortal.cpp b/src/WebPortal.cpp index c49c2b8e..8fa4a59c 100644 --- a/src/WebPortal.cpp +++ b/src/WebPortal.cpp @@ -40,6 +40,10 @@ #include "Version.h" #include "LogCapture.hpp" +#ifdef NORVI_AE01_R +#include "NorviButtonHandler.hpp" +#endif + namespace PoolController { // File-scoped state for streaming LittleFS upload (used by handleFsUploadStream) @@ -666,6 +670,13 @@ void WebPortal::apiGetConfig() { settingsObj["timezone"] = ConfigManager::getSettings().timezoneIndex; settingsObj["time_loss_green_hours"] = ConfigManager::getSettings().timeLossGreenHours; settingsObj["time_loss_red_hours"] = ConfigManager::getSettings().timeLossRedHours; + settingsObj["btn1_min"] = ConfigManager::getSettings().btn1Min; + settingsObj["btn1_max"] = ConfigManager::getSettings().btn1Max; + settingsObj["btn2_min"] = ConfigManager::getSettings().btn2Min; + settingsObj["btn2_max"] = ConfigManager::getSettings().btn2Max; + settingsObj["btn3_min"] = ConfigManager::getSettings().btn3Min; + settingsObj["btn3_max"] = ConfigManager::getSettings().btn3Max; + settingsObj["btn_no_press"] = ConfigManager::getSettings().btnNoPress; settingsObj["timer_start_hour"] = operationModeNode.getTimerSetting().timerStartHour; settingsObj["timer_start_min"] = operationModeNode.getTimerSetting().timerStartMinutes; settingsObj["timer_end_hour"] = operationModeNode.getTimerSetting().timerEndHour; @@ -750,12 +761,32 @@ void WebPortal::apiSaveConfig() { ConfigManager::getSettings().tempCircFactor = server_.arg("circ_factor").toInt(); if (server_.hasArg("circ_max_runtime")) ConfigManager::getSettings().tempCircMaxRuntime = server_.arg("circ_max_runtime").toInt(); + if (server_.hasArg("btn1_min")) + ConfigManager::getSettings().btn1Min = server_.arg("btn1_min").toInt(); + if (server_.hasArg("btn1_max")) + ConfigManager::getSettings().btn1Max = server_.arg("btn1_max").toInt(); + if (server_.hasArg("btn2_min")) + ConfigManager::getSettings().btn2Min = server_.arg("btn2_min").toInt(); + if (server_.hasArg("btn2_max")) + ConfigManager::getSettings().btn2Max = server_.arg("btn2_max").toInt(); + if (server_.hasArg("btn3_min")) + ConfigManager::getSettings().btn3Min = server_.arg("btn3_min").toInt(); + if (server_.hasArg("btn3_max")) + ConfigManager::getSettings().btn3Max = server_.arg("btn3_max").toInt(); + if (server_.hasArg("btn_no_press")) + ConfigManager::getSettings().btnNoPress = server_.arg("btn_no_press").toInt(); ConfigManager::getSettings().timezoneIndex = server_.arg("timezone").toInt(); ConfigManager::getSettings().timeLossGreenHours = server_.arg("green").toInt(); ConfigManager::getSettings().timeLossRedHours = server_.arg("red").toInt(); ConfigManager::save(); +#ifdef NORVI_AE01_R + // Apply button thresholds to the running handler immediately so the + // new values take effect without a reboot (P2 review fix). + NorviButtonHandler::applySettings(); +#endif + // Apply timezone change to running clock immediately (P2) setTimezoneIndex(ConfigManager::getSettings().timezoneIndex); diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index 8454fc4d..84f4238c 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -58,6 +58,7 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/LocalSettingsMenu.cpp ${PROJ_ROOT}/src/Ky040Decoder.cpp ${PROJ_ROOT}/src/OlimexEncoderHandler.cpp + ${PROJ_ROOT}/src/NorviButtonHandler.cpp ) # Mock sources (compiled once) diff --git a/test/native/mocks/ConfigManager.hpp b/test/native/mocks/ConfigManager.hpp index 6f3d216e..d1f2300e 100644 --- a/test/native/mocks/ConfigManager.hpp +++ b/test/native/mocks/ConfigManager.hpp @@ -20,6 +20,13 @@ struct Settings { float tempCircThreshold = 24.0f; int tempCircFactor = 30; int tempCircMaxRuntime = 720; + uint16_t btn1Min = 3100; + uint16_t btn1Max = 3520; + uint16_t btn2Min = 3520; + uint16_t btn2Max = 3880; + uint16_t btn3Min = 3880; + uint16_t btn3Max = 4095; + uint16_t btnNoPress = 4096; }; struct WiFiConfig { From c4fc499f458d20010e525050c7832c4dfa817675 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Mon, 17 Aug 2026 12:05:31 +0200 Subject: [PATCH 14/25] feat(calibration): guided NORVI button calibration wizard (#182) * docs: add NORVI button calibration design spec * docs(calibration): add implementation plan for button calibration * feat(calibration): add CalibrationManager state machine with measurement * test(calibration): cover threshold computation and error paths * feat(calibration): suppress button actions during calibration * feat(calibration): add REST endpoints for calibration wizard * feat(calibration): add guided calibration wizard to web UI * style(calibration): apply clang-format to CalibrationManager * style(calibration): fix clang-format and trailing newlines for CI * docs(calibration): fix trailing newlines for editorconfig CI * fix(calibration): accept exact minimum gap and reject release levels The wait-phase predicate required a reading strictly above previousLevel + MIN_LEVEL_GAP and also accepted readings far below it. Hardware whose adjacent button levels sit exactly 100 ADC counts apart could therefore never advance past the wait phase, while releasing a button (ADC dropping back toward the resting level) was mistaken for the next button level and produced non-ascending thresholds. Accept only readings at least MIN_LEVEL_GAP above the previous level (inclusive), matching the validation boundary in computeThresholds(). Addresses PR #182 review comments. * fix(calibration): space ADC samples across the intended window The sample phase collected all 20 readings within a few loop iterations because it had no time gate, so correlated noise or the transient right after a button press could be averaged into a calibrated level. Gate each sample on SAMPLE_INTERVAL_MS so the readings span the documented one-second window. Addresses PR #182 review comment. * fix(calibration): preserve settings in memory when persistence fails The new thresholds were written into the live Settings before ConfigManager::save() ran, so a failed save left the failed values active in memory even though calibration reported ERROR. Snapshot the previous thresholds, commit the new ones, and restore the snapshot when save() fails so the old thresholds stay untouched. Addresses PR #182 review comment. * fix(buttons): suppress callbacks until release after calibration When calibration reaches DONE while Button 3 is still held, isActive() immediately becomes false and the handler starts processing that same held press, firing the S3 callback (which can toggle the pool pump) before the UI can report completion. Arm callback suppression on the calibration-finish transition, reset press tracking, and keep callbacks disabled until the ADC returns to the no-press range for the debounce interval. Addresses PR #182 review comment. * fix(web): hide calibration wizard on non-NORVI firmware The LittleFS web assets are shared across all build environments, but the /api/calibrate/* routes are compiled out unless NORVI_AE01_R is defined. Report NORVI support in /api/status and hide the calibration button on targets without it, so the wizard is no longer exposed with a start request that always 404s. Addresses PR #182 review comment. * fix(calibration): restart sampling when the level stops matching The sample phase accumulated all 20 readings without validating them, so releasing the button or a large ADC transition mid-window produced a mixed average (e.g. ten S1 readings at 3400 plus ten resting readings at 2700 yield 3050) that could pass the final checks and misclassify the real button level. Reject any sample that leaves the stability window around the level accepted in the wait phase and restart the measurement. Addresses PR #182 review comment. * fix(web): resume an already-running calibration After a page reload or a lost start response the calibration stays active on the device, so the start request returns 409 and the UI only alerted. Reopen the wizard modal and resume polling on 409 so the user can watch progress or cancel the orphaned calibration instead of being stuck with disabled front-panel buttons. Addresses PR #182 review comment. * fix(web): avoid duplicate calibration polling loops Starting the wizard twice before the first request completed created two polling intervals, overwriting calibPollTimer and leaving an orphan loop that kept calling loadConfig() after DONE. Clear an existing timer before starting a new poll loop. Addresses PR #182 review comment. * fix(web): keep the wizard open when cancellation is rejected When the session expired, handleAuthentication() serves the login page with HTTP 200, so the cancel request was treated as success and the modal closed while calibration kept running with suppressed callbacks. Verify an actual API response (non-HTML content type) before closing and surface re-authentication instead. Addresses PR #182 review comment. * fix(calibration): revalidate the averaged level before advancing Samples may drift within the stability window and yield an average below previousLevel + MIN_LEVEL_GAP, which later failed the sanity check in computeThresholds() and aborted the entire wizard. Validate the averaged level against the previous measurement before storing it; on failure the step retries instead of advancing. The value is only written to the status once validation passes. Addresses PR #182 review comment. * fix(web): reject login HTML from calibration API responses handleAuthentication() serves the login page with HTTP 200 on session expiry, so the start request was treated as success and the wizard opened while the device stayed idle. Verify the response is an actual calibration API response before opening, and stop the status poller instead of parsing the login HTML as JSON. Addresses PR #182 review comment. * feat(web): guide calibration wizard through clear step-by-step flow Make the wizard's flow obvious from the moment it opens: - Always-visible overview line: four measurements run one after another, keep the button pressed until its step turns green. - 'Next:' line under the current instruction names the upcoming step (hold Button 1/2/3, then auto-computed thresholds). - Start button hint on the dashboard states the expected sequence and duration. - Keep every status text readable: new messages only replace the current one after a 1.2 s minimum display time. - DONE now shows a brief green success state (1.5 s) before the modal closes, instead of closing instantly. - Live ADC meter bar, numbered step progress with checkmarks, and a color-coded waiting/measuring/retry state chip (previous iteration). --- data/web/app.js | 201 ++++ data/web/index.html | 41 + data/web/style.css | 131 ++ .../2026-08-16-norvi-button-calibration.md | 1070 +++++++++++++++++ ...6-08-16-norvi-button-calibration-design.md | 161 +++ src/CalibrationManager.cpp | 368 ++++++ src/CalibrationManager.hpp | 102 ++ src/NorviButtonHandler.cpp | 71 +- src/NorviButtonHandler.hpp | 9 + src/PoolController.cpp | 3 + src/WebPortal.cpp | 57 + src/WebPortal.hpp | 6 + test/native/CMakeLists.txt | 16 +- test/native/mocks/ConfigManager.cpp | 1 + test/native/mocks/ConfigManager.hpp | 3 +- .../native/tests/test_calibration_manager.cpp | 355 ++++++ test/native/tests/test_main.cpp | 2 + test/native/tests/test_webportal_json.cpp | 32 + 18 files changed, 2618 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-16-norvi-button-calibration.md create mode 100644 docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md create mode 100644 src/CalibrationManager.cpp create mode 100644 src/CalibrationManager.hpp create mode 100644 test/native/tests/test_calibration_manager.cpp diff --git a/data/web/app.js b/data/web/app.js index 80777e2b..2a58a31a 100644 --- a/data/web/app.js +++ b/data/web/app.js @@ -61,6 +61,13 @@ async function loadTelemetry() { document.getElementById('solarThreshold').textContent = 'min ' + data.temp_min_solar.toFixed(1) + '°C'; } + // NORVI capability: hide the calibration wizard on non-NORVI firmware + // (the /api/calibrate/* routes are compiled out there). + const calibSection = document.getElementById('calibrationSection'); + if (calibSection) { + calibSection.style.display = data.norvi ? '' : 'none'; + } + // Pumpen — Toggle-Switches aktualisieren if (data.pool_pump != null) { setPumpSwitch('pool', data.pool_pump); @@ -651,6 +658,200 @@ async function saveControllerSettings() { } } +// ── Button Calibration Wizard ── + +let calibPollTimer = null; + +function showCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'flex'; +} + +function closeCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'none'; + if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; } + if (calibCloseTimer) { clearTimeout(calibCloseTimer); calibCloseTimer = null; } + calibMsgPending = null; + calibMsgShownAt = 0; +} + +function startCalibrationPolling() { + // Guard against duplicate poll loops when the start button is activated + // twice before the first request completes. + if (calibPollTimer) { clearInterval(calibPollTimer); } + calibPollTimer = setInterval(pollCalibrationStatus, 500); + pollCalibrationStatus(); +} + +async function startCalibration() { + const res = await fetch('/api/calibrate/start', { method: 'POST' }); + // handleAuthentication() serves the login page with HTTP 200 when the + // session expired, so verify an actual API response first. + const type = res.headers.get('content-type') || ''; + if (type.includes('text/html')) { + showLoginForm(); + alert('Session expired — please log in again.'); + return; + } + if (res.status === 409) { + // Calibration is already running on the device (e.g. after a page + // reload or a lost start response) — resume the running wizard so the + // user can watch progress or cancel it instead of being stuck. + showCalibrationModal(); + startCalibrationPolling(); + return; + } + if (!res.ok) { alert('Calibration could not be started.'); return; } + showCalibrationModal(); + startCalibrationPolling(); +} + +// ── Calibration wizard UI state ── + +const CALIB_STEP_HEADLINES = { + 1: 'Release all buttons', + 2: 'Press and hold Button 1', + 3: 'Press and hold Button 2', + 4: 'Press and hold Button 3', + 5: 'Calibration complete', + 6: 'Calibration failed' +}; + +const CALIB_NEXT_UP = { + 1: 'Next: hold Button 1', + 2: 'Next: hold Button 2', + 3: 'Next: hold Button 3', + 4: 'Then the levels are computed and saved automatically.' +}; + +// Keep every status text readable: a new message is only shown once the +// previous one has been on screen for at least MIN_MSG_MS milliseconds. +const CALIB_MIN_MSG_MS = 1200; +let calibMsgShownAt = 0; +let calibMsgPending = null; +let calibCloseTimer = null; + +function showCalibrationMessage(text) { + const el = document.getElementById('calibStepText'); + if (!el) return; + const now = Date.now(); + if (now - calibMsgShownAt < CALIB_MIN_MSG_MS) { + // Current text still on screen — remember the newest one, promote later + calibMsgPending = text; + return; + } + if (calibMsgPending) { text = calibMsgPending; calibMsgPending = null; } + if (text !== el.textContent) { + el.textContent = text; + calibMsgShownAt = now; + } +} + +function calibPhase(message) { + if (!message) return 'waiting'; + if (message.includes('Computing')) return 'saving'; + if (message.includes('sampling')) return 'sampling'; + if (message.includes('try again') || message.includes('too close') || message.includes('changed')) return 'retry'; + if (message.includes('complete')) return 'done'; + return 'waiting'; +} + +function updateCalibrationUi(st) { + const phase = calibPhase(st.message || ''); + const step = st.step; + + // Bold instruction headline for the current action + let headline = CALIB_STEP_HEADLINES[step] || 'Calibration'; + if (phase === 'saving') { + headline = 'Computing thresholds
'; + } else if (phase === 'retry') { + headline = 'Try again — hold steady'; + } else if (phase === 'sampling' && step >= 2 && step <= 4) { + headline = 'Keep holding Button ' + (step - 1); + } else if (phase === 'sampling' && step === 1) { + headline = 'Hold steady
'; + } + const headlineEl = document.getElementById('calibActionHeadline'); + if (headlineEl) headlineEl.textContent = headline; + + // State chip + const chipLabels = { waiting: 'Waiting', sampling: 'Measuring', saving: 'Saving', retry: 'Retry', done: 'Done', error: 'Error' }; + const chip = document.getElementById('calibStateChip'); + if (chip) { + chip.textContent = chipLabels[phase] || chipLabels.waiting; + chip.className = 'calib-chip calib-chip-' + phase; + } + + // Firmware detail line (throttled so it stays readable) + showCalibrationMessage(st.message || ''); + + // What happens next + const nextUp = document.getElementById('calibNextUp'); + if (nextUp) nextUp.textContent = CALIB_NEXT_UP[step] || ''; + + // Live ADC meter (0-4095) + document.getElementById('calibLiveAdc').textContent = st.live_adc; + const fill = document.getElementById('calibMeterFill'); + if (fill) { + fill.style.width = Math.min(100, Math.round((st.live_adc / 4095) * 100)) + '%'; + fill.classList.toggle('sampling', phase === 'sampling'); + } + + // Step progress: current highlighted, completed marked with a check + const steps = ['calibP0', 'calibP1', 'calibP2', 'calibP3']; + steps.forEach((id, i) => { + const el = document.getElementById(id); + if (!el) return; + const done = (i + 1) < step; + const active = (i + 1) === step; + el.classList.toggle('active', active); + el.classList.toggle('done', done); + const dot = el.querySelector('.calib-step-dot'); + if (dot) dot.textContent = done ? '✓' : (i + 1); + }); +} + +async function pollCalibrationStatus() { + const res = await fetch('/api/calibrate/status'); + if (!res.ok) return; + // The login page is served with HTTP 200 on session expiry — stop + // polling instead of trying to parse HTML as JSON. + const type = res.headers.get('content-type') || ''; + if (type.includes('text/html')) { + if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; } + showLoginForm(); + alert('Session expired — please log in again to continue calibration.'); + return; + } + const st = await res.json(); + updateCalibrationUi(st); + + if (st.step === 5) { // DONE — show the success state briefly, then close + if (!calibCloseTimer) { + calibCloseTimer = setTimeout(() => { + calibCloseTimer = null; + closeCalibrationModal(); + loadConfig(); // refresh threshold fields + }, 1500); + } + } else if (st.step === 6) { // ERROR + closeCalibrationModal(); + alert('Calibration failed: ' + (st.message || 'unknown error')); + } +} + +async function cancelCalibration() { + const res = await fetch('/api/calibrate/cancel', { method: 'POST' }); + // handleAuthentication() serves the login page with HTTP 200 when the + // session expired, so verify an actual API response before closing. + const type = res.headers.get('content-type') || ''; + if (res.ok && !type.includes('text/html')) { + closeCalibrationModal(); + } else { + showLoginForm(); + alert('Session expired — please log in again to cancel calibration.'); + } +} + // ── Save Time Settings (Time Tab) ── async function saveTimeSettings() { diff --git a/data/web/index.html b/data/web/index.html index 2e2d309e..57eec0a7 100644 --- a/data/web/index.html +++ b/data/web/index.html @@ -39,6 +39,38 @@

+
+

🎯 Button Calibration

+

Four measurements run one after another. Keep the button pressed until its step turns green.

+ + +
+ Waiting +
Release all buttons
+

Release all buttons — measuring resting level

+

Next: hold Button 1

+
+ + +
+
1Resting
+
2Button 1
+
3Button 2
+
4Button 3
+
+ + +
+
+
—ADC
+
+ + +
+

+
+
+
+ + About a minute · Release, then press and hold Button 1, 2, 3 in turn +
+ +

⏱ Timer Schedule

diff --git a/data/web/style.css b/data/web/style.css index 62700e84..4fbd5c7a 100644 --- a/data/web/style.css +++ b/data/web/style.css @@ -523,3 +523,134 @@ input:focus, select:focus { color: var(--accent-blue); } #btnClearLogs { outline: none; } + +/* ── Calibration wizard ──────────────────────────────────────────────── */ + +.calib-action { text-align: center; margin-bottom: 1.25rem; } + +.calib-guide { + color: var(--text-muted); + text-align: center; + font-size: 0.85rem; + margin: 0 0 1.25rem; +} + +.calib-nextup { + color: var(--text-muted); + font-size: 0.78rem; + margin: 0.5rem 0 0; + font-style: italic; +} + +.calib-chip { + display: inline-block; + padding: 0.2rem 0.7rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + border: 1px solid var(--panel-border); + color: var(--text-muted); + margin-bottom: 0.6rem; +} + +.calib-chip-sampling { color: #00e5ff; border-color: rgba(0, 229, 255, 0.45); animation: calib-pulse 1.2s ease-in-out infinite; } +.calib-chip-saving { color: #00e5ff; border-color: rgba(0, 229, 255, 0.45); } +.calib-chip-retry { color: #f59e0b; border-color: rgba(245, 158, 11, 0.45); } +.calib-chip-done { color: #4ade80; border-color: rgba(74, 222, 128, 0.45); } +.calib-chip-error { color: #ef4444; border-color: rgba(239, 68, 68, 0.45); } + +@keyframes calib-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +.calib-headline { + font-size: 1.35rem; + font-weight: 700; + color: #e2f0f7; + margin: 0 0 0.35rem; +} + +.calib-detail { color: var(--text-muted); margin: 0; font-size: 0.85rem; } + +.calib-progress { display: flex; justify-content: space-between; margin-bottom: 1.25rem; } + +.calib-step { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; min-width: 0; } + +.calib-step-dot { + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 700; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--panel-border); + color: var(--text-muted); +} + +.calib-step-label { font-size: 0.72rem; color: var(--text-muted); } + +.calib-step.active .calib-step-dot { + background: rgba(0, 229, 255, 0.15); + border-color: #00e5ff; + color: #00e5ff; + box-shadow: 0 0 10px rgba(0, 229, 255, 0.35); +} + +.calib-step.active .calib-step-label { color: #00e5ff; font-weight: 600; } + +.calib-step.done .calib-step-dot { + background: rgba(74, 222, 128, 0.15); + border-color: #4ade80; + color: #4ade80; +} + +.calib-step.done .calib-step-label { color: #4ade80; } + +.calib-meter { margin-bottom: 1.25rem; } + +.calib-meter-bar { + height: 10px; + border-radius: 999px; + overflow: hidden; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--panel-border); +} + +.calib-meter-fill { + height: 100%; + width: 0%; + background: linear-gradient(90deg, #00b4d8, #00e5ff); + transition: width 0.12s linear; +} + +.calib-meter-fill.sampling { animation: calib-pulse 0.9s ease-in-out infinite; } + +.calib-meter-readout { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-top: 0.4rem; + font-size: 0.85rem; +} + +.calib-meter-readout #calibLiveAdc { font-weight: 700; color: #e2f0f7; } +.calib-meter-unit { color: var(--text-muted); } + +.calib-cancel { + width: 100%; + padding: 0.6rem; + background: transparent; + border: 1px solid var(--panel-border); + border-radius: 8px; + color: var(--text-muted); + font-size: 0.85rem; + cursor: pointer; +} + +.calib-cancel:hover { color: #e2f0f7; border-color: rgba(226, 240, 247, 0.35); } diff --git a/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md b/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md new file mode 100644 index 00000000..73ebad78 --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md @@ -0,0 +1,1070 @@ +# NORVI Button Calibration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically. + +**Architecture:** A separate `CalibrationManager` module owns a calibration state machine (IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE/ERROR) with injectable ADC-read and time functions for native testing. `NorviButtonHandler::loop()` early-returns while calibration is active. WebPortal exposes three auth-protected REST endpoints; the web UI shows a modal wizard that polls the status endpoint. + +**Tech Stack:** C++17 (ESP32/Arduino, PlatformIO env `norvi_ae01_r`), ArduinoJson, native CMake test runner (`test/native`), vanilla JS/CSS web assets on LittleFS. + +## Global Constraints + +- All new C++ code is guarded by `#ifdef NORVI_AE01_R` (only compiled for the NORVI variant). +- ADC is 12-bit: valid range 0–4095; `btnNoPress` sentinel stays 4096 (never changed by calibration). +- Minimum gap between adjacent measured levels: 100 ADC. +- Per-step timeout: 10 s; sampling: 20 readings at 50 ms intervals (1 s window), averaged. +- `btn3Max` stays 4095 (full scale); `btnNoPress` stays 4096. +- Thresholds are written to `ConfigManager::getSettings()` (fields `btn1Min`
`btnNoPress`) and persisted via `ConfigManager::save()`. +- REST endpoints are auth-protected via `handleAuthentication()` (same pattern as `/api/config`). +- Web UI: modal dialog pattern follows the existing `loginModal` (inline overlay, `display:flex/none`). +- Native test build: `test/native/CMakeLists.txt` defines `NORVI_AE01_R`; mocks live in `test/native/mocks/` and shadow production headers. +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`). + +--- + +### Task 1: CalibrationManager core — state machine, measurement, hooks + +**Files:** +- Create: `src/CalibrationManager.hpp` +- Create: `src/CalibrationManager.cpp` +- Create: `test/native/tests/test_calibration_manager.cpp` +- Modify: `test/native/CMakeLists.txt` (add `CalibrationManager.cpp` + `NorviButtonHandler.cpp` to `SERVICE_SOURCES`, add test to `TEST_SOURCES`) +- Modify: `test/native/mocks/ConfigManager.hpp` (add btn fields to `Settings` struct) +- Modify: `test/native/tests/test_main.cpp` (register `run_calibration_manager_tests`) + +**Interfaces:** +- Consumes: `ConfigManager::getSettings()` (mock provides `Settings` with btn fields), `analogRead` (mocked in `Arduino.h`), `millis` (mocked), `NorviButtonHandler::applySettings()` (compiled from `src/`). +- Produces: + - `enum class CalibrationManager::Step { IDLE, RESTING, BTN1, BTN2, BTN3, DONE, ERROR }` + - `struct CalibrationManager::CalibrationStatus { Step step; uint16_t liveAdc; uint16_t restingLevel, s1, s2, s3; const char* message; }` + - `static void begin()`, `static void loop()`, `static bool start()`, `static void cancel()`, `static CalibrationStatus getStatus()`, `static bool isActive()` + - Test hooks: `static void setAdcReadForTest(uint16_t (*fn)())`, `static void setTimeForTest(uint32_t (*fn)())` + +- [ ] **Step 1: Add btn fields to the mock Settings struct** + +In `test/native/mocks/ConfigManager.hpp`, extend `struct Settings` (after `tempCircMaxRuntime`): + +```cpp + uint16_t btn1Min = 3100; + uint16_t btn1Max = 3520; + uint16_t btn2Min = 3520; + uint16_t btn2Max = 3880; + uint16_t btn3Min = 3880; + uint16_t btn3Max = 4095; + uint16_t btnNoPress = 4096; +``` + +- [ ] **Step 2: Write the failing test file** + +Create `test/native/tests/test_calibration_manager.cpp` following the `test_ky040_decoder.cpp` pattern (ASSERT_EQ macro, `run_calibration_manager_tests()` returning failure count, `test_suite_end`): + +```cpp +#include +#include "CalibrationManager.hpp" + +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::CalibrationManager; + +// Test hooks: controllable ADC + clock +static uint16_t g_adc = 0; +static uint32_t g_now = 0; +static uint16_t fakeAdc() { return g_adc; } +static uint32_t fakeTime() { return g_now; } + +static int test_start_from_idle() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::start(), true); + ASSERT_EQ(CalibrationManager::isActive(), true); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_resting_measurement() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // Stable resting level: advance through the wait + sample phases + for (uint32_t t = 0; t < 2000; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700); + return 0; +} + +static int test_timeout_retries_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // No stable level: ADC oscillates wildly, never stabilizes + g_adc = 100; + for (uint32_t t = 0; t < 11000; t += 50) { + g_now = t; + g_adc = (g_adc + 500) % 4096; // never stable + CalibrationManager::loop(); + } + // Still in RESTING (retried), not advanced + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_cancel_from_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + CalibrationManager::cancel(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::IDLE); + return 0; +} + +int run_calibration_manager_tests() { + int failures = 0; + failures += test_start_from_idle(); + failures += test_resting_measurement(); + failures += test_timeout_retries_step(); + failures += test_cancel_from_step(); + test_suite_end("CalibrationManager", 4 - failures, failures); + if (failures == 0) { + printf(" CalibrationManager Tests: 4 passed, 0 failed\n"); + } + return failures; +} +``` + +- [ ] **Step 3: Register the test in the build** + +In `test/native/CMakeLists.txt`: +- Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/CalibrationManager.cpp` and `${PROJ_ROOT}/src/NorviButtonHandler.cpp` (needed because CalibrationManager calls `NorviButtonHandler::applySettings()`). +- Add to `TEST_SOURCES`: `${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp`. + +In `test/native/tests/test_main.cpp`, add `extern int run_calibration_manager_tests();` near the other externs and `total += run_calibration_manager_tests();` in `main()`. + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: FAIL — `CalibrationManager.hpp` not found (header does not exist yet). + +- [ ] **Step 5: Create the header** + +Create `src/CalibrationManager.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.hpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * Measures the resting level and each button level via the shared ADC input + * (GPIO32), computes the button thresholds at the midpoints between adjacent + * levels, and persists them through ConfigManager. + * + * @note Only available when the NORVI_AE01_R preprocessor macro is defined. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief State machine that drives the button calibration wizard. + * + * The wizard is driven from PoolController::loop() via loop(). The web UI + * polls getStatus() to render instructions and the live ADC value, and calls + * start()/cancel() to control the flow. + */ +class CalibrationManager { +public: + /** @brief User-facing calibration steps. COMPUTE/SAVE are internal. */ + enum class Step : std::uint8_t { + IDLE = 0, ///< Not calibrating + RESTING, ///< Measure resting level (no button pressed) + BTN1, ///< Measure Button 1 level + BTN2, ///< Measure Button 2 level + BTN3, ///< Measure Button 3 level + DONE, ///< Calibration finished, thresholds saved + ERROR ///< Calibration failed (message in status) + }; + + /** @brief Snapshot of the calibration state for the web UI. */ + struct CalibrationStatus { + Step step = Step::IDLE; + uint16_t liveAdc = 0; ///< Current filtered ADC reading + uint16_t restingLevel = 0; ///< Measured resting level (0 until measured) + uint16_t s1 = 0; ///< Measured Button 1 level (0 until measured) + uint16_t s2 = 0; ///< Measured Button 2 level (0 until measured) + uint16_t s3 = 0; ///< Measured Button 3 level (0 until measured) + const char* message = ""; ///< Instruction or error text + }; + + /** @brief Initialize the calibration manager. */ + static void begin(); + + /** @brief Drive the calibration state machine. Call from PoolController::loop(). */ + static void loop(); + + /** @brief Start calibration. @return false if already active. */ + static bool start(); + + /** @brief Cancel calibration; old thresholds stay in NVS. */ + static void cancel(); + + /** @brief Current calibration status snapshot. */ + static CalibrationStatus getStatus(); + + /** @brief True while a calibration is running (used to suppress button callbacks). */ + static bool isActive(); + + // ── Test hooks (native tests only) ──────────────────────────────────── + /** @brief Override the ADC read function (default: analogRead on PIN_BUTTON_ADC). */ + static void setAdcReadForTest(uint16_t (*fn)()); + /** @brief Override the time function (default: millis). */ + static void setTimeForTest(uint32_t (*fn)()); + +private: + enum class State : std::uint8_t { + IDLE, RESTING, BTN1, BTN2, BTN3, COMPUTE, SAVE, DONE, ERROR + }; + + static void enterState(State s); + static void handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel); + static void computeThresholds(); + static void saveThresholds(); + static uint16_t readAdc(); + static uint32_t now(); + + static State state_; + static CalibrationStatus status_; + static uint16_t (*adcRead_)(); + static uint32_t (*timeFn_)(); + + // Measurement phase state + static uint32_t stepStartMs_; + static uint32_t sampleCount_; + static uint32_t sampleSum_; + static uint16_t lastReading_; + static uint16_t stableCount_; + static bool sampling_; +}; + +} // namespace PoolController +``` + +- [ ] **Step 6: Create the implementation** + +Create `src/CalibrationManager.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.cpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * @note This file is only compiled when `NORVI_AE01_R` is defined. + */ + +#ifdef NORVI_AE01_R + +#include "CalibrationManager.hpp" + +#include +#include "Config.hpp" +#include "ConfigManager.hpp" +#include "LogCapture.hpp" +#include "NorviButtonHandler.hpp" + +namespace PoolController { + +// ── Constants ────────────────────────────────────────────────────────────── + +/// Per-step timeout: no stable level within this window → retry the step. +static constexpr uint32_t STEP_TIMEOUT_MS{10000}; +/// Minimum gap between adjacent measured levels (ADC counts). +static constexpr uint16_t MIN_LEVEL_GAP{100}; +/// Number of samples averaged per level (50 ms apart → 1 s window). +static constexpr uint32_t SAMPLE_COUNT{20}; +/// Consecutive readings within this window count as "stable". +static constexpr uint16_t STABILITY_WINDOW{50}; +/// Consecutive stable readings required before sampling starts. +static constexpr uint8_t STABLE_READINGS{3}; + +// ── Static members ───────────────────────────────────────────────────────── + +CalibrationManager::State CalibrationManager::state_{State::IDLE}; +CalibrationManager::CalibrationStatus CalibrationManager::status_{}; +uint16_t (*CalibrationManager::adcRead_)() = nullptr; +uint32_t (*CalibrationManager::timeFn_)() = nullptr; +uint32_t CalibrationManager::stepStartMs_{0}; +uint32_t CalibrationManager::sampleCount_{0}; +uint32_t CalibrationManager::sampleSum_{0}; +uint16_t CalibrationManager::lastReading_{0}; +uint16_t CalibrationManager::stableCount_{0}; +bool CalibrationManager::sampling_{false}; + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::begin() { + if (adcRead_ == nullptr) { + adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); }; + } + if (timeFn_ == nullptr) { + timeFn_ = millis; + } + state_ = State::IDLE; + status_ = CalibrationStatus{}; + LOG_INFO("✓ CalibrationManager initialized\n"); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::loop() { + if (state_ == State::IDLE || state_ == State::DONE || state_ == State::ERROR) { + return; + } + + const uint32_t t = now(); + status_.liveAdc = readAdc(); + + switch (state_) { + case State::RESTING: + handleMeasurementStep(State::RESTING, 0, status_.restingLevel); + break; + case State::BTN1: + handleMeasurementStep(State::BTN1, status_.restingLevel, status_.s1); + break; + case State::BTN2: + handleMeasurementStep(State::BTN2, status_.s1, status_.s2); + break; + case State::BTN3: + handleMeasurementStep(State::BTN3, status_.s2, status_.s3); + break; + case State::COMPUTE: + computeThresholds(); + break; + case State::SAVE: + saveThresholds(); + break; + default: + break; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel) { + const uint32_t t = now(); + + if (!sampling_) { + // ── Wait phase: look for a stable reading ────────────────────────── + if (t - stepStartMs_ >= STEP_TIMEOUT_MS) { + // Timeout → retry the step (stay in the same state, reset timer) + stepStartMs_ = t; + stableCount_ = 0; + status_.message = "No stable level detected — please try again"; + LOG_WARN("Calibration step timeout, retrying\n"); + return; + } + + const uint16_t reading = readAdc(); + const bool differsFromPrevious = + (previousLevel == 0) || (reading > previousLevel + MIN_LEVEL_GAP) || + (reading < previousLevel - MIN_LEVEL_GAP); + + if (differsFromPrevious && + (stableCount_ == 0 || + (reading > lastReading_ - STABILITY_WINDOW && reading < lastReading_ + STABILITY_WINDOW))) { + stableCount_++; + lastReading_ = reading; + } else { + stableCount_ = 0; + lastReading_ = reading; + } + + if (stableCount_ >= STABLE_READINGS) { + // Stable → start sampling + sampling_ = true; + sampleCount_ = 0; + sampleSum_ = 0; + status_.message = "Level stable — sampling
"; + } + return; + } + + // ── Sample phase: collect SAMPLE_COUNT readings ────────────────────── + sampleSum_ += readAdc(); + sampleCount_++; + if (sampleCount_ >= SAMPLE_COUNT) { + outLevel = static_cast(sampleSum_ / SAMPLE_COUNT); + sampling_ = false; + stableCount_ = 0; + stepStartMs_ = t; + + switch (step) { + case State::RESTING: + status_.message = "Please press and hold Button 1"; + enterState(State::BTN1); + break; + case State::BTN1: + status_.message = "Please press and hold Button 2"; + enterState(State::BTN2); + break; + case State::BTN2: + status_.message = "Please press and hold Button 3"; + enterState(State::BTN3); + break; + case State::BTN3: + status_.message = "Computing thresholds
"; + enterState(State::COMPUTE); + break; + default: + break; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::computeThresholds() { + const uint16_t resting = status_.restingLevel; + const uint16_t s1 = status_.s1; + const uint16_t s2 = status_.s2; + const uint16_t s3 = status_.s3; + + // Sanity checks: strictly ascending with minimum gaps, S3 within ADC range + if (!(resting < s1 && s1 < s2 && s2 < s3) || + (s1 - resting < MIN_LEVEL_GAP) || (s2 - s1 < MIN_LEVEL_GAP) || (s3 - s2 < MIN_LEVEL_GAP) || + s3 > 4095) { + status_.step = Step::ERROR; + status_.message = "Levels not ascending or too close — please re-run calibration"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: levels resting=%u s1=%u s2=%u s3=%u\n", resting, s1, s2, s3); + return; + } + + auto& s = ConfigManager::getSettings(); + s.btn1Min = (resting + s1) / 2; + s.btn1Max = (s1 + s2) / 2; + s.btn2Min = (s1 + s2) / 2; + s.btn2Max = (s2 + s3) / 2; + s.btn3Min = (s2 + s3) / 2; + s.btn3Max = 4095; // full scale stays + s.btnNoPress = 4096; // sentinel stays + + state_ = State::SAVE; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::saveThresholds() { + if (!ConfigManager::save()) { + status_.step = Step::ERROR; + status_.message = "Failed to save thresholds — please retry"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: ConfigManager::save() returned false\n"); + return; + } + NorviButtonHandler::applySettings(); + status_.step = Step::DONE; + status_.message = "Calibration complete — thresholds saved"; + state_ = State::DONE; + LOG_INFO("✓ Calibration complete: btn1=%u-%u btn2=%u-%u btn3=%u-%u noPress=%u\n", + ConfigManager::getSettings().btn1Min, ConfigManager::getSettings().btn1Max, + ConfigManager::getSettings().btn2Min, ConfigManager::getSettings().btn2Max, + ConfigManager::getSettings().btn3Min, ConfigManager::getSettings().btn3Max, + ConfigManager::getSettings().btnNoPress); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +bool CalibrationManager::start() { + if (state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR) { + return false; // already running + } + status_ = CalibrationStatus{}; + status_.step = Step::RESTING; + status_.message = "Release all buttons — measuring resting level"; + enterState(State::RESTING); + LOG_INFO("Calibration started\n"); + return true; +} + +void CalibrationManager::cancel() { + if (state_ == State::IDLE) { + return; + } + status_ = CalibrationStatus{}; + status_.step = Step::IDLE; + state_ = State::IDLE; + sampling_ = false; + stableCount_ = 0; + LOG_INFO("Calibration cancelled\n"); +} + +CalibrationManager::CalibrationStatus CalibrationManager::getStatus() { + return status_; +} + +bool CalibrationManager::isActive() { + return state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::enterState(State s) { + state_ = s; + stepStartMs_ = now(); + sampling_ = false; + stableCount_ = 0; + lastReading_ = 0; + switch (s) { + case State::RESTING: status_.step = Step::RESTING; break; + case State::BTN1: status_.step = Step::BTN1; break; + case State::BTN2: status_.step = Step::BTN2; break; + case State::BTN3: status_.step = Step::BTN3; break; + case State::DONE: status_.step = Step::DONE; break; + case State::ERROR: status_.step = Step::ERROR; break; + default: break; + } +} + +uint16_t CalibrationManager::readAdc() { + return adcRead_ ? adcRead_() : 0; +} + +uint32_t CalibrationManager::now() { + return timeFn_ ? timeFn_() : 0; +} + +void CalibrationManager::setAdcReadForTest(uint16_t (*fn)()) { + adcRead_ = fn; +} + +void CalibrationManager::setTimeForTest(uint32_t (*fn)()) { + timeFn_ = fn; +} + +} // namespace PoolController + +#endif // NORVI_AE01_R +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — all 4 CalibrationManager tests pass, no regressions in other suites. + +- [ ] **Step 8: Commit** + +```bash +git add src/CalibrationManager.hpp src/CalibrationManager.cpp test/native/tests/test_calibration_manager.cpp test/native/CMakeLists.txt test/native/mocks/ConfigManager.hpp test/native/tests/test_main.cpp +git commit -m "feat(calibration): add CalibrationManager state machine with measurement" +``` + +--- + +### Task 2: COMPUTE/SAVE tests — threshold math, error paths + +**Files:** +- Modify: `test/native/tests/test_calibration_manager.cpp` (add tests) + +**Interfaces:** +- Consumes: `CalibrationManager::start()`, `loop()`, `getStatus()`, test hooks from Task 1; `ConfigManager::getSettings()` (mock). +- Produces: nothing new — verifies Task 1's COMPUTE/SAVE behavior. + +- [ ] **Step 1: Add a save-failure hook to the mock** + +In `test/native/mocks/ConfigManager.hpp`, change `save()` to: + +```cpp + static bool save() { return !_saveFails; } + static bool _saveFails; // test hook +``` + +In `test/native/mocks/ConfigManager.cpp`, add `bool ConfigManager::_saveFails = false;` near the other static definitions. + +- [ ] **Step 2: Write the failing tests** + +Append to `test/native/tests/test_calibration_manager.cpp`: + +```cpp +static int test_full_calibration_saves_thresholds() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + // Helper: hold a level for the wait + sample phases, then switch + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3400, 2000); // S1 + holdLevel(3700, 4000); // S2 + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE); + auto& s = PoolController::ConfigManager::getSettings(); + ASSERT_EQ(s.btn1Min, (2700 + 3400) / 2); + ASSERT_EQ(s.btn1Max, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Min, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Max, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Min, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Max, 4095); + ASSERT_EQ(s.btnNoPress, 4096); + return 0; +} + +static int test_non_ascending_levels_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3700, 2000); // S1 (too high — user pressed wrong button) + holdLevel(3400, 4000); // S2 (below S1 → sanity check fails) + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + return 0; +} + +static int test_save_failure_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); + holdLevel(3400, 2000); + holdLevel(3700, 4000); + holdLevel(4095, 6000); + + // Force save failure via the mock hook → ERROR state + PoolController::ConfigManager::_saveFails = true; + CalibrationManager::loop(); + PoolController::ConfigManager::_saveFails = false; + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + return 0; +} +``` + +- [ ] **Step 3: Register the new tests** + +Add the three new test functions to `run_calibration_manager_tests()` and bump the expected count from 4 to 7. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — 7 CalibrationManager tests, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add test/native/tests/test_calibration_manager.cpp test/native/mocks/ConfigManager.hpp test/native/mocks/ConfigManager.cpp +git commit -m "test(calibration): cover threshold computation and error paths" +``` + +--- + +### Task 3: Suppress button callbacks during calibration + wire into PoolController + +**Files:** +- Modify: `src/NorviButtonHandler.cpp` (early-return in `loop()`) +- Modify: `src/PoolController.cpp` (call `CalibrationManager::begin()` and `loop()`) + +**Interfaces:** +- Consumes: `CalibrationManager::isActive()` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Early-return in NorviButtonHandler::loop()** + +In `src/NorviButtonHandler.cpp`, add the include and the guard at the top of `loop()`: + +```cpp +#include "CalibrationManager.hpp" +``` + +```cpp +void NorviButtonHandler::loop() { + // Suppress button handling while calibration is running — the wizard + // owns the ADC input and button presses must not trigger actions. + if (CalibrationManager::isActive()) { + return; + } + + const uint32_t now = millis(); + // ... existing body unchanged +``` + +- [ ] **Step 2: Wire CalibrationManager into PoolController** + +In `src/PoolController.cpp`, inside the `#ifdef NORVI_AE01_R` block near `NorviButtonHandler::begin()` (line ~296), add: + +```cpp + CalibrationManager::begin(); +``` + +And in the `#ifdef NORVI_AE01_R` block in `loop()` (near line 502), add: + +```cpp + CalibrationManager::loop(); +``` + +- [ ] **Step 3: Verify the firmware builds** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 4: Run native tests (no regressions)** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — all suites green. + +- [ ] **Step 5: Commit** + +```bash +git add src/NorviButtonHandler.cpp src/PoolController.cpp +git commit -m "feat(calibration): suppress button actions during calibration" +``` + +--- + +### Task 4: WebPortal REST endpoints + +**Files:** +- Modify: `src/WebPortal.cpp` (add 3 routes + handlers) +- Modify: `test/native/tests/test_webportal_json.cpp` (add calibration status JSON test) + +**Interfaces:** +- Consumes: `CalibrationManager::start()`, `cancel()`, `getStatus()`, `isActive()` (Task 1). +- Produces: REST endpoints `POST /api/calibrate/start`, `GET /api/calibrate/status`, `POST /api/calibrate/cancel`. + +- [ ] **Step 1: Add the routes** + +In `src/WebPortal.cpp` `setupRoutes()`, after the `/api/config` routes (line ~209), add: + +```cpp + // Button calibration wizard (NORVI) + server_.on("/api/calibrate/start", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateStart(); + }); + server_.on("/api/calibrate/status", HTTP_GET, []() { + if (!handleAuthentication()) + return; + apiCalibrateStatus(); + }); + server_.on("/api/calibrate/cancel", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateCancel(); + }); +``` + +- [ ] **Step 2: Add the handlers** + +In `src/WebPortal.cpp`, add the handler implementations (near the other `api*` handlers, e.g. after `apiSaveConfig`): + +```cpp +// ── Button calibration (NORVI) ──────────────────────────────────────────── + +void WebPortal::apiCalibrateStart() { + if (!CalibrationManager::start()) { + server_.send(409, "text/plain", "Calibration already running"); + return; + } + server_.send(200, "text/plain", "OK"); +} + +void WebPortal::apiCalibrateStatus() { + const auto st = CalibrationManager::getStatus(); + JsonDocument doc; + doc["step"] = static_cast(st.step); + doc["live_adc"] = st.liveAdc; + doc["resting"] = st.restingLevel; + doc["s1"] = st.s1; + doc["s2"] = st.s2; + doc["s3"] = st.s3; + doc["message"] = st.message; + String json; + serializeJson(doc, json); + server_.send(200, "application/json", json); +} + +void WebPortal::apiCalibrateCancel() { + CalibrationManager::cancel(); + server_.send(200, "text/plain", "OK"); +} +``` + +Add the three method declarations to `src/WebPortal.hpp` (private section) and the `#include "CalibrationManager.hpp"` to `src/WebPortal.cpp` (guarded by `#ifdef NORVI_AE01_R` — the handlers are only compiled for the NORVI variant; wrap the route registrations and handler bodies in `#ifdef NORVI_AE01_R`). + +- [ ] **Step 3: Write the failing test** + +In `test/native/tests/test_webportal_json.cpp`, add a test that verifies the status JSON shape (following the existing `apiGetStatus` test pattern — construct the expected JSON inline and check keys/types): + +```cpp + // ── Test: calibration status JSON ── + { + test_begin("WebPortal::apiCalibrateStatus", "returns JSON with all fields"); + + JsonDocument doc; + doc["step"] = 1; + doc["live_adc"] = 2700; + doc["resting"] = 0; + doc["s1"] = 0; + doc["s2"] = 0; + doc["s3"] = 0; + doc["message"] = "Release all buttons — measuring resting level"; + + int errs = 0; + const char *requiredKeys[] = {"step", "live_adc", "resting", "s1", "s2", "s3", "message"}; + for (auto key : requiredKeys) { + if (!doc.containsKey(key)) { + char msg[128]; + snprintf(msg, sizeof(msg), "Missing required key: %s", key); + test_fail(__FILE__, __LINE__, msg); + errs++; + } + } + ASSERT_TRUE(doc["step"].is()); + ASSERT_TRUE(doc["live_adc"].is()); + ASSERT_TRUE(doc["message"].is()); + + test_suite_end("WebPortal::apiCalibrateStatus", errs == 0 ? 3 : 0, errs); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — calibration status JSON test passes, no regressions. + +- [ ] **Step 5: Verify the firmware builds** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add src/WebPortal.cpp src/WebPortal.hpp test/native/tests/test_webportal_json.cpp +git commit -m "feat(calibration): add REST endpoints for calibration wizard" +``` + +--- + +### Task 5: Web UI — start button, modal wizard, polling + +**Files:** +- Modify: `data/web/index.html` (start button + modal markup) +- Modify: `data/web/app.js` (wizard logic, polling, modal handling) + +**Interfaces:** +- Consumes: REST endpoints from Task 4 (`POST /api/calibrate/start`, `GET /api/calibrate/status`, `POST /api/calibrate/cancel`). +- Produces: `startCalibration()`, `pollCalibrationStatus()`, `cancelCalibration()`, `closeCalibrationModal()` (global functions used by inline `onclick` handlers). + +- [ ] **Step 1: Add the start button** + +In `data/web/index.html`, in the Button Thresholds section (after the `btnNoPress` input group, line ~356), add: + +```html +
+
+ + Guided wizard: press and hold each button in turn to measure its ADC level +
+ +
+``` + +- [ ] **Step 2: Add the modal markup** + +In `data/web/index.html`, after the `loginModal` div (line ~40), add the calibration modal (same inline-overlay pattern): + +```html + + +``` + +- [ ] **Step 3: Add the wizard logic to app.js** + +In `data/web/app.js`, add (near the other settings functions): + +```js +// ── Button Calibration Wizard ── + +let calibPollTimer = null; + +function showCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'flex'; +} + +function closeCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'none'; + if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; } +} + +async function startCalibration() { + const res = await fetch('/api/calibrate/start', { method: 'POST' }); + if (!res.ok) { alert('Calibration could not be started.'); return; } + showCalibrationModal(); + calibPollTimer = setInterval(pollCalibrationStatus, 500); + pollCalibrationStatus(); +} + +async function pollCalibrationStatus() { + const res = await fetch('/api/calibrate/status'); + if (!res.ok) return; + const st = await res.json(); + document.getElementById('calibStepText').textContent = st.message || ''; + document.getElementById('calibLiveAdc').textContent = st.live_adc; + + const steps = ['calibP0', 'calibP1', 'calibP2', 'calibP3']; + const active = st.step; // 1=RESTING, 2=BTN1, 3=BTN2, 4=BTN3, 5=DONE, 6=ERROR + steps.forEach((id, i) => { + const el = document.getElementById(id); + el.style.color = (i + 1 === active) ? '#00e5ff' : (i + 1 < active ? '#4ade80' : 'var(--text-muted)'); + }); + + if (st.step === 5) { // DONE + closeCalibrationModal(); + loadConfig(); // refresh threshold fields + } else if (st.step === 6) { // ERROR + closeCalibrationModal(); + alert('Calibration failed: ' + (st.message || 'unknown error')); + } +} + +async function cancelCalibration() { + await fetch('/api/calibrate/cancel', { method: 'POST' }); + closeCalibrationModal(); +} +``` + +- [ ] **Step 4: Verify the web assets are valid** + +Run: `node --check data/web/app.js` +Expected: no syntax errors. + +- [ ] **Step 5: Verify the firmware builds** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add data/web/index.html data/web/app.js +git commit -m "feat(calibration): add guided calibration wizard to web UI" +``` + +--- + +### Task 6: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Full native test run** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: all suites pass (including 7 CalibrationManager tests). + +- [ ] **Step 2: Firmware build for all environments** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 3: clang-format check** + +Run: `clang-format --dry-run --Werror --style=file:.clang-format src/CalibrationManager.hpp src/CalibrationManager.cpp src/NorviButtonHandler.cpp src/PoolController.cpp src/WebPortal.cpp` +Expected: no output (all formatted). If errors, run `clang-format -i` on the offending files. + +- [ ] **Step 4: Manual device verification (optional, requires hardware)** + +Flash `norvi_ae01_r` to the device, open the web UI → Pool tab → Start Calibration, and verify: +1. Modal opens with "Release all buttons — measuring resting level". +2. Live ADC value updates every ~500 ms. +3. Pressing and holding each button advances the wizard. +4. On completion, the threshold fields show the new values. +5. Cancel works and old thresholds remain. + +- [ ] **Step 5: Report** + +Summarize: files changed, test results, build results, and any manual verification performed. diff --git a/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md b/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md new file mode 100644 index 00000000..a8745eaa --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md @@ -0,0 +1,161 @@ +# Design: Automatic NORVI Button Threshold Calibration + +**Date:** 2026-08-16 +**Status:** Draft for user review +**Scope:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically, replacing manual calibration. + +## Motivation + +The NORVI AE01-R front-panel buttons share a single analog input (GPIO32) and +produce distinct ADC levels via a resistor ladder. The thresholds +(`btn1Min`
`btnNoPress`) are currently calibrated manually: the values were +measured once on 2026-08-16 and hard-coded as defaults, then made configurable +through NVS and the web UI. Manual calibration requires reading live ADC values +and computing midpoints by hand — error-prone and hardware-dependent. + +This design adds an automatic calibration flow: a guided wizard in the web UI +that samples the resting level and each button level, computes the thresholds +at the midpoints between adjacent levels, and persists them to NVS. + +## Design Decisions (from brainstorming) + +- **Trigger:** Guided wizard in the web UI (modal dialog), started via a + "Start Calibration" button in the Button Thresholds section. +- **Sampling:** Press-and-hold; the device detects the level change itself and + samples ~20 readings over 1 s, then averages them. No user timing required. +- **Resting level:** Measured first; `btn1Min` is set to the midpoint between + the resting level and S1. +- **No-press sentinel:** `btnNoPress` stays at 4096 (no-op sentinel, S3 reads + full scale). Calibration never changes it. +- **Error handling:** Per-step timeout (10 s) and minimum-gap check (100 ADC) + cause the step to be retried; the wizard can be cancelled at any time; old + thresholds remain untouched until a successful save. +- **Live feedback:** The wizard polls the current ADC value every ~500 ms so the + user can see the level is stable and the button is detected. +- **Architecture:** Separate `CalibrationManager` module with its own state + machine; `NorviButtonHandler` stays focused on detection. + +## Architecture + +### New module: `CalibrationManager` + +`src/CalibrationManager.hpp/.cpp` — compiled only when `NORVI_AE01_R` is +defined. + +Owns the calibration state machine and reads the ADC directly on GPIO32 +(`PIN_BUTTON_ADC`) with its own simple averaging (20 samples over 1 s) and +stability detection. Independent of the filtering inside `NorviButtonHandler`. + +**States:** + +```text +IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE + └─────────────── ERROR (retry current step or cancel → IDLE) +``` + +**Public API (static):** + +- `begin()` — initialize. +- `loop()` — drive the state machine; called from `PoolController::loop()`. +- `start()` — begin calibration; only valid from `IDLE`. +- `cancel()` — abort and return to `IDLE`; old thresholds stay in NVS. +- `getStatus()` — current step, live ADC, measured levels so far, message. +- `isActive()` — true while a calibration is running. + +**Status struct:** + +```cpp +enum class Step { IDLE, RESTING, BTN1, BTN2, BTN3, DONE, ERROR }; +struct CalibrationStatus { + Step step; + uint16_t liveAdc; // current filtered ADC reading + uint16_t restingLevel; // measured so far (0 until measured) + uint16_t s1, s2, s3; // measured button levels (0 until measured) + const char* message; // instruction or error text +}; +``` + +### Button callback suppression + +`NorviButtonHandler::loop()` early-returns when `CalibrationManager::isActive()` +is true, so no button callback can fire during calibration (no accidental pump +toggle, mode cycle, or save-and-reboot). One-way dependency +`NorviButtonHandler → CalibrationManager`, no cycle. + +### Threshold computation + +Midpoints between adjacent levels: + +```text +btn1Min = (resting + S1) / 2 btn1Max = (S1 + S2) / 2 +btn2Min = (S1 + S2) / 2 btn2Max = (S2 + S3) / 2 +btn3Min = (S2 + S3) / 2 btn3Max = 4095 (full scale stays) +btnNoPress = 4096 (sentinel, unchanged) +``` + +Sanity checks before saving: + +- Levels must be strictly ascending: `resting < S1 < S2 < S3`. +- Minimum gap between adjacent levels: 100 ADC. +- `S3` must be ≀ 4095. + +On success: `ConfigManager::save()` then `NorviButtonHandler::applySettings()` +so the new thresholds apply to the running handler immediately (no reboot). + +## REST API (WebPortal.cpp, auth-protected) + +- `POST /api/calibrate/start` — start calibration (from IDLE). +- `GET /api/calibrate/status` — JSON: `{ step, liveAdc, resting, s1, s2, s3, message }`. +- `POST /api/calibrate/cancel` — cancel calibration. + +No "complete" endpoint: the state machine saves automatically when `COMPUTE` +succeeds. + +## Web UI + +- **Button:** "Start Calibration" in the Button Thresholds (NORVI) section. +- **Modal dialog** with: + - Step instructions ("Please press and hold Button 1
"). + - Live ADC value (poll `GET /api/calibrate/status` every 500 ms). + - Progress indicator (Resting → S1 → S2 → S3). + - Cancel button. + - On success: close modal, reload config so the fields show the new values. + - On error: show message, offer retry of the current step. + +## Error Handling + +| Condition | Behavior | +|---|---| +| No stable level within 10 s | Retry current step, message "No stable level detected — please try again" | +| Level too close to previous (< 100 ADC) | Retry current step, message "Level too close to previous — please check" | +| Cancel (button or endpoint) | Return to IDLE, old thresholds untouched | +| Save failure | ERROR state, old thresholds untouched | + +## Testing + +Native tests (no hardware), with an injectable ADC read function (test hook +instead of `analogRead`): + +- **State machine:** transitions IDLE→RESTING→
→DONE; cancel from every step; + timeout behavior. +- **Threshold computation:** midpoint math, ascending-level sanity checks, + minimum-gap enforcement, S3 = 4095 edge case. +- **Error cases:** unstable level, too-close level, timeout → correct state and + message. +- **Web UI:** manual verification on the device (wizard flow, live ADC, cancel). + +## Files + +New: + +- `src/CalibrationManager.hpp` +- `src/CalibrationManager.cpp` +- `test/native/tests/test_calibration_manager.cpp` + +Modified: + +- `src/NorviButtonHandler.cpp` — early-return in `loop()` when calibration active. +- `src/WebPortal.cpp` — three calibration endpoints. +- `data/web/index.html` — start button + modal markup. +- `data/web/app.js` — wizard logic, polling, modal handling. +- `test/native/CMakeLists.txt` — add test source. diff --git a/src/CalibrationManager.cpp b/src/CalibrationManager.cpp new file mode 100644 index 00000000..88add7da --- /dev/null +++ b/src/CalibrationManager.cpp @@ -0,0 +1,368 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.cpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * @note This file is only compiled when `NORVI_AE01_R` is defined. + */ + +#ifdef NORVI_AE01_R + +#include "CalibrationManager.hpp" + +#include +#include "Config.hpp" +#include "ConfigManager.hpp" +#include "LogCapture.hpp" +#include "NorviButtonHandler.hpp" + +namespace PoolController { + +// ── Constants ────────────────────────────────────────────────────────────── + +/// Per-step timeout: no stable level within this window → retry the step. +static constexpr uint32_t STEP_TIMEOUT_MS{10000}; +/// Minimum gap between adjacent measured levels (ADC counts). +static constexpr uint16_t MIN_LEVEL_GAP{100}; +/// Number of samples averaged per level (50 ms apart → 1 s window). +static constexpr uint32_t SAMPLE_COUNT{20}; +/// Interval between individual samples during the sample phase. +static constexpr uint32_t SAMPLE_INTERVAL_MS{50}; +/// Consecutive readings within this window count as "stable". +static constexpr uint16_t STABILITY_WINDOW{50}; +/// Consecutive stable readings required before sampling starts. +static constexpr uint8_t STABLE_READINGS{3}; + +// ── Static members ───────────────────────────────────────────────────────── + +CalibrationManager::State CalibrationManager::state_{State::IDLE}; +CalibrationManager::CalibrationStatus CalibrationManager::status_{}; +uint16_t (*CalibrationManager::adcRead_)() = nullptr; +uint32_t (*CalibrationManager::timeFn_)() = nullptr; +uint32_t CalibrationManager::stepStartMs_{0}; +uint32_t CalibrationManager::sampleCount_{0}; +uint32_t CalibrationManager::sampleSum_{0}; +uint32_t CalibrationManager::lastSampleMs_{0}; +uint16_t CalibrationManager::lastReading_{0}; +uint16_t CalibrationManager::stableCount_{0}; +bool CalibrationManager::sampling_{false}; + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::begin() { + if (adcRead_ == nullptr) { + adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); }; + } + if (timeFn_ == nullptr) { + timeFn_ = []() { return static_cast(millis()); }; + } + state_ = State::IDLE; + status_ = CalibrationStatus{}; + LOG_INFO("✓ CalibrationManager initialized\n"); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::loop() { + if (state_ == State::IDLE || state_ == State::DONE || state_ == State::ERROR) { + return; + } + + const uint32_t t = now(); + status_.liveAdc = readAdc(); + + switch (state_) { + case State::RESTING: + handleMeasurementStep(State::RESTING, 0, status_.restingLevel); + break; + case State::BTN1: + handleMeasurementStep(State::BTN1, status_.restingLevel, status_.s1); + break; + case State::BTN2: + handleMeasurementStep(State::BTN2, status_.s1, status_.s2); + break; + case State::BTN3: + handleMeasurementStep(State::BTN3, status_.s2, status_.s3); + break; + case State::COMPUTE: + computeThresholds(); + break; + case State::SAVE: + saveThresholds(); + break; + default: + break; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::handleMeasurementStep(State step, uint16_t previousLevel, uint16_t &outLevel) { + const uint32_t t = now(); + + if (!sampling_) { + // ── Wait phase: look for a stable reading ────────────────────────── + if (t - stepStartMs_ >= STEP_TIMEOUT_MS) { + // Timeout → retry the step (stay in the same state, reset timer) + stepStartMs_ = t; + stableCount_ = 0; + status_.message = "No stable level detected — please try again"; + LOG_WARN("Calibration step timeout, retrying\n"); + return; + } + + const uint16_t reading = readAdc(); + // Button levels are strictly ascending: accept only readings at least + // MIN_LEVEL_GAP above the previous level (inclusive). This rejects + // release levels (which drop back toward the resting level) and accepts + // hardware whose adjacent levels sit exactly at the minimum gap. + const bool differsFromPrevious = (previousLevel == 0) || (reading >= previousLevel + MIN_LEVEL_GAP); + + if (differsFromPrevious && + (stableCount_ == 0 || (reading > lastReading_ - STABILITY_WINDOW && reading < lastReading_ + STABILITY_WINDOW))) { + stableCount_++; + lastReading_ = reading; + } else { + stableCount_ = 0; + lastReading_ = reading; + } + + if (stableCount_ >= STABLE_READINGS) { + // Stable → start sampling + sampling_ = true; + sampleCount_ = 0; + sampleSum_ = 0; + lastSampleMs_ = t; + status_.message = "Level stable — sampling
"; + } + return; + } + + // ── Sample phase: collect SAMPLE_COUNT readings ────────────────────── + // Gate on the sample interval so the readings span the intended window + // instead of being collected within a few loop iterations. + if (t - lastSampleMs_ >= SAMPLE_INTERVAL_MS) { + lastSampleMs_ = t; + const uint16_t reading = readAdc(); + // Reject outliers: if the reading leaves the stability window around + // the level accepted in the wait phase (e.g. the button was released), + // restart the measurement instead of averaging a mixed level. + if (reading < lastReading_ - STABILITY_WINDOW || reading > lastReading_ + STABILITY_WINDOW) { + sampling_ = false; + stableCount_ = 0; + status_.message = "Level changed — please hold the button steady"; + LOG_WARN("Calibration sample out of range, restarting step\n"); + return; + } + sampleSum_ += reading; + sampleCount_++; + if (sampleCount_ >= SAMPLE_COUNT) { + const uint16_t average = static_cast(sampleSum_ / SAMPLE_COUNT); + sampling_ = false; + stableCount_ = 0; + stepStartMs_ = t; + + // Revalidate the averaged level against the previous measurement: + // samples may drift within the stability window and produce an + // average below the minimum gap, which would otherwise fail the + // final sanity check in computeThresholds() and abort the wizard. + if (previousLevel != 0 && average < previousLevel + MIN_LEVEL_GAP) { + status_.message = "Level too close to the previous one — please try again"; + LOG_WARN("Calibration average %u below minimum gap, retrying step\n", average); + return; // stay in this state; the wait phase restarts + } + outLevel = average; + + switch (step) { + case State::RESTING: + status_.message = "Please press and hold Button 1"; + enterState(State::BTN1); + break; + case State::BTN1: + status_.message = "Please press and hold Button 2"; + enterState(State::BTN2); + break; + case State::BTN2: + status_.message = "Please press and hold Button 3"; + enterState(State::BTN3); + break; + case State::BTN3: + status_.message = "Computing thresholds
"; + enterState(State::COMPUTE); + break; + default: + break; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::computeThresholds() { + const uint16_t resting = status_.restingLevel; + const uint16_t s1 = status_.s1; + const uint16_t s2 = status_.s2; + const uint16_t s3 = status_.s3; + + // Sanity checks: strictly ascending with minimum gaps, S3 within ADC range + if (!(resting < s1 && s1 < s2 && s2 < s3) || (s1 - resting < MIN_LEVEL_GAP) || (s2 - s1 < MIN_LEVEL_GAP) || + (s3 - s2 < MIN_LEVEL_GAP) || s3 > 4095) { + status_.step = Step::ERROR; + status_.message = "Levels not ascending or too close — please re-run calibration"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: levels resting=%u s1=%u s2=%u s3=%u\n", resting, s1, s2, s3); + return; + } + + state_ = State::SAVE; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::saveThresholds() { + auto &s = ConfigManager::getSettings(); + + // Snapshot the previous thresholds so they can be restored if persistence + // fails — the live Settings must not expose values that were never saved. + const uint16_t prevBtn1Min = s.btn1Min; + const uint16_t prevBtn1Max = s.btn1Max; + const uint16_t prevBtn2Min = s.btn2Min; + const uint16_t prevBtn2Max = s.btn2Max; + const uint16_t prevBtn3Min = s.btn3Min; + const uint16_t prevBtn3Max = s.btn3Max; + const uint16_t prevBtnNoPress = s.btnNoPress; + + // Compute the new thresholds into temporaries first. + const uint16_t resting = status_.restingLevel; + const uint16_t s1 = status_.s1; + const uint16_t s2 = status_.s2; + const uint16_t s3 = status_.s3; + const uint16_t btn1Min = (resting + s1) / 2; + const uint16_t btn1Max = (s1 + s2) / 2; + const uint16_t btn2Min = (s1 + s2) / 2; + const uint16_t btn2Max = (s2 + s3) / 2; + const uint16_t btn3Min = (s2 + s3) / 2; + + // Commit to the live Settings so save() persists them
 + s.btn1Min = btn1Min; + s.btn1Max = btn1Max; + s.btn2Min = btn2Min; + s.btn2Max = btn2Max; + s.btn3Min = btn3Min; + s.btn3Max = 4095; // full scale stays + s.btnNoPress = 4096; // sentinel stays + + if (!ConfigManager::save()) { + // 
and restore the previous values if persistence failed. + s.btn1Min = prevBtn1Min; + s.btn1Max = prevBtn1Max; + s.btn2Min = prevBtn2Min; + s.btn2Max = prevBtn2Max; + s.btn3Min = prevBtn3Min; + s.btn3Max = prevBtn3Max; + s.btnNoPress = prevBtnNoPress; + + status_.step = Step::ERROR; + status_.message = "Failed to save thresholds — please retry"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: ConfigManager::save() returned false\n"); + return; + } + NorviButtonHandler::applySettings(); + status_.step = Step::DONE; + status_.message = "Calibration complete — thresholds saved"; + state_ = State::DONE; + LOG_INFO("✓ Calibration complete: btn1=%u-%u btn2=%u-%u btn3=%u-%u noPress=%u\n", s.btn1Min, s.btn1Max, s.btn2Min, s.btn2Max, + s.btn3Min, s.btn3Max, s.btnNoPress); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +bool CalibrationManager::start() { + if (state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR) { + return false; // already running + } + status_ = CalibrationStatus{}; + status_.step = Step::RESTING; + status_.message = "Release all buttons — measuring resting level"; + enterState(State::RESTING); + LOG_INFO("Calibration started\n"); + return true; +} + +void CalibrationManager::cancel() { + if (state_ == State::IDLE) { + return; + } + status_ = CalibrationStatus{}; + status_.step = Step::IDLE; + state_ = State::IDLE; + sampling_ = false; + stableCount_ = 0; + LOG_INFO("Calibration cancelled\n"); +} + +CalibrationManager::CalibrationStatus CalibrationManager::getStatus() { + return status_; +} + +bool CalibrationManager::isActive() { + return state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::enterState(State s) { + state_ = s; + stepStartMs_ = now(); + sampling_ = false; + stableCount_ = 0; + lastReading_ = 0; + lastSampleMs_ = stepStartMs_; + switch (s) { + case State::RESTING: + status_.step = Step::RESTING; + break; + case State::BTN1: + status_.step = Step::BTN1; + break; + case State::BTN2: + status_.step = Step::BTN2; + break; + case State::BTN3: + status_.step = Step::BTN3; + break; + case State::DONE: + status_.step = Step::DONE; + break; + case State::ERROR: + status_.step = Step::ERROR; + break; + default: + break; + } +} + +uint16_t CalibrationManager::readAdc() { + return adcRead_ ? adcRead_() : 0; +} + +uint32_t CalibrationManager::now() { + return timeFn_ ? timeFn_() : 0; +} + +void CalibrationManager::setAdcReadForTest(uint16_t (*fn)()) { + adcRead_ = fn; +} + +void CalibrationManager::setTimeForTest(uint32_t (*fn)()) { + timeFn_ = fn; +} + +} // namespace PoolController + +#endif // NORVI_AE01_R diff --git a/src/CalibrationManager.hpp b/src/CalibrationManager.hpp new file mode 100644 index 00000000..1145ab65 --- /dev/null +++ b/src/CalibrationManager.hpp @@ -0,0 +1,102 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.hpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * Measures the resting level and each button level via the shared ADC input + * (GPIO32), computes the button thresholds at the midpoints between adjacent + * levels, and persists them through ConfigManager. + * + * @note Only available when the NORVI_AE01_R preprocessor macro is defined. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief State machine that drives the button calibration wizard. + * + * The wizard is driven from PoolController::loop() via loop(). The web UI + * polls getStatus() to render instructions and the live ADC value, and calls + * start()/cancel() to control the flow. + */ +class CalibrationManager { +public: + /** @brief User-facing calibration steps. COMPUTE/SAVE are internal. */ + enum class Step : std::uint8_t { + IDLE = 0, ///< Not calibrating + RESTING, ///< Measure resting level (no button pressed) + BTN1, ///< Measure Button 1 level + BTN2, ///< Measure Button 2 level + BTN3, ///< Measure Button 3 level + DONE, ///< Calibration finished, thresholds saved + ERROR ///< Calibration failed (message in status) + }; + + /** @brief Snapshot of the calibration state for the web UI. */ + struct CalibrationStatus { + Step step = Step::IDLE; + uint16_t liveAdc = 0; ///< Current filtered ADC reading + uint16_t restingLevel = 0; ///< Measured resting level (0 until measured) + uint16_t s1 = 0; ///< Measured Button 1 level (0 until measured) + uint16_t s2 = 0; ///< Measured Button 2 level (0 until measured) + uint16_t s3 = 0; ///< Measured Button 3 level (0 until measured) + const char *message = ""; ///< Instruction or error text + }; + + /** @brief Initialize the calibration manager. */ + static void begin(); + + /** @brief Drive the calibration state machine. Call from PoolController::loop(). */ + static void loop(); + + /** @brief Start calibration. @return false if already active. */ + static bool start(); + + /** @brief Cancel calibration; old thresholds stay in NVS. */ + static void cancel(); + + /** @brief Current calibration status snapshot. */ + static CalibrationStatus getStatus(); + + /** @brief True while a calibration is running (used to suppress button callbacks). */ + static bool isActive(); + + // ── Test hooks (native tests only) ──────────────────────────────────── + /** @brief Override the ADC read function (default: analogRead on PIN_BUTTON_ADC). */ + static void setAdcReadForTest(uint16_t (*fn)()); + /** @brief Override the time function (default: millis). */ + static void setTimeForTest(uint32_t (*fn)()); + +private: + enum class State : std::uint8_t { IDLE, RESTING, BTN1, BTN2, BTN3, COMPUTE, SAVE, DONE, ERROR }; + + static void enterState(State s); + static void handleMeasurementStep(State step, uint16_t previousLevel, uint16_t &outLevel); + static void computeThresholds(); + static void saveThresholds(); + static uint16_t readAdc(); + static uint32_t now(); + + static State state_; + static CalibrationStatus status_; + static uint16_t (*adcRead_)(); + static uint32_t (*timeFn_)(); + + // Measurement phase state + static uint32_t stepStartMs_; + static uint32_t sampleCount_; + static uint32_t sampleSum_; + static uint32_t lastSampleMs_; + static uint16_t lastReading_; + static uint16_t stableCount_; + static bool sampling_; +}; + +} // namespace PoolController diff --git a/src/NorviButtonHandler.cpp b/src/NorviButtonHandler.cpp index d6a6a681..f6a0bd3b 100644 --- a/src/NorviButtonHandler.cpp +++ b/src/NorviButtonHandler.cpp @@ -18,6 +18,7 @@ #include "NorviButtonHandler.hpp" #include +#include "CalibrationManager.hpp" #include "Config.hpp" #include "ConfigManager.hpp" #include "LogCapture.hpp" @@ -51,6 +52,8 @@ NorviButtonHandler::ButtonLongPressCallback NorviButtonHandler::cbButton3Long_ = uint32_t NorviButtonHandler::pressStartMs_ = 0; uint32_t NorviButtonHandler::releasePendingMs_ = 0; +bool NorviButtonHandler::calibWasActive_ = false; +bool NorviButtonHandler::suppressCallbacks_ = false; // ADC thresholds — defaults are the 2026-08-16 calibrated values; begin() // overwrites them from ConfigManager (NVS) so they are user-configurable. @@ -71,6 +74,9 @@ void NorviButtonHandler::begin() { applySettings(); + calibWasActive_ = false; + suppressCallbacks_ = false; + // Take an initial sample to let the ADC stabilise analogRead(PIN_BUTTON_ADC); delay(10); @@ -99,6 +105,67 @@ void NorviButtonHandler::applySettings() { // ═══════════════════════════════════════════════════════════════════════════ void NorviButtonHandler::loop() { + const bool calibActive = CalibrationManager::isActive(); + + // Enhanced ADC stability check: only accept values that are stable for 150ms + static uint16_t lastStableAdc_ = 0; + static uint32_t lastStableTime_ = 0; + + // Calibration just finished (DONE/ERROR): the user may still be holding + // the button from the last wizard step. Reset press tracking and keep + // callbacks suppressed until the ADC returns to the no-press range for + // the debounce interval, so the held press cannot fire its callback. + if (!calibActive && calibWasActive_) { + suppressCallbacks_ = true; + currentButton_ = Button::NONE; + stableButton_ = Button::NONE; + pressStartMs_ = 0; + releasePendingMs_ = 0; + lastChangeMs_ = millis(); + LOG_DEBUG("Calibration finished — callbacks suppressed until all buttons released\n"); + } + calibWasActive_ = calibActive; + + // Suppress button handling while calibration is running — the wizard + // owns the ADC input and button presses must not trigger actions. + if (calibActive) { + return; + } + + // While suppression is armed, keep sampling so the release can be + // detected, but never fire callbacks. + if (suppressCallbacks_) { + const uint32_t now = millis(); + if (now - lastSampleMs_ < SAMPLE_INTERVAL_MS) { + return; + } + lastSampleMs_ = now; + const uint16_t rawAdc = analogRead(PIN_BUTTON_ADC); + if (detectButton(rawAdc) == Button::NONE) { + if (releasePendingMs_ == 0) { + releasePendingMs_ = now; + } else if (now - releasePendingMs_ >= DEBOUNCE_MS) { + // Confirmed all-buttons-released — re-enable callbacks and + // re-baseline the filter so stale button-level samples cannot + // produce a spurious press. + suppressCallbacks_ = false; + releasePendingMs_ = 0; + for (uint8_t i = 0; i < ADC_FILTER_SIZE; i++) { + adcSamples_[i] = rawAdc; + } + adcSampleIndex_ = 0; + lastFilteredAdc_ = rawAdc; + lastStableAdc_ = rawAdc; + lastStableTime_ = now; + lastRaw_ = rawAdc; + LOG_DEBUG("All buttons released — callbacks re-enabled\n"); + } + } else { + releasePendingMs_ = 0; + } + return; + } + const uint32_t now = millis(); // Sample at fixed intervals to reduce noise @@ -110,10 +177,6 @@ void NorviButtonHandler::loop() { // Read ADC uint16_t rawAdc = analogRead(PIN_BUTTON_ADC); - // Enhanced ADC stability check: only accept values that are stable for 150ms - static uint16_t lastStableAdc_ = 0; - static uint32_t lastStableTime_ = 0; - // Fast-attack: a genuine press/release changes the button range — even // when the ADC delta is small (no-press ~2700 → S1 ~3400 = 700). Base the // re-initialization on button-range transitions so every valid press is diff --git a/src/NorviButtonHandler.hpp b/src/NorviButtonHandler.hpp index fd2ab3b8..138c5be3 100644 --- a/src/NorviButtonHandler.hpp +++ b/src/NorviButtonHandler.hpp @@ -178,6 +178,15 @@ class NorviButtonHandler { /// committed to `currentButton_` after DEBOUNCE_MS. static uint32_t releasePendingMs_; + /// True while calibration was active on the previous loop() call. + /// Used to detect the calibration-finish transition. + static bool calibWasActive_; + + /// While set, callbacks stay suppressed until a confirmed + /// all-buttons-released state is observed (armed when calibration + /// finishes while a button is still held). + static bool suppressCallbacks_; + // ── ADC thresholds (12-bit, 0–4095) ────────────────────────────────── // Calibrated from live measurements on the NORVI AE01-R (2026-08-16): // No press: ~2610–2990 (oscillates; must map to NONE) diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 64129244..1f24946c 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -41,6 +41,7 @@ #ifdef NORVI_AE01_R #include "NorviOledDisplay.hpp" #include "NorviButtonHandler.hpp" +#include "CalibrationManager.hpp" #endif #if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) @@ -294,6 +295,7 @@ auto PoolControllerContext::setup() -> void { // Initialize NORVI-specific peripherals (OLED display + front buttons) NorviOledDisplay::begin(); NorviButtonHandler::begin(); + CalibrationManager::begin(); // Wire button callbacks (S1=UP, S2=DOWN, S3=ACTION) // ── S1 (UP) ─────────────────────────────────────────────────────────── @@ -500,6 +502,7 @@ auto PoolControllerContext::loop() -> void { // Update NORVI OLED display and read front-panel buttons NorviOledDisplay::loop(); NorviButtonHandler::loop(); + CalibrationManager::loop(); #endif // Run drivers & logic rules diff --git a/src/WebPortal.cpp b/src/WebPortal.cpp index 8fa4a59c..88b08e17 100644 --- a/src/WebPortal.cpp +++ b/src/WebPortal.cpp @@ -42,6 +42,7 @@ #ifdef NORVI_AE01_R #include "NorviButtonHandler.hpp" +#include "CalibrationManager.hpp" #endif namespace PoolController { @@ -211,6 +212,25 @@ void WebPortal::setupRoutes() { return; apiSaveConfig(); }); + +#ifdef NORVI_AE01_R + // Button calibration wizard (NORVI) + server_.on("/api/calibrate/start", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateStart(); + }); + server_.on("/api/calibrate/status", HTTP_GET, []() { + if (!handleAuthentication()) + return; + apiCalibrateStatus(); + }); + server_.on("/api/calibrate/cancel", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateCancel(); + }); +#endif server_.on("/api/mode", HTTP_POST, []() { if (!handleAuthentication()) return; @@ -465,6 +485,11 @@ void WebPortal::apiGetStatus() { doc["local_ip"] = NetworkManager::getLocalIP(); doc["fw_version"] = FW_VERSION; doc["authenticated"] = isClientAuthenticated(); +#ifdef NORVI_AE01_R + doc["norvi"] = true; +#else + doc["norvi"] = false; +#endif // Current date/time in configured timezone TimeChangeRule *tcr; @@ -868,6 +893,38 @@ void WebPortal::apiSaveConfig() { server_.send(400, "text/plain", "Invalid Config Request"); } +#ifdef NORVI_AE01_R +// ── Button calibration (NORVI) ──────────────────────────────────────────── + +void WebPortal::apiCalibrateStart() { + if (!CalibrationManager::start()) { + server_.send(409, "text/plain", "Calibration already running"); + return; + } + server_.send(200, "text/plain", "OK"); +} + +void WebPortal::apiCalibrateStatus() { + const auto st = CalibrationManager::getStatus(); + JsonDocument doc; + doc["step"] = static_cast(st.step); + doc["live_adc"] = st.liveAdc; + doc["resting"] = st.restingLevel; + doc["s1"] = st.s1; + doc["s2"] = st.s2; + doc["s3"] = st.s3; + doc["message"] = st.message; + String json; + serializeJson(doc, json); + server_.send(200, "application/json", json); +} + +void WebPortal::apiCalibrateCancel() { + CalibrationManager::cancel(); + server_.send(200, "text/plain", "OK"); +} +#endif // NORVI_AE01_R + void WebPortal::apiSetMode() { if (!server_.hasArg("mode")) { server_.send(400, "application/json", "{\"status\":\"error\",\"message\":\"Missing mode\"}"); diff --git a/src/WebPortal.hpp b/src/WebPortal.hpp index 0831097b..21c29acc 100644 --- a/src/WebPortal.hpp +++ b/src/WebPortal.hpp @@ -130,6 +130,12 @@ class WebPortal { static void apiFsUpload(); /** @brief Streaming upload handler for /api/fs/upload multipart file data. */ static void handleFsUploadStream(); + /** @brief POST /api/calibrate/start — start the NORVI button calibration wizard. */ + static void apiCalibrateStart(); + /** @brief GET /api/calibrate/status — return calibration status as JSON. */ + static void apiCalibrateStatus(); + /** @brief POST /api/calibrate/cancel — cancel a running calibration. */ + static void apiCalibrateCancel(); static WebServer server_; static DNSServer dnsServer_; diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index 84f4238c..1dc9b399 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -32,18 +32,22 @@ include_directories( ) # ── Wrappers ────────────────────────────────────────────────────────────── -# WebPortal, MqttPublisher, and Timer include class headers whose mock layout -# differs from production. Compiling from wrappers/ avoids GCC's -# "current-file-directory first" rule for #include "..." so the mock headers -# take effect. +# WebPortal, MqttPublisher, Timer, and CalibrationManager include class +# headers whose mock layout differs from production. Compiling from +# wrappers/ avoids GCC's "current-file-directory first" rule for +# #include "..." so the mock headers take effect. file(COPY ${PROJ_ROOT}/src/WebPortal.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/MqttPublisher.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/Timer.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) +file(COPY ${PROJ_ROOT}/src/CalibrationManager.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) +file(COPY ${PROJ_ROOT}/src/NorviButtonHandler.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) set(WRAPPER_SOURCES ${CMAKE_CURRENT_BINARY_DIR}/wrappers/WebPortal.cpp ${CMAKE_CURRENT_BINARY_DIR}/wrappers/MqttPublisher.cpp ${CMAKE_CURRENT_BINARY_DIR}/wrappers/Timer.cpp + ${CMAKE_CURRENT_BINARY_DIR}/wrappers/CalibrationManager.cpp + ${CMAKE_CURRENT_BINARY_DIR}/wrappers/NorviButtonHandler.cpp ) # ── Service classes ─────────────────────────────────────────────────────── @@ -56,9 +60,8 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/OtaUpdater.cpp ${PROJ_ROOT}/src/LogCapture.cpp ${PROJ_ROOT}/src/LocalSettingsMenu.cpp - ${PROJ_ROOT}/src/Ky040Decoder.cpp +${PROJ_ROOT}/src/Ky040Decoder.cpp ${PROJ_ROOT}/src/OlimexEncoderHandler.cpp - ${PROJ_ROOT}/src/NorviButtonHandler.cpp ) # Mock sources (compiled once) @@ -84,6 +87,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_webportal_logs.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_local_settings_menu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ky040_decoder.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp ) add_executable(test_runner diff --git a/test/native/mocks/ConfigManager.cpp b/test/native/mocks/ConfigManager.cpp index 67fd0a00..56e07f74 100644 --- a/test/native/mocks/ConfigManager.cpp +++ b/test/native/mocks/ConfigManager.cpp @@ -10,6 +10,7 @@ NtpConfig ConfigManager::_ntp; String ConfigManager::_adminPasswordHash = // SHA-256("admin") "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; bool ConfigManager::_configured = false; +bool ConfigManager::_saveFails = false; // ── Sensor Address Mapping ── uint8_t ConfigManager::_sensorSolarAddr[8] = {0}; diff --git a/test/native/mocks/ConfigManager.hpp b/test/native/mocks/ConfigManager.hpp index d1f2300e..d3dd8c6c 100644 --- a/test/native/mocks/ConfigManager.hpp +++ b/test/native/mocks/ConfigManager.hpp @@ -51,7 +51,8 @@ class ConfigManager { static void setConfigured(bool configured) { _configured = configured; } static bool begin() { return true; } static bool load() { return true; } - static bool save() { return true; } + static bool save() { return !_saveFails; } + static bool _saveFails; // test hook static void reset() { _adminPasswordHash = // gitleaks:allow SHA-256("admin") F("8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"); diff --git a/test/native/tests/test_calibration_manager.cpp b/test/native/tests/test_calibration_manager.cpp new file mode 100644 index 00000000..661bd3e5 --- /dev/null +++ b/test/native/tests/test_calibration_manager.cpp @@ -0,0 +1,355 @@ +#include +#include "CalibrationManager.hpp" +#include "ConfigManager.hpp" + +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::CalibrationManager; + +// Test hooks: controllable ADC + clock +static uint16_t g_adc = 0; +static uint32_t g_now = 0; +static uint16_t fakeAdc() { + return g_adc; +} +static uint32_t fakeTime() { + return g_now; +} + +static int test_start_from_idle() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::start(), true); + ASSERT_EQ(CalibrationManager::isActive(), true); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_resting_measurement() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // Stable resting level: advance through the wait + sample phases + for (uint32_t t = 0; t < 2000; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700); + return 0; +} + +static int test_timeout_retries_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // No stable level: ADC oscillates wildly, never stabilizes + g_adc = 100; + for (uint32_t t = 0; t < 11000; t += 50) { + g_now = t; + g_adc = (g_adc + 500) % 4096; // never stable + CalibrationManager::loop(); + } + // Still in RESTING (retried), not advanced + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_cancel_from_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + CalibrationManager::cancel(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::IDLE); + return 0; +} + +static int test_full_calibration_saves_thresholds() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + // Helper: hold a level for the wait + sample phases, then switch + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3400, 2000); // S1 + holdLevel(3700, 4000); // S2 + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE); + auto &s = PoolController::ConfigManager::getSettings(); + ASSERT_EQ(s.btn1Min, (2700 + 3400) / 2); + ASSERT_EQ(s.btn1Max, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Min, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Max, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Min, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Max, 4095); + ASSERT_EQ(s.btnNoPress, 4096); + return 0; +} + +static int test_sample_spacing_gate() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + // Wait phase: 3 stable readings → sampling starts at t=100 + for (uint32_t t = 0; t <= 100; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + + // Rapid loop() calls without time advance must not collect samples — + // the sample phase is gated on SAMPLE_INTERVAL_MS. + for (int i = 0; i < 100; i++) { + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + + // Advance time in 50 ms steps → sampling completes over ~1 s window + for (uint32_t t = 150; t < 2000; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700); + return 0; +} + +static int test_average_below_gap_retries_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting → BTN1 at t=1100 + + // BTN1: wait phase accepts 2800 (exactly at the minimum gap boundary), + // then the samples drift down to 2790 — still inside the stability + // window, but the average lands below previousLevel + MIN_LEVEL_GAP. + g_adc = 2800; + for (uint32_t t = 2000; t <= 2100; t += 50) { // wait t=2000..2100 → sampling + g_now = t; + CalibrationManager::loop(); + } + g_adc = 2790; + for (uint32_t t = 2150; t < 3150; t += 50) { // 20 samples t=2150..3100 → completes + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().s1, 0); + + // Press again well above the gap → completes normally + holdLevel(2900, 3500); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN2); + ASSERT_EQ(CalibrationManager::getStatus().s1, 2900); + return 0; +} + +static int test_sample_restart_on_level_change() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting → BTN1 at t=1100 + + // BTN1: stabilize at 3400, then release mid-sampling → restart + g_adc = 3400; + for (uint32_t t = 2000; t <= 2200; t += 50) { // wait t=2000..2100, samples t=2150,2200 + g_now = t; + CalibrationManager::loop(); + } + g_adc = 2700; // release during sampling + for (uint32_t t = 2250; t < 3000; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().s1, 0); + + // Press again → sampling completes with a clean level + holdLevel(3400, 3000); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN2); + ASSERT_EQ(CalibrationManager::getStatus().s1, 3400); + return 0; +} + +static int test_release_level_rejected() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3700, 2000); // S1 + holdLevel(2700, 4000); // release level (below S1) — must be rejected + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN2); + holdLevel(3900, 5500); // valid S2 + holdLevel(4095, 8000); // S3 + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE); + return 0; +} + +static int test_exact_minimum_gap_accepted() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + // Every level sits exactly MIN_LEVEL_GAP (100) above the previous one — + // the inclusive boundary must accept these. + holdLevel(2700, 0); // resting + holdLevel(2800, 2000); // S1 = resting + 100 + holdLevel(2900, 4000); // S2 = S1 + 100 + holdLevel(3000, 6000); // S3 = S2 + 100 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE); + return 0; +} + +static int test_save_failure_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; + g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); + holdLevel(3400, 2000); + holdLevel(3700, 4000); + + // Snapshot the live thresholds before the failed save + auto &s = PoolController::ConfigManager::getSettings(); + const uint16_t prevBtn1Min = s.btn1Min; + const uint16_t prevBtn1Max = s.btn1Max; + const uint16_t prevBtn2Min = s.btn2Min; + const uint16_t prevBtn2Max = s.btn2Max; + const uint16_t prevBtn3Min = s.btn3Min; + const uint16_t prevBtn3Max = s.btn3Max; + const uint16_t prevBtnNoPress = s.btnNoPress; + + // Force save failure before the final step completes → ERROR state + PoolController::ConfigManager::_saveFails = true; + holdLevel(4095, 6000); + PoolController::ConfigManager::_saveFails = false; + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + // Live Settings must be untouched when persistence fails + ASSERT_EQ(s.btn1Min, prevBtn1Min); + ASSERT_EQ(s.btn1Max, prevBtn1Max); + ASSERT_EQ(s.btn2Min, prevBtn2Min); + ASSERT_EQ(s.btn2Max, prevBtn2Max); + ASSERT_EQ(s.btn3Min, prevBtn3Min); + ASSERT_EQ(s.btn3Max, prevBtn3Max); + ASSERT_EQ(s.btnNoPress, prevBtnNoPress); + return 0; +} + +int run_calibration_manager_tests() { + int failures = 0; + failures += test_start_from_idle(); + failures += test_resting_measurement(); + failures += test_timeout_retries_step(); + failures += test_cancel_from_step(); + failures += test_full_calibration_saves_thresholds(); + failures += test_sample_spacing_gate(); + failures += test_sample_restart_on_level_change(); + failures += test_average_below_gap_retries_step(); + failures += test_release_level_rejected(); + failures += test_exact_minimum_gap_accepted(); + failures += test_save_failure_error(); + test_suite_end("CalibrationManager", 11 - failures, failures); + if (failures == 0) { + printf(" CalibrationManager Tests: 11 passed, 0 failed\n"); + } + return failures; +} diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index e5f33827..30518b34 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -110,6 +110,7 @@ extern int run_logcapture_tests(); extern int run_webportal_logs_tests(); extern int run_local_settings_menu_tests(); extern int run_ky040_decoder_tests(); +extern int run_calibration_manager_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -128,6 +129,7 @@ int main() { total += run_webportal_logs_tests(); total += run_local_settings_menu_tests(); total += run_ky040_decoder_tests(); + total += run_calibration_manager_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); diff --git a/test/native/tests/test_webportal_json.cpp b/test/native/tests/test_webportal_json.cpp index 397ff9fb..45aadbeb 100644 --- a/test/native/tests/test_webportal_json.cpp +++ b/test/native/tests/test_webportal_json.cpp @@ -258,5 +258,37 @@ int run_webportal_json_tests() { passed++; } + // ── Test: calibration status JSON ── + { + test_begin("WebPortal::apiCalibrateStatus", "returns JSON with all fields"); + + JsonDocument doc; + doc["step"] = 1; + doc["live_adc"] = 2700; + doc["resting"] = 0; + doc["s1"] = 0; + doc["s2"] = 0; + doc["s3"] = 0; + doc["message"] = "Release all buttons — measuring resting level"; + + int errs = 0; + const char *requiredKeys[] = {"step", "live_adc", "resting", "s1", "s2", "s3", "message"}; + for (auto key : requiredKeys) { + if (!doc.containsKey(key)) { + char msg[128]; + snprintf(msg, sizeof(msg), "Missing required key: %s", key); + test_fail(__FILE__, __LINE__, msg); + errs++; + } + } + ASSERT_TRUE(doc["step"].is()); + ASSERT_TRUE(doc["live_adc"].is()); + ASSERT_TRUE(doc["message"].is()); + + test_suite_end("WebPortal::apiCalibrateStatus", errs == 0 ? 3 : 0, errs); + passed += (errs == 0) ? 1 : 0; + failed += errs; + } + return passed + failed; } From 2a8f91993e890dd12ffb54a7d22b68c417ef764f Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Mon, 17 Aug 2026 21:52:20 +0200 Subject: [PATCH 15/25] fix(timer): keep pump running when extension wraps past midnight (#180) --- src/Rule.hpp | 44 ++-- test/native/mocks/TestTime.hpp | 12 ++ test/native/mocks/stubs.cpp | 12 +- test/native/tests/test_rules.cpp | 354 +++++++++++++++++++++++++++++++ 4 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 test/native/mocks/TestTime.hpp diff --git a/src/Rule.hpp b/src/Rule.hpp index 5bde1b24..292c471e 100644 --- a/src/Rule.hpp +++ b/src/Rule.hpp @@ -54,7 +54,10 @@ class Rule { /** * @brief Reset temperature-based runtime extension (call on mode change). */ - void resetTemperatureExtension() { _activeEndMinutes = 0; } + void resetTemperatureExtension() { + _activeEndMinutes = 0; + _activeEndTime = 0; + } /** * @brief Get current temperature-extended end time in minutes since midnight. @@ -195,14 +198,27 @@ class Rule { timerActive = (difftime(now, start) >= 0) && (difftime(now, end) <= 0); } - uint16_t nowMinutes = time.tm_hour * 60 + time.tm_min; - // Step 1: If timer is active, apply temperature extension if (timerActive && poolTemp > 0.0f && poolTemp == poolTemp) { uint16_t extendedEnd = calculateEffectiveEndMinutes(baseStartMinutes, baseEndMinutes, poolTemp); - // Only extend, never shorten - if (extendedEnd > _activeEndMinutes) { + // Absolute expiry anchored to the current cycle's base start. Minutes + // since midnight alone cannot tell a wrap-past-midnight end apart from + // the next cycle's start — e.g. a full-day extension (max runtime 1440) + // ends exactly at the next base start and would otherwise never expire, + // keeping the pump on even after the water cools. + time_t cycleStart = start; + if (crossesMidnight && now < start) { + // Post-midnight part of a midnight-crossing window: the cycle started + // on the previous day. + cycleStart = start - 86400; + } + int totalRuntime = static_cast(extendedEnd) - static_cast(baseStartMinutes); + time_t expiry = cycleStart + static_cast(totalRuntime) * 60; + + // Only extend, never shorten (within the current cycle) + if (expiry > _activeEndTime) { + _activeEndTime = expiry; _activeEndMinutes = extendedEnd; uint8_t eh = (_activeEndMinutes / 60) % 24; uint8_t em = _activeEndMinutes % 60; @@ -213,20 +229,10 @@ class Rule { } // Step 2: Check if we're within the extended window - if (_activeEndMinutes > 0) { - // Normalize extended end to 0-1439 for comparison with nowMinutes - uint16_t normalizedEnd = _activeEndMinutes % 1440; - - bool inExtendedWindow; - if (crossesMidnight) { - // Extended window with midnight crossing - inExtendedWindow = (nowMinutes >= baseStartMinutes || nowMinutes <= normalizedEnd); - } else { - inExtendedWindow = (nowMinutes < normalizedEnd); - } - - if (inExtendedWindow) { + if (_activeEndTime > 0) { + if (now < _activeEndTime) { #ifdef DEBUG_RULE_TIMER + uint16_t normalizedEnd = _activeEndMinutes % 1440; Serial.printf( " checkPoolPumpTimer = true (extended to %02d:%02d)\n", (uint8_t)(normalizedEnd / 60), (uint8_t)(normalizedEnd % 60)); #endif @@ -234,6 +240,7 @@ class Rule { } // Extended window expired — reset + _activeEndTime = 0; _activeEndMinutes = 0; } @@ -328,4 +335,5 @@ class Rule { TimerSetting _timerSetting; uint16_t _activeEndMinutes = 0; ///< Temperature-extended end in minutes since midnight + time_t _activeEndTime = 0; ///< Absolute expiry of the temperature extension (0 = none) }; diff --git a/test/native/mocks/TestTime.hpp b/test/native/mocks/TestTime.hpp new file mode 100644 index 00000000..202e0158 --- /dev/null +++ b/test/native/mocks/TestTime.hpp @@ -0,0 +1,12 @@ +#pragma once +#include + +/** + * @brief Test-only hook to control the mock clock used by getCurrentDateTime(). + * + * The native test harness stubs getTimeFor() (see stubs.cpp) to return a + * controllable epoch. Tests that need a specific wall-clock time build the + * epoch via mktime() from a tm struct, so the localtime_r() round-trip inside + * getCurrentDateTime() is independent of the host timezone. + */ +void setMockTime(time_t t); \ No newline at end of file diff --git a/test/native/mocks/stubs.cpp b/test/native/mocks/stubs.cpp index ba3be016..9b3d3319 100644 --- a/test/native/mocks/stubs.cpp +++ b/test/native/mocks/stubs.cpp @@ -92,10 +92,20 @@ uint8_t getTimeDegradationRedHours() { return 24; } +// Controllable mock clock for timer-window tests. Defaults to epoch 0 +// (1970-01-01 00:00 UTC) to preserve existing test behavior; tests that need +// a specific wall-clock time call setMockTime() with an epoch built via +// mktime() so the localtime_r() round-trip is host-timezone independent. +static time_t g_mockTime = 0; + +void setMockTime(time_t t) { + g_mockTime = t; +} + time_t getTimeFor(int tzIndex, TimeChangeRule **tcr) { if (tcr) *tcr = nullptr; - return 0; + return g_mockTime; } String getTimeInfoFor(int) { return String("UTC"); diff --git a/test/native/tests/test_rules.cpp b/test/native/tests/test_rules.cpp index e2da06fc..84a39ece 100644 --- a/test/native/tests/test_rules.cpp +++ b/test/native/tests/test_rules.cpp @@ -8,11 +8,13 @@ // Mock includes (these are picked up via -I mocks/) #include "Arduino.h" +#include "ConfigManager.hpp" #include "Rule.hpp" #include "RuleAuto.hpp" #include "RuleManu.hpp" #include "RuleBoost.hpp" #include "RuleTimer.hpp" +#include "TestTime.hpp" // Test framework extern void test_begin(const char *suite, const char *name); @@ -20,6 +22,37 @@ extern void test_pass(const char *file, int line); extern void test_fail(const char *file, int line, const char *msg); extern void test_suite_end(const char *name, int passed, int failed); +/** + * @brief Test harness exposing the protected pump-timer logic. + */ +class PumpTimerHarness : public Rule { +public: + using Rule::checkPoolPumpTimer; // expose both overloads + using Rule::setTimerSetting; + using Rule::getActiveEndMinutes; + const char *getMode() override { return "test"; } + void loop() override {} +}; + +/** + * @brief Set the mock wall-clock time used by getCurrentDateTime(). + * + * The epoch is built with mktime() from a tm struct, so the localtime_r() + * round-trip inside getCurrentDateTime() yields the same wall-clock time + * regardless of the host timezone. + */ +static void setWallClock(int hour, int min, int dayOffset = 0) { + tm t = {}; + t.tm_year = 2026 - 1900; + t.tm_mon = 5; // June — avoids DST ambiguity + t.tm_mday = 15 + dayOffset; + t.tm_hour = hour; + t.tm_min = min; + t.tm_sec = 0; + t.tm_isdst = -1; + setMockTime(mktime(&t)); +} + #define ASSERT_TRUE(cond) \ do { \ if (!(cond)) { \ @@ -184,5 +217,326 @@ int run_rule_tests() { test_suite_end("RuleTimer::extended", rc == 0 ? 1 : 0, rc != 0 ? 1 : 0); } + // ── Test: temp extension wrapping past midnight with same-day base timer ── + // Base 16:00-20:00 (does NOT cross midnight), pool 40.0°C: + // threshold 24.0, factor 30 → extra = 16*30 = 480 + // baseRuntime = 240, totalRuntime = min(240+480, 720) = 720 + // extended = 960 + 720 = 1680 → 04:00 next day (wraps past midnight) + // Regression: Step 2 previously keyed the midnight check on the BASE timer + // (crossesMidnight=false), so during the base window the extension was reset + // and the pump turned OFF at 20:00 instead of running until 04:00. + { + test_begin("PumpTimer", "wrap-past-midnight extension keeps pump ON"); + + int suiteFailed = 0; + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 16; + ts.timerStartMinutes = 0; + ts.timerEndHour = 20; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(18, 0); // inside base window → extension applied + bool on = timer.checkPoolPumpTimer(40.0f); + bool extSet = (timer.getActiveEndMinutes() == 1680); + rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1680)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(20, 30); // past base end, still inside extended window + on = timer.checkPoolPumpTimer(40.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 20:30 — extension was reset (bug)"); + failed++; + } + + setWallClock(2, 0, 1); // 02:00 next day, still inside extended window + on = timer.checkPoolPumpTimer(40.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 02:00 — expected extension active until 04:00"); + failed++; + } + + setWallClock(4, 30, 1); // 04:30 next day → extension expired + on = timer.checkPoolPumpTimer(40.0f); + rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 0 and 0)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + test_suite_end("PumpTimer::wrap_past_midnight", suiteFailed == 0 ? 4 : 0, suiteFailed != 0 ? 1 : 0); + } + + // ── Test: same-day extension still works (no regression) ── + // Base 08:00-18:00, pool 28.0°C → extra = 4*30 = 120, total = 720, + // extended = 480 + 720 = 1200 (20:00, no wrap). + { + test_begin("PumpTimer", "same-day extension keeps pump ON"); + + int suiteFailed = 0; + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 8; + ts.timerStartMinutes = 0; + ts.timerEndHour = 18; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(10, 0); // inside base window → extension applied + bool on = timer.checkPoolPumpTimer(28.0f); + bool extSet = (timer.getActiveEndMinutes() == 1200); + rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1200)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(19, 0); // past base end, inside extended window (until 20:00) + on = timer.checkPoolPumpTimer(28.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 19:00 — expected extension active until 20:00"); + failed++; + } + + setWallClock(20, 30); // past extended end → extension expired + on = timer.checkPoolPumpTimer(28.0f); + rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 0 and 0)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + test_suite_end("PumpTimer::same_day", suiteFailed == 0 ? 3 : 0, suiteFailed != 0 ? 1 : 0); + } + + // ── Test: base timer crossing midnight without extension (no regression) ── + // Base 22:00-06:00, pool 20.0°C (below threshold) → no extension. + // Window must still be active at e.g. 02:00 and inactive at 12:00. + { + test_begin("PumpTimer", "midnight-crossing base timer without extension"); + + int suiteFailed = 0; + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 22; + ts.timerStartMinutes = 0; + ts.timerEndHour = 6; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(2, 0); // inside base window (after midnight) + bool on = timer.checkPoolPumpTimer(20.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 02:00 — expected base window active"); + failed++; + } + + setWallClock(12, 0); // outside base window + on = timer.checkPoolPumpTimer(20.0f); + rc = (!on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump ON at 12:00 — expected outside base window"); + failed++; + } + + test_suite_end("PumpTimer::base_crosses_midnight", suiteFailed == 0 ? 2 : 0, suiteFailed != 0 ? 1 : 0); + } + + // ── Test: base timer crossing midnight WITH extension (no regression) ── + // Base 22:00-06:00, pool 30.0°C → extra = 6*30 = 180, total = 480+180 = 660, + // extended = 1320 + 660 = 1980 → 09:00 next day (wraps). + { + test_begin("PumpTimer", "midnight-crossing base timer with extension"); + + int suiteFailed = 0; + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 22; + ts.timerStartMinutes = 0; + ts.timerEndHour = 6; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + setWallClock(23, 0); // inside base window → extension applied + bool on = timer.checkPoolPumpTimer(30.0f); + bool extSet = (timer.getActiveEndMinutes() == 1980); + rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1980)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(8, 0, 1); // next day 08:00, inside extended window (until 09:00) + on = timer.checkPoolPumpTimer(30.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 08:00 — expected extension active until 09:00"); + failed++; + } + + setWallClock(10, 0, 1); // next day 10:00 → extension expired + on = timer.checkPoolPumpTimer(30.0f); + rc = (!on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump ON at 10:00 — expected extension expired"); + failed++; + } + + test_suite_end("PumpTimer::crossing_with_extension", suiteFailed == 0 ? 3 : 0, suiteFailed != 0 ? 1 : 0); + } + + // ── Test: full-day extension (max runtime 1440) expires at the next cycle ── + // Base 16:00-20:00, pool 64.0°C, tempCircMaxRuntime = 1440: + // extra = 40*30 = 1200, baseRuntime = 240, total = min(1440, 1440) = 1440 + // extended = 960 + 1440 = 2400 → 16:00 next day (full 24 h cycle) + // Regression: normalizedEnd (2400 % 1440 = 960) equals baseStartMinutes, so + // the old wrap predicate "now >= start || now <= start" was true at every + // minute — the extension never expired, even after the water cooled. + { + test_begin("PumpTimer", "full-day extension expires at next cycle"); + + PoolController::ConfigManager::getSettings().tempCircMaxRuntime = 1440; + + PumpTimerHarness timer; + TimerSetting ts; + ts.timerStartHour = 16; + ts.timerStartMinutes = 0; + ts.timerEndHour = 20; + ts.timerEndMinutes = 0; + timer.setTimerSetting(ts); + + int suiteFailed = 0; + + setWallClock(18, 0); // inside base window → full-day extension applied + bool on = timer.checkPoolPumpTimer(64.0f); + bool extSet = (timer.getActiveEndMinutes() == 2400); + rc = (on && extSet) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 2400)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(10, 0, 1); // next day 10:00 — still within the 24 h extension + on = timer.checkPoolPumpTimer(64.0f); + rc = (on) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + test_fail(__FILE__, __LINE__, "Pump OFF at 10:00 — expected full-day extension active"); + failed++; + } + + setWallClock(16, 0, 1); // next cycle start, water cooled below threshold + on = timer.checkPoolPumpTimer(20.0f); + bool reset = (timer.getActiveEndMinutes() == 1200); // reset to base end + rc = (on && reset) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 1 and 1200)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + setWallClock(22, 0, 1); // next day 22:00 → extension must be gone + on = timer.checkPoolPumpTimer(20.0f); + rc = (!on && timer.getActiveEndMinutes() == 0) ? 0 : 1; + suiteFailed |= rc; + if (rc == 0) { + test_pass(__FILE__, __LINE__); + passed++; + } else { + char msg[128]; + snprintf(msg, sizeof(msg), "on=%d activeEnd=%u (expected 0 and 0)", on, timer.getActiveEndMinutes()); + test_fail(__FILE__, __LINE__, msg); + failed++; + } + + PoolController::ConfigManager::getSettings().tempCircMaxRuntime = 720; + + test_suite_end("PumpTimer::full_day_expiry", suiteFailed == 0 ? 4 : 0, suiteFailed != 0 ? 1 : 0); + } + return passed + failed; } From 9fe127129414a4e59bf1d9176d7b56f4e8900d11 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:52:57 +0200 Subject: [PATCH 16/25] build(deps): bump github/codeql-action from 4.37.6 to 4.37.7 (#183) --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0f2f58d6..d3d53195 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -37,7 +37,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.6 + uses: github/codeql-action/init@v4.37.7 with: languages: ${{ matrix.language }} @@ -57,7 +57,7 @@ jobs: # The reliable fix is to post-process the SARIF and strip results # under .pio/libdeps/ before uploading. - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.6 + uses: github/codeql-action/analyze@v4.37.7 with: output: sarif-results upload: never @@ -74,6 +74,6 @@ jobs: done - name: Upload CodeQL results - uses: github/codeql-action/upload-sarif@v4.37.6 + uses: github/codeql-action/upload-sarif@v4.37.7 with: sarif_file: sarif-results From 3d61ebd096b9dcd2bd1b2a8f0ee07215745e2693 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Tue, 18 Aug 2026 13:13:20 +0200 Subject: [PATCH 17/25] perf(web): optimize PWA startup (#184) --- .gitignore | 3 ++ data/web/app.js | 80 ++++++++++++++++++++++++++++++++---- data/web/index.html | 32 +++++++++++---- data/web/sw.js | 84 +++++++++++++++++++++++++------------- scripts/gzip-web-assets.sh | 25 ++++++++++++ src/WebPortal.cpp | 78 +++++++++++++++++++++-------------- src/WebPortal.hpp | 2 + 7 files changed, 231 insertions(+), 73 deletions(-) create mode 100755 scripts/gzip-web-assets.sh diff --git a/.gitignore b/.gitignore index d8aaceac..e6557f09 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,6 @@ test/native/relay_safety/build/ # Frontend (separate JS project) — ignore lockfiles and node_modules test/frontend/ graphify-out/ + +# Pre-compressed web assets (generated by scripts/gzip-web-assets.sh) +data/web/*.gz diff --git a/data/web/app.js b/data/web/app.js index 2a58a31a..da5f49f0 100644 --- a/data/web/app.js +++ b/data/web/app.js @@ -21,6 +21,18 @@ function switchTab(tabName) { // Close more menu if open const moreMenu = document.getElementById('moreMenu'); if (moreMenu) moreMenu.style.display = 'none'; + + // Lazy-load tab data on first activation instead of at page load: the + // single-threaded device server would otherwise queue config/sensor + // requests behind the dashboard telemetry poll on every page open. + if (!configLoaded && ['pool', 'time', 'wifi', 'mqtt', 'system'].includes(tabName)) { + configLoaded = true; + loadConfig(); + } + if (!sensorsLoaded && tabName === 'sensors') { + sensorsLoaded = true; + loadSensors(); + } } function toggleMoreMenu() { @@ -38,6 +50,9 @@ console.log('[pool] app.js loaded, version=2026-06-05'); let isAuthenticated = false; let hasAutoSwitchedToWifi = false; // one-shot guard so AP-mode redirect doesn't fight user navigation +let configLoaded = false; // lazy-load guard: /api/config fetched on first admin tab activation +let sensorsLoaded = false; // lazy-load guard: /api/sensors fetched on first Sensors tab activation +let configLoadsInFlight = 0; // >0 while /api/config is loading; config save buttons stay disabled async function loadTelemetry() { try { @@ -301,6 +316,14 @@ function updateAuthUI() { } } + // Config fields and save buttons must stay disabled while /api/config is + // loading (or failed to load): the loops above re-enable every tab control + // on each telemetry poll and would otherwise undo setConfigFieldsDisabled() + // before the load finishes — or expose markup defaults after a failure. + if (isAuthenticated && (configLoadsInFlight > 0 || !configLoaded)) { + setConfigFieldsDisabled(true); + } + // 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 @@ -941,9 +964,34 @@ async function factoryReset() { // ── Load Config ── +// Config save buttons stay disabled while /api/config is loading, so a save +// cannot submit markup defaults for fields not yet populated by the response. +// Config fields and save buttons stay disabled while /api/config is loading: +// a save must not submit markup defaults, and a late response must not +// overwrite edits entered before the load finished. +const CONFIG_FIELD_SELECTOR = '#tab-pool input, #tab-pool select, #tab-pool button, ' + + '#tab-time input, #tab-time select, #tab-time button, ' + + '#tab-wifi input, #tab-wifi select, #tab-wifi button, ' + + '#tab-mqtt input, #tab-mqtt select, #tab-mqtt button'; +const CONFIG_SAVE_BUTTONS = ['btnSavePassword']; // system tab save is outside the field tabs + +function setConfigFieldsDisabled(disabled) { + for (const el of document.querySelectorAll(CONFIG_FIELD_SELECTOR)) { + el.disabled = disabled; + } + CONFIG_SAVE_BUTTONS.forEach(id => { + const btn = document.getElementById(id); + if (btn) btn.disabled = disabled; + }); +} + async function loadConfig() { + configLoadsInFlight++; + setConfigFieldsDisabled(true); + let ok = false; try { const res = await fetch('/api/config'); + if (!res.ok) throw new Error('config request failed: ' + res.status); const data = await res.json(); document.getElementById('wifiSsid').value = data.wifi.ssid; @@ -976,8 +1024,17 @@ async function loadConfig() { document.getElementById('poolThreshold').textContent = 'max ' + data.settings.temp_max_pool.toFixed(1) + '°C'; document.getElementById('solarThreshold').textContent = 'min ' + data.settings.temp_min_solar.toFixed(1) + '°C'; highlightMode(data.settings.op_mode); + ok = true; } catch (e) { - // Silent + // Transient failure — keep the save buttons disabled and reset the + // lazy-load guard so the next tab activation retries, instead of + // leaving markup defaults editable without a loaded config. + configLoaded = false; + } finally { + configLoadsInFlight--; + if (configLoadsInFlight === 0 && ok) { + setConfigFieldsDisabled(false); + } } } @@ -1104,6 +1161,7 @@ let loadedMapping = { solar: null, pool: null }; async function loadSensors() { try { const res = await fetch('/api/sensors'); + if (!res.ok) throw new Error('sensors request failed: ' + res.status); const data = await res.json(); const solarAddr = data.mapping.solar || null; @@ -1139,9 +1197,17 @@ async function loadSensors() { buildRadioGroup('poolRadioGroup', devices, solarAddr, poolAddr, 'pool'); updateSensorSaveBar(); + + // Only mark the guard loaded on success so a failed request retries on + // the next tab activation instead of being skipped forever. + sensorsLoaded = true; } catch (e) { + // Transient failure — reset the guard for a retry on the next tab + // activation and offer an inline refresh control. + sensorsLoaded = false; document.getElementById('sensorList').innerHTML = - '
Failed to load sensors: ' + e.message + '
'; + '
Failed to load sensors: ' + e.message + + '
'; } } @@ -1359,8 +1425,8 @@ updateAuthUI = function() { setInterval(loadTelemetry, 2000); setInterval(loadLogs, 2000); -window.onload = function() { - loadTelemetry(); - loadConfig(); - loadSensors(); -}; +// The script is deferred, so the DOM is fully parsed at this point. Start the +// telemetry loop immediately instead of waiting for window.onload (which waits +// for every resource, including the async stylesheet). Config and sensor data +// are lazy-loaded on first tab activation (see switchTab). +loadTelemetry(); diff --git a/data/web/index.html b/data/web/index.html index 57eec0a7..d3f53f58 100644 --- a/data/web/index.html +++ b/data/web/index.html @@ -19,8 +19,24 @@ - - + + + + + +

Pool Controller

@@ -245,7 +261,7 @@

WLAN Configuration

- + @@ -267,7 +283,7 @@

MQTT Settings

- + @@ -410,7 +426,7 @@

⏱ Timer Schedule

- + @@ -456,7 +472,7 @@

⚠ Time Sync Degradation

- + @@ -517,7 +533,7 @@

Security Settings

- +

Firmware Version

@@ -614,7 +630,7 @@

â„č About

- + -# Pool Controller | \ud83c\udfca Smart Swimming Pool +# Pool Controller | 🏊 Smart Swimming Pool The **Pool Controller** is the **central control unit** for your smart swimming pool. Built around an **ESP32 microcontroller**, it provides intelligent automation for pool circulation, solar heating, and comprehensive monitoring. @@ -38,37 +38,37 @@ The **Pool Controller** is the **central control unit** for your smart swimming - [x] Timesync via NTP (europe.pool.ntp.org) - [x] Logging of system events and diagnostics -### \ud83c\udfca Pool Management +### 🏊 Pool Management - **Timed circulation** for automatic water cleaning - **Solar heating control** via additional pump - **Multiple operation modes**: Auto, Manual, Boost, Timer - **Temperature-based automation** (disable solar heating when pool is too hot) -### \ud83c\udf10 Smart Home Integration -- **Home Assistant MQTT Discovery** (v3.3.0+) \u2014 Native integration with automatic entity creation -- **MQTT protocol** \u2014 Works with any MQTT-compatible smart home system -- **REST API** \u2014 Direct device control via HTTP -- **Web Dashboard** \u2014 Built-in web interface for configuration and monitoring +### 🌐 Smart Home Integration +- **Home Assistant MQTT Discovery** (v3.3.0+) — Native integration with automatic entity creation +- **MQTT protocol** — Works with any MQTT-compatible smart home system +- **REST API** — Direct device control via HTTP +- **Web Dashboard** — Built-in web interface for configuration and monitoring -### \ud83d\udee1\ufe0f Reliability & 24/7 Operation -- **State Persistence** \u2014 All settings survive reboots and power failures -- **System Health Monitoring** \u2014 Continuous health checks with auto-recovery -- **Memory Optimization** \u2014 Efficient resource usage for long-term operation -- **Hardware Watchdog** \u2014 Automatic recovery from system hangs +### đŸ›Ąïž Reliability & 24/7 Operation +- **State Persistence** — All settings survive reboots and power failures +- **System Health Monitoring** — Continuous health checks with auto-recovery +- **Memory Optimization** — Efficient resource usage for long-term operation +- **Hardware Watchdog** — Automatic recovery from system hangs -### \ud83d\udd27 Developer Features -- **Over-The-Air (OTA) Updates** \u2014 Remote firmware updates via WiFi -- **NTP Time Synchronization** \u2014 Automatic time sync with configurable servers -- **Timezone Support** \u2014 DST handling for 10 major timezones -- **Comprehensive Logging** \u2014 Debug information via MQTT +### 🔧 Developer Features +- **Over-The-Air (OTA) Updates** — Remote firmware updates via WiFi +- **NTP Time Synchronization** — Automatic time sync with configurable servers +- **Timezone Support** — DST handling for 10 major timezones +- **Comprehensive Logging** — Debug information via MQTT -## \ud83d\udce6 Quick Start +## 📩 Quick Start **New users:** Begin with the [Quick Start Guide](quick-start.md) for step-by-step setup instructions. **Experienced users:** See the [Hardware Guide](hardware-guide.md) for parts list and wiring, or the [MQTT Configuration](mqtt-configuration.md) for smart home integration. -## \ud83c\udf10 MQTT Topics Overview +## 🌐 MQTT Topics Overview The Pool Controller publishes and subscribes to the following MQTT topics: @@ -91,40 +91,40 @@ smart-swimmingpool/pool-controller/settings **Complete Reference:** [MQTT Configuration Guide](mqtt-configuration.md) -## \ud83d\udcbb Hardware Requirements +## đŸ’» Hardware Requirements | Component | Qty | Approx. Cost | Notes | |-----------|:---:|:------------:|-------| -| ESP32 Development Board | 1 | 10\u201315\u20ac | 4MB+ flash required | -| DS18B20 Temperature Sensor (waterproof) | 2 | 8\u201312\u20ac | Pool + solar collector | -| 2-Channel 5V Relay Module | 1 | 5\u20138\u20ac | With optocoupler isolation | -| Resistor 4.7k\u03a9 | 2 | < 1\u20ac | Pull-up for OneWire | -| USB Power Supply 5V/\u22651A | 1 | 5\u201310\u20ac | For ESP32 | -| **Total** | | **~45\u201375\u20ac** | Without pumps | +| ESP32 Development Board | 1 | 10–15€ | 4MB+ flash required | +| DS18B20 Temperature Sensor (waterproof) | 2 | 8–12€ | Pool + solar collector | +| 2-Channel 5V Relay Module | 1 | 5–8€ | With optocoupler isolation | +| Resistor 4.7kΩ | 2 | < 1€ | Pull-up for OneWire | +| USB Power Supply 5V/≄1A | 1 | 5–10€ | For ESP32 | +| **Total** | | **~45–75€** | Without pumps | -## \ud83d\ude80 Getting Started +## 🚀 Getting Started -1. **Order Parts** \u2014 See [Hardware Guide](hardware-guide.md) for complete BOM -2. **Assemble Hardware** \u2014 Follow wiring diagrams and safety instructions -3. **Flash Firmware** \u2014 Use PlatformIO to build and upload -4. **Configure WiFi & MQTT** \u2014 Via web interface or serial monitor -5. **Integrate with Smart Home** \u2014 Auto-discovery with Home Assistant +1. **Order Parts** — See [Hardware Guide](hardware-guide.md) for complete BOM +2. **Assemble Hardware** — Follow wiring diagrams and safety instructions +3. **Flash Firmware** — Use PlatformIO to build and upload +4. **Configure WiFi & MQTT** — Via web interface or serial monitor +5. **Integrate with Smart Home** — Auto-discovery with Home Assistant -## \ud83d\udce2 Support & Community +## 📱 Support & Community - **Documentation:** [smart-swimmingpool.com](https://smart-swimmingpool.com) - **Discussions:** [GitHub Discussions](https://github.com/smart-swimmingpool/smart-swimmingpool.github.io/discussions) - **Issues:** [GitHub Issues](https://github.com/smart-swimmingpool/pool-controller/issues) -## \ud83d\udcdc Additional Resources +## 📜 Additional Resources -- [PlatformIO Documentation](https://docs.platformio.org/) \u2014 Build system and development -- [ESP32 Datasheet](https://www.espressif.com/en/products/socs/esp32) \u2014 Microcontroller specifications -- [MQTT Protocol](https://mqtt.org/) \u2014 Message Queuing Telemetry Transport -- [Home Assistant MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) \u2014 Smart home integration -- [DS18B20 Datasheet](https://datasheets.maximintegrated.com/en/ds/DS18B20.pdf) \u2014 Temperature sensor specifications +- [PlatformIO Documentation](https://docs.platformio.org/) — Build system and development +- [ESP32 Datasheet](https://www.espressif.com/en/products/socs/esp32) — Microcontroller specifications +- [MQTT Protocol](https://mqtt.org/) — Message Queuing Telemetry Transport +- [Home Assistant MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) — Smart home integration +- [DS18B20 Datasheet](https://datasheets.maximintegrated.com/en/ds/DS18B20.pdf) — Temperature sensor specifications -## \ud83d\udce1\ufe0f Version Information +## đŸ“Ąïž Version Information **Current Version:** v3.3.0 (ESP32-only) diff --git a/docs/mqtt-configuration.de.md b/docs/mqtt-configuration.de.md index ffcd9a0d..1a0500d8 100644 --- a/docs/mqtt-configuration.de.md +++ b/docs/mqtt-configuration.de.md @@ -162,7 +162,7 @@ Ein gebrauchsfertiges Lovelace-Dashboard-Beispiel mit: ist verfĂŒgbar in: -- [`docs/home-assistant-dashboard-pool.yaml`](home-assistant-dashboard-pool.yaml) +- [`docs/home-assistant/dashboard.yaml`](home-assistant/dashboard.yaml) ## Migration von Homie (Vor v3.3.0) diff --git a/docs/mqtt-configuration.md b/docs/mqtt-configuration.md index 59ed0a18..18f60a2d 100644 --- a/docs/mqtt-configuration.md +++ b/docs/mqtt-configuration.md @@ -161,7 +161,7 @@ A ready-to-use Lovelace dashboard example with: is available in: -- [`docs/home-assistant-dashboard-pool.yaml`](home-assistant-dashboard-pool.yaml) +- [`docs/home-assistant/dashboard.yaml`](home-assistant/dashboard.yaml) ## Migration from Homie (Pre-v3.3.0) diff --git a/docs/quick-start.md b/docs/quick-start.md index 75106292..e151b579 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -6,8 +6,8 @@ weight: 10 --- **Target audience:** DIY enthusiasts with **basic electronics knowledge** (soldering, GPIO, MQTT). -**Time required:** ~4\u20136 hours (including parts sourcing). -**Cost:** ~45\u201375\u20ac (excluding pumps and pool infrastructure). +**Time required:** ~4–6 hours (including parts sourcing). +**Cost:** ~45–75€ (excluding pumps and pool infrastructure). This guide walks you through **every step** to get your Pool Controller up and running, from ordering parts to integrating with your smart home. @@ -31,9 +31,9 @@ This guide walks you through **every step** to get your Pool Controller up and r --- -## \ud83d\udce6 Step 1: Order Parts +## 📩 Step 1: Order Parts -### \ud83d\uded2 Shopping List (BOM) +### 🛒 Shopping List (BOM) Use the following table to order all required components. @@ -41,20 +41,20 @@ Use the following table to order all required components. | # | Component | Qty | Approx. Cost | Notes | Recommended Links | |---|-----------|:---:|:------------:|-------|------------------| -| 1 | ESP32 Development Board (e.g., ESP32 DevKit V1, NodeMCU-32S) | 1 | 10\u201315\u20ac | **Must have 4MB+ flash** | [Amazon DE](https://www.amazon.de/s?k=ESP32+DevKit+V1), [Reichelt](https://www.reichelt.de/) | -| 2 | DS18B20 Temperature Sensor (waterproof, stainless steel, 1m cable) | 2 | 8\u201312\u20ac | One for pool, one for solar collector | [Amazon DE](https://www.amazon.de/s?k=DS18B20+wasserfest), [AliExpress](https://www.aliexpress.com/) | -| 3 | 2-Channel 5V Relay Module (with optocoupler isolation) | 1 | 5\u20138\u20ac | **Must be active-high trigger!** (relay activates when GPIO signal is HIGH) | [Amazon DE](https://www.amazon.de/s?k=2+Channel+5V+Relay+Module), [Reichelt](https://www.reichelt.de/) | -| 4 | Resistor 4.7k\u03a9 (\u00bcW, metal film) | 2 | < 1\u20ac | Pull-up for OneWire data lines | [Reichelt](https://www.reichelt.de/), [Conrad](https://www.conrad.de/) | -| 5 | Breadboard + jumper wires (for prototyping) **OR** Perfboard + pin headers (for permanent build) | 1 | 3\u20138\u20ac | Breadboard for testing, perfboard for final build | [Amazon DE](https://www.amazon.de/s?k=breadboard), [Reichelt](https://www.reichelt.de/) | -| 6 | USB Power Supply 5V/\u22651A (e.g., phone charger) | 1 | 5\u201310\u20ac | Powers ESP32 + relay module | Any USB charger | -| 7 | Hookup wire (0.14\u20130.5mmÂČ, various colors) | — | 3\u20135\u20ac | For permanent wiring | [Reichelt](https://www.reichelt.de/) | -| 8 | Enclosure (IP54+ for outdoor use) | 1 | 5\u201310\u20ac | Optional but recommended | [Amazon DE](https://www.amazon.de/s?k=IP54+Geh\u00e4use), [Reichelt](https://www.reichelt.de/) | -| 9 | Screw terminals (2-pin, 5mm pitch) | 4\u20136 | 2\u20133\u20ac | For removable connections | [Reichelt](https://www.reichelt.de/) | -| **Total** | | | **~45\u201375\u20ac** | Without pumps/pool infrastructure | | +| 1 | ESP32 Development Board (e.g., ESP32 DevKit V1, NodeMCU-32S) | 1 | 10–15€ | **Must have 4MB+ flash** | [Amazon DE](https://www.amazon.de/s?k=ESP32+DevKit+V1), [Reichelt](https://www.reichelt.de/) | +| 2 | DS18B20 Temperature Sensor (waterproof, stainless steel, 1m cable) | 2 | 8–12€ | One for pool, one for solar collector | [Amazon DE](https://www.amazon.de/s?k=DS18B20+wasserfest), [AliExpress](https://www.aliexpress.com/) | +| 3 | 2-Channel 5V Relay Module (with optocoupler isolation) | 1 | 5–8€ | **Must be active-high trigger!** (relay activates when GPIO signal is HIGH) | [Amazon DE](https://www.amazon.de/s?k=2+Channel+5V+Relay+Module), [Reichelt](https://www.reichelt.de/) | +| 4 | Resistor 4.7kΩ (ÂŒW, metal film) | 2 | < 1€ | Pull-up for OneWire data lines | [Reichelt](https://www.reichelt.de/), [Conrad](https://www.conrad.de/) | +| 5 | Breadboard + jumper wires (for prototyping) **OR** Perfboard + pin headers (for permanent build) | 1 | 3–8€ | Breadboard for testing, perfboard for final build | [Amazon DE](https://www.amazon.de/s?k=breadboard), [Reichelt](https://www.reichelt.de/) | +| 6 | USB Power Supply 5V/≄1A (e.g., phone charger) | 1 | 5–10€ | Powers ESP32 + relay module | Any USB charger | +| 7 | Hookup wire (0.14–0.5mmÂČ, various colors) | — | 3–5€ | For permanent wiring | [Reichelt](https://www.reichelt.de/) | +| 8 | Enclosure (IP54+ for outdoor use) | 1 | 5–10€ | Optional but recommended | [Amazon DE](https://www.amazon.de/s?k=IP54+GehĂ€use), [Reichelt](https://www.reichelt.de/) | +| 9 | Screw terminals (2-pin, 5mm pitch) | 4–6 | 2–3€ | For removable connections | [Reichelt](https://www.reichelt.de/) | +| **Total** | | | **~45–75€** | Without pumps/pool infrastructure | | > **â„č Note:** The relay module **must** be **active-high** (relay ON when GPIO = HIGH). Many cheap modules are active-low by default but have a jumper to switch the logic. Verify this before permanent installation! -### \ud83d\udd0c ESP32 Board Selection +### 🔌 ESP32 Board Selection **Recommended boards:** - **ESP32 DevKit V1** — Most common, widely available @@ -67,7 +67,7 @@ Use the following table to order all required components. **Verification:** Check that your board has the **ESP32-WROOM-32** module and **4MB+ flash memory**. -### \ud83c\udf10 Relay Module Selection +### 🌐 Relay Module Selection **Critical:** The relay module **must** have **optocoupler isolation** for safety and **active-high logic** for compatibility with the firmware. @@ -86,13 +86,13 @@ Use the following table to order all required components. --- -## \ud83d\udd0c Step 2: Assemble Hardware +## 🔌 Step 2: Assemble Hardware -### \ud83d\udccc Required Tools +### 📌 Required Tools | Tool | Purpose | Notes | |------|---------|-------| -| Soldering iron | Soldering components | 320\u2013350\u00b0C for leaded solder | +| Soldering iron | Soldering components | 320–350°C for leaded solder | | Solder | Electrical connections | Leaded recommended for beginners | | Flux | Improve solder flow | Rosin-core flux | | Wire cutters/strippers | Cut and strip wires | For hookup wire | @@ -130,7 +130,7 @@ ESP32 Development Board - Ensure all pins are properly inserted 2. **Add pull-up resistors** - - Insert 4.7k\u0019 resistors between 3.3V rail and GPIO32, GPIO33 + - Insert 4.7kΩ resistors between 3.3V rail and GPIO32, GPIO33 - These are required for DS18B20 sensor communication 3. **Connect DS18B20 sensors** @@ -185,9 +185,9 @@ ESP32 Development Board --- -## \u26a1 Step 3: Connect to Pumps and Sensors +## ⚡ Step 3: Connect to Pumps and Sensors -### \u26a1 Relay Wiring (230V AC) +### ⚡ Relay Wiring (230V AC) > **⚠ WARNING: Mains voltage! Disconnect power before wiring!** @@ -211,7 +211,7 @@ Relay COM ----- Mains L (Phase) - Keep **low-voltage wiring (ESP32, sensors) physically separate** from mains wiring - Use **cable glands** for outdoor installations -### \ud83c\udf21 DS18B20 Sensor Installation +### đŸŒĄïž DS18B20 Sensor Installation **Pool Sensor:** - Place sensor in pool water, **away from direct sunlight** @@ -251,7 +251,7 @@ Relay COM ----- Mains L (Phase) cd pool-controller ``` -### \u26a1 Connect ESP32 to Computer +### ⚡ Connect ESP32 to Computer 1. Connect ESP32 to computer via **USB cable** (data-capable, not charge-only) 2. Check that device is detected: @@ -261,7 +261,7 @@ Relay COM ----- Mains L (Phase) 3. **Note the serial port** for the next step -### \u26a1 Build and Flash +### ⚡ Build and Flash #### Using PlatformIO CLI ```bash @@ -452,9 +452,9 @@ for ready-to-use `.things` and `.items` examples. --- -## \ud83c\udf00 Step 8: Final Installation +## 🌀 Step 8: Final Installation -### \ud83c\udf00 Enclosure +### 🌀 Enclosure 1. **Choose enclosure** - **IP54+ rating** for outdoor use @@ -470,10 +470,10 @@ for ready-to-use `.things` and `.items` examples. - Label all screw terminals - Create a **wiring diagram** for future reference -### \ud83c\udf00 Power Supply +### 🌀 Power Supply 1. **Choose power supply** - - **USB phone charger** (5V/\u22651A) for indoor use + - **USB phone charger** (5V/≄1A) for indoor use - **Outdoor-rated power supply** for external installation - **USB power bank** for temporary setup @@ -481,7 +481,7 @@ for ready-to-use `.things` and `.items` examples. - Use **surge protector** for outdoor installations - Consider **UPS** for power failure protection -### \ud83c\udf00 Location +### 🌀 Location 1. **Indoor installation** (recommended) - Protect from weather @@ -496,11 +496,11 @@ for ready-to-use `.things` and `.items` examples. --- -## \ud83d\udc89 Next Steps +## 💉 Next Steps Now that your Pool Controller is up and running, explore these advanced features: -### \u26a1 Automation +### ⚡ Automation - **Set up automation rules** in Home Assistant/openHAB - **Temperature-based control:** Turn on solar heating when pool is cold @@ -527,7 +527,7 @@ automation: entity_id: switch.pool_controller_solar_pump ``` -### \u26a1 Monitoring +### ⚡ Monitoring - **Set up Grafana dashboard** for historical data - **Configure alerts** for abnormal conditions @@ -535,7 +535,7 @@ automation: **Grafana Dashboard:** [smart-swimmingpool/grafana-dashboard](https://github.com/smart-swimmingpool/grafana-dashboard) -### \u26a1 Advanced Configuration +### ⚡ Advanced Configuration - **Adjust temperature thresholds** for your climate - **Configure circulation schedules** based on pool usage @@ -546,9 +546,9 @@ automation: --- -## \ud83d\ude4f Tips for Success +## 🙏 Tips for Success -### \u26a1 Best Practices +### ⚡ Best Practices 1. **Start with breadboard prototyping** before permanent installation 2. **Test each component individually** before connecting everything @@ -558,16 +558,16 @@ automation: 6. **Test in AP mode first** before configuring WiFi/MQTT 7. **Monitor serial output** during initial setup -### \u26a1 Common Pitfalls +### ⚡ Common Pitfalls 1. **Active-low vs active-high relays** — Verify your relay logic! 2. **Missing pull-up resistors** — DS18B20 sensors won't work without them -3. **Insufficient power supply** — Use 5V/\u22651A for ESP32 + relay +3. **Insufficient power supply** — Use 5V/≄1A for ESP32 + relay 4. **WiFi range issues** — Ensure good signal at installation location 5. **MQTT broker not running** — Verify broker is accessible from ESP32 6. **Mains wiring errors** — Always use RCD and verify with electrician -### \u26a1 Maintenance +### ⚡ Maintenance 1. **Regularly check connections** for corrosion or loose wires 2. **Clean DS18B20 sensors** periodically (especially pool sensor) @@ -577,7 +577,7 @@ automation: --- -## \ud83d\udce2 Need Help? +## 📱 Need Help? If you encounter issues: @@ -593,9 +593,9 @@ If you encounter issues: --- -## \ud83d\udcbb Additional Resources +## đŸ’» Additional Resources -### \u26a1 Documentation +### ⚡ Documentation - [Hardware Guide](hardware-guide.md) — Detailed assembly instructions - [Users Guide](users-guide.md) — Operation modes and web interface @@ -604,7 +604,7 @@ If you encounter issues: - [OTA Updates](ota-updates.md) — Remote firmware updates - [Software Guide](software-guide.md) — Development and build process -### \u26a1 External Resources +### ⚡ External Resources - [PlatformIO Documentation](https://docs.platformio.org/) — Build system - [ESP32 Datasheet](https://www.espressif.com/en/products/socs/esp32) — Microcontroller @@ -613,7 +613,7 @@ If you encounter issues: - [Home Assistant MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery) — Smart home integration - [Electrical Safety](https://www.electricalsafetyfirst.org.uk/) — General safety guidelines -### \u26a1 Community +### ⚡ Community - [GitHub Discussions](https://github.com/smart-swimmingpool/smart-swimmingpool.github.io/discussions) — Ask questions, share ideas - [Home Assistant Community](https://community.home-assistant.io/) — Smart home discussions diff --git a/docs/quickstart.md b/docs/quickstart.md index 9664600f..fdf29458 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -374,7 +374,7 @@ _Beispielaufbau auf einem Breadboard_ - Gehe zu **Dashboard → Bearbeiten**. - FĂŒge die **Pool Controller-EntitĂ€ten** hinzu (z. B. Temperatursensoren, Schalter fĂŒr Pumpen). - **Beispiel-Dashboard:** - - [Lovelace Dashboard fĂŒr Pool Controller](https://github.com/smart-swimmingpool/pool-controller/blob/main/docs/home-assistant-dashboard-pool.yaml) + - [Lovelace Dashboard fĂŒr Pool Controller](https://github.com/smart-swimmingpool/pool-controller/blob/main/docs/home-assistant/dashboard.yaml) --- diff --git a/docs/security-fixes-v3.2.1.md b/docs/security-fixes-v3.2.1.md index b9be0aa4..def7c6f3 100644 --- a/docs/security-fixes-v3.2.1.md +++ b/docs/security-fixes-v3.2.1.md @@ -808,8 +808,6 @@ For questions or issues related to these security fixes: - **Security Issues**: Open a confidential issue at [GitHub Security](https://github.com/smart-swimmingpool/pool-controller/security) - **General Issues**: Open an issue at [GitHub Issues](https://github.com/smart-swimmingpool/pool-controller/issues) -- **Discussions**: Join the discussion at [GitHub - Discussions](https://github.com/smart-swimmingpool/smart-swimmingpool.github.io/discussions) ---