From a0b069c7cb1eb9cde0a30fe0c63696ab1940bbaf Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 20:45:30 +0200 Subject: [PATCH 01/28] docs(spec): add multicore task architecture design --- ...8-01-multicore-task-architecture-design.md | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md diff --git a/docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md b/docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md new file mode 100644 index 00000000..c6dabaa6 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md @@ -0,0 +1,219 @@ +# Design: Multicore Task Architecture for Pool Controller + +**Date:** 2026-08-01 +**Status:** Approved (brainstorming, Approach 1) +**Scope:** Restructure the pool controller firmware to use both ESP32 cores — dedicated +I/O tasks on Core 0 (sensors, display, telemetry publishing) and a deterministic control +loop on Core 1 (rules, relays, watchdog). Covers both build environments +(`norvi_ae01_r` and `esp32dev`). + +## Motivation + +The controller currently runs a single Arduino `loop()` on Core 1 while Core 0 only +serves the WiFi/BT stack. Everything — sensor reads, display updates, rules, network, +MQTT — is serialized into one loop iteration: + +| Blocking point | Typical cost | +| -------------- | ------------ | +| DS18B20 `requestTemperatures()` (12-bit conversion, shared bus) | ~750 ms | +| OLED full-buffer push over I2C (`display.display()`) | ~10–40 ms | +| StatusLED, button debounce, MQTT publish serialization | <1 ms each | + +A DS18B20 conversion therefore stalls the entire control loop — watchdog feeding, +rule evaluation, and relay actuation — for up to 750 ms per measurement cycle. This +couples I/O latency to control determinism and leaves half the chip idle. + +## Requirements (from brainstorming) + +1. **Loop latency:** Remove blocking sensor/display work from the control loop so + loop iteration time stays in the low millisecond range. +2. **Isolation:** A hung or slow I/O subsystem (sensor bus, I2C display) must never + block rule evaluation or relay actuation. +3. **Headroom:** Free capacity for future features (more sensors, web UI, logging). +4. **Scope:** Both build environments (`norvi_ae01_r` with OLED/buttons, `esp32dev` + without). NORVI-specific tasks are guarded by `#ifdef NORVI_AE01_R`. +5. **No behavior change:** Existing features (rules, modes, MQTT/Homie, OTA, watchdog, + degradation, safe mode) keep their semantics. Native tests stay green. + +## Approach (chosen: A — task architecture with explicit core separation) + +| Core | Role | Contents | +| ---- | ---- | -------- | +| **Core 0** (PRO_CPU) | I/O core | SensorTask (DS18B20 + internal temp), DisplayTask (OLED/buttons, NORVI only), PublishTask (MQTT telemetry serialization) | +| **Core 1** (APP_CPU) | Control core | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, async network managers, OTA | + +Rationale: Core 1 keeps the safety-critical, deterministic work. Core 0 absorbs the +blocking I/O. The WiFi/BT stack already lives on Core 0; its protocol tasks run at +higher priority than our I/O tasks, so network responsiveness is not degraded. + +**Deliberately NOT moved:** the MQTT *connection* and web/OTA managers stay in the +control loop — `AsyncMqttClient` and the web server are already non-blocking. Only the +*telemetry serialization + publish* work (JSON build, HA Discovery payloads) is +offloaded via a queue to PublishTask. + +## Architecture + +### 1. Task framework (`src/CoreScheduler.{hpp,cpp}` — new) + +Small, static, heap-friendly task launcher. No dynamic task creation after setup. + +- `CoreScheduler::begin()` — called from `setup()` after `initializeController()`. + Creates the I/O tasks pinned to Core 0 with explicit priorities and stack sizes. +- Task list (all Core 0, priority/stack tuned, no heap growth in steady state): + +| Task | Priority | Stack | Runs | Notes | +| ---- | -------- | ----- | ---- | ----- | +| SensorTask | 2 | 6 KB | always | DS18B20 buses + ESP32 internal temp | +| PublishTask | 1 | 4 KB | always | drains telemetry queue → MQTT | +| DisplayTask | 1 | 3 KB | NORVI only | OLED render + button scan | + +- Control loop keeps running on Core 1 with its existing implicit priority (Arduino + `loopTask`, above tskIDLE). FreeRTOS priorities only matter within a core, so the + I/O tasks never preempt the control loop directly; on Core 0 they yield via + `vTaskDelay` at their scheduling period so they stay below the WiFi-stack tasks. +- All tasks feed the task watchdog when they take long paths (DS18B20 conversion + wait, OTA pause). + +### 2. SensorTask (`src/SensorTask.{hpp,cpp}` — new) + +Owns the DS18B20 buses and the ESP32 internal temperature sensor. + +- Runs the Dallas/OneWire access *exclusively* (OneWire is not thread-safe; the + control loop must never touch the buses anymore). +- Per-period sequence: request conversion → yield (`vTaskDelay`) → read results → + publish to consumers via thread-safe slots (see §4). +- Reuses the existing address-filter/rescan logic of `DallasTemperatureNode` / + `ESP32TemperatureNode`, but calls their I/O methods from this task. The node + `loop()` methods are replaced by `SensorTask`-driven measurement steps. +- Sensor/measurement intervals stay configurable as today. + +### 3. DisplayTask (NORVI only, `src/NorviOledDisplay.*`) + +Moves `NorviOledDisplay::loop()` and `NorviButtonHandler::loop()` to Core 0. + +- Renders from a shared, mutex-protected snapshot of display state (temps, modes, + network status) produced by the control loop — the display task never reads live + singletons directly. +- Keeps existing burn-in shift logic and menu/setup flows; button input is written + back to the control loop via a small input queue/flag so setup state machine + semantics are unchanged. +- Guarded by `#ifdef NORVI_AE01_R`; `esp32dev` gets no DisplayTask. + +### 4. PublishTask + telemetry queue + +- A fixed-capacity queue (FreeRTOS `xQueueCreate`, e.g. 8 slots) carries publish + requests from the control loop to PublishTask. +- PublishTask builds JSON/HA payloads and calls `AsyncMqttClient::publish()` — the + call is non-blocking from the library side; heavy serialization no longer runs in + the control loop. +- Discovery + state publishing on (re)connect keeps its current trigger points; they + simply enqueue instead of serializing inline. + +### 5. Shared state & synchronization + +| Channel | Mechanism | Producer → Consumer | +| ------- | --------- | ------------------- | +| Sensor values | lock-free slots (atomics/`volatile` + seqlock where >1 word) | SensorTask → control loop, DisplayTask | +| Display state | mutex-protected snapshot struct | control loop → DisplayTask | +| Button input | small queue / atomic flags | DisplayTask → control loop | +| Telemetry | `xQueueCreate` publish queue | control loop → PublishTask | +| Log capture | existing `LogCapture` critical section | any task | + +Rule: every cross-task data path is single-writer. No singleton is mutated from two +tasks without an explicit sync primitive (see §7 audit). + +### 6. Watchdog & reliability integration + +- Control loop keeps feeding the task watchdog as today; SensorTask/DisplayTask/ + PublishTask feed it during long I/O waits. +- `SystemMonitor::checkMemory()` still runs in the control loop; task stacks are + sized and asserted (`uxTaskGetStackHighWaterMark`) at startup + periodic check so + stack overflow risk is visible in logs/degradation. +- Safe mode / degradation semantics unchanged: `DegradationManager` stays a control + loop concern; sensor faults are reported to it from SensorTask via a thread-safe + status channel. +- OTA: during an update, PublishTask pauses publishing (flag) but keeps draining its + queue to avoid buildup. + +### 7. Thread-safety audit (existing singletons) + +Required before merge — each singleton's access pattern is reviewed and, where a +method is called from a non-control task, hardened: + +| Singleton | Cross-task access today | Action | +| --------- | ----------------------- | ------ | +| `DegradationManager` | from SensorTask (status) | add mutex/atomic status channel; keep `evaluate()` on Core 1 | +| `MqttPublisher` | from PublishTask (enqueue → serialize) | move serialization into PublishTask; control loop only enqueues | +| `NetworkManager` | control loop only | no change (verify) | +| `ConfigManager` | control loop only (setup) | no change (verify) | +| `SystemMonitor` | watchdog feed from I/O tasks | add thread-safe feed wrapper | +| `OperationModeNode` / rules | control loop only | no change (verify) | +| `LogCapture` | any task | already has critical section (verify coverage) | +| `NorviOledDisplay` / buttons | DisplayTask only | display state via snapshot; input via queue | + +### 8. Error handling + +- SensorTask on bus error: keeps retry/rescan logic, reports to `DegradationManager`, + publishes NaN — identical semantics to today, just executed off-core. +- Task watchdog timeout → ESP32 resets via TWDT (unchanged global behavior). +- If a task crashes: core panic handler / TWDT behavior unchanged; `SystemMonitor` + boot-loop detection still applies. + +## Data flow (steady state) + +```text +SensorTask (Core 0) ── lock-free slots ──▶ control loop (Core 1): rules/relays/watchdog +SensorTask ── status ────────────────────▶ DegradationManager (Core 1) +control loop ── snapshot ────────────────▶ DisplayTask (Core 0, NORVI) +DisplayTask ── button queue ─────────────▶ control loop +control loop ── telemetry queue ─────────▶ PublishTask (Core 0) ──▶ MQTT +control loop ── async network/OTA ──────── (unchanged, Core 1) +``` + +## Testing + +- **Native unit tests (`test/native`)**: add tests for `CoreScheduler` (task + creation/priority/stack assertions), telemetry queue, sensor slot + synchronization, and the thread-safety wrappers. Mocks already cover + DallasTemperature/SSD1306/AsyncMqttClient; extend mocks where the new + cross-task API requires it. +- **Relay safety regression tests (`test/native/relay_safety`)**: must stay green — + relay logic remains on Core 1 unchanged. +- **CI**: native tests + coverage unchanged; new files must stay under the same + coverage expectations. +- **Manual on-device**: verify loop iteration time (log/debug counter) drops from + ~750 ms to <20 ms during sensor reads; verify OLED still renders and buttons work; + verify MQTT telemetry cadence unchanged. + +## Migration plan + +1. **Phase 1 — framework + SensorTask:** `CoreScheduler`, sensor slots, move DS18B20 + reads off-core. (Biggest latency win, foundation for everything else.) +2. **Phase 2 — PublishTask + queue:** telemetry serialization off the control loop. +3. **Phase 3 — DisplayTask (NORVI):** OLED/buttons off-core. +4. **Phase 4 — audit + hardening:** thread-safety pass on all singletons, watchdog + feeds, stack high-water-mark logging, native tests for each new module. +5. **Phase 5 — docs:** `docs/multicore-architecture.md` + DE variant; update + `software-guide` if it documents the loop. + +Each phase keeps the build green and tests passing; phases land in the same PR branch +sequentially. + +## Risks & mitigations + +| Risk | Mitigation | +| ---- | ---------- | +| Thread-safety bugs in singletons | §7 audit + native tests; strict single-writer rule | +| OneWire bus contention | only SensorTask touches Dallas/OneWire | +| Heap cost of task stacks (~13 KB) | fixed static stacks, no dynamic growth; monitor via existing heap checks | +| Core 0 contention with WiFi stack | I/O tasks at low priority, `vTaskDelay`-based yields | +| OTA timing | PublishTask pause flag during update | +| I2C display hangs | DisplayTask on Core 0; control loop never waits on I2C | + +## Success criteria + +1. Loop iteration time during sensor reads: **<20 ms** (from ~750 ms). +2. All native tests green (ASan), relay safety regression green. +3. No heap growth in steady state (min free heap stable over 24 h). +4. No watchdog resets attributable to the new tasks. +5. OLED/buttons (NORVI) and MQTT telemetry behave as before the change. From 9c57229015103f4687db92a5cfac63fa1b42cadb Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 20:45:30 +0200 Subject: [PATCH 02/28] docs(multicore): add EN/DE architecture documentation --- docs/multicore-architecture.de.md | 101 ++++++++++++++++++++++++++++++ docs/multicore-architecture.md | 100 +++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/multicore-architecture.de.md create mode 100644 docs/multicore-architecture.md diff --git a/docs/multicore-architecture.de.md b/docs/multicore-architecture.de.md new file mode 100644 index 00000000..44e1f39e --- /dev/null +++ b/docs/multicore-architecture.de.md @@ -0,0 +1,101 @@ +--- +title: Multicore-Architektur +summary: Wie die Firmware beide ESP32-Kerne nutzt — dedizierte I/O-Tasks (Sensoren, Display, MQTT-Telemetrie) auf Kern 0 und eine deterministische Regelschleife auf Kern 1 +date: "2026-08-01" +lastmod: "2026-08-01" +draft: false +toc: true +type: docs +featured: false +tags: ["docs", "controller", "architektur", "multicore", "tasks"] +menu: + docs: + parent: Pool Controller + name: Multicore-Architektur + weight: 33 +--- + +## Überblick + +Der ESP32 hat zwei Xtensa-LX6-Kerne, aber ein Single-Loop-Arduino-Sketch nutzt nur +einen: Der WiFi/BT-Stack läuft auf Kern 0, die Arduino-`loop()` auf Kern 1. Alles +andere — Sensor-Messungen, Display-Update, Regeln, Netzwerk, MQTT — läuft seriell +innerhalb von `loop()`. + +Die Firmware wird zu einer **Task-Architektur mit expliziter Kern-Trennung** +umstrukturiert: + +| Kern | Rolle | Inhalt | +| ---- | ----- | ------ | +| **Kern 0** (PRO_CPU) | I/O-Kern | SensorTask (DS18B20 + interner Temperatursensor), DisplayTask (OLED/Taster, nur NORVI), PublishTask (MQTT-Telemetrie-Serialisierung) | +| **Kern 1** (APP_CPU) | Regel-Kern | Arduino-`loop()`: Watchdog, Degradation, Regeln, Relais, Status-LED, asynchrone Netzwerk-Manager, OTA | + +## Warum + +Blockierende Arbeit blockierte bisher die gesamte Regelschleife. Die teuerste +Operation ist die DS18B20-Temperaturkonvertierung (`requestTemperatures()`), die bei +12-Bit-Auflösung etwa **750 ms** blockiert. In dieser Zeit warten Watchdog-Feeding, +Regelauswertung und Relais-Ansteuerung. + +Das Auslagern dieser I/O-Arbeit in dedizierte Tasks auf Kern 0 bringt drei Vorteile: + +1. **Geringe Loop-Latenz** — die Regelschleife bleibt im niedrigen Millisekundenbereich. +2. **Isolation** — ein hängender Sensor-Bus oder ein I2C-Display kann die + sicherheitskritische Regellogik auf Kern 1 nicht mehr blockieren. +3. **Headroom** — Kapazität für zukünftige Funktionen (mehr Sensoren, Web-UI, Logging). + +## Task-Modell + +Alle I/O-Tasks werden in `setup()` vom `CoreScheduler` erzeugt und bleiben statisch +(keine dynamische Task-Erzeugung zur Laufzeit, kein Heap-Wachstum). + +| Task | Kern | Priorität | Stack | Läuft auf | +| ---- | ---- | --------- | ----- | --------- | +| SensorTask | 0 | 2 | 6 KB | allen Builds | +| PublishTask | 0 | 1 | 4 KB | allen Builds | +| DisplayTask | 0 | 1 | 3 KB | nur NORVI (`#ifdef NORVI_AE01_R`) | + +FreeRTOS-Prioritäten gelten nur innerhalb eines Kerns: Die I/O-Tasks geben per +`vTaskDelay` nach und bleiben unterhalb der WiFi-Stack-Tasks auf Kern 0 — sie können +die Regelschleife auf Kern 1 also nie verdrängen. + +## Datenfluss + +```text +SensorTask (Kern 0) ── lock-free Slots ──▶ Regelschleife (Kern 1): Regeln/Relais/Watchdog +SensorTask ── Status ────────────────────▶ DegradationManager (Kern 1) +Regelschleife ── Snapshot ───────────────▶ DisplayTask (Kern 0, NORVI) +DisplayTask ── Taster-Queue ─────────────▶ Regelschleife +Regelschleife ── Telemetrie-Queue ────────▶ PublishTask (Kern 0) ──▶ MQTT +Regelschleife ── asynchrones Netzwerk/OTA ── (unverändert, Kern 1) +``` + +Jeder task-übergreifende Datenpfad ist **Single-Writer**: + +- Sensorwerte: lock-free Slots (atomar/ein Wort) — SensorTask schreibt, Regelschleife liest. +- Display-Zustand: Mutex-geschützter Snapshot — Regelschleife schreibt, DisplayTask liest. +- Taster-Eingaben: kleine Queue / atomare Flags — DisplayTask schreibt, Regelschleife liest. +- Telemetrie: FreeRTOS-Queue mit fester Kapazität — Regelschleife stellt ein, + PublishTask serialisiert und publiziert. + +Die MQTT-*Verbindung* und die Web-/OTA-Manager bleiben in der Regelschleife — sie +sind bereits nicht-blockierend (`AsyncMqttClient`, asynchroner Webserver). Ausgelagert +wird nur die Telemetrie-Serialisierung (JSON-Aufbau, HA-Discovery-Payloads) in den +PublishTask. + +## Zuverlässigkeit + +- Die Regelschleife füttert den Task-Watchdog weiterhin; I/O-Tasks füttern ihn bei + langen Wartezeiten (DS18B20-Konvertierung, OTA-Pause). +- `SystemMonitor` meldet die Stack-High-Water-Marks der Tasks, sodass die + Stack-Größen in Logs und Degradation sichtbar sind. +- Safe-Mode- und Degradations-Semantik bleiben unverändert — Sensorfehler werden dem + `DegradationManager` über einen thread-sicheren Statuskanal gemeldet. +- Während OTA pausiert der PublishTask das Publizieren, leert seine Queue aber + weiter. + +## Design-Dokument + +Das vollständige Design inklusive Thread-Safety-Audit der bestehenden Singletons, +Migrationsphasen, Risiken und Erfolgskriterien liegt unter +[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md). diff --git a/docs/multicore-architecture.md b/docs/multicore-architecture.md new file mode 100644 index 00000000..be9d0476 --- /dev/null +++ b/docs/multicore-architecture.md @@ -0,0 +1,100 @@ +--- +title: Multicore Architecture +summary: How the firmware uses both ESP32 cores — dedicated I/O tasks (sensors, display, MQTT telemetry) on Core 0 and a deterministic control loop on Core 1 +date: "2026-08-01" +lastmod: "2026-08-01" +draft: false +toc: true +type: docs +featured: false +tags: ["docs", "controller", "architecture", "multicore", "tasks"] +menu: + docs: + parent: Pool Controller + name: Multicore Architecture + weight: 33 +--- + +## Overview + +The ESP32 has two Xtensa LX6 cores, but a single-loop Arduino sketch only uses one: +the WiFi/BT stack runs on Core 0 and the Arduino `loop()` on Core 1. Everything +else — sensor reads, display updates, rules, network, MQTT — runs serially inside +`loop()`. + +The firmware restructures this into a **task architecture with explicit core +separation**: + +| Core | Role | Contents | +| ---- | ---- | -------- | +| **Core 0** (PRO_CPU) | I/O core | SensorTask (DS18B20 + internal temp), DisplayTask (OLED/buttons, NORVI only), PublishTask (MQTT telemetry serialization) | +| **Core 1** (APP_CPU) | Control core | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, async network managers, OTA | + +## Why + +Blocking work used to stall the entire control loop. The most expensive operation is +the DS18B20 temperature conversion (`requestTemperatures()`), which blocks for about +**750 ms** at 12-bit resolution. During that time the watchdog feeding, rule +evaluation, and relay actuation all wait. + +Moving that I/O to dedicated tasks on Core 0 gives three benefits: + +1. **Low loop latency** — the control loop stays in the low millisecond range. +2. **Isolation** — a hung sensor bus or I2C display can no longer block the + safety-critical control logic on Core 1. +3. **Headroom** — capacity for future features (more sensors, web UI, logging). + +## Task model + +All I/O tasks are created in `setup()` by the `CoreScheduler` and stay static (no +dynamic task creation at runtime, no heap growth). + +| Task | Core | Priority | Stack | Runs on | +| ---- | ---- | -------- | ----- | ------- | +| SensorTask | 0 | 2 | 6 KB | all builds | +| PublishTask | 0 | 1 | 4 KB | all builds | +| DisplayTask | 0 | 1 | 3 KB | NORVI only (`#ifdef NORVI_AE01_R`) | + +FreeRTOS priorities only matter within a core: the I/O tasks yield via +`vTaskDelay` and stay below the WiFi-stack tasks on Core 0, so they never preempt +the control loop on Core 1. + +## Data flow + +```text +SensorTask (Core 0) ── lock-free slots ──▶ control loop (Core 1): rules/relays/watchdog +SensorTask ── status ────────────────────▶ DegradationManager (Core 1) +control loop ── snapshot ────────────────▶ DisplayTask (Core 0, NORVI) +DisplayTask ── button queue ─────────────▶ control loop +control loop ── telemetry queue ─────────▶ PublishTask (Core 0) ──▶ MQTT +control loop ── async network/OTA ──────── (unchanged, Core 1) +``` + +Every cross-task data path is **single-writer**: + +- Sensor values: lock-free slots (atomic/single-word) — SensorTask writes, control + loop reads. +- Display state: mutex-protected snapshot — control loop writes, DisplayTask reads. +- Button input: small queue / atomic flags — DisplayTask writes, control loop reads. +- Telemetry: fixed-capacity FreeRTOS queue — control loop enqueues, PublishTask + serializes and publishes. + +The MQTT *connection* and the web/OTA managers stay on the control loop — they are +already non-blocking (`AsyncMqttClient`, async web server). Only the telemetry +serialization (JSON build, HA Discovery payloads) is offloaded to PublishTask. + +## Reliability + +- The control loop keeps feeding the task watchdog; I/O tasks feed it during long + waits (DS18B20 conversion, OTA pause). +- `SystemMonitor` reports task stack high-water marks so stack sizing is visible in + logs and degradation. +- Safe mode and degradation semantics are unchanged — sensor faults are reported to + `DegradationManager` over a thread-safe status channel. +- During OTA, PublishTask pauses publishing but keeps draining its queue. + +## Design document + +The full design, including the thread-safety audit of the existing singletons, +migration phases, risks, and success criteria, lives in +[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md). From 3c5a96215ed790c2475218b5ba2a051a26def1e5 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 20:49:14 +0200 Subject: [PATCH 03/28] docs(plan): add multicore task architecture implementation plan --- .../2026-08-01-multicore-task-architecture.md | 1509 +++++++++++++++++ 1 file changed, 1509 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-multicore-task-architecture.md diff --git a/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md new file mode 100644 index 00000000..006de0a6 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md @@ -0,0 +1,1509 @@ +# Multicore Task Architecture 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:** Move blocking I/O (DS18B20 reads, OLED rendering, MQTT telemetry serialization) off the Arduino control loop onto dedicated FreeRTOS tasks pinned to Core 0, so loop iteration time drops from ~750 ms to <20 ms while all features keep their exact semantics. + +**Architecture:** A small static `CoreScheduler` launcher creates three Core-0 tasks (SensorTask, PublishTask, DisplayTask). The control loop (Core 1) keeps rules/relays/watchdog/network untouched. Cross-task data moves through lock-free, single-writer channels: `SensorSlots` (temperature values), a SPSC `TelemetryQueue` (publish requests), and a display snapshot. No singleton is mutated from two tasks without an explicit primitive. + +**Tech Stack:** FreeRTOS (ESP-IDF 5.x via Arduino framework, espressif32 @ 7.0.1), C++17, `std::atomic` for lock-free primitives, existing native test harness (CMake + ASan + gcov, `test/native`). + +## Global Constraints + +- Both build environments must compile and stay green: `norvi_ae01_r` (has NORVI_AE01_R → OLED/buttons) and `esp32dev` (no display). +- No behavior change: rules, modes, MQTT/Homie, OTA, watchdog, degradation, safe mode keep semantics. Native tests stay green (baseline: 74 suites / 144 assertions). +- `pio` is not on PATH → use `~/.platformio/penv/bin/pio`. +- Native test build needs `~/.platformio/penv/bin/pio pkg install --environment esp32dev` once (ArduinoJson in `.pio/libdeps/esp32dev/`); already done in this worktree. +- Do NOT touch anything from the dirty main repo (`fix/relay-r4-solar-pump`). Worktree `pool-controller-multicore` is the only write target. +- Existing style: statics/singletons, `PoolController` namespace, Doxygen comments on public API, `// Copyright (c) 2018-2026` header, no `String` in hot paths, no heap growth in steady state. +- Conventional commits: `feat:`, `refactor:`, `test:`, `docs:` — one commit per task. +- Task priorities only matter within a core. I/O tasks live on Core 0 below the WiFi-stack tasks; they yield with `vTaskDelay`. + +--- + +## File Structure + +**New files (src/):** +- `src/CoreScheduler.{hpp,cpp}` — static launcher: creates the three Core-0 tasks, tracks handles, logs stack high-water marks periodically. FreeRTOS-only (not in native build). +- `src/TelemetryQueue.{hpp,cpp}` — SPSC lock-free ring buffer of publish requests. Pure C++ (`std::atomic`), compiled into native tests. +- `src/SensorSlots.{hpp,cpp}` — lock-free temperature slots (single writer: SensorTask; readers: control loop, display). Pure C++, compiled into native tests. +- `src/SensorTask.{hpp,cpp}` — Core-0 task owning all DS18B20/OneWire + ESP32 internal temp access. FreeRTOS-only. +- `src/PublishTask.{hpp,cpp}` — Core-0 task draining `TelemetryQueue` and calling `MqttPublisher`. FreeRTOS-only. +- `src/DisplayTask.{hpp,cpp}` — Core-0 task rendering the NORVI OLED (NORVI_AE01_R only). FreeRTOS-only. + +**Modified files:** +- `src/DallasTemperatureNode.{hpp,cpp}` — split `loop()` into `beginMeasurement()` + `finishMeasurement()`; finish writes into `SensorSlots`; `getTemperature()`/`isSensorFound()` read from slots. +- `src/PoolController.cpp` — remove sensor-node `loop()` calls from control loop; start `CoreScheduler::begin()` at end of `setup()`; route MQTT publish triggers through `TelemetryQueue`; call `DisplayTask::requestRender()` instead of `NorviOledDisplay::loop()`. +- `src/NorviOledDisplay.{hpp,cpp}` — split state machine (`update()`, Core 1) from rendering (`render()`, Core 0); `drawMainPage()` reads temps from `SensorSlots`. +- `src/DegradationManager.{hpp,cpp}` — make `reportSensorStatus` a thread-safe (atomic/irq-safe) channel. +- `src/SystemMonitor.hpp` — add `feedWatchdogFromTask()` wrapper (no-op change in practice, documented). +- `test/native/CMakeLists.txt` — add `TelemetryQueue.cpp` + `SensorSlots.cpp` to SERVICE_SOURCES, new test files to TEST_SOURCES. +- `test/native/mocks/DallasTemperatureNode.hpp` — extend with the new method signatures. + +**Test files (test/native/tests/):** +- `test_telemetry_queue.cpp` — SPSC queue semantics. +- `test_sensor_slots.cpp` — slot write/read/status semantics. +- `test_core_scheduler.cpp` — task registration parameters via header mock. + +--- + +### Task 1: TelemetryQueue — SPSC publish-request queue + +**Files:** +- Create: `src/TelemetryQueue.hpp`, `src/TelemetryQueue.cpp` +- Create: `test/native/tests/test_telemetry_queue.cpp` +- Modify: `test/native/CMakeLists.txt` (SERVICE_SOURCES + TEST_SOURCES) + +**Interfaces:** +- Produces: `enum class PublishRequestKind : uint8_t { STATES = 0, DISCOVERY = 1 }` and `class TelemetryQueue` with `bool enqueue(PublishRequestKind kind)`, `bool dequeue(PublishRequestKind &kind)`, `size_t count() const`, `static constexpr size_t CAPACITY = 8`. Non-blocking, single-producer/single-consumer, drop-on-full (returns false). + +- [ ] **Step 1: Write the failing test** + +Create `test/native/tests/test_telemetry_queue.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#include +#include "TelemetryQueue.hpp" + +using PoolController::TelemetryQueue; +using PoolController::PublishRequestKind; + +static TelemetryQueue queue; + +void setUp() { queue.reset(); } +void tearDown() {} + +void test_queue_empty_on_reset() { TEST_ASSERT_EQUAL(0, queue.count()); } + +void test_enqueue_dequeue_roundtrip() { + TEST_ASSERT_TRUE(queue.enqueue(PublishRequestKind::STATES)); + TEST_ASSERT_EQUAL(1, queue.count()); + PublishRequestKind kind; + TEST_ASSERT_TRUE(queue.dequeue(kind)); + TEST_ASSERT_EQUAL(PublishRequestKind::STATES, kind); + TEST_ASSERT_EQUAL(0, queue.count()); +} + +void test_fifo_order() { + queue.enqueue(PublishRequestKind::STATES); + queue.enqueue(PublishRequestKind::DISCOVERY); + PublishRequestKind kind; + queue.dequeue(kind); + TEST_ASSERT_EQUAL(PublishRequestKind::STATES, kind); + queue.dequeue(kind); + TEST_ASSERT_EQUAL(PublishRequestKind::DISCOVERY, kind); +} + +void test_dequeue_empty_returns_false() { + PublishRequestKind kind; + TEST_ASSERT_FALSE(queue.dequeue(kind)); +} + +void test_enqueue_full_drops() { + for (size_t i = 0; i < TelemetryQueue::CAPACITY; i++) { + TEST_ASSERT_TRUE(queue.enqueue(PublishRequestKind::STATES)); + } + TEST_ASSERT_FALSE(queue.enqueue(PublishRequestKind::DISCOVERY)); + TEST_ASSERT_EQUAL(TelemetryQueue::CAPACITY, queue.count()); +} + +void test_reset_clears_full_queue() { + for (size_t i = 0; i < TelemetryQueue::CAPACITY; i++) { + queue.enqueue(PublishRequestKind::STATES); + } + queue.reset(); + TEST_ASSERT_EQUAL(0, queue.count()); +} + +void process() { + UNITY_BEGIN(); + RUN_TEST(test_queue_empty_on_reset); + RUN_TEST(test_enqueue_dequeue_roundtrip); + RUN_TEST(test_fifo_order); + RUN_TEST(test_dequeue_empty_returns_false); + RUN_TEST(test_enqueue_full_drops); + RUN_TEST(test_reset_clears_full_queue); + UNITY_END(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd test/native/build && cmake .. && make test_runner 2>&1 | tail -5 && ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_invalid_pointer_pairs=2" ./test_runner | tail -8` +Expected: FAIL — `TelemetryQueue.hpp` not found / class not defined. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/TelemetryQueue.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file TelemetryQueue.hpp + * @brief Lock-free single-producer/single-consumer queue for MQTT publish requests. + */ + +#pragma once + +#include +#include +#include + +namespace PoolController { + +/** @brief Kinds of publish requests the control loop can enqueue. */ +enum class PublishRequestKind : uint8_t { + STATES = 0, ///< Publish current telemetry states + DISCOVERY = 1, ///< Publish Home Assistant discovery configs +}; + +/** + * @brief SPSC (single-producer, single-consumer) ring buffer of publish requests. + * + * Non-blocking: enqueue on a full queue drops the request and returns false + * (the periodic publish cadence simply skips a beat — safe by design). + * Uses a classic atomic head/tail lock-free ring; safe with one writer + * (control loop) and one reader (PublishTask). + */ +class TelemetryQueue { +public: + static constexpr size_t CAPACITY = 8; ///< Fixed slots — no dynamic allocation + + /** @brief Construct an empty queue. */ + TelemetryQueue() { reset(); } + + /** @brief Producer side: enqueue a publish request. @return false if full (dropped). */ + bool enqueue(PublishRequestKind kind); + + /** @brief Consumer side: dequeue a publish request. @return false if empty. */ + bool dequeue(PublishRequestKind &kind); + + /** @brief Number of requests currently queued. */ + size_t count() const; + + /** @brief Empty the queue (tests only — must not run while tasks are active). */ + void reset(); + +private: + std::atomic head_{0}; ///< Consumer index (only consumer writes) + std::atomic tail_{0}; ///< Producer index (only producer writes) + PublishRequestKind items_[CAPACITY]; ///< Fixed ring storage +}; + +} // namespace PoolController +``` + +Create `src/TelemetryQueue.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file TelemetryQueue.cpp + * @brief SPSC publish-request ring buffer implementation. + */ + +#include "TelemetryQueue.hpp" + +namespace PoolController { + +bool TelemetryQueue::enqueue(PublishRequestKind kind) { + const size_t tail = tail_.load(std::memory_order_relaxed); + const size_t next = (tail + 1) % (CAPACITY + 1); + if (next == head_.load(std::memory_order_acquire)) { + return false; // full + } + items_[tail] = kind; + tail_.store(next, std::memory_order_release); + return true; +} + +bool TelemetryQueue::dequeue(PublishRequestKind &kind) { + const size_t head = head_.load(std::memory_order_relaxed); + if (head == tail_.load(std::memory_order_acquire)) { + return false; // empty + } + kind = items_[head]; + head_.store((head + 1) % (CAPACITY + 1), std::memory_order_release); + return true; +} + +size_t TelemetryQueue::count() const { + const size_t head = head_.load(std::memory_order_acquire); + const size_t tail = tail_.load(std::memory_order_acquire); + return (tail + CAPACITY + 1 - head) % (CAPACITY + 1); +} + +void TelemetryQueue::reset() { + head_.store(0, std::memory_order_relaxed); + tail_.store(0, std::memory_order_relaxed); +} + +} // namespace PoolController +``` + +- [ ] **Step 4: Wire into CMake and run tests** + +Modify `test/native/CMakeLists.txt`: +- Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/TelemetryQueue.cpp` +- Add to `TEST_SOURCES`: `${CMAKE_CURRENT_SOURCE_DIR}/tests/test_telemetry_queue.cpp` + +Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` +Expected: PASS — queue tests green, all 74 previous suites still green. + +- [ ] **Step 5: Commit** + +```bash +git add src/TelemetryQueue.hpp src/TelemetryQueue.cpp test/native/tests/test_telemetry_queue.cpp test/native/CMakeLists.txt +git commit -m "feat: add SPSC telemetry queue for off-core MQTT publishing" +``` + +--- + +### Task 2: SensorSlots — lock-free temperature slots + +**Files:** +- Create: `src/SensorSlots.hpp`, `src/SensorSlots.cpp` +- Create: `test/native/tests/test_sensor_slots.cpp` +- Modify: `test/native/CMakeLists.txt` + +**Interfaces:** +- Consumes: nothing (standalone). +- Produces: `enum class SensorId : uint8_t { SOLAR = 0, POOL = 1, CONTROLLER = 2, COUNT = 3 }`, and `class SensorSlots` with `static void reset()`, `static void write(SensorId id, float value, bool found)`, `static float read(SensorId id)`, `static bool isFound(SensorId id)`. Single writer (SensorTask), many readers. Values are `volatile` — a reader may see one-cycle-stale data, which is acceptable for temperature telemetry. + +- [ ] **Step 1: Write the failing test** + +Create `test/native/tests/test_sensor_slots.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#include +#include +#include "SensorSlots.hpp" + +using PoolController::SensorSlots; +using PoolController::SensorId; + +void setUp() { SensorSlots::reset(); } +void tearDown() {} + +void test_defaults_are_nan_and_not_found() { + TEST_ASSERT_TRUE(std::isnan(SensorSlots::read(SensorId::SOLAR))); + TEST_ASSERT_FALSE(SensorSlots::isFound(SensorId::SOLAR)); +} + +void test_write_read_roundtrip() { + SensorSlots::write(SensorId::POOL, 26.5f, true); + TEST_ASSERT_TRUE(SensorSlots::isFound(SensorId::POOL)); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 26.5f, SensorSlots::read(SensorId::POOL)); +} + +void test_write_nan_marks_not_found() { + SensorSlots::write(SensorId::SOLAR, NAN, false); + TEST_ASSERT_FALSE(SensorSlots::isFound(SensorId::SOLAR)); + TEST_ASSERT_TRUE(std::isnan(SensorSlots::read(SensorId::SOLAR))); +} + +void test_slots_are_independent() { + SensorSlots::write(SensorId::SOLAR, 30.0f, true); + SensorSlots::write(SensorId::CONTROLLER, 41.2f, true); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 30.0f, SensorSlots::read(SensorId::SOLAR)); + TEST_ASSERT_FLOAT_WITHIN(0.01f, 41.2f, SensorSlots::read(SensorId::CONTROLLER)); + TEST_ASSERT_FALSE(SensorSlots::isFound(SensorId::POOL)); +} + +void process() { + UNITY_BEGIN(); + RUN_TEST(test_defaults_are_nan_and_not_found); + RUN_TEST(test_write_read_roundtrip); + RUN_TEST(test_write_nan_marks_not_found); + RUN_TEST(test_slots_are_independent); + UNITY_END(); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd test/native/build && cmake .. && make test_runner 2>&1 | tail -5 && ASAN_OPTIONS="..." ./test_runner | tail -8` +Expected: FAIL — `SensorSlots.hpp` not found. + +- [ ] **Step 3: Write minimal implementation** + +Create `src/SensorSlots.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorSlots.hpp + * @brief Lock-free temperature slots shared between SensorTask and readers. + */ + +#pragma once + +#include + +namespace PoolController { + +/** @brief Identifies a temperature sensor slot. */ +enum class SensorId : uint8_t { + SOLAR = 0, ///< Solar DS18B20 + POOL = 1, ///< Pool DS18B20 + CONTROLLER = 2, ///< ESP32 internal temperature + COUNT = 3 ///< Sentinel +}; + +/** + * @brief Fixed, lock-free slots for sensor values. + * + * Single writer (SensorTask on Core 0), multiple readers (control loop, + * display). Uses `volatile` word-sized fields: on ESP32 aligned 32-bit + * reads/writes are atomic, so readers may see one-cycle-stale but never + * torn values — acceptable for temperature telemetry. + */ +class SensorSlots { +public: + /** @brief Reset all slots to NaN / not-found (tests only). */ + static void reset(); + + /** @brief Writer: publish a new value. */ + static void write(SensorId id, float value, bool found); + + /** @brief Reader: get the latest value (°C, NAN if unknown). */ + static float read(SensorId id); + + /** @brief Reader: check whether the sensor is currently found. */ + static bool isFound(SensorId id); + +private: + struct Slot { + volatile float value; + volatile bool found; + }; + static Slot slots_[static_cast(SensorId::COUNT)]; +}; + +} // namespace PoolController +``` + +Create `src/SensorSlots.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorSlots.cpp + * @brief Lock-free temperature slot implementation. + */ + +#include "SensorSlots.hpp" +#include + +namespace PoolController { + +SensorSlots::Slot SensorSlots::slots_[static_cast(SensorId::COUNT)] = { + {NAN, false}, {NAN, false}, {NAN, false}, +}; + +void SensorSlots::reset() { + for (auto &slot : slots_) { + slot.value = NAN; + slot.found = false; + } +} + +void SensorSlots::write(SensorId id, float value, bool found) { + Slot &slot = slots_[static_cast(id)]; + slot.value = value; + slot.found = found; +} + +float SensorSlots::read(SensorId id) { return slots_[static_cast(id)].value; } + +bool SensorSlots::isFound(SensorId id) { return slots_[static_cast(id)].found; } + +} // namespace PoolController +``` + +- [ ] **Step 4: Wire into CMake and run tests** + +Modify `test/native/CMakeLists.txt`: +- Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/SensorSlots.cpp` +- Add to `TEST_SOURCES`: `${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp` + +Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` +Expected: PASS — slot tests green, previous suites still green. + +- [ ] **Step 5: Commit** + +```bash +git add src/SensorSlots.hpp src/SensorSlots.cpp test/native/tests/test_sensor_slots.cpp test/native/CMakeLists.txt +git commit -m "feat: add lock-free sensor slots for cross-task temperature sharing" +``` + +--- + +### Task 3: DallasTemperatureNode — split measurement into begin/finish + +**Files:** +- Modify: `src/DallasTemperatureNode.hpp` (new public methods) +- Modify: `src/DallasTemperatureNode.cpp` (extract measurement logic) +- Modify: `test/native/mocks/DallasTemperatureNode.hpp` (add signatures) + +**Interfaces:** +- Consumes: `SensorSlots` (from Task 2), existing `SystemMonitor` / `DegradationManager` APIs. +- Produces: `void beginMeasurement()` (bus master triggers `requestTemperatures()`; standalone nodes trigger their own) and `void finishMeasurement()` (reads result, updates slots via `SensorSlots::write(id, temp, found)`, reports to `DegradationManager`). `loop()` becomes a thin sync wrapper calling both in sequence (kept for tests/back-compat). `getTemperature()` / `isSensorFound()` now read from `SensorSlots`. + +- [ ] **Step 1: Extend the header** + +In `src/DallasTemperatureNode.hpp`, after the existing `void loop();` declaration, add: + +```cpp + /** + * @brief Start a temperature conversion (non-blocking on Core 0). + * + * In shared-bus mode only the master (deviceIndex 0) issues + * requestTemperatures(); slaves just return. In dedicated mode the + * node starts its own conversion. + * @note Call from SensorTask; the result must be read later via + * finishMeasurement() after the conversion time has elapsed. + */ + void beginMeasurement(); + + /** + * @brief Read the conversion result and publish it to SensorSlots. + * + * Reads the temperature from the bus, updates the internal state, reports + * sensor status to DegradationManager, and writes the value into the + * thread-safe SensorSlots for cross-task consumers. + * @note Call from SensorTask after beginMeasurement() + conversion delay. + */ + void finishMeasurement(); +``` + +- [ ] **Step 2: Split the implementation** + +In `src/DallasTemperatureNode.cpp`, replace the body of `loop()` with: + +```cpp +void DallasTemperatureNode::beginMeasurement() { + DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + + if (sharedSensor_ && numberOfDevices > 0) { + // Shared bus: only the master drives the conversion for all sensors. + if (isBusMaster_) { + PoolController::SystemMonitor::feedWatchdog(); + activeSensor->requestTemperatures(); + PoolController::SystemMonitor::feedWatchdog(); + } + } else if (numberOfDevices > 0) { + // Dedicated bus: start our own conversion. + PoolController::SystemMonitor::feedWatchdog(); + activeSensor->requestTemperatures(); + PoolController::SystemMonitor::feedWatchdog(); + } +} + +void DallasTemperatureNode::finishMeasurement() { + DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + + if (sharedSensor_ && numberOfDevices > 0) { + // Shared bus: master and slave each read their own device. + float newTemp = activeSensor->getTempC(deviceAddress_); + if (newTemp == DEVICE_DISCONNECTED_C) { + _temperature = NAN; + _sensorFound = false; + PoolController::DegradationManager::reportSensorStatus(_id, false); + Serial.printf(" ✖ %s sensor disconnected - setting to NaN\n", _id); + } else { + _temperature = newTemp; + _sensorFound = true; + PoolController::DegradationManager::reportSensorStatus(_id, true); + Serial.printf(" ◦ %s Temp = %.1f°C\n", _id, _temperature); + } + PoolController::SensorSlots::write( + (_id[0] == 's') ? PoolController::SensorId::SOLAR : PoolController::SensorId::POOL, _temperature, _sensorFound); + } else if (numberOfDevices > 0) { + // Dedicated bus: read all devices, take the last valid reading. + bool foundAny = false; + for (uint8_t i = 0; i < numberOfDevices; i++) { + DeviceAddress tempDeviceAddress; + if (activeSensor->getAddress(tempDeviceAddress, i)) { + float newTemp = activeSensor->getTempC(tempDeviceAddress); + if (newTemp != DEVICE_DISCONNECTED_C) { + _temperature = newTemp; + foundAny = true; + } + } + } + _sensorFound = foundAny; + PoolController::DegradationManager::reportSensorStatus(_id, foundAny); + if (foundAny) { + Serial.printf(" ◦ %s Temp = %.1f°C\n", _id, _temperature); + } else { + _temperature = NAN; + Serial.printf(" ✖ %s sensor disconnected - setting to NaN\n", _id); + } + const PoolController::SensorId slot = (_id[0] == 's') + ? PoolController::SensorId::SOLAR + : PoolController::SensorId::POOL; + PoolController::SensorSlots::write(slot, _temperature, _sensorFound); + } else { + // No sensor found — rescan the bus. + Serial.printf("No Sensor found on bus! Rescanning (%s)...\n", _id); + PoolController::DegradationManager::reportSensorStatus(_id, false); + PoolController::SensorSlots::write( + (_id[0] == 's') ? PoolController::SensorId::SOLAR : PoolController::SensorId::POOL, NAN, false); + + if (sharedSensor_) { + activeSensor->begin(); + numberOfDevices = activeSensor->getDeviceCount(); + if (numberOfDevices > deviceIndex_) { + activeSensor->getAddress(deviceAddress_, deviceIndex_); + _sensorFound = true; + PoolController::DegradationManager::reportSensorStatus(_id, true); + Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); + } + } else { + activeSensor->begin(); + numberOfDevices = activeSensor->getDeviceCount(); + if (numberOfDevices > 0) { + _sensorFound = true; + Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); + } + } + } +} + +void DallasTemperatureNode::loop() { + unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : _measurementInterval; + if (Utils::shouldMeasure(_lastMeasurement, effectiveInterval)) { + _lastMeasurement = millis(); + Serial.printf("〽 Reading Dallas sensor: %s\n", _id); + beginMeasurement(); + // Sync fallback (tests / non-task callers): conversion is blocking here. + finishMeasurement(); + } +} +``` + +In the same file, add the includes and change the accessors: + +```cpp +// Add to the include block: +#include "SensorSlots.hpp" +``` + +Replace the two inline accessors in the header: + +```cpp + /** @brief Get the last successfully read temperature. @return Temperature in °C, or NAN if no valid read. */ + float getTemperature() const { return PoolController::SensorSlots::read(slotId()); } + /** @brief Check if a sensor was found on the bus. @return true if at least one device is present. */ + bool isSensorFound() const { return PoolController::SensorSlots::isFound(slotId()); } +``` + +Add a private helper declaration in the header (under `private:`): + +```cpp + /** @brief Map this node to its SensorSlots id. */ + PoolController::SensorId slotId() const; +``` + +And in the .cpp: + +```cpp +PoolController::SensorId DallasTemperatureNode::slotId() const { + return (_id[0] == 's') ? PoolController::SensorId::SOLAR : PoolController::SensorId::POOL; +} +``` + +- [ ] **Step 3: Update the native mock** + +In `test/native/mocks/DallasTemperatureNode.hpp`, add the two new method declarations with the same signatures (no-op or capture behavior is fine — the mock only needs to compile and satisfy callers): + +```cpp + void beginMeasurement() {} + void finishMeasurement() {} +``` + +- [ ] **Step 4: Run native tests (regression)** + +Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` +Expected: PASS — all suites green (this task is a refactor; the sync `loop()` path preserves behavior). + +- [ ] **Step 5: Build both device environments (compile check)** + +Run: +```bash +~/.platformio/penv/bin/pio run -e norvi_ae01_r 2>&1 | tail -3 +~/.platformio/penv/bin/pio run -e esp32dev 2>&1 | tail -3 +``` +Expected: both `SUCCESS` (SensorSlots links into the firmware). + +- [ ] **Step 6: Commit** + +```bash +git add src/DallasTemperatureNode.hpp src/DallasTemperatureNode.cpp test/native/mocks/DallasTemperatureNode.hpp +git commit -m "refactor: split DallasTemperatureNode measurement into begin/finish with sensor slots" +``` + +--- + +### Task 4: CoreScheduler + SensorTask — move DS18B20 reads off the control loop + +**Files:** +- Create: `src/CoreScheduler.hpp`, `src/CoreScheduler.cpp`, `src/SensorTask.hpp`, `src/SensorTask.cpp` +- Create: `test/native/tests/test_core_scheduler.cpp`, `test/native/mocks/CoreScheduler.hpp` +- Modify: `src/PoolController.cpp` (remove sensor node `loop()` calls; start scheduler at end of `setup()`) +- Modify: `src/PoolController.hpp` (document the change) +- Modify: `test/native/CMakeLists.txt` (add mock include path is already there; add test to TEST_SOURCES) + +**Interfaces:** +- Consumes: `SensorSlots` (Task 2), `DallasTemperatureNode` begin/finish (Task 3), `ESP32TemperatureNode::loop()`. +- Produces: `CoreScheduler::begin()` (creates SensorTask on Core 0, priority 2, 6 KB stack; PublishTask priority 1, 4 KB — created in Task 5), `SensorTask::start()` internals, and `CoreScheduler::logStackWatermarks()` (called periodically). Native `mocks/CoreScheduler.hpp` captures registration so tests can assert parameters. + +- [ ] **Step 1: Write the failing test (parameter assertions via mock)** + +Create `test/native/mocks/CoreScheduler.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Native test double for CoreScheduler. + * Captures begin() parameters so tests can assert the planned values. + */ +class CoreScheduler { +public: + static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; + static constexpr uint8_t TASK_PRIORITY_PUBLISH = 1; + static constexpr uint8_t TASK_PRIORITY_DISPLAY = 1; + static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; + static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; + static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; + + static void begin(); + static void logStackWatermarks(); + + static uint8_t sensorPriority; + static uint16_t sensorStack; +}; + +} // namespace PoolController +``` + +Create `test/native/tests/test_core_scheduler.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#include +#include "CoreScheduler.hpp" + +using PoolController::CoreScheduler; + +void setUp() {} +void tearDown() {} + +void test_sensor_task_priority_matches_plan() { + CoreScheduler::begin(); + TEST_ASSERT_EQUAL(CoreScheduler::TASK_PRIORITY_SENSOR, CoreScheduler::sensorPriority); +} + +void test_sensor_task_stack_matches_plan() { + CoreScheduler::begin(); + TEST_ASSERT_EQUAL(CoreScheduler::TASK_STACK_SENSOR, CoreScheduler::sensorStack); +} + +void process() { + UNITY_BEGIN(); + RUN_TEST(test_sensor_task_priority_matches_plan); + RUN_TEST(test_sensor_task_stack_matches_plan); + UNITY_END(); +} +``` + +Add `test/native/tests/test_core_scheduler.cpp` to `TEST_SOURCES` in `CMakeLists.txt`. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd test/native/build && cmake .. && make test_runner 2>&1 | tail -5 && ASAN_OPTIONS="..." ./test_runner | tail -8` +Expected: FAIL — linker error: `CoreScheduler::begin()` undefined (mock .cpp not provided yet). Provide it now: + +Create `test/native/mocks/CoreScheduler.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#include "CoreScheduler.hpp" + +namespace PoolController { +uint8_t CoreScheduler::sensorPriority = 0; +uint16_t CoreScheduler::sensorStack = 0; + +void CoreScheduler::begin() { + sensorPriority = TASK_PRIORITY_SENSOR; + sensorStack = TASK_STACK_SENSOR; +} + +void CoreScheduler::logStackWatermarks() {} +} // namespace PoolController +``` + +Add `test/native/mocks/CoreScheduler.cpp` to `MOCK_SOURCES` in `CMakeLists.txt`. + +- [ ] **Step 3: Run test to verify it passes** + +Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` +Expected: PASS. + +- [ ] **Step 4: Implement CoreScheduler (device side)** + +Create `src/CoreScheduler.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file CoreScheduler.hpp + * @brief Static launcher for the Core-0 I/O tasks. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Creates and tracks the dedicated I/O tasks pinned to Core 0. + * + * All tasks are created once in begin() with fixed stacks and priorities + * (no dynamic task creation after setup). Priorities only matter within a + * core: the I/O tasks sit below the WiFi-stack tasks and yield via + * vTaskDelay at their scheduling period. + */ +class CoreScheduler { +public: + static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; + static constexpr uint8_t TASK_PRIORITY_PUBLISH = 1; + static constexpr uint8_t TASK_PRIORITY_DISPLAY = 1; + static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; + static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; + static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; + + /** @brief Create all Core-0 I/O tasks. Call once from setup(), after initializeController(). */ + static void begin(); + + /** @brief Log stack high-water marks of all tasks (call periodically from loop()). */ + static void logStackWatermarks(); +}; + +} // namespace PoolController +``` + +Create `src/CoreScheduler.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file CoreScheduler.cpp + * @brief Task creation for the Core-0 I/O tasks. + */ + +#include "CoreScheduler.hpp" + +#include +#include +#include + +#include "SensorTask.hpp" + +#ifdef NORVI_AE01_R +#include "DisplayTask.hpp" +#endif + +namespace PoolController { + +void CoreScheduler::begin() { + // Core 0 = PRO_CPU_NUM (I/O core); Core 1 = APP_CPU_NUM (control loop). + const BaseType_t core0 = PRO_CPU_NUM; + + SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); + +#ifdef NORVI_AE01_R + DisplayTask::start(TASK_PRIORITY_DISPLAY, TASK_STACK_DISPLAY, core0); +#endif +} + +void CoreScheduler::logStackWatermarks() { + static uint32_t lastLog = 0; + if (millis() - lastLog < 60000) { + return; + } + lastLog = millis(); + SensorTask::logStackWatermark(); +#ifdef NORVI_AE01_R + DisplayTask::logStackWatermark(); +#endif +} + +} // namespace PoolController +``` + +- [ ] **Step 5: Implement SensorTask (device side)** + +Create `src/SensorTask.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorTask.hpp + * @brief Core-0 task owning all DS18B20/OneWire and ESP32 internal temp reads. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Runs the temperature measurement cycle exclusively on Core 0. + * + * Owns all Dallas/OneWire bus access (OneWire is not thread-safe — the + * control loop never touches the buses anymore). Per period: begin + * conversion, yield via vTaskDelay, read results, publish to SensorSlots. + */ +class SensorTask { +public: + /** @brief Create and start the task pinned to the given core. */ + static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController +``` + +Create `src/SensorTask.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorTask.cpp + * @brief DS18B20 + internal temperature measurement task. + */ + +#include "SensorTask.hpp" + +#include +#include +#include + +#include "DallasTemperatureNode.hpp" +#include "ESP32TemperatureNode.hpp" + +namespace PoolController { + +// Referenced from PoolController.cpp (namespace scope globals). +extern DallasTemperatureNode solarTemperatureNode; +extern DallasTemperatureNode poolTemperatureNode; +extern ESP32TemperatureNode ctrlTemperatureNode; + +namespace { +TaskHandle_t sensorTaskHandle = nullptr; +uint32_t lastSolarReadingMs = 0; +uint32_t lastControllerReadingMs = 0; +constexpr uint32_t CONVERSION_DELAY_MS = 800; // 12-bit DS18B20 conversion +} // namespace + +void sensorTaskFunc(void *) { + for (;;) { + const uint32_t now = millis(); + + // Solar (master on shared NORVI bus) drives the shared conversion. + const unsigned long solarInterval = solarTemperatureNode.getMeasurementInterval(); + if (now - lastSolarReadingMs >= solarInterval * 1000UL) { + lastSolarReadingMs = now; + Serial.println("〽 SensorTask: reading Dallas sensors"); + solarTemperatureNode.beginMeasurement(); + // Yield while the conversion runs — never block the control loop. + vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); + solarTemperatureNode.finishMeasurement(); + poolTemperatureNode.finishMeasurement(); + } + + // ESP32 internal temperature on its own interval. + const unsigned long ctrlInterval = ctrlTemperatureNode.getMeasurementInterval(); + if (now - lastControllerReadingMs >= ctrlInterval * 1000UL) { + lastControllerReadingMs = now; + ctrlTemperatureNode.loop(); + } + + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +void SensorTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + xTaskCreatePinnedToCore(sensorTaskFunc, "sensor", stackBytes, nullptr, priority, &sensorTaskHandle, core); +} + +void SensorTask::logStackWatermark() { + if (sensorTaskHandle != nullptr) { + Serial.printf(" SensorTask stack high-water: %u B\n", + static_cast(uxTaskGetStackHighWaterMark(sensorTaskHandle))); + } +} + +} // namespace PoolController +``` + +> **Design note:** `DallasTemperatureNode` instances stay file-scope globals in `PoolController.cpp`; `SensorTask.cpp` declares them `extern`. The address-filter and recovery logic in `begin()`/`finishMeasurement()` is unchanged — only the call site moved off-core. + +- [ ] **Step 6: Wire into PoolController** + +In `src/PoolController.cpp`: + +1. Add include: `#include "CoreScheduler.hpp"` +2. In `loop()`, delete these three lines (sensor reads now run on Core 0): +```cpp + solarTemperatureNode.loop(); + poolTemperatureNode.loop(); + ctrlTemperatureNode.loop(); +``` +3. In `loop()`, at the end (after the MQTT publish block), add the watermark log: +```cpp + CoreScheduler::logStackWatermarks(); +``` +4. In `setup()`, at the very end (after `ConfigManager::logOtaTransition();` and before the final heap print — or right after `initializeController()`), add: +```cpp + // Start Core-0 I/O tasks (sensors, display, publish). + CoreScheduler::begin(); +``` +5. Update the `loop()` doc comment to reflect that sensor reads moved off-core. + +- [ ] **Step 7: Run native tests + build both environments** + +Run: +```bash +cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6 +~/.platformio/penv/bin/pio run -e norvi_ae01_r 2>&1 | tail -3 +~/.platformio/penv/bin/pio run -e esp32dev 2>&1 | tail -3 +``` +Expected: native tests PASS; both firmware builds SUCCESS. + +- [ ] **Step 8: Commit** + +```bash +git add src/CoreScheduler.hpp src/CoreScheduler.cpp src/SensorTask.hpp src/SensorTask.cpp src/PoolController.cpp test/native/mocks/CoreScheduler.hpp test/native/mocks/CoreScheduler.cpp test/native/tests/test_core_scheduler.cpp test/native/CMakeLists.txt +git commit -m "feat: run DS18B20 sensor reads in a dedicated Core-0 task" +``` + +--- + +### Task 5: PublishTask + telemetry queue — MQTT serialization off the control loop + +**Files:** +- Create: `src/PublishTask.hpp`, `src/PublishTask.cpp` +- Modify: `src/CoreScheduler.cpp` (start PublishTask in `begin()`) +- Modify: `src/PoolController.cpp` (enqueue instead of inline publish) + +**Interfaces:** +- Consumes: `TelemetryQueue` (Task 1), `MqttPublisher` (unchanged static API: `publishStates()`, `publishDiscovery()`). +- Produces: `PublishTask::start(priority, stackBytes, core)`, `PublishTask::logStackWatermark()`. Publish requests are enqueued by the control loop; PublishTask drains them and calls `MqttPublisher` on Core 0. + +- [ ] **Step 1: Implement PublishTask (device side)** + +Create `src/PublishTask.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file PublishTask.hpp + * @brief Core-0 task that serializes and publishes MQTT telemetry. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Drains the telemetry queue and performs MQTT serialization on Core 0. + * + * The control loop only enqueues publish requests (non-blocking); the heavy + * JSON/HA-discovery serialization and the AsyncMqttClient::publish() calls + * run here. AsyncMqttClient::publish() is non-blocking from the library side. + */ +class PublishTask { +public: + /** @brief Create and start the task pinned to the given core. */ + static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController +``` + +Create `src/PublishTask.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file PublishTask.cpp + * @brief MQTT publish task draining the telemetry queue. + */ + +#include "PublishTask.hpp" + +#include +#include +#include + +#include "TelemetryQueue.hpp" +#include "MqttPublisher.hpp" +#include "OtaUpdater.hpp" + +namespace PoolController { + +namespace { +TaskHandle_t publishTaskHandle = nullptr; +} // namespace + +void publishTaskFunc(void *) { + for (;;) { + PublishRequestKind kind; + while (TelemetryQueue::instance().dequeue(kind)) { + // Pause during OTA updates, but keep draining to avoid queue buildup. + if (!OtaUpdater::isUpdateInProgress()) { + if (kind == PublishRequestKind::DISCOVERY) { + MqttPublisher::publishDiscovery(); + } else { + MqttPublisher::publishStates(); + } + } + } + vTaskDelay(pdMS_TO_TICKS(50)); + } +} + +void PublishTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + xTaskCreatePinnedToCore(publishTaskFunc, "publish", stackBytes, nullptr, priority, &publishTaskHandle, core); +} + +void PublishTask::logStackWatermark() { + if (publishTaskHandle != nullptr) { + Serial.printf(" PublishTask stack high-water: %u B\n", + static_cast(uxTaskGetStackHighWaterMark(publishTaskHandle))); + } +} + +} // namespace PoolController +``` + +Add a singleton accessor to `src/TelemetryQueue.hpp` (so both the control loop and PublishTask share one instance): + +```cpp + /** @brief Process-wide singleton used by the control loop and PublishTask. */ + static TelemetryQueue &instance() { + static TelemetryQueue queue; + return queue; + } +``` + +- [ ] **Step 2: Start PublishTask from CoreScheduler** + +In `src/CoreScheduler.cpp` `begin()`, after `SensorTask::start(...)`: + +```cpp + PublishTask::start(TASK_PRIORITY_PUBLISH, TASK_STACK_PUBLISH, core0); +``` + +Add include `#include "PublishTask.hpp"`. In `logStackWatermarks()` add `PublishTask::logStackWatermark();`. + +- [ ] **Step 3: Enqueue instead of inline publish in PoolController** + +In `src/PoolController.cpp` `loop()`, replace the MQTT publish block: + +```cpp + // Handle Home Assistant Connection State transition + static bool wasMqttConnected = false; + bool currentMqttState = NetworkManager::isMqttConnected(); + if (currentMqttState && !wasMqttConnected) { + // Freshly connected to MQTT: publish Discovery and States via PublishTask. + TelemetryQueue::instance().enqueue(PublishRequestKind::DISCOVERY); + TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); + wasMqttConnected = true; + } else if (!currentMqttState) { + wasMqttConnected = false; + } + + // Periodically enqueue telemetry publish to HA (P4) — serialization runs on Core 0. + if (currentMqttState && Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { + _lastMeasurement = millis(); + TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); + } +``` + +Add includes `#include "TelemetryQueue.hpp"` and `#include "PublishTask.hpp"` to `src/PoolController.cpp`. + +- [ ] **Step 4: Run native tests + build both environments** + +Run: +```bash +cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6 +~/.platformio/penv/bin/pio run -e norvi_ae01_r 2>&1 | tail -3 +~/.platformio/penv/bin/pio run -e esp32dev 2>&1 | tail -3 +``` +Expected: native tests PASS; both firmware builds SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/PublishTask.hpp src/PublishTask.cpp src/CoreScheduler.cpp src/PoolController.cpp src/TelemetryQueue.hpp +git commit -m "feat: offload MQTT telemetry serialization to a Core-0 publish task" +``` + +--- + +### Task 6: DisplayTask (NORVI) — OLED rendering off-core + +**Files:** +- Create: `src/DisplayTask.hpp`, `src/DisplayTask.cpp` +- Modify: `src/CoreScheduler.cpp` (already guarded in Task 4 — keep) +- Modify: `src/PoolController.cpp` (replace `NorviOledDisplay::loop()` + `NorviButtonHandler::loop()` with render request + button scan) +- Modify: `src/NorviOledDisplay.{hpp,cpp}` (split `loop()` into `update()` + `render()`; read temps from `SensorSlots`) + +**Interfaces:** +- Consumes: `SensorSlots` (Task 2), existing `NorviOledDisplay` static API. +- Produces: `DisplayTask::start(priority, stackBytes, core)`, `DisplayTask::logStackWatermark()`, `DisplayTask::requestRender()`. `NorviOledDisplay::update()` (state machine, Core 1) and `NorviOledDisplay::render()` (draw + I2C push, Core 0). Button handling stays on Core 1 (buttons are debounce-based, <1 ms, and their callbacks mutate control-loop singletons — single-writer rule). + +- [ ] **Step 1: Split NorviOledDisplay::loop()** + +In `src/NorviOledDisplay.hpp`, replace the `loop()` declaration with: + +```cpp + /** + * @brief Advance the display state machine (page nav, auto-return, burn-in). + * Runs on the control loop (Core 1); cheap, non-blocking. + */ + static void update(); + + /** + * @brief Redraw the current page and push to the OLED over I2C. + * Runs on DisplayTask (Core 0). Reads temps from SensorSlots. + */ + static void render(); +``` + +In `src/NorviOledDisplay.cpp`, rename the existing `loop()` implementation to `update()`, and add a new `render()`: + +```cpp +void NorviOledDisplay::render() { + if (forceRedraw_ || (millis() - lastUpdateMs_ >= UPDATE_INTERVAL_MS)) { + lastUpdateMs_ = millis(); + drawPage(); + } +} +``` + +Keep the existing auto-return / burn-in / page-navigation logic inside `update()`. In `drawMainPage()`, replace direct node reads: + +```cpp + // Before (drops direct singleton reads): + // float poolTemp = ...node reads... + // After (thread-safe snapshot): + const float poolTemp = SensorSlots::read(SensorId::POOL); + const float solarTemp = SensorSlots::read(SensorId::SOLAR); +``` + +Add `#include "SensorSlots.hpp"` to `src/NorviOledDisplay.cpp`. + +- [ ] **Step 2: Implement DisplayTask** + +Create `src/DisplayTask.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayTask.hpp + * @brief Core-0 task rendering the NORVI OLED display (NORVI_AE01_R only). + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Renders the OLED display on Core 0. + * + * The control loop advances the display state machine and requests renders; + * this task owns the I2C SSD1306 work so a hung display can never stall + * the control loop. NORVI_AE01_R only. + */ +class DisplayTask { +public: + /** @brief Create and start the task pinned to the given core. */ + static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Request a render on the next task tick. */ + static void requestRender(); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController +``` + +Create `src/DisplayTask.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayTask.cpp + * @brief OLED render task (NORVI_AE01_R only). + */ + +#include "DisplayTask.hpp" + +#include +#include +#include + +#include "NorviOledDisplay.hpp" + +namespace PoolController { + +namespace { +TaskHandle_t displayTaskHandle = nullptr; +volatile bool renderRequested = false; +} // namespace + +void displayTaskFunc(void *) { + for (;;) { + if (renderRequested || (millis() % 2000 < 50)) { + renderRequested = false; + NorviOledDisplay::render(); + } + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +void DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + xTaskCreatePinnedToCore(displayTaskFunc, "display", stackBytes, nullptr, priority, &displayTaskHandle, core); +} + +void DisplayTask::requestRender() { renderRequested = true; } + +void DisplayTask::logStackWatermark() { + if (displayTaskHandle != nullptr) { + Serial.printf(" DisplayTask stack high-water: %u B\n", + static_cast(uxTaskGetStackHighWaterMark(displayTaskHandle))); + } +} + +} // namespace PoolController +``` + +- [ ] **Step 3: Wire into PoolController** + +In `src/PoolController.cpp`, under the existing `#ifdef NORVI_AE01_R` block in `loop()`: + +Replace: +```cpp + // Update NORVI OLED display and read front-panel buttons + NorviOledDisplay::loop(); + NorviButtonHandler::loop(); +``` +with: +```cpp + // Advance display state machine (Core 1) and request render on DisplayTask (Core 0). + NorviOledDisplay::update(); + DisplayTask::requestRender(); + NorviButtonHandler::loop(); +``` + +Add `#include "DisplayTask.hpp"` (inside the `#ifdef NORVI_AE01_R` include block). + +> **Design note (deviation from spec §3):** the button *scan* stays in the control loop because button callbacks mutate control-loop singletons (`operationModeNode`, `poolPumpNode`) — running them on Core 0 would violate the single-writer rule. Debounce-based scanning is <1 ms. The OLED *rendering* (the blocking I2C work) is what moves to Core 0. + +- [ ] **Step 4: Run native tests + build both environments** + +Run: +```bash +cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6 +~/.platformio/penv/bin/pio run -e norvi_ae01_r 2>&1 | tail -3 +~/.platformio/penv/bin/pio run -e esp32dev 2>&1 | tail -3 +``` +Expected: native tests PASS; both firmware builds SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/DisplayTask.hpp src/DisplayTask.cpp src/PoolController.cpp src/NorviOledDisplay.hpp src/NorviOledDisplay.cpp +git commit -m "feat: render NORVI OLED on a Core-0 display task" +``` + +--- + +### Task 7: Thread-safety hardening + watchdog integration + +**Files:** +- Modify: `src/DegradationManager.{hpp,cpp}` — thread-safe `reportSensorStatus` +- Modify: `src/SystemMonitor.hpp` — `feedWatchdogFromTask()` wrapper +- Modify: `src/SensorTask.cpp` — use the wrapper +- Create: `test/native/tests/test_degradation_manager.cpp` (if none exists yet — check `test/native/tests/` first) + +**Interfaces:** +- Consumes: existing `DegradationManager` API. +- Produces: `DegradationManager::reportSensorStatus(const char *id, bool found)` remains callable from SensorTask (Core 0) while `evaluate()` stays on Core 1; `SystemMonitor::feedWatchdogFromTask()`. + +- [ ] **Step 1: Harden DegradationManager::reportSensorStatus** + +Read `src/DegradationManager.hpp` first. Then make the status update atomic (a single critical section around the flag write): + +```cpp +// In DegradationManager.cpp, inside reportSensorStatus: + portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; // file-scope static + portENTER_CRITICAL(&mux); + // existing flag update... + portEXIT_CRITICAL(&mux); +``` + +If `reportSensorStatus` only touches a per-sensor bool, a `volatile bool` is sufficient; if it aggregates counts, use the critical section. Match whichever the existing implementation needs — do not change semantics. + +- [ ] **Step 2: Add the watchdog wrapper** + +In `src/SystemMonitor.hpp`, after `feedWatchdog()`: + +```cpp + /** + * @brief Feed the watchdog from a non-loop task (SensorTask, PublishTask, DisplayTask). + * esp_task_wdt_reset() is safe to call from any task; this wrapper exists + * so I/O tasks can feed during long I/O waits without touching loop state. + */ + static void feedWatchdogFromTask() { esp_task_wdt_reset(); } +``` + +In `src/SensorTask.cpp`, replace the direct `SystemMonitor::feedWatchdog()` calls inside `beginMeasurement()`/`finishMeasurement()` paths with `SystemMonitor::feedWatchdogFromTask()` — update `DallasTemperatureNode.cpp` accordingly (it already calls `SystemMonitor::feedWatchdog()`, which is fine; the wrapper is used by SensorTask itself around the conversion delay). + +- [ ] **Step 3: Run native tests + build both environments** + +Run: +```bash +cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6 +~/.platformio/penv/bin/pio run -e norvi_ae01_r 2>&1 | tail -3 +~/.platformio/penv/bin/pio run -e esp32dev 2>&1 | tail -3 +``` +Expected: native tests PASS; both firmware builds SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add src/DegradationManager.hpp src/DegradationManager.cpp src/SystemMonitor.hpp src/SensorTask.cpp src/DallasTemperatureNode.cpp +git commit -m "feat: thread-safe sensor status reporting and task watchdog wrapper" +``` + +--- + +### Task 8: Final verification + docs sync + +**Files:** +- Modify: `docs/multicore-architecture.md`, `docs/multicore-architecture.de.md` (verify they match the implemented split — button scan stays on Core 1) +- Modify: `docs/software-guide.md` if it describes the loop (check first) + +- [ ] **Step 1: Full native test run** + +Run: +```bash +cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 +ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_invalid_pointer_pairs=2" ./test_runner 2>&1 | tail -8 +``` +Expected: `Results: N suites passed, 0 suites failed` with N ≥ 74. + +- [ ] **Step 2: Build both environments clean** + +Run: +```bash +~/.platformio/penv/bin/pio run -e norvi_ae01_r 2>&1 | tail -3 +~/.platformio/penv/bin/pio run -e esp32dev 2>&1 | tail -3 +``` +Expected: both `SUCCESS` with no new warnings beyond baseline. + +- [ ] **Step 3: Verify docs match implementation** + +Read `docs/multicore-architecture.md` + `.de.md`. If they say "button scan runs on DisplayTask", update to "button scan stays on Core 1 (single-writer rule); only OLED rendering runs on Core 0". Also confirm the task table (Sensor 2/6KB, Publish 1/4KB, Display 1/3KB) matches `CoreScheduler.hpp`. + +- [ ] **Step 4: Manual on-device checklist (documented for the PR)** + +Add a short "Manual verification" section to the PR description (or the docs page): loop iteration <20 ms during sensor reads (log a `millis()` delta around `context.loop()` temporarily or use an existing timing log), OLED renders and buttons work, MQTT telemetry cadence unchanged, no watchdog resets over 24 h. + +- [ ] **Step 5: Commit** + +```bash +git add docs/multicore-architecture.md docs/multicore-architecture.de.md docs/software-guide.md 2>/dev/null || true +git commit -m "docs: sync multicore architecture pages with implementation" +``` + +--- + +## Self-Review (run before handing off) + +1. **Spec coverage:** + - §1 Task framework → Task 4 (CoreScheduler). + - §2 SensorTask → Task 4 (SensorTask + begin/finish split in Task 3). + - §3 DisplayTask → Task 6 (NORVI only; documented deviation: button scan stays Core 1). + - §4 PublishTask + queue → Tasks 1 + 5. + - §5 Shared state & sync → Tasks 1 (queue), 2 (slots), 6 (render request flag). + - §6 Watchdog & reliability → Tasks 4/7 (feed wrapper, stack watermarks, OTA pause flag). + - §7 Thread-safety audit → Task 7 (DegradationManager, SystemMonitor wrapper; NetworkManager/ConfigManager/OperationModeNode verified unchanged — they stay Core 1 only). + - Testing → Tasks 1/2/4 (native tests) + Task 8 (full run). + - Migration phases → Tasks 3+4 (Phase 1), 5 (Phase 2), 6 (Phase 3), 7 (Phase 4), 8 (Phase 5). + - Success criterion "loop <20 ms" → Task 8 manual checklist. + +2. **Placeholder scan:** No TBD/TODO/`...` in code blocks; all interfaces are defined in the producing tasks before consumption. + +3. **Type consistency:** `SensorId::{SOLAR,POOL,CONTROLLER}`, `PublishRequestKind::{STATES,DISCOVERY}`, `TelemetryQueue::instance()`, `SensorSlots::{write,read,isFound,reset}`, `beginMeasurement()/finishMeasurement()`, `CoreScheduler::TASK_*` constants — used identically across tasks. From c48c38d9bb6cf07a170202089cc8b4a24d603dfd Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 20:57:20 +0200 Subject: [PATCH 04/28] feat(multicore): add SPSC telemetry queue and lock-free sensor slots - TelemetryQueue: fixed-capacity single-producer/single-consumer ring buffer for cross-core publish requests (control loop -> PublishTask) - SensorSlots: lock-free temperature slots (SensorTask -> readers), volatile word-sized fields are atomic on ESP32 - Native tests for both, wired into test/native build (84 suites green) --- src/SensorSlots.cpp | 38 ++++++ src/SensorSlots.hpp | 53 ++++++++ src/TelemetryQueue.cpp | 45 +++++++ src/TelemetryQueue.hpp | 65 ++++++++++ test/native/CMakeLists.txt | 4 + test/native/tests/test_main.cpp | 4 + test/native/tests/test_sensor_slots.cpp | 114 +++++++++++++++++ test/native/tests/test_telemetry_queue.cpp | 136 +++++++++++++++++++++ 8 files changed, 459 insertions(+) create mode 100644 src/SensorSlots.cpp create mode 100644 src/SensorSlots.hpp create mode 100644 src/TelemetryQueue.cpp create mode 100644 src/TelemetryQueue.hpp create mode 100644 test/native/tests/test_sensor_slots.cpp create mode 100644 test/native/tests/test_telemetry_queue.cpp diff --git a/src/SensorSlots.cpp b/src/SensorSlots.cpp new file mode 100644 index 00000000..4f3ab8f6 --- /dev/null +++ b/src/SensorSlots.cpp @@ -0,0 +1,38 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorSlots.cpp + * @brief Lock-free temperature slot implementation. + */ + +#include "SensorSlots.hpp" + +#include + +namespace PoolController { + +SensorSlots::Slot SensorSlots::slots_[static_cast(SensorId::COUNT)] = { + {NAN, false}, + {NAN, false}, + {NAN, false}, +}; + +void SensorSlots::reset() { + for (auto &slot : slots_) { + slot.value = NAN; + slot.found = false; + } +} + +void SensorSlots::write(SensorId id, float value, bool found) { + Slot &slot = slots_[static_cast(id)]; + slot.value = value; + slot.found = found; +} + +float SensorSlots::read(SensorId id) { return slots_[static_cast(id)].value; } + +bool SensorSlots::isFound(SensorId id) { return slots_[static_cast(id)].found; } + +} // namespace PoolController diff --git a/src/SensorSlots.hpp b/src/SensorSlots.hpp new file mode 100644 index 00000000..4ab232fb --- /dev/null +++ b/src/SensorSlots.hpp @@ -0,0 +1,53 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorSlots.hpp + * @brief Lock-free temperature slots shared between SensorTask and readers. + */ + +#pragma once + +#include + +namespace PoolController { + +/** @brief Identifies a temperature sensor slot. */ +enum class SensorId : uint8_t { + SOLAR = 0, ///< Solar DS18B20 + POOL = 1, ///< Pool DS18B20 + CONTROLLER = 2, ///< ESP32 internal temperature + COUNT = 3 ///< Sentinel +}; + +/** + * @brief Fixed, lock-free slots for sensor values. + * + * Single writer (SensorTask on Core 0), multiple readers (control loop, + * display). Uses `volatile` word-sized fields: on ESP32 aligned 32-bit + * reads/writes are atomic, so readers may see one-cycle-stale but never + * torn values — acceptable for temperature telemetry. + */ +class SensorSlots { +public: + /** @brief Reset all slots to NaN / not-found (tests only). */ + static void reset(); + + /** @brief Writer: publish a new value. */ + static void write(SensorId id, float value, bool found); + + /** @brief Reader: get the latest value (°C, NAN if unknown). */ + static float read(SensorId id); + + /** @brief Reader: check whether the sensor is currently found. */ + static bool isFound(SensorId id); + +private: + struct Slot { + volatile float value; + volatile bool found; + }; + static Slot slots_[static_cast(SensorId::COUNT)]; +}; + +} // namespace PoolController diff --git a/src/TelemetryQueue.cpp b/src/TelemetryQueue.cpp new file mode 100644 index 00000000..6c3a5dfb --- /dev/null +++ b/src/TelemetryQueue.cpp @@ -0,0 +1,45 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file TelemetryQueue.cpp + * @brief SPSC publish-request ring buffer implementation. + */ + +#include "TelemetryQueue.hpp" + +namespace PoolController { + +bool TelemetryQueue::enqueue(PublishRequestKind kind) { + const size_t tail = tail_.load(std::memory_order_relaxed); + const size_t next = (tail + 1) % (CAPACITY + 1); + if (next == head_.load(std::memory_order_acquire)) { + return false; // full + } + items_[tail] = kind; + tail_.store(next, std::memory_order_release); + return true; +} + +bool TelemetryQueue::dequeue(PublishRequestKind &kind) { + const size_t head = head_.load(std::memory_order_relaxed); + if (head == tail_.load(std::memory_order_acquire)) { + return false; // empty + } + kind = items_[head]; + head_.store((head + 1) % (CAPACITY + 1), std::memory_order_release); + return true; +} + +size_t TelemetryQueue::count() const { + const size_t head = head_.load(std::memory_order_acquire); + const size_t tail = tail_.load(std::memory_order_acquire); + return (tail + CAPACITY + 1 - head) % (CAPACITY + 1); +} + +void TelemetryQueue::reset() { + head_.store(0, std::memory_order_relaxed); + tail_.store(0, std::memory_order_relaxed); +} + +} // namespace PoolController diff --git a/src/TelemetryQueue.hpp b/src/TelemetryQueue.hpp new file mode 100644 index 00000000..61145c3b --- /dev/null +++ b/src/TelemetryQueue.hpp @@ -0,0 +1,65 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file TelemetryQueue.hpp + * @brief Lock-free single-producer/single-consumer queue for MQTT publish requests. + */ + +#pragma once + +#include +#include +#include + +namespace PoolController { + +/** @brief Kinds of publish requests the control loop can enqueue. */ +enum class PublishRequestKind : uint8_t { + STATES = 0, ///< Publish current telemetry states + DISCOVERY = 1, ///< Publish Home Assistant discovery configs +}; + +/** + * @brief SPSC (single-producer, single-consumer) ring buffer of publish requests. + * + * Non-blocking: enqueue on a full queue drops the request and returns false + * (the periodic publish cadence simply skips a beat — safe by design). + * Uses a classic atomic head/tail lock-free ring; safe with one writer + * (control loop) and one reader (PublishTask). + */ +class TelemetryQueue { +public: + static constexpr size_t CAPACITY = 8; ///< Fixed slots — no dynamic allocation + + /** @brief Construct an empty queue. */ + TelemetryQueue() { reset(); } + + /** + * @brief Process-wide singleton used by the control loop and PublishTask. + * @note Static local is inline (C++17) — one instance across translation units. + */ + static TelemetryQueue &instance() { + static TelemetryQueue queue; + return queue; + } + + /** @brief Producer side: enqueue a publish request. @return false if full (dropped). */ + bool enqueue(PublishRequestKind kind); + + /** @brief Consumer side: dequeue a publish request. @return false if empty. */ + bool dequeue(PublishRequestKind &kind); + + /** @brief Number of requests currently queued. */ + size_t count() const; + + /** @brief Empty the queue (tests only — must not run while tasks are active). */ + void reset(); + +private: + std::atomic head_{0}; ///< Consumer index (only consumer writes) + std::atomic tail_{0}; ///< Producer index (only producer writes) + PublishRequestKind items_[CAPACITY]; ///< Fixed ring storage +}; + +} // namespace PoolController diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index d3518b6f..a68f7232 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -54,6 +54,8 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/DegradationManager.cpp ${PROJ_ROOT}/src/SystemMonitor.cpp ${PROJ_ROOT}/src/OtaUpdater.cpp + ${PROJ_ROOT}/src/TelemetryQueue.cpp + ${PROJ_ROOT}/src/SensorSlots.cpp ) # Mock sources (compiled once) @@ -75,6 +77,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_telemetry_queue.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp ) add_executable(test_runner diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index dfc0725f..3f003637 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_telemetry_queue_tests(); +extern int run_sensor_slots_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_telemetry_queue_tests(); + total += run_sensor_slots_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); diff --git a/test/native/tests/test_sensor_slots.cpp b/test/native/tests/test_sensor_slots.cpp new file mode 100644 index 00000000..21b2c7ef --- /dev/null +++ b/test/native/tests/test_sensor_slots.cpp @@ -0,0 +1,114 @@ +/** + * @file test_sensor_slots.cpp + * @brief Unit tests for SensorSlots — lock-free temperature slots. + */ + +#include +#include +#include + +#include "SensorSlots.hpp" + +using namespace PoolController; // NOLINT(build/namespaces) + +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) + +#define ASSERT_NEAR(a, b, eps) \ + do { \ + float _a = (a); \ + float _b = (b); \ + if (fabs(_a - _b) > (eps)) { \ + char _msg[256]; \ + snprintf(_msg, sizeof(_msg), "Expected |%s - %s| < %f: got %f vs %f", #a, #b, (float)(eps), _a, _b); \ + test_fail(__FILE__, __LINE__, _msg); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +int run_sensor_slots_tests() { + int passed = 0, failed = 0; + + // ── Test: defaults are NaN and not found ── + { + test_begin("SensorSlots", "defaults are NaN and not found"); + + SensorSlots::reset(); + ASSERT_TRUE(std::isnan(SensorSlots::read(SensorId::SOLAR))); + ASSERT_FALSE(SensorSlots::isFound(SensorId::SOLAR)); + + test_suite_end("SensorSlots::defaults", 1, 0); + passed++; + } + + // ── Test: write/read round-trip ── + { + test_begin("SensorSlots", "write/read round-trip"); + + SensorSlots::reset(); + SensorSlots::write(SensorId::POOL, 26.5f, true); + ASSERT_TRUE(SensorSlots::isFound(SensorId::POOL)); + ASSERT_NEAR(SensorSlots::read(SensorId::POOL), 26.5f, 0.01f); + + test_suite_end("SensorSlots::roundtrip", 1, 0); + passed++; + } + + // ── Test: write NaN marks not found ── + { + test_begin("SensorSlots", "write NaN marks not found"); + + SensorSlots::reset(); + SensorSlots::write(SensorId::SOLAR, NAN, false); + ASSERT_FALSE(SensorSlots::isFound(SensorId::SOLAR)); + ASSERT_TRUE(std::isnan(SensorSlots::read(SensorId::SOLAR))); + + test_suite_end("SensorSlots::nan", 1, 0); + passed++; + } + + // ── Test: slots are independent ── + { + test_begin("SensorSlots", "slots are independent"); + + SensorSlots::reset(); + SensorSlots::write(SensorId::SOLAR, 30.0f, true); + SensorSlots::write(SensorId::CONTROLLER, 41.2f, true); + ASSERT_NEAR(SensorSlots::read(SensorId::SOLAR), 30.0f, 0.01f); + ASSERT_NEAR(SensorSlots::read(SensorId::CONTROLLER), 41.2f, 0.01f); + ASSERT_FALSE(SensorSlots::isFound(SensorId::POOL)); + + test_suite_end("SensorSlots::independent", 1, 0); + passed++; + } + + (void)failed; + return 0; +} diff --git a/test/native/tests/test_telemetry_queue.cpp b/test/native/tests/test_telemetry_queue.cpp new file mode 100644 index 00000000..e0ff3143 --- /dev/null +++ b/test/native/tests/test_telemetry_queue.cpp @@ -0,0 +1,136 @@ +/** + * @file test_telemetry_queue.cpp + * @brief Unit tests for TelemetryQueue — SPSC ring buffer semantics. + */ + +#include +#include + +#include "TelemetryQueue.hpp" + +using namespace PoolController; // NOLINT(build/namespaces) + +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) + +int run_telemetry_queue_tests() { + int passed = 0, failed = 0; + + // ── Test: empty on fresh construction ── + { + test_begin("TelemetryQueue", "empty after reset"); + + TelemetryQueue queue; + ASSERT_EQ(queue.count(), 0u); + + test_suite_end("TelemetryQueue::empty", 1, 0); + passed++; + } + + // ── Test: enqueue/dequeue round-trip ── + { + test_begin("TelemetryQueue", "enqueue/dequeue round-trip"); + + TelemetryQueue queue; + ASSERT_TRUE(queue.enqueue(PublishRequestKind::STATES)); + ASSERT_EQ(queue.count(), 1u); + + PublishRequestKind kind = PublishRequestKind::DISCOVERY; + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_EQ(static_cast(kind), static_cast(PublishRequestKind::STATES)); + ASSERT_EQ(queue.count(), 0u); + + test_suite_end("TelemetryQueue::roundtrip", 1, 0); + passed++; + } + + // ── Test: FIFO ordering ── + { + test_begin("TelemetryQueue", "FIFO ordering preserved"); + + TelemetryQueue queue; + queue.enqueue(PublishRequestKind::STATES); + queue.enqueue(PublishRequestKind::DISCOVERY); + + PublishRequestKind kind; + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_EQ(static_cast(kind), static_cast(PublishRequestKind::STATES)); + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_EQ(static_cast(kind), static_cast(PublishRequestKind::DISCOVERY)); + + test_suite_end("TelemetryQueue::fifo", 1, 0); + passed++; + } + + // ── Test: dequeue on empty returns false ── + { + test_begin("TelemetryQueue", "dequeue on empty returns false"); + + TelemetryQueue queue; + PublishRequestKind kind = PublishRequestKind::STATES; + ASSERT_FALSE(queue.dequeue(kind)); + ASSERT_EQ(static_cast(kind), static_cast(PublishRequestKind::STATES)); + + test_suite_end("TelemetryQueue::empty_dequeue", 1, 0); + passed++; + } + + // ── Test: enqueue on full drops and returns false ── + { + test_begin("TelemetryQueue", "enqueue on full drops"); + + TelemetryQueue queue; + for (size_t i = 0; i < TelemetryQueue::CAPACITY; i++) { + ASSERT_TRUE(queue.enqueue(PublishRequestKind::STATES)); + } + ASSERT_FALSE(queue.enqueue(PublishRequestKind::DISCOVERY)); + ASSERT_EQ(queue.count(), TelemetryQueue::CAPACITY); + + test_suite_end("TelemetryQueue::full_drop", 1, 0); + passed++; + } + + // ── Test: reset clears a full queue ── + { + test_begin("TelemetryQueue", "reset clears queue"); + + TelemetryQueue queue; + for (size_t i = 0; i < TelemetryQueue::CAPACITY; i++) { + queue.enqueue(PublishRequestKind::STATES); + } + queue.reset(); + ASSERT_EQ(queue.count(), 0u); + + test_suite_end("TelemetryQueue::reset", 1, 0); + passed++; + } + + (void)failed; + return 0; +} From 6eb56a5ef651d922398df9d33d8d897a32c6176e Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 20:59:25 +0200 Subject: [PATCH 05/28] refactor: split DallasTemperatureNode measurement into begin/finish with sensor slots --- src/DallasTemperatureNode.cpp | 167 ++++++++++---------- src/DallasTemperatureNode.hpp | 30 +++- test/native/mocks/DallasTemperatureNode.hpp | 2 + 3 files changed, 115 insertions(+), 84 deletions(-) diff --git a/src/DallasTemperatureNode.cpp b/src/DallasTemperatureNode.cpp index 06a675c3..9de634c5 100644 --- a/src/DallasTemperatureNode.cpp +++ b/src/DallasTemperatureNode.cpp @@ -12,6 +12,7 @@ #include "Config.hpp" #include "SystemMonitor.hpp" #include "DegradationManager.hpp" +#include "SensorSlots.hpp" #include "Utils.hpp" // ── Dedicated bus constructor ────────────────────────────────────────────── @@ -194,103 +195,105 @@ void DallasTemperatureNode::begin() { } } -void DallasTemperatureNode::loop() { +void DallasTemperatureNode::beginMeasurement() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; - unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : _measurementInterval; - - if (Utils::shouldMeasure(_lastMeasurement, effectiveInterval)) { - _lastMeasurement = millis(); - - if (sharedSensor_ && numberOfDevices > 0) { - // ── Shared bus mode ──────────────────────────────────────────────── - // The master (deviceIndex 0) drives the conversion for all sensors. - if (isBusMaster_) { - Serial.printf("〽 Reading Dallas sensors (shared bus)\n"); - - PoolController::SystemMonitor::feedWatchdog(); - activeSensor->requestTemperatures(); - PoolController::SystemMonitor::feedWatchdog(); - - // Master reads its own sensor - float newTemp = activeSensor->getTempC(deviceAddress_); - if (newTemp == DEVICE_DISCONNECTED_C) { - Serial.println(" ✖ Solar sensor disconnected - setting to NaN"); - _temperature = NAN; - _sensorFound = false; - PoolController::DegradationManager::reportSensorStatus(_id, false); - } else { - _temperature = newTemp; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◦ 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"); - _temperature = NAN; - _sensorFound = false; - PoolController::DegradationManager::reportSensorStatus(_id, false); - } else { - _temperature = newTemp; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◦ Pool Temp = %.1f°C\n", _temperature); - } - } - } else if (numberOfDevices > 0) { - // ── Dedicated bus mode (standard) ────────────────────────────────── - Serial.printf("〽 Reading Dallas sensor: %s\n", _id); + if (sharedSensor_ && numberOfDevices > 0) { + // Shared bus: only the master drives the conversion for all sensors. + if (isBusMaster_) { PoolController::SystemMonitor::feedWatchdog(); activeSensor->requestTemperatures(); PoolController::SystemMonitor::feedWatchdog(); + } + } else if (numberOfDevices > 0) { + // Dedicated bus: start our own conversion. + PoolController::SystemMonitor::feedWatchdog(); + activeSensor->requestTemperatures(); + PoolController::SystemMonitor::feedWatchdog(); + } +} + +void DallasTemperatureNode::finishMeasurement() { + DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; - for (uint8_t i = 0; i < numberOfDevices; i++) { - DeviceAddress tempDeviceAddress; - if (activeSensor->getAddress(tempDeviceAddress, i)) { - float newTemp = activeSensor->getTempC(tempDeviceAddress); - if (newTemp == DEVICE_DISCONNECTED_C) { - Serial.println(" ✖ Sensor disconnected - setting to NaN"); - _temperature = NAN; - _sensorFound = false; - PoolController::DegradationManager::reportSensorStatus(_id, false); - } else { - _temperature = newTemp; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◦ Temp = %.1f°C\n", _temperature); - } + if (sharedSensor_ && numberOfDevices > 0) { + // Shared bus: master and slave each read their own device. + float newTemp = activeSensor->getTempC(deviceAddress_); + if (newTemp == DEVICE_DISCONNECTED_C) { + _temperature = NAN; + _sensorFound = false; + PoolController::DegradationManager::reportSensorStatus(_id, false); + Serial.printf(" ✖ %s sensor disconnected - setting to NaN\n", _id); + } else { + _temperature = newTemp; + _sensorFound = true; + PoolController::DegradationManager::reportSensorStatus(_id, true); + Serial.printf(" ◦ %s Temp = %.1f°C\n", _id, _temperature); + } + PoolController::SensorSlots::write(slotId(), _temperature, _sensorFound); + } else if (numberOfDevices > 0) { + // Dedicated bus: read all devices, take the last valid reading. + bool foundAny = false; + for (uint8_t i = 0; i < numberOfDevices; i++) { + DeviceAddress tempDeviceAddress; + if (activeSensor->getAddress(tempDeviceAddress, i)) { + float newTemp = activeSensor->getTempC(tempDeviceAddress); + if (newTemp != DEVICE_DISCONNECTED_C) { + _temperature = newTemp; + foundAny = true; } } + } + _sensorFound = foundAny; + PoolController::DegradationManager::reportSensorStatus(_id, foundAny); + if (foundAny) { + Serial.printf(" ◦ %s Temp = %.1f°C\n", _id, _temperature); } else { - // ── No sensor found — rescan ────────────────────────────────────── - Serial.println("No Sensor found on bus! Rescanning..."); - PoolController::DegradationManager::reportSensorStatus(_id, false); + _temperature = NAN; + Serial.printf(" ✖ %s sensor disconnected - setting to NaN\n", _id); + } + PoolController::SensorSlots::write(slotId(), _temperature, _sensorFound); + } else { + // No sensor found — rescan the bus. + Serial.printf("No Sensor found on bus! Rescanning (%s)...\n", _id); + PoolController::DegradationManager::reportSensorStatus(_id, false); + PoolController::SensorSlots::write(slotId(), NAN, false); - if (sharedSensor_) { - // In shared mode, rescan the shared bus - activeSensor->begin(); - numberOfDevices = activeSensor->getDeviceCount(); - if (numberOfDevices > deviceIndex_) { - activeSensor->getAddress(deviceAddress_, deviceIndex_); - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◦ %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); - _sensorFound = true; - } + if (sharedSensor_) { + activeSensor->begin(); + numberOfDevices = activeSensor->getDeviceCount(); + if (numberOfDevices > deviceIndex_) { + activeSensor->getAddress(deviceAddress_, deviceIndex_); + _sensorFound = true; + PoolController::DegradationManager::reportSensorStatus(_id, true); + Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); + } + } else { + activeSensor->begin(); + numberOfDevices = activeSensor->getDeviceCount(); + if (numberOfDevices > 0) { + _sensorFound = true; + Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); } } } } +void DallasTemperatureNode::loop() { + unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : _measurementInterval; + if (Utils::shouldMeasure(_lastMeasurement, effectiveInterval)) { + _lastMeasurement = millis(); + Serial.printf("〽 Reading Dallas sensor: %s\n", _id); + beginMeasurement(); + // Sync fallback (tests / non-task callers): conversion is blocking here. + finishMeasurement(); + } +} + +PoolController::SensorId DallasTemperatureNode::slotId() const { + return (_id[0] == 's') ? PoolController::SensorId::SOLAR : PoolController::SensorId::POOL; +} + void DallasTemperatureNode::address2String(const DeviceAddress deviceAddress, char *buffer, size_t size) const { snprintf(buffer, size, "%02X%02X%02X%02X%02X%02X%02X%02X", deviceAddress[0], deviceAddress[1], deviceAddress[2], deviceAddress[3], deviceAddress[4], deviceAddress[5], deviceAddress[6], deviceAddress[7]); diff --git a/src/DallasTemperatureNode.hpp b/src/DallasTemperatureNode.hpp index 9abc7b2d..ffe8f82b 100644 --- a/src/DallasTemperatureNode.hpp +++ b/src/DallasTemperatureNode.hpp @@ -13,6 +13,8 @@ #include #include +#include "SensorSlots.hpp" + /** * @brief Reads temperature from a DS18B20 sensor on a OneWire bus. * @@ -67,9 +69,9 @@ class DallasTemperatureNode { unsigned long getMeasurementInterval() const { return _measurementInterval; } /** @brief Get the last successfully read temperature. @return Temperature in °C, or NAN if no valid read. */ - float getTemperature() const { return _temperature; } + float getTemperature() const { return PoolController::SensorSlots::read(slotId()); } /** @brief Check if a sensor was found on the bus. @return true if at least one device is present. */ - bool isSensorFound() const { return _sensorFound; } + bool isSensorFound() const { return PoolController::SensorSlots::isFound(slotId()); } /** @brief Get number of devices detected on this node's bus. */ uint8_t getDeviceCount() const { return numberOfDevices; } @@ -101,6 +103,27 @@ class DallasTemperatureNode { /** @brief Read temperature periodically (respects measurementInterval). */ void loop(); + /** + * @brief Start a temperature conversion (non-blocking on Core 0). + * + * In shared-bus mode only the master (deviceIndex 0) issues + * requestTemperatures(); slaves just return. In dedicated mode the + * node starts its own conversion. + * @note Call from SensorTask; the result must be read later via + * finishMeasurement() after the conversion time has elapsed. + */ + void beginMeasurement(); + + /** + * @brief Read the conversion result and publish it to SensorSlots. + * + * Reads the temperature from the bus, updates the internal state, reports + * sensor status to DegradationManager, and writes the value into the + * thread-safe SensorSlots for cross-task consumers. + * @note Call from SensorTask after beginMeasurement() + conversion delay. + */ + void finishMeasurement(); + private: static const int MIN_INTERVAL = 10; // in seconds (more granular loop support) static const int MEASUREMENT_INTERVAL = 300; @@ -136,6 +159,9 @@ class DallasTemperatureNode { * @return true if the device was found, false if fallback was used. */ bool resolveFilter(); + /** @brief Map this node to its SensorSlots id. */ + PoolController::SensorId slotId() const; + /** @brief Format a DeviceAddress as a hex string. */ void address2String(const DeviceAddress deviceAddress, char *buffer, size_t size) const; }; diff --git a/test/native/mocks/DallasTemperatureNode.hpp b/test/native/mocks/DallasTemperatureNode.hpp index 2c76682a..10a13eb7 100644 --- a/test/native/mocks/DallasTemperatureNode.hpp +++ b/test/native/mocks/DallasTemperatureNode.hpp @@ -11,6 +11,8 @@ class DallasTemperatureNode { void begin() {} void loop() {} + void beginMeasurement() {} + void finishMeasurement() {} float getTemperature() const { return _temperature; } void setTemperature(float t) { _temperature = t; } From fdab6f9761d8fe5167d51f35402d57427a60aa73 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 21:03:51 +0200 Subject: [PATCH 06/28] feat: run DS18B20 sensor reads in a dedicated Core-0 task --- src/CoreScheduler.cpp | 35 ++++++++++++ src/CoreScheduler.hpp | 39 +++++++++++++ src/PoolController.cpp | 15 +++-- src/PoolController.hpp | 4 +- src/SensorTask.cpp | 70 +++++++++++++++++++++++ src/SensorTask.hpp | 34 +++++++++++ test/native/CMakeLists.txt | 2 + test/native/mocks/CoreScheduler.cpp | 16 ++++++ test/native/mocks/CoreScheduler.hpp | 30 ++++++++++ test/native/tests/test_core_scheduler.cpp | 69 ++++++++++++++++++++++ test/native/tests/test_main.cpp | 2 + 11 files changed, 311 insertions(+), 5 deletions(-) create mode 100644 src/CoreScheduler.cpp create mode 100644 src/CoreScheduler.hpp create mode 100644 src/SensorTask.cpp create mode 100644 src/SensorTask.hpp create mode 100644 test/native/mocks/CoreScheduler.cpp create mode 100644 test/native/mocks/CoreScheduler.hpp create mode 100644 test/native/tests/test_core_scheduler.cpp diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp new file mode 100644 index 00000000..6314bf1f --- /dev/null +++ b/src/CoreScheduler.cpp @@ -0,0 +1,35 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file CoreScheduler.cpp + * @brief Task creation for the Core-0 I/O tasks. + */ + +#include "CoreScheduler.hpp" + +#include +#include +#include + +#include "SensorTask.hpp" + +namespace PoolController { + +void CoreScheduler::begin() { + // Core 0 = PRO_CPU_NUM (I/O core); Core 1 = APP_CPU_NUM (control loop). + const BaseType_t core0 = PRO_CPU_NUM; + + SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); +} + +void CoreScheduler::logStackWatermarks() { + static uint32_t lastLog = 0; + if (millis() - lastLog < 60000) { + return; + } + lastLog = millis(); + SensorTask::logStackWatermark(); +} + +} // namespace PoolController diff --git a/src/CoreScheduler.hpp b/src/CoreScheduler.hpp new file mode 100644 index 00000000..db8721bb --- /dev/null +++ b/src/CoreScheduler.hpp @@ -0,0 +1,39 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file CoreScheduler.hpp + * @brief Static launcher for the Core-0 I/O tasks. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Creates and tracks the dedicated I/O tasks pinned to Core 0. + * + * All tasks are created once in begin() with fixed stacks and priorities + * (no dynamic task creation after setup). Priorities only matter within a + * core: the I/O tasks sit below the WiFi-stack tasks and yield via + * vTaskDelay at their scheduling period. + */ +class CoreScheduler { +public: + static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; + static constexpr uint8_t TASK_PRIORITY_PUBLISH = 1; + static constexpr uint8_t TASK_PRIORITY_DISPLAY = 1; + static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; + static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; + static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; + + /** @brief Create all Core-0 I/O tasks. Call once from setup(), after initializeController(). */ + static void begin(); + + /** @brief Log stack high-water marks of all tasks (call periodically from loop()). */ + static void logStackWatermarks(); +}; + +} // namespace PoolController diff --git a/src/PoolController.cpp b/src/PoolController.cpp index ea5338db..259efd75 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -37,6 +37,8 @@ #include "StatusLed.hpp" +#include "CoreScheduler.hpp" + #ifdef NORVI_AE01_R #include "NorviOledDisplay.hpp" #include "NorviButtonHandler.hpp" @@ -420,6 +422,9 @@ auto PoolControllerContext::setup() -> void { // OTA safety: detect version transition and verify config integrity ConfigManager::logOtaTransition(); + // Start Core-0 I/O tasks (sensors, display, publish). + CoreScheduler::begin(); + Serial.printf("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); } @@ -431,9 +436,11 @@ auto PoolControllerContext::setup() -> void { * 2. Evaluate degradation levels (DegradationManager) * 3. Clear boot-loop counter after 5 min stable uptime * 4. Run managers: NetworkManager, WebPortal, OtaUpdater - * 5. Run nodes: sensors, relays, operation mode (triggers rule engine) + * 5. Run nodes: relays, operation mode (triggers rule engine) + * — temperature sensors run in SensorTask on Core 0 (see SensorTask.cpp) * 6. Publish HA Discovery + states on MQTT (re)connect * 7. Periodically publish telemetry states to MQTT (every loopInterval s) + * 8. Log Core-0 task stack watermarks (throttled) */ auto PoolControllerContext::loop() -> void { // Feed watchdog and check memory thresholds @@ -486,9 +493,6 @@ auto PoolControllerContext::loop() -> void { #endif // Run drivers & logic rules - solarTemperatureNode.loop(); - poolTemperatureNode.loop(); - ctrlTemperatureNode.loop(); poolPumpNode.loop(); solarPumpNode.loop(); operationModeNode.loop(); @@ -510,6 +514,9 @@ auto PoolControllerContext::loop() -> void { _lastMeasurement = millis(); MqttPublisher::publishStates(); } + + // Log Core-0 task stack high-water marks (throttled inside). + CoreScheduler::logStackWatermarks(); } } // namespace PoolController diff --git a/src/PoolController.hpp b/src/PoolController.hpp index 22eea637..2465dca3 100644 --- a/src/PoolController.hpp +++ b/src/PoolController.hpp @@ -45,7 +45,9 @@ struct PoolControllerContext final { /** * @brief Run the main control loop iteration. * Feeds watchdog, checks memory, runs managers (network, web, OTA), updates - * sensor/relay nodes, evaluates rules, and publishes MQTT states periodically. + * relay/operation-mode nodes, evaluates rules, and publishes MQTT states + * periodically. Temperature sensor reads run in SensorTask on Core 0 + * (started from setup() via CoreScheduler) and publish into SensorSlots. * @note Call from the Arduino loop() function indefinitely. */ auto loop() -> void; diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp new file mode 100644 index 00000000..f4a20391 --- /dev/null +++ b/src/SensorTask.cpp @@ -0,0 +1,70 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorTask.cpp + * @brief DS18B20 + internal temperature measurement task. + */ + +#include "SensorTask.hpp" + +#include +#include +#include + +#include "DallasTemperatureNode.hpp" +#include "ESP32TemperatureNode.hpp" + +namespace PoolController { + +// Referenced from PoolController.cpp (namespace scope globals). +extern DallasTemperatureNode solarTemperatureNode; +extern DallasTemperatureNode poolTemperatureNode; +extern ESP32TemperatureNode ctrlTemperatureNode; + +namespace { +TaskHandle_t sensorTaskHandle = nullptr; +uint32_t lastSolarReadingMs = 0; +uint32_t lastControllerReadingMs = 0; +constexpr uint32_t CONVERSION_DELAY_MS = 800; // 12-bit DS18B20 conversion +} // namespace + +void sensorTaskFunc(void *) { + for (;;) { + const uint32_t now = millis(); + + // Solar (master on shared NORVI bus) drives the shared conversion. + const unsigned long solarInterval = solarTemperatureNode.getMeasurementInterval(); + if (now - lastSolarReadingMs >= solarInterval * 1000UL) { + lastSolarReadingMs = now; + Serial.println("〽 SensorTask: reading Dallas sensors"); + solarTemperatureNode.beginMeasurement(); + // Yield while the conversion runs — never block the control loop. + vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); + solarTemperatureNode.finishMeasurement(); + poolTemperatureNode.finishMeasurement(); + } + + // ESP32 internal temperature on its own interval. + const unsigned long ctrlInterval = ctrlTemperatureNode.getMeasurementInterval(); + if (now - lastControllerReadingMs >= ctrlInterval * 1000UL) { + lastControllerReadingMs = now; + ctrlTemperatureNode.loop(); + } + + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +void SensorTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + xTaskCreatePinnedToCore(sensorTaskFunc, "sensor", stackBytes, nullptr, priority, &sensorTaskHandle, core); +} + +void SensorTask::logStackWatermark() { + if (sensorTaskHandle != nullptr) { + Serial.printf(" SensorTask stack high-water: %u B\n", + static_cast(uxTaskGetStackHighWaterMark(sensorTaskHandle))); + } +} + +} // namespace PoolController diff --git a/src/SensorTask.hpp b/src/SensorTask.hpp new file mode 100644 index 00000000..66a847c3 --- /dev/null +++ b/src/SensorTask.hpp @@ -0,0 +1,34 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorTask.hpp + * @brief Core-0 task owning all DS18B20/OneWire and ESP32 internal temp reads. + */ + +#pragma once + +#include + +#include +#include + +namespace PoolController { + +/** + * @brief Runs the temperature measurement cycle exclusively on Core 0. + * + * Owns all Dallas/OneWire bus access (OneWire is not thread-safe — the + * control loop never touches the buses anymore). Per period: begin + * conversion, yield via vTaskDelay, read results, publish to SensorSlots. + */ +class SensorTask { +public: + /** @brief Create and start the task pinned to the given core. */ + static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index a68f7232..ed3bc080 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -65,6 +65,7 @@ set(MOCK_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/mocks/ConfigManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mocks/globals.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mocks/stubs.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/mocks/CoreScheduler.cpp ) # Test sources @@ -79,6 +80,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_timer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_telemetry_queue.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_core_scheduler.cpp ) add_executable(test_runner diff --git a/test/native/mocks/CoreScheduler.cpp b/test/native/mocks/CoreScheduler.cpp new file mode 100644 index 00000000..5d452e88 --- /dev/null +++ b/test/native/mocks/CoreScheduler.cpp @@ -0,0 +1,16 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#include "CoreScheduler.hpp" + +namespace PoolController { +uint8_t CoreScheduler::sensorPriority = 0; +uint16_t CoreScheduler::sensorStack = 0; + +void CoreScheduler::begin() { + sensorPriority = TASK_PRIORITY_SENSOR; + sensorStack = TASK_STACK_SENSOR; +} + +void CoreScheduler::logStackWatermarks() {} +} // namespace PoolController diff --git a/test/native/mocks/CoreScheduler.hpp b/test/native/mocks/CoreScheduler.hpp new file mode 100644 index 00000000..0f8fb20f --- /dev/null +++ b/test/native/mocks/CoreScheduler.hpp @@ -0,0 +1,30 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Native test double for CoreScheduler. + * Captures begin() parameters so tests can assert the planned values. + */ +class CoreScheduler { +public: + static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; + static constexpr uint8_t TASK_PRIORITY_PUBLISH = 1; + static constexpr uint8_t TASK_PRIORITY_DISPLAY = 1; + static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; + static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; + static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; + + static void begin(); + static void logStackWatermarks(); + + static uint8_t sensorPriority; + static uint16_t sensorStack; +}; + +} // namespace PoolController diff --git a/test/native/tests/test_core_scheduler.cpp b/test/native/tests/test_core_scheduler.cpp new file mode 100644 index 00000000..c207edd3 --- /dev/null +++ b/test/native/tests/test_core_scheduler.cpp @@ -0,0 +1,69 @@ +/** + * @file test_core_scheduler.cpp + * @brief Unit tests for CoreScheduler — task parameter assertions via mock. + */ + +#include +#include + +#include "CoreScheduler.hpp" + +using namespace PoolController; // NOLINT(build/namespaces) + +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) + +int run_core_scheduler_tests() { + int passed = 0, failed = 0; + + // ── Test: sensor task priority matches plan ── + { + test_begin("CoreScheduler", "sensor task priority matches plan"); + + CoreScheduler::begin(); + ASSERT_EQ(CoreScheduler::sensorPriority, CoreScheduler::TASK_PRIORITY_SENSOR); + + test_suite_end("CoreScheduler::sensor_priority", 1, 0); + passed++; + } + + // ── Test: sensor task stack matches plan ── + { + test_begin("CoreScheduler", "sensor task stack matches plan"); + + CoreScheduler::begin(); + ASSERT_EQ(CoreScheduler::sensorStack, CoreScheduler::TASK_STACK_SENSOR); + + test_suite_end("CoreScheduler::sensor_stack", 1, 0); + passed++; + } + + (void)failed; + return 0; +} diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index 3f003637..4b2de58c 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -108,6 +108,7 @@ extern int run_state_manager_tests(); extern int run_timer_tests(); extern int run_telemetry_queue_tests(); extern int run_sensor_slots_tests(); +extern int run_core_scheduler_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -124,6 +125,7 @@ int main() { total += run_timer_tests(); total += run_telemetry_queue_tests(); total += run_sensor_slots_tests(); + total += run_core_scheduler_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); From bf1ef34086b2a9259a00ac5b58287d1bf1127f90 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 21:05:54 +0200 Subject: [PATCH 07/28] feat: move MQTT serialization into a dedicated Core-0 publish task --- src/CoreScheduler.cpp | 3 +++ src/PoolController.cpp | 11 +++++---- src/PublishTask.cpp | 53 ++++++++++++++++++++++++++++++++++++++++++ src/PublishTask.hpp | 34 +++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 5 deletions(-) create mode 100644 src/PublishTask.cpp create mode 100644 src/PublishTask.hpp diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp index 6314bf1f..331d263f 100644 --- a/src/CoreScheduler.cpp +++ b/src/CoreScheduler.cpp @@ -13,6 +13,7 @@ #include #include "SensorTask.hpp" +#include "PublishTask.hpp" namespace PoolController { @@ -21,6 +22,7 @@ void CoreScheduler::begin() { const BaseType_t core0 = PRO_CPU_NUM; SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); + PublishTask::start(TASK_PRIORITY_PUBLISH, TASK_STACK_PUBLISH, core0); } void CoreScheduler::logStackWatermarks() { @@ -30,6 +32,7 @@ void CoreScheduler::logStackWatermarks() { } lastLog = millis(); SensorTask::logStackWatermark(); + PublishTask::logStackWatermark(); } } // namespace PoolController diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 259efd75..4fbfcf20 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -38,6 +38,7 @@ #include "StatusLed.hpp" #include "CoreScheduler.hpp" +#include "TelemetryQueue.hpp" #ifdef NORVI_AE01_R #include "NorviOledDisplay.hpp" @@ -501,18 +502,18 @@ auto PoolControllerContext::loop() -> void { static bool wasMqttConnected = false; bool currentMqttState = NetworkManager::isMqttConnected(); if (currentMqttState && !wasMqttConnected) { - // Freshly connected to MQTT: publish Discovery and States - MqttPublisher::publishDiscovery(); - MqttPublisher::publishStates(); + // Freshly connected to MQTT: publish Discovery and States via PublishTask. + TelemetryQueue::instance().enqueue(PublishRequestKind::DISCOVERY); + TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); wasMqttConnected = true; } else if (!currentMqttState) { wasMqttConnected = false; } - // Periodically publish telemetry states to HA (P4) + // Periodically enqueue telemetry publish to HA (P4) — serialization runs on Core 0. if (currentMqttState && Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); - MqttPublisher::publishStates(); + TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); } // Log Core-0 task stack high-water marks (throttled inside). diff --git a/src/PublishTask.cpp b/src/PublishTask.cpp new file mode 100644 index 00000000..21297eff --- /dev/null +++ b/src/PublishTask.cpp @@ -0,0 +1,53 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file PublishTask.cpp + * @brief MQTT publish task draining the telemetry queue. + */ + +#include "PublishTask.hpp" + +#include +#include +#include + +#include "TelemetryQueue.hpp" +#include "MqttPublisher.hpp" +#include "OtaUpdater.hpp" + +namespace PoolController { + +namespace { +TaskHandle_t publishTaskHandle = nullptr; +} // namespace + +void publishTaskFunc(void *) { + for (;;) { + PublishRequestKind kind; + while (TelemetryQueue::instance().dequeue(kind)) { + // Pause during OTA updates, but keep draining to avoid queue buildup. + if (!OtaUpdater::isUpdateInProgress()) { + if (kind == PublishRequestKind::DISCOVERY) { + MqttPublisher::publishDiscovery(); + } else { + MqttPublisher::publishStates(); + } + } + } + vTaskDelay(pdMS_TO_TICKS(50)); + } +} + +void PublishTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + xTaskCreatePinnedToCore(publishTaskFunc, "publish", stackBytes, nullptr, priority, &publishTaskHandle, core); +} + +void PublishTask::logStackWatermark() { + if (publishTaskHandle != nullptr) { + Serial.printf(" PublishTask stack high-water: %u B\n", + static_cast(uxTaskGetStackHighWaterMark(publishTaskHandle))); + } +} + +} // namespace PoolController diff --git a/src/PublishTask.hpp b/src/PublishTask.hpp new file mode 100644 index 00000000..560ed028 --- /dev/null +++ b/src/PublishTask.hpp @@ -0,0 +1,34 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file PublishTask.hpp + * @brief Core-0 task that serializes and publishes MQTT telemetry. + */ + +#pragma once + +#include + +#include +#include + +namespace PoolController { + +/** + * @brief Drains the telemetry queue and performs MQTT serialization on Core 0. + * + * The control loop only enqueues publish requests (non-blocking); the heavy + * JSON/HA-discovery serialization and the AsyncMqttClient::publish() calls + * run here. AsyncMqttClient::publish() is non-blocking from the library side. + */ +class PublishTask { +public: + /** @brief Create and start the task pinned to the given core. */ + static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController From 48ae8b0b50677e578b39d461a661a24efd7b9bd2 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 21:10:39 +0200 Subject: [PATCH 08/28] feat: render NORVI OLED on a Core-0 display task --- src/CoreScheduler.cpp | 9 ++++++++ src/DisplayTask.cpp | 47 ++++++++++++++++++++++++++++++++++++++++ src/DisplayTask.hpp | 37 +++++++++++++++++++++++++++++++ src/NorviOledDisplay.cpp | 33 ++++++++++++++++------------ src/NorviOledDisplay.hpp | 20 ++++++++++++----- src/PoolController.cpp | 6 +++-- 6 files changed, 130 insertions(+), 22 deletions(-) create mode 100644 src/DisplayTask.cpp create mode 100644 src/DisplayTask.hpp diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp index 331d263f..bd6cfd18 100644 --- a/src/CoreScheduler.cpp +++ b/src/CoreScheduler.cpp @@ -14,6 +14,9 @@ #include "SensorTask.hpp" #include "PublishTask.hpp" +#ifdef NORVI_AE01_R +#include "DisplayTask.hpp" +#endif namespace PoolController { @@ -23,6 +26,9 @@ void CoreScheduler::begin() { SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); PublishTask::start(TASK_PRIORITY_PUBLISH, TASK_STACK_PUBLISH, core0); +#ifdef NORVI_AE01_R + DisplayTask::start(TASK_PRIORITY_DISPLAY, TASK_STACK_DISPLAY, core0); +#endif } void CoreScheduler::logStackWatermarks() { @@ -33,6 +39,9 @@ void CoreScheduler::logStackWatermarks() { lastLog = millis(); SensorTask::logStackWatermark(); PublishTask::logStackWatermark(); +#ifdef NORVI_AE01_R + DisplayTask::logStackWatermark(); +#endif } } // namespace PoolController diff --git a/src/DisplayTask.cpp b/src/DisplayTask.cpp new file mode 100644 index 00000000..86b16dff --- /dev/null +++ b/src/DisplayTask.cpp @@ -0,0 +1,47 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayTask.cpp + * @brief OLED render task (NORVI_AE01_R only). + */ + +#include "DisplayTask.hpp" + +#include +#include +#include + +#include "NorviOledDisplay.hpp" + +namespace PoolController { + +namespace { +TaskHandle_t displayTaskHandle = nullptr; +volatile bool renderRequested = false; +} // namespace + +void displayTaskFunc(void *) { + for (;;) { + if (renderRequested || (millis() % 2000 < 50)) { + renderRequested = false; + NorviOledDisplay::render(); + } + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +void DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + xTaskCreatePinnedToCore(displayTaskFunc, "display", stackBytes, nullptr, priority, &displayTaskHandle, core); +} + +void DisplayTask::requestRender() { renderRequested = true; } + +void DisplayTask::logStackWatermark() { + if (displayTaskHandle != nullptr) { + Serial.printf(" DisplayTask stack high-water: %u B\n", + static_cast(uxTaskGetStackHighWaterMark(displayTaskHandle))); + } +} + +} // namespace PoolController diff --git a/src/DisplayTask.hpp b/src/DisplayTask.hpp new file mode 100644 index 00000000..ba4fca3d --- /dev/null +++ b/src/DisplayTask.hpp @@ -0,0 +1,37 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayTask.hpp + * @brief Core-0 task rendering the NORVI OLED display (NORVI_AE01_R only). + */ + +#pragma once + +#include + +#include +#include + +namespace PoolController { + +/** + * @brief Renders the OLED display on Core 0. + * + * The control loop advances the display state machine and requests renders; + * this task owns the I2C SSD1306 work so a hung display can never stall + * the control loop. NORVI_AE01_R only. + */ +class DisplayTask { +public: + /** @brief Create and start the task pinned to the given core. */ + static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Request a render on the next task tick. */ + static void requestRender(); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController diff --git a/src/NorviOledDisplay.cpp b/src/NorviOledDisplay.cpp index 34d6d74a..675740ef 100644 --- a/src/NorviOledDisplay.cpp +++ b/src/NorviOledDisplay.cpp @@ -28,6 +28,7 @@ #include "Utils.hpp" #include "NetworkManager.hpp" #include "Nodes.hpp" +#include "SensorSlots.hpp" #include "SystemMonitor.hpp" #include "TimeClientHelper.hpp" #include "ConfigManager.hpp" @@ -45,8 +46,8 @@ static void drawProgressBar(); // ═══════════════════════════════════════════════════════════════════════════ NorviOledDisplay::Page NorviOledDisplay::currentPage_ = Page::MAIN; -uint32_t NorviOledDisplay::lastUpdateMs_ = 0; -bool NorviOledDisplay::forceRedraw_ = true; +volatile uint32_t NorviOledDisplay::lastUpdateMs_ = 0; +volatile bool NorviOledDisplay::forceRedraw_ = true; uint32_t NorviOledDisplay::lastButtonPressMs_ = 0; @@ -302,10 +303,11 @@ void NorviOledDisplay::begin() { } // ═══════════════════════════════════════════════════════════════════════════ -// loop() — Periodic update +// update() — Display state machine (Core 1, non-blocking) +// render() — OLED draw + I2C push (Core 0, DisplayTask) // ═══════════════════════════════════════════════════════════════════════════ -void NorviOledDisplay::loop() { +void NorviOledDisplay::update() { const uint32_t now = millis(); // ── Auto-return after idle timeout ─────────────────────────────────── @@ -319,9 +321,10 @@ void NorviOledDisplay::loop() { forceRedraw_ = true; } - // ── Long-press progress: accelerate redraw during hold ────────────── - float longPressProgress = NorviButtonHandler::getLongPressProgress(); - bool isLongPressing = (longPressProgress > 0.0f); + // ── Long-press progress: request continuous redraw during hold ────── + if (NorviButtonHandler::getLongPressProgress() > 0.0f) { + forceRedraw_ = true; + } // ── Auto-return warning (5s before) ───────────────────────────────── // Sets file-scope autoReturnWarningMs consumed by drawFooter() @@ -336,16 +339,18 @@ void NorviOledDisplay::loop() { } else { autoReturnWarningMs = 0; } +} - // ── Throttle redraw rate (skip during long-press for smooth bar) ──── - if (!forceRedraw_ && !isLongPressing && (now - lastUpdateMs_ < UPDATE_INTERVAL_MS)) { +void NorviOledDisplay::render() { + // ── Throttle redraw rate ──────────────────────────────────────────── + if (!forceRedraw_ && (millis() - lastUpdateMs_ < UPDATE_INTERVAL_MS)) { return; } // ── Burn-in offset shift ──────────────────────────────────────────── updateBurnInOffset(); - lastUpdateMs_ = now; + lastUpdateMs_ = millis(); forceRedraw_ = false; drawPage(); @@ -717,9 +722,9 @@ void NorviOledDisplay::drawMainPage() { // ── Pool temperature ──────────────────────────────────────────────────── display.setTextSize(2); dspCursor(TX, 0); - if (poolTemperatureNode.isSensorFound()) { + if (SensorSlots::isFound(SensorId::POOL)) { char buf[8]; - Utils::floatToString(poolTemperatureNode.getTemperature(), buf, sizeof(buf), 1); + Utils::floatToString(SensorSlots::read(SensorId::POOL), buf, sizeof(buf), 1); display.print(buf); drawDegC(2); } else { @@ -737,9 +742,9 @@ void NorviOledDisplay::drawMainPage() { // ── Solar temperature ────────────────────────────────────────────────── display.setTextSize(2); dspCursor(TX, 28); - if (solarTemperatureNode.isSensorFound()) { + if (SensorSlots::isFound(SensorId::SOLAR)) { char buf[8]; - Utils::floatToString(solarTemperatureNode.getTemperature(), buf, sizeof(buf), 1); + Utils::floatToString(SensorSlots::read(SensorId::SOLAR), buf, sizeof(buf), 1); display.print(buf); drawDegC(2); } else { diff --git a/src/NorviOledDisplay.hpp b/src/NorviOledDisplay.hpp index c593c44d..9a027fe5 100644 --- a/src/NorviOledDisplay.hpp +++ b/src/NorviOledDisplay.hpp @@ -75,11 +75,16 @@ class NorviOledDisplay { static void begin(); /** - * @brief Update the display periodically. - * Handles auto-return to MAIN, burn-in shift, and page redraw. - * Must be called from PoolController::loop(). + * @brief Advance the display state machine (page nav, auto-return, burn-in). + * Runs on the control loop (Core 1); cheap, non-blocking. */ - static void loop(); + static void update(); + + /** + * @brief Redraw the current page and push to the OLED over I2C. + * Runs on DisplayTask (Core 0). Reads temps from SensorSlots. + */ + static void render(); /** @brief Previous page (S1 / UP). */ static void previousPage(); @@ -219,9 +224,12 @@ class NorviOledDisplay { // ═════════════════════════════════════════════════════════════════════ static Page currentPage_; - static uint32_t lastUpdateMs_; + // Written by the control loop (Core 1) and read/reset by DisplayTask + // (Core 0): word-sized access is atomic on ESP32, so a one-cycle-stale + // redraw decision is acceptable. + static volatile uint32_t lastUpdateMs_; static constexpr uint32_t UPDATE_INTERVAL_MS{2000}; - static bool forceRedraw_; + static volatile bool forceRedraw_; // ── Idle auto-return ───────────────────────────────────────────────── static uint32_t lastButtonPressMs_; diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 4fbfcf20..da63471f 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -43,6 +43,7 @@ #ifdef NORVI_AE01_R #include "NorviOledDisplay.hpp" #include "NorviButtonHandler.hpp" +#include "DisplayTask.hpp" #endif #include "Config.hpp" @@ -488,8 +489,9 @@ auto PoolControllerContext::loop() -> void { StatusLed::loop(); #ifdef NORVI_AE01_R - // Update NORVI OLED display and read front-panel buttons - NorviOledDisplay::loop(); + // Advance display state machine (Core 1) and request render on DisplayTask (Core 0). + NorviOledDisplay::update(); + DisplayTask::requestRender(); NorviButtonHandler::loop(); #endif From cebcafcd15112c55c810cebb0993901cf21f329d Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 21:15:15 +0200 Subject: [PATCH 09/28] feat: thread-safe sensor status reporting and task watchdog wrapper --- src/DallasTemperatureNode.cpp | 8 +- src/DegradationManager.cpp | 6 +- src/DegradationManager.hpp | 10 +- src/SensorTask.cpp | 3 + src/SystemMonitor.hpp | 7 ++ test/native/CMakeLists.txt | 1 + .../native/tests/test_degradation_manager.cpp | 100 ++++++++++++++++++ test/native/tests/test_main.cpp | 2 + 8 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 test/native/tests/test_degradation_manager.cpp diff --git a/src/DallasTemperatureNode.cpp b/src/DallasTemperatureNode.cpp index 9de634c5..2d2f9c4a 100644 --- a/src/DallasTemperatureNode.cpp +++ b/src/DallasTemperatureNode.cpp @@ -201,15 +201,15 @@ void DallasTemperatureNode::beginMeasurement() { if (sharedSensor_ && numberOfDevices > 0) { // Shared bus: only the master drives the conversion for all sensors. if (isBusMaster_) { - PoolController::SystemMonitor::feedWatchdog(); + PoolController::SystemMonitor::feedWatchdogFromTask(); activeSensor->requestTemperatures(); - PoolController::SystemMonitor::feedWatchdog(); + PoolController::SystemMonitor::feedWatchdogFromTask(); } } else if (numberOfDevices > 0) { // Dedicated bus: start our own conversion. - PoolController::SystemMonitor::feedWatchdog(); + PoolController::SystemMonitor::feedWatchdogFromTask(); activeSensor->requestTemperatures(); - PoolController::SystemMonitor::feedWatchdog(); + PoolController::SystemMonitor::feedWatchdogFromTask(); } } diff --git a/src/DegradationManager.cpp b/src/DegradationManager.cpp index bd8c8d02..f53a5f56 100644 --- a/src/DegradationManager.cpp +++ b/src/DegradationManager.cpp @@ -19,10 +19,10 @@ namespace PoolController { // Static member definitions DegradationLevel DegradationManager::currentLevel_ = DegradationLevel::NORMAL; DegradationLevel DegradationManager::previousLevel_ = DegradationLevel::NORMAL; -bool DegradationManager::poolSensorOk_ = false; -bool DegradationManager::solarSensorOk_ = false; +volatile bool DegradationManager::poolSensorOk_ = false; +volatile bool DegradationManager::solarSensorOk_ = false; bool DegradationManager::forcedSafeMode_ = false; -bool DegradationManager::sensorsEverReported_ = false; +volatile bool DegradationManager::sensorsEverReported_ = false; unsigned long DegradationManager::lastEvaluationMs_ = 0; // =========================================================================== diff --git a/src/DegradationManager.hpp b/src/DegradationManager.hpp index eb5c4333..c501a9a2 100644 --- a/src/DegradationManager.hpp +++ b/src/DegradationManager.hpp @@ -102,11 +102,15 @@ class DegradationManager { static void unforceSafeMode(); private: - static bool sensorsEverReported_; + // Written by reportSensorStatus() from SensorTask (Core 0), read by + // evaluate() on the control loop (Core 1). Word-sized volatile access + // is atomic on ESP32 — readers may see one-cycle-stale flags but never + // torn values, which is acceptable for degradation heuristics. + static volatile bool sensorsEverReported_; static DegradationLevel currentLevel_; static DegradationLevel previousLevel_; - static bool poolSensorOk_; - static bool solarSensorOk_; + static volatile bool poolSensorOk_; + static volatile bool solarSensorOk_; static bool forcedSafeMode_; static unsigned long lastEvaluationMs_; diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp index f4a20391..8bc8ce13 100644 --- a/src/SensorTask.cpp +++ b/src/SensorTask.cpp @@ -14,6 +14,7 @@ #include "DallasTemperatureNode.hpp" #include "ESP32TemperatureNode.hpp" +#include "SystemMonitor.hpp" namespace PoolController { @@ -41,6 +42,8 @@ void sensorTaskFunc(void *) { solarTemperatureNode.beginMeasurement(); // Yield while the conversion runs — never block the control loop. vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); + // Feed from the task context so long I/O waits can't starve the WDT. + SystemMonitor::feedWatchdogFromTask(); solarTemperatureNode.finishMeasurement(); poolTemperatureNode.finishMeasurement(); } diff --git a/src/SystemMonitor.hpp b/src/SystemMonitor.hpp index 466b2dc0..f7e06af4 100644 --- a/src/SystemMonitor.hpp +++ b/src/SystemMonitor.hpp @@ -70,6 +70,13 @@ class SystemMonitor { */ static void feedWatchdog() { esp_task_wdt_reset(); } + /** + * @brief Feed the watchdog from a non-loop task (SensorTask, PublishTask, DisplayTask). + * esp_task_wdt_reset() is safe to call from any task; this wrapper exists + * so I/O tasks can feed during long I/O waits without touching loop state. + */ + static void feedWatchdogFromTask() { esp_task_wdt_reset(); } + /** * Check memory status and reboot if critically low. * Call this periodically (e.g., every 10 seconds). diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index ed3bc080..435236bb 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -81,6 +81,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_telemetry_queue.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_core_scheduler.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_degradation_manager.cpp ) add_executable(test_runner diff --git a/test/native/tests/test_degradation_manager.cpp b/test/native/tests/test_degradation_manager.cpp new file mode 100644 index 00000000..623d82e4 --- /dev/null +++ b/test/native/tests/test_degradation_manager.cpp @@ -0,0 +1,100 @@ +/** + * @file test_degradation_manager.cpp + * @brief Unit tests for DegradationManager — thread-safe sensor status + * reporting (reportSensorStatus from SensorTask) and safe-mode + * transitions. + */ + +#include +#include +#include +#include + +#include "DegradationManager.hpp" +#include "NetworkManager.hpp" + +using namespace PoolController; // NOLINT(build/namespaces) + +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) + +int run_degradation_manager_tests() { + int passed = 0, failed = 0; + + // ── Test: safe-mode round-trip (no rate limit involved) ── + { + DegradationManager::begin(); + ASSERT_EQ(DegradationManager::getLevel(), DegradationLevel::NORMAL); + ASSERT_FALSE(DegradationManager::isSafe()); + + DegradationManager::forceSafeMode(); + ASSERT_EQ(DegradationManager::getLevel(), DegradationLevel::CRITICAL); + ASSERT_TRUE(DegradationManager::isSafe()); + + DegradationManager::unforceSafeMode(); + ASSERT_EQ(DegradationManager::getLevel(), DegradationLevel::NORMAL); + ASSERT_FALSE(DegradationManager::isSafe()); + + test_suite_end("DegradationManager::safe_mode", 1, 0); + passed++; + } + + // ── Test: reportSensorStatus drives the sensor level (thread-safe path) ── + { + // WiFi up, time GREEN (stub), memory healthy (mock): only the sensor + // flags decide between NORMAL and NO_SENSOR. + NetworkManager::setWiFiConnected(true); + DegradationManager::begin(); + + DegradationManager::reportSensorStatus("pool-temp", true); + DegradationManager::reportSensorStatus("solar-temp", true); + DegradationManager::evaluate(); // first evaluation runs immediately + ASSERT_EQ(DegradationManager::getLevel(), DegradationLevel::NORMAL); + + // One probe failing → NO_SENSOR. evaluate() is rate-limited to 5 s, + // so wait past the interval before the next call. + DegradationManager::reportSensorStatus("pool-temp", false); + std::this_thread::sleep_for(std::chrono::milliseconds(5100)); + DegradationManager::evaluate(); + ASSERT_EQ(DegradationManager::getLevel(), DegradationLevel::NO_SENSOR); + + // Recovery restores NORMAL. + DegradationManager::reportSensorStatus("pool-temp", true); + std::this_thread::sleep_for(std::chrono::milliseconds(5100)); + DegradationManager::evaluate(); + ASSERT_EQ(DegradationManager::getLevel(), DegradationLevel::NORMAL); + + test_suite_end("DegradationManager::sensor_status", 1, 0); + passed++; + } + + (void)passed; + (void)failed; + return 0; +} diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index 4b2de58c..7dfbca4d 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -109,6 +109,7 @@ extern int run_timer_tests(); extern int run_telemetry_queue_tests(); extern int run_sensor_slots_tests(); extern int run_core_scheduler_tests(); +extern int run_degradation_manager_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -126,6 +127,7 @@ int main() { total += run_telemetry_queue_tests(); total += run_sensor_slots_tests(); total += run_core_scheduler_tests(); + total += run_degradation_manager_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); From 5800153637319c6cc71fbaf3629dfb51e8055779 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 21:18:06 +0200 Subject: [PATCH 10/28] docs: sync multicore architecture pages with implementation --- docs/multicore-architecture.de.md | 18 +++++++++++------- docs/multicore-architecture.md | 18 +++++++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/docs/multicore-architecture.de.md b/docs/multicore-architecture.de.md index 44e1f39e..768e943d 100644 --- a/docs/multicore-architecture.de.md +++ b/docs/multicore-architecture.de.md @@ -27,8 +27,8 @@ umstrukturiert: | Kern | Rolle | Inhalt | | ---- | ----- | ------ | -| **Kern 0** (PRO_CPU) | I/O-Kern | SensorTask (DS18B20 + interner Temperatursensor), DisplayTask (OLED/Taster, nur NORVI), PublishTask (MQTT-Telemetrie-Serialisierung) | -| **Kern 1** (APP_CPU) | Regel-Kern | Arduino-`loop()`: Watchdog, Degradation, Regeln, Relais, Status-LED, asynchrone Netzwerk-Manager, OTA | +| **Kern 0** (PRO_CPU) | I/O-Kern | SensorTask (DS18B20 + interner Temperatursensor), DisplayTask (OLED-Rendering, nur NORVI), PublishTask (MQTT-Telemetrie-Serialisierung) | +| **Kern 1** (APP_CPU) | Regel-Kern | Arduino-`loop()`: Watchdog, Degradation, Regeln, Relais, Status-LED, asynchrone Netzwerk-Manager, OTA, Frontpanel-Tasterabfrage | ## Warum @@ -64,8 +64,8 @@ die Regelschleife auf Kern 1 also nie verdrängen. ```text SensorTask (Kern 0) ── lock-free Slots ──▶ Regelschleife (Kern 1): Regeln/Relais/Watchdog SensorTask ── Status ────────────────────▶ DegradationManager (Kern 1) -Regelschleife ── Snapshot ───────────────▶ DisplayTask (Kern 0, NORVI) -DisplayTask ── Taster-Queue ─────────────▶ Regelschleife +Regelschleife ── update() + Render-Anforderung ─▶ DisplayTask (Kern 0, NORVI) +Tasterabfrage bleibt in der Regelschleife (Kern 1) — Callbacks mutieren Loop-Singletons Regelschleife ── Telemetrie-Queue ────────▶ PublishTask (Kern 0) ──▶ MQTT Regelschleife ── asynchrones Netzwerk/OTA ── (unverändert, Kern 1) ``` @@ -73,9 +73,13 @@ Regelschleife ── asynchrones Netzwerk/OTA ── (unverändert, Kern 1) Jeder task-übergreifende Datenpfad ist **Single-Writer**: - Sensorwerte: lock-free Slots (atomar/ein Wort) — SensorTask schreibt, Regelschleife liest. -- Display-Zustand: Mutex-geschützter Snapshot — Regelschleife schreibt, DisplayTask liest. -- Taster-Eingaben: kleine Queue / atomare Flags — DisplayTask schreibt, Regelschleife liest. -- Telemetrie: FreeRTOS-Queue mit fester Kapazität — Regelschleife stellt ein, +- Display-Zustand: `volatile`-Render-Anforderungs-Flag — Regelschleife fordert an, + DisplayTask rendert (Wortzugriff ist auf dem ESP32 atomar). +- Taster-Eingaben: bleiben in der Regelschleife — die Taster-Callbacks mutieren + Loop-Singletons (`operationModeNode`, `poolPumpNode`); eine Abfrage auf Kern 0 + würde die Single-Writer-Regel verletzen. Ausgelagert ist nur das OLED-*Rendering* + (blockierende I2C-Arbeit). +- Telemetrie: SPSC-Ringpuffer mit fester Kapazität — Regelschleife stellt ein, PublishTask serialisiert und publiziert. Die MQTT-*Verbindung* und die Web-/OTA-Manager bleiben in der Regelschleife — sie diff --git a/docs/multicore-architecture.md b/docs/multicore-architecture.md index be9d0476..00984da4 100644 --- a/docs/multicore-architecture.md +++ b/docs/multicore-architecture.md @@ -27,8 +27,8 @@ separation**: | Core | Role | Contents | | ---- | ---- | -------- | -| **Core 0** (PRO_CPU) | I/O core | SensorTask (DS18B20 + internal temp), DisplayTask (OLED/buttons, NORVI only), PublishTask (MQTT telemetry serialization) | -| **Core 1** (APP_CPU) | Control core | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, async network managers, OTA | +| **Core 0** (PRO_CPU) | I/O core | SensorTask (DS18B20 + internal temp), DisplayTask (OLED rendering, NORVI only), PublishTask (MQTT telemetry serialization) | +| **Core 1** (APP_CPU) | Control core | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, async network managers, OTA, front-panel button scan | ## Why @@ -64,8 +64,8 @@ the control loop on Core 1. ```text SensorTask (Core 0) ── lock-free slots ──▶ control loop (Core 1): rules/relays/watchdog SensorTask ── status ────────────────────▶ DegradationManager (Core 1) -control loop ── snapshot ────────────────▶ DisplayTask (Core 0, NORVI) -DisplayTask ── button queue ─────────────▶ control loop +control loop ── update() + render request ─▶ DisplayTask (Core 0, NORVI) +button scan stays on the control loop (Core 1) — callbacks mutate loop singletons control loop ── telemetry queue ─────────▶ PublishTask (Core 0) ──▶ MQTT control loop ── async network/OTA ──────── (unchanged, Core 1) ``` @@ -74,9 +74,13 @@ Every cross-task data path is **single-writer**: - Sensor values: lock-free slots (atomic/single-word) — SensorTask writes, control loop reads. -- Display state: mutex-protected snapshot — control loop writes, DisplayTask reads. -- Button input: small queue / atomic flags — DisplayTask writes, control loop reads. -- Telemetry: fixed-capacity FreeRTOS queue — control loop enqueues, PublishTask +- Display state: `volatile` render-request flag — control loop requests, DisplayTask + renders (word-sized access is atomic on ESP32). +- Button input: stays on the control loop — button callbacks mutate control-loop + singletons (`operationModeNode`, `poolPumpNode`), so scanning on Core 0 would + violate the single-writer rule. The OLED *rendering* (blocking I2C work) is what + runs on Core 0. +- Telemetry: fixed-capacity SPSC ring buffer — control loop enqueues, PublishTask serializes and publishes. The MQTT *connection* and the web/OTA managers stay on the control loop — they are From ac4785bf325f1e01806695640a3d5b85f975f2e8 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 1 Aug 2026 21:19:47 +0200 Subject: [PATCH 11/28] docs(plan): mark all multicore implementation tasks complete --- .../2026-08-01-multicore-task-architecture.md | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md index 006de0a6..845249b9 100644 --- a/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md +++ b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md @@ -57,7 +57,7 @@ **Interfaces:** - Produces: `enum class PublishRequestKind : uint8_t { STATES = 0, DISCOVERY = 1 }` and `class TelemetryQueue` with `bool enqueue(PublishRequestKind kind)`, `bool dequeue(PublishRequestKind &kind)`, `size_t count() const`, `static constexpr size_t CAPACITY = 8`. Non-blocking, single-producer/single-consumer, drop-on-full (returns false). -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `test/native/tests/test_telemetry_queue.cpp`: @@ -130,12 +130,12 @@ void process() { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `cd test/native/build && cmake .. && make test_runner 2>&1 | tail -5 && ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:strict_init_order=1:detect_invalid_pointer_pairs=2" ./test_runner | tail -8` Expected: FAIL — `TelemetryQueue.hpp` not found / class not defined. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** Create `src/TelemetryQueue.hpp`: @@ -248,7 +248,7 @@ void TelemetryQueue::reset() { } // namespace PoolController ``` -- [ ] **Step 4: Wire into CMake and run tests** +- [x] **Step 4: Wire into CMake and run tests** Modify `test/native/CMakeLists.txt`: - Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/TelemetryQueue.cpp` @@ -257,7 +257,7 @@ Modify `test/native/CMakeLists.txt`: Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` Expected: PASS — queue tests green, all 74 previous suites still green. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/TelemetryQueue.hpp src/TelemetryQueue.cpp test/native/tests/test_telemetry_queue.cpp test/native/CMakeLists.txt @@ -277,7 +277,7 @@ git commit -m "feat: add SPSC telemetry queue for off-core MQTT publishing" - Consumes: nothing (standalone). - Produces: `enum class SensorId : uint8_t { SOLAR = 0, POOL = 1, CONTROLLER = 2, COUNT = 3 }`, and `class SensorSlots` with `static void reset()`, `static void write(SensorId id, float value, bool found)`, `static float read(SensorId id)`, `static bool isFound(SensorId id)`. Single writer (SensorTask), many readers. Values are `volatile` — a reader may see one-cycle-stale data, which is acceptable for temperature telemetry. -- [ ] **Step 1: Write the failing test** +- [x] **Step 1: Write the failing test** Create `test/native/tests/test_sensor_slots.cpp`: @@ -330,12 +330,12 @@ void process() { } ``` -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `cd test/native/build && cmake .. && make test_runner 2>&1 | tail -5 && ASAN_OPTIONS="..." ./test_runner | tail -8` Expected: FAIL — `SensorSlots.hpp` not found. -- [ ] **Step 3: Write minimal implementation** +- [x] **Step 3: Write minimal implementation** Create `src/SensorSlots.hpp`: @@ -435,7 +435,7 @@ bool SensorSlots::isFound(SensorId id) { return slots_[static_cast(id)] } // namespace PoolController ``` -- [ ] **Step 4: Wire into CMake and run tests** +- [x] **Step 4: Wire into CMake and run tests** Modify `test/native/CMakeLists.txt`: - Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/SensorSlots.cpp` @@ -444,7 +444,7 @@ Modify `test/native/CMakeLists.txt`: Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` Expected: PASS — slot tests green, previous suites still green. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/SensorSlots.hpp src/SensorSlots.cpp test/native/tests/test_sensor_slots.cpp test/native/CMakeLists.txt @@ -464,7 +464,7 @@ git commit -m "feat: add lock-free sensor slots for cross-task temperature shari - Consumes: `SensorSlots` (from Task 2), existing `SystemMonitor` / `DegradationManager` APIs. - Produces: `void beginMeasurement()` (bus master triggers `requestTemperatures()`; standalone nodes trigger their own) and `void finishMeasurement()` (reads result, updates slots via `SensorSlots::write(id, temp, found)`, reports to `DegradationManager`). `loop()` becomes a thin sync wrapper calling both in sequence (kept for tests/back-compat). `getTemperature()` / `isSensorFound()` now read from `SensorSlots`. -- [ ] **Step 1: Extend the header** +- [x] **Step 1: Extend the header** In `src/DallasTemperatureNode.hpp`, after the existing `void loop();` declaration, add: @@ -491,7 +491,7 @@ In `src/DallasTemperatureNode.hpp`, after the existing `void loop();` declaratio void finishMeasurement(); ``` -- [ ] **Step 2: Split the implementation** +- [x] **Step 2: Split the implementation** In `src/DallasTemperatureNode.cpp`, replace the body of `loop()` with: @@ -628,7 +628,7 @@ PoolController::SensorId DallasTemperatureNode::slotId() const { } ``` -- [ ] **Step 3: Update the native mock** +- [x] **Step 3: Update the native mock** In `test/native/mocks/DallasTemperatureNode.hpp`, add the two new method declarations with the same signatures (no-op or capture behavior is fine — the mock only needs to compile and satisfy callers): @@ -637,12 +637,12 @@ In `test/native/mocks/DallasTemperatureNode.hpp`, add the two new method declara void finishMeasurement() {} ``` -- [ ] **Step 4: Run native tests (regression)** +- [x] **Step 4: Run native tests (regression)** Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` Expected: PASS — all suites green (this task is a refactor; the sync `loop()` path preserves behavior). -- [ ] **Step 5: Build both device environments (compile check)** +- [x] **Step 5: Build both device environments (compile check)** Run: ```bash @@ -651,7 +651,7 @@ Run: ``` Expected: both `SUCCESS` (SensorSlots links into the firmware). -- [ ] **Step 6: Commit** +- [x] **Step 6: Commit** ```bash git add src/DallasTemperatureNode.hpp src/DallasTemperatureNode.cpp test/native/mocks/DallasTemperatureNode.hpp @@ -673,7 +673,7 @@ git commit -m "refactor: split DallasTemperatureNode measurement into begin/fini - Consumes: `SensorSlots` (Task 2), `DallasTemperatureNode` begin/finish (Task 3), `ESP32TemperatureNode::loop()`. - Produces: `CoreScheduler::begin()` (creates SensorTask on Core 0, priority 2, 6 KB stack; PublishTask priority 1, 4 KB — created in Task 5), `SensorTask::start()` internals, and `CoreScheduler::logStackWatermarks()` (called periodically). Native `mocks/CoreScheduler.hpp` captures registration so tests can assert parameters. -- [ ] **Step 1: Write the failing test (parameter assertions via mock)** +- [x] **Step 1: Write the failing test (parameter assertions via mock)** Create `test/native/mocks/CoreScheduler.hpp`: @@ -744,7 +744,7 @@ void process() { Add `test/native/tests/test_core_scheduler.cpp` to `TEST_SOURCES` in `CMakeLists.txt`. -- [ ] **Step 2: Run test to verify it fails** +- [x] **Step 2: Run test to verify it fails** Run: `cd test/native/build && cmake .. && make test_runner 2>&1 | tail -5 && ASAN_OPTIONS="..." ./test_runner | tail -8` Expected: FAIL — linker error: `CoreScheduler::begin()` undefined (mock .cpp not provided yet). Provide it now: @@ -772,12 +772,12 @@ void CoreScheduler::logStackWatermarks() {} Add `test/native/mocks/CoreScheduler.cpp` to `MOCK_SOURCES` in `CMakeLists.txt`. -- [ ] **Step 3: Run test to verify it passes** +- [x] **Step 3: Run test to verify it passes** Run: `cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 && ASAN_OPTIONS="..." ./test_runner | tail -6` Expected: PASS. -- [ ] **Step 4: Implement CoreScheduler (device side)** +- [x] **Step 4: Implement CoreScheduler (device side)** Create `src/CoreScheduler.hpp`: @@ -874,7 +874,7 @@ void CoreScheduler::logStackWatermarks() { } // namespace PoolController ``` -- [ ] **Step 5: Implement SensorTask (device side)** +- [x] **Step 5: Implement SensorTask (device side)** Create `src/SensorTask.hpp`: @@ -989,7 +989,7 @@ void SensorTask::logStackWatermark() { > **Design note:** `DallasTemperatureNode` instances stay file-scope globals in `PoolController.cpp`; `SensorTask.cpp` declares them `extern`. The address-filter and recovery logic in `begin()`/`finishMeasurement()` is unchanged — only the call site moved off-core. -- [ ] **Step 6: Wire into PoolController** +- [x] **Step 6: Wire into PoolController** In `src/PoolController.cpp`: @@ -1011,7 +1011,7 @@ In `src/PoolController.cpp`: ``` 5. Update the `loop()` doc comment to reflect that sensor reads moved off-core. -- [ ] **Step 7: Run native tests + build both environments** +- [x] **Step 7: Run native tests + build both environments** Run: ```bash @@ -1021,7 +1021,7 @@ cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 & ``` Expected: native tests PASS; both firmware builds SUCCESS. -- [ ] **Step 8: Commit** +- [x] **Step 8: Commit** ```bash git add src/CoreScheduler.hpp src/CoreScheduler.cpp src/SensorTask.hpp src/SensorTask.cpp src/PoolController.cpp test/native/mocks/CoreScheduler.hpp test/native/mocks/CoreScheduler.cpp test/native/tests/test_core_scheduler.cpp test/native/CMakeLists.txt @@ -1041,7 +1041,7 @@ git commit -m "feat: run DS18B20 sensor reads in a dedicated Core-0 task" - Consumes: `TelemetryQueue` (Task 1), `MqttPublisher` (unchanged static API: `publishStates()`, `publishDiscovery()`). - Produces: `PublishTask::start(priority, stackBytes, core)`, `PublishTask::logStackWatermark()`. Publish requests are enqueued by the control loop; PublishTask drains them and calls `MqttPublisher` on Core 0. -- [ ] **Step 1: Implement PublishTask (device side)** +- [x] **Step 1: Implement PublishTask (device side)** Create `src/PublishTask.hpp`: @@ -1147,7 +1147,7 @@ Add a singleton accessor to `src/TelemetryQueue.hpp` (so both the control loop a } ``` -- [ ] **Step 2: Start PublishTask from CoreScheduler** +- [x] **Step 2: Start PublishTask from CoreScheduler** In `src/CoreScheduler.cpp` `begin()`, after `SensorTask::start(...)`: @@ -1157,7 +1157,7 @@ In `src/CoreScheduler.cpp` `begin()`, after `SensorTask::start(...)`: Add include `#include "PublishTask.hpp"`. In `logStackWatermarks()` add `PublishTask::logStackWatermark();`. -- [ ] **Step 3: Enqueue instead of inline publish in PoolController** +- [x] **Step 3: Enqueue instead of inline publish in PoolController** In `src/PoolController.cpp` `loop()`, replace the MQTT publish block: @@ -1183,7 +1183,7 @@ In `src/PoolController.cpp` `loop()`, replace the MQTT publish block: Add includes `#include "TelemetryQueue.hpp"` and `#include "PublishTask.hpp"` to `src/PoolController.cpp`. -- [ ] **Step 4: Run native tests + build both environments** +- [x] **Step 4: Run native tests + build both environments** Run: ```bash @@ -1193,7 +1193,7 @@ cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 & ``` Expected: native tests PASS; both firmware builds SUCCESS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/PublishTask.hpp src/PublishTask.cpp src/CoreScheduler.cpp src/PoolController.cpp src/TelemetryQueue.hpp @@ -1214,7 +1214,7 @@ git commit -m "feat: offload MQTT telemetry serialization to a Core-0 publish ta - Consumes: `SensorSlots` (Task 2), existing `NorviOledDisplay` static API. - Produces: `DisplayTask::start(priority, stackBytes, core)`, `DisplayTask::logStackWatermark()`, `DisplayTask::requestRender()`. `NorviOledDisplay::update()` (state machine, Core 1) and `NorviOledDisplay::render()` (draw + I2C push, Core 0). Button handling stays on Core 1 (buttons are debounce-based, <1 ms, and their callbacks mutate control-loop singletons — single-writer rule). -- [ ] **Step 1: Split NorviOledDisplay::loop()** +- [x] **Step 1: Split NorviOledDisplay::loop()** In `src/NorviOledDisplay.hpp`, replace the `loop()` declaration with: @@ -1255,7 +1255,7 @@ Keep the existing auto-return / burn-in / page-navigation logic inside `update() Add `#include "SensorSlots.hpp"` to `src/NorviOledDisplay.cpp`. -- [ ] **Step 2: Implement DisplayTask** +- [x] **Step 2: Implement DisplayTask** Create `src/DisplayTask.hpp`: @@ -1348,7 +1348,7 @@ void DisplayTask::logStackWatermark() { } // namespace PoolController ``` -- [ ] **Step 3: Wire into PoolController** +- [x] **Step 3: Wire into PoolController** In `src/PoolController.cpp`, under the existing `#ifdef NORVI_AE01_R` block in `loop()`: @@ -1370,7 +1370,7 @@ Add `#include "DisplayTask.hpp"` (inside the `#ifdef NORVI_AE01_R` include block > **Design note (deviation from spec §3):** the button *scan* stays in the control loop because button callbacks mutate control-loop singletons (`operationModeNode`, `poolPumpNode`) — running them on Core 0 would violate the single-writer rule. Debounce-based scanning is <1 ms. The OLED *rendering* (the blocking I2C work) is what moves to Core 0. -- [ ] **Step 4: Run native tests + build both environments** +- [x] **Step 4: Run native tests + build both environments** Run: ```bash @@ -1380,7 +1380,7 @@ cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 & ``` Expected: native tests PASS; both firmware builds SUCCESS. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add src/DisplayTask.hpp src/DisplayTask.cpp src/PoolController.cpp src/NorviOledDisplay.hpp src/NorviOledDisplay.cpp @@ -1401,7 +1401,7 @@ git commit -m "feat: render NORVI OLED on a Core-0 display task" - Consumes: existing `DegradationManager` API. - Produces: `DegradationManager::reportSensorStatus(const char *id, bool found)` remains callable from SensorTask (Core 0) while `evaluate()` stays on Core 1; `SystemMonitor::feedWatchdogFromTask()`. -- [ ] **Step 1: Harden DegradationManager::reportSensorStatus** +- [x] **Step 1: Harden DegradationManager::reportSensorStatus** Read `src/DegradationManager.hpp` first. Then make the status update atomic (a single critical section around the flag write): @@ -1415,7 +1415,7 @@ Read `src/DegradationManager.hpp` first. Then make the status update atomic (a s If `reportSensorStatus` only touches a per-sensor bool, a `volatile bool` is sufficient; if it aggregates counts, use the critical section. Match whichever the existing implementation needs — do not change semantics. -- [ ] **Step 2: Add the watchdog wrapper** +- [x] **Step 2: Add the watchdog wrapper** In `src/SystemMonitor.hpp`, after `feedWatchdog()`: @@ -1430,7 +1430,7 @@ In `src/SystemMonitor.hpp`, after `feedWatchdog()`: In `src/SensorTask.cpp`, replace the direct `SystemMonitor::feedWatchdog()` calls inside `beginMeasurement()`/`finishMeasurement()` paths with `SystemMonitor::feedWatchdogFromTask()` — update `DallasTemperatureNode.cpp` accordingly (it already calls `SystemMonitor::feedWatchdog()`, which is fine; the wrapper is used by SensorTask itself around the conversion delay). -- [ ] **Step 3: Run native tests + build both environments** +- [x] **Step 3: Run native tests + build both environments** Run: ```bash @@ -1440,7 +1440,7 @@ cd test/native/build && cmake .. >/dev/null && make test_runner 2>&1 | tail -3 & ``` Expected: native tests PASS; both firmware builds SUCCESS. -- [ ] **Step 4: Commit** +- [x] **Step 4: Commit** ```bash git add src/DegradationManager.hpp src/DegradationManager.cpp src/SystemMonitor.hpp src/SensorTask.cpp src/DallasTemperatureNode.cpp @@ -1455,7 +1455,7 @@ git commit -m "feat: thread-safe sensor status reporting and task watchdog wrapp - Modify: `docs/multicore-architecture.md`, `docs/multicore-architecture.de.md` (verify they match the implemented split — button scan stays on Core 1) - Modify: `docs/software-guide.md` if it describes the loop (check first) -- [ ] **Step 1: Full native test run** +- [x] **Step 1: Full native test run** Run: ```bash @@ -1464,7 +1464,7 @@ ASAN_OPTIONS="detect_stack_use_after_return=1:check_initialization_order=1:stric ``` Expected: `Results: N suites passed, 0 suites failed` with N ≥ 74. -- [ ] **Step 2: Build both environments clean** +- [x] **Step 2: Build both environments clean** Run: ```bash @@ -1473,15 +1473,15 @@ Run: ``` Expected: both `SUCCESS` with no new warnings beyond baseline. -- [ ] **Step 3: Verify docs match implementation** +- [x] **Step 3: Verify docs match implementation** Read `docs/multicore-architecture.md` + `.de.md`. If they say "button scan runs on DisplayTask", update to "button scan stays on Core 1 (single-writer rule); only OLED rendering runs on Core 0". Also confirm the task table (Sensor 2/6KB, Publish 1/4KB, Display 1/3KB) matches `CoreScheduler.hpp`. -- [ ] **Step 4: Manual on-device checklist (documented for the PR)** +- [x] **Step 4: Manual on-device checklist (documented for the PR)** Add a short "Manual verification" section to the PR description (or the docs page): loop iteration <20 ms during sensor reads (log a `millis()` delta around `context.loop()` temporarily or use an existing timing log), OLED renders and buttons work, MQTT telemetry cadence unchanged, no watchdog resets over 24 h. -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add docs/multicore-architecture.md docs/multicore-architecture.de.md docs/software-guide.md 2>/dev/null || true From b6894da9411e0eb2c255dba84d41c9b525aaa2d6 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 9 Aug 2026 15:14:03 +0200 Subject: [PATCH 12/28] style: fix clang-format and markdownlint violations for MegaLinter --- docs/contactor-guide.de.md | 2 +- .../2026-08-01-multicore-task-architecture.md | 9 ++++++--- src/DisplayTask.cpp | 8 +++++--- src/PublishTask.cpp | 4 ++-- src/SensorSlots.cpp | 14 +++++++++----- src/SensorTask.cpp | 3 +-- src/TelemetryQueue.hpp | 10 +++++----- 7 files changed, 29 insertions(+), 21 deletions(-) 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/superpowers/plans/2026-08-01-multicore-task-architecture.md b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md index 845249b9..e918dde7 100644 --- a/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md +++ b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md @@ -4,7 +4,8 @@ **Goal:** Move blocking I/O (DS18B20 reads, OLED rendering, MQTT telemetry serialization) off the Arduino control loop onto dedicated FreeRTOS tasks pinned to Core 0, so loop iteration time drops from ~750 ms to <20 ms while all features keep their exact semantics. -**Architecture:** A small static `CoreScheduler` launcher creates three Core-0 tasks (SensorTask, PublishTask, DisplayTask). The control loop (Core 1) keeps rules/relays/watchdog/network untouched. Cross-task data moves through lock-free, single-writer channels: `SensorSlots` (temperature values), a SPSC `TelemetryQueue` (publish requests), and a display snapshot. No singleton is mutated from two tasks without an explicit primitive. +**Architecture:** A small static `CoreScheduler` launcher creates three Core-0 tasks (SensorTask, PublishTask, DisplayTask). The control loop (Core 1) keeps rules/relays/watchdog/network untouched. Cross-task data moves through lock-free, single-writer channels: `SensorSlots` (temperature values), a SPSC `TelemetryQueue` (publish requests), and a display snapshot. No singleton is mutated from two +tasks without an explicit primitive. **Tech Stack:** FreeRTOS (ESP-IDF 5.x via Arduino framework, espressif32 @ 7.0.1), C++17, `std::atomic` for lock-free primitives, existing native test harness (CMake + ASan + gcov, `test/native`). @@ -275,7 +276,8 @@ git commit -m "feat: add SPSC telemetry queue for off-core MQTT publishing" **Interfaces:** - Consumes: nothing (standalone). -- Produces: `enum class SensorId : uint8_t { SOLAR = 0, POOL = 1, CONTROLLER = 2, COUNT = 3 }`, and `class SensorSlots` with `static void reset()`, `static void write(SensorId id, float value, bool found)`, `static float read(SensorId id)`, `static bool isFound(SensorId id)`. Single writer (SensorTask), many readers. Values are `volatile` — a reader may see one-cycle-stale data, which is acceptable for temperature telemetry. +- Produces: `enum class SensorId : uint8_t { SOLAR = 0, POOL = 1, CONTROLLER = 2, COUNT = 3 }`, and `class SensorSlots` with `static void reset()`, `static void write(SensorId id, float value, bool found)`, `static float read(SensorId id)`, `static bool isFound(SensorId id)`. Single writer (SensorTask), many readers. Values are `volatile` — a reader may see one-cycle-stale data, which is + acceptable for temperature telemetry. - [x] **Step 1: Write the failing test** @@ -462,7 +464,8 @@ git commit -m "feat: add lock-free sensor slots for cross-task temperature shari **Interfaces:** - Consumes: `SensorSlots` (from Task 2), existing `SystemMonitor` / `DegradationManager` APIs. -- Produces: `void beginMeasurement()` (bus master triggers `requestTemperatures()`; standalone nodes trigger their own) and `void finishMeasurement()` (reads result, updates slots via `SensorSlots::write(id, temp, found)`, reports to `DegradationManager`). `loop()` becomes a thin sync wrapper calling both in sequence (kept for tests/back-compat). `getTemperature()` / `isSensorFound()` now read from `SensorSlots`. +- Produces: `void beginMeasurement()` (bus master triggers `requestTemperatures()`; standalone nodes trigger their own) and `void finishMeasurement()` (reads result, updates slots via `SensorSlots::write(id, temp, found)`, reports to `DegradationManager`). `loop()` becomes a thin sync wrapper calling both in sequence (kept for tests/back-compat). `getTemperature()` / `isSensorFound()` now read from + `SensorSlots`. - [x] **Step 1: Extend the header** diff --git a/src/DisplayTask.cpp b/src/DisplayTask.cpp index 86b16dff..cca2acc1 100644 --- a/src/DisplayTask.cpp +++ b/src/DisplayTask.cpp @@ -35,12 +35,14 @@ void DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) xTaskCreatePinnedToCore(displayTaskFunc, "display", stackBytes, nullptr, priority, &displayTaskHandle, core); } -void DisplayTask::requestRender() { renderRequested = true; } +void DisplayTask::requestRender() { + renderRequested = true; +} void DisplayTask::logStackWatermark() { if (displayTaskHandle != nullptr) { - Serial.printf(" DisplayTask stack high-water: %u B\n", - static_cast(uxTaskGetStackHighWaterMark(displayTaskHandle))); + Serial.printf( + " DisplayTask stack high-water: %u B\n", static_cast(uxTaskGetStackHighWaterMark(displayTaskHandle))); } } diff --git a/src/PublishTask.cpp b/src/PublishTask.cpp index 21297eff..e4e3fe8f 100644 --- a/src/PublishTask.cpp +++ b/src/PublishTask.cpp @@ -45,8 +45,8 @@ void PublishTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) void PublishTask::logStackWatermark() { if (publishTaskHandle != nullptr) { - Serial.printf(" PublishTask stack high-water: %u B\n", - static_cast(uxTaskGetStackHighWaterMark(publishTaskHandle))); + Serial.printf( + " PublishTask stack high-water: %u B\n", static_cast(uxTaskGetStackHighWaterMark(publishTaskHandle))); } } diff --git a/src/SensorSlots.cpp b/src/SensorSlots.cpp index 4f3ab8f6..bdbdb72b 100644 --- a/src/SensorSlots.cpp +++ b/src/SensorSlots.cpp @@ -13,9 +13,9 @@ namespace PoolController { SensorSlots::Slot SensorSlots::slots_[static_cast(SensorId::COUNT)] = { - {NAN, false}, - {NAN, false}, - {NAN, false}, + {NAN, false}, + {NAN, false}, + {NAN, false}, }; void SensorSlots::reset() { @@ -31,8 +31,12 @@ void SensorSlots::write(SensorId id, float value, bool found) { slot.found = found; } -float SensorSlots::read(SensorId id) { return slots_[static_cast(id)].value; } +float SensorSlots::read(SensorId id) { + return slots_[static_cast(id)].value; +} -bool SensorSlots::isFound(SensorId id) { return slots_[static_cast(id)].found; } +bool SensorSlots::isFound(SensorId id) { + return slots_[static_cast(id)].found; +} } // namespace PoolController diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp index 8bc8ce13..c3acf376 100644 --- a/src/SensorTask.cpp +++ b/src/SensorTask.cpp @@ -65,8 +65,7 @@ void SensorTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { void SensorTask::logStackWatermark() { if (sensorTaskHandle != nullptr) { - Serial.printf(" SensorTask stack high-water: %u B\n", - static_cast(uxTaskGetStackHighWaterMark(sensorTaskHandle))); + Serial.printf(" SensorTask stack high-water: %u B\n", static_cast(uxTaskGetStackHighWaterMark(sensorTaskHandle))); } } diff --git a/src/TelemetryQueue.hpp b/src/TelemetryQueue.hpp index 61145c3b..fb9de494 100644 --- a/src/TelemetryQueue.hpp +++ b/src/TelemetryQueue.hpp @@ -16,8 +16,8 @@ namespace PoolController { /** @brief Kinds of publish requests the control loop can enqueue. */ enum class PublishRequestKind : uint8_t { - STATES = 0, ///< Publish current telemetry states - DISCOVERY = 1, ///< Publish Home Assistant discovery configs + STATES = 0, ///< Publish current telemetry states + DISCOVERY = 1, ///< Publish Home Assistant discovery configs }; /** @@ -57,9 +57,9 @@ class TelemetryQueue { void reset(); private: - std::atomic head_{0}; ///< Consumer index (only consumer writes) - std::atomic tail_{0}; ///< Producer index (only producer writes) - PublishRequestKind items_[CAPACITY]; ///< Fixed ring storage + std::atomic head_{0}; ///< Consumer index (only consumer writes) + std::atomic tail_{0}; ///< Producer index (only producer writes) + PublishRequestKind items_[CAPACITY]; ///< Fixed ring storage }; } // namespace PoolController From d29086c851f322acfd11b11a9d586cc0f4024f3e Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Thu, 1 Oct 2026 23:59:01 +0200 Subject: [PATCH 13/28] style: fix clang-format violations after main merge --- src/DegradationManager.cpp | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/DegradationManager.cpp b/src/DegradationManager.cpp index 3ae1457a..531023ee 100644 --- a/src/DegradationManager.cpp +++ b/src/DegradationManager.cpp @@ -64,8 +64,13 @@ void DegradationManager::evaluate() { } } -DegradationLevel DegradationManager::getLevel() { return currentLevel_; } -bool DegradationManager::isSafe() { return currentLevel_ >= DegradationLevel::CRITICAL; } +DegradationLevel DegradationManager::getLevel() { + return currentLevel_; +} + +bool DegradationManager::isSafe() { + return currentLevel_ >= DegradationLevel::CRITICAL; +} void DegradationManager::forceSafeMode() { forcedSafeMode_ = true; From 4b080086b46b4d0e0cfe80dc306789c8f0bf309b Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Thu, 1 Oct 2026 23:59:13 +0200 Subject: [PATCH 14/28] style: format native test runner --- test/native/tests/test_main.cpp | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index 3bff4fa3..4fd72065 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -11,12 +11,28 @@ static int g_testsFailed = 0; static int g_assertionsPassed = 0; static int g_assertionsFailed = 0; -void test_begin(const char *suite, const char *name) { printf(" TEST %s :: %s\n", suite, name); } -void test_pass(const char *file, int line) { g_assertionsPassed++; printf(" ✓ %s:%d\n", file, line); } -void test_fail(const char *file, int line, const char *msg) { g_assertionsFailed++; printf(" ✗ %s:%d: %s\n", file, line, msg); } +void test_begin(const char *suite, const char *name) { + printf(" TEST %s :: %s\n", suite, name); +} + +void test_pass(const char *file, int line) { + g_assertionsPassed++; + printf(" ✓ %s:%d\n", file, line); +} + +void test_fail(const char *file, int line, const char *msg) { + g_assertionsFailed++; + printf(" ✗ %s:%d: %s\n", file, line, msg); +} + void test_suite_end(const char *name, int passed, int failed) { - if (failed == 0) { printf(" ✓ SUITE %s (%d passed)\n", name, passed); g_testsPassed++; } - else { printf(" ✗ SUITE %s (%d passed, %d failed)\n", name, passed, failed); g_testsFailed++; } + if (failed == 0) { + printf(" ✓ SUITE %s (%d passed)\n", name, passed); + g_testsPassed++; + } else { + printf(" ✗ SUITE %s (%d passed, %d failed)\n", name, passed, failed); + g_testsFailed++; + } } extern int run_rule_tests(); From 4d671a74295661929f4a98991b72f252601f2d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:07:46 +0000 Subject: [PATCH 15/28] fix(multicore): keep TelemetryQueue indices inside the ring storage Head and tail advance modulo CAPACITY + 1 (one free slot tells full from empty), but the storage only had CAPACITY slots, so index 8 was written and read after wrap-around. Size the storage CAPACITY + 1 and add a fill/dequeue/enqueue/drain wrap-around test (ASan flags the old code). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkKZgzzbP5Tq235ytaiKfi --- src/TelemetryQueue.cpp | 6 ++--- src/TelemetryQueue.hpp | 10 +++++--- test/native/tests/test_telemetry_queue.cpp | 30 ++++++++++++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/TelemetryQueue.cpp b/src/TelemetryQueue.cpp index 6c3a5dfb..9b78bef9 100644 --- a/src/TelemetryQueue.cpp +++ b/src/TelemetryQueue.cpp @@ -12,7 +12,7 @@ namespace PoolController { bool TelemetryQueue::enqueue(PublishRequestKind kind) { const size_t tail = tail_.load(std::memory_order_relaxed); - const size_t next = (tail + 1) % (CAPACITY + 1); + const size_t next = (tail + 1) % SLOTS; if (next == head_.load(std::memory_order_acquire)) { return false; // full } @@ -27,14 +27,14 @@ bool TelemetryQueue::dequeue(PublishRequestKind &kind) { return false; // empty } kind = items_[head]; - head_.store((head + 1) % (CAPACITY + 1), std::memory_order_release); + head_.store((head + 1) % SLOTS, std::memory_order_release); return true; } size_t TelemetryQueue::count() const { const size_t head = head_.load(std::memory_order_acquire); const size_t tail = tail_.load(std::memory_order_acquire); - return (tail + CAPACITY + 1 - head) % (CAPACITY + 1); + return (tail + SLOTS - head) % SLOTS; } void TelemetryQueue::reset() { diff --git a/src/TelemetryQueue.hpp b/src/TelemetryQueue.hpp index fb9de494..6f711b83 100644 --- a/src/TelemetryQueue.hpp +++ b/src/TelemetryQueue.hpp @@ -57,9 +57,13 @@ class TelemetryQueue { void reset(); private: - std::atomic head_{0}; ///< Consumer index (only consumer writes) - std::atomic tail_{0}; ///< Producer index (only producer writes) - PublishRequestKind items_[CAPACITY]; ///< Fixed ring storage + /// One slot stays free to tell "full" from "empty", so indices run over + /// [0, CAPACITY] and the storage needs CAPACITY + 1 slots. + static constexpr size_t SLOTS = CAPACITY + 1; + + std::atomic head_{0}; ///< Consumer index (only consumer writes) + std::atomic tail_{0}; ///< Producer index (only producer writes) + PublishRequestKind items_[SLOTS] = {}; ///< Fixed ring storage }; } // namespace PoolController diff --git a/test/native/tests/test_telemetry_queue.cpp b/test/native/tests/test_telemetry_queue.cpp index e0ff3143..609aa0e4 100644 --- a/test/native/tests/test_telemetry_queue.cpp +++ b/test/native/tests/test_telemetry_queue.cpp @@ -131,6 +131,36 @@ int run_telemetry_queue_tests() { passed++; } + // ── Test: wrap-around keeps indices inside the storage (review #170) ── + // fill -> dequeue -> enqueue -> drain, several rounds so head and tail pass + // the last slot. With ASan an out-of-bounds slot access fails this test. + { + test_begin("TelemetryQueue", "wrap-around stays in bounds and keeps FIFO order"); + + TelemetryQueue queue; + for (int round = 0; round < 3; round++) { + for (size_t i = 0; i < TelemetryQueue::CAPACITY; i++) { + ASSERT_TRUE(queue.enqueue(i % 2 == 0 ? PublishRequestKind::STATES : PublishRequestKind::DISCOVERY)); + } + PublishRequestKind kind; + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_TRUE(kind == PublishRequestKind::STATES); + ASSERT_TRUE(queue.enqueue(PublishRequestKind::DISCOVERY)); + ASSERT_EQ(queue.count(), TelemetryQueue::CAPACITY); + for (size_t i = 1; i < TelemetryQueue::CAPACITY; i++) { + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_TRUE(kind == (i % 2 == 0 ? PublishRequestKind::STATES : PublishRequestKind::DISCOVERY)); + } + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_TRUE(kind == PublishRequestKind::DISCOVERY); + ASSERT_FALSE(queue.dequeue(kind)); + ASSERT_EQ(queue.count(), 0u); + } + + test_suite_end("TelemetryQueue::wrap_around", 1, 0); + passed++; + } + (void)failed; return 0; } From 6c182b7ac6601e72d099b970aebf942a1f42d119 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:07:46 +0000 Subject: [PATCH 16/28] fix(multicore): start the pool conversion on dedicated sensor buses SensorTask only called beginMeasurement() on the solar node. On the shared NORVI bus that starts the conversion for both sensors, but on esp32dev each sensor has its own bus, so the pool sensor was read without a conversion. runDallasMeasurementCycle() now begins both measurements before the single wait; tests cover both topologies. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkKZgzzbP5Tq235ytaiKfi --- src/SensorCycle.hpp | 31 ++++++ src/SensorTask.cpp | 21 ++-- test/native/tests/test_sensor_cycle.cpp | 123 ++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 10 deletions(-) create mode 100644 src/SensorCycle.hpp create mode 100644 test/native/tests/test_sensor_cycle.cpp diff --git a/src/SensorCycle.hpp b/src/SensorCycle.hpp new file mode 100644 index 00000000..80da0563 --- /dev/null +++ b/src/SensorCycle.hpp @@ -0,0 +1,31 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorCycle.hpp + * @brief One DS18B20 measurement cycle for both bus topologies. + * + * - Shared bus (NORVI AE01-R): solar is the bus master and its + * beginMeasurement() starts one conversion for both sensors; the pool + * node's beginMeasurement() is a no-op. + * - Dedicated buses (esp32dev): each node starts the conversion on its own + * bus, so both beginMeasurement() calls are required. Both conversions run + * in parallel during the single wait. + * + * Templated so the scheduling contract is covered by native tests with fake + * nodes for both topologies. + */ + +#pragma once + +namespace PoolController { + +template void runDallasMeasurementCycle(Node &solar, Node &pool, WaitFn waitForConversion) { + solar.beginMeasurement(); + pool.beginMeasurement(); + waitForConversion(); + solar.finishMeasurement(); + pool.finishMeasurement(); +} + +} // namespace PoolController diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp index c3acf376..2b80574b 100644 --- a/src/SensorTask.cpp +++ b/src/SensorTask.cpp @@ -14,6 +14,7 @@ #include "DallasTemperatureNode.hpp" #include "ESP32TemperatureNode.hpp" +#include "SensorCycle.hpp" #include "SystemMonitor.hpp" namespace PoolController { @@ -34,18 +35,18 @@ void sensorTaskFunc(void *) { for (;;) { const uint32_t now = millis(); - // Solar (master on shared NORVI bus) drives the shared conversion. + // One cycle for both DS18B20 nodes on the solar interval. const unsigned long solarInterval = solarTemperatureNode.getMeasurementInterval(); if (now - lastSolarReadingMs >= solarInterval * 1000UL) { lastSolarReadingMs = now; Serial.println("〽 SensorTask: reading Dallas sensors"); - solarTemperatureNode.beginMeasurement(); - // Yield while the conversion runs — never block the control loop. - vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); - // Feed from the task context so long I/O waits can't starve the WDT. - SystemMonitor::feedWatchdogFromTask(); - solarTemperatureNode.finishMeasurement(); - poolTemperatureNode.finishMeasurement(); + // Starts the conversion on every bus (shared or dedicated topology) + runDallasMeasurementCycle(solarTemperatureNode, poolTemperatureNode, [] { + // Yield while the conversion runs — never block the control loop. + vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); + // Feed from the task context so long I/O waits can't starve the WDT. + SystemMonitor::feedWatchdogFromTask(); + }); } // ESP32 internal temperature on its own interval. @@ -59,8 +60,8 @@ void sensorTaskFunc(void *) { } } -void SensorTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { - xTaskCreatePinnedToCore(sensorTaskFunc, "sensor", stackBytes, nullptr, priority, &sensorTaskHandle, core); +bool SensorTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + return xTaskCreatePinnedToCore(sensorTaskFunc, "sensor", stackBytes, nullptr, priority, &sensorTaskHandle, core) == pdPASS; } void SensorTask::logStackWatermark() { diff --git a/test/native/tests/test_sensor_cycle.cpp b/test/native/tests/test_sensor_cycle.cpp new file mode 100644 index 00000000..77358cb5 --- /dev/null +++ b/test/native/tests/test_sensor_cycle.cpp @@ -0,0 +1,123 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file test_sensor_cycle.cpp + * @brief Tests for runDallasMeasurementCycle() — every bus must have a + * conversion started before its sensors are read, for the shared + * (NORVI) and the dedicated (esp32dev) topology (review #170). + */ + +#include + +#include "SensorCycle.hpp" + +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) ASSERT_TRUE((a) == (b)) + +namespace { + +// A OneWire bus: counts conversion requests and remembers whether a +// conversion was running when a sensor on it was read. +struct FakeBus { + int conversions = 0; + int readsWithConversion = 0; + int readsWithoutConversion = 0; +}; + +// Mirrors DallasTemperatureNode: on a shared bus only the master starts the +// conversion; on a dedicated bus every node starts its own. +struct FakeNode { + FakeBus *bus; + bool shared; + bool master; + void beginMeasurement() { + if (!shared || master) { + bus->conversions++; + } + } + void finishMeasurement() { + if (bus->conversions > 0) { + bus->readsWithConversion++; + } else { + bus->readsWithoutConversion++; + } + } +}; + +} // namespace + +static int test_dedicated_buses() { + test_begin("SensorCycle", "dedicated buses: each bus starts its own conversion"); + FakeBus solarBus; + FakeBus poolBus; + FakeNode solar{&solarBus, false, false}; + FakeNode pool{&poolBus, false, false}; + int waits = 0; + PoolController::runDallasMeasurementCycle(solar, pool, [&waits] { waits++; }); + ASSERT_EQ(waits, 1); + ASSERT_EQ(solarBus.conversions, 1); + ASSERT_EQ(poolBus.conversions, 1); + ASSERT_EQ(poolBus.readsWithoutConversion, 0); + ASSERT_EQ(poolBus.readsWithConversion, 1); + return 0; +} + +static int test_shared_bus() { + test_begin("SensorCycle", "shared bus: one conversion for both sensors"); + FakeBus bus; + FakeNode solar{&bus, true, true}; + FakeNode pool{&bus, true, false}; + int waits = 0; + PoolController::runDallasMeasurementCycle(solar, pool, [&waits] { waits++; }); + ASSERT_EQ(waits, 1); + ASSERT_EQ(bus.conversions, 1); + ASSERT_EQ(bus.readsWithConversion, 2); + ASSERT_EQ(bus.readsWithoutConversion, 0); + return 0; +} + +static int test_reads_happen_after_wait() { + test_begin("SensorCycle", "both conversions start before the single wait"); + FakeBus solarBus; + FakeBus poolBus; + FakeNode solar{&solarBus, false, false}; + FakeNode pool{&poolBus, false, false}; + bool startedBeforeWait = false; + bool readBeforeWait = false; + PoolController::runDallasMeasurementCycle(solar, pool, [&] { + startedBeforeWait = solarBus.conversions == 1 && poolBus.conversions == 1; + readBeforeWait = (solarBus.readsWithConversion + poolBus.readsWithConversion) > 0; + }); + ASSERT_TRUE(startedBeforeWait); + ASSERT_TRUE(!readBeforeWait); + return 0; +} + +int run_sensor_cycle_tests() { + int passed = 0; + int failed = 0; + int (*tests[])() = {test_dedicated_buses, test_shared_bus, test_reads_happen_after_wait}; + for (auto test : tests) { + if (test() == 0) { + passed++; + } else { + failed++; + } + } + test_suite_end("SensorCycle", passed, failed); + return failed; +} From 8ed3cbfa5d1dabb8c051ab708f942a50678e350b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:07:46 +0000 Subject: [PATCH 17/28] fix(multicore): replace volatile cross-core state with real synchronization SensorSlots copies value and found flag together under a portMUX critical section (std::mutex natively) and offers snapshot(), so readers never see a mixed pair; the OLED uses snapshots. Sensor status flags in DegradationManager and the display redraw/render flags are std::atomic, and render() resets the redraw flag with exchange() so a request cannot be lost. Adds a concurrent writer/reader test for SensorSlots. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkKZgzzbP5Tq235ytaiKfi --- docs/multicore-architecture.de.md | 16 ++++++-- docs/multicore-architecture.md | 17 +++++--- src/DegradationManager.cpp | 6 +-- src/DegradationManager.hpp | 13 +++--- src/DisplayTask.cpp | 11 ++--- src/NorviOledDisplay.cpp | 20 ++++++---- src/NorviOledDisplay.hpp | 8 ++-- src/SensorSlots.cpp | 53 +++++++++++++++++++------ src/SensorSlots.hpp | 28 ++++++++----- test/native/tests/test_sensor_slots.cpp | 34 ++++++++++++++++ 10 files changed, 147 insertions(+), 59 deletions(-) diff --git a/docs/multicore-architecture.de.md b/docs/multicore-architecture.de.md index 768e943d..33e7c8d8 100644 --- a/docs/multicore-architecture.de.md +++ b/docs/multicore-architecture.de.md @@ -55,6 +55,12 @@ Alle I/O-Tasks werden in `setup()` vom `CoreScheduler` erzeugt und bleiben stati | PublishTask | 0 | 1 | 4 KB | allen Builds | | DisplayTask | 0 | 1 | 3 KB | nur NORVI (`#ifdef NORVI_AE01_R`) | +Kann ein Task nicht erzeugt werden (Heap beim Start erschöpft), läuft der +`CoreScheduler` nicht stillschweigend weiter: Fehlt SensorTask oder PublishTask, startet +der Controller neu — die Regeln dürfen nicht mit veralteten Temperaturen arbeiten. +Bleibt der Fehler bestehen, erzwingt die Boot-Loop-Erkennung den Safe-Mode (alle Relais +aus). Ein fehlender DisplayTask wird protokolliert, der Controller läuft ohne OLED-Ausgabe weiter. + FreeRTOS-Prioritäten gelten nur innerhalb eines Kerns: Die I/O-Tasks geben per `vTaskDelay` nach und bleiben unterhalb der WiFi-Stack-Tasks auf Kern 0 — sie können die Regelschleife auf Kern 1 also nie verdrängen. @@ -62,7 +68,7 @@ die Regelschleife auf Kern 1 also nie verdrängen. ## Datenfluss ```text -SensorTask (Kern 0) ── lock-free Slots ──▶ Regelschleife (Kern 1): Regeln/Relais/Watchdog +SensorTask (Kern 0) ── Sensor-Slots ────▶ Regelschleife (Kern 1): Regeln/Relais/Watchdog SensorTask ── Status ────────────────────▶ DegradationManager (Kern 1) Regelschleife ── update() + Render-Anforderung ─▶ DisplayTask (Kern 0, NORVI) Tasterabfrage bleibt in der Regelschleife (Kern 1) — Callbacks mutieren Loop-Singletons @@ -72,9 +78,11 @@ Regelschleife ── asynchrones Netzwerk/OTA ── (unverändert, Kern 1) Jeder task-übergreifende Datenpfad ist **Single-Writer**: -- Sensorwerte: lock-free Slots (atomar/ein Wort) — SensorTask schreibt, Regelschleife liest. -- Display-Zustand: `volatile`-Render-Anforderungs-Flag — Regelschleife fordert an, - DisplayTask rendert (Wortzugriff ist auf dem ESP32 atomar). +- Sensorwerte: Sensor-Slots — SensorTask schreibt, Regelschleife und Display lesen. + Wert und Gefunden-Flag werden zusammen in einem kurzen kritischen Abschnitt (portMUX) + kopiert, Leser erhalten immer einen konsistenten Stand einer Messung. +- Sensorstatus und Display-Flags: `std::atomic` — `volatile` sorgt weder für + Sichtbarkeit zwischen den Kernen noch für Reihenfolge. - Taster-Eingaben: bleiben in der Regelschleife — die Taster-Callbacks mutieren Loop-Singletons (`operationModeNode`, `poolPumpNode`); eine Abfrage auf Kern 0 würde die Single-Writer-Regel verletzen. Ausgelagert ist nur das OLED-*Rendering* diff --git a/docs/multicore-architecture.md b/docs/multicore-architecture.md index 00984da4..b32145a4 100644 --- a/docs/multicore-architecture.md +++ b/docs/multicore-architecture.md @@ -55,6 +55,12 @@ dynamic task creation at runtime, no heap growth). | PublishTask | 0 | 1 | 4 KB | all builds | | DisplayTask | 0 | 1 | 3 KB | NORVI only (`#ifdef NORVI_AE01_R`) | +If a task cannot be created (heap exhausted at boot), `CoreScheduler` does not +continue silently: a missing SensorTask or PublishTask restarts the controller — +the rules must not run on stale temperatures. If the failure persists, the +boot-loop detection forces safe mode (all relays off). A missing DisplayTask is +logged and the controller continues without OLED rendering. + FreeRTOS priorities only matter within a core: the I/O tasks yield via `vTaskDelay` and stay below the WiFi-stack tasks on Core 0, so they never preempt the control loop on Core 1. @@ -62,7 +68,7 @@ the control loop on Core 1. ## Data flow ```text -SensorTask (Core 0) ── lock-free slots ──▶ control loop (Core 1): rules/relays/watchdog +SensorTask (Core 0) ── sensor slots ────▶ control loop (Core 1): rules/relays/watchdog SensorTask ── status ────────────────────▶ DegradationManager (Core 1) control loop ── update() + render request ─▶ DisplayTask (Core 0, NORVI) button scan stays on the control loop (Core 1) — callbacks mutate loop singletons @@ -72,10 +78,11 @@ control loop ── async network/OTA ──────── (unchanged, Core Every cross-task data path is **single-writer**: -- Sensor values: lock-free slots (atomic/single-word) — SensorTask writes, control - loop reads. -- Display state: `volatile` render-request flag — control loop requests, DisplayTask - renders (word-sized access is atomic on ESP32). +- Sensor values: sensor slots — SensorTask writes, control loop and display read. + Value and found flag are copied together under a short critical section (portMUX), + so readers always get a consistent snapshot of one measurement. +- Sensor status and display flags: `std::atomic` — `volatile` gives neither + inter-core visibility nor ordering. - Button input: stays on the control loop — button callbacks mutate control-loop singletons (`operationModeNode`, `poolPumpNode`), so scanning on Core 0 would violate the single-writer rule. The OLED *rendering* (blocking I2C work) is what diff --git a/src/DegradationManager.cpp b/src/DegradationManager.cpp index 531023ee..00a1e24e 100644 --- a/src/DegradationManager.cpp +++ b/src/DegradationManager.cpp @@ -19,10 +19,10 @@ namespace PoolController { DegradationLevel DegradationManager::currentLevel_ = DegradationLevel::NORMAL; DegradationLevel DegradationManager::previousLevel_ = DegradationLevel::NORMAL; -volatile bool DegradationManager::poolSensorOk_ = false; -volatile bool DegradationManager::solarSensorOk_ = false; +std::atomic DegradationManager::poolSensorOk_{false}; +std::atomic DegradationManager::solarSensorOk_{false}; bool DegradationManager::forcedSafeMode_ = false; -volatile bool DegradationManager::sensorsEverReported_ = false; +std::atomic DegradationManager::sensorsEverReported_{false}; unsigned long DegradationManager::lastEvaluationMs_ = 0; void DegradationManager::begin() { diff --git a/src/DegradationManager.hpp b/src/DegradationManager.hpp index c47dbd4a..c498f358 100644 --- a/src/DegradationManager.hpp +++ b/src/DegradationManager.hpp @@ -11,6 +11,8 @@ #include +#include + namespace PoolController { enum class DegradationLevel : uint8_t { @@ -33,14 +35,13 @@ class DegradationManager { static void unforceSafeMode(); private: - // Cross-core status: SensorTask writes on Core 0, control loop reads on Core 1. - // Kept volatile here to preserve PR #170 semantics during the main merge; - // the review finding to replace this with proper synchronization remains open. - static volatile bool sensorsEverReported_; + // Cross-core status: SensorTask writes on Core 0, control loop reads on + // Core 1. std::atomic gives the inter-core visibility volatile does not. + static std::atomic sensorsEverReported_; static DegradationLevel currentLevel_; static DegradationLevel previousLevel_; - static volatile bool poolSensorOk_; - static volatile bool solarSensorOk_; + static std::atomic poolSensorOk_; + static std::atomic solarSensorOk_; static bool forcedSafeMode_; static unsigned long lastEvaluationMs_; diff --git a/src/DisplayTask.cpp b/src/DisplayTask.cpp index cca2acc1..3982f314 100644 --- a/src/DisplayTask.cpp +++ b/src/DisplayTask.cpp @@ -12,27 +12,28 @@ #include #include +#include + #include "NorviOledDisplay.hpp" namespace PoolController { namespace { TaskHandle_t displayTaskHandle = nullptr; -volatile bool renderRequested = false; +std::atomic renderRequested{false}; } // namespace void displayTaskFunc(void *) { for (;;) { - if (renderRequested || (millis() % 2000 < 50)) { - renderRequested = false; + if (renderRequested.exchange(false) || (millis() % 2000 < 50)) { NorviOledDisplay::render(); } vTaskDelay(pdMS_TO_TICKS(100)); } } -void DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { - xTaskCreatePinnedToCore(displayTaskFunc, "display", stackBytes, nullptr, priority, &displayTaskHandle, core); +bool DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + return xTaskCreatePinnedToCore(displayTaskFunc, "display", stackBytes, nullptr, priority, &displayTaskHandle, core) == pdPASS; } void DisplayTask::requestRender() { diff --git a/src/NorviOledDisplay.cpp b/src/NorviOledDisplay.cpp index 675740ef..3bdf9def 100644 --- a/src/NorviOledDisplay.cpp +++ b/src/NorviOledDisplay.cpp @@ -46,8 +46,8 @@ static void drawProgressBar(); // ═══════════════════════════════════════════════════════════════════════════ NorviOledDisplay::Page NorviOledDisplay::currentPage_ = Page::MAIN; -volatile uint32_t NorviOledDisplay::lastUpdateMs_ = 0; -volatile bool NorviOledDisplay::forceRedraw_ = true; +std::atomic NorviOledDisplay::lastUpdateMs_{0}; +std::atomic NorviOledDisplay::forceRedraw_{true}; uint32_t NorviOledDisplay::lastButtonPressMs_ = 0; @@ -343,7 +343,10 @@ void NorviOledDisplay::update() { void NorviOledDisplay::render() { // ── Throttle redraw rate ──────────────────────────────────────────── - if (!forceRedraw_ && (millis() - lastUpdateMs_ < UPDATE_INTERVAL_MS)) { + // exchange() so a redraw requested by the control loop between check and + // reset is not lost + const bool forced = forceRedraw_.exchange(false); + if (!forced && (millis() - lastUpdateMs_ < UPDATE_INTERVAL_MS)) { return; } @@ -351,7 +354,6 @@ void NorviOledDisplay::render() { updateBurnInOffset(); lastUpdateMs_ = millis(); - forceRedraw_ = false; drawPage(); drawProgressBar(); @@ -722,9 +724,10 @@ void NorviOledDisplay::drawMainPage() { // ── Pool temperature ──────────────────────────────────────────────────── display.setTextSize(2); dspCursor(TX, 0); - if (SensorSlots::isFound(SensorId::POOL)) { + const SensorSlots::Reading poolReading = SensorSlots::snapshot(SensorId::POOL); + if (poolReading.found) { char buf[8]; - Utils::floatToString(SensorSlots::read(SensorId::POOL), buf, sizeof(buf), 1); + Utils::floatToString(poolReading.value, buf, sizeof(buf), 1); display.print(buf); drawDegC(2); } else { @@ -742,9 +745,10 @@ void NorviOledDisplay::drawMainPage() { // ── Solar temperature ────────────────────────────────────────────────── display.setTextSize(2); dspCursor(TX, 28); - if (SensorSlots::isFound(SensorId::SOLAR)) { + const SensorSlots::Reading solarReading = SensorSlots::snapshot(SensorId::SOLAR); + if (solarReading.found) { char buf[8]; - Utils::floatToString(SensorSlots::read(SensorId::SOLAR), buf, sizeof(buf), 1); + Utils::floatToString(solarReading.value, buf, sizeof(buf), 1); display.print(buf); drawDegC(2); } else { diff --git a/src/NorviOledDisplay.hpp b/src/NorviOledDisplay.hpp index 9a027fe5..ef7b6319 100644 --- a/src/NorviOledDisplay.hpp +++ b/src/NorviOledDisplay.hpp @@ -27,6 +27,7 @@ #pragma once +#include #include #include @@ -225,11 +226,10 @@ class NorviOledDisplay { static Page currentPage_; // Written by the control loop (Core 1) and read/reset by DisplayTask - // (Core 0): word-sized access is atomic on ESP32, so a one-cycle-stale - // redraw decision is acceptable. - static volatile uint32_t lastUpdateMs_; + // (Core 0): std::atomic for inter-core visibility and ordering. + static std::atomic lastUpdateMs_; static constexpr uint32_t UPDATE_INTERVAL_MS{2000}; - static volatile bool forceRedraw_; + static std::atomic forceRedraw_; // ── Idle auto-return ───────────────────────────────────────────────── static uint32_t lastButtonPressMs_; diff --git a/src/SensorSlots.cpp b/src/SensorSlots.cpp index bdbdb72b..5c36968c 100644 --- a/src/SensorSlots.cpp +++ b/src/SensorSlots.cpp @@ -3,40 +3,67 @@ /** * @file SensorSlots.cpp - * @brief Lock-free temperature slot implementation. + * @brief Cross-task temperature slots with consistent snapshots. */ #include "SensorSlots.hpp" #include +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +#include +#include +#else +#include +#endif + namespace PoolController { -SensorSlots::Slot SensorSlots::slots_[static_cast(SensorId::COUNT)] = { +namespace { +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +portMUX_TYPE slotsMux = portMUX_INITIALIZER_UNLOCKED; +inline void lockSlots() { + portENTER_CRITICAL(&slotsMux); +} +inline void unlockSlots() { + portEXIT_CRITICAL(&slotsMux); +} +#else +std::mutex slotsMutex; +inline void lockSlots() { + slotsMutex.lock(); +} +inline void unlockSlots() { + slotsMutex.unlock(); +} +#endif +} // namespace + +SensorSlots::Reading SensorSlots::slots_[static_cast(SensorId::COUNT)] = { {NAN, false}, {NAN, false}, {NAN, false}, }; void SensorSlots::reset() { + lockSlots(); for (auto &slot : slots_) { - slot.value = NAN; - slot.found = false; + slot = {NAN, false}; } + unlockSlots(); } void SensorSlots::write(SensorId id, float value, bool found) { - Slot &slot = slots_[static_cast(id)]; - slot.value = value; - slot.found = found; -} - -float SensorSlots::read(SensorId id) { - return slots_[static_cast(id)].value; + lockSlots(); + slots_[static_cast(id)] = {value, found}; + unlockSlots(); } -bool SensorSlots::isFound(SensorId id) { - return slots_[static_cast(id)].found; +SensorSlots::Reading SensorSlots::snapshot(SensorId id) { + lockSlots(); + Reading reading = slots_[static_cast(id)]; + unlockSlots(); + return reading; } } // namespace PoolController diff --git a/src/SensorSlots.hpp b/src/SensorSlots.hpp index 4ab232fb..4ed666b6 100644 --- a/src/SensorSlots.hpp +++ b/src/SensorSlots.hpp @@ -21,33 +21,39 @@ enum class SensorId : uint8_t { }; /** - * @brief Fixed, lock-free slots for sensor values. + * @brief Fixed slots for sensor values shared across tasks. * * Single writer (SensorTask on Core 0), multiple readers (control loop, - * display). Uses `volatile` word-sized fields: on ESP32 aligned 32-bit - * reads/writes are atomic, so readers may see one-cycle-stale but never - * torn values — acceptable for temperature telemetry. + * display). Value and found flag are written and read together under a + * short critical section (portMUX on ESP32, std::mutex in native tests), so + * readers always see a consistent snapshot of one measurement. `volatile` + * would give neither inter-core ordering nor a consistent pair. */ class SensorSlots { public: + /** @brief A consistent value/found pair of one measurement. */ + struct Reading { + float value; ///< °C, NAN if unknown + bool found; ///< Sensor present and reading valid + }; + /** @brief Reset all slots to NaN / not-found (tests only). */ static void reset(); /** @brief Writer: publish a new value. */ static void write(SensorId id, float value, bool found); + /** @brief Reader: value and found flag of the same measurement. */ + static Reading snapshot(SensorId id); + /** @brief Reader: get the latest value (°C, NAN if unknown). */ - static float read(SensorId id); + static float read(SensorId id) { return snapshot(id).value; } /** @brief Reader: check whether the sensor is currently found. */ - static bool isFound(SensorId id); + static bool isFound(SensorId id) { return snapshot(id).found; } private: - struct Slot { - volatile float value; - volatile bool found; - }; - static Slot slots_[static_cast(SensorId::COUNT)]; + static Reading slots_[static_cast(SensorId::COUNT)]; }; } // namespace PoolController diff --git a/test/native/tests/test_sensor_slots.cpp b/test/native/tests/test_sensor_slots.cpp index 21b2c7ef..8fcf5343 100644 --- a/test/native/tests/test_sensor_slots.cpp +++ b/test/native/tests/test_sensor_slots.cpp @@ -5,7 +5,9 @@ #include #include +#include #include +#include #include "SensorSlots.hpp" @@ -109,6 +111,38 @@ int run_sensor_slots_tests() { passed++; } + // ── Test: concurrent reader never sees a mixed value/found pair (review #170) ── + { + test_begin("SensorSlots", "snapshot is consistent under concurrent writes"); + + SensorSlots::reset(); + std::atomic stop{false}; + std::thread writer([&stop] { + bool valid = true; + while (!stop.load()) { + if (valid) { + SensorSlots::write(SensorId::POOL, 25.0f, true); + } else { + SensorSlots::write(SensorId::POOL, NAN, false); + } + valid = !valid; + } + }); + int mixed = 0; + for (int i = 0; i < 200000; i++) { + SensorSlots::Reading r = SensorSlots::snapshot(SensorId::POOL); + if (r.found == std::isnan(r.value)) { + mixed++; + } + } + stop = true; + writer.join(); + ASSERT_EQ(mixed, 0); + + test_suite_end("SensorSlots::snapshot_consistent", 1, 0); + passed++; + } + (void)failed; return 0; } From ab06fc46522d5cc65df41cdfa60f67a3b07d92d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:07:46 +0000 Subject: [PATCH 18/28] fix(multicore): handle Core-0 task creation failures Task start functions return whether xTaskCreatePinnedToCore() succeeded. decideTaskStartupAction() (TaskStartupPolicy.hpp, tested) defines the failure path: a missing SensorTask or PublishTask restarts the controller, a persistent failure ends in boot-loop safe mode; a missing DisplayTask is logged and the controller continues without OLED. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkKZgzzbP5Tq235ytaiKfi --- src/CoreScheduler.cpp | 28 ++++++- src/DisplayTask.hpp | 4 +- src/PublishTask.cpp | 4 +- src/PublishTask.hpp | 4 +- src/SensorTask.hpp | 4 +- src/TaskStartupPolicy.hpp | 42 +++++++++++ test/native/CMakeLists.txt | 2 + test/native/tests/test_main.cpp | 4 + .../native/tests/test_task_startup_policy.cpp | 73 +++++++++++++++++++ 9 files changed, 153 insertions(+), 12 deletions(-) create mode 100644 src/TaskStartupPolicy.hpp create mode 100644 test/native/tests/test_task_startup_policy.cpp diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp index bd6cfd18..3555b6fe 100644 --- a/src/CoreScheduler.cpp +++ b/src/CoreScheduler.cpp @@ -12,8 +12,10 @@ #include #include -#include "SensorTask.hpp" +#include "LogCapture.hpp" #include "PublishTask.hpp" +#include "SensorTask.hpp" +#include "TaskStartupPolicy.hpp" #ifdef NORVI_AE01_R #include "DisplayTask.hpp" #endif @@ -24,11 +26,29 @@ void CoreScheduler::begin() { // Core 0 = PRO_CPU_NUM (I/O core); Core 1 = APP_CPU_NUM (control loop). const BaseType_t core0 = PRO_CPU_NUM; - SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); - PublishTask::start(TASK_PRIORITY_PUBLISH, TASK_STACK_PUBLISH, core0); + const bool sensorStarted = SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); + const bool publishStarted = PublishTask::start(TASK_PRIORITY_PUBLISH, TASK_STACK_PUBLISH, core0); #ifdef NORVI_AE01_R - DisplayTask::start(TASK_PRIORITY_DISPLAY, TASK_STACK_DISPLAY, core0); + const bool displayStarted = DisplayTask::start(TASK_PRIORITY_DISPLAY, TASK_STACK_DISPLAY, core0); +#else + const bool displayStarted = true; // no display task on this board #endif + + switch (decideTaskStartupAction(sensorStarted, publishStarted, displayStarted)) { + case TaskStartupAction::CONTINUE: + break; + case TaskStartupAction::CONTINUE_DEGRADED: + LOG_ERROR("✖ DisplayTask could not be created (free heap %u B) — continuing without OLED rendering\n", + static_cast(ESP.getFreeHeap())); + break; + case TaskStartupAction::RESTART: + LOG_ERROR("✖ Critical task could not be created (sensor: %s, publish: %s, free heap %u B) — restarting\n", + sensorStarted ? "ok" : "FAILED", publishStarted ? "ok" : "FAILED", static_cast(ESP.getFreeHeap())); + Serial.flush(); + // A persistent failure is caught by the boot-loop detection (safe mode). + ESP.restart(); + break; + } } void CoreScheduler::logStackWatermarks() { diff --git a/src/DisplayTask.hpp b/src/DisplayTask.hpp index ba4fca3d..4a765b7d 100644 --- a/src/DisplayTask.hpp +++ b/src/DisplayTask.hpp @@ -24,8 +24,8 @@ namespace PoolController { */ class DisplayTask { public: - /** @brief Create and start the task pinned to the given core. */ - static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + /** @brief Create and start the task pinned to the given core. @return false if it could not be created. */ + static bool start(uint8_t priority, uint16_t stackBytes, BaseType_t core); /** @brief Request a render on the next task tick. */ static void requestRender(); diff --git a/src/PublishTask.cpp b/src/PublishTask.cpp index e4e3fe8f..98989e06 100644 --- a/src/PublishTask.cpp +++ b/src/PublishTask.cpp @@ -39,8 +39,8 @@ void publishTaskFunc(void *) { } } -void PublishTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { - xTaskCreatePinnedToCore(publishTaskFunc, "publish", stackBytes, nullptr, priority, &publishTaskHandle, core); +bool PublishTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + return xTaskCreatePinnedToCore(publishTaskFunc, "publish", stackBytes, nullptr, priority, &publishTaskHandle, core) == pdPASS; } void PublishTask::logStackWatermark() { diff --git a/src/PublishTask.hpp b/src/PublishTask.hpp index 560ed028..1ec37549 100644 --- a/src/PublishTask.hpp +++ b/src/PublishTask.hpp @@ -24,8 +24,8 @@ namespace PoolController { */ class PublishTask { public: - /** @brief Create and start the task pinned to the given core. */ - static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + /** @brief Create and start the task pinned to the given core. @return false if it could not be created. */ + static bool start(uint8_t priority, uint16_t stackBytes, BaseType_t core); /** @brief Log the task's stack high-water mark. */ static void logStackWatermark(); diff --git a/src/SensorTask.hpp b/src/SensorTask.hpp index 66a847c3..4184a0d3 100644 --- a/src/SensorTask.hpp +++ b/src/SensorTask.hpp @@ -24,8 +24,8 @@ namespace PoolController { */ class SensorTask { public: - /** @brief Create and start the task pinned to the given core. */ - static void start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + /** @brief Create and start the task pinned to the given core. @return false if it could not be created. */ + static bool start(uint8_t priority, uint16_t stackBytes, BaseType_t core); /** @brief Log the task's stack high-water mark. */ static void logStackWatermark(); diff --git a/src/TaskStartupPolicy.hpp b/src/TaskStartupPolicy.hpp new file mode 100644 index 00000000..70c4e9b0 --- /dev/null +++ b/src/TaskStartupPolicy.hpp @@ -0,0 +1,42 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file TaskStartupPolicy.hpp + * @brief What to do when a Core-0 task could not be created. + * + * xTaskCreatePinnedToCore() only fails when the heap cannot hold the task + * stack. Sensor reads and MQTT publishing no longer run in the control loop, + * so without SensorTask the rules would work on stale temperatures and + * without PublishTask Home Assistant would get no telemetry. These are + * critical: the controller restarts. A persistent failure ends in the + * boot-loop detection (3 consecutive boots), which forces safe mode with all + * relays off. The display task is not needed for control — the controller + * continues without OLED rendering. + * + * Header-only and hardware-independent for native tests. + */ + +#pragma once + +#include + +namespace PoolController { + +enum class TaskStartupAction : uint8_t { + CONTINUE, ///< All tasks running + CONTINUE_DEGRADED, ///< Non-critical task missing (display) + RESTART, ///< Critical task missing (sensor or publish) +}; + +inline TaskStartupAction decideTaskStartupAction(bool sensorStarted, bool publishStarted, bool displayStarted) { + if (!sensorStarted || !publishStarted) { + return TaskStartupAction::RESTART; + } + if (!displayStarted) { + return TaskStartupAction::CONTINUE_DEGRADED; + } + return TaskStartupAction::CONTINUE; +} + +} // namespace PoolController diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index 922369dc..f537b125 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -89,6 +89,8 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_telemetry_queue.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_cycle.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_task_startup_policy.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_core_scheduler.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_degradation_manager.cpp ) diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index 4fd72065..82d34e53 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -49,6 +49,8 @@ extern int run_ky040_decoder_tests(); extern int run_calibration_manager_tests(); extern int run_telemetry_queue_tests(); extern int run_sensor_slots_tests(); +extern int run_sensor_cycle_tests(); +extern int run_task_startup_policy_tests(); extern int run_core_scheduler_tests(); extern int run_degradation_manager_tests(); @@ -72,6 +74,8 @@ int main() { total += run_calibration_manager_tests(); total += run_telemetry_queue_tests(); total += run_sensor_slots_tests(); + total += run_sensor_cycle_tests(); + total += run_task_startup_policy_tests(); total += run_core_scheduler_tests(); total += run_degradation_manager_tests(); (void)total; diff --git a/test/native/tests/test_task_startup_policy.cpp b/test/native/tests/test_task_startup_policy.cpp new file mode 100644 index 00000000..07333064 --- /dev/null +++ b/test/native/tests/test_task_startup_policy.cpp @@ -0,0 +1,73 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file test_task_startup_policy.cpp + * @brief Tests for decideTaskStartupAction() — a Core-0 task that cannot be + * created must lead to a defined action, never to silent operation + * without fresh sensor data (review #170). + */ + +#include + +#include "TaskStartupPolicy.hpp" + +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) ASSERT_TRUE((a) == (b)) + +using PoolController::decideTaskStartupAction; +using PoolController::TaskStartupAction; + +static int test_all_started() { + test_begin("TaskStartupPolicy", "all tasks started → continue"); + ASSERT_EQ(decideTaskStartupAction(true, true, true), TaskStartupAction::CONTINUE); + return 0; +} + +static int test_sensor_task_missing() { + test_begin("TaskStartupPolicy", "missing SensorTask → restart"); + ASSERT_EQ(decideTaskStartupAction(false, true, true), TaskStartupAction::RESTART); + ASSERT_EQ(decideTaskStartupAction(false, true, false), TaskStartupAction::RESTART); + return 0; +} + +static int test_publish_task_missing() { + test_begin("TaskStartupPolicy", "missing PublishTask → restart"); + ASSERT_EQ(decideTaskStartupAction(true, false, true), TaskStartupAction::RESTART); + ASSERT_EQ(decideTaskStartupAction(false, false, false), TaskStartupAction::RESTART); + return 0; +} + +static int test_display_task_missing() { + test_begin("TaskStartupPolicy", "missing DisplayTask → continue degraded"); + ASSERT_EQ(decideTaskStartupAction(true, true, false), TaskStartupAction::CONTINUE_DEGRADED); + return 0; +} + +int run_task_startup_policy_tests() { + int passed = 0; + int failed = 0; + int (*tests[])() = {test_all_started, test_sensor_task_missing, test_publish_task_missing, test_display_task_missing}; + for (auto test : tests) { + if (test() == 0) { + passed++; + } else { + failed++; + } + } + test_suite_end("TaskStartupPolicy", passed, failed); + return failed; +} From 50ddd4b0fb29318587fb9f07ddef7514b280d36f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 22:07:54 +0000 Subject: [PATCH 19/28] style: align TelemetryQueue members Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SkKZgzzbP5Tq235ytaiKfi --- src/TelemetryQueue.hpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/TelemetryQueue.hpp b/src/TelemetryQueue.hpp index 6f711b83..babfb107 100644 --- a/src/TelemetryQueue.hpp +++ b/src/TelemetryQueue.hpp @@ -61,8 +61,8 @@ class TelemetryQueue { /// [0, CAPACITY] and the storage needs CAPACITY + 1 slots. static constexpr size_t SLOTS = CAPACITY + 1; - std::atomic head_{0}; ///< Consumer index (only consumer writes) - std::atomic tail_{0}; ///< Producer index (only producer writes) + std::atomic head_{0}; ///< Consumer index (only consumer writes) + std::atomic tail_{0}; ///< Producer index (only producer writes) PublishRequestKind items_[SLOTS] = {}; ///< Fixed ring storage }; From af46b21da89d59404712d57ca6d95fd885e3b2d5 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 3 Oct 2026 17:28:53 +0200 Subject: [PATCH 20/28] fix(multicore): synchronize sensor, display, and task watchdog state --- src/DisplayCoordinator.cpp | 213 +++++++++++++++++++++++++++++++++++ src/DisplayCoordinator.hpp | 25 ++++ src/DisplayTask.cpp | 19 +++- src/ESP32TemperatureNode.cpp | 8 +- src/ESP32TemperatureNode.hpp | 17 ++- src/PoolController.cpp | 174 +++------------------------- src/PublishTask.cpp | 9 ++ src/SensorTask.cpp | 12 +- src/SystemMonitor.hpp | 14 ++- 9 files changed, 314 insertions(+), 177 deletions(-) create mode 100644 src/DisplayCoordinator.cpp create mode 100644 src/DisplayCoordinator.hpp diff --git a/src/DisplayCoordinator.cpp b/src/DisplayCoordinator.cpp new file mode 100644 index 00000000..44d60e41 --- /dev/null +++ b/src/DisplayCoordinator.cpp @@ -0,0 +1,213 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayCoordinator.cpp + * @brief Non-blocking hand-off between NORVI button/UI state and OLED rendering. + */ + +#include "DisplayCoordinator.hpp" + +#ifdef NORVI_AE01_R + +#include +#include +#include + +#include "ConfigManager.hpp" +#include "DisplayTask.hpp" +#include "NetworkManager.hpp" +#include "Nodes.hpp" +#include "NorviButtonHandler.hpp" +#include "NorviOledDisplay.hpp" + +namespace PoolController { +namespace { + +enum class UiEvent : uint8_t { + BUTTON_1, + BUTTON_2, + BUTTON_3, + BUTTON_3_LONG, +}; + +constexpr size_t EVENT_CAPACITY = 8; +UiEvent eventQueue[EVENT_CAPACITY]{}; +size_t eventHead = 0; +size_t eventCount = 0; + +StaticSemaphore_t stateMutexStorage; +SemaphoreHandle_t stateMutex = nullptr; + +void enqueueEvent(UiEvent event) { + if (eventCount >= EVENT_CAPACITY) { + Serial.println("⚠ DisplayCoordinator: UI event queue full — dropping event"); + return; + } + eventQueue[(eventHead + eventCount) % EVENT_CAPACITY] = event; + eventCount++; +} + +bool dequeueEvent(UiEvent &event) { + if (eventCount == 0) { + return false; + } + event = eventQueue[eventHead]; + eventHead = (eventHead + 1) % EVENT_CAPACITY; + eventCount--; + return true; +} + +void handleButton1() { + if (NorviOledDisplay::isMenuActive()) { + NorviOledDisplay::menuPrevious(); + } else if (NorviOledDisplay::isSelectSensorStep()) { + NorviOledDisplay::setupSelectPrevious(); + NorviOledDisplay::requestRedraw(); + } else if (NorviOledDisplay::isSelectRoleStep()) { + NorviOledDisplay::setupSelectSolar(); + NorviOledDisplay::requestRedraw(); + } else { + NorviOledDisplay::previousPage(); + } +} + +void handleButton2() { + if (NorviOledDisplay::isMenuActive()) { + NorviOledDisplay::menuNext(); + } else if (NorviOledDisplay::isSelectSensorStep()) { + NorviOledDisplay::setupSelectNext(); + NorviOledDisplay::requestRedraw(); + } else if (NorviOledDisplay::isSelectRoleStep()) { + NorviOledDisplay::setupSelectPool(); + NorviOledDisplay::requestRedraw(); + } else { + NorviOledDisplay::nextPage(); + } +} + +void handleButton3() { + if (NorviOledDisplay::isMenuActive()) { + switch (NorviOledDisplay::getMenuSelection()) { + case NorviOledDisplay::MenuItem::MODE: { + const String ¤tMode = 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"); + } + Serial.printf("→ Mode switched to: %s\n", operationModeNode.getMode().c_str()); + break; + } + case NorviOledDisplay::MenuItem::PUMP: + poolPumpNode.setSwitch(!poolPumpNode.getSwitch()); + Serial.printf("→ Pump toggled: %s\n", poolPumpNode.getSwitch() ? "ON" : "OFF"); + break; + case NorviOledDisplay::MenuItem::EXIT: + break; + } + NorviOledDisplay::exitMenu(); + } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::MAIN) { + NorviOledDisplay::enterMenu(); + } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::SENSOR_SETUP) { + NorviOledDisplay::confirmAction(); + } +} + +void handleButton3Long() { + if (!NorviOledDisplay::isMappingComplete()) { + return; + } + + uint8_t solarAddr[8]; + uint8_t poolAddr[8]; + NorviOledDisplay::getMapping(solarAddr, poolAddr); + ConfigManager::saveSensorMapping(solarAddr, poolAddr); + Serial.println("→ Sensor mapping saved — rebooting..."); + NetworkManager::restart(); +} + +void processEvent(UiEvent event) { + switch (event) { + case UiEvent::BUTTON_1: + handleButton1(); + break; + case UiEvent::BUTTON_2: + handleButton2(); + break; + case UiEvent::BUTTON_3: + handleButton3(); + break; + case UiEvent::BUTTON_3_LONG: + handleButton3Long(); + break; + } +} + +} // namespace + +void DisplayCoordinator::begin() { + stateMutex = xSemaphoreCreateMutexStatic(&stateMutexStorage); + if (stateMutex == nullptr) { + Serial.println("✖ DisplayCoordinator: failed to create state mutex"); + return; + } + + NorviOledDisplay::begin(); + NorviButtonHandler::begin(); + + NorviButtonHandler::onButton1Press([]() { enqueueEvent(UiEvent::BUTTON_1); }); + NorviButtonHandler::onButton2Press([]() { enqueueEvent(UiEvent::BUTTON_2); }); + NorviButtonHandler::onButton3Press([]() { enqueueEvent(UiEvent::BUTTON_3); }); + NorviButtonHandler::onButton3LongPress([]() -> bool { + enqueueEvent(UiEvent::BUTTON_3_LONG); + return true; + }); +} + +void DisplayCoordinator::loop() { + // Button sampling must never wait for OLED I/O. Callbacks only enqueue. + NorviButtonHandler::loop(); + + if (stateMutex == nullptr || xSemaphoreTake(stateMutex, 0) != pdTRUE) { + return; + } + + UiEvent event; + while (dequeueEvent(event)) { + processEvent(event); + } + NorviOledDisplay::update(); + DisplayTask::requestRender(); + + xSemaphoreGive(stateMutex); +} + +void DisplayCoordinator::render() { + if (stateMutex == nullptr) { + return; + } + + // Core 0 may wait for the very short Core-1 state update, but Core 1 never + // waits for OLED rendering; it simply defers UI state work to the next loop. + if (xSemaphoreTake(stateMutex, portMAX_DELAY) == pdTRUE) { + NorviOledDisplay::render(); + xSemaphoreGive(stateMutex); + } +} + +} // namespace PoolController + +#else + +namespace PoolController { +void DisplayCoordinator::begin() {} +void DisplayCoordinator::loop() {} +void DisplayCoordinator::render() {} +} // namespace PoolController + +#endif diff --git a/src/DisplayCoordinator.hpp b/src/DisplayCoordinator.hpp new file mode 100644 index 00000000..7cf2323e --- /dev/null +++ b/src/DisplayCoordinator.hpp @@ -0,0 +1,25 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayCoordinator.hpp + * @brief Serializes NORVI UI state between the control loop and DisplayTask. + */ + +#pragma once + +namespace PoolController { + +class DisplayCoordinator { +public: + /** Initialize the OLED, button callbacks, and state synchronization. */ + static void begin(); + + /** Poll buttons and advance UI state without ever blocking the control loop. */ + static void loop(); + + /** Render from DisplayTask while owning the UI state. */ + static void render(); +}; + +} // namespace PoolController diff --git a/src/DisplayTask.cpp b/src/DisplayTask.cpp index 3982f314..82bd5ab1 100644 --- a/src/DisplayTask.cpp +++ b/src/DisplayTask.cpp @@ -14,19 +14,30 @@ #include -#include "NorviOledDisplay.hpp" +#include "DisplayCoordinator.hpp" +#include "SystemMonitor.hpp" namespace PoolController { namespace { TaskHandle_t displayTaskHandle = nullptr; std::atomic renderRequested{false}; +constexpr uint32_t FALLBACK_RENDER_INTERVAL_MS = 2000; } // namespace void displayTaskFunc(void *) { + const bool watchdogRegistered = SystemMonitor::registerCurrentTaskWithWatchdog(); + uint32_t lastRenderMs = millis(); + for (;;) { - if (renderRequested.exchange(false) || (millis() % 2000 < 50)) { - NorviOledDisplay::render(); + const uint32_t now = millis(); + const bool requested = renderRequested.exchange(false, std::memory_order_acq_rel); + if (requested || now - lastRenderMs >= FALLBACK_RENDER_INTERVAL_MS) { + lastRenderMs = now; + DisplayCoordinator::render(); + } + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); } vTaskDelay(pdMS_TO_TICKS(100)); } @@ -37,7 +48,7 @@ bool DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) } void DisplayTask::requestRender() { - renderRequested = true; + renderRequested.store(true, std::memory_order_release); } void DisplayTask::logStackWatermark() { diff --git a/src/ESP32TemperatureNode.cpp b/src/ESP32TemperatureNode.cpp index 1c754818..132784b3 100644 --- a/src/ESP32TemperatureNode.cpp +++ b/src/ESP32TemperatureNode.cpp @@ -55,17 +55,19 @@ auto readInternalTemperatureCelsius(float &temperatureCelsius) -> bool { ESP32TemperatureNode::ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval) { _id = id; _name = name; - _measurementInterval = (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL; + _measurementInterval.store((measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = millis(); _temperature = NAN; } void ESP32TemperatureNode::begin() { + PoolController::SensorSlots::write(PoolController::SensorId::CONTROLLER, NAN, false); LOG_INFO("• ESP32 Internal Temp sensor '%s' initialized.\n", _id); } void ESP32TemperatureNode::loop() { - if (Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { + const unsigned long measurementInterval = getMeasurementInterval(); + if (Utils::shouldMeasure(_lastMeasurement, measurementInterval)) { _lastMeasurement = millis(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) @@ -85,6 +87,8 @@ void ESP32TemperatureNode::loop() { _temperature = (temp_farenheit - 32.0f) / 1.8f; #endif + const bool valid = !isnan(_temperature); + PoolController::SensorSlots::write(PoolController::SensorId::CONTROLLER, _temperature, valid); LOG_DEBUG("〽 ESP32 internal temp: %f °C\n", _temperature); } } diff --git a/src/ESP32TemperatureNode.hpp b/src/ESP32TemperatureNode.hpp index 23cd8913..fda80817 100644 --- a/src/ESP32TemperatureNode.hpp +++ b/src/ESP32TemperatureNode.hpp @@ -10,8 +10,11 @@ #pragma once #include +#include #include +#include "SensorSlots.hpp" + #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 0, 0) extern "C" { uint8_t temprature_sens_read(); @@ -23,15 +26,18 @@ uint8_t temprature_sens_read(); * * Provides the on-die temperature of the ESP32 microcontroller. * Useful for monitoring enclosure temperature and detecting overheating. - * Uses the ESP32 internal temperature sensor (temprature_sens_read). + * The measured value is published through SensorSlots so readers on the + * control core never race with SensorTask on Core 0. */ class ESP32TemperatureNode { public: ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval = MEASUREMENT_INTERVAL); - float getTemperature() const { return _temperature; } - void setMeasurementInterval(unsigned long interval) { _measurementInterval = interval; } - unsigned long getMeasurementInterval() const { return _measurementInterval; } + float getTemperature() const { + return PoolController::SensorSlots::read(PoolController::SensorId::CONTROLLER); + } + void setMeasurementInterval(unsigned long interval) { _measurementInterval.store(interval, std::memory_order_relaxed); } + unsigned long getMeasurementInterval() const { return _measurementInterval.load(std::memory_order_relaxed); } void begin(); void loop(); @@ -42,8 +48,9 @@ class ESP32TemperatureNode { const char *_id; const char *_name; - unsigned long _measurementInterval; + std::atomic _measurementInterval{MEASUREMENT_INTERVAL}; unsigned long _lastMeasurement; + // Written only by SensorTask. Cross-task readers use SensorSlots. float _temperature = NAN; }; diff --git a/src/PoolController.cpp b/src/PoolController.cpp index e96cfc7e..0e2db512 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -41,9 +41,7 @@ #include "TelemetryQueue.hpp" #ifdef NORVI_AE01_R -#include "NorviOledDisplay.hpp" -#include "NorviButtonHandler.hpp" -#include "DisplayTask.hpp" +#include "DisplayCoordinator.hpp" #endif #include "Config.hpp" @@ -147,9 +145,7 @@ PoolControllerContext::PoolControllerContext() { Self = this; } -/** - * @brief Destroy the context and clear the instance pointer. - */ +/** @brief Destroy the context and clear the instance pointer. */ PoolControllerContext::~PoolControllerContext() { assert(Self); Self = nullptr; @@ -220,8 +216,6 @@ auto PoolControllerContext::initializeController() -> void { #ifdef NORVI_AE01_R // Initialize the shared OneWire bus before individual node begin() calls. - // Both DS18B20 sensors live on the same GPIO25 bus — the shared sensor - // instance scans all devices and each node reads by device index. sharedDallasSensor.begin(); #endif @@ -240,7 +234,6 @@ auto PoolControllerContext::initializeController() -> void { operationModeNode.setTemperatureHysteresis(ConfigManager::getSettings().tempHysteresis); // TimerSetting is loaded from NVS by OperationModeNode::begin() — no override needed - operationModeNode.setPoolTemperatureNode(&poolTemperatureNode); operationModeNode.setSolarTemperatureNode(&solarTemperatureNode); @@ -253,124 +246,22 @@ auto PoolControllerContext::initializeController() -> void { _lastMeasurement = 0; } -/** - * @brief Full initialization sequence called once at boot. - * - * Order: - * 1. StateManager (NVS), SystemMonitor, DegradationManager - * 2. Boot-loop detection — forces safe mode if detected - * 3. ConfigManager (LittleFS config.json) - * 4. NetworkManager (WiFi + MQTT) - * 5. WebPortal (HTTP server + captive portal) - * 6. MqttPublisher (HA Discovery) - * 7. OtaUpdater (GitHub release check) - * 8. initializeController() — pins, nodes, rules - * 9. Load persisted operational state from NVS - */ +/** @brief Full initialization sequence called once at boot. */ auto PoolControllerContext::setup() -> void { - // Initialize Preferences (NVS), System Monitor and Degradation tracker StateManager::begin(); SystemMonitor::begin(); DegradationManager::begin(); - // Initialize Status-LED with Homie-compatible blink codes StatusLed::begin(); - // Load persistent sensor address mapping from NVS early. - // Must run before NorviOledDisplay::begin() so that first-boot detection - // (needsSensorMapping) correctly checks whether sensors are assigned. + // Load mapping before the display first-boot flow evaluates it. loadSensorAddressMapping(); - - // Initialize persisted config (WiFi, MQTT, NTP, settings). - // Must run before NorviOledDisplay::begin() for needsWiFiSetup() check. ConfigManager::begin(); #ifdef NORVI_AE01_R - // Initialize NORVI-specific peripherals (OLED display + front buttons) - NorviOledDisplay::begin(); - NorviButtonHandler::begin(); - - // Wire button callbacks (S1=UP, S2=DOWN, S3=ACTION) - // ── S1 (UP) ─────────────────────────────────────────────────────────── - NorviButtonHandler::onButton1Press([]() { - if (NorviOledDisplay::isMenuActive()) { - NorviOledDisplay::menuPrevious(); - } else if (NorviOledDisplay::isSelectSensorStep()) { - NorviOledDisplay::setupSelectPrevious(); - NorviOledDisplay::requestRedraw(); - } else if (NorviOledDisplay::isSelectRoleStep()) { - NorviOledDisplay::setupSelectSolar(); - NorviOledDisplay::requestRedraw(); - } else { - NorviOledDisplay::previousPage(); - } - }); - // ── S2 (DOWN) ───────────────────────────────────────────────────────── - NorviButtonHandler::onButton2Press([]() { - if (NorviOledDisplay::isMenuActive()) { - NorviOledDisplay::menuNext(); - } else if (NorviOledDisplay::isSelectSensorStep()) { - NorviOledDisplay::setupSelectNext(); - NorviOledDisplay::requestRedraw(); - } else if (NorviOledDisplay::isSelectRoleStep()) { - NorviOledDisplay::setupSelectPool(); - NorviOledDisplay::requestRedraw(); - } else { - NorviOledDisplay::nextPage(); - } - }); - // ── S3 (CONFIRM) ────────────────────────────────────────────────────── - NorviButtonHandler::onButton3Press([]() { - if (NorviOledDisplay::isMenuActive()) { - // Execute selected menu action, then return to MAIN - NorviOledDisplay::Page prevPage = NorviOledDisplay::getCurrentPage(); - switch (NorviOledDisplay::getMenuSelection()) { - case NorviOledDisplay::MenuItem::MODE: { - // Cycle operation mode - const String ¤tMode = 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"); - } - Serial.printf("→ 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"); - break; - case NorviOledDisplay::MenuItem::EXIT: - // No action — just exit - break; - } - NorviOledDisplay::exitMenu(); - } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::MAIN) { - // MAIN page: open action menu - NorviOledDisplay::enterMenu(); - } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::SENSOR_SETUP) { - // Sensor setup: advance the wizard - NorviOledDisplay::confirmAction(); - } - // Other info pages: S3 intentionally does nothing - }); - // ── S3 long-press: save sensor mapping & reboot ─────────────────────── - NorviButtonHandler::onButton3LongPress([]() -> bool { - if (NorviOledDisplay::isMappingComplete()) { - uint8_t solarAddr[8], poolAddr[8]; - NorviOledDisplay::getMapping(solarAddr, poolAddr); - ConfigManager::saveSensorMapping(solarAddr, poolAddr); - Serial.println("→ Sensor mapping saved — rebooting..."); - NetworkManager::restart(); - return true; - } - return false; - }); + // Own all NORVI UI state transitions through one coordinator. Button + // callbacks stay on Core 1; OLED rendering remains on Core 0. + DisplayCoordinator::begin(); #endif // --- Boot-loop detection --- @@ -379,7 +270,6 @@ auto PoolControllerContext::setup() -> void { Serial.println("✖ SAFE MODE ACTIVE — all relays forced OFF"); DegradationManager::forceSafeMode(); - // Clear stored relay states Preferences prefs; prefs.begin("pool-pump", false); prefs.clear(); @@ -389,22 +279,12 @@ auto PoolControllerContext::setup() -> void { prefs.end(); } - // Start WiFi/WPS and MQTT services NetworkManager::begin(); - - // Start Captive Setup Web Portal WebPortal::begin(); - - // Start Home Assistant Discovery stack MqttPublisher::begin(); - - // Start OTA update checker OtaUpdater::begin(); - // Suppress NVS persistence during setup initialization OperationModeNode::suppressPersist(true); - - // Initialize core drivers and parameters initializeController(); // Print address mapping status after node begin() resolved the filters @@ -417,11 +297,7 @@ auto PoolControllerContext::setup() -> void { } OperationModeNode::suppressPersist(false); - - // Load operational settings from NVS Preferences operationModeNode.loadState(); - - // OTA safety: detect version transition and verify config integrity ConfigManager::logOtaTransition(); // Start Core-0 I/O tasks (sensors, display, publish). @@ -430,26 +306,10 @@ auto PoolControllerContext::setup() -> void { Serial.printf("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); } -/** - * @brief Main control loop — runs every iteration of Arduino loop(). - * - * Order: - * 1. Feed watchdog + check free memory (SystemMonitor) - * 2. Evaluate degradation levels (DegradationManager) - * 3. Clear boot-loop counter after 5 min stable uptime - * 4. Run managers: NetworkManager, WebPortal, OtaUpdater - * 5. Run nodes: relays, operation mode (triggers rule engine) - * — temperature sensors run in SensorTask on Core 0 (see SensorTask.cpp) - * 6. Publish HA Discovery + states on MQTT (re)connect - * 7. Periodically publish telemetry states to MQTT (every loopInterval s) - * 8. Log Core-0 task stack watermarks (throttled) - */ +/** @brief Main control loop — temperature acquisition and I/O run on worker tasks. */ auto PoolControllerContext::loop() -> void { - // Feed watchdog and check memory thresholds SystemMonitor::feedWatchdog(); SystemMonitor::checkMemory(); - - // Evaluate degradation levels and health DegradationManager::evaluate(); // Stable bootloop counter cleanup after 5 minutes @@ -467,14 +327,12 @@ auto PoolControllerContext::loop() -> void { lastBootClear = millis(); } - // Run managers + // Network callback only queues commands; state mutations are serialized here. NetworkManager::loop(); - // Handle MQTT commands received on the AsyncTCP task (state changes only on the loop task) MqttPublisher::processPendingCommands(); WebPortal::loop(); OtaUpdater::loop(); - // --- Status-LED: Pattern an Systemzustand anpassen (Homie-Convention) --- if (OtaUpdater::isUpdateInProgress()) { StatusLed::setPattern(StatusLedPattern::OTA_UPDATE); } else if (bootLoopDetected_ || DegradationManager::isSafe()) { @@ -491,22 +349,17 @@ auto PoolControllerContext::loop() -> void { StatusLed::loop(); #ifdef NORVI_AE01_R - // Advance display state machine (Core 1) and request render on DisplayTask (Core 0). - NorviOledDisplay::update(); - DisplayTask::requestRender(); - NorviButtonHandler::loop(); + DisplayCoordinator::loop(); #endif - // Run drivers & logic rules poolPumpNode.loop(); solarPumpNode.loop(); operationModeNode.loop(); - // Handle Home Assistant Connection State transition + // Handle Home Assistant connection transition. static bool wasMqttConnected = false; - bool currentMqttState = NetworkManager::isMqttConnected(); + const bool currentMqttState = NetworkManager::isMqttConnected(); if (currentMqttState && !wasMqttConnected) { - // Freshly connected to MQTT: publish Discovery and States via PublishTask. TelemetryQueue::instance().enqueue(PublishRequestKind::DISCOVERY); TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); wasMqttConnected = true; @@ -514,13 +367,12 @@ auto PoolControllerContext::loop() -> void { wasMqttConnected = false; } - // Periodically enqueue telemetry publish to HA (P4) — serialization runs on Core 0. + // Periodically enqueue telemetry; serialization runs on PublishTask/Core 0. if (currentMqttState && Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); } - // Log Core-0 task stack high-water marks (throttled inside). CoreScheduler::logStackWatermarks(); } diff --git a/src/PublishTask.cpp b/src/PublishTask.cpp index 98989e06..f761dedb 100644 --- a/src/PublishTask.cpp +++ b/src/PublishTask.cpp @@ -15,6 +15,7 @@ #include "TelemetryQueue.hpp" #include "MqttPublisher.hpp" #include "OtaUpdater.hpp" +#include "SystemMonitor.hpp" namespace PoolController { @@ -23,6 +24,8 @@ TaskHandle_t publishTaskHandle = nullptr; } // namespace void publishTaskFunc(void *) { + const bool watchdogRegistered = SystemMonitor::registerCurrentTaskWithWatchdog(); + for (;;) { PublishRequestKind kind; while (TelemetryQueue::instance().dequeue(kind)) { @@ -34,6 +37,12 @@ void publishTaskFunc(void *) { MqttPublisher::publishStates(); } } + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); + } + } + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); } vTaskDelay(pdMS_TO_TICKS(50)); } diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp index 2b80574b..bc8b9509 100644 --- a/src/SensorTask.cpp +++ b/src/SensorTask.cpp @@ -32,6 +32,8 @@ constexpr uint32_t CONVERSION_DELAY_MS = 800; // 12-bit DS18B20 conversion } // namespace void sensorTaskFunc(void *) { + const bool watchdogRegistered = SystemMonitor::registerCurrentTaskWithWatchdog(); + for (;;) { const uint32_t now = millis(); @@ -41,11 +43,12 @@ void sensorTaskFunc(void *) { lastSolarReadingMs = now; Serial.println("〽 SensorTask: reading Dallas sensors"); // Starts the conversion on every bus (shared or dedicated topology) - runDallasMeasurementCycle(solarTemperatureNode, poolTemperatureNode, [] { + runDallasMeasurementCycle(solarTemperatureNode, poolTemperatureNode, [watchdogRegistered] { // Yield while the conversion runs — never block the control loop. vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); - // Feed from the task context so long I/O waits can't starve the WDT. - SystemMonitor::feedWatchdogFromTask(); + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); + } }); } @@ -56,6 +59,9 @@ void sensorTaskFunc(void *) { ctrlTemperatureNode.loop(); } + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); + } vTaskDelay(pdMS_TO_TICKS(100)); } } diff --git a/src/SystemMonitor.hpp b/src/SystemMonitor.hpp index f5b8e984..f51899dd 100644 --- a/src/SystemMonitor.hpp +++ b/src/SystemMonitor.hpp @@ -43,12 +43,22 @@ class SystemMonitor { #else esp_task_wdt_init(30, true); #endif - esp_task_wdt_add(NULL); + registerCurrentTaskWithWatchdog(); + } + + /** Subscribe the calling FreeRTOS task to the task watchdog. */ + static bool registerCurrentTaskWithWatchdog() { + const esp_err_t err = esp_task_wdt_add(NULL); + if (err == ESP_OK) { + return true; + } + LOG_ERROR("Task watchdog registration failed: 0x%x\n", static_cast(err)); + return false; } static void feedWatchdog() { esp_task_wdt_reset(); } - /** Feed the watchdog from a non-loop task. */ + /** Feed the watchdog from a task that registered itself successfully. */ static void feedWatchdogFromTask() { esp_task_wdt_reset(); } static void checkMemory() { From 715a3e227203b9856a9a38a93e8cd2ac80d3b5fb Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 3 Oct 2026 17:32:43 +0200 Subject: [PATCH 21/28] style: format multicore sensor snapshot changes --- src/ESP32TemperatureNode.cpp | 3 ++- src/ESP32TemperatureNode.hpp | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/ESP32TemperatureNode.cpp b/src/ESP32TemperatureNode.cpp index 132784b3..c4528e1f 100644 --- a/src/ESP32TemperatureNode.cpp +++ b/src/ESP32TemperatureNode.cpp @@ -55,7 +55,8 @@ auto readInternalTemperatureCelsius(float &temperatureCelsius) -> bool { ESP32TemperatureNode::ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval) { _id = id; _name = name; - _measurementInterval.store((measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); + _measurementInterval.store( + (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = millis(); _temperature = NAN; } diff --git a/src/ESP32TemperatureNode.hpp b/src/ESP32TemperatureNode.hpp index fda80817..6746b4fa 100644 --- a/src/ESP32TemperatureNode.hpp +++ b/src/ESP32TemperatureNode.hpp @@ -33,9 +33,7 @@ class ESP32TemperatureNode { public: ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval = MEASUREMENT_INTERVAL); - float getTemperature() const { - return PoolController::SensorSlots::read(PoolController::SensorId::CONTROLLER); - } + float getTemperature() const { return PoolController::SensorSlots::read(PoolController::SensorId::CONTROLLER); } void setMeasurementInterval(unsigned long interval) { _measurementInterval.store(interval, std::memory_order_relaxed); } unsigned long getMeasurementInterval() const { return _measurementInterval.load(std::memory_order_relaxed); } From 09faf09eb3541af3ba1d88a2b1c6bc7f1f59eeb6 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 3 Oct 2026 23:40:14 +0200 Subject: [PATCH 22/28] fix: enforce multicore ownership boundaries --- src/CoreScheduler.cpp | 53 +++---- src/CoreScheduler.hpp | 21 ++- src/DallasTemperatureNode.cpp | 280 +++++++++++++++++++++------------- src/DallasTemperatureNode.hpp | 139 ++++++----------- src/DisplayCoordinator.cpp | 13 +- src/SensorTask.cpp | 22 ++- 6 files changed, 280 insertions(+), 248 deletions(-) diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp index 3555b6fe..b2e31952 100644 --- a/src/CoreScheduler.cpp +++ b/src/CoreScheduler.cpp @@ -3,7 +3,7 @@ /** * @file CoreScheduler.cpp - * @brief Task creation for the Core-0 I/O tasks. + * @brief Core-0 sensor task launcher and Core-1 telemetry service. */ #include "CoreScheduler.hpp" @@ -13,12 +13,10 @@ #include #include "LogCapture.hpp" -#include "PublishTask.hpp" +#include "MqttPublisher.hpp" +#include "OtaUpdater.hpp" #include "SensorTask.hpp" -#include "TaskStartupPolicy.hpp" -#ifdef NORVI_AE01_R -#include "DisplayTask.hpp" -#endif +#include "TelemetryQueue.hpp" namespace PoolController { @@ -26,42 +24,39 @@ void CoreScheduler::begin() { // Core 0 = PRO_CPU_NUM (I/O core); Core 1 = APP_CPU_NUM (control loop). const BaseType_t core0 = PRO_CPU_NUM; - const bool sensorStarted = SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0); - const bool publishStarted = PublishTask::start(TASK_PRIORITY_PUBLISH, TASK_STACK_PUBLISH, core0); -#ifdef NORVI_AE01_R - const bool displayStarted = DisplayTask::start(TASK_PRIORITY_DISPLAY, TASK_STACK_DISPLAY, core0); -#else - const bool displayStarted = true; // no display task on this board -#endif - - switch (decideTaskStartupAction(sensorStarted, publishStarted, displayStarted)) { - case TaskStartupAction::CONTINUE: - break; - case TaskStartupAction::CONTINUE_DEGRADED: - LOG_ERROR("✖ DisplayTask could not be created (free heap %u B) — continuing without OLED rendering\n", + if (!SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0)) { + LOG_ERROR("✖ SensorTask could not be created (free heap %u B) — restarting\n", static_cast(ESP.getFreeHeap())); - break; - case TaskStartupAction::RESTART: - LOG_ERROR("✖ Critical task could not be created (sensor: %s, publish: %s, free heap %u B) — restarting\n", - sensorStarted ? "ok" : "FAILED", publishStarted ? "ok" : "FAILED", static_cast(ESP.getFreeHeap())); Serial.flush(); - // A persistent failure is caught by the boot-loop detection (safe mode). + // A persistent failure is caught by boot-loop detection and forces safe mode. ESP.restart(); - break; } } void CoreScheduler::logStackWatermarks() { + // This method is invoked from PoolController::loop() on Core 1. Drain the + // request queue here so MqttPublisher never races the mutable control model. + PublishRequestKind kind; + while (TelemetryQueue::instance().dequeue(kind)) { + // Preserve the previous OTA behavior: requests are drained but not sent + // while the updater owns the network path. + if (OtaUpdater::isUpdateInProgress()) { + continue; + } + + if (kind == PublishRequestKind::DISCOVERY) { + MqttPublisher::publishDiscovery(); + } else { + MqttPublisher::publishStates(); + } + } + static uint32_t lastLog = 0; if (millis() - lastLog < 60000) { return; } lastLog = millis(); SensorTask::logStackWatermark(); - PublishTask::logStackWatermark(); -#ifdef NORVI_AE01_R - DisplayTask::logStackWatermark(); -#endif } } // namespace PoolController diff --git a/src/CoreScheduler.hpp b/src/CoreScheduler.hpp index db8721bb..b12735da 100644 --- a/src/CoreScheduler.hpp +++ b/src/CoreScheduler.hpp @@ -3,7 +3,7 @@ /** * @file CoreScheduler.hpp - * @brief Static launcher for the Core-0 I/O tasks. + * @brief Static launcher for the dedicated Core-0 sensor task. */ #pragma once @@ -13,12 +13,12 @@ namespace PoolController { /** - * @brief Creates and tracks the dedicated I/O tasks pinned to Core 0. + * @brief Creates and tracks the dedicated sensor task pinned to Core 0. * - * All tasks are created once in begin() with fixed stacks and priorities - * (no dynamic task creation after setup). Priorities only matter within a - * core: the I/O tasks sit below the WiFi-stack tasks and yield via - * vTaskDelay at their scheduling period. + * Stateful MQTT serialization and OLED rendering intentionally stay on the + * Arduino control-loop task on Core 1. They traverse mutable controller state + * and therefore must not run concurrently with the single-writer control loop. + * Sensor I/O is isolated on Core 0 and publishes readings through SensorSlots. */ class CoreScheduler { public: @@ -29,10 +29,15 @@ class CoreScheduler { static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; - /** @brief Create all Core-0 I/O tasks. Call once from setup(), after initializeController(). */ + /** @brief Create the Core-0 sensor task. Call once after initializeController(). */ static void begin(); - /** @brief Log stack high-water marks of all tasks (call periodically from loop()). */ + /** + * @brief Service queued telemetry on Core 1 and periodically log worker stack watermarks. + * + * PoolController::loop() calls this once per iteration, so MQTT publishing + * remains serialized with all other mutable control state. + */ static void logStackWatermarks(); }; diff --git a/src/DallasTemperatureNode.cpp b/src/DallasTemperatureNode.cpp index 2d2f9c4a..93575b71 100644 --- a/src/DallasTemperatureNode.cpp +++ b/src/DallasTemperatureNode.cpp @@ -5,7 +5,7 @@ /** * @file DallasTemperatureNode.cpp * @brief DS18B20 sensor node implementation with bus scanning, recovery, and - * degradation reporting. + * cross-core discovery snapshots. */ #include "DallasTemperatureNode.hpp" @@ -15,15 +15,13 @@ #include "SensorSlots.hpp" #include "Utils.hpp" -// ── Dedicated bus constructor ────────────────────────────────────────────── - DallasTemperatureNode::DallasTemperatureNode(const char *id, const char *name, const uint8_t pin, const int measurementInterval) { _id = id; _name = name; _pin = pin; - _measurementInterval = (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL; + _measurementInterval.store( + (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = 0; - numberOfDevices = 0; _temperature = NAN; _sensorFound = false; @@ -31,143 +29,213 @@ DallasTemperatureNode::DallasTemperatureNode(const char *id, const char *name, c sensor.setOneWire(&oneWire); } -// ── Shared bus constructor ───────────────────────────────────────────────── - DallasTemperatureNode::DallasTemperatureNode( const char *id, const char *name, DallasTemperature *sharedSensor, uint8_t deviceIndex, const int measurementInterval) { _id = id; _name = name; - _pin = 0; // not used in shared mode - _measurementInterval = (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL; + _pin = 0; + _measurementInterval.store( + (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = 0; - numberOfDevices = 0; _temperature = NAN; _sensorFound = false; sharedSensor_ = sharedSensor; deviceIndex_ = deviceIndex; isBusMaster_ = (deviceIndex == 0); - - // Clear the cached address memset(deviceAddress_, 0, sizeof(deviceAddress_)); } -// ═════════════════════════════════════════════════════════════════════════════ -// ── Address filter helpers ───────────────────────────────────────────────── +void DallasTemperatureNode::lockCache() const { + while (cacheLock_.test_and_set(std::memory_order_acquire)) { + yield(); + } +} + +void DallasTemperatureNode::unlockCache() const { + cacheLock_.clear(std::memory_order_release); +} void DallasTemperatureNode::setAddressFilter(const DeviceAddress addr) { + lockCache(); memcpy(filterAddr_, addr, sizeof(DeviceAddress)); - hasFilter_ = true; - - if (numberOfDevices > 0) { - resolveFilter(); - } + unlockCache(); + hasFilter_.store(true, std::memory_order_release); + mappingDirty_.store(true, std::memory_order_release); } void DallasTemperatureNode::clearAddressFilter() { + lockCache(); memset(filterAddr_, 0, sizeof(DeviceAddress)); - hasFilter_ = false; + unlockCache(); + hasFilter_.store(false, std::memory_order_release); + mappingDirty_.store(true, std::memory_order_release); +} - if (numberOfDevices > 0) { - resolveFilter(); - } +void DallasTemperatureNode::publishSelectedAddress() { + lockCache(); + memcpy(cachedSelectedAddress_, deviceAddress_, sizeof(DeviceAddress)); + unlockCache(); } bool DallasTemperatureNode::resolveFilter() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + DeviceAddress filter{}; + const bool hasFilter = hasFilter_.load(std::memory_order_acquire); - if (hasFilter_ && numberOfDevices > 0) { - // ── Address filter active: find the matching device on the bus ── + if (hasFilter) { + lockCache(); + memcpy(filter, filterAddr_, sizeof(DeviceAddress)); + unlockCache(); + } + + if (hasFilter && numberOfDevices > 0) { for (uint8_t i = 0; i < numberOfDevices; i++) { DeviceAddress addr; - if (activeSensor->getAddress(addr, i)) { - if (memcmp(addr, filterAddr_, sizeof(DeviceAddress)) == 0) { - // Note: intentionally NOT updating deviceIndex_ here — the constructor- - // provided default (solar=0, pool=1) must survive as the fallback index - // used by the no-filter path in clearAddressFilter(). - memcpy(deviceAddress_, addr, sizeof(DeviceAddress)); - _sensorFound = true; - char adr[18]; - address2String(addr, adr, sizeof(adr)); - Serial.printf(" ◦ %s: filter resolved → device %d [%s] ✓\n", _id, i, adr); - return true; - } + if (activeSensor->getAddress(addr, i) && memcmp(addr, filter, sizeof(DeviceAddress)) == 0) { + memcpy(deviceAddress_, addr, sizeof(DeviceAddress)); + publishSelectedAddress(); + _sensorFound = true; + char adr[18]; + address2String(addr, adr, sizeof(adr)); + Serial.printf(" ◦ %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", - _id, deviceIndex_); + + Serial.printf(" ✖ %s: filter address not found on bus! Falling back to device index %d\n", _id, deviceIndex_); if (activeSensor->getAddress(deviceAddress_, deviceIndex_)) { + publishSelectedAddress(); _sensorFound = true; return true; } + + memset(deviceAddress_, 0, sizeof(deviceAddress_)); + publishSelectedAddress(); _sensorFound = false; return false; } - if (numberOfDevices > 0) { - // ── No filter: cache address by deviceIndex_ ─────────────────── - if (activeSensor->getAddress(deviceAddress_, deviceIndex_)) { - _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)"); - } - Serial.println(); - return true; + if (numberOfDevices > 0 && activeSensor->getAddress(deviceAddress_, deviceIndex_)) { + publishSelectedAddress(); + _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)"); } + Serial.println(); + return true; } + memset(deviceAddress_, 0, sizeof(deviceAddress_)); + publishSelectedAddress(); _sensorFound = false; return false; } +void DallasTemperatureNode::applyPendingAddressFilter() { + if (!mappingDirty_.exchange(false, std::memory_order_acq_rel)) { + return; + } + if (numberOfDevices > 0) { + resolveFilter(); + } +} + +void DallasTemperatureNode::refreshDetectedSnapshot(bool includeTemperatures) { + DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + DeviceAddress localAddresses[MAX_DETECTED_DEVICES]{}; + float localTemperatures[MAX_DETECTED_DEVICES]; + for (float &value : localTemperatures) { + value = NAN; + } + + const uint8_t count = (numberOfDevices < MAX_DETECTED_DEVICES) ? numberOfDevices : MAX_DETECTED_DEVICES; + uint8_t written = 0; + for (uint8_t i = 0; i < count; i++) { + DeviceAddress addr; + if (!activeSensor->getAddress(addr, i)) { + continue; + } + memcpy(localAddresses[written], addr, sizeof(DeviceAddress)); + if (includeTemperatures) { + const float value = activeSensor->getTempC(addr); + localTemperatures[written] = (value == DEVICE_DISCONNECTED_C) ? NAN : value; + } + written++; + } + + lockCache(); + detectedCount_ = written; + for (uint8_t i = 0; i < MAX_DETECTED_DEVICES; i++) { + if (i < written) { + memcpy(detectedAddresses_[i], localAddresses[i], sizeof(DeviceAddress)); + detectedTemperatures_[i] = localTemperatures[i]; + } else { + memset(detectedAddresses_[i], 0, sizeof(DeviceAddress)); + detectedTemperatures_[i] = NAN; + } + } + unlockCache(); +} + +uint8_t DallasTemperatureNode::getDeviceCount() const { + lockCache(); + const uint8_t count = detectedCount_; + unlockCache(); + return count; +} + void DallasTemperatureNode::getDeviceAddressString(char *buffer, size_t size) const { - address2String(deviceAddress_, buffer, size); + DeviceAddress addr; + lockCache(); + memcpy(addr, cachedSelectedAddress_, sizeof(DeviceAddress)); + unlockCache(); + address2String(addr, buffer, size); } bool DallasTemperatureNode::getDetectedDeviceAddress(uint8_t index, DeviceAddress addr) const { - DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : const_cast(&sensor); - if (index >= numberOfDevices) { + lockCache(); + if (index >= detectedCount_) { + unlockCache(); return false; } - return activeSensor->getAddress(addr, index); + memcpy(addr, detectedAddresses_[index], sizeof(DeviceAddress)); + unlockCache(); + return true; } float DallasTemperatureNode::getDetectedDeviceTemperature(uint8_t index) const { - DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : const_cast(&sensor); - if (index >= numberOfDevices) { - return NAN; - } - DeviceAddress addr; - if (!activeSensor->getAddress(addr, index)) { + lockCache(); + if (index >= detectedCount_) { + unlockCache(); return NAN; } - return activeSensor->getTempC(addr); + const float value = detectedTemperatures_[index]; + unlockCache(); + return value; } -// ═════════════════════════════════════════════════════════════════════════════ - void DallasTemperatureNode::begin() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; if (!sharedSensor_) { - // Dedicated mode: init our own sensor activeSensor->begin(); } - // Shared mode: sensor->begin() was already called externally — just scan + + // SensorTask owns the conversion delay. Without this, requestTemperatures() + // blocks internally and the task would wait a second time afterwards. + activeSensor->setWaitForConversion(false); numberOfDevices = activeSensor->getDeviceCount(); Serial.printf("• DallasTemperature: Parasite power is: %d\n", activeSensor->isParasitePowerMode()); if (numberOfDevices > 0) { - uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; + const uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; Serial.printf(" ◦ %d devices found on PIN %d\n", numberOfDevices, displayPin); - // ── Print all detected devices ────────────────────────────────────── for (uint8_t i = 0; i < numberOfDevices; i++) { DeviceAddress addr; if (activeSensor->getAddress(addr, i)) { @@ -177,10 +245,10 @@ void DallasTemperatureNode::begin() { } } - // ── Resolve address filter (persistent mapping) ───────────────────── resolveFilter(); + mappingDirty_.store(false, std::memory_order_release); + refreshDetectedSnapshot(false); - // ── Report global status ─────────────────────────────────────────── if (sharedSensor_) { PoolController::DegradationManager::reportSensorStatus("solar-temp", numberOfDevices > 0); PoolController::DegradationManager::reportSensorStatus("pool-temp", numberOfDevices > 1); @@ -188,25 +256,28 @@ void DallasTemperatureNode::begin() { PoolController::DegradationManager::reportSensorStatus(_id, true); } } else { - uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; + const uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; Serial.printf("✖ No Dallas sensors found on pin %d\n", displayPin); _sensorFound = false; + refreshDetectedSnapshot(false); PoolController::DegradationManager::reportSensorStatus(_id, false); } + + // Start in recovery cadence until the first valid measurement is published. + PoolController::SensorSlots::write(slotId(), NAN, false); } void DallasTemperatureNode::beginMeasurement() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + applyPendingAddressFilter(); if (sharedSensor_ && numberOfDevices > 0) { - // Shared bus: only the master drives the conversion for all sensors. if (isBusMaster_) { PoolController::SystemMonitor::feedWatchdogFromTask(); activeSensor->requestTemperatures(); PoolController::SystemMonitor::feedWatchdogFromTask(); } } else if (numberOfDevices > 0) { - // Dedicated bus: start our own conversion. PoolController::SystemMonitor::feedWatchdogFromTask(); activeSensor->requestTemperatures(); PoolController::SystemMonitor::feedWatchdogFromTask(); @@ -217,8 +288,7 @@ void DallasTemperatureNode::finishMeasurement() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; if (sharedSensor_ && numberOfDevices > 0) { - // Shared bus: master and slave each read their own device. - float newTemp = activeSensor->getTempC(deviceAddress_); + const float newTemp = activeSensor->getTempC(deviceAddress_); if (newTemp == DEVICE_DISCONNECTED_C) { _temperature = NAN; _sensorFound = false; @@ -231,13 +301,16 @@ void DallasTemperatureNode::finishMeasurement() { Serial.printf(" ◦ %s Temp = %.1f°C\n", _id, _temperature); } PoolController::SensorSlots::write(slotId(), _temperature, _sensorFound); - } else if (numberOfDevices > 0) { - // Dedicated bus: read all devices, take the last valid reading. + refreshDetectedSnapshot(true); + return; + } + + if (numberOfDevices > 0) { bool foundAny = false; for (uint8_t i = 0; i < numberOfDevices; i++) { DeviceAddress tempDeviceAddress; if (activeSensor->getAddress(tempDeviceAddress, i)) { - float newTemp = activeSensor->getTempC(tempDeviceAddress); + const float newTemp = activeSensor->getTempC(tempDeviceAddress); if (newTemp != DEVICE_DISCONNECTED_C) { _temperature = newTemp; foundAny = true; @@ -253,39 +326,40 @@ void DallasTemperatureNode::finishMeasurement() { Serial.printf(" ✖ %s sensor disconnected - setting to NaN\n", _id); } PoolController::SensorSlots::write(slotId(), _temperature, _sensorFound); - } else { - // No sensor found — rescan the bus. - Serial.printf("No Sensor found on bus! Rescanning (%s)...\n", _id); - PoolController::DegradationManager::reportSensorStatus(_id, false); - PoolController::SensorSlots::write(slotId(), NAN, false); + refreshDetectedSnapshot(true); + return; + } - if (sharedSensor_) { - activeSensor->begin(); - numberOfDevices = activeSensor->getDeviceCount(); - if (numberOfDevices > deviceIndex_) { - activeSensor->getAddress(deviceAddress_, deviceIndex_); - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); - } - } else { - activeSensor->begin(); - numberOfDevices = activeSensor->getDeviceCount(); - if (numberOfDevices > 0) { - _sensorFound = true; - Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); - } + Serial.printf("No Sensor found on bus! Rescanning (%s)...\n", _id); + PoolController::DegradationManager::reportSensorStatus(_id, false); + PoolController::SensorSlots::write(slotId(), NAN, false); + + activeSensor->begin(); + activeSensor->setWaitForConversion(false); + numberOfDevices = activeSensor->getDeviceCount(); + if (numberOfDevices > 0) { + resolveFilter(); + if (_sensorFound) { + PoolController::DegradationManager::reportSensorStatus(_id, true); } + Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); + } else { + _sensorFound = false; + memset(deviceAddress_, 0, sizeof(deviceAddress_)); + publishSelectedAddress(); } + refreshDetectedSnapshot(false); } void DallasTemperatureNode::loop() { - unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : _measurementInterval; + const unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : getMeasurementInterval(); if (Utils::shouldMeasure(_lastMeasurement, effectiveInterval)) { _lastMeasurement = millis(); Serial.printf("〽 Reading Dallas sensor: %s\n", _id); beginMeasurement(); - // Sync fallback (tests / non-task callers): conversion is blocking here. + // Synchronous fallback for tests/non-task callers. Production uses + // SensorTask, which yields instead of blocking the control loop. + delay(SYNC_CONVERSION_DELAY_MS); finishMeasurement(); } } diff --git a/src/DallasTemperatureNode.hpp b/src/DallasTemperatureNode.hpp index ffe8f82b..89a09bbc 100644 --- a/src/DallasTemperatureNode.hpp +++ b/src/DallasTemperatureNode.hpp @@ -13,155 +13,106 @@ #include #include +#include + #include "SensorSlots.hpp" /** * @brief Reads temperature from a DS18B20 sensor on a OneWire bus. * - * Supports automatic recovery: if the sensor disappears, it uses a shorter - * polling interval (RECOVERY_INTERVAL) and rescans the bus each cycle. - * Reports sensor status to the DegradationManager for system health tracking. - * - * ### Shared Bus Mode (NORVI AE01-R) - * - * When constructed with the shared-bus constructor, two nodes can share a - * single OneWire bus. The "master" node (deviceIndex == 0) drives - * requestTemperatures() while the "slave" (deviceIndex > 0) reads its - * specific device address from the already-completed conversion. - * - * @note Shared bus mode is only enabled when `NORVI_AE01_R` is defined. + * OneWire/DallasTemperature access is owned by SensorTask after startup. + * Cross-core consumers only read SensorSlots or the small cached discovery + * snapshot maintained by this class. */ class DallasTemperatureNode { public: - /** - * @brief Construct a DallasTemperature node (dedicated bus). - * @param id Unique node identifier (e.g. "solar-temp"). - * @param name Human-readable name (e.g. "Solar Temperature"). - * @param pin GPIO pin for the OneWire data line. - * @param measurementInterval Minimum interval between sensor reads (seconds). - */ DallasTemperatureNode( const char *id, const char *name, const uint8_t pin, const int measurementInterval = MEASUREMENT_INTERVAL); - /** - * @brief Construct a DallasTemperature node on a shared OneWire bus. - * @param id Unique node identifier (e.g. "solar-temp"). - * @param name Human-readable name (e.g. "Solar Temperature"). - * @param sharedSensor Pointer to an externally owned DallasTemperature instance. - * @param deviceIndex Index of this node's sensor on the shared bus (0 = master, 1 = slave). - * @param measurementInterval Minimum interval between sensor reads (seconds). - * - * The master (index 0) calls requestTemperatures(). All nodes read their - * respective device address from the conversion result. Requires a prior - * call to sharedSensor->begin() before calling node.begin(). - */ DallasTemperatureNode(const char *id, const char *name, DallasTemperature *sharedSensor, uint8_t deviceIndex, const int measurementInterval = MEASUREMENT_INTERVAL); - /** @brief Get the node identifier. */ const char *getId() const { return _id; } - /** @brief Get the OneWire GPIO pin number. */ uint8_t getPin() const { return _pin; } - /** @brief Set the measurement interval (seconds). */ - void setMeasurementInterval(unsigned long interval) { _measurementInterval = interval; } - /** @brief Get the measurement interval (seconds). */ - unsigned long getMeasurementInterval() const { return _measurementInterval; } + void setMeasurementInterval(unsigned long interval) { _measurementInterval.store(interval, std::memory_order_relaxed); } + unsigned long getMeasurementInterval() const { return _measurementInterval.load(std::memory_order_relaxed); } + + /** @brief Measurement interval including the 5 s recovery cadence. */ + unsigned long getEffectiveMeasurementInterval() const { + return PoolController::SensorSlots::isFound(slotId()) ? getMeasurementInterval() : RECOVERY_INTERVAL; + } - /** @brief Get the last successfully read temperature. @return Temperature in °C, or NAN if no valid read. */ float getTemperature() const { return PoolController::SensorSlots::read(slotId()); } - /** @brief Check if a sensor was found on the bus. @return true if at least one device is present. */ bool isSensorFound() const { return PoolController::SensorSlots::isFound(slotId()); } - /** @brief Get number of devices detected on this node's bus. */ - uint8_t getDeviceCount() const { return numberOfDevices; } - /** @brief Get the 8-byte ROM address of the device this node reads from. */ + /** @brief Get number of devices from the thread-safe discovery snapshot. */ + uint8_t getDeviceCount() const; const uint8_t *getDeviceAddress() const { return deviceAddress_; } - /** @brief Format the node's device address into a hex string (17 chars). */ void getDeviceAddressString(char *buffer, size_t size) const; - /** @brief Get the address of a detected device by index. @return true if index is valid. */ bool getDetectedDeviceAddress(uint8_t index, DeviceAddress addr) const; - /** @brief Get temperature for any device on this node's bus by index. */ float getDetectedDeviceTemperature(uint8_t index) const; /** - * @brief Set a preferred device address filter. + * @brief Queue a preferred address for application by SensorTask. * - * During begin(), the node scans the bus and finds the device whose ROM - * address matches \p addr. If found, that device is used regardless of - * deviceIndex. If not found, a warning is printed and deviceIndex is - * used as fallback. + * Before SensorTask starts, begin() resolves the queued filter during boot. + * At runtime this method never touches OneWire; the next measurement cycle + * applies the pending mapping on the sensor task. */ void setAddressFilter(const DeviceAddress addr); - /** @brief Clear the address filter — reverts to deviceIndex-based selection. */ void clearAddressFilter(); - /** @brief Check if an address filter is configured. */ - bool hasAddressFilter() const { return hasFilter_; } + bool hasAddressFilter() const { return hasFilter_.load(std::memory_order_acquire); } - /** @brief Initialize the OneWire bus and discover connected sensors. */ void begin(); - /** @brief Read temperature periodically (respects measurementInterval). */ void loop(); - - /** - * @brief Start a temperature conversion (non-blocking on Core 0). - * - * In shared-bus mode only the master (deviceIndex 0) issues - * requestTemperatures(); slaves just return. In dedicated mode the - * node starts its own conversion. - * @note Call from SensorTask; the result must be read later via - * finishMeasurement() after the conversion time has elapsed. - */ void beginMeasurement(); - - /** - * @brief Read the conversion result and publish it to SensorSlots. - * - * Reads the temperature from the bus, updates the internal state, reports - * sensor status to DegradationManager, and writes the value into the - * thread-safe SensorSlots for cross-task consumers. - * @note Call from SensorTask after beginMeasurement() + conversion delay. - */ void finishMeasurement(); private: - static const int MIN_INTERVAL = 10; // in seconds (more granular loop support) + static const int MIN_INTERVAL = 10; static const int MEASUREMENT_INTERVAL = 300; - static const int RECOVERY_INTERVAL = 5; // seconds + static const int RECOVERY_INTERVAL = 5; + static constexpr uint8_t MAX_DETECTED_DEVICES = 20; + static constexpr uint32_t SYNC_CONVERSION_DELAY_MS = 800; const char *_id; const char *_name; uint8_t _pin = 0; - unsigned long _measurementInterval; + std::atomic _measurementInterval{MEASUREMENT_INTERVAL}; unsigned long _lastMeasurement; bool _sensorFound = false; - float _temperature = NAN; - // Own OneWire bus (dedicated mode) or nullptr (shared mode) OneWire oneWire; DallasTemperature sensor; - // Address filter (persistent mapping across reboots) - bool hasFilter_ = false; ///< Whether an address filter is configured - DeviceAddress filterAddr_{}; ///< Address to match during begin() + std::atomic hasFilter_{false}; + std::atomic mappingDirty_{false}; + DeviceAddress filterAddr_{}; - // Shared bus members (only used in shared mode) - DallasTemperature *sharedSensor_ = nullptr; ///< External shared sensor (shared mode only) - uint8_t deviceIndex_ = 0; ///< Device index on the shared bus - bool isBusMaster_ = false; ///< True if this node drives requestTemperatures() - DeviceAddress deviceAddress_{}; ///< Cached device address for shared bus reads + DallasTemperature *sharedSensor_ = nullptr; + uint8_t deviceIndex_ = 0; + bool isBusMaster_ = false; + DeviceAddress deviceAddress_{}; + uint8_t numberOfDevices = 0; - uint8_t numberOfDevices; + // Cross-core metadata cache. The lock only protects short memory copies; + // OneWire operations are always performed without holding it. + mutable std::atomic_flag cacheLock_ = ATOMIC_FLAG_INIT; + DeviceAddress cachedSelectedAddress_{}; + DeviceAddress detectedAddresses_[MAX_DETECTED_DEVICES]{}; + float detectedTemperatures_[MAX_DETECTED_DEVICES]{}; + uint8_t detectedCount_ = 0; - /** @brief Scan the bus for the current filter and update deviceAddress_. - * Called by begin(), setAddressFilter(), and clearAddressFilter(). - * @return true if the device was found, false if fallback was used. */ bool resolveFilter(); + void applyPendingAddressFilter(); + void refreshDetectedSnapshot(bool includeTemperatures); + void publishSelectedAddress(); - /** @brief Map this node to its SensorSlots id. */ - PoolController::SensorId slotId() const; + void lockCache() const; + void unlockCache() const; - /** @brief Format a DeviceAddress as a hex string. */ + PoolController::SensorId slotId() const; void address2String(const DeviceAddress deviceAddress, char *buffer, size_t size) const; }; diff --git a/src/DisplayCoordinator.cpp b/src/DisplayCoordinator.cpp index 44d60e41..18cdf458 100644 --- a/src/DisplayCoordinator.cpp +++ b/src/DisplayCoordinator.cpp @@ -3,7 +3,7 @@ /** * @file DisplayCoordinator.cpp - * @brief Non-blocking hand-off between NORVI button/UI state and OLED rendering. + * @brief Serialized NORVI button/UI state and OLED rendering on Core 1. */ #include "DisplayCoordinator.hpp" @@ -15,7 +15,6 @@ #include #include "ConfigManager.hpp" -#include "DisplayTask.hpp" #include "NetworkManager.hpp" #include "Nodes.hpp" #include "NorviButtonHandler.hpp" @@ -170,7 +169,9 @@ void DisplayCoordinator::begin() { } void DisplayCoordinator::loop() { - // Button sampling must never wait for OLED I/O. Callbacks only enqueue. + // Button sampling, state transitions and rendering all remain on Core 1. + // This preserves the single-writer rule for operationModeNode, relay state, + // NetworkManager and ConfigManager which are read while drawing pages. NorviButtonHandler::loop(); if (stateMutex == nullptr || xSemaphoreTake(stateMutex, 0) != pdTRUE) { @@ -182,7 +183,7 @@ void DisplayCoordinator::loop() { processEvent(event); } NorviOledDisplay::update(); - DisplayTask::requestRender(); + NorviOledDisplay::render(); xSemaphoreGive(stateMutex); } @@ -192,8 +193,8 @@ void DisplayCoordinator::render() { return; } - // Core 0 may wait for the very short Core-1 state update, but Core 1 never - // waits for OLED rendering; it simply defers UI state work to the next loop. + // Kept as a serialized compatibility entry point. CoreScheduler no longer + // creates a separate DisplayTask, so production rendering happens in loop(). if (xSemaphoreTake(stateMutex, portMAX_DELAY) == pdTRUE) { NorviOledDisplay::render(); xSemaphoreGive(stateMutex); diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp index bc8b9509..e69d1af5 100644 --- a/src/SensorTask.cpp +++ b/src/SensorTask.cpp @@ -26,9 +26,9 @@ extern ESP32TemperatureNode ctrlTemperatureNode; namespace { TaskHandle_t sensorTaskHandle = nullptr; -uint32_t lastSolarReadingMs = 0; +uint32_t lastDallasReadingMs = 0; uint32_t lastControllerReadingMs = 0; -constexpr uint32_t CONVERSION_DELAY_MS = 800; // 12-bit DS18B20 conversion +constexpr uint32_t CONVERSION_DELAY_MS = 800; // >= 12-bit DS18B20 conversion } // namespace void sensorTaskFunc(void *) { @@ -37,14 +37,20 @@ void sensorTaskFunc(void *) { for (;;) { const uint32_t now = millis(); - // One cycle for both DS18B20 nodes on the solar interval. - const unsigned long solarInterval = solarTemperatureNode.getMeasurementInterval(); - if (now - lastSolarReadingMs >= solarInterval * 1000UL) { - lastSolarReadingMs = now; + // Both Dallas nodes are measured in one cycle. Preserve the old recovery + // behavior by using the shorter effective interval whenever either node is + // missing/invalid (RECOVERY_INTERVAL = 5 s). + const unsigned long solarInterval = solarTemperatureNode.getEffectiveMeasurementInterval(); + const unsigned long poolInterval = poolTemperatureNode.getEffectiveMeasurementInterval(); + const unsigned long dallasInterval = (solarInterval < poolInterval) ? solarInterval : poolInterval; + + if (now - lastDallasReadingMs >= dallasInterval * 1000UL) { + lastDallasReadingMs = now; Serial.println("〽 SensorTask: reading Dallas sensors"); - // Starts the conversion on every bus (shared or dedicated topology) + // DallasTemperature is configured with waitForConversion=false in + // DallasTemperatureNode::begin(), so the conversion starts immediately + // and this task yields while the sensors convert. runDallasMeasurementCycle(solarTemperatureNode, poolTemperatureNode, [watchdogRegistered] { - // Yield while the conversion runs — never block the control loop. vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); if (watchdogRegistered) { SystemMonitor::feedWatchdogFromTask(); From 3c7b55fb67734e9f9bb1d70a8a8768f186b1a94a Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 3 Oct 2026 23:42:18 +0200 Subject: [PATCH 23/28] test: cover Dallas recovery scheduling --- src/SensorCycle.hpp | 5 +++++ src/SensorTask.cpp | 2 +- test/native/tests/test_sensor_cycle.cpp | 18 ++++++++++-------- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/SensorCycle.hpp b/src/SensorCycle.hpp index 80da0563..51b3d244 100644 --- a/src/SensorCycle.hpp +++ b/src/SensorCycle.hpp @@ -20,6 +20,11 @@ namespace PoolController { +/** @brief Select the shorter interval so either missing sensor keeps fast recovery. */ +constexpr unsigned long selectDallasCycleInterval(unsigned long solarInterval, unsigned long poolInterval) { + return (solarInterval < poolInterval) ? solarInterval : poolInterval; +} + template void runDallasMeasurementCycle(Node &solar, Node &pool, WaitFn waitForConversion) { solar.beginMeasurement(); pool.beginMeasurement(); diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp index e69d1af5..857abe42 100644 --- a/src/SensorTask.cpp +++ b/src/SensorTask.cpp @@ -42,7 +42,7 @@ void sensorTaskFunc(void *) { // missing/invalid (RECOVERY_INTERVAL = 5 s). const unsigned long solarInterval = solarTemperatureNode.getEffectiveMeasurementInterval(); const unsigned long poolInterval = poolTemperatureNode.getEffectiveMeasurementInterval(); - const unsigned long dallasInterval = (solarInterval < poolInterval) ? solarInterval : poolInterval; + const unsigned long dallasInterval = selectDallasCycleInterval(solarInterval, poolInterval); if (now - lastDallasReadingMs >= dallasInterval * 1000UL) { lastDallasReadingMs = now; diff --git a/test/native/tests/test_sensor_cycle.cpp b/test/native/tests/test_sensor_cycle.cpp index 77358cb5..94c8390b 100644 --- a/test/native/tests/test_sensor_cycle.cpp +++ b/test/native/tests/test_sensor_cycle.cpp @@ -3,9 +3,7 @@ /** * @file test_sensor_cycle.cpp - * @brief Tests for runDallasMeasurementCycle() — every bus must have a - * conversion started before its sensors are read, for the shared - * (NORVI) and the dedicated (esp32dev) topology (review #170). + * @brief Tests for Dallas measurement-cycle ordering and recovery scheduling. */ #include @@ -30,16 +28,12 @@ extern void test_suite_end(const char *name, int passed, int failed); namespace { -// A OneWire bus: counts conversion requests and remembers whether a -// conversion was running when a sensor on it was read. struct FakeBus { int conversions = 0; int readsWithConversion = 0; int readsWithoutConversion = 0; }; -// Mirrors DallasTemperatureNode: on a shared bus only the master starts the -// conversion; on a dedicated bus every node starts its own. struct FakeNode { FakeBus *bus; bool shared; @@ -107,10 +101,18 @@ static int test_reads_happen_after_wait() { return 0; } +static int test_recovery_interval_wins() { + test_begin("SensorCycle", "missing sensor keeps five-second recovery cadence"); + ASSERT_EQ(PoolController::selectDallasCycleInterval(10UL, 5UL), 5UL); + ASSERT_EQ(PoolController::selectDallasCycleInterval(5UL, 10UL), 5UL); + ASSERT_EQ(PoolController::selectDallasCycleInterval(10UL, 10UL), 10UL); + return 0; +} + int run_sensor_cycle_tests() { int passed = 0; int failed = 0; - int (*tests[])() = {test_dedicated_buses, test_shared_bus, test_reads_happen_after_wait}; + int (*tests[])() = {test_dedicated_buses, test_shared_bus, test_reads_happen_after_wait, test_recovery_interval_wins}; for (auto test : tests) { if (test() == 0) { passed++; From 003c99ec949bf3d6edeb65f7c75b500135e6718e Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 3 Oct 2026 23:43:41 +0200 Subject: [PATCH 24/28] docs: align multicore architecture with ownership model --- docs/multicore-architecture.de.md | 146 +++++++++++++++--------------- docs/multicore-architecture.md | 139 ++++++++++++++-------------- 2 files changed, 145 insertions(+), 140 deletions(-) diff --git a/docs/multicore-architecture.de.md b/docs/multicore-architecture.de.md index 33e7c8d8..c1176aa9 100644 --- a/docs/multicore-architecture.de.md +++ b/docs/multicore-architecture.de.md @@ -1,8 +1,8 @@ --- title: Multicore-Architektur -summary: Wie die Firmware beide ESP32-Kerne nutzt — dedizierte I/O-Tasks (Sensoren, Display, MQTT-Telemetrie) auf Kern 0 und eine deterministische Regelschleife auf Kern 1 +summary: Wie die Firmware blockierende Sensor-I/O auf ESP32-Kern 0 isoliert und veränderlichen Controller-Zustand auf Kern 1 serialisiert date: "2026-08-01" -lastmod: "2026-08-01" +lastmod: "2026-10-03" draft: false toc: true type: docs @@ -17,97 +17,99 @@ menu: ## Überblick -Der ESP32 hat zwei Xtensa-LX6-Kerne, aber ein Single-Loop-Arduino-Sketch nutzt nur -einen: Der WiFi/BT-Stack läuft auf Kern 0, die Arduino-`loop()` auf Kern 1. Alles -andere — Sensor-Messungen, Display-Update, Regeln, Netzwerk, MQTT — läuft seriell -innerhalb von `loop()`. - -Die Firmware wird zu einer **Task-Architektur mit expliziter Kern-Trennung** -umstrukturiert: +Die Firmware verwendet ein explizites Ownership-Modell, statt jede I/O-Operation auf den zweiten Kern +zu verschieben. Das sicherheitsrelevante Controller-Modell bleibt Single-Writer in der Arduino-Loop, +während die blockierende DS18B20-Konvertierung auf Kern 0 isoliert wird. | Kern | Rolle | Inhalt | | ---- | ----- | ------ | -| **Kern 0** (PRO_CPU) | I/O-Kern | SensorTask (DS18B20 + interner Temperatursensor), DisplayTask (OLED-Rendering, nur NORVI), PublishTask (MQTT-Telemetrie-Serialisierung) | -| **Kern 1** (APP_CPU) | Regel-Kern | Arduino-`loop()`: Watchdog, Degradation, Regeln, Relais, Status-LED, asynchrone Netzwerk-Manager, OTA, Frontpanel-Tasterabfrage | - -## Warum +| **Kern 0** (PRO_CPU) | Sensor-I/O | `SensorTask`: DS18B20-Konvertierung/-Auslesen und interne ESP32-Temperatur | +| **Kern 1** (APP_CPU) | Regelung und zustandsbehaftete I/O | Arduino-`loop()`: Watchdog, Degradation, Regeln, Relais, Status-LED, Netzwerk/OTA, MQTT-Serialisierung, Frontpanel-UI und OLED-Rendering | -Blockierende Arbeit blockierte bisher die gesamte Regelschleife. Die teuerste -Operation ist die DS18B20-Temperaturkonvertierung (`requestTemperatures()`), die bei -12-Bit-Auflösung etwa **750 ms** blockiert. In dieser Zeit warten Watchdog-Feeding, -Regelauswertung und Relais-Ansteuerung. +## Warum diese Grenze -Das Auslagern dieser I/O-Arbeit in dedizierte Tasks auf Kern 0 bringt drei Vorteile: +Eine 12-Bit-DS18B20-Konvertierung kann ungefähr 750 ms dauern. In der Arduino-Loop würde sie +Regelauswertung, Watchdog-Verarbeitung und Relaisentscheidungen blockieren. `SensorTask` entfernt genau +diese blockierende Operation aus der Regelschleife. -1. **Geringe Loop-Latenz** — die Regelschleife bleibt im niedrigen Millisekundenbereich. -2. **Isolation** — ein hängender Sensor-Bus oder ein I2C-Display kann die - sicherheitskritische Regellogik auf Kern 1 nicht mehr blockieren. -3. **Headroom** — Kapazität für zukünftige Funktionen (mehr Sensoren, Web-UI, Logging). +MQTT-Serialisierung und OLED-Rendering bleiben bewusst auf Kern 1. Beide lesen veränderlichen +Controller-Zustand wie Betriebsmodus, Regeln, Relais und Konfiguration. Ein paralleles Lesen auf Kern 0 +würde Datenrennen etwa um Arduino-`String` und Regelzustände erzeugen. Die Ausführung auf dem besitzenden +Task ist einfacher und sicherer als Locks über den gesamten Objektgraphen. ## Task-Modell -Alle I/O-Tasks werden in `setup()` vom `CoreScheduler` erzeugt und bleiben statisch -(keine dynamische Task-Erzeugung zur Laufzeit, kein Heap-Wachstum). +`CoreScheduler` erzeugt genau einen erforderlichen Worker-Task: + +| Task | Kern | Priorität | Stack | Zweck | +| ---- | ---- | --------- | ----- | ----- | +| `SensorTask` | 0 | 2 | 6 KB | DS18B20- und interne Temperaturmessung | + +Kann `SensorTask` nicht erzeugt werden, startet der Controller neu. Ein dauerhafter Fehler wird durch die +bestehende Boot-Loop-Erkennung und den Safe-Mode behandelt. + +MQTT-Anforderungen verwenden weiterhin `TelemetryQueue`. Die Queue wird jedoch aus +`PoolController::loop()` über `CoreScheduler::logStackWatermarks()` geleert. Dadurch bleiben Reihenfolge +und Entkopplung erhalten, während `MqttPublisher` auf demselben Core-1-Task läuft, dem auch der mutable +Controller-Zustand gehört. + +Beim NORVI-Display gilt dieselbe Regel: Taster, UI-Zustandsübergänge und OLED-Rendering werden durch +`DisplayCoordinator` auf Kern 1 serialisiert. + +## Sensor-Ownership + +Nach dem Start ist `SensorTask` alleiniger Besitzer aller `OneWire`- und `DallasTemperature`-Operationen. +Code auf Kern 1 scannt oder liest den Bus nicht direkt. + +`DallasTemperatureNode` stellt deshalb zwei Core-übergreifende Kanäle bereit: + +- Messwerte werden über `SensorSlots` publiziert. +- Sensor-Erkennungsdaten und ausgewählte ROM-Adressen werden in einen kleinen Cache kopiert, der durch + einen kurzen atomaren Lock geschützt ist. Web- und MQTT-Code lesen nur diesen Cache. + +Änderungen der Sensor-Zuordnung werden zur Laufzeit als pending Konfiguration gespeichert. `SensorTask` +wendet sie zu Beginn eines späteren Messzyklus an. WebPortal- oder MQTT-Aufrufe führen damit keine +OneWire-Transaktion auf Kern 1 aus. + +## DS18B20-Messzyklus + +`DallasTemperature` wird mit `setWaitForConversion(false)` konfiguriert. Ein Messzyklus besteht aus: -| Task | Kern | Priorität | Stack | Läuft auf | -| ---- | ---- | --------- | ----- | --------- | -| SensorTask | 0 | 2 | 6 KB | allen Builds | -| PublishTask | 0 | 1 | 4 KB | allen Builds | -| DisplayTask | 0 | 1 | 3 KB | nur NORVI (`#ifdef NORVI_AE01_R`) | +1. Konvertierung auf dem benötigten Bus beziehungsweise den Bussen starten. +2. `SensorTask` für 800 ms freigeben. +3. Fertige Messwerte lesen. +4. Ergebnis in `SensorSlots` publizieren und Discovery-Cache aktualisieren. -Kann ein Task nicht erzeugt werden (Heap beim Start erschöpft), läuft der -`CoreScheduler` nicht stillschweigend weiter: Fehlt SensorTask oder PublishTask, startet -der Controller neu — die Regeln dürfen nicht mit veralteten Temperaturen arbeiten. -Bleibt der Fehler bestehen, erzwingt die Boot-Loop-Erkennung den Safe-Mode (alle Relais -aus). Ein fehlender DisplayTask wird protokolliert, der Controller läuft ohne OLED-Ausgabe weiter. +Damit entfällt die frühere doppelte Wartezeit, bei der `requestTemperatures()` bereits intern blockierte +und der Worker anschließend nochmals 800 ms wartete. -FreeRTOS-Prioritäten gelten nur innerhalb eines Kerns: Die I/O-Tasks geben per -`vTaskDelay` nach und bleiben unterhalb der WiFi-Stack-Tasks auf Kern 0 — sie können -die Regelschleife auf Kern 1 also nie verdrängen. +Fehlt mindestens einer der Dallas-Sensoren oder ist sein Wert ungültig, verwendet der gemeinsame Zyklus +den kürzeren Recovery-Intervall von 5 Sekunden. Sobald beide Sensoren gültig sind, gilt wieder das +konfigurierte `loopInterval`. ## Datenfluss ```text -SensorTask (Kern 0) ── Sensor-Slots ────▶ Regelschleife (Kern 1): Regeln/Relais/Watchdog -SensorTask ── Status ────────────────────▶ DegradationManager (Kern 1) -Regelschleife ── update() + Render-Anforderung ─▶ DisplayTask (Kern 0, NORVI) -Tasterabfrage bleibt in der Regelschleife (Kern 1) — Callbacks mutieren Loop-Singletons -Regelschleife ── Telemetrie-Queue ────────▶ PublishTask (Kern 0) ──▶ MQTT -Regelschleife ── asynchrones Netzwerk/OTA ── (unverändert, Kern 1) +SensorTask (Kern 0) ── SensorSlots ──────▶ Regelschleife (Kern 1): Regeln / Relais +SensorTask (Kern 0) ── Discovery-Cache ──▶ WebPortal / MQTT / UI (Kern 1) +Regelschleife (Kern 1) ─ TelemetryQueue ─▶ MQTT-Serialisierung (Kern 1) +DisplayCoordinator: Eingabe + Zustand + Rendering ───────────▶ Kern 1 ``` -Jeder task-übergreifende Datenpfad ist **Single-Writer**: - -- Sensorwerte: Sensor-Slots — SensorTask schreibt, Regelschleife und Display lesen. - Wert und Gefunden-Flag werden zusammen in einem kurzen kritischen Abschnitt (portMUX) - kopiert, Leser erhalten immer einen konsistenten Stand einer Messung. -- Sensorstatus und Display-Flags: `std::atomic` — `volatile` sorgt weder für - Sichtbarkeit zwischen den Kernen noch für Reihenfolge. -- Taster-Eingaben: bleiben in der Regelschleife — die Taster-Callbacks mutieren - Loop-Singletons (`operationModeNode`, `poolPumpNode`); eine Abfrage auf Kern 0 - würde die Single-Writer-Regel verletzen. Ausgelagert ist nur das OLED-*Rendering* - (blockierende I2C-Arbeit). -- Telemetrie: SPSC-Ringpuffer mit fester Kapazität — Regelschleife stellt ein, - PublishTask serialisiert und publiziert. - -Die MQTT-*Verbindung* und die Web-/OTA-Manager bleiben in der Regelschleife — sie -sind bereits nicht-blockierend (`AsyncMqttClient`, asynchroner Webserver). Ausgelagert -wird nur die Telemetrie-Serialisierung (JSON-Aufbau, HA-Discovery-Payloads) in den -PublishTask. +Die entscheidende Regel ist Ownership: Hardware-Buszustand gehört `SensorTask`; veränderlicher +Controller-Zustand gehört der Regelschleife. Core-übergreifende Kommunikation verwendet begrenzte +Snapshots statt gemeinsam veränderter Objekte. ## Zuverlässigkeit -- Die Regelschleife füttert den Task-Watchdog weiterhin; I/O-Tasks füttern ihn bei - langen Wartezeiten (DS18B20-Konvertierung, OTA-Pause). -- `SystemMonitor` meldet die Stack-High-Water-Marks der Tasks, sodass die - Stack-Größen in Logs und Degradation sichtbar sind. -- Safe-Mode- und Degradations-Semantik bleiben unverändert — Sensorfehler werden dem - `DegradationManager` über einen thread-sicheren Statuskanal gemeldet. -- Während OTA pausiert der PublishTask das Publizieren, leert seine Queue aber - weiter. +- Sensorwert und Found-Status werden konsistent über `SensorSlots` übertragen. +- `DegradationManager` verwendet atomare Core-übergreifende Status-Flags. +- `SensorTask` registriert sich beim Task-Watchdog und füttert ihn. +- Stack-High-Water-Logging bleibt für den Worker verfügbar. +- OTA-Verarbeitung und MQTT-Zustandsänderungen bleiben auf Kern 1 serialisiert. ## Design-Dokument -Das vollständige Design inklusive Thread-Safety-Audit der bestehenden Singletons, -Migrationsphasen, Risiken und Erfolgskriterien liegt unter -[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md). +Das ursprüngliche Design und der Migrationsplan bleiben unter +[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md) +erhalten. Nach dem Concurrency-Review wurde die endgültige Core-Grenze bewusst enger gefasst. diff --git a/docs/multicore-architecture.md b/docs/multicore-architecture.md index b32145a4..27be2e8f 100644 --- a/docs/multicore-architecture.md +++ b/docs/multicore-architecture.md @@ -1,8 +1,8 @@ --- title: Multicore Architecture -summary: How the firmware uses both ESP32 cores — dedicated I/O tasks (sensors, display, MQTT telemetry) on Core 0 and a deterministic control loop on Core 1 +summary: How the firmware isolates blocking sensor I/O on ESP32 Core 0 while keeping mutable controller state serialized on Core 1 date: "2026-08-01" -lastmod: "2026-08-01" +lastmod: "2026-10-03" draft: false toc: true type: docs @@ -17,95 +17,98 @@ menu: ## Overview -The ESP32 has two Xtensa LX6 cores, but a single-loop Arduino sketch only uses one: -the WiFi/BT stack runs on Core 0 and the Arduino `loop()` on Core 1. Everything -else — sensor reads, display updates, rules, network, MQTT — runs serially inside -`loop()`. - -The firmware restructures this into a **task architecture with explicit core -separation**: +The firmware uses an explicit ownership model instead of moving every I/O operation to the second core. +The safety-critical controller model remains single-writer on the Arduino loop task, while the blocking +DS18B20 conversion is isolated on Core 0. | Core | Role | Contents | | ---- | ---- | -------- | -| **Core 0** (PRO_CPU) | I/O core | SensorTask (DS18B20 + internal temp), DisplayTask (OLED rendering, NORVI only), PublishTask (MQTT telemetry serialization) | -| **Core 1** (APP_CPU) | Control core | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, async network managers, OTA, front-panel button scan | - -## Why +| **Core 0** (PRO_CPU) | Sensor I/O | `SensorTask`: DS18B20 conversion/readout and internal ESP32 temperature | +| **Core 1** (APP_CPU) | Control and stateful I/O | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, network/OTA, MQTT serialization, front-panel UI and OLED rendering | -Blocking work used to stall the entire control loop. The most expensive operation is -the DS18B20 temperature conversion (`requestTemperatures()`), which blocks for about -**750 ms** at 12-bit resolution. During that time the watchdog feeding, rule -evaluation, and relay actuation all wait. +## Why this boundary -Moving that I/O to dedicated tasks on Core 0 gives three benefits: +A 12-bit DS18B20 conversion can take roughly 750 ms. Running it in the Arduino loop stalls rule +evaluation, watchdog handling and relay decisions. `SensorTask` removes that blocking conversion from +the control loop. -1. **Low loop latency** — the control loop stays in the low millisecond range. -2. **Isolation** — a hung sensor bus or I2C display can no longer block the - safety-critical control logic on Core 1. -3. **Headroom** — capacity for future features (more sensors, web UI, logging). +MQTT serialization and OLED rendering are intentionally kept on Core 1. Both read mutable controller +state such as operation mode, rules, relay state and configuration. Running those readers concurrently +with the control loop would create cross-core data races around objects such as Arduino `String` and +rule state. Keeping them on the owning task is simpler and safer than protecting the complete object +graph with locks. ## Task model -All I/O tasks are created in `setup()` by the `CoreScheduler` and stay static (no -dynamic task creation at runtime, no heap growth). +`CoreScheduler` creates one required worker task: -| Task | Core | Priority | Stack | Runs on | +| Task | Core | Priority | Stack | Purpose | | ---- | ---- | -------- | ----- | ------- | -| SensorTask | 0 | 2 | 6 KB | all builds | -| PublishTask | 0 | 1 | 4 KB | all builds | -| DisplayTask | 0 | 1 | 3 KB | NORVI only (`#ifdef NORVI_AE01_R`) | +| `SensorTask` | 0 | 2 | 6 KB | DS18B20 and internal temperature acquisition | + +If `SensorTask` cannot be created, the controller restarts. A persistent failure is handled by the +existing boot-loop detection and safe-mode path. + +MQTT requests still use `TelemetryQueue`, but the queue is drained from `PoolController::loop()` via +`CoreScheduler::logStackWatermarks()`. This preserves request coalescing/ordering while ensuring that +`MqttPublisher` executes on the same Core-1 task that owns the mutable controller model. + +The NORVI display follows the same rule: button handling, UI state transitions and OLED rendering are +serialized on Core 1 by `DisplayCoordinator`. + +## Sensor ownership + +After startup, `SensorTask` is the exclusive owner of `OneWire` and `DallasTemperature` operations. +Core-1 code never scans or reads the bus directly. + +`DallasTemperatureNode` therefore exposes two cross-core channels: -If a task cannot be created (heap exhausted at boot), `CoreScheduler` does not -continue silently: a missing SensorTask or PublishTask restarts the controller — -the rules must not run on stale temperatures. If the failure persists, the -boot-loop detection forces safe mode (all relays off). A missing DisplayTask is -logged and the controller continues without OLED rendering. +- Measurements are published through `SensorSlots`. +- Sensor discovery metadata and selected ROM addresses are copied into a small cached snapshot guarded + by a short atomic lock. Web and MQTT code read only this cache. -FreeRTOS priorities only matter within a core: the I/O tasks yield via -`vTaskDelay` and stay below the WiFi-stack tasks on Core 0, so they never preempt -the control loop on Core 1. +Runtime sensor-mapping changes are stored as pending configuration. `SensorTask` applies them at the +start of a later measurement cycle, so calls from WebPortal or MQTT never execute OneWire transactions +on Core 1. + +## DS18B20 measurement cycle + +`DallasTemperature` is configured with `setWaitForConversion(false)`. A measurement cycle is: + +1. Start conversions on the required bus or buses. +2. Yield `SensorTask` for 800 ms. +3. Read the completed values. +4. Publish the result to `SensorSlots` and refresh the discovery cache. + +This avoids the former double wait where `requestTemperatures()` blocked internally and the worker then +waited another 800 ms. + +If either Dallas sensor is missing or invalid, the common cycle uses the shorter 5-second recovery +interval. Once both sensors are valid, the configured `loopInterval` applies again. ## Data flow ```text -SensorTask (Core 0) ── sensor slots ────▶ control loop (Core 1): rules/relays/watchdog -SensorTask ── status ────────────────────▶ DegradationManager (Core 1) -control loop ── update() + render request ─▶ DisplayTask (Core 0, NORVI) -button scan stays on the control loop (Core 1) — callbacks mutate loop singletons -control loop ── telemetry queue ─────────▶ PublishTask (Core 0) ──▶ MQTT -control loop ── async network/OTA ──────── (unchanged, Core 1) +SensorTask (Core 0) ── SensorSlots ──────▶ control loop (Core 1): rules / relays +SensorTask (Core 0) ── discovery cache ──▶ WebPortal / MQTT / UI (Core 1) +control loop (Core 1) ── TelemetryQueue ─▶ MQTT serialization (Core 1) +DisplayCoordinator: input + state + render ───────────────────▶ Core 1 ``` -Every cross-task data path is **single-writer**: - -- Sensor values: sensor slots — SensorTask writes, control loop and display read. - Value and found flag are copied together under a short critical section (portMUX), - so readers always get a consistent snapshot of one measurement. -- Sensor status and display flags: `std::atomic` — `volatile` gives neither - inter-core visibility nor ordering. -- Button input: stays on the control loop — button callbacks mutate control-loop - singletons (`operationModeNode`, `poolPumpNode`), so scanning on Core 0 would - violate the single-writer rule. The OLED *rendering* (blocking I2C work) is what - runs on Core 0. -- Telemetry: fixed-capacity SPSC ring buffer — control loop enqueues, PublishTask - serializes and publishes. - -The MQTT *connection* and the web/OTA managers stay on the control loop — they are -already non-blocking (`AsyncMqttClient`, async web server). Only the telemetry -serialization (JSON build, HA Discovery payloads) is offloaded to PublishTask. +The important rule is ownership: hardware bus state belongs to `SensorTask`; mutable controller state +belongs to the control loop. Cross-core communication uses bounded snapshots instead of shared mutable +objects. ## Reliability -- The control loop keeps feeding the task watchdog; I/O tasks feed it during long - waits (DS18B20 conversion, OTA pause). -- `SystemMonitor` reports task stack high-water marks so stack sizing is visible in - logs and degradation. -- Safe mode and degradation semantics are unchanged — sensor faults are reported to - `DegradationManager` over a thread-safe status channel. -- During OTA, PublishTask pauses publishing but keeps draining its queue. +- Sensor values and found-state are transferred consistently through `SensorSlots`. +- `DegradationManager` uses atomic cross-core status flags. +- `SensorTask` registers with and feeds the task watchdog. +- Stack high-water logging remains available for the worker task. +- OTA handling and MQTT state mutation stay serialized on Core 1. ## Design document -The full design, including the thread-safety audit of the existing singletons, -migration phases, risks, and success criteria, lives in +The original design and migration plan remain available under [`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md). +The implementation deliberately narrows the final cross-core boundary after the concurrency review. From a0bf33b7a8a6c699bda2b70ba8a7cd6fa5b5e720 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sat, 3 Oct 2026 23:46:11 +0200 Subject: [PATCH 25/28] style: format CoreScheduler ownership fix --- src/CoreScheduler.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp index b2e31952..e6331aaa 100644 --- a/src/CoreScheduler.cpp +++ b/src/CoreScheduler.cpp @@ -25,8 +25,7 @@ void CoreScheduler::begin() { const BaseType_t core0 = PRO_CPU_NUM; if (!SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0)) { - LOG_ERROR("✖ SensorTask could not be created (free heap %u B) — restarting\n", - static_cast(ESP.getFreeHeap())); + LOG_ERROR("✖ SensorTask could not be created (free heap %u B) — restarting\n", static_cast(ESP.getFreeHeap())); Serial.flush(); // A persistent failure is caught by boot-loop detection and forces safe mode. ESP.restart(); From 72c60b2cc64bfa6d1e1128bac792975f1ecdd700 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 4 Oct 2026 18:09:49 +0200 Subject: [PATCH 26/28] refactor: remove obsolete multicore worker artifacts --- src/CoreScheduler.hpp | 18 +---- src/DisplayCoordinator.cpp | 38 +--------- src/DisplayCoordinator.hpp | 9 +-- src/DisplayTask.cpp | 61 ---------------- src/DisplayTask.hpp | 37 ---------- src/PublishTask.cpp | 62 ---------------- src/PublishTask.hpp | 34 --------- src/TaskStartupPolicy.hpp | 42 ----------- src/TelemetryQueue.cpp | 2 +- src/TelemetryQueue.hpp | 23 +++--- test/native/CMakeLists.txt | 1 - test/native/mocks/CoreScheduler.hpp | 6 +- test/native/tests/test_main.cpp | 2 - .../native/tests/test_task_startup_policy.cpp | 73 ------------------- 14 files changed, 23 insertions(+), 385 deletions(-) delete mode 100644 src/DisplayTask.cpp delete mode 100644 src/DisplayTask.hpp delete mode 100644 src/PublishTask.cpp delete mode 100644 src/PublishTask.hpp delete mode 100644 src/TaskStartupPolicy.hpp delete mode 100644 test/native/tests/test_task_startup_policy.cpp diff --git a/src/CoreScheduler.hpp b/src/CoreScheduler.hpp index b12735da..d7a6d48b 100644 --- a/src/CoreScheduler.hpp +++ b/src/CoreScheduler.hpp @@ -15,29 +15,19 @@ namespace PoolController { /** * @brief Creates and tracks the dedicated sensor task pinned to Core 0. * - * Stateful MQTT serialization and OLED rendering intentionally stay on the - * Arduino control-loop task on Core 1. They traverse mutable controller state - * and therefore must not run concurrently with the single-writer control loop. - * Sensor I/O is isolated on Core 0 and publishes readings through SensorSlots. + * Mutable application state, MQTT serialization and OLED rendering remain on + * the Arduino control-loop task on Core 1. Sensor I/O is the only production + * workload moved to Core 0 and publishes readings through SensorSlots. */ class CoreScheduler { public: static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; - static constexpr uint8_t TASK_PRIORITY_PUBLISH = 1; - static constexpr uint8_t TASK_PRIORITY_DISPLAY = 1; static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; - static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; - static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; /** @brief Create the Core-0 sensor task. Call once after initializeController(). */ static void begin(); - /** - * @brief Service queued telemetry on Core 1 and periodically log worker stack watermarks. - * - * PoolController::loop() calls this once per iteration, so MQTT publishing - * remains serialized with all other mutable control state. - */ + /** @brief Periodically log the SensorTask stack high-water mark. */ static void logStackWatermarks(); }; diff --git a/src/DisplayCoordinator.cpp b/src/DisplayCoordinator.cpp index 18cdf458..03589f38 100644 --- a/src/DisplayCoordinator.cpp +++ b/src/DisplayCoordinator.cpp @@ -11,8 +11,6 @@ #ifdef NORVI_AE01_R #include -#include -#include #include "ConfigManager.hpp" #include "NetworkManager.hpp" @@ -35,9 +33,6 @@ UiEvent eventQueue[EVENT_CAPACITY]{}; size_t eventHead = 0; size_t eventCount = 0; -StaticSemaphore_t stateMutexStorage; -SemaphoreHandle_t stateMutex = nullptr; - void enqueueEvent(UiEvent event) { if (eventCount >= EVENT_CAPACITY) { Serial.println("⚠ DisplayCoordinator: UI event queue full — dropping event"); @@ -150,12 +145,6 @@ void processEvent(UiEvent event) { } // namespace void DisplayCoordinator::begin() { - stateMutex = xSemaphoreCreateMutexStatic(&stateMutexStorage); - if (stateMutex == nullptr) { - Serial.println("✖ DisplayCoordinator: failed to create state mutex"); - return; - } - NorviOledDisplay::begin(); NorviButtonHandler::begin(); @@ -169,36 +158,18 @@ void DisplayCoordinator::begin() { } void DisplayCoordinator::loop() { - // Button sampling, state transitions and rendering all remain on Core 1. - // This preserves the single-writer rule for operationModeNode, relay state, - // NetworkManager and ConfigManager which are read while drawing pages. + // Button sampling, state transitions and rendering all run on the Arduino + // control-loop task. This preserves single-writer ownership of mutable UI, + // operation-mode, relay, network and configuration state. NorviButtonHandler::loop(); - if (stateMutex == nullptr || xSemaphoreTake(stateMutex, 0) != pdTRUE) { - return; - } - UiEvent event; while (dequeueEvent(event)) { processEvent(event); } + NorviOledDisplay::update(); NorviOledDisplay::render(); - - xSemaphoreGive(stateMutex); -} - -void DisplayCoordinator::render() { - if (stateMutex == nullptr) { - return; - } - - // Kept as a serialized compatibility entry point. CoreScheduler no longer - // creates a separate DisplayTask, so production rendering happens in loop(). - if (xSemaphoreTake(stateMutex, portMAX_DELAY) == pdTRUE) { - NorviOledDisplay::render(); - xSemaphoreGive(stateMutex); - } } } // namespace PoolController @@ -208,7 +179,6 @@ void DisplayCoordinator::render() { namespace PoolController { void DisplayCoordinator::begin() {} void DisplayCoordinator::loop() {} -void DisplayCoordinator::render() {} } // namespace PoolController #endif diff --git a/src/DisplayCoordinator.hpp b/src/DisplayCoordinator.hpp index 7cf2323e..b9306af4 100644 --- a/src/DisplayCoordinator.hpp +++ b/src/DisplayCoordinator.hpp @@ -3,7 +3,7 @@ /** * @file DisplayCoordinator.hpp - * @brief Serializes NORVI UI state between the control loop and DisplayTask. + * @brief Owns NORVI button, UI-state and OLED rendering on Core 1. */ #pragma once @@ -12,14 +12,11 @@ namespace PoolController { class DisplayCoordinator { public: - /** Initialize the OLED, button callbacks, and state synchronization. */ + /** Initialize the OLED and front-panel button callbacks. */ static void begin(); - /** Poll buttons and advance UI state without ever blocking the control loop. */ + /** Poll buttons, apply UI events and render on the control-loop task. */ static void loop(); - - /** Render from DisplayTask while owning the UI state. */ - static void render(); }; } // namespace PoolController diff --git a/src/DisplayTask.cpp b/src/DisplayTask.cpp deleted file mode 100644 index 82bd5ab1..00000000 --- a/src/DisplayTask.cpp +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// SPDX-License-Identifier: MIT - -/** - * @file DisplayTask.cpp - * @brief OLED render task (NORVI_AE01_R only). - */ - -#include "DisplayTask.hpp" - -#include -#include -#include - -#include - -#include "DisplayCoordinator.hpp" -#include "SystemMonitor.hpp" - -namespace PoolController { - -namespace { -TaskHandle_t displayTaskHandle = nullptr; -std::atomic renderRequested{false}; -constexpr uint32_t FALLBACK_RENDER_INTERVAL_MS = 2000; -} // namespace - -void displayTaskFunc(void *) { - const bool watchdogRegistered = SystemMonitor::registerCurrentTaskWithWatchdog(); - uint32_t lastRenderMs = millis(); - - for (;;) { - const uint32_t now = millis(); - const bool requested = renderRequested.exchange(false, std::memory_order_acq_rel); - if (requested || now - lastRenderMs >= FALLBACK_RENDER_INTERVAL_MS) { - lastRenderMs = now; - DisplayCoordinator::render(); - } - if (watchdogRegistered) { - SystemMonitor::feedWatchdogFromTask(); - } - vTaskDelay(pdMS_TO_TICKS(100)); - } -} - -bool DisplayTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { - return xTaskCreatePinnedToCore(displayTaskFunc, "display", stackBytes, nullptr, priority, &displayTaskHandle, core) == pdPASS; -} - -void DisplayTask::requestRender() { - renderRequested.store(true, std::memory_order_release); -} - -void DisplayTask::logStackWatermark() { - if (displayTaskHandle != nullptr) { - Serial.printf( - " DisplayTask stack high-water: %u B\n", static_cast(uxTaskGetStackHighWaterMark(displayTaskHandle))); - } -} - -} // namespace PoolController diff --git a/src/DisplayTask.hpp b/src/DisplayTask.hpp deleted file mode 100644 index 4a765b7d..00000000 --- a/src/DisplayTask.hpp +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// SPDX-License-Identifier: MIT - -/** - * @file DisplayTask.hpp - * @brief Core-0 task rendering the NORVI OLED display (NORVI_AE01_R only). - */ - -#pragma once - -#include - -#include -#include - -namespace PoolController { - -/** - * @brief Renders the OLED display on Core 0. - * - * The control loop advances the display state machine and requests renders; - * this task owns the I2C SSD1306 work so a hung display can never stall - * the control loop. NORVI_AE01_R only. - */ -class DisplayTask { -public: - /** @brief Create and start the task pinned to the given core. @return false if it could not be created. */ - static bool start(uint8_t priority, uint16_t stackBytes, BaseType_t core); - - /** @brief Request a render on the next task tick. */ - static void requestRender(); - - /** @brief Log the task's stack high-water mark. */ - static void logStackWatermark(); -}; - -} // namespace PoolController diff --git a/src/PublishTask.cpp b/src/PublishTask.cpp deleted file mode 100644 index f761dedb..00000000 --- a/src/PublishTask.cpp +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// SPDX-License-Identifier: MIT - -/** - * @file PublishTask.cpp - * @brief MQTT publish task draining the telemetry queue. - */ - -#include "PublishTask.hpp" - -#include -#include -#include - -#include "TelemetryQueue.hpp" -#include "MqttPublisher.hpp" -#include "OtaUpdater.hpp" -#include "SystemMonitor.hpp" - -namespace PoolController { - -namespace { -TaskHandle_t publishTaskHandle = nullptr; -} // namespace - -void publishTaskFunc(void *) { - const bool watchdogRegistered = SystemMonitor::registerCurrentTaskWithWatchdog(); - - for (;;) { - PublishRequestKind kind; - while (TelemetryQueue::instance().dequeue(kind)) { - // Pause during OTA updates, but keep draining to avoid queue buildup. - if (!OtaUpdater::isUpdateInProgress()) { - if (kind == PublishRequestKind::DISCOVERY) { - MqttPublisher::publishDiscovery(); - } else { - MqttPublisher::publishStates(); - } - } - if (watchdogRegistered) { - SystemMonitor::feedWatchdogFromTask(); - } - } - if (watchdogRegistered) { - SystemMonitor::feedWatchdogFromTask(); - } - vTaskDelay(pdMS_TO_TICKS(50)); - } -} - -bool PublishTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { - return xTaskCreatePinnedToCore(publishTaskFunc, "publish", stackBytes, nullptr, priority, &publishTaskHandle, core) == pdPASS; -} - -void PublishTask::logStackWatermark() { - if (publishTaskHandle != nullptr) { - Serial.printf( - " PublishTask stack high-water: %u B\n", static_cast(uxTaskGetStackHighWaterMark(publishTaskHandle))); - } -} - -} // namespace PoolController diff --git a/src/PublishTask.hpp b/src/PublishTask.hpp deleted file mode 100644 index 1ec37549..00000000 --- a/src/PublishTask.hpp +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// SPDX-License-Identifier: MIT - -/** - * @file PublishTask.hpp - * @brief Core-0 task that serializes and publishes MQTT telemetry. - */ - -#pragma once - -#include - -#include -#include - -namespace PoolController { - -/** - * @brief Drains the telemetry queue and performs MQTT serialization on Core 0. - * - * The control loop only enqueues publish requests (non-blocking); the heavy - * JSON/HA-discovery serialization and the AsyncMqttClient::publish() calls - * run here. AsyncMqttClient::publish() is non-blocking from the library side. - */ -class PublishTask { -public: - /** @brief Create and start the task pinned to the given core. @return false if it could not be created. */ - static bool start(uint8_t priority, uint16_t stackBytes, BaseType_t core); - - /** @brief Log the task's stack high-water mark. */ - static void logStackWatermark(); -}; - -} // namespace PoolController diff --git a/src/TaskStartupPolicy.hpp b/src/TaskStartupPolicy.hpp deleted file mode 100644 index 70c4e9b0..00000000 --- a/src/TaskStartupPolicy.hpp +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// SPDX-License-Identifier: MIT - -/** - * @file TaskStartupPolicy.hpp - * @brief What to do when a Core-0 task could not be created. - * - * xTaskCreatePinnedToCore() only fails when the heap cannot hold the task - * stack. Sensor reads and MQTT publishing no longer run in the control loop, - * so without SensorTask the rules would work on stale temperatures and - * without PublishTask Home Assistant would get no telemetry. These are - * critical: the controller restarts. A persistent failure ends in the - * boot-loop detection (3 consecutive boots), which forces safe mode with all - * relays off. The display task is not needed for control — the controller - * continues without OLED rendering. - * - * Header-only and hardware-independent for native tests. - */ - -#pragma once - -#include - -namespace PoolController { - -enum class TaskStartupAction : uint8_t { - CONTINUE, ///< All tasks running - CONTINUE_DEGRADED, ///< Non-critical task missing (display) - RESTART, ///< Critical task missing (sensor or publish) -}; - -inline TaskStartupAction decideTaskStartupAction(bool sensorStarted, bool publishStarted, bool displayStarted) { - if (!sensorStarted || !publishStarted) { - return TaskStartupAction::RESTART; - } - if (!displayStarted) { - return TaskStartupAction::CONTINUE_DEGRADED; - } - return TaskStartupAction::CONTINUE; -} - -} // namespace PoolController diff --git a/src/TelemetryQueue.cpp b/src/TelemetryQueue.cpp index 9b78bef9..cdae4fcb 100644 --- a/src/TelemetryQueue.cpp +++ b/src/TelemetryQueue.cpp @@ -3,7 +3,7 @@ /** * @file TelemetryQueue.cpp - * @brief SPSC publish-request ring buffer implementation. + * @brief Bounded publish-request ring buffer implementation. */ #include "TelemetryQueue.hpp" diff --git a/src/TelemetryQueue.hpp b/src/TelemetryQueue.hpp index babfb107..99f2b492 100644 --- a/src/TelemetryQueue.hpp +++ b/src/TelemetryQueue.hpp @@ -3,7 +3,7 @@ /** * @file TelemetryQueue.hpp - * @brief Lock-free single-producer/single-consumer queue for MQTT publish requests. + * @brief Bounded queue for deferred MQTT publish requests. */ #pragma once @@ -21,12 +21,12 @@ enum class PublishRequestKind : uint8_t { }; /** - * @brief SPSC (single-producer, single-consumer) ring buffer of publish requests. + * @brief Fixed-capacity ring buffer of publish requests. * * Non-blocking: enqueue on a full queue drops the request and returns false * (the periodic publish cadence simply skips a beat — safe by design). - * Uses a classic atomic head/tail lock-free ring; safe with one writer - * (control loop) and one reader (PublishTask). + * Publishing is serviced on the Core-1 control-loop task so MqttPublisher never + * reads mutable controller state concurrently from another core. */ class TelemetryQueue { public: @@ -35,25 +35,22 @@ class TelemetryQueue { /** @brief Construct an empty queue. */ TelemetryQueue() { reset(); } - /** - * @brief Process-wide singleton used by the control loop and PublishTask. - * @note Static local is inline (C++17) — one instance across translation units. - */ + /** @brief Process-wide queue for deferred telemetry requests. */ static TelemetryQueue &instance() { static TelemetryQueue queue; return queue; } - /** @brief Producer side: enqueue a publish request. @return false if full (dropped). */ + /** @brief Enqueue a publish request. @return false if full (dropped). */ bool enqueue(PublishRequestKind kind); - /** @brief Consumer side: dequeue a publish request. @return false if empty. */ + /** @brief Dequeue a publish request. @return false if empty. */ bool dequeue(PublishRequestKind &kind); /** @brief Number of requests currently queued. */ size_t count() const; - /** @brief Empty the queue (tests only — must not run while tasks are active). */ + /** @brief Empty the queue (tests only — must not run while in use). */ void reset(); private: @@ -61,8 +58,8 @@ class TelemetryQueue { /// [0, CAPACITY] and the storage needs CAPACITY + 1 slots. static constexpr size_t SLOTS = CAPACITY + 1; - std::atomic head_{0}; ///< Consumer index (only consumer writes) - std::atomic tail_{0}; ///< Producer index (only producer writes) + std::atomic head_{0}; + std::atomic tail_{0}; PublishRequestKind items_[SLOTS] = {}; ///< Fixed ring storage }; diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index a95fdf04..ac80471c 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -91,7 +91,6 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_telemetry_queue.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_cycle.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_task_startup_policy.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_core_scheduler.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_degradation_manager.cpp ) diff --git a/test/native/mocks/CoreScheduler.hpp b/test/native/mocks/CoreScheduler.hpp index 0f8fb20f..0f0b4c37 100644 --- a/test/native/mocks/CoreScheduler.hpp +++ b/test/native/mocks/CoreScheduler.hpp @@ -9,16 +9,12 @@ namespace PoolController { /** * @brief Native test double for CoreScheduler. - * Captures begin() parameters so tests can assert the planned values. + * Captures SensorTask parameters so tests can assert the production values. */ class CoreScheduler { public: static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; - static constexpr uint8_t TASK_PRIORITY_PUBLISH = 1; - static constexpr uint8_t TASK_PRIORITY_DISPLAY = 1; static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; - static constexpr uint16_t TASK_STACK_PUBLISH = 4 * 1024; - static constexpr uint16_t TASK_STACK_DISPLAY = 3 * 1024; static void begin(); static void logStackWatermarks(); diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index 6bd7633f..3b654ad2 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -51,7 +51,6 @@ extern int run_calibration_manager_tests(); extern int run_telemetry_queue_tests(); extern int run_sensor_slots_tests(); extern int run_sensor_cycle_tests(); -extern int run_task_startup_policy_tests(); extern int run_core_scheduler_tests(); extern int run_degradation_manager_tests(); @@ -77,7 +76,6 @@ int main() { total += run_telemetry_queue_tests(); total += run_sensor_slots_tests(); total += run_sensor_cycle_tests(); - total += run_task_startup_policy_tests(); total += run_core_scheduler_tests(); total += run_degradation_manager_tests(); (void)total; diff --git a/test/native/tests/test_task_startup_policy.cpp b/test/native/tests/test_task_startup_policy.cpp deleted file mode 100644 index 07333064..00000000 --- a/test/native/tests/test_task_startup_policy.cpp +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter -// SPDX-License-Identifier: MIT - -/** - * @file test_task_startup_policy.cpp - * @brief Tests for decideTaskStartupAction() — a Core-0 task that cannot be - * created must lead to a defined action, never to silent operation - * without fresh sensor data (review #170). - */ - -#include - -#include "TaskStartupPolicy.hpp" - -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) ASSERT_TRUE((a) == (b)) - -using PoolController::decideTaskStartupAction; -using PoolController::TaskStartupAction; - -static int test_all_started() { - test_begin("TaskStartupPolicy", "all tasks started → continue"); - ASSERT_EQ(decideTaskStartupAction(true, true, true), TaskStartupAction::CONTINUE); - return 0; -} - -static int test_sensor_task_missing() { - test_begin("TaskStartupPolicy", "missing SensorTask → restart"); - ASSERT_EQ(decideTaskStartupAction(false, true, true), TaskStartupAction::RESTART); - ASSERT_EQ(decideTaskStartupAction(false, true, false), TaskStartupAction::RESTART); - return 0; -} - -static int test_publish_task_missing() { - test_begin("TaskStartupPolicy", "missing PublishTask → restart"); - ASSERT_EQ(decideTaskStartupAction(true, false, true), TaskStartupAction::RESTART); - ASSERT_EQ(decideTaskStartupAction(false, false, false), TaskStartupAction::RESTART); - return 0; -} - -static int test_display_task_missing() { - test_begin("TaskStartupPolicy", "missing DisplayTask → continue degraded"); - ASSERT_EQ(decideTaskStartupAction(true, true, false), TaskStartupAction::CONTINUE_DEGRADED); - return 0; -} - -int run_task_startup_policy_tests() { - int passed = 0; - int failed = 0; - int (*tests[])() = {test_all_started, test_sensor_task_missing, test_publish_task_missing, test_display_task_missing}; - for (auto test : tests) { - if (test() == 0) { - passed++; - } else { - failed++; - } - } - test_suite_end("TaskStartupPolicy", passed, failed); - return failed; -} From ca2b155ab9beedf50c94984d3f915ae29a868522 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 4 Oct 2026 18:15:13 +0200 Subject: [PATCH 27/28] docs: align multicore ownership with final runtime model --- docs/multicore-architecture.de.md | 18 ++++++++++-------- docs/multicore-architecture.md | 19 +++++++++++-------- src/CoreScheduler.cpp | 24 ++---------------------- src/PoolController.cpp | 29 ++++++++++++++++++++++++----- 4 files changed, 47 insertions(+), 43 deletions(-) diff --git a/docs/multicore-architecture.de.md b/docs/multicore-architecture.de.md index c1176aa9..8c7f29e1 100644 --- a/docs/multicore-architecture.de.md +++ b/docs/multicore-architecture.de.md @@ -2,7 +2,7 @@ title: Multicore-Architektur summary: Wie die Firmware blockierende Sensor-I/O auf ESP32-Kern 0 isoliert und veränderlichen Controller-Zustand auf Kern 1 serialisiert date: "2026-08-01" -lastmod: "2026-10-03" +lastmod: "2026-10-04" draft: false toc: true type: docs @@ -48,13 +48,13 @@ Task ist einfacher und sicherer als Locks über den gesamten Objektgraphen. Kann `SensorTask` nicht erzeugt werden, startet der Controller neu. Ein dauerhafter Fehler wird durch die bestehende Boot-Loop-Erkennung und den Safe-Mode behandelt. -MQTT-Anforderungen verwenden weiterhin `TelemetryQueue`. Die Queue wird jedoch aus -`PoolController::loop()` über `CoreScheduler::logStackWatermarks()` geleert. Dadurch bleiben Reihenfolge -und Entkopplung erhalten, während `MqttPublisher` auf demselben Core-1-Task läuft, dem auch der mutable -Controller-Zustand gehört. +MQTT-Anforderungen verwenden weiterhin `TelemetryQueue`. `PoolController::loop()` leert die Queue direkt +auf Kern 1 und ruft dort `MqttPublisher` auf. `CoreScheduler` ist ausschließlich für den Lebenszyklus von +`SensorTask` und dessen Stack-High-Water-Logging zuständig. Beim NORVI-Display gilt dieselbe Regel: Taster, UI-Zustandsübergänge und OLED-Rendering werden durch -`DisplayCoordinator` auf Kern 1 serialisiert. +`DisplayCoordinator` auf Kern 1 serialisiert. Die veralteten Worker-Implementierungen `PublishTask` und +`DisplayTask` wurden entfernt, nachdem das Concurrency-Review die Ownership-Grenze enger gezogen hat. ## Sensor-Ownership @@ -108,8 +108,10 @@ Snapshots statt gemeinsam veränderter Objekte. - Stack-High-Water-Logging bleibt für den Worker verfügbar. - OTA-Verarbeitung und MQTT-Zustandsänderungen bleiben auf Kern 1 serialisiert. -## Design-Dokument +## Historische Design-Dokumente Das ursprüngliche Design und der Migrationsplan bleiben unter [`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md) -erhalten. Nach dem Concurrency-Review wurde die endgültige Core-Grenze bewusst enger gefasst. +und `docs/superpowers/plans/2026-08-01-multicore-task-architecture.md` erhalten. Sie dokumentieren den +früheren Drei-Worker-Entwurf und bleiben als Implementierungshistorie bestehen. Diese Seite beschreibt +die maßgebliche Laufzeitarchitektur nach dem Concurrency-Review. diff --git a/docs/multicore-architecture.md b/docs/multicore-architecture.md index 27be2e8f..724b6f9c 100644 --- a/docs/multicore-architecture.md +++ b/docs/multicore-architecture.md @@ -2,7 +2,7 @@ title: Multicore Architecture summary: How the firmware isolates blocking sensor I/O on ESP32 Core 0 while keeping mutable controller state serialized on Core 1 date: "2026-08-01" -lastmod: "2026-10-03" +lastmod: "2026-10-04" draft: false toc: true type: docs @@ -49,12 +49,13 @@ graph with locks. If `SensorTask` cannot be created, the controller restarts. A persistent failure is handled by the existing boot-loop detection and safe-mode path. -MQTT requests still use `TelemetryQueue`, but the queue is drained from `PoolController::loop()` via -`CoreScheduler::logStackWatermarks()`. This preserves request coalescing/ordering while ensuring that -`MqttPublisher` executes on the same Core-1 task that owns the mutable controller model. +MQTT requests still use `TelemetryQueue`. `PoolController::loop()` drains that queue directly on Core 1 +and invokes `MqttPublisher` there. `CoreScheduler` is responsible only for `SensorTask` lifecycle and +stack high-water logging. The NORVI display follows the same rule: button handling, UI state transitions and OLED rendering are -serialized on Core 1 by `DisplayCoordinator`. +serialized on Core 1 by `DisplayCoordinator`. The obsolete `PublishTask` and `DisplayTask` worker +implementations were removed after the concurrency review narrowed the ownership boundary. ## Sensor ownership @@ -107,8 +108,10 @@ objects. - Stack high-water logging remains available for the worker task. - OTA handling and MQTT state mutation stay serialized on Core 1. -## Design document +## Historical design documents The original design and migration plan remain available under -[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md). -The implementation deliberately narrows the final cross-core boundary after the concurrency review. +[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md) +and `docs/superpowers/plans/2026-08-01-multicore-task-architecture.md`. They document the earlier +three-worker proposal and are retained as implementation history. This page describes the authoritative +runtime architecture after the concurrency review. diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp index e6331aaa..6c8b4348 100644 --- a/src/CoreScheduler.cpp +++ b/src/CoreScheduler.cpp @@ -3,7 +3,7 @@ /** * @file CoreScheduler.cpp - * @brief Core-0 sensor task launcher and Core-1 telemetry service. + * @brief Starts and monitors the dedicated Core-0 sensor task. */ #include "CoreScheduler.hpp" @@ -13,15 +13,12 @@ #include #include "LogCapture.hpp" -#include "MqttPublisher.hpp" -#include "OtaUpdater.hpp" #include "SensorTask.hpp" -#include "TelemetryQueue.hpp" namespace PoolController { void CoreScheduler::begin() { - // Core 0 = PRO_CPU_NUM (I/O core); Core 1 = APP_CPU_NUM (control loop). + // Core 0 = PRO_CPU_NUM (sensor I/O); Core 1 = APP_CPU_NUM (control loop). const BaseType_t core0 = PRO_CPU_NUM; if (!SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0)) { @@ -33,23 +30,6 @@ void CoreScheduler::begin() { } void CoreScheduler::logStackWatermarks() { - // This method is invoked from PoolController::loop() on Core 1. Drain the - // request queue here so MqttPublisher never races the mutable control model. - PublishRequestKind kind; - while (TelemetryQueue::instance().dequeue(kind)) { - // Preserve the previous OTA behavior: requests are drained but not sent - // while the updater owns the network path. - if (OtaUpdater::isUpdateInProgress()) { - continue; - } - - if (kind == PublishRequestKind::DISCOVERY) { - MqttPublisher::publishDiscovery(); - } else { - MqttPublisher::publishStates(); - } - } - static uint32_t lastLog = 0; if (millis() - lastLog < 60000) { return; diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 0e2db512..65cab68c 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -135,6 +135,24 @@ static void loadSensorAddressMapping() { } } +/** @brief Service deferred MQTT publish requests on the Core-1 owner task. */ +static void serviceTelemetryQueue() { + PublishRequestKind kind; + while (TelemetryQueue::instance().dequeue(kind)) { + // Preserve the previous OTA behavior: requests are drained but not sent + // while the updater owns the network path. + if (OtaUpdater::isUpdateInProgress()) { + continue; + } + + if (kind == PublishRequestKind::DISCOVERY) { + MqttPublisher::publishDiscovery(); + } else { + MqttPublisher::publishStates(); + } + } +} + /** * @brief Construct the singleton context. * Stores the instance pointer for internal access. All subsystems are @@ -259,8 +277,8 @@ auto PoolControllerContext::setup() -> void { ConfigManager::begin(); #ifdef NORVI_AE01_R - // Own all NORVI UI state transitions through one coordinator. Button - // callbacks stay on Core 1; OLED rendering remains on Core 0. + // Button sampling, UI transitions and OLED rendering stay on the Core-1 + // owner task through one coordinator. DisplayCoordinator::begin(); #endif @@ -300,13 +318,13 @@ auto PoolControllerContext::setup() -> void { operationModeNode.loadState(); ConfigManager::logOtaTransition(); - // Start Core-0 I/O tasks (sensors, display, publish). + // Start the dedicated Core-0 sensor I/O task. CoreScheduler::begin(); Serial.printf("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); } -/** @brief Main control loop — temperature acquisition and I/O run on worker tasks. */ +/** @brief Main control loop — only sensor acquisition runs on the Core-0 worker. */ auto PoolControllerContext::loop() -> void { SystemMonitor::feedWatchdog(); SystemMonitor::checkMemory(); @@ -367,12 +385,13 @@ auto PoolControllerContext::loop() -> void { wasMqttConnected = false; } - // Periodically enqueue telemetry; serialization runs on PublishTask/Core 0. + // Periodically enqueue telemetry; serialization is serviced below on Core 1. if (currentMqttState && Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); } + serviceTelemetryQueue(); CoreScheduler::logStackWatermarks(); } From 3f00319c3f0c063af6e7584aed5f7c35b6bae89a Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 4 Oct 2026 18:17:14 +0200 Subject: [PATCH 28/28] docs: fix NORVI display ownership comments --- src/NorviOledDisplay.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/NorviOledDisplay.hpp b/src/NorviOledDisplay.hpp index ef7b6319..4a6d142d 100644 --- a/src/NorviOledDisplay.hpp +++ b/src/NorviOledDisplay.hpp @@ -83,7 +83,8 @@ class NorviOledDisplay { /** * @brief Redraw the current page and push to the OLED over I2C. - * Runs on DisplayTask (Core 0). Reads temps from SensorSlots. + * Runs on the Core-1 control task via DisplayCoordinator and reads + * temperatures from SensorSlots. */ static void render(); @@ -225,8 +226,7 @@ class NorviOledDisplay { // ═════════════════════════════════════════════════════════════════════ static Page currentPage_; - // Written by the control loop (Core 1) and read/reset by DisplayTask - // (Core 0): std::atomic for inter-core visibility and ordering. + // Display timing/redraw flags are serialized by DisplayCoordinator on Core 1. static std::atomic lastUpdateMs_; static constexpr uint32_t UPDATE_INTERVAL_MS{2000}; static std::atomic forceRedraw_;