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/multicore-architecture.de.md b/docs/multicore-architecture.de.md new file mode 100644 index 00000000..768e943d --- /dev/null +++ b/docs/multicore-architecture.de.md @@ -0,0 +1,105 @@ +--- +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-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 + +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 ── 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) +``` + +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). +- 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. + +## 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..00984da4 --- /dev/null +++ b/docs/multicore-architecture.md @@ -0,0 +1,104 @@ +--- +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 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 + +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 ── 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) +``` + +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). +- 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. + +## 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). 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..e918dde7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-multicore-task-architecture.md @@ -0,0 +1,1512 @@ +# 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). + +- [x] **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(); +} +``` + +- [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. + +- [x] **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 +``` + +- [x] **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. + +- [x] **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. + +- [x] **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(); +} +``` + +- [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. + +- [x] **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 +``` + +- [x] **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. + +- [x] **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`. + +- [x] **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(); +``` + +- [x] **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; +} +``` + +- [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): + +```cpp + void beginMeasurement() {} + void finishMeasurement() {} +``` + +- [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). + +- [x] **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). + +- [x] **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. + +- [x] **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`. + +- [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: + +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`. + +- [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. + +- [x] **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 +``` + +- [x] **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. + +- [x] **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. + +- [x] **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. + +- [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 +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. + +- [x] **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; + } +``` + +- [x] **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();`. + +- [x] **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`. + +- [x] **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. + +- [x] **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). + +- [x] **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`. + +- [x] **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 +``` + +- [x] **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. + +- [x] **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. + +- [x] **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()`. + +- [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): + +```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. + +- [x] **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). + +- [x] **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. + +- [x] **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) + +- [x] **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. + +- [x] **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. + +- [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`. + +- [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. + +- [x] **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. 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. diff --git a/src/CoreScheduler.cpp b/src/CoreScheduler.cpp new file mode 100644 index 00000000..bd6cfd18 --- /dev/null +++ b/src/CoreScheduler.cpp @@ -0,0 +1,47 @@ +// 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" +#include "PublishTask.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); + 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() { + 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 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/DallasTemperatureNode.cpp b/src/DallasTemperatureNode.cpp index 06a675c3..2d2f9c4a 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: 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(); + } +} - 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 { +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(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; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - Serial.printf(" ◦ Pool Temp = %.1f°C\n", _temperature); + foundAny = true; } } - } else if (numberOfDevices > 0) { - // ── Dedicated bus mode (standard) ────────────────────────────────── - Serial.printf("〽 Reading Dallas sensor: %s\n", _id); + } + _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); + } + 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); - PoolController::SystemMonitor::feedWatchdog(); - activeSensor->requestTemperatures(); - PoolController::SystemMonitor::feedWatchdog(); - - 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_) { + 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 { - // ── No sensor found — rescan ────────────────────────────────────── - Serial.println("No Sensor found on bus! Rescanning..."); - PoolController::DegradationManager::reportSensorStatus(_id, 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; - } + 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/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/DisplayTask.cpp b/src/DisplayTask.cpp new file mode 100644 index 00000000..cca2acc1 --- /dev/null +++ b/src/DisplayTask.cpp @@ -0,0 +1,49 @@ +// 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 ea5338db..da63471f 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -37,9 +37,13 @@ #include "StatusLed.hpp" +#include "CoreScheduler.hpp" +#include "TelemetryQueue.hpp" + #ifdef NORVI_AE01_R #include "NorviOledDisplay.hpp" #include "NorviButtonHandler.hpp" +#include "DisplayTask.hpp" #endif #include "Config.hpp" @@ -420,6 +424,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 +438,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 @@ -480,15 +489,13 @@ 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 // Run drivers & logic rules - solarTemperatureNode.loop(); - poolTemperatureNode.loop(); - ctrlTemperatureNode.loop(); poolPumpNode.loop(); solarPumpNode.loop(); operationModeNode.loop(); @@ -497,19 +504,22 @@ 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). + 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/PublishTask.cpp b/src/PublishTask.cpp new file mode 100644 index 00000000..e4e3fe8f --- /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 diff --git a/src/SensorSlots.cpp b/src/SensorSlots.cpp new file mode 100644 index 00000000..bdbdb72b --- /dev/null +++ b/src/SensorSlots.cpp @@ -0,0 +1,42 @@ +// 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/SensorTask.cpp b/src/SensorTask.cpp new file mode 100644 index 00000000..c3acf376 --- /dev/null +++ b/src/SensorTask.cpp @@ -0,0 +1,72 @@ +// 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" +#include "SystemMonitor.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)); + // Feed from the task context so long I/O waits can't starve the WDT. + SystemMonitor::feedWatchdogFromTask(); + 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/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/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..fb9de494 --- /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..435236bb 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) @@ -63,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 @@ -75,6 +78,10 @@ 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 + ${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/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/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; } 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_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 dfc0725f..7dfbca4d 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -106,6 +106,10 @@ 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(); +extern int run_core_scheduler_tests(); +extern int run_degradation_manager_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -120,6 +124,10 @@ 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(); + 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); 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; +}