diff --git a/docs/multicore-architecture.de.md b/docs/multicore-architecture.de.md new file mode 100644 index 00000000..8c7f29e1 --- /dev/null +++ b/docs/multicore-architecture.de.md @@ -0,0 +1,117 @@ +--- +title: Multicore-Architektur +summary: Wie die Firmware blockierende Sensor-I/O auf ESP32-Kern 0 isoliert und veränderlichen Controller-Zustand auf Kern 1 serialisiert +date: "2026-08-01" +lastmod: "2026-10-04" +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 + +Die Firmware verwendet ein explizites Ownership-Modell, statt jede I/O-Operation auf den zweiten Kern +zu verschieben. Das sicherheitsrelevante Controller-Modell bleibt Single-Writer in der Arduino-Loop, +während die blockierende DS18B20-Konvertierung auf Kern 0 isoliert wird. + +| Kern | Rolle | Inhalt | +| ---- | ----- | ------ | +| **Kern 0** (PRO_CPU) | Sensor-I/O | `SensorTask`: DS18B20-Konvertierung/-Auslesen und interne ESP32-Temperatur | +| **Kern 1** (APP_CPU) | Regelung und zustandsbehaftete I/O | Arduino-`loop()`: Watchdog, Degradation, Regeln, Relais, Status-LED, Netzwerk/OTA, MQTT-Serialisierung, Frontpanel-UI und OLED-Rendering | + +## Warum diese Grenze + +Eine 12-Bit-DS18B20-Konvertierung kann ungefähr 750 ms dauern. In der Arduino-Loop würde sie +Regelauswertung, Watchdog-Verarbeitung und Relaisentscheidungen blockieren. `SensorTask` entfernt genau +diese blockierende Operation aus der Regelschleife. + +MQTT-Serialisierung und OLED-Rendering bleiben bewusst auf Kern 1. Beide lesen veränderlichen +Controller-Zustand wie Betriebsmodus, Regeln, Relais und Konfiguration. Ein paralleles Lesen auf Kern 0 +würde Datenrennen etwa um Arduino-`String` und Regelzustände erzeugen. Die Ausführung auf dem besitzenden +Task ist einfacher und sicherer als Locks über den gesamten Objektgraphen. + +## Task-Modell + +`CoreScheduler` erzeugt genau einen erforderlichen Worker-Task: + +| Task | Kern | Priorität | Stack | Zweck | +| ---- | ---- | --------- | ----- | ----- | +| `SensorTask` | 0 | 2 | 6 KB | DS18B20- und interne Temperaturmessung | + +Kann `SensorTask` nicht erzeugt werden, startet der Controller neu. Ein dauerhafter Fehler wird durch die +bestehende Boot-Loop-Erkennung und den Safe-Mode behandelt. + +MQTT-Anforderungen verwenden weiterhin `TelemetryQueue`. `PoolController::loop()` leert die Queue direkt +auf Kern 1 und ruft dort `MqttPublisher` auf. `CoreScheduler` ist ausschließlich für den Lebenszyklus von +`SensorTask` und dessen Stack-High-Water-Logging zuständig. + +Beim NORVI-Display gilt dieselbe Regel: Taster, UI-Zustandsübergänge und OLED-Rendering werden durch +`DisplayCoordinator` auf Kern 1 serialisiert. Die veralteten Worker-Implementierungen `PublishTask` und +`DisplayTask` wurden entfernt, nachdem das Concurrency-Review die Ownership-Grenze enger gezogen hat. + +## Sensor-Ownership + +Nach dem Start ist `SensorTask` alleiniger Besitzer aller `OneWire`- und `DallasTemperature`-Operationen. +Code auf Kern 1 scannt oder liest den Bus nicht direkt. + +`DallasTemperatureNode` stellt deshalb zwei Core-übergreifende Kanäle bereit: + +- Messwerte werden über `SensorSlots` publiziert. +- Sensor-Erkennungsdaten und ausgewählte ROM-Adressen werden in einen kleinen Cache kopiert, der durch + einen kurzen atomaren Lock geschützt ist. Web- und MQTT-Code lesen nur diesen Cache. + +Änderungen der Sensor-Zuordnung werden zur Laufzeit als pending Konfiguration gespeichert. `SensorTask` +wendet sie zu Beginn eines späteren Messzyklus an. WebPortal- oder MQTT-Aufrufe führen damit keine +OneWire-Transaktion auf Kern 1 aus. + +## DS18B20-Messzyklus + +`DallasTemperature` wird mit `setWaitForConversion(false)` konfiguriert. Ein Messzyklus besteht aus: + +1. Konvertierung auf dem benötigten Bus beziehungsweise den Bussen starten. +2. `SensorTask` für 800 ms freigeben. +3. Fertige Messwerte lesen. +4. Ergebnis in `SensorSlots` publizieren und Discovery-Cache aktualisieren. + +Damit entfällt die frühere doppelte Wartezeit, bei der `requestTemperatures()` bereits intern blockierte +und der Worker anschließend nochmals 800 ms wartete. + +Fehlt mindestens einer der Dallas-Sensoren oder ist sein Wert ungültig, verwendet der gemeinsame Zyklus +den kürzeren Recovery-Intervall von 5 Sekunden. Sobald beide Sensoren gültig sind, gilt wieder das +konfigurierte `loopInterval`. + +## Datenfluss + +```text +SensorTask (Kern 0) ── SensorSlots ──────▶ Regelschleife (Kern 1): Regeln / Relais +SensorTask (Kern 0) ── Discovery-Cache ──▶ WebPortal / MQTT / UI (Kern 1) +Regelschleife (Kern 1) ─ TelemetryQueue ─▶ MQTT-Serialisierung (Kern 1) +DisplayCoordinator: Eingabe + Zustand + Rendering ───────────▶ Kern 1 +``` + +Die entscheidende Regel ist Ownership: Hardware-Buszustand gehört `SensorTask`; veränderlicher +Controller-Zustand gehört der Regelschleife. Core-übergreifende Kommunikation verwendet begrenzte +Snapshots statt gemeinsam veränderter Objekte. + +## Zuverlässigkeit + +- Sensorwert und Found-Status werden konsistent über `SensorSlots` übertragen. +- `DegradationManager` verwendet atomare Core-übergreifende Status-Flags. +- `SensorTask` registriert sich beim Task-Watchdog und füttert ihn. +- Stack-High-Water-Logging bleibt für den Worker verfügbar. +- OTA-Verarbeitung und MQTT-Zustandsänderungen bleiben auf Kern 1 serialisiert. + +## Historische Design-Dokumente + +Das ursprüngliche Design und der Migrationsplan bleiben unter +[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md) +und `docs/superpowers/plans/2026-08-01-multicore-task-architecture.md` erhalten. Sie dokumentieren den +früheren Drei-Worker-Entwurf und bleiben als Implementierungshistorie bestehen. Diese Seite beschreibt +die maßgebliche Laufzeitarchitektur nach dem Concurrency-Review. diff --git a/docs/multicore-architecture.md b/docs/multicore-architecture.md new file mode 100644 index 00000000..724b6f9c --- /dev/null +++ b/docs/multicore-architecture.md @@ -0,0 +1,117 @@ +--- +title: Multicore Architecture +summary: How the firmware isolates blocking sensor I/O on ESP32 Core 0 while keeping mutable controller state serialized on Core 1 +date: "2026-08-01" +lastmod: "2026-10-04" +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 firmware uses an explicit ownership model instead of moving every I/O operation to the second core. +The safety-critical controller model remains single-writer on the Arduino loop task, while the blocking +DS18B20 conversion is isolated on Core 0. + +| Core | Role | Contents | +| ---- | ---- | -------- | +| **Core 0** (PRO_CPU) | Sensor I/O | `SensorTask`: DS18B20 conversion/readout and internal ESP32 temperature | +| **Core 1** (APP_CPU) | Control and stateful I/O | Arduino `loop()`: watchdog, degradation, rules, relays, StatusLED, network/OTA, MQTT serialization, front-panel UI and OLED rendering | + +## Why this boundary + +A 12-bit DS18B20 conversion can take roughly 750 ms. Running it in the Arduino loop stalls rule +evaluation, watchdog handling and relay decisions. `SensorTask` removes that blocking conversion from +the control loop. + +MQTT serialization and OLED rendering are intentionally kept on Core 1. Both read mutable controller +state such as operation mode, rules, relay state and configuration. Running those readers concurrently +with the control loop would create cross-core data races around objects such as Arduino `String` and +rule state. Keeping them on the owning task is simpler and safer than protecting the complete object +graph with locks. + +## Task model + +`CoreScheduler` creates one required worker task: + +| Task | Core | Priority | Stack | Purpose | +| ---- | ---- | -------- | ----- | ------- | +| `SensorTask` | 0 | 2 | 6 KB | DS18B20 and internal temperature acquisition | + +If `SensorTask` cannot be created, the controller restarts. A persistent failure is handled by the +existing boot-loop detection and safe-mode path. + +MQTT requests still use `TelemetryQueue`. `PoolController::loop()` drains that queue directly on Core 1 +and invokes `MqttPublisher` there. `CoreScheduler` is responsible only for `SensorTask` lifecycle and +stack high-water logging. + +The NORVI display follows the same rule: button handling, UI state transitions and OLED rendering are +serialized on Core 1 by `DisplayCoordinator`. The obsolete `PublishTask` and `DisplayTask` worker +implementations were removed after the concurrency review narrowed the ownership boundary. + +## Sensor ownership + +After startup, `SensorTask` is the exclusive owner of `OneWire` and `DallasTemperature` operations. +Core-1 code never scans or reads the bus directly. + +`DallasTemperatureNode` therefore exposes two cross-core channels: + +- Measurements are published through `SensorSlots`. +- Sensor discovery metadata and selected ROM addresses are copied into a small cached snapshot guarded + by a short atomic lock. Web and MQTT code read only this cache. + +Runtime sensor-mapping changes are stored as pending configuration. `SensorTask` applies them at the +start of a later measurement cycle, so calls from WebPortal or MQTT never execute OneWire transactions +on Core 1. + +## DS18B20 measurement cycle + +`DallasTemperature` is configured with `setWaitForConversion(false)`. A measurement cycle is: + +1. Start conversions on the required bus or buses. +2. Yield `SensorTask` for 800 ms. +3. Read the completed values. +4. Publish the result to `SensorSlots` and refresh the discovery cache. + +This avoids the former double wait where `requestTemperatures()` blocked internally and the worker then +waited another 800 ms. + +If either Dallas sensor is missing or invalid, the common cycle uses the shorter 5-second recovery +interval. Once both sensors are valid, the configured `loopInterval` applies again. + +## Data flow + +```text +SensorTask (Core 0) ── SensorSlots ──────▶ control loop (Core 1): rules / relays +SensorTask (Core 0) ── discovery cache ──▶ WebPortal / MQTT / UI (Core 1) +control loop (Core 1) ── TelemetryQueue ─▶ MQTT serialization (Core 1) +DisplayCoordinator: input + state + render ───────────────────▶ Core 1 +``` + +The important rule is ownership: hardware bus state belongs to `SensorTask`; mutable controller state +belongs to the control loop. Cross-core communication uses bounded snapshots instead of shared mutable +objects. + +## Reliability + +- Sensor values and found-state are transferred consistently through `SensorSlots`. +- `DegradationManager` uses atomic cross-core status flags. +- `SensorTask` registers with and feeds the task watchdog. +- Stack high-water logging remains available for the worker task. +- OTA handling and MQTT state mutation stay serialized on Core 1. + +## Historical design documents + +The original design and migration plan remain available under +[`docs/superpowers/specs/2026-08-01-multicore-task-architecture-design.md`](../superpowers/specs/2026-08-01-multicore-task-architecture-design.md) +and `docs/superpowers/plans/2026-08-01-multicore-task-architecture.md`. They document the earlier +three-worker proposal and are retained as implementation history. This page describes the authoritative +runtime architecture after the concurrency review. diff --git a/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..6c8b4348 --- /dev/null +++ b/src/CoreScheduler.cpp @@ -0,0 +1,41 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file CoreScheduler.cpp + * @brief Starts and monitors the dedicated Core-0 sensor task. + */ + +#include "CoreScheduler.hpp" + +#include +#include +#include + +#include "LogCapture.hpp" +#include "SensorTask.hpp" + +namespace PoolController { + +void CoreScheduler::begin() { + // Core 0 = PRO_CPU_NUM (sensor I/O); Core 1 = APP_CPU_NUM (control loop). + const BaseType_t core0 = PRO_CPU_NUM; + + if (!SensorTask::start(TASK_PRIORITY_SENSOR, TASK_STACK_SENSOR, core0)) { + LOG_ERROR("✖ SensorTask could not be created (free heap %u B) — restarting\n", static_cast(ESP.getFreeHeap())); + Serial.flush(); + // A persistent failure is caught by boot-loop detection and forces safe mode. + ESP.restart(); + } +} + +void CoreScheduler::logStackWatermarks() { + static uint32_t lastLog = 0; + if (millis() - lastLog < 60000) { + return; + } + lastLog = millis(); + SensorTask::logStackWatermark(); +} + +} // namespace PoolController diff --git a/src/CoreScheduler.hpp b/src/CoreScheduler.hpp new file mode 100644 index 00000000..d7a6d48b --- /dev/null +++ b/src/CoreScheduler.hpp @@ -0,0 +1,34 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file CoreScheduler.hpp + * @brief Static launcher for the dedicated Core-0 sensor task. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief Creates and tracks the dedicated sensor task pinned to Core 0. + * + * Mutable application state, MQTT serialization and OLED rendering remain on + * the Arduino control-loop task on Core 1. Sensor I/O is the only production + * workload moved to Core 0 and publishes readings through SensorSlots. + */ +class CoreScheduler { +public: + static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; + static constexpr uint16_t TASK_STACK_SENSOR = 6 * 1024; + + /** @brief Create the Core-0 sensor task. Call once after initializeController(). */ + static void begin(); + + /** @brief Periodically log the SensorTask stack high-water mark. */ + static void logStackWatermarks(); +}; + +} // namespace PoolController diff --git a/src/DallasTemperatureNode.cpp b/src/DallasTemperatureNode.cpp index 14f88eb9..93575b71 100644 --- a/src/DallasTemperatureNode.cpp +++ b/src/DallasTemperatureNode.cpp @@ -5,25 +5,23 @@ /** * @file DallasTemperatureNode.cpp * @brief DS18B20 sensor node implementation with bus scanning, recovery, and - * degradation reporting. + * cross-core discovery snapshots. */ #include "DallasTemperatureNode.hpp" #include "Config.hpp" #include "SystemMonitor.hpp" #include "DegradationManager.hpp" +#include "SensorSlots.hpp" #include "Utils.hpp" -#include "LogCapture.hpp" - -// ── Dedicated bus constructor ────────────────────────────────────────────── DallasTemperatureNode::DallasTemperatureNode(const char *id, const char *name, const uint8_t pin, const int measurementInterval) { _id = id; _name = name; _pin = pin; - _measurementInterval = (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL; + _measurementInterval.store( + (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = 0; - numberOfDevices = 0; _temperature = NAN; _sensorFound = false; @@ -31,156 +29,226 @@ DallasTemperatureNode::DallasTemperatureNode(const char *id, const char *name, c sensor.setOneWire(&oneWire); } -// ── Shared bus constructor ───────────────────────────────────────────────── - DallasTemperatureNode::DallasTemperatureNode( const char *id, const char *name, DallasTemperature *sharedSensor, uint8_t deviceIndex, const int measurementInterval) { _id = id; _name = name; - _pin = 0; // not used in shared mode - _measurementInterval = (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL; + _pin = 0; + _measurementInterval.store( + (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = 0; - numberOfDevices = 0; _temperature = NAN; _sensorFound = false; sharedSensor_ = sharedSensor; deviceIndex_ = deviceIndex; isBusMaster_ = (deviceIndex == 0); - - // Clear the cached address memset(deviceAddress_, 0, sizeof(deviceAddress_)); } -// ═════════════════════════════════════════════════════════════════════════════ -// ── Address filter helpers ───────────────────────────────────────────────── +void DallasTemperatureNode::lockCache() const { + while (cacheLock_.test_and_set(std::memory_order_acquire)) { + yield(); + } +} + +void DallasTemperatureNode::unlockCache() const { + cacheLock_.clear(std::memory_order_release); +} void DallasTemperatureNode::setAddressFilter(const DeviceAddress addr) { + lockCache(); memcpy(filterAddr_, addr, sizeof(DeviceAddress)); - hasFilter_ = true; - - if (numberOfDevices > 0) { - resolveFilter(); - } + unlockCache(); + hasFilter_.store(true, std::memory_order_release); + mappingDirty_.store(true, std::memory_order_release); } void DallasTemperatureNode::clearAddressFilter() { + lockCache(); memset(filterAddr_, 0, sizeof(DeviceAddress)); - hasFilter_ = false; + unlockCache(); + hasFilter_.store(false, std::memory_order_release); + mappingDirty_.store(true, std::memory_order_release); +} - if (numberOfDevices > 0) { - resolveFilter(); - } +void DallasTemperatureNode::publishSelectedAddress() { + lockCache(); + memcpy(cachedSelectedAddress_, deviceAddress_, sizeof(DeviceAddress)); + unlockCache(); } bool DallasTemperatureNode::resolveFilter() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + DeviceAddress filter{}; + const bool hasFilter = hasFilter_.load(std::memory_order_acquire); + + if (hasFilter) { + lockCache(); + memcpy(filter, filterAddr_, sizeof(DeviceAddress)); + unlockCache(); + } - if (hasFilter_ && numberOfDevices > 0) { - // ── Address filter active: find the matching device on the bus ── + if (hasFilter && numberOfDevices > 0) { for (uint8_t i = 0; i < numberOfDevices; i++) { DeviceAddress addr; - if (activeSensor->getAddress(addr, i)) { - if (memcmp(addr, filterAddr_, sizeof(DeviceAddress)) == 0) { - // Note: intentionally NOT updating deviceIndex_ here — the constructor- - // provided default (solar=0, pool=1) must survive as the fallback index - // used by the no-filter path in clearAddressFilter(). - memcpy(deviceAddress_, addr, sizeof(DeviceAddress)); - _sensorFound = true; - char adr[18]; - address2String(addr, adr, sizeof(adr)); - LOG_INFO(" ◦ %s: filter resolved → device %d [%s] ✓\n", _id, i, adr); - return true; - } + if (activeSensor->getAddress(addr, i) && memcmp(addr, filter, sizeof(DeviceAddress)) == 0) { + memcpy(deviceAddress_, addr, sizeof(DeviceAddress)); + publishSelectedAddress(); + _sensorFound = true; + char adr[18]; + address2String(addr, adr, sizeof(adr)); + Serial.printf(" ◦ %s: filter resolved → device %d [%s] ✓\n", _id, i, adr); + return true; } } - // Filter address not found on bus — fall back to deviceIndex_ - LOG_ERROR(" ✖ %s: filter address not found on bus! " - "Falling back to device index %d\n", - _id, deviceIndex_); + + Serial.printf(" ✖ %s: filter address not found on bus! Falling back to device index %d\n", _id, deviceIndex_); if (activeSensor->getAddress(deviceAddress_, deviceIndex_)) { + publishSelectedAddress(); _sensorFound = true; return true; } + + memset(deviceAddress_, 0, sizeof(deviceAddress_)); + publishSelectedAddress(); _sensorFound = false; return false; } - if (numberOfDevices > 0) { - // ── No filter: cache address by deviceIndex_ ─────────────────── - if (activeSensor->getAddress(deviceAddress_, deviceIndex_)) { - _sensorFound = true; - char adr[18]; - address2String(deviceAddress_, adr, sizeof(adr)); - if (sharedSensor_) { - LOG_INFO(" ◦ %s: no filter → device %d [%s] (shared bus)\n", _id, deviceIndex_, adr); - } else { - LOG_INFO(" ◦ %s: no filter → device %d [%s]\n", _id, deviceIndex_, adr); - } - return true; + if (numberOfDevices > 0 && activeSensor->getAddress(deviceAddress_, deviceIndex_)) { + publishSelectedAddress(); + _sensorFound = true; + char adr[18]; + address2String(deviceAddress_, adr, sizeof(adr)); + Serial.printf(" ◦ %s: no filter → device %d [%s]", _id, deviceIndex_, adr); + if (sharedSensor_) { + Serial.print(" (shared bus)"); } + Serial.println(); + return true; } + memset(deviceAddress_, 0, sizeof(deviceAddress_)); + publishSelectedAddress(); _sensorFound = false; return false; } +void DallasTemperatureNode::applyPendingAddressFilter() { + if (!mappingDirty_.exchange(false, std::memory_order_acq_rel)) { + return; + } + if (numberOfDevices > 0) { + resolveFilter(); + } +} + +void DallasTemperatureNode::refreshDetectedSnapshot(bool includeTemperatures) { + DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; + DeviceAddress localAddresses[MAX_DETECTED_DEVICES]{}; + float localTemperatures[MAX_DETECTED_DEVICES]; + for (float &value : localTemperatures) { + value = NAN; + } + + const uint8_t count = (numberOfDevices < MAX_DETECTED_DEVICES) ? numberOfDevices : MAX_DETECTED_DEVICES; + uint8_t written = 0; + for (uint8_t i = 0; i < count; i++) { + DeviceAddress addr; + if (!activeSensor->getAddress(addr, i)) { + continue; + } + memcpy(localAddresses[written], addr, sizeof(DeviceAddress)); + if (includeTemperatures) { + const float value = activeSensor->getTempC(addr); + localTemperatures[written] = (value == DEVICE_DISCONNECTED_C) ? NAN : value; + } + written++; + } + + lockCache(); + detectedCount_ = written; + for (uint8_t i = 0; i < MAX_DETECTED_DEVICES; i++) { + if (i < written) { + memcpy(detectedAddresses_[i], localAddresses[i], sizeof(DeviceAddress)); + detectedTemperatures_[i] = localTemperatures[i]; + } else { + memset(detectedAddresses_[i], 0, sizeof(DeviceAddress)); + detectedTemperatures_[i] = NAN; + } + } + unlockCache(); +} + +uint8_t DallasTemperatureNode::getDeviceCount() const { + lockCache(); + const uint8_t count = detectedCount_; + unlockCache(); + return count; +} + void DallasTemperatureNode::getDeviceAddressString(char *buffer, size_t size) const { - address2String(deviceAddress_, buffer, size); + DeviceAddress addr; + lockCache(); + memcpy(addr, cachedSelectedAddress_, sizeof(DeviceAddress)); + unlockCache(); + address2String(addr, buffer, size); } bool DallasTemperatureNode::getDetectedDeviceAddress(uint8_t index, DeviceAddress addr) const { - DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : const_cast(&sensor); - if (index >= numberOfDevices) { + lockCache(); + if (index >= detectedCount_) { + unlockCache(); return false; } - return activeSensor->getAddress(addr, index); + memcpy(addr, detectedAddresses_[index], sizeof(DeviceAddress)); + unlockCache(); + return true; } float DallasTemperatureNode::getDetectedDeviceTemperature(uint8_t index) const { - DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : const_cast(&sensor); - if (index >= numberOfDevices) { + lockCache(); + if (index >= detectedCount_) { + unlockCache(); return NAN; } - DeviceAddress addr; - if (!activeSensor->getAddress(addr, index)) { - return NAN; - } - return activeSensor->getTempC(addr); + const float value = detectedTemperatures_[index]; + unlockCache(); + return value; } -// ═════════════════════════════════════════════════════════════════════════════ - void DallasTemperatureNode::begin() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; if (!sharedSensor_) { - // Dedicated mode: init our own sensor activeSensor->begin(); } - // Shared mode: sensor->begin() was already called externally — just scan + + // SensorTask owns the conversion delay. Without this, requestTemperatures() + // blocks internally and the task would wait a second time afterwards. + activeSensor->setWaitForConversion(false); numberOfDevices = activeSensor->getDeviceCount(); - LOG_INFO("• DallasTemperature: Parasite power is: %d\n", activeSensor->isParasitePowerMode()); + Serial.printf("• DallasTemperature: Parasite power is: %d\n", activeSensor->isParasitePowerMode()); if (numberOfDevices > 0) { - uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; - LOG_INFO(" ◦ %d devices found on PIN %d\n", numberOfDevices, displayPin); + const uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; + Serial.printf(" ◦ %d devices found on PIN %d\n", numberOfDevices, displayPin); - // ── Print all detected devices ────────────────────────────────────── for (uint8_t i = 0; i < numberOfDevices; i++) { DeviceAddress addr; if (activeSensor->getAddress(addr, i)) { char adr[18]; address2String(addr, adr, sizeof(adr)); - LOG_INFO(" ◦ PIN %d: Device %d address: %s\n", displayPin, i, adr); + Serial.printf(" ◦ PIN %d: Device %d address: %s\n", displayPin, i, adr); } } - // ── Resolve address filter (persistent mapping) ───────────────────── resolveFilter(); + mappingDirty_.store(false, std::memory_order_release); + refreshDetectedSnapshot(false); - // ── Report global status ─────────────────────────────────────────── if (sharedSensor_) { PoolController::DegradationManager::reportSensorStatus("solar-temp", numberOfDevices > 0); PoolController::DegradationManager::reportSensorStatus("pool-temp", numberOfDevices > 1); @@ -188,108 +256,116 @@ void DallasTemperatureNode::begin() { PoolController::DegradationManager::reportSensorStatus(_id, true); } } else { - uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; - LOG_ERROR("✖ No Dallas sensors found on pin %d\n", displayPin); + const uint8_t displayPin = sharedSensor_ ? PoolController::PIN_DS_SOLAR : _pin; + Serial.printf("✖ No Dallas sensors found on pin %d\n", displayPin); _sensorFound = false; + refreshDetectedSnapshot(false); PoolController::DegradationManager::reportSensorStatus(_id, false); } + + // Start in recovery cadence until the first valid measurement is published. + PoolController::SensorSlots::write(slotId(), NAN, false); } -void DallasTemperatureNode::loop() { +void DallasTemperatureNode::beginMeasurement() { DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; - unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : _measurementInterval; + applyPendingAddressFilter(); - if (Utils::shouldMeasure(_lastMeasurement, effectiveInterval)) { - _lastMeasurement = millis(); + if (sharedSensor_ && numberOfDevices > 0) { + if (isBusMaster_) { + PoolController::SystemMonitor::feedWatchdogFromTask(); + activeSensor->requestTemperatures(); + PoolController::SystemMonitor::feedWatchdogFromTask(); + } + } else if (numberOfDevices > 0) { + 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_) { - LOG_DEBUG("〽 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) { - LOG_ERROR(" ✖ Solar sensor disconnected - setting to NaN\n"); - _temperature = NAN; - _sensorFound = false; - PoolController::DegradationManager::reportSensorStatus(_id, false); - } else { - _temperature = newTemp; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - LOG_DEBUG(" ◦ Solar Temp = %.1f°C\n", _temperature); - } - } else { - // Slave: read from the conversion the master already triggered - float newTemp = activeSensor->getTempC(deviceAddress_); - if (newTemp == DEVICE_DISCONNECTED_C) { - LOG_ERROR(" ✖ Pool sensor disconnected - setting to NaN\n"); - _temperature = NAN; - _sensorFound = false; - PoolController::DegradationManager::reportSensorStatus(_id, false); - } else { - _temperature = newTemp; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - LOG_DEBUG(" ◦ Pool Temp = %.1f°C\n", _temperature); - } - } - } else if (numberOfDevices > 0) { - // ── Dedicated bus mode (standard) ────────────────────────────────── - LOG_DEBUG("〽 Reading Dallas sensor: %s\n", _id); +void DallasTemperatureNode::finishMeasurement() { + DallasTemperature *activeSensor = sharedSensor_ ? sharedSensor_ : &sensor; - 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) { - LOG_ERROR(" ✖ Sensor disconnected - setting to NaN\n"); - _temperature = NAN; - _sensorFound = false; - PoolController::DegradationManager::reportSensorStatus(_id, false); - } else { - _temperature = newTemp; - _sensorFound = true; - PoolController::DegradationManager::reportSensorStatus(_id, true); - LOG_DEBUG(" ◦ Temp = %.1f°C\n", _temperature); - } - } - } - } else { - // ── No sensor found — rescan ────────────────────────────────────── - LOG_INFO("No Sensor found on bus! Rescanning...\n"); + if (sharedSensor_ && numberOfDevices > 0) { + const 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); + refreshDetectedSnapshot(true); + return; + } - 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); - LOG_INFO(" ◦ %d device(s) found after rescan\n", numberOfDevices); - } - } else { - activeSensor->begin(); - numberOfDevices = activeSensor->getDeviceCount(); - if (numberOfDevices > 0) { - LOG_INFO(" ◦ %d device(s) found after rescan\n", numberOfDevices); - _sensorFound = true; + if (numberOfDevices > 0) { + bool foundAny = false; + for (uint8_t i = 0; i < numberOfDevices; i++) { + DeviceAddress tempDeviceAddress; + if (activeSensor->getAddress(tempDeviceAddress, i)) { + const 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); + } + PoolController::SensorSlots::write(slotId(), _temperature, _sensorFound); + refreshDetectedSnapshot(true); + return; } + + Serial.printf("No Sensor found on bus! Rescanning (%s)...\n", _id); + PoolController::DegradationManager::reportSensorStatus(_id, false); + PoolController::SensorSlots::write(slotId(), NAN, false); + + activeSensor->begin(); + activeSensor->setWaitForConversion(false); + numberOfDevices = activeSensor->getDeviceCount(); + if (numberOfDevices > 0) { + resolveFilter(); + if (_sensorFound) { + PoolController::DegradationManager::reportSensorStatus(_id, true); + } + Serial.printf(" ◦ %d device(s) found after rescan\n", numberOfDevices); + } else { + _sensorFound = false; + memset(deviceAddress_, 0, sizeof(deviceAddress_)); + publishSelectedAddress(); + } + refreshDetectedSnapshot(false); +} + +void DallasTemperatureNode::loop() { + const unsigned long effectiveInterval = std::isnan(_temperature) ? RECOVERY_INTERVAL : getMeasurementInterval(); + if (Utils::shouldMeasure(_lastMeasurement, effectiveInterval)) { + _lastMeasurement = millis(); + Serial.printf("〽 Reading Dallas sensor: %s\n", _id); + beginMeasurement(); + // Synchronous fallback for tests/non-task callers. Production uses + // SensorTask, which yields instead of blocking the control loop. + delay(SYNC_CONVERSION_DELAY_MS); + 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 { diff --git a/src/DallasTemperatureNode.hpp b/src/DallasTemperatureNode.hpp index 9abc7b2d..89a09bbc 100644 --- a/src/DallasTemperatureNode.hpp +++ b/src/DallasTemperatureNode.hpp @@ -13,129 +13,106 @@ #include #include +#include + +#include "SensorSlots.hpp" + /** * @brief Reads temperature from a DS18B20 sensor on a OneWire bus. * - * Supports automatic recovery: if the sensor disappears, it uses a shorter - * polling interval (RECOVERY_INTERVAL) and rescans the bus each cycle. - * Reports sensor status to the DegradationManager for system health tracking. - * - * ### Shared Bus Mode (NORVI AE01-R) - * - * When constructed with the shared-bus constructor, two nodes can share a - * single OneWire bus. The "master" node (deviceIndex == 0) drives - * requestTemperatures() while the "slave" (deviceIndex > 0) reads its - * specific device address from the already-completed conversion. - * - * @note Shared bus mode is only enabled when `NORVI_AE01_R` is defined. + * OneWire/DallasTemperature access is owned by SensorTask after startup. + * Cross-core consumers only read SensorSlots or the small cached discovery + * snapshot maintained by this class. */ class DallasTemperatureNode { public: - /** - * @brief Construct a DallasTemperature node (dedicated bus). - * @param id Unique node identifier (e.g. "solar-temp"). - * @param name Human-readable name (e.g. "Solar Temperature"). - * @param pin GPIO pin for the OneWire data line. - * @param measurementInterval Minimum interval between sensor reads (seconds). - */ DallasTemperatureNode( const char *id, const char *name, const uint8_t pin, const int measurementInterval = MEASUREMENT_INTERVAL); - /** - * @brief Construct a DallasTemperature node on a shared OneWire bus. - * @param id Unique node identifier (e.g. "solar-temp"). - * @param name Human-readable name (e.g. "Solar Temperature"). - * @param sharedSensor Pointer to an externally owned DallasTemperature instance. - * @param deviceIndex Index of this node's sensor on the shared bus (0 = master, 1 = slave). - * @param measurementInterval Minimum interval between sensor reads (seconds). - * - * The master (index 0) calls requestTemperatures(). All nodes read their - * respective device address from the conversion result. Requires a prior - * call to sharedSensor->begin() before calling node.begin(). - */ DallasTemperatureNode(const char *id, const char *name, DallasTemperature *sharedSensor, uint8_t deviceIndex, const int measurementInterval = MEASUREMENT_INTERVAL); - /** @brief Get the node identifier. */ const char *getId() const { return _id; } - /** @brief Get the OneWire GPIO pin number. */ uint8_t getPin() const { return _pin; } - /** @brief Set the measurement interval (seconds). */ - void setMeasurementInterval(unsigned long interval) { _measurementInterval = interval; } - /** @brief Get the measurement interval (seconds). */ - unsigned long getMeasurementInterval() const { return _measurementInterval; } + void setMeasurementInterval(unsigned long interval) { _measurementInterval.store(interval, std::memory_order_relaxed); } + unsigned long getMeasurementInterval() const { return _measurementInterval.load(std::memory_order_relaxed); } - /** @brief Get the last successfully read temperature. @return Temperature in °C, or NAN if no valid read. */ - float getTemperature() const { return _temperature; } - /** @brief Check if a sensor was found on the bus. @return true if at least one device is present. */ - bool isSensorFound() const { return _sensorFound; } + /** @brief Measurement interval including the 5 s recovery cadence. */ + unsigned long getEffectiveMeasurementInterval() const { + return PoolController::SensorSlots::isFound(slotId()) ? getMeasurementInterval() : RECOVERY_INTERVAL; + } - /** @brief Get number of devices detected on this node's bus. */ - uint8_t getDeviceCount() const { return numberOfDevices; } - /** @brief Get the 8-byte ROM address of the device this node reads from. */ + float getTemperature() const { return PoolController::SensorSlots::read(slotId()); } + bool isSensorFound() const { return PoolController::SensorSlots::isFound(slotId()); } + + /** @brief Get number of devices from the thread-safe discovery snapshot. */ + uint8_t getDeviceCount() const; const uint8_t *getDeviceAddress() const { return deviceAddress_; } - /** @brief Format the node's device address into a hex string (17 chars). */ void getDeviceAddressString(char *buffer, size_t size) const; - /** @brief Get the address of a detected device by index. @return true if index is valid. */ bool getDetectedDeviceAddress(uint8_t index, DeviceAddress addr) const; - /** @brief Get temperature for any device on this node's bus by index. */ float getDetectedDeviceTemperature(uint8_t index) const; /** - * @brief Set a preferred device address filter. + * @brief Queue a preferred address for application by SensorTask. * - * During begin(), the node scans the bus and finds the device whose ROM - * address matches \p addr. If found, that device is used regardless of - * deviceIndex. If not found, a warning is printed and deviceIndex is - * used as fallback. + * Before SensorTask starts, begin() resolves the queued filter during boot. + * At runtime this method never touches OneWire; the next measurement cycle + * applies the pending mapping on the sensor task. */ void setAddressFilter(const DeviceAddress addr); - /** @brief Clear the address filter — reverts to deviceIndex-based selection. */ void clearAddressFilter(); - /** @brief Check if an address filter is configured. */ - bool hasAddressFilter() const { return hasFilter_; } + bool hasAddressFilter() const { return hasFilter_.load(std::memory_order_acquire); } - /** @brief Initialize the OneWire bus and discover connected sensors. */ void begin(); - /** @brief Read temperature periodically (respects measurementInterval). */ void loop(); + void beginMeasurement(); + void finishMeasurement(); private: - static const int MIN_INTERVAL = 10; // in seconds (more granular loop support) + static const int MIN_INTERVAL = 10; static const int MEASUREMENT_INTERVAL = 300; - static const int RECOVERY_INTERVAL = 5; // seconds + static const int RECOVERY_INTERVAL = 5; + static constexpr uint8_t MAX_DETECTED_DEVICES = 20; + static constexpr uint32_t SYNC_CONVERSION_DELAY_MS = 800; const char *_id; const char *_name; uint8_t _pin = 0; - unsigned long _measurementInterval; + std::atomic _measurementInterval{MEASUREMENT_INTERVAL}; unsigned long _lastMeasurement; bool _sensorFound = false; - float _temperature = NAN; - // Own OneWire bus (dedicated mode) or nullptr (shared mode) OneWire oneWire; DallasTemperature sensor; - // Address filter (persistent mapping across reboots) - bool hasFilter_ = false; ///< Whether an address filter is configured - DeviceAddress filterAddr_{}; ///< Address to match during begin() + std::atomic hasFilter_{false}; + std::atomic mappingDirty_{false}; + DeviceAddress filterAddr_{}; - // Shared bus members (only used in shared mode) - DallasTemperature *sharedSensor_ = nullptr; ///< External shared sensor (shared mode only) - uint8_t deviceIndex_ = 0; ///< Device index on the shared bus - bool isBusMaster_ = false; ///< True if this node drives requestTemperatures() - DeviceAddress deviceAddress_{}; ///< Cached device address for shared bus reads + DallasTemperature *sharedSensor_ = nullptr; + uint8_t deviceIndex_ = 0; + bool isBusMaster_ = false; + DeviceAddress deviceAddress_{}; + uint8_t numberOfDevices = 0; - uint8_t numberOfDevices; + // Cross-core metadata cache. The lock only protects short memory copies; + // OneWire operations are always performed without holding it. + mutable std::atomic_flag cacheLock_ = ATOMIC_FLAG_INIT; + DeviceAddress cachedSelectedAddress_{}; + DeviceAddress detectedAddresses_[MAX_DETECTED_DEVICES]{}; + float detectedTemperatures_[MAX_DETECTED_DEVICES]{}; + uint8_t detectedCount_ = 0; - /** @brief Scan the bus for the current filter and update deviceAddress_. - * Called by begin(), setAddressFilter(), and clearAddressFilter(). - * @return true if the device was found, false if fallback was used. */ bool resolveFilter(); + void applyPendingAddressFilter(); + void refreshDetectedSnapshot(bool includeTemperatures); + void publishSelectedAddress(); + + void lockCache() const; + void unlockCache() const; - /** @brief Format a DeviceAddress as a hex string. */ + PoolController::SensorId slotId() const; void address2String(const DeviceAddress deviceAddress, char *buffer, size_t size) const; }; diff --git a/src/DegradationManager.cpp b/src/DegradationManager.cpp index f5998c82..00a1e24e 100644 --- a/src/DegradationManager.cpp +++ b/src/DegradationManager.cpp @@ -13,33 +13,26 @@ #include "NetworkManager.hpp" #include "Nodes.hpp" #include "SystemMonitor.hpp" -#include "TimeClientHelper.hpp" // TimeDegradation, getTimeDegradation +#include "TimeClientHelper.hpp" namespace PoolController { -// Static member definitions DegradationLevel DegradationManager::currentLevel_ = DegradationLevel::NORMAL; DegradationLevel DegradationManager::previousLevel_ = DegradationLevel::NORMAL; -bool DegradationManager::poolSensorOk_ = false; -bool DegradationManager::solarSensorOk_ = false; +std::atomic DegradationManager::poolSensorOk_{false}; +std::atomic DegradationManager::solarSensorOk_{false}; bool DegradationManager::forcedSafeMode_ = false; -bool DegradationManager::sensorsEverReported_ = false; +std::atomic DegradationManager::sensorsEverReported_{false}; unsigned long DegradationManager::lastEvaluationMs_ = 0; -// =========================================================================== -// Public API -// =========================================================================== - void DegradationManager::begin() { - // Don't reset if already forced into safe mode by boot-loop detection if (currentLevel_ == DegradationLevel::CRITICAL) { previousLevel_ = DegradationLevel::CRITICAL; - // forcedSafeMode_ is preserved; evaluate() will not downgrade below CRITICAL return; } currentLevel_ = DegradationLevel::NORMAL; previousLevel_ = DegradationLevel::NORMAL; - poolSensorOk_ = false; // Pessimistic — both probes must report healthy + poolSensorOk_ = false; solarSensorOk_ = false; forcedSafeMode_ = false; lastEvaluationMs_ = 0; @@ -49,15 +42,11 @@ void DegradationManager::begin() { void DegradationManager::evaluate() { unsigned long now = millis(); - - // Rate-limit evaluation to EVALUATION_INTERVAL_MS if (now - lastEvaluationMs_ < EVALUATION_INTERVAL_MS) { return; } lastEvaluationMs_ = now; - // If force-safe-mode is active, never auto-downgrade below CRITICAL. - // Only an explicit disable or reboot can clear forced safe mode. if (forcedSafeMode_) { if (currentLevel_ != DegradationLevel::CRITICAL) { previousLevel_ = currentLevel_; @@ -68,7 +57,6 @@ void DegradationManager::evaluate() { } DegradationLevel newLevel = evaluateLevel(); - if (newLevel != currentLevel_) { previousLevel_ = currentLevel_; currentLevel_ = newLevel; @@ -111,7 +99,7 @@ const char *DegradationManager::levelToString(DegradationLevel level) { } void DegradationManager::reportSensorStatus(const char *nodeId, bool valid) { - sensorsEverReported_ = true; // Mark that at least one sensor has been reported + sensorsEverReported_ = true; if (strcmp(nodeId, "pool-temp") == 0) { poolSensorOk_ = valid; } else if (strcmp(nodeId, "solar-temp") == 0) { @@ -128,38 +116,26 @@ void DegradationManager::unforceSafeMode() { } } -// =========================================================================== -// Private helpers -// =========================================================================== - void DegradationManager::onTransition() { - // Log the transition LOG_INFO("⚙ Degradation: %s → %s\n", levelToString(previousLevel_), levelToString(currentLevel_)); - // Additional per-level actions switch (currentLevel_) { case DegradationLevel::NORMAL: LOG_INFO("✓ All systems nominal\n"); break; - case DegradationLevel::NO_WIFI: LOG_WARN("⚠ WiFi/MQTT disconnected — operating offline\n"); LOG_WARN(" All control rules still active\n"); break; - case DegradationLevel::NO_TIME: LOG_WARN("⚠ NTP time sync lost — timer scheduling degraded\n"); LOG_WARN(" Timer mode falls back to auto mode\n"); break; - case DegradationLevel::NO_SENSOR: LOG_WARN("⚠ Temperature sensor fault — using cautious defaults\n"); LOG_WARN(" Auto mode may not function correctly\n"); break; - case DegradationLevel::CRITICAL: - // Log the concrete reason for entering safe mode. Priority mirrors - // evaluateLevel(): forced (boot-loop) > low memory > multiple failures. if (forcedSafeMode_) { LOG_ERROR("✖ SAFE MODE — reason: boot-loop detected (safe mode forced)\n"); } else if (!SystemMonitor::isHealthy()) { @@ -169,8 +145,6 @@ void DegradationManager::onTransition() { if (!NetworkManager::isWiFiConnected()) { LOG_ERROR(" - WiFi/MQTT disconnected\n"); } - // NTP loss while WiFi is down is a consequence, not an independent - // failure — mirror the counting logic in evaluateLevel(). if (NetworkManager::isWiFiConnected() && getTimeDegradation() == TimeDegradation::RED) { LOG_ERROR(" - NTP time sync lost\n"); } @@ -179,38 +153,27 @@ void DegradationManager::onTransition() { } } LOG_ERROR(" Entering safe mode — all relays off\n"); - // De-energize both relays immediately (P1 review fix) poolPumpNode.setSwitch(false); solarPumpNode.setSwitch(false); break; } - // MQTT notification — best-effort, no retry. - // Full publish is handled by MqttPublisher on its next publish cycle. if (NetworkManager::isMqttConnected()) { LOG_INFO(" Degradation state will be published via MQTT\n"); } } DegradationLevel DegradationManager::evaluateLevel() { - // Gather system health signals bool wifiOk = NetworkManager::isWiFiConnected(); - // If time is RED (lost) but WiFi is up, try an NTP sync immediately. - // The Time library only syncs every SYNC_INTERVAL (3600s), so without - // this the system could stay in NO_TIME for an hour after NTP recovers. if (wifiOk && (getTimeDegradation() == TimeDegradation::RED)) { forceNtpUpdate(); } - // Time is OK for GREEN + YELLOW (millis() estimate is usable up to 24h) bool timeOk = (getTimeDegradation() != TimeDegradation::RED); bool memoryOk = SystemMonitor::isHealthy(); - bool sensorOk = sensorsEverReported_ ? (poolSensorOk_ && solarSensorOk_) : true; // Both probes must be healthy + bool sensorOk = sensorsEverReported_ ? (poolSensorOk_ && solarSensorOk_) : true; - // Count active failures (memory failure = CRITICAL immediately). - // Time loss without WiFi is a consequence, not an independent failure — - // counting both would escalate a plain WiFi outage to CRITICAL. uint8_t failureCount = 0; if (!wifiOk) failureCount++; @@ -219,19 +182,12 @@ DegradationLevel DegradationManager::evaluateLevel() { if (!sensorOk) failureCount++; - // --- Decision logic --- - - // Memory critical → always CRITICAL (may reboot soon anyway) if (!memoryOk) { return DegradationLevel::CRITICAL; } - - // Multiple concurrent failures → CRITICAL if (failureCount >= 2) { return DegradationLevel::CRITICAL; } - - // Single failures — return the most specific level if (!sensorOk) return DegradationLevel::NO_SENSOR; if (!timeOk) diff --git a/src/DegradationManager.hpp b/src/DegradationManager.hpp index eb5c4333..c498f358 100644 --- a/src/DegradationManager.hpp +++ b/src/DegradationManager.hpp @@ -9,119 +9,45 @@ #pragma once -/** - * Degradation Manager — central system health monitor - * - * Tracks the overall system state and provides a single source of truth for - * degradation-aware decisions across all components (timer fallback, - * MQTT publish queue, sensor recovery etc). - * - * Design: stateless singleton (like SystemMonitor). Components call - * evaluate() on every loop() — the manager internally rate-limits - * checks to avoid overhead. - * - * Integration points: - * - PoolController::loop() calls DegradationManager::evaluate() - * - DallasTemperatureNode calls reportSensorStatus() - * - RuleAuto / RuleTimer query getLevel() for time degradation - * - MqttInterface queries getLevel() for publish decisions - */ - #include +#include + namespace PoolController { -/** - * System degradation levels. - * Order matters: higher numeric value = worse state. - */ enum class DegradationLevel : uint8_t { - NORMAL = 0, // Everything nominal - NO_WIFI = 1, // WiFi/MQTT disconnected — local operation still works - NO_TIME = 2, // NTP sync lost — timer-based scheduling degraded - NO_SENSOR = 3, // One or more temperature sensors failed — cautious defaults - CRITICAL = 4, // Multiple failures or critically low memory — safe mode + NORMAL = 0, + NO_WIFI = 1, + NO_TIME = 2, + NO_SENSOR = 3, + CRITICAL = 4, }; -/** - * Central health monitor. - * - * Typical usage in PoolController::loop(): - * @code - * SystemMonitor::feedWatchdog(); - * SystemMonitor::checkMemory(); - * DegradationManager::evaluate(); - * NetworkManager::loop(); - * @endcode - */ class DegradationManager { public: - /** - * Initialize with NORMAL level. - */ static void begin(); - - /** - * Evaluate current system state and transition if needed. - * Call this once per loop() iteration — internal rate-limiting - * prevents excessive checks. - */ static void evaluate(); - - /** - * Get current degradation level. - */ static DegradationLevel getLevel(); - - /** - * True when level >= CRITICAL — system should enter safe mode - * (all relays off, minimal operations only). - */ static bool isSafe(); - - /** - * Immediately force CRITICAL / safe mode. - * Used by boot-loop detection (P8) and other pre-initialization checks. - */ static void forceSafeMode(); - - /** - * Human-readable level name (for logging / MQTT). - */ static const char *levelToString(DegradationLevel level); - - /** - * Report sensor health status from a temperature node. - * Call with false when a sensor read fails, true on successful read. - * The @p nodeId distinguishes the two DallasTemperatureNode instances - * ("pool-temp", "solar-temp") so both must be healthy for sensorOk. - */ static void reportSensorStatus(const char *nodeId, bool valid); - - /** Exit forced safe mode — called when the boot-loop is cleared. */ static void unforceSafeMode(); private: - static bool sensorsEverReported_; + // Cross-core status: SensorTask writes on Core 0, control loop reads on + // Core 1. std::atomic gives the inter-core visibility volatile does not. + static std::atomic sensorsEverReported_; static DegradationLevel currentLevel_; static DegradationLevel previousLevel_; - static bool poolSensorOk_; - static bool solarSensorOk_; + static std::atomic poolSensorOk_; + static std::atomic solarSensorOk_; static bool forcedSafeMode_; static unsigned long lastEvaluationMs_; static constexpr unsigned long EVALUATION_INTERVAL_MS = 5000; - /** - * Called on every level transition. - * Logs the event and, if MQTT is connected, publishes a state update. - */ static void onTransition(); - - /** - * Determine the current level from first principles. - * Called at most once per EVALUATION_INTERVAL_MS. - */ static DegradationLevel evaluateLevel(); }; diff --git a/src/DisplayCoordinator.cpp b/src/DisplayCoordinator.cpp new file mode 100644 index 00000000..03589f38 --- /dev/null +++ b/src/DisplayCoordinator.cpp @@ -0,0 +1,184 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayCoordinator.cpp + * @brief Serialized NORVI button/UI state and OLED rendering on Core 1. + */ + +#include "DisplayCoordinator.hpp" + +#ifdef NORVI_AE01_R + +#include + +#include "ConfigManager.hpp" +#include "NetworkManager.hpp" +#include "Nodes.hpp" +#include "NorviButtonHandler.hpp" +#include "NorviOledDisplay.hpp" + +namespace PoolController { +namespace { + +enum class UiEvent : uint8_t { + BUTTON_1, + BUTTON_2, + BUTTON_3, + BUTTON_3_LONG, +}; + +constexpr size_t EVENT_CAPACITY = 8; +UiEvent eventQueue[EVENT_CAPACITY]{}; +size_t eventHead = 0; +size_t eventCount = 0; + +void enqueueEvent(UiEvent event) { + if (eventCount >= EVENT_CAPACITY) { + Serial.println("⚠ DisplayCoordinator: UI event queue full — dropping event"); + return; + } + eventQueue[(eventHead + eventCount) % EVENT_CAPACITY] = event; + eventCount++; +} + +bool dequeueEvent(UiEvent &event) { + if (eventCount == 0) { + return false; + } + event = eventQueue[eventHead]; + eventHead = (eventHead + 1) % EVENT_CAPACITY; + eventCount--; + return true; +} + +void handleButton1() { + if (NorviOledDisplay::isMenuActive()) { + NorviOledDisplay::menuPrevious(); + } else if (NorviOledDisplay::isSelectSensorStep()) { + NorviOledDisplay::setupSelectPrevious(); + NorviOledDisplay::requestRedraw(); + } else if (NorviOledDisplay::isSelectRoleStep()) { + NorviOledDisplay::setupSelectSolar(); + NorviOledDisplay::requestRedraw(); + } else { + NorviOledDisplay::previousPage(); + } +} + +void handleButton2() { + if (NorviOledDisplay::isMenuActive()) { + NorviOledDisplay::menuNext(); + } else if (NorviOledDisplay::isSelectSensorStep()) { + NorviOledDisplay::setupSelectNext(); + NorviOledDisplay::requestRedraw(); + } else if (NorviOledDisplay::isSelectRoleStep()) { + NorviOledDisplay::setupSelectPool(); + NorviOledDisplay::requestRedraw(); + } else { + NorviOledDisplay::nextPage(); + } +} + +void handleButton3() { + if (NorviOledDisplay::isMenuActive()) { + switch (NorviOledDisplay::getMenuSelection()) { + case NorviOledDisplay::MenuItem::MODE: { + const String ¤tMode = operationModeNode.getMode(); + if (currentMode == "auto") { + operationModeNode.setMode("manu"); + } else if (currentMode == "manu") { + operationModeNode.setMode("boost"); + } else if (currentMode == "boost") { + operationModeNode.setMode("timer"); + } else { + operationModeNode.setMode("auto"); + } + Serial.printf("→ Mode switched to: %s\n", operationModeNode.getMode().c_str()); + break; + } + case NorviOledDisplay::MenuItem::PUMP: + poolPumpNode.setSwitch(!poolPumpNode.getSwitch()); + Serial.printf("→ Pump toggled: %s\n", poolPumpNode.getSwitch() ? "ON" : "OFF"); + break; + case NorviOledDisplay::MenuItem::EXIT: + break; + } + NorviOledDisplay::exitMenu(); + } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::MAIN) { + NorviOledDisplay::enterMenu(); + } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::SENSOR_SETUP) { + NorviOledDisplay::confirmAction(); + } +} + +void handleButton3Long() { + if (!NorviOledDisplay::isMappingComplete()) { + return; + } + + uint8_t solarAddr[8]; + uint8_t poolAddr[8]; + NorviOledDisplay::getMapping(solarAddr, poolAddr); + ConfigManager::saveSensorMapping(solarAddr, poolAddr); + Serial.println("→ Sensor mapping saved — rebooting..."); + NetworkManager::restart(); +} + +void processEvent(UiEvent event) { + switch (event) { + case UiEvent::BUTTON_1: + handleButton1(); + break; + case UiEvent::BUTTON_2: + handleButton2(); + break; + case UiEvent::BUTTON_3: + handleButton3(); + break; + case UiEvent::BUTTON_3_LONG: + handleButton3Long(); + break; + } +} + +} // namespace + +void DisplayCoordinator::begin() { + NorviOledDisplay::begin(); + NorviButtonHandler::begin(); + + NorviButtonHandler::onButton1Press([]() { enqueueEvent(UiEvent::BUTTON_1); }); + NorviButtonHandler::onButton2Press([]() { enqueueEvent(UiEvent::BUTTON_2); }); + NorviButtonHandler::onButton3Press([]() { enqueueEvent(UiEvent::BUTTON_3); }); + NorviButtonHandler::onButton3LongPress([]() -> bool { + enqueueEvent(UiEvent::BUTTON_3_LONG); + return true; + }); +} + +void DisplayCoordinator::loop() { + // Button sampling, state transitions and rendering all run on the Arduino + // control-loop task. This preserves single-writer ownership of mutable UI, + // operation-mode, relay, network and configuration state. + NorviButtonHandler::loop(); + + UiEvent event; + while (dequeueEvent(event)) { + processEvent(event); + } + + NorviOledDisplay::update(); + NorviOledDisplay::render(); +} + +} // namespace PoolController + +#else + +namespace PoolController { +void DisplayCoordinator::begin() {} +void DisplayCoordinator::loop() {} +} // namespace PoolController + +#endif diff --git a/src/DisplayCoordinator.hpp b/src/DisplayCoordinator.hpp new file mode 100644 index 00000000..b9306af4 --- /dev/null +++ b/src/DisplayCoordinator.hpp @@ -0,0 +1,22 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file DisplayCoordinator.hpp + * @brief Owns NORVI button, UI-state and OLED rendering on Core 1. + */ + +#pragma once + +namespace PoolController { + +class DisplayCoordinator { +public: + /** Initialize the OLED and front-panel button callbacks. */ + static void begin(); + + /** Poll buttons, apply UI events and render on the control-loop task. */ + static void loop(); +}; + +} // namespace PoolController diff --git a/src/ESP32TemperatureNode.cpp b/src/ESP32TemperatureNode.cpp index 1c754818..c4528e1f 100644 --- a/src/ESP32TemperatureNode.cpp +++ b/src/ESP32TemperatureNode.cpp @@ -55,17 +55,20 @@ auto readInternalTemperatureCelsius(float &temperatureCelsius) -> bool { ESP32TemperatureNode::ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval) { _id = id; _name = name; - _measurementInterval = (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL; + _measurementInterval.store( + (measurementInterval > MIN_INTERVAL) ? measurementInterval : MIN_INTERVAL, std::memory_order_relaxed); _lastMeasurement = millis(); _temperature = NAN; } void ESP32TemperatureNode::begin() { + PoolController::SensorSlots::write(PoolController::SensorId::CONTROLLER, NAN, false); LOG_INFO("• ESP32 Internal Temp sensor '%s' initialized.\n", _id); } void ESP32TemperatureNode::loop() { - if (Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { + const unsigned long measurementInterval = getMeasurementInterval(); + if (Utils::shouldMeasure(_lastMeasurement, measurementInterval)) { _lastMeasurement = millis(); #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) @@ -85,6 +88,8 @@ void ESP32TemperatureNode::loop() { _temperature = (temp_farenheit - 32.0f) / 1.8f; #endif + const bool valid = !isnan(_temperature); + PoolController::SensorSlots::write(PoolController::SensorId::CONTROLLER, _temperature, valid); LOG_DEBUG("〽 ESP32 internal temp: %f °C\n", _temperature); } } diff --git a/src/ESP32TemperatureNode.hpp b/src/ESP32TemperatureNode.hpp index 23cd8913..6746b4fa 100644 --- a/src/ESP32TemperatureNode.hpp +++ b/src/ESP32TemperatureNode.hpp @@ -10,8 +10,11 @@ #pragma once #include +#include #include +#include "SensorSlots.hpp" + #if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 0, 0) extern "C" { uint8_t temprature_sens_read(); @@ -23,15 +26,16 @@ uint8_t temprature_sens_read(); * * Provides the on-die temperature of the ESP32 microcontroller. * Useful for monitoring enclosure temperature and detecting overheating. - * Uses the ESP32 internal temperature sensor (temprature_sens_read). + * The measured value is published through SensorSlots so readers on the + * control core never race with SensorTask on Core 0. */ class ESP32TemperatureNode { public: ESP32TemperatureNode(const char *id, const char *name, const int measurementInterval = MEASUREMENT_INTERVAL); - float getTemperature() const { return _temperature; } - void setMeasurementInterval(unsigned long interval) { _measurementInterval = interval; } - unsigned long getMeasurementInterval() const { return _measurementInterval; } + float getTemperature() const { return PoolController::SensorSlots::read(PoolController::SensorId::CONTROLLER); } + void setMeasurementInterval(unsigned long interval) { _measurementInterval.store(interval, std::memory_order_relaxed); } + unsigned long getMeasurementInterval() const { return _measurementInterval.load(std::memory_order_relaxed); } void begin(); void loop(); @@ -42,8 +46,9 @@ class ESP32TemperatureNode { const char *_id; const char *_name; - unsigned long _measurementInterval; + std::atomic _measurementInterval{MEASUREMENT_INTERVAL}; unsigned long _lastMeasurement; + // Written only by SensorTask. Cross-task readers use SensorSlots. float _temperature = NAN; }; diff --git a/src/NorviOledDisplay.cpp b/src/NorviOledDisplay.cpp index b96a0a7d..3bdf9def 100644 --- a/src/NorviOledDisplay.cpp +++ b/src/NorviOledDisplay.cpp @@ -28,7 +28,7 @@ #include "Utils.hpp" #include "NetworkManager.hpp" #include "Nodes.hpp" -#include "LogCapture.hpp" +#include "SensorSlots.hpp" #include "SystemMonitor.hpp" #include "TimeClientHelper.hpp" #include "ConfigManager.hpp" @@ -46,8 +46,8 @@ static void drawProgressBar(); // ═══════════════════════════════════════════════════════════════════════════ NorviOledDisplay::Page NorviOledDisplay::currentPage_ = Page::MAIN; -uint32_t NorviOledDisplay::lastUpdateMs_ = 0; -bool NorviOledDisplay::forceRedraw_ = true; +std::atomic NorviOledDisplay::lastUpdateMs_{0}; +std::atomic NorviOledDisplay::forceRedraw_{true}; uint32_t NorviOledDisplay::lastButtonPressMs_ = 0; @@ -262,16 +262,16 @@ static void drawScrollingText(int16_t x, int16_t y, const __FlashStringHelper *t // ═══════════════════════════════════════════════════════════════════════════ void NorviOledDisplay::begin() { - LOG_INFO("• NorviOledDisplay initializing on I2C GPIO16(SDA)/GPIO17(SCL)...\n"); + Serial.println("• NorviOledDisplay initializing on I2C GPIO16(SDA)/GPIO17(SCL)..."); Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL); if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { - LOG_ERROR("✖ NorviOledDisplay: SSD1306 allocation failed — display disabled\n"); + Serial.println("✖ NorviOledDisplay: SSD1306 allocation failed — display disabled"); return; } - LOG_INFO("✓ NorviOledDisplay initialized (128×64, address 0x3C)\n"); + Serial.println("✓ NorviOledDisplay initialized (128×64, address 0x3C)"); // ── Splash screen ────────────────────────────────────────────────────── display.clearDisplay(); @@ -288,10 +288,10 @@ void NorviOledDisplay::begin() { // ── Determine starting page based on first-boot state ────────────────── if (needsWiFiSetup()) { currentPage_ = Page::WIFI_SETUP; - LOG_INFO("→ First boot: no WiFi configured — showing WIFI_SETUP page\n"); + Serial.println("→ First boot: no WiFi configured — showing WIFI_SETUP page"); } else if (needsSensorMapping()) { currentPage_ = Page::SENSOR_SETUP; - LOG_INFO("→ First boot: sensors not mapped — showing SENSOR_SETUP page\n"); + Serial.println("→ First boot: sensors not mapped — showing SENSOR_SETUP page"); } else { currentPage_ = Page::MAIN; firstBootDone_ = true; @@ -303,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 ─────────────────────────────────── @@ -320,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() @@ -337,17 +339,21 @@ 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 ──────────────────────────────────────────── + // exchange() so a redraw requested by the control loop between check and + // reset is not lost + const bool forced = forceRedraw_.exchange(false); + if (!forced && (millis() - lastUpdateMs_ < UPDATE_INTERVAL_MS)) { return; } // ── Burn-in offset shift ──────────────────────────────────────────── updateBurnInOffset(); - lastUpdateMs_ = now; - forceRedraw_ = false; + lastUpdateMs_ = millis(); drawPage(); drawProgressBar(); @@ -415,7 +421,7 @@ void NorviOledDisplay::confirmAction() { setupStep_ = SetupStep::IDLE; // Check if both done if (setupSolarDone_ && setupPoolDone_) { - LOG_INFO("→ Both sensors assigned — save mapping via long-press S3\n"); + Serial.println("→ Both sensors assigned — save mapping via long-press S3"); } forceRedraw_ = true; } @@ -718,9 +724,10 @@ void NorviOledDisplay::drawMainPage() { // ── Pool temperature ──────────────────────────────────────────────────── display.setTextSize(2); dspCursor(TX, 0); - if (poolTemperatureNode.isSensorFound()) { + const SensorSlots::Reading poolReading = SensorSlots::snapshot(SensorId::POOL); + if (poolReading.found) { char buf[8]; - Utils::floatToString(poolTemperatureNode.getTemperature(), buf, sizeof(buf), 1); + Utils::floatToString(poolReading.value, buf, sizeof(buf), 1); display.print(buf); drawDegC(2); } else { @@ -738,9 +745,10 @@ void NorviOledDisplay::drawMainPage() { // ── Solar temperature ────────────────────────────────────────────────── display.setTextSize(2); dspCursor(TX, 28); - if (solarTemperatureNode.isSensorFound()) { + const SensorSlots::Reading solarReading = SensorSlots::snapshot(SensorId::SOLAR); + if (solarReading.found) { char buf[8]; - Utils::floatToString(solarTemperatureNode.getTemperature(), buf, sizeof(buf), 1); + Utils::floatToString(solarReading.value, buf, sizeof(buf), 1); display.print(buf); drawDegC(2); } else { @@ -1314,7 +1322,7 @@ bool NorviOledDisplay::setupApplyAssignment() { } memcpy(setupSolarAddr_, addr, 8); setupSolarDone_ = true; - LOG_INFO("→ Sensor assigned as Solar\n"); + Serial.println("→ Sensor assigned as Solar"); } else { // If this address is already assigned as Solar, clear that if (setupSolarDone_ && memcmp(addr, setupSolarAddr_, 8) == 0) { @@ -1323,7 +1331,7 @@ bool NorviOledDisplay::setupApplyAssignment() { } memcpy(setupPoolAddr_, addr, 8); setupPoolDone_ = true; - LOG_INFO("→ Sensor assigned as Pool\n"); + Serial.println("→ Sensor assigned as Pool"); } forceRedraw_ = true; diff --git a/src/NorviOledDisplay.hpp b/src/NorviOledDisplay.hpp index c593c44d..4a6d142d 100644 --- a/src/NorviOledDisplay.hpp +++ b/src/NorviOledDisplay.hpp @@ -27,6 +27,7 @@ #pragma once +#include #include #include @@ -75,11 +76,17 @@ 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 the Core-1 control task via DisplayCoordinator and reads + * temperatures from SensorSlots. + */ + static void render(); /** @brief Previous page (S1 / UP). */ static void previousPage(); @@ -219,9 +226,10 @@ class NorviOledDisplay { // ═════════════════════════════════════════════════════════════════════ static Page currentPage_; - static uint32_t lastUpdateMs_; + // Display timing/redraw flags are serialized by DisplayCoordinator on Core 1. + static std::atomic lastUpdateMs_; static constexpr uint32_t UPDATE_INTERVAL_MS{2000}; - static bool forceRedraw_; + static std::atomic forceRedraw_; // ── Idle auto-return ───────────────────────────────────────────────── static uint32_t lastButtonPressMs_; diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 57666c26..65cab68c 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -34,18 +34,14 @@ #include "OtaUpdater.hpp" #include "Utils.hpp" #include "WpsProvisioner.hpp" -#include "LogCapture.hpp" #include "StatusLed.hpp" -#ifdef NORVI_AE01_R -#include "NorviOledDisplay.hpp" -#include "NorviButtonHandler.hpp" -#include "CalibrationManager.hpp" -#endif +#include "CoreScheduler.hpp" +#include "TelemetryQueue.hpp" -#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) -#include "OlimexLocalUi.hpp" +#ifdef NORVI_AE01_R +#include "DisplayCoordinator.hpp" #endif #include "Config.hpp" @@ -67,10 +63,6 @@ ESP32TemperatureNode ctrlTemperatureNode("controller-temp", "Controller Temperat // NORVI AE01-R uses active-HIGH relays (HIGH = relay ON, LOW = relay OFF) RelayModuleNode poolPumpNode("pool-pump", "Pool Pump", PIN_RELAY_POOL, false); RelayModuleNode solarPumpNode("solar-pump", "Solar Pump", PIN_RELAY_SOLAR, false); -#elif defined(OLIMEX_ESP32_C6_EVB) -// Olimex relay outputs are active-HIGH -RelayModuleNode poolPumpNode("pool-pump", "Pool Pump", PIN_RELAY_POOL, false); -RelayModuleNode solarPumpNode("solar-pump", "Solar Pump", PIN_RELAY_SOLAR, false); #else // Standard external relay modules use active-LOW (LOW = relay ON, HIGH = relay OFF) RelayModuleNode poolPumpNode("pool-pump", "Pool Pump", PIN_RELAY_POOL); @@ -125,21 +117,39 @@ static void loadSensorAddressMapping() { solarTemperatureNode.setAddressFilter(solarAddr); char buf[17]; addressToString(solarAddr, buf, sizeof(buf)); - LOG_INFO("• Sensor mapping: Solar address loaded [%s]\n", buf); + Serial.printf("• Sensor mapping: Solar address loaded [%s]\n", buf); } if (plen == 8 && !isAddressZero(poolAddr)) { poolTemperatureNode.setAddressFilter(poolAddr); char buf[17]; addressToString(poolAddr, buf, sizeof(buf)); - LOG_INFO("• Sensor mapping: Pool address loaded [%s]\n", buf); + Serial.printf("• Sensor mapping: Pool address loaded [%s]\n", buf); } if ((slen == 8 && !isAddressZero(solarAddr)) || (plen == 8 && !isAddressZero(poolAddr))) { - LOG_INFO("• Sensor mapping: address filters applied (one or both sensors)\n"); + Serial.println("• Sensor mapping: address filters applied (one or both sensors)"); } else { - LOG_WARN("• Sensor mapping: no addresses configured — using default device indices\n"); - LOG_INFO(" ℹ To configure: long-press Button 1 → Sensor Setup page → assign sensors\n"); + Serial.println("• Sensor mapping: no addresses configured — using default device indices"); + Serial.println(" ℹ To configure: long-press Button 1 → Sensor Setup page → assign sensors"); + } +} + +/** @brief Service deferred MQTT publish requests on the Core-1 owner task. */ +static void serviceTelemetryQueue() { + PublishRequestKind kind; + while (TelemetryQueue::instance().dequeue(kind)) { + // Preserve the previous OTA behavior: requests are drained but not sent + // while the updater owns the network path. + if (OtaUpdater::isUpdateInProgress()) { + continue; + } + + if (kind == PublishRequestKind::DISCOVERY) { + MqttPublisher::publishDiscovery(); + } else { + MqttPublisher::publishStates(); + } } } @@ -153,9 +163,7 @@ PoolControllerContext::PoolControllerContext() { Self = this; } -/** - * @brief Destroy the context and clear the instance pointer. - */ +/** @brief Destroy the context and clear the instance pointer. */ PoolControllerContext::~PoolControllerContext() { assert(Self); Self = nullptr; @@ -176,33 +184,33 @@ auto PoolControllerContext::initializeController() -> void { continue; } #endif - LOG_ERROR("✖ PIN CONFLICT: %s (pin %d) and %s (pin %d) use same pin!\n", pinNames[i], pins[i], pinNames[j], pins[j]); + Serial.printf("✖ PIN CONFLICT: %s (pin %d) and %s (pin %d) use same pin!\n", pinNames[i], pins[i], pinNames[j], pins[j]); pinConflict = true; } } } if (pinConflict) { - LOG_ERROR("✖ FATAL: Pin configuration conflicts detected!\n"); - LOG_WARN(" System will reboot in 5 seconds to try and recover...\n"); + Serial.println("✖ FATAL: Pin configuration conflicts detected!"); + Serial.println(" System will reboot in 5 seconds to try and recover..."); Serial.flush(); delay(5000); ESP.restart(); // F27 Fix! Clean restart instead of blocking WDT loop } else { - LOG_INFO("✓ Pin configuration validated - no conflicts (optimierte Belegung)\n"); - LOG_INFO(" Solar Temp (DS18B20): GPIO %d\n", PIN_DS_SOLAR); + Serial.println("✓ Pin configuration validated - no conflicts (optimierte Belegung)"); + Serial.printf(" Solar Temp (DS18B20): GPIO %d\n", PIN_DS_SOLAR); + Serial.printf(" Pool Temp (DS18B20): GPIO %d", PIN_DS_POOL); #ifdef NORVI_AE01_R - LOG_INFO(" Pool Temp (DS18B20): GPIO %d (shared bus via GPIO25)\n", PIN_DS_POOL); -#else - LOG_INFO(" Pool Temp (DS18B20): GPIO %d\n", PIN_DS_POOL); + Serial.print(" (shared bus via GPIO25)"); #endif - LOG_INFO(" Pool Pump (Relay): GPIO %d\n", PIN_RELAY_POOL); - LOG_INFO(" Solar Pump (Relay): GPIO %d\n", PIN_RELAY_SOLAR); + Serial.println(); + Serial.printf(" Pool Pump (Relay): GPIO %d\n", PIN_RELAY_POOL); + Serial.printf(" Solar Pump (Relay): GPIO %d\n", PIN_RELAY_SOLAR); + Serial.printf(" Status LED: GPIO %d", PIN_LED_STATUS); #ifdef LED_BUILTIN - LOG_INFO(" Status LED: GPIO %d (LED_BUILTIN)\n", PIN_LED_STATUS); -#else - LOG_INFO(" Status LED: GPIO %d\n", PIN_LED_STATUS); + Serial.print(" (LED_BUILTIN)"); #endif + Serial.println(); } // Set measurement intervals and propagate to all nodes @@ -226,8 +234,6 @@ auto PoolControllerContext::initializeController() -> void { #ifdef NORVI_AE01_R // Initialize the shared OneWire bus before individual node begin() calls. - // Both DS18B20 sensors live on the same GPIO25 bus — the shared sensor - // instance scans all devices and each node reads by device index. sharedDallasSensor.begin(); #endif @@ -240,13 +246,12 @@ auto PoolControllerContext::initializeController() -> void { operationModeNode.begin(); // Load properties into Operation Mode - operationModeNode.setMode(ConfigManager::getSettings().opMode.c_str(), "boot:config"); + operationModeNode.setMode(ConfigManager::getSettings().opMode.c_str()); operationModeNode.setPoolMaxTemperature(ConfigManager::getSettings().tempMaxPool); operationModeNode.setSolarMinTemperature(ConfigManager::getSettings().tempMinSolar); operationModeNode.setTemperatureHysteresis(ConfigManager::getSettings().tempHysteresis); // TimerSetting is loaded from NVS by OperationModeNode::begin() — no override needed - operationModeNode.setPoolTemperatureNode(&poolTemperatureNode); operationModeNode.setSolarTemperatureNode(&solarTemperatureNode); @@ -259,134 +264,30 @@ auto PoolControllerContext::initializeController() -> void { _lastMeasurement = 0; } -/** - * @brief Full initialization sequence called once at boot. - * - * Order: - * 1. StateManager (NVS), SystemMonitor, DegradationManager - * 2. Boot-loop detection — forces safe mode if detected - * 3. ConfigManager (LittleFS config.json) - * 4. NetworkManager (WiFi + MQTT) - * 5. WebPortal (HTTP server + captive portal) - * 6. MqttPublisher (HA Discovery) - * 7. OtaUpdater (GitHub release check) - * 8. initializeController() — pins, nodes, rules - * 9. Load persisted operational state from NVS - */ +/** @brief Full initialization sequence called once at boot. */ auto PoolControllerContext::setup() -> void { - // Initialize Preferences (NVS), System Monitor and Degradation tracker StateManager::begin(); SystemMonitor::begin(); DegradationManager::begin(); - // Initialize Status-LED with Homie-compatible blink codes StatusLed::begin(); - // Load persistent sensor address mapping from NVS early. - // Must run before NorviOledDisplay::begin() so that first-boot detection - // (needsSensorMapping) correctly checks whether sensors are assigned. + // Load mapping before the display first-boot flow evaluates it. loadSensorAddressMapping(); - - // Initialize persisted config (WiFi, MQTT, NTP, settings). - // Must run before NorviOledDisplay::begin() for needsWiFiSetup() check. ConfigManager::begin(); #ifdef NORVI_AE01_R - // Initialize NORVI-specific peripherals (OLED display + front buttons) - NorviOledDisplay::begin(); - NorviButtonHandler::begin(); - CalibrationManager::begin(); - - // Wire button callbacks (S1=UP, S2=DOWN, S3=ACTION) - // ── S1 (UP) ─────────────────────────────────────────────────────────── - NorviButtonHandler::onButton1Press([]() { - if (NorviOledDisplay::isMenuActive()) { - NorviOledDisplay::menuPrevious(); - } else if (NorviOledDisplay::isSelectSensorStep()) { - NorviOledDisplay::setupSelectPrevious(); - NorviOledDisplay::requestRedraw(); - } else if (NorviOledDisplay::isSelectRoleStep()) { - NorviOledDisplay::setupSelectSolar(); - NorviOledDisplay::requestRedraw(); - } else { - NorviOledDisplay::previousPage(); - } - }); - // ── S2 (DOWN) ───────────────────────────────────────────────────────── - NorviButtonHandler::onButton2Press([]() { - if (NorviOledDisplay::isMenuActive()) { - NorviOledDisplay::menuNext(); - } else if (NorviOledDisplay::isSelectSensorStep()) { - NorviOledDisplay::setupSelectNext(); - NorviOledDisplay::requestRedraw(); - } else if (NorviOledDisplay::isSelectRoleStep()) { - NorviOledDisplay::setupSelectPool(); - NorviOledDisplay::requestRedraw(); - } else { - NorviOledDisplay::nextPage(); - } - }); - // ── S3 (CONFIRM) ────────────────────────────────────────────────────── - NorviButtonHandler::onButton3Press([]() { - if (NorviOledDisplay::isMenuActive()) { - // Execute selected menu action, then return to MAIN - NorviOledDisplay::Page prevPage = NorviOledDisplay::getCurrentPage(); - switch (NorviOledDisplay::getMenuSelection()) { - case NorviOledDisplay::MenuItem::MODE: { - // Cycle operation mode - const String ¤tMode = operationModeNode.getMode(); - if (currentMode == "auto") { - operationModeNode.setMode("manu", "button:S3/menu"); - } else if (currentMode == "manu") { - operationModeNode.setMode("boost", "button:S3/menu"); - } else if (currentMode == "boost") { - operationModeNode.setMode("timer", "button:S3/menu"); - } else { - operationModeNode.setMode("auto", "button:S3/menu"); - } - LOG_INFO("→ Mode switched to: %s\n", operationModeNode.getMode().c_str()); - break; - } - case NorviOledDisplay::MenuItem::PUMP: - // Toggle pool pump - poolPumpNode.setSwitch(!poolPumpNode.getSwitch()); - LOG_INFO("→ Pump toggled: %s\n", poolPumpNode.getSwitch() ? "ON" : "OFF"); - break; - case NorviOledDisplay::MenuItem::EXIT: - // No action — just exit - break; - } - NorviOledDisplay::exitMenu(); - } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::MAIN) { - // MAIN page: open action menu - NorviOledDisplay::enterMenu(); - } else if (NorviOledDisplay::getCurrentPage() == NorviOledDisplay::Page::SENSOR_SETUP) { - // Sensor setup: advance the wizard - NorviOledDisplay::confirmAction(); - } - // Other info pages: S3 intentionally does nothing - }); - // ── S3 long-press: save sensor mapping & reboot ─────────────────────── - NorviButtonHandler::onButton3LongPress([]() -> bool { - if (NorviOledDisplay::isMappingComplete()) { - uint8_t solarAddr[8], poolAddr[8]; - NorviOledDisplay::getMapping(solarAddr, poolAddr); - ConfigManager::saveSensorMapping(solarAddr, poolAddr); - LOG_INFO("→ Sensor mapping saved — rebooting...\n"); - NetworkManager::restart(); - return true; - } - return false; - }); + // Button sampling, UI transitions and OLED rendering stay on the Core-1 + // owner task through one coordinator. + DisplayCoordinator::begin(); #endif // --- Boot-loop detection --- bootLoopDetected_ = SystemMonitor::detectBootLoop(); if (bootLoopDetected_) { - LOG_ERROR("✖ SAFE MODE ACTIVE — all relays forced OFF\n"); + Serial.println("✖ SAFE MODE ACTIVE — all relays forced OFF"); DegradationManager::forceSafeMode(); - // Clear stored relay states Preferences prefs; prefs.begin("pool-pump", false); prefs.clear(); @@ -396,66 +297,37 @@ auto PoolControllerContext::setup() -> void { prefs.end(); } - // Start WiFi/WPS and MQTT services NetworkManager::begin(); - - // Start Captive Setup Web Portal WebPortal::begin(); - - // Start Home Assistant Discovery stack MqttPublisher::begin(); - - // Start OTA update checker OtaUpdater::begin(); - // Suppress NVS persistence during setup initialization OperationModeNode::suppressPersist(true); - - // Initialize core drivers and parameters initializeController(); // Print address mapping status after node begin() resolved the filters if (solarTemperatureNode.hasAddressFilter() || poolTemperatureNode.hasAddressFilter()) { char buf[17]; solarTemperatureNode.getDeviceAddressString(buf, sizeof(buf)); - LOG_INFO(" ◦ Solar node → device [%s] (status: %s)\n", buf, solarTemperatureNode.isSensorFound() ? "✓" : "✖"); + Serial.printf(" ◦ Solar node → device [%s] (status: %s)\n", buf, solarTemperatureNode.isSensorFound() ? "✓" : "✖"); poolTemperatureNode.getDeviceAddressString(buf, sizeof(buf)); - LOG_INFO(" ◦ Pool node → device [%s] (status: %s)\n", buf, poolTemperatureNode.isSensorFound() ? "✓" : "✖"); + Serial.printf(" ◦ Pool node → device [%s] (status: %s)\n", buf, poolTemperatureNode.isSensorFound() ? "✓" : "✖"); } OperationModeNode::suppressPersist(false); - - // Load operational settings from NVS Preferences operationModeNode.loadState(); - -#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) - OlimexLocalUi::begin(); -#endif - - // OTA safety: detect version transition and verify config integrity ConfigManager::logOtaTransition(); - LOG_INFO("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); + // Start the dedicated Core-0 sensor I/O task. + CoreScheduler::begin(); + + Serial.printf("✓ Controller setup completed. Free heap: %u B\n", ESP.getFreeHeap()); } -/** - * @brief Main control loop — runs every iteration of Arduino loop(). - * - * Order: - * 1. Feed watchdog + check free memory (SystemMonitor) - * 2. Evaluate degradation levels (DegradationManager) - * 3. Clear boot-loop counter after 5 min stable uptime - * 4. Run managers: NetworkManager, WebPortal, OtaUpdater - * 5. Run nodes: sensors, relays, operation mode (triggers rule engine) - * 6. Publish HA Discovery + states on MQTT (re)connect - * 7. Periodically publish telemetry states to MQTT (every loopInterval s) - */ +/** @brief Main control loop — only sensor acquisition runs on the Core-0 worker. */ auto PoolControllerContext::loop() -> void { - // Feed watchdog and check memory thresholds SystemMonitor::feedWatchdog(); SystemMonitor::checkMemory(); - - // Evaluate degradation levels and health DegradationManager::evaluate(); // Stable bootloop counter cleanup after 5 minutes @@ -466,21 +338,19 @@ auto PoolControllerContext::loop() -> void { bootCounterCleared = true; SystemMonitor::clearBootLoopCounter(); if (bootLoopDetected_) { - LOG_INFO("→ Safe-mode: 5 min stable — boot-loop counter cleared\n"); + Serial.println("→ Safe-mode: 5 min stable — boot-loop counter cleared"); DegradationManager::unforceSafeMode(); bootLoopDetected_ = false; } lastBootClear = millis(); } - // Run managers + // Network callback only queues commands; state mutations are serialized here. NetworkManager::loop(); - // Handle MQTT commands received on the AsyncTCP task (state changes only on the loop task) MqttPublisher::processPendingCommands(); WebPortal::loop(); OtaUpdater::loop(); - // --- Status-LED: Pattern an Systemzustand anpassen (Homie-Convention) --- if (OtaUpdater::isUpdateInProgress()) { StatusLed::setPattern(StatusLedPattern::OTA_UPDATE); } else if (bootLoopDetected_ || DegradationManager::isSafe()) { @@ -496,42 +366,33 @@ auto PoolControllerContext::loop() -> void { } StatusLed::loop(); -#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) - OlimexLocalUi::loop(); -#endif - #ifdef NORVI_AE01_R - // Update NORVI OLED display and read front-panel buttons - NorviOledDisplay::loop(); - NorviButtonHandler::loop(); - CalibrationManager::loop(); + DisplayCoordinator::loop(); #endif - // Run drivers & logic rules - solarTemperatureNode.loop(); - poolTemperatureNode.loop(); - ctrlTemperatureNode.loop(); poolPumpNode.loop(); solarPumpNode.loop(); operationModeNode.loop(); - // Handle Home Assistant Connection State transition + // Handle Home Assistant connection transition. static bool wasMqttConnected = false; - bool currentMqttState = NetworkManager::isMqttConnected(); + const bool currentMqttState = NetworkManager::isMqttConnected(); if (currentMqttState && !wasMqttConnected) { - // Freshly connected to MQTT: publish Discovery and States - MqttPublisher::publishDiscovery(); - MqttPublisher::publishStates(); + 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; serialization is serviced below on Core 1. if (currentMqttState && Utils::shouldMeasure(_lastMeasurement, _measurementInterval)) { _lastMeasurement = millis(); - MqttPublisher::publishStates(); + TelemetryQueue::instance().enqueue(PublishRequestKind::STATES); } + + serviceTelemetryQueue(); + 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/SensorCycle.hpp b/src/SensorCycle.hpp new file mode 100644 index 00000000..51b3d244 --- /dev/null +++ b/src/SensorCycle.hpp @@ -0,0 +1,36 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorCycle.hpp + * @brief One DS18B20 measurement cycle for both bus topologies. + * + * - Shared bus (NORVI AE01-R): solar is the bus master and its + * beginMeasurement() starts one conversion for both sensors; the pool + * node's beginMeasurement() is a no-op. + * - Dedicated buses (esp32dev): each node starts the conversion on its own + * bus, so both beginMeasurement() calls are required. Both conversions run + * in parallel during the single wait. + * + * Templated so the scheduling contract is covered by native tests with fake + * nodes for both topologies. + */ + +#pragma once + +namespace PoolController { + +/** @brief Select the shorter interval so either missing sensor keeps fast recovery. */ +constexpr unsigned long selectDallasCycleInterval(unsigned long solarInterval, unsigned long poolInterval) { + return (solarInterval < poolInterval) ? solarInterval : poolInterval; +} + +template void runDallasMeasurementCycle(Node &solar, Node &pool, WaitFn waitForConversion) { + solar.beginMeasurement(); + pool.beginMeasurement(); + waitForConversion(); + solar.finishMeasurement(); + pool.finishMeasurement(); +} + +} // namespace PoolController diff --git a/src/SensorSlots.cpp b/src/SensorSlots.cpp new file mode 100644 index 00000000..5c36968c --- /dev/null +++ b/src/SensorSlots.cpp @@ -0,0 +1,69 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file SensorSlots.cpp + * @brief Cross-task temperature slots with consistent snapshots. + */ + +#include "SensorSlots.hpp" + +#include + +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +#include +#include +#else +#include +#endif + +namespace PoolController { + +namespace { +#if defined(ESP32) || defined(ARDUINO_ARCH_ESP32) +portMUX_TYPE slotsMux = portMUX_INITIALIZER_UNLOCKED; +inline void lockSlots() { + portENTER_CRITICAL(&slotsMux); +} +inline void unlockSlots() { + portEXIT_CRITICAL(&slotsMux); +} +#else +std::mutex slotsMutex; +inline void lockSlots() { + slotsMutex.lock(); +} +inline void unlockSlots() { + slotsMutex.unlock(); +} +#endif +} // namespace + +SensorSlots::Reading SensorSlots::slots_[static_cast(SensorId::COUNT)] = { + {NAN, false}, + {NAN, false}, + {NAN, false}, +}; + +void SensorSlots::reset() { + lockSlots(); + for (auto &slot : slots_) { + slot = {NAN, false}; + } + unlockSlots(); +} + +void SensorSlots::write(SensorId id, float value, bool found) { + lockSlots(); + slots_[static_cast(id)] = {value, found}; + unlockSlots(); +} + +SensorSlots::Reading SensorSlots::snapshot(SensorId id) { + lockSlots(); + Reading reading = slots_[static_cast(id)]; + unlockSlots(); + return reading; +} + +} // namespace PoolController diff --git a/src/SensorSlots.hpp b/src/SensorSlots.hpp new file mode 100644 index 00000000..4ed666b6 --- /dev/null +++ b/src/SensorSlots.hpp @@ -0,0 +1,59 @@ +// 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 slots for sensor values shared across tasks. + * + * Single writer (SensorTask on Core 0), multiple readers (control loop, + * display). Value and found flag are written and read together under a + * short critical section (portMUX on ESP32, std::mutex in native tests), so + * readers always see a consistent snapshot of one measurement. `volatile` + * would give neither inter-core ordering nor a consistent pair. + */ +class SensorSlots { +public: + /** @brief A consistent value/found pair of one measurement. */ + struct Reading { + float value; ///< °C, NAN if unknown + bool found; ///< Sensor present and reading valid + }; + + /** @brief Reset all slots to NaN / not-found (tests only). */ + static void reset(); + + /** @brief Writer: publish a new value. */ + static void write(SensorId id, float value, bool found); + + /** @brief Reader: value and found flag of the same measurement. */ + static Reading snapshot(SensorId id); + + /** @brief Reader: get the latest value (°C, NAN if unknown). */ + static float read(SensorId id) { return snapshot(id).value; } + + /** @brief Reader: check whether the sensor is currently found. */ + static bool isFound(SensorId id) { return snapshot(id).found; } + +private: + static Reading slots_[static_cast(SensorId::COUNT)]; +}; + +} // namespace PoolController diff --git a/src/SensorTask.cpp b/src/SensorTask.cpp new file mode 100644 index 00000000..857abe42 --- /dev/null +++ b/src/SensorTask.cpp @@ -0,0 +1,85 @@ +// 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 "SensorCycle.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 lastDallasReadingMs = 0; +uint32_t lastControllerReadingMs = 0; +constexpr uint32_t CONVERSION_DELAY_MS = 800; // >= 12-bit DS18B20 conversion +} // namespace + +void sensorTaskFunc(void *) { + const bool watchdogRegistered = SystemMonitor::registerCurrentTaskWithWatchdog(); + + for (;;) { + const uint32_t now = millis(); + + // Both Dallas nodes are measured in one cycle. Preserve the old recovery + // behavior by using the shorter effective interval whenever either node is + // missing/invalid (RECOVERY_INTERVAL = 5 s). + const unsigned long solarInterval = solarTemperatureNode.getEffectiveMeasurementInterval(); + const unsigned long poolInterval = poolTemperatureNode.getEffectiveMeasurementInterval(); + const unsigned long dallasInterval = selectDallasCycleInterval(solarInterval, poolInterval); + + if (now - lastDallasReadingMs >= dallasInterval * 1000UL) { + lastDallasReadingMs = now; + Serial.println("〽 SensorTask: reading Dallas sensors"); + // DallasTemperature is configured with waitForConversion=false in + // DallasTemperatureNode::begin(), so the conversion starts immediately + // and this task yields while the sensors convert. + runDallasMeasurementCycle(solarTemperatureNode, poolTemperatureNode, [watchdogRegistered] { + vTaskDelay(pdMS_TO_TICKS(CONVERSION_DELAY_MS)); + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); + } + }); + } + + // ESP32 internal temperature on its own interval. + const unsigned long ctrlInterval = ctrlTemperatureNode.getMeasurementInterval(); + if (now - lastControllerReadingMs >= ctrlInterval * 1000UL) { + lastControllerReadingMs = now; + ctrlTemperatureNode.loop(); + } + + if (watchdogRegistered) { + SystemMonitor::feedWatchdogFromTask(); + } + vTaskDelay(pdMS_TO_TICKS(100)); + } +} + +bool SensorTask::start(uint8_t priority, uint16_t stackBytes, BaseType_t core) { + return xTaskCreatePinnedToCore(sensorTaskFunc, "sensor", stackBytes, nullptr, priority, &sensorTaskHandle, core) == pdPASS; +} + +void SensorTask::logStackWatermark() { + 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..4184a0d3 --- /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. @return false if it could not be created. */ + static bool start(uint8_t priority, uint16_t stackBytes, BaseType_t core); + + /** @brief Log the task's stack high-water mark. */ + static void logStackWatermark(); +}; + +} // namespace PoolController diff --git a/src/SystemMonitor.hpp b/src/SystemMonitor.hpp index b87d6a1f..f51899dd 100644 --- a/src/SystemMonitor.hpp +++ b/src/SystemMonitor.hpp @@ -9,15 +9,6 @@ #pragma once -/** - * Watchdog and Memory Monitor for 24/7 Operation - * - * Monitors memory usage and automatically reboots if memory gets critically low. - * Provides watchdog functionality to detect system hangs. - * - * ESP8266 support was removed in v3.2.0. - */ - #include #include #include @@ -27,9 +18,6 @@ namespace PoolController { -/** - * Memory and Watchdog Monitor - */ class SystemMonitor { private: static constexpr uint32_t LOW_MEMORY_THRESHOLD = 16384; @@ -40,15 +28,6 @@ class SystemMonitor { static bool lowMemoryWarning; public: - /** - * Initialize system monitor and watchdog. - * ESP32 TWDT: 30-second timeout, panic on timeout. - * - * ESP-IDF 5.x changed the WDT API to use a config struct, and the Arduino - * framework pre-initialises the TWDT before setup() runs. Use - * esp_task_wdt_reconfigure() on ESP-IDF ≥ 5 so we can adjust the timeout - * without failing with ESP_ERR_INVALID_STATE. - */ static void begin() { lastMemoryCheck = 0; minFreeHeap = ESP.getFreeHeap(); @@ -64,35 +43,36 @@ class SystemMonitor { #else esp_task_wdt_init(30, true); #endif - esp_task_wdt_add(NULL); + registerCurrentTaskWithWatchdog(); + } + + /** Subscribe the calling FreeRTOS task to the task watchdog. */ + static bool registerCurrentTaskWithWatchdog() { + const esp_err_t err = esp_task_wdt_add(NULL); + if (err == ESP_OK) { + return true; + } + LOG_ERROR("Task watchdog registration failed: 0x%x\n", static_cast(err)); + return false; } - /** - * Feed the watchdog — call this regularly in main loop - */ static void feedWatchdog() { esp_task_wdt_reset(); } - /** - * Check memory status and reboot if critically low. - * Call this periodically (e.g., every 10 seconds). - */ + /** Feed the watchdog from a task that registered itself successfully. */ + static void feedWatchdogFromTask() { esp_task_wdt_reset(); } + static void checkMemory() { uint32_t now = millis(); - - // Check every 10 seconds if (now - lastMemoryCheck < 10000) { return; } lastMemoryCheck = now; uint32_t freeHeap = ESP.getFreeHeap(); - - // Track minimum heap if (freeHeap < minFreeHeap) { minFreeHeap = freeHeap; } - // Critical memory — reboot immediately if (freeHeap < CRITICAL_MEMORY_THRESHOLD) { LOG_ERROR("CRITICAL: Free heap %d bytes < %d bytes. Rebooting...\n", freeHeap, CRITICAL_MEMORY_THRESHOLD); Serial.flush(); @@ -100,24 +80,17 @@ class SystemMonitor { ESP.restart(); } - // Low memory — log warning if (freeHeap < LOW_MEMORY_THRESHOLD && !lowMemoryWarning) { - LOG_WARN("WARNING: Low memory detected. Free heap: %d bytes " - "(min: %d)\n", - freeHeap, minFreeHeap); + LOG_WARN("WARNING: Low memory detected. Free heap: %d bytes (min: %d)\n", freeHeap, minFreeHeap); lowMemoryWarning = true; } else if (freeHeap >= LOW_MEMORY_THRESHOLD && lowMemoryWarning) { lowMemoryWarning = false; } } - /** Get current free heap */ static uint32_t getFreeHeap() { return ESP.getFreeHeap(); } - - /** Get minimum free heap since boot */ static uint32_t getMinFreeHeap() { return minFreeHeap; } - /** Force a reboot */ static void reboot() { LOG_INFO("System reboot requested\n"); Serial.flush(); @@ -125,37 +98,16 @@ class SystemMonitor { ESP.restart(); } - /** Get uptime in seconds */ static uint32_t getUptimeSeconds() { return millis() / 1000; } - - /** Check if system is healthy */ static bool isHealthy() { return ESP.getFreeHeap() >= LOW_MEMORY_THRESHOLD; } - // --- Boot-loop detection (P8) --- - - /** Number of consecutive boots before safe mode activates */ static constexpr uint8_t BOOT_LOOP_MAX_COUNT = 3; + static constexpr uint32_t BOOT_LOOP_CLEAR_AFTER_SEC = 300; - /** Minimum uptime (seconds) before clearing the boot-loop counter */ - static constexpr uint32_t BOOT_LOOP_CLEAR_AFTER_SEC = 300; // 5 min - - /** - * Detect boot-loop pattern. - * Call this as early as possible in setup(), before MQTT/network initializes. - * - * Increments a persistent boot counter in NVS on every boot. - * Returns true when BOOT_LOOP_MAX_COUNT consecutive boots have occurred - * without a reset (which happens after stable uptime in loop()). - * - * The counter is reset to 0 by clearBootLoopCounter(), called from - * PoolController::loop() after BOOT_LOOP_CLEAR_AFTER_SEC of stable operation. - */ static bool detectBootLoop() { Preferences prefs; prefs.begin("sysmon", false); - int bootCount = prefs.getInt("bootCount", 0) + 1; - LOG_INFO(" Boot counter: %d\n", bootCount); bool isBootLoop = (bootCount >= BOOT_LOOP_MAX_COUNT); @@ -166,15 +118,9 @@ class SystemMonitor { prefs.putInt("bootCount", bootCount); prefs.end(); - return isBootLoop; } - /** - * Clear the boot-loop counter. - * Called from PoolController::loop() after BOOT_LOOP_CLEAR_AFTER_SEC seconds - * of stable operation to indicate a healthy boot. - */ static void clearBootLoopCounter() { Preferences prefs; prefs.begin("sysmon", false); diff --git a/src/TelemetryQueue.cpp b/src/TelemetryQueue.cpp new file mode 100644 index 00000000..cdae4fcb --- /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 Bounded 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) % SLOTS; + 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) % SLOTS, std::memory_order_release); + return true; +} + +size_t TelemetryQueue::count() const { + const size_t head = head_.load(std::memory_order_acquire); + const size_t tail = tail_.load(std::memory_order_acquire); + return (tail + SLOTS - head) % SLOTS; +} + +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..99f2b492 --- /dev/null +++ b/src/TelemetryQueue.hpp @@ -0,0 +1,66 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file TelemetryQueue.hpp + * @brief Bounded queue for deferred 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 Fixed-capacity ring buffer of publish requests. + * + * Non-blocking: enqueue on a full queue drops the request and returns false + * (the periodic publish cadence simply skips a beat — safe by design). + * Publishing is serviced on the Core-1 control-loop task so MqttPublisher never + * reads mutable controller state concurrently from another core. + */ +class TelemetryQueue { +public: + static constexpr size_t CAPACITY = 8; ///< Fixed slots — no dynamic allocation + + /** @brief Construct an empty queue. */ + TelemetryQueue() { reset(); } + + /** @brief Process-wide queue for deferred telemetry requests. */ + static TelemetryQueue &instance() { + static TelemetryQueue queue; + return queue; + } + + /** @brief Enqueue a publish request. @return false if full (dropped). */ + bool enqueue(PublishRequestKind kind); + + /** @brief Dequeue a publish request. @return false if empty. */ + bool dequeue(PublishRequestKind &kind); + + /** @brief Number of requests currently queued. */ + size_t count() const; + + /** @brief Empty the queue (tests only — must not run while in use). */ + void reset(); + +private: + /// One slot stays free to tell "full" from "empty", so indices run over + /// [0, CAPACITY] and the storage needs CAPACITY + 1 slots. + static constexpr size_t SLOTS = CAPACITY + 1; + + std::atomic head_{0}; + std::atomic tail_{0}; + PublishRequestKind items_[SLOTS] = {}; ///< Fixed ring storage +}; + +} // namespace PoolController diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index e35ad22e..ac80471c 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -33,12 +33,12 @@ include_directories( # ── Wrappers ────────────────────────────────────────────────────────────── # WebPortal, MqttPublisher, Timer, and CalibrationManager include class -# headers whose mock layout differs from production. Compiling from +# headers whose mock layout differs from production. Compiling from # wrappers/ avoids GCC's "current-file-directory first" rule for # #include "..." so the mock headers take effect. -file(COPY ${PROJ_ROOT}/src/WebPortal.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) +file(COPY ${PROJ_ROOT}/src/WebPortal.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/MqttPublisher.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) -file(COPY ${PROJ_ROOT}/src/Timer.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) +file(COPY ${PROJ_ROOT}/src/Timer.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/CalibrationManager.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/NorviButtonHandler.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) @@ -51,8 +51,6 @@ set(WRAPPER_SOURCES ) # ── Service classes ─────────────────────────────────────────────────────── -# Compiled from src/ directly (their own headers are in src/, so they find -# the production header first — only Arduino-level mocks are needed). set(SERVICE_SOURCES ${PROJ_ROOT}/src/StateManager.cpp ${PROJ_ROOT}/src/DegradationManager.cpp @@ -60,26 +58,28 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/OtaUpdater.cpp ${PROJ_ROOT}/src/LogCapture.cpp ${PROJ_ROOT}/src/LocalSettingsMenu.cpp -${PROJ_ROOT}/src/Ky040Decoder.cpp + ${PROJ_ROOT}/src/Ky040Decoder.cpp ${PROJ_ROOT}/src/OlimexEncoderHandler.cpp + ${PROJ_ROOT}/src/TelemetryQueue.cpp + ${PROJ_ROOT}/src/SensorSlots.cpp ) -# Mock sources (compiled once) set(MOCK_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/mocks/captures.cpp ${CMAKE_CURRENT_SOURCE_DIR}/mocks/NetworkManager.cpp ${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 set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_rules.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_config_manager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_webportal_json.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mqttpublisher.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mqtt_command_queue.cpp ${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 @@ -88,7 +88,11 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_local_settings_menu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ky040_decoder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_mqtt_command_queue.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_telemetry_queue.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_slots.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_sensor_cycle.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_core_scheduler.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_degradation_manager.cpp ) add_executable(test_runner @@ -104,8 +108,5 @@ target_include_directories(test_runner PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/wrappers ) -# Link gcov target_link_libraries(test_runner gcov) - -# Test command add_test(NAME pool_controller_test COMMAND 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..0f0b4c37 --- /dev/null +++ b/test/native/mocks/CoreScheduler.hpp @@ -0,0 +1,26 @@ +// 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 SensorTask parameters so tests can assert the production values. + */ +class CoreScheduler { +public: + static constexpr uint8_t TASK_PRIORITY_SENSOR = 2; + static constexpr uint16_t TASK_STACK_SENSOR = 6 * 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 84b388d6..3b654ad2 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -1,23 +1,11 @@ /** * @file test_main.cpp * @brief Main test runner — entry point for all native C++ tests. - * - * Compiles and runs all pool-controller unit tests natively (x86_64) - * using mocked ESP32/Arduino headers. Coverage is collected via gcov. - * - * Build: - * cd test/native && mkdir -p build && cd build - * cmake .. && make && ./test_runner - * - * Coverage: - * gcovr --root ../.. --filter 'src/' . - * # or: lcov --capture --directory . --output-file coverage.info */ #include #include #include -// Test counters static int g_testsPassed = 0; static int g_testsFailed = 0; static int g_assertionsPassed = 0; @@ -47,62 +35,11 @@ void test_suite_end(const char *name, int passed, int failed) { } } -// Assertion macros -#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_STREQ(a, b) \ - do { \ - const char *_a = (a); \ - const char *_b = (b); \ - if (strcmp(_a, _b) != 0) { \ - char _msg[256]; \ - snprintf(_msg, sizeof(_msg), "Expected strcmp(%s, %s) == 0: got '%s' vs '%s'", #a, #b, _a, _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) - -// Suite declarations extern int run_rule_tests(); extern int run_config_manager_tests(); extern int run_webportal_json_tests(); extern int run_mqttpublisher_tests(); +extern int run_mqtt_command_queue_tests(); extern int run_security_tests(); extern int run_state_manager_tests(); extern int run_timer_tests(); @@ -111,7 +48,11 @@ extern int run_webportal_logs_tests(); extern int run_local_settings_menu_tests(); extern int run_ky040_decoder_tests(); extern int run_calibration_manager_tests(); -extern int run_mqtt_command_queue_tests(); +extern int run_telemetry_queue_tests(); +extern int run_sensor_slots_tests(); +extern int run_sensor_cycle_tests(); +extern int run_core_scheduler_tests(); +extern int run_degradation_manager_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -123,6 +64,7 @@ int main() { total += run_config_manager_tests(); total += run_webportal_json_tests(); total += run_mqttpublisher_tests(); + total += run_mqtt_command_queue_tests(); total += run_security_tests(); total += run_state_manager_tests(); total += run_timer_tests(); @@ -131,7 +73,12 @@ int main() { total += run_local_settings_menu_tests(); total += run_ky040_decoder_tests(); total += run_calibration_manager_tests(); - total += run_mqtt_command_queue_tests(); + total += run_telemetry_queue_tests(); + total += run_sensor_slots_tests(); + total += run_sensor_cycle_tests(); + total += run_core_scheduler_tests(); + total += run_degradation_manager_tests(); + (void)total; printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); diff --git a/test/native/tests/test_sensor_cycle.cpp b/test/native/tests/test_sensor_cycle.cpp new file mode 100644 index 00000000..94c8390b --- /dev/null +++ b/test/native/tests/test_sensor_cycle.cpp @@ -0,0 +1,125 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// SPDX-License-Identifier: MIT + +/** + * @file test_sensor_cycle.cpp + * @brief Tests for Dallas measurement-cycle ordering and recovery scheduling. + */ + +#include + +#include "SensorCycle.hpp" + +extern void test_begin(const char *suite, const char *name); +extern void test_pass(const char *file, int line); +extern void test_fail(const char *file, int line, const char *msg); +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_TRUE(cond) \ + do { \ + if (!(cond)) { \ + test_fail(__FILE__, __LINE__, "Expected true: " #cond); \ + return 1; \ + } \ + test_pass(__FILE__, __LINE__); \ + } while (0) + +#define ASSERT_EQ(a, b) ASSERT_TRUE((a) == (b)) + +namespace { + +struct FakeBus { + int conversions = 0; + int readsWithConversion = 0; + int readsWithoutConversion = 0; +}; + +struct FakeNode { + FakeBus *bus; + bool shared; + bool master; + void beginMeasurement() { + if (!shared || master) { + bus->conversions++; + } + } + void finishMeasurement() { + if (bus->conversions > 0) { + bus->readsWithConversion++; + } else { + bus->readsWithoutConversion++; + } + } +}; + +} // namespace + +static int test_dedicated_buses() { + test_begin("SensorCycle", "dedicated buses: each bus starts its own conversion"); + FakeBus solarBus; + FakeBus poolBus; + FakeNode solar{&solarBus, false, false}; + FakeNode pool{&poolBus, false, false}; + int waits = 0; + PoolController::runDallasMeasurementCycle(solar, pool, [&waits] { waits++; }); + ASSERT_EQ(waits, 1); + ASSERT_EQ(solarBus.conversions, 1); + ASSERT_EQ(poolBus.conversions, 1); + ASSERT_EQ(poolBus.readsWithoutConversion, 0); + ASSERT_EQ(poolBus.readsWithConversion, 1); + return 0; +} + +static int test_shared_bus() { + test_begin("SensorCycle", "shared bus: one conversion for both sensors"); + FakeBus bus; + FakeNode solar{&bus, true, true}; + FakeNode pool{&bus, true, false}; + int waits = 0; + PoolController::runDallasMeasurementCycle(solar, pool, [&waits] { waits++; }); + ASSERT_EQ(waits, 1); + ASSERT_EQ(bus.conversions, 1); + ASSERT_EQ(bus.readsWithConversion, 2); + ASSERT_EQ(bus.readsWithoutConversion, 0); + return 0; +} + +static int test_reads_happen_after_wait() { + test_begin("SensorCycle", "both conversions start before the single wait"); + FakeBus solarBus; + FakeBus poolBus; + FakeNode solar{&solarBus, false, false}; + FakeNode pool{&poolBus, false, false}; + bool startedBeforeWait = false; + bool readBeforeWait = false; + PoolController::runDallasMeasurementCycle(solar, pool, [&] { + startedBeforeWait = solarBus.conversions == 1 && poolBus.conversions == 1; + readBeforeWait = (solarBus.readsWithConversion + poolBus.readsWithConversion) > 0; + }); + ASSERT_TRUE(startedBeforeWait); + ASSERT_TRUE(!readBeforeWait); + return 0; +} + +static int test_recovery_interval_wins() { + test_begin("SensorCycle", "missing sensor keeps five-second recovery cadence"); + ASSERT_EQ(PoolController::selectDallasCycleInterval(10UL, 5UL), 5UL); + ASSERT_EQ(PoolController::selectDallasCycleInterval(5UL, 10UL), 5UL); + ASSERT_EQ(PoolController::selectDallasCycleInterval(10UL, 10UL), 10UL); + return 0; +} + +int run_sensor_cycle_tests() { + int passed = 0; + int failed = 0; + int (*tests[])() = {test_dedicated_buses, test_shared_bus, test_reads_happen_after_wait, test_recovery_interval_wins}; + for (auto test : tests) { + if (test() == 0) { + passed++; + } else { + failed++; + } + } + test_suite_end("SensorCycle", passed, failed); + return failed; +} diff --git a/test/native/tests/test_sensor_slots.cpp b/test/native/tests/test_sensor_slots.cpp new file mode 100644 index 00000000..8fcf5343 --- /dev/null +++ b/test/native/tests/test_sensor_slots.cpp @@ -0,0 +1,148 @@ +/** + * @file test_sensor_slots.cpp + * @brief Unit tests for SensorSlots — lock-free temperature slots. + */ + +#include +#include +#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++; + } + + // ── Test: concurrent reader never sees a mixed value/found pair (review #170) ── + { + test_begin("SensorSlots", "snapshot is consistent under concurrent writes"); + + SensorSlots::reset(); + std::atomic stop{false}; + std::thread writer([&stop] { + bool valid = true; + while (!stop.load()) { + if (valid) { + SensorSlots::write(SensorId::POOL, 25.0f, true); + } else { + SensorSlots::write(SensorId::POOL, NAN, false); + } + valid = !valid; + } + }); + int mixed = 0; + for (int i = 0; i < 200000; i++) { + SensorSlots::Reading r = SensorSlots::snapshot(SensorId::POOL); + if (r.found == std::isnan(r.value)) { + mixed++; + } + } + stop = true; + writer.join(); + ASSERT_EQ(mixed, 0); + + test_suite_end("SensorSlots::snapshot_consistent", 1, 0); + passed++; + } + + (void)failed; + return 0; +} diff --git a/test/native/tests/test_telemetry_queue.cpp b/test/native/tests/test_telemetry_queue.cpp new file mode 100644 index 00000000..609aa0e4 --- /dev/null +++ b/test/native/tests/test_telemetry_queue.cpp @@ -0,0 +1,166 @@ +/** + * @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++; + } + + // ── Test: wrap-around keeps indices inside the storage (review #170) ── + // fill -> dequeue -> enqueue -> drain, several rounds so head and tail pass + // the last slot. With ASan an out-of-bounds slot access fails this test. + { + test_begin("TelemetryQueue", "wrap-around stays in bounds and keeps FIFO order"); + + TelemetryQueue queue; + for (int round = 0; round < 3; round++) { + for (size_t i = 0; i < TelemetryQueue::CAPACITY; i++) { + ASSERT_TRUE(queue.enqueue(i % 2 == 0 ? PublishRequestKind::STATES : PublishRequestKind::DISCOVERY)); + } + PublishRequestKind kind; + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_TRUE(kind == PublishRequestKind::STATES); + ASSERT_TRUE(queue.enqueue(PublishRequestKind::DISCOVERY)); + ASSERT_EQ(queue.count(), TelemetryQueue::CAPACITY); + for (size_t i = 1; i < TelemetryQueue::CAPACITY; i++) { + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_TRUE(kind == (i % 2 == 0 ? PublishRequestKind::STATES : PublishRequestKind::DISCOVERY)); + } + ASSERT_TRUE(queue.dequeue(kind)); + ASSERT_TRUE(kind == PublishRequestKind::DISCOVERY); + ASSERT_FALSE(queue.dequeue(kind)); + ASSERT_EQ(queue.count(), 0u); + } + + test_suite_end("TelemetryQueue::wrap_around", 1, 0); + passed++; + } + + (void)failed; + return 0; +}