From 4c02da407bc173bdd9c374e7b2503d4d30a7252a Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 22:34:55 +0200 Subject: [PATCH 01/22] docs: add NORVI button calibration design spec --- ...6-08-16-norvi-button-calibration-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md diff --git a/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md b/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md new file mode 100644 index 00000000..54946f88 --- /dev/null +++ b/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md @@ -0,0 +1,161 @@ +# Design: Automatic NORVI Button Threshold Calibration + +**Date:** 2026-08-16 +**Status:** Draft for user review +**Scope:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically, replacing manual calibration. + +## Motivation + +The NORVI AE01-R front-panel buttons share a single analog input (GPIO32) and +produce distinct ADC levels via a resistor ladder. The thresholds +(`btn1Min`…`btnNoPress`) are currently calibrated manually: the values were +measured once on 2026-08-16 and hard-coded as defaults, then made configurable +through NVS and the web UI. Manual calibration requires reading live ADC values +and computing midpoints by hand — error-prone and hardware-dependent. + +This design adds an automatic calibration flow: a guided wizard in the web UI +that samples the resting level and each button level, computes the thresholds +at the midpoints between adjacent levels, and persists them to NVS. + +## Design Decisions (from brainstorming) + +- **Trigger:** Guided wizard in the web UI (modal dialog), started via a + "Start Calibration" button in the Button Thresholds section. +- **Sampling:** Press-and-hold; the device detects the level change itself and + samples ~20 readings over 1 s, then averages them. No user timing required. +- **Resting level:** Measured first; `btn1Min` is set to the midpoint between + the resting level and S1. +- **No-press sentinel:** `btnNoPress` stays at 4096 (no-op sentinel, S3 reads + full scale). Calibration never changes it. +- **Error handling:** Per-step timeout (10 s) and minimum-gap check (100 ADC) + cause the step to be retried; the wizard can be cancelled at any time; old + thresholds remain untouched until a successful save. +- **Live feedback:** The wizard polls the current ADC value every ~500 ms so the + user can see the level is stable and the button is detected. +- **Architecture:** Separate `CalibrationManager` module with its own state + machine; `NorviButtonHandler` stays focused on detection. + +## Architecture + +### New module: `CalibrationManager` + +`src/CalibrationManager.hpp/.cpp` — compiled only when `NORVI_AE01_R` is +defined. + +Owns the calibration state machine and reads the ADC directly on GPIO32 +(`PIN_BUTTON_ADC`) with its own simple averaging (20 samples over 1 s) and +stability detection. Independent of the filtering inside `NorviButtonHandler`. + +**States:** + +```text +IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE + └─────────────── ERROR (retry current step or cancel → IDLE) +``` + +**Public API (static):** + +- `begin()` — initialize. +- `loop()` — drive the state machine; called from `PoolController::loop()`. +- `start()` — begin calibration; only valid from `IDLE`. +- `cancel()` — abort and return to `IDLE`; old thresholds stay in NVS. +- `getStatus()` — current step, live ADC, measured levels so far, message. +- `isActive()` — true while a calibration is running. + +**Status struct:** + +```cpp +enum class Step { IDLE, RESTING, BTN1, BTN2, BTN3, DONE, ERROR }; +struct CalibrationStatus { + Step step; + uint16_t liveAdc; // current filtered ADC reading + uint16_t restingLevel; // measured so far (0 until measured) + uint16_t s1, s2, s3; // measured button levels (0 until measured) + const char* message; // instruction or error text +}; +``` + +### Button callback suppression + +`NorviButtonHandler::loop()` early-returns when `CalibrationManager::isActive()` +is true, so no button callback can fire during calibration (no accidental pump +toggle, mode cycle, or save-and-reboot). One-way dependency +`NorviButtonHandler → CalibrationManager`, no cycle. + +### Threshold computation + +Midpoints between adjacent levels: + +```text +btn1Min = (resting + S1) / 2 btn1Max = (S1 + S2) / 2 +btn2Min = (S1 + S2) / 2 btn2Max = (S2 + S3) / 2 +btn3Min = (S2 + S3) / 2 btn3Max = 4095 (full scale stays) +btnNoPress = 4096 (sentinel, unchanged) +``` + +Sanity checks before saving: + +- Levels must be strictly ascending: `resting < S1 < S2 < S3`. +- Minimum gap between adjacent levels: 100 ADC. +- `S3` must be ≤ 4095. + +On success: `ConfigManager::save()` then `NorviButtonHandler::applySettings()` +so the new thresholds apply to the running handler immediately (no reboot). + +## REST API (WebPortal.cpp, auth-protected) + +- `POST /api/calibrate/start` — start calibration (from IDLE). +- `GET /api/calibrate/status` — JSON: `{ step, liveAdc, resting, s1, s2, s3, message }`. +- `POST /api/calibrate/cancel` — cancel calibration. + +No "complete" endpoint: the state machine saves automatically when `COMPUTE` +succeeds. + +## Web UI + +- **Button:** "Start Calibration" in the Button Thresholds (NORVI) section. +- **Modal dialog** with: + - Step instructions ("Please press and hold Button 1…"). + - Live ADC value (poll `GET /api/calibrate/status` every 500 ms). + - Progress indicator (Resting → S1 → S2 → S3). + - Cancel button. + - On success: close modal, reload config so the fields show the new values. + - On error: show message, offer retry of the current step. + +## Error Handling + +| Condition | Behavior | +|---|---| +| No stable level within 10 s | Retry current step, message "No stable level detected — please try again" | +| Level too close to previous (< 100 ADC) | Retry current step, message "Level too close to previous — please check" | +| Cancel (button or endpoint) | Return to IDLE, old thresholds untouched | +| Save failure | ERROR state, old thresholds untouched | + +## Testing + +Native tests (no hardware), with an injectable ADC read function (test hook +instead of `analogRead`): + +- **State machine:** transitions IDLE→RESTING→…→DONE; cancel from every step; + timeout behavior. +- **Threshold computation:** midpoint math, ascending-level sanity checks, + minimum-gap enforcement, S3 = 4095 edge case. +- **Error cases:** unstable level, too-close level, timeout → correct state and + message. +- **Web UI:** manual verification on the device (wizard flow, live ADC, cancel). + +## Files + +New: + +- `src/CalibrationManager.hpp` +- `src/CalibrationManager.cpp` +- `test/native/tests/test_calibration_manager.cpp` + +Modified: + +- `src/NorviButtonHandler.cpp` — early-return in `loop()` when calibration active. +- `src/WebPortal.cpp` — three calibration endpoints. +- `data/web/index.html` — start button + modal markup. +- `data/web/app.js` — wizard logic, polling, modal handling. +- `test/native/CMakeLists.txt` — add test source. \ No newline at end of file From e11e68c6f55a651ff5636e262980b73bcf1d034b Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 22:46:16 +0200 Subject: [PATCH 02/22] docs(calibration): add implementation plan for button calibration --- .../2026-08-16-norvi-button-calibration.md | 1070 +++++++++++++++++ 1 file changed, 1070 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-16-norvi-button-calibration.md diff --git a/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md b/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md new file mode 100644 index 00000000..737756ac --- /dev/null +++ b/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md @@ -0,0 +1,1070 @@ +# NORVI Button Calibration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically. + +**Architecture:** A separate `CalibrationManager` module owns a calibration state machine (IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE/ERROR) with injectable ADC-read and time functions for native testing. `NorviButtonHandler::loop()` early-returns while calibration is active. WebPortal exposes three auth-protected REST endpoints; the web UI shows a modal wizard that polls the status endpoint. + +**Tech Stack:** C++17 (ESP32/Arduino, PlatformIO env `norvi_ae01_r`), ArduinoJson, native CMake test runner (`test/native`), vanilla JS/CSS web assets on LittleFS. + +## Global Constraints + +- All new C++ code is guarded by `#ifdef NORVI_AE01_R` (only compiled for the NORVI variant). +- ADC is 12-bit: valid range 0–4095; `btnNoPress` sentinel stays 4096 (never changed by calibration). +- Minimum gap between adjacent measured levels: 100 ADC. +- Per-step timeout: 10 s; sampling: 20 readings at 50 ms intervals (1 s window), averaged. +- `btn3Max` stays 4095 (full scale); `btnNoPress` stays 4096. +- Thresholds are written to `ConfigManager::getSettings()` (fields `btn1Min`…`btnNoPress`) and persisted via `ConfigManager::save()`. +- REST endpoints are auth-protected via `handleAuthentication()` (same pattern as `/api/config`). +- Web UI: modal dialog pattern follows the existing `loginModal` (inline overlay, `display:flex/none`). +- Native test build: `test/native/CMakeLists.txt` defines `NORVI_AE01_R`; mocks live in `test/native/mocks/` and shadow production headers. +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`). + +--- + +### Task 1: CalibrationManager core — state machine, measurement, hooks + +**Files:** +- Create: `src/CalibrationManager.hpp` +- Create: `src/CalibrationManager.cpp` +- Create: `test/native/tests/test_calibration_manager.cpp` +- Modify: `test/native/CMakeLists.txt` (add `CalibrationManager.cpp` + `NorviButtonHandler.cpp` to `SERVICE_SOURCES`, add test to `TEST_SOURCES`) +- Modify: `test/native/mocks/ConfigManager.hpp` (add btn fields to `Settings` struct) +- Modify: `test/native/tests/test_main.cpp` (register `run_calibration_manager_tests`) + +**Interfaces:** +- Consumes: `ConfigManager::getSettings()` (mock provides `Settings` with btn fields), `analogRead` (mocked in `Arduino.h`), `millis` (mocked), `NorviButtonHandler::applySettings()` (compiled from `src/`). +- Produces: + - `enum class CalibrationManager::Step { IDLE, RESTING, BTN1, BTN2, BTN3, DONE, ERROR }` + - `struct CalibrationManager::CalibrationStatus { Step step; uint16_t liveAdc; uint16_t restingLevel, s1, s2, s3; const char* message; }` + - `static void begin()`, `static void loop()`, `static bool start()`, `static void cancel()`, `static CalibrationStatus getStatus()`, `static bool isActive()` + - Test hooks: `static void setAdcReadForTest(uint16_t (*fn)())`, `static void setTimeForTest(uint32_t (*fn)())` + +- [ ] **Step 1: Add btn fields to the mock Settings struct** + +In `test/native/mocks/ConfigManager.hpp`, extend `struct Settings` (after `tempCircMaxRuntime`): + +```cpp + uint16_t btn1Min = 3100; + uint16_t btn1Max = 3520; + uint16_t btn2Min = 3520; + uint16_t btn2Max = 3880; + uint16_t btn3Min = 3880; + uint16_t btn3Max = 4095; + uint16_t btnNoPress = 4096; +``` + +- [ ] **Step 2: Write the failing test file** + +Create `test/native/tests/test_calibration_manager.cpp` following the `test_ky040_decoder.cpp` pattern (ASSERT_EQ macro, `run_calibration_manager_tests()` returning failure count, `test_suite_end`): + +```cpp +#include +#include "CalibrationManager.hpp" + +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::CalibrationManager; + +// Test hooks: controllable ADC + clock +static uint16_t g_adc = 0; +static uint32_t g_now = 0; +static uint16_t fakeAdc() { return g_adc; } +static uint32_t fakeTime() { return g_now; } + +static int test_start_from_idle() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::start(), true); + ASSERT_EQ(CalibrationManager::isActive(), true); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_resting_measurement() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // Stable resting level: advance through the wait + sample phases + for (uint32_t t = 0; t < 2000; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700); + return 0; +} + +static int test_timeout_retries_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // No stable level: ADC oscillates wildly, never stabilizes + g_adc = 100; + for (uint32_t t = 0; t < 11000; t += 50) { + g_now = t; + g_adc = (g_adc + 500) % 4096; // never stable + CalibrationManager::loop(); + } + // Still in RESTING (retried), not advanced + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_cancel_from_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + CalibrationManager::cancel(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::IDLE); + return 0; +} + +int run_calibration_manager_tests() { + int failures = 0; + failures += test_start_from_idle(); + failures += test_resting_measurement(); + failures += test_timeout_retries_step(); + failures += test_cancel_from_step(); + test_suite_end("CalibrationManager", 4 - failures, failures); + if (failures == 0) { + printf(" CalibrationManager Tests: 4 passed, 0 failed\n"); + } + return failures; +} +``` + +- [ ] **Step 3: Register the test in the build** + +In `test/native/CMakeLists.txt`: +- Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/CalibrationManager.cpp` and `${PROJ_ROOT}/src/NorviButtonHandler.cpp` (needed because CalibrationManager calls `NorviButtonHandler::applySettings()`). +- Add to `TEST_SOURCES`: `${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp`. + +In `test/native/tests/test_main.cpp`, add `extern int run_calibration_manager_tests();` near the other externs and `total += run_calibration_manager_tests();` in `main()`. + +- [ ] **Step 4: Run test to verify it fails** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: FAIL — `CalibrationManager.hpp` not found (header does not exist yet). + +- [ ] **Step 5: Create the header** + +Create `src/CalibrationManager.hpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.hpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * Measures the resting level and each button level via the shared ADC input + * (GPIO32), computes the button thresholds at the midpoints between adjacent + * levels, and persists them through ConfigManager. + * + * @note Only available when the NORVI_AE01_R preprocessor macro is defined. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief State machine that drives the button calibration wizard. + * + * The wizard is driven from PoolController::loop() via loop(). The web UI + * polls getStatus() to render instructions and the live ADC value, and calls + * start()/cancel() to control the flow. + */ +class CalibrationManager { +public: + /** @brief User-facing calibration steps. COMPUTE/SAVE are internal. */ + enum class Step : std::uint8_t { + IDLE = 0, ///< Not calibrating + RESTING, ///< Measure resting level (no button pressed) + BTN1, ///< Measure Button 1 level + BTN2, ///< Measure Button 2 level + BTN3, ///< Measure Button 3 level + DONE, ///< Calibration finished, thresholds saved + ERROR ///< Calibration failed (message in status) + }; + + /** @brief Snapshot of the calibration state for the web UI. */ + struct CalibrationStatus { + Step step = Step::IDLE; + uint16_t liveAdc = 0; ///< Current filtered ADC reading + uint16_t restingLevel = 0; ///< Measured resting level (0 until measured) + uint16_t s1 = 0; ///< Measured Button 1 level (0 until measured) + uint16_t s2 = 0; ///< Measured Button 2 level (0 until measured) + uint16_t s3 = 0; ///< Measured Button 3 level (0 until measured) + const char* message = ""; ///< Instruction or error text + }; + + /** @brief Initialize the calibration manager. */ + static void begin(); + + /** @brief Drive the calibration state machine. Call from PoolController::loop(). */ + static void loop(); + + /** @brief Start calibration. @return false if already active. */ + static bool start(); + + /** @brief Cancel calibration; old thresholds stay in NVS. */ + static void cancel(); + + /** @brief Current calibration status snapshot. */ + static CalibrationStatus getStatus(); + + /** @brief True while a calibration is running (used to suppress button callbacks). */ + static bool isActive(); + + // ── Test hooks (native tests only) ──────────────────────────────────── + /** @brief Override the ADC read function (default: analogRead on PIN_BUTTON_ADC). */ + static void setAdcReadForTest(uint16_t (*fn)()); + /** @brief Override the time function (default: millis). */ + static void setTimeForTest(uint32_t (*fn)()); + +private: + enum class State : std::uint8_t { + IDLE, RESTING, BTN1, BTN2, BTN3, COMPUTE, SAVE, DONE, ERROR + }; + + static void enterState(State s); + static void handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel); + static void computeThresholds(); + static void saveThresholds(); + static uint16_t readAdc(); + static uint32_t now(); + + static State state_; + static CalibrationStatus status_; + static uint16_t (*adcRead_)(); + static uint32_t (*timeFn_)(); + + // Measurement phase state + static uint32_t stepStartMs_; + static uint32_t sampleCount_; + static uint32_t sampleSum_; + static uint16_t lastReading_; + static uint16_t stableCount_; + static bool sampling_; +}; + +} // namespace PoolController +``` + +- [ ] **Step 6: Create the implementation** + +Create `src/CalibrationManager.cpp`: + +```cpp +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.cpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * @note This file is only compiled when `NORVI_AE01_R` is defined. + */ + +#ifdef NORVI_AE01_R + +#include "CalibrationManager.hpp" + +#include +#include "Config.hpp" +#include "ConfigManager.hpp" +#include "LogCapture.hpp" +#include "NorviButtonHandler.hpp" + +namespace PoolController { + +// ── Constants ────────────────────────────────────────────────────────────── + +/// Per-step timeout: no stable level within this window → retry the step. +static constexpr uint32_t STEP_TIMEOUT_MS{10000}; +/// Minimum gap between adjacent measured levels (ADC counts). +static constexpr uint16_t MIN_LEVEL_GAP{100}; +/// Number of samples averaged per level (50 ms apart → 1 s window). +static constexpr uint32_t SAMPLE_COUNT{20}; +/// Consecutive readings within this window count as "stable". +static constexpr uint16_t STABILITY_WINDOW{50}; +/// Consecutive stable readings required before sampling starts. +static constexpr uint8_t STABLE_READINGS{3}; + +// ── Static members ───────────────────────────────────────────────────────── + +CalibrationManager::State CalibrationManager::state_{State::IDLE}; +CalibrationManager::CalibrationStatus CalibrationManager::status_{}; +uint16_t (*CalibrationManager::adcRead_)() = nullptr; +uint32_t (*CalibrationManager::timeFn_)() = nullptr; +uint32_t CalibrationManager::stepStartMs_{0}; +uint32_t CalibrationManager::sampleCount_{0}; +uint32_t CalibrationManager::sampleSum_{0}; +uint16_t CalibrationManager::lastReading_{0}; +uint16_t CalibrationManager::stableCount_{0}; +bool CalibrationManager::sampling_{false}; + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::begin() { + if (adcRead_ == nullptr) { + adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); }; + } + if (timeFn_ == nullptr) { + timeFn_ = millis; + } + state_ = State::IDLE; + status_ = CalibrationStatus{}; + LOG_INFO("✓ CalibrationManager initialized\n"); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::loop() { + if (state_ == State::IDLE || state_ == State::DONE || state_ == State::ERROR) { + return; + } + + const uint32_t t = now(); + status_.liveAdc = readAdc(); + + switch (state_) { + case State::RESTING: + handleMeasurementStep(State::RESTING, 0, status_.restingLevel); + break; + case State::BTN1: + handleMeasurementStep(State::BTN1, status_.restingLevel, status_.s1); + break; + case State::BTN2: + handleMeasurementStep(State::BTN2, status_.s1, status_.s2); + break; + case State::BTN3: + handleMeasurementStep(State::BTN3, status_.s2, status_.s3); + break; + case State::COMPUTE: + computeThresholds(); + break; + case State::SAVE: + saveThresholds(); + break; + default: + break; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel) { + const uint32_t t = now(); + + if (!sampling_) { + // ── Wait phase: look for a stable reading ────────────────────────── + if (t - stepStartMs_ >= STEP_TIMEOUT_MS) { + // Timeout → retry the step (stay in the same state, reset timer) + stepStartMs_ = t; + stableCount_ = 0; + status_.message = "No stable level detected — please try again"; + LOG_WARN("Calibration step timeout, retrying\n"); + return; + } + + const uint16_t reading = readAdc(); + const bool differsFromPrevious = + (previousLevel == 0) || (reading > previousLevel + MIN_LEVEL_GAP) || + (reading < previousLevel - MIN_LEVEL_GAP); + + if (differsFromPrevious && + (stableCount_ == 0 || + (reading > lastReading_ - STABILITY_WINDOW && reading < lastReading_ + STABILITY_WINDOW))) { + stableCount_++; + lastReading_ = reading; + } else { + stableCount_ = 0; + lastReading_ = reading; + } + + if (stableCount_ >= STABLE_READINGS) { + // Stable → start sampling + sampling_ = true; + sampleCount_ = 0; + sampleSum_ = 0; + status_.message = "Level stable — sampling…"; + } + return; + } + + // ── Sample phase: collect SAMPLE_COUNT readings ────────────────────── + sampleSum_ += readAdc(); + sampleCount_++; + if (sampleCount_ >= SAMPLE_COUNT) { + outLevel = static_cast(sampleSum_ / SAMPLE_COUNT); + sampling_ = false; + stableCount_ = 0; + stepStartMs_ = t; + + switch (step) { + case State::RESTING: + status_.message = "Please press and hold Button 1"; + enterState(State::BTN1); + break; + case State::BTN1: + status_.message = "Please press and hold Button 2"; + enterState(State::BTN2); + break; + case State::BTN2: + status_.message = "Please press and hold Button 3"; + enterState(State::BTN3); + break; + case State::BTN3: + status_.message = "Computing thresholds…"; + enterState(State::COMPUTE); + break; + default: + break; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::computeThresholds() { + const uint16_t resting = status_.restingLevel; + const uint16_t s1 = status_.s1; + const uint16_t s2 = status_.s2; + const uint16_t s3 = status_.s3; + + // Sanity checks: strictly ascending with minimum gaps, S3 within ADC range + if (!(resting < s1 && s1 < s2 && s2 < s3) || + (s1 - resting < MIN_LEVEL_GAP) || (s2 - s1 < MIN_LEVEL_GAP) || (s3 - s2 < MIN_LEVEL_GAP) || + s3 > 4095) { + status_.step = Step::ERROR; + status_.message = "Levels not ascending or too close — please re-run calibration"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: levels resting=%u s1=%u s2=%u s3=%u\n", resting, s1, s2, s3); + return; + } + + auto& s = ConfigManager::getSettings(); + s.btn1Min = (resting + s1) / 2; + s.btn1Max = (s1 + s2) / 2; + s.btn2Min = (s1 + s2) / 2; + s.btn2Max = (s2 + s3) / 2; + s.btn3Min = (s2 + s3) / 2; + s.btn3Max = 4095; // full scale stays + s.btnNoPress = 4096; // sentinel stays + + state_ = State::SAVE; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::saveThresholds() { + if (!ConfigManager::save()) { + status_.step = Step::ERROR; + status_.message = "Failed to save thresholds — please retry"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: ConfigManager::save() returned false\n"); + return; + } + NorviButtonHandler::applySettings(); + status_.step = Step::DONE; + status_.message = "Calibration complete — thresholds saved"; + state_ = State::DONE; + LOG_INFO("✓ Calibration complete: btn1=%u-%u btn2=%u-%u btn3=%u-%u noPress=%u\n", + ConfigManager::getSettings().btn1Min, ConfigManager::getSettings().btn1Max, + ConfigManager::getSettings().btn2Min, ConfigManager::getSettings().btn2Max, + ConfigManager::getSettings().btn3Min, ConfigManager::getSettings().btn3Max, + ConfigManager::getSettings().btnNoPress); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +bool CalibrationManager::start() { + if (state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR) { + return false; // already running + } + status_ = CalibrationStatus{}; + status_.step = Step::RESTING; + status_.message = "Release all buttons — measuring resting level"; + enterState(State::RESTING); + LOG_INFO("Calibration started\n"); + return true; +} + +void CalibrationManager::cancel() { + if (state_ == State::IDLE) { + return; + } + status_ = CalibrationStatus{}; + status_.step = Step::IDLE; + state_ = State::IDLE; + sampling_ = false; + stableCount_ = 0; + LOG_INFO("Calibration cancelled\n"); +} + +CalibrationManager::CalibrationStatus CalibrationManager::getStatus() { + return status_; +} + +bool CalibrationManager::isActive() { + return state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::enterState(State s) { + state_ = s; + stepStartMs_ = now(); + sampling_ = false; + stableCount_ = 0; + lastReading_ = 0; + switch (s) { + case State::RESTING: status_.step = Step::RESTING; break; + case State::BTN1: status_.step = Step::BTN1; break; + case State::BTN2: status_.step = Step::BTN2; break; + case State::BTN3: status_.step = Step::BTN3; break; + case State::DONE: status_.step = Step::DONE; break; + case State::ERROR: status_.step = Step::ERROR; break; + default: break; + } +} + +uint16_t CalibrationManager::readAdc() { + return adcRead_ ? adcRead_() : 0; +} + +uint32_t CalibrationManager::now() { + return timeFn_ ? timeFn_() : 0; +} + +void CalibrationManager::setAdcReadForTest(uint16_t (*fn)()) { + adcRead_ = fn; +} + +void CalibrationManager::setTimeForTest(uint32_t (*fn)()) { + timeFn_ = fn; +} + +} // namespace PoolController + +#endif // NORVI_AE01_R +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — all 4 CalibrationManager tests pass, no regressions in other suites. + +- [ ] **Step 8: Commit** + +```bash +git add src/CalibrationManager.hpp src/CalibrationManager.cpp test/native/tests/test_calibration_manager.cpp test/native/CMakeLists.txt test/native/mocks/ConfigManager.hpp test/native/tests/test_main.cpp +git commit -m "feat(calibration): add CalibrationManager state machine with measurement" +``` + +--- + +### Task 2: COMPUTE/SAVE tests — threshold math, error paths + +**Files:** +- Modify: `test/native/tests/test_calibration_manager.cpp` (add tests) + +**Interfaces:** +- Consumes: `CalibrationManager::start()`, `loop()`, `getStatus()`, test hooks from Task 1; `ConfigManager::getSettings()` (mock). +- Produces: nothing new — verifies Task 1's COMPUTE/SAVE behavior. + +- [ ] **Step 1: Add a save-failure hook to the mock** + +In `test/native/mocks/ConfigManager.hpp`, change `save()` to: + +```cpp + static bool save() { return !_saveFails; } + static bool _saveFails; // test hook +``` + +In `test/native/mocks/ConfigManager.cpp`, add `bool ConfigManager::_saveFails = false;` near the other static definitions. + +- [ ] **Step 2: Write the failing tests** + +Append to `test/native/tests/test_calibration_manager.cpp`: + +```cpp +static int test_full_calibration_saves_thresholds() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + // Helper: hold a level for the wait + sample phases, then switch + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3400, 2000); // S1 + holdLevel(3700, 4000); // S2 + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE); + auto& s = PoolController::ConfigManager::getSettings(); + ASSERT_EQ(s.btn1Min, (2700 + 3400) / 2); + ASSERT_EQ(s.btn1Max, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Min, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Max, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Min, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Max, 4095); + ASSERT_EQ(s.btnNoPress, 4096); + return 0; +} + +static int test_non_ascending_levels_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3700, 2000); // S1 (too high — user pressed wrong button) + holdLevel(3400, 4000); // S2 (below S1 → sanity check fails) + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + return 0; +} + +static int test_save_failure_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); + holdLevel(3400, 2000); + holdLevel(3700, 4000); + holdLevel(4095, 6000); + + // Force save failure via the mock hook → ERROR state + PoolController::ConfigManager::_saveFails = true; + CalibrationManager::loop(); + PoolController::ConfigManager::_saveFails = false; + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + return 0; +} +``` + +- [ ] **Step 3: Register the new tests** + +Add the three new test functions to `run_calibration_manager_tests()` and bump the expected count from 4 to 7. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — 7 CalibrationManager tests, no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add test/native/tests/test_calibration_manager.cpp test/native/mocks/ConfigManager.hpp test/native/mocks/ConfigManager.cpp +git commit -m "test(calibration): cover threshold computation and error paths" +``` + +--- + +### Task 3: Suppress button callbacks during calibration + wire into PoolController + +**Files:** +- Modify: `src/NorviButtonHandler.cpp` (early-return in `loop()`) +- Modify: `src/PoolController.cpp` (call `CalibrationManager::begin()` and `loop()`) + +**Interfaces:** +- Consumes: `CalibrationManager::isActive()` (Task 1). +- Produces: nothing new. + +- [ ] **Step 1: Early-return in NorviButtonHandler::loop()** + +In `src/NorviButtonHandler.cpp`, add the include and the guard at the top of `loop()`: + +```cpp +#include "CalibrationManager.hpp" +``` + +```cpp +void NorviButtonHandler::loop() { + // Suppress button handling while calibration is running — the wizard + // owns the ADC input and button presses must not trigger actions. + if (CalibrationManager::isActive()) { + return; + } + + const uint32_t now = millis(); + // ... existing body unchanged +``` + +- [ ] **Step 2: Wire CalibrationManager into PoolController** + +In `src/PoolController.cpp`, inside the `#ifdef NORVI_AE01_R` block near `NorviButtonHandler::begin()` (line ~296), add: + +```cpp + CalibrationManager::begin(); +``` + +And in the `#ifdef NORVI_AE01_R` block in `loop()` (near line 502), add: + +```cpp + CalibrationManager::loop(); +``` + +- [ ] **Step 3: Verify the firmware builds** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 4: Run native tests (no regressions)** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — all suites green. + +- [ ] **Step 5: Commit** + +```bash +git add src/NorviButtonHandler.cpp src/PoolController.cpp +git commit -m "feat(calibration): suppress button actions during calibration" +``` + +--- + +### Task 4: WebPortal REST endpoints + +**Files:** +- Modify: `src/WebPortal.cpp` (add 3 routes + handlers) +- Modify: `test/native/tests/test_webportal_json.cpp` (add calibration status JSON test) + +**Interfaces:** +- Consumes: `CalibrationManager::start()`, `cancel()`, `getStatus()`, `isActive()` (Task 1). +- Produces: REST endpoints `POST /api/calibrate/start`, `GET /api/calibrate/status`, `POST /api/calibrate/cancel`. + +- [ ] **Step 1: Add the routes** + +In `src/WebPortal.cpp` `setupRoutes()`, after the `/api/config` routes (line ~209), add: + +```cpp + // Button calibration wizard (NORVI) + server_.on("/api/calibrate/start", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateStart(); + }); + server_.on("/api/calibrate/status", HTTP_GET, []() { + if (!handleAuthentication()) + return; + apiCalibrateStatus(); + }); + server_.on("/api/calibrate/cancel", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateCancel(); + }); +``` + +- [ ] **Step 2: Add the handlers** + +In `src/WebPortal.cpp`, add the handler implementations (near the other `api*` handlers, e.g. after `apiSaveConfig`): + +```cpp +// ── Button calibration (NORVI) ──────────────────────────────────────────── + +void WebPortal::apiCalibrateStart() { + if (!CalibrationManager::start()) { + server_.send(409, "text/plain", "Calibration already running"); + return; + } + server_.send(200, "text/plain", "OK"); +} + +void WebPortal::apiCalibrateStatus() { + const auto st = CalibrationManager::getStatus(); + JsonDocument doc; + doc["step"] = static_cast(st.step); + doc["live_adc"] = st.liveAdc; + doc["resting"] = st.restingLevel; + doc["s1"] = st.s1; + doc["s2"] = st.s2; + doc["s3"] = st.s3; + doc["message"] = st.message; + String json; + serializeJson(doc, json); + server_.send(200, "application/json", json); +} + +void WebPortal::apiCalibrateCancel() { + CalibrationManager::cancel(); + server_.send(200, "text/plain", "OK"); +} +``` + +Add the three method declarations to `src/WebPortal.hpp` (private section) and the `#include "CalibrationManager.hpp"` to `src/WebPortal.cpp` (guarded by `#ifdef NORVI_AE01_R` — the handlers are only compiled for the NORVI variant; wrap the route registrations and handler bodies in `#ifdef NORVI_AE01_R`). + +- [ ] **Step 3: Write the failing test** + +In `test/native/tests/test_webportal_json.cpp`, add a test that verifies the status JSON shape (following the existing `apiGetStatus` test pattern — construct the expected JSON inline and check keys/types): + +```cpp + // ── Test: calibration status JSON ── + { + test_begin("WebPortal::apiCalibrateStatus", "returns JSON with all fields"); + + JsonDocument doc; + doc["step"] = 1; + doc["live_adc"] = 2700; + doc["resting"] = 0; + doc["s1"] = 0; + doc["s2"] = 0; + doc["s3"] = 0; + doc["message"] = "Release all buttons — measuring resting level"; + + int errs = 0; + const char *requiredKeys[] = {"step", "live_adc", "resting", "s1", "s2", "s3", "message"}; + for (auto key : requiredKeys) { + if (!doc.containsKey(key)) { + char msg[128]; + snprintf(msg, sizeof(msg), "Missing required key: %s", key); + test_fail(__FILE__, __LINE__, msg); + errs++; + } + } + ASSERT_TRUE(doc["step"].is()); + ASSERT_TRUE(doc["live_adc"].is()); + ASSERT_TRUE(doc["message"].is()); + + test_suite_end("WebPortal::apiCalibrateStatus", errs == 0 ? 3 : 0, errs); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: PASS — calibration status JSON test passes, no regressions. + +- [ ] **Step 5: Verify the firmware builds** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add src/WebPortal.cpp src/WebPortal.hpp test/native/tests/test_webportal_json.cpp +git commit -m "feat(calibration): add REST endpoints for calibration wizard" +``` + +--- + +### Task 5: Web UI — start button, modal wizard, polling + +**Files:** +- Modify: `data/web/index.html` (start button + modal markup) +- Modify: `data/web/app.js` (wizard logic, polling, modal handling) + +**Interfaces:** +- Consumes: REST endpoints from Task 4 (`POST /api/calibrate/start`, `GET /api/calibrate/status`, `POST /api/calibrate/cancel`). +- Produces: `startCalibration()`, `pollCalibrationStatus()`, `cancelCalibration()`, `closeCalibrationModal()` (global functions used by inline `onclick` handlers). + +- [ ] **Step 1: Add the start button** + +In `data/web/index.html`, in the Button Thresholds section (after the `btnNoPress` input group, line ~356), add: + +```html +
+
+ + Guided wizard: press and hold each button in turn to measure its ADC level +
+ +
+``` + +- [ ] **Step 2: Add the modal markup** + +In `data/web/index.html`, after the `loginModal` div (line ~40), add the calibration modal (same inline-overlay pattern): + +```html + + +``` + +- [ ] **Step 3: Add the wizard logic to app.js** + +In `data/web/app.js`, add (near the other settings functions): + +```js +// ── Button Calibration Wizard ── + +let calibPollTimer = null; + +function showCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'flex'; +} + +function closeCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'none'; + if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; } +} + +async function startCalibration() { + const res = await fetch('/api/calibrate/start', { method: 'POST' }); + if (!res.ok) { alert('Calibration could not be started.'); return; } + showCalibrationModal(); + calibPollTimer = setInterval(pollCalibrationStatus, 500); + pollCalibrationStatus(); +} + +async function pollCalibrationStatus() { + const res = await fetch('/api/calibrate/status'); + if (!res.ok) return; + const st = await res.json(); + document.getElementById('calibStepText').textContent = st.message || ''; + document.getElementById('calibLiveAdc').textContent = st.live_adc; + + const steps = ['calibP0', 'calibP1', 'calibP2', 'calibP3']; + const active = st.step; // 1=RESTING, 2=BTN1, 3=BTN2, 4=BTN3, 5=DONE, 6=ERROR + steps.forEach((id, i) => { + const el = document.getElementById(id); + el.style.color = (i + 1 === active) ? '#00e5ff' : (i + 1 < active ? '#4ade80' : 'var(--text-muted)'); + }); + + if (st.step === 5) { // DONE + closeCalibrationModal(); + loadConfig(); // refresh threshold fields + } else if (st.step === 6) { // ERROR + closeCalibrationModal(); + alert('Calibration failed: ' + (st.message || 'unknown error')); + } +} + +async function cancelCalibration() { + await fetch('/api/calibrate/cancel', { method: 'POST' }); + closeCalibrationModal(); +} +``` + +- [ ] **Step 4: Verify the web assets are valid** + +Run: `node --check data/web/app.js` +Expected: no syntax errors. + +- [ ] **Step 5: Verify the firmware builds** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 6: Commit** + +```bash +git add data/web/index.html data/web/app.js +git commit -m "feat(calibration): add guided calibration wizard to web UI" +``` + +--- + +### Task 6: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Full native test run** + +Run: `cmake -B build -S . && cmake --build build && ./build/test_runner` +Expected: all suites pass (including 7 CalibrationManager tests). + +- [ ] **Step 2: Firmware build for all environments** + +Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r` +Expected: SUCCESS. + +- [ ] **Step 3: clang-format check** + +Run: `clang-format --dry-run --Werror --style=file:.clang-format src/CalibrationManager.hpp src/CalibrationManager.cpp src/NorviButtonHandler.cpp src/PoolController.cpp src/WebPortal.cpp` +Expected: no output (all formatted). If errors, run `clang-format -i` on the offending files. + +- [ ] **Step 4: Manual device verification (optional, requires hardware)** + +Flash `norvi_ae01_r` to the device, open the web UI → Pool tab → Start Calibration, and verify: +1. Modal opens with "Release all buttons — measuring resting level". +2. Live ADC value updates every ~500 ms. +3. Pressing and holding each button advances the wizard. +4. On completion, the threshold fields show the new values. +5. Cancel works and old thresholds remain. + +- [ ] **Step 5: Report** + +Summarize: files changed, test results, build results, and any manual verification performed. \ No newline at end of file From 5413bf6b658f0392162b33038275de31bd865d5f Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 22:55:16 +0200 Subject: [PATCH 03/22] feat(calibration): add CalibrationManager state machine with measurement --- src/CalibrationManager.cpp | 294 ++++++++++++++++++ src/CalibrationManager.hpp | 103 ++++++ test/native/CMakeLists.txt | 4 +- .../native/tests/test_calibration_manager.cpp | 93 ++++++ test/native/tests/test_main.cpp | 2 + 5 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/CalibrationManager.cpp create mode 100644 src/CalibrationManager.hpp create mode 100644 test/native/tests/test_calibration_manager.cpp diff --git a/src/CalibrationManager.cpp b/src/CalibrationManager.cpp new file mode 100644 index 00000000..82d93d11 --- /dev/null +++ b/src/CalibrationManager.cpp @@ -0,0 +1,294 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.cpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * @note This file is only compiled when `NORVI_AE01_R` is defined. + */ + +#ifdef NORVI_AE01_R + +#include "CalibrationManager.hpp" + +#include +#include "Config.hpp" +#include "ConfigManager.hpp" +#include "LogCapture.hpp" +#include "NorviButtonHandler.hpp" + +namespace PoolController { + +// ── Constants ────────────────────────────────────────────────────────────── + +/// Per-step timeout: no stable level within this window → retry the step. +static constexpr uint32_t STEP_TIMEOUT_MS{10000}; +/// Minimum gap between adjacent measured levels (ADC counts). +static constexpr uint16_t MIN_LEVEL_GAP{100}; +/// Number of samples averaged per level (50 ms apart → 1 s window). +static constexpr uint32_t SAMPLE_COUNT{20}; +/// Consecutive readings within this window count as "stable". +static constexpr uint16_t STABILITY_WINDOW{50}; +/// Consecutive stable readings required before sampling starts. +static constexpr uint8_t STABLE_READINGS{3}; + +// ── Static members ───────────────────────────────────────────────────────── + +CalibrationManager::State CalibrationManager::state_{State::IDLE}; +CalibrationManager::CalibrationStatus CalibrationManager::status_{}; +uint16_t (*CalibrationManager::adcRead_)() = nullptr; +uint32_t (*CalibrationManager::timeFn_)() = nullptr; +uint32_t CalibrationManager::stepStartMs_{0}; +uint32_t CalibrationManager::sampleCount_{0}; +uint32_t CalibrationManager::sampleSum_{0}; +uint16_t CalibrationManager::lastReading_{0}; +uint16_t CalibrationManager::stableCount_{0}; +bool CalibrationManager::sampling_{false}; + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::begin() { + if (adcRead_ == nullptr) { + adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); }; + } + if (timeFn_ == nullptr) { + timeFn_ = millis; + } + state_ = State::IDLE; + status_ = CalibrationStatus{}; + LOG_INFO("✓ CalibrationManager initialized\n"); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::loop() { + if (state_ == State::IDLE || state_ == State::DONE || state_ == State::ERROR) { + return; + } + + const uint32_t t = now(); + status_.liveAdc = readAdc(); + + switch (state_) { + case State::RESTING: + handleMeasurementStep(State::RESTING, 0, status_.restingLevel); + break; + case State::BTN1: + handleMeasurementStep(State::BTN1, status_.restingLevel, status_.s1); + break; + case State::BTN2: + handleMeasurementStep(State::BTN2, status_.s1, status_.s2); + break; + case State::BTN3: + handleMeasurementStep(State::BTN3, status_.s2, status_.s3); + break; + case State::COMPUTE: + computeThresholds(); + break; + case State::SAVE: + saveThresholds(); + break; + default: + break; + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel) { + const uint32_t t = now(); + + if (!sampling_) { + // ── Wait phase: look for a stable reading ────────────────────────── + if (t - stepStartMs_ >= STEP_TIMEOUT_MS) { + // Timeout → retry the step (stay in the same state, reset timer) + stepStartMs_ = t; + stableCount_ = 0; + status_.message = "No stable level detected — please try again"; + LOG_WARN("Calibration step timeout, retrying\n"); + return; + } + + const uint16_t reading = readAdc(); + const bool differsFromPrevious = + (previousLevel == 0) || (reading > previousLevel + MIN_LEVEL_GAP) || + (reading < previousLevel - MIN_LEVEL_GAP); + + if (differsFromPrevious && + (stableCount_ == 0 || + (reading > lastReading_ - STABILITY_WINDOW && reading < lastReading_ + STABILITY_WINDOW))) { + stableCount_++; + lastReading_ = reading; + } else { + stableCount_ = 0; + lastReading_ = reading; + } + + if (stableCount_ >= STABLE_READINGS) { + // Stable → start sampling + sampling_ = true; + sampleCount_ = 0; + sampleSum_ = 0; + status_.message = "Level stable — sampling…"; + } + return; + } + + // ── Sample phase: collect SAMPLE_COUNT readings ────────────────────── + sampleSum_ += readAdc(); + sampleCount_++; + if (sampleCount_ >= SAMPLE_COUNT) { + outLevel = static_cast(sampleSum_ / SAMPLE_COUNT); + sampling_ = false; + stableCount_ = 0; + stepStartMs_ = t; + + switch (step) { + case State::RESTING: + status_.message = "Please press and hold Button 1"; + enterState(State::BTN1); + break; + case State::BTN1: + status_.message = "Please press and hold Button 2"; + enterState(State::BTN2); + break; + case State::BTN2: + status_.message = "Please press and hold Button 3"; + enterState(State::BTN3); + break; + case State::BTN3: + status_.message = "Computing thresholds…"; + enterState(State::COMPUTE); + break; + default: + break; + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::computeThresholds() { + const uint16_t resting = status_.restingLevel; + const uint16_t s1 = status_.s1; + const uint16_t s2 = status_.s2; + const uint16_t s3 = status_.s3; + + // Sanity checks: strictly ascending with minimum gaps, S3 within ADC range + if (!(resting < s1 && s1 < s2 && s2 < s3) || + (s1 - resting < MIN_LEVEL_GAP) || (s2 - s1 < MIN_LEVEL_GAP) || (s3 - s2 < MIN_LEVEL_GAP) || + s3 > 4095) { + status_.step = Step::ERROR; + status_.message = "Levels not ascending or too close — please re-run calibration"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: levels resting=%u s1=%u s2=%u s3=%u\n", resting, s1, s2, s3); + return; + } + + auto& s = ConfigManager::getSettings(); + s.btn1Min = (resting + s1) / 2; + s.btn1Max = (s1 + s2) / 2; + s.btn2Min = (s1 + s2) / 2; + s.btn2Max = (s2 + s3) / 2; + s.btn3Min = (s2 + s3) / 2; + s.btn3Max = 4095; // full scale stays + s.btnNoPress = 4096; // sentinel stays + + state_ = State::SAVE; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::saveThresholds() { + if (!ConfigManager::save()) { + status_.step = Step::ERROR; + status_.message = "Failed to save thresholds — please retry"; + state_ = State::ERROR; + LOG_ERROR("Calibration failed: ConfigManager::save() returned false\n"); + return; + } + NorviButtonHandler::applySettings(); + status_.step = Step::DONE; + status_.message = "Calibration complete — thresholds saved"; + state_ = State::DONE; + LOG_INFO("✓ Calibration complete: btn1=%u-%u btn2=%u-%u btn3=%u-%u noPress=%u\n", + ConfigManager::getSettings().btn1Min, ConfigManager::getSettings().btn1Max, + ConfigManager::getSettings().btn2Min, ConfigManager::getSettings().btn2Max, + ConfigManager::getSettings().btn3Min, ConfigManager::getSettings().btn3Max, + ConfigManager::getSettings().btnNoPress); +} + +// ═══════════════════════════════════════════════════════════════════════════ + +bool CalibrationManager::start() { + if (state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR) { + return false; // already running + } + status_ = CalibrationStatus{}; + status_.step = Step::RESTING; + status_.message = "Release all buttons — measuring resting level"; + enterState(State::RESTING); + LOG_INFO("Calibration started\n"); + return true; +} + +void CalibrationManager::cancel() { + if (state_ == State::IDLE) { + return; + } + status_ = CalibrationStatus{}; + status_.step = Step::IDLE; + state_ = State::IDLE; + sampling_ = false; + stableCount_ = 0; + LOG_INFO("Calibration cancelled\n"); +} + +CalibrationManager::CalibrationStatus CalibrationManager::getStatus() { + return status_; +} + +bool CalibrationManager::isActive() { + return state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR; +} + +// ═══════════════════════════════════════════════════════════════════════════ + +void CalibrationManager::enterState(State s) { + state_ = s; + stepStartMs_ = now(); + sampling_ = false; + stableCount_ = 0; + lastReading_ = 0; + switch (s) { + case State::RESTING: status_.step = Step::RESTING; break; + case State::BTN1: status_.step = Step::BTN1; break; + case State::BTN2: status_.step = Step::BTN2; break; + case State::BTN3: status_.step = Step::BTN3; break; + case State::DONE: status_.step = Step::DONE; break; + case State::ERROR: status_.step = Step::ERROR; break; + default: break; + } +} + +uint16_t CalibrationManager::readAdc() { + return adcRead_ ? adcRead_() : 0; +} + +uint32_t CalibrationManager::now() { + return timeFn_ ? timeFn_() : 0; +} + +void CalibrationManager::setAdcReadForTest(uint16_t (*fn)()) { + adcRead_ = fn; +} + +void CalibrationManager::setTimeForTest(uint32_t (*fn)()) { + timeFn_ = fn; +} + +} // namespace PoolController + +#endif // NORVI_AE01_R \ No newline at end of file diff --git a/src/CalibrationManager.hpp b/src/CalibrationManager.hpp new file mode 100644 index 00000000..a43380a6 --- /dev/null +++ b/src/CalibrationManager.hpp @@ -0,0 +1,103 @@ +// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter +// +// SPDX-License-Identifier: MIT + +/** + * @file CalibrationManager.hpp + * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons. + * + * Measures the resting level and each button level via the shared ADC input + * (GPIO32), computes the button thresholds at the midpoints between adjacent + * levels, and persists them through ConfigManager. + * + * @note Only available when the NORVI_AE01_R preprocessor macro is defined. + */ + +#pragma once + +#include + +namespace PoolController { + +/** + * @brief State machine that drives the button calibration wizard. + * + * The wizard is driven from PoolController::loop() via loop(). The web UI + * polls getStatus() to render instructions and the live ADC value, and calls + * start()/cancel() to control the flow. + */ +class CalibrationManager { +public: + /** @brief User-facing calibration steps. COMPUTE/SAVE are internal. */ + enum class Step : std::uint8_t { + IDLE = 0, ///< Not calibrating + RESTING, ///< Measure resting level (no button pressed) + BTN1, ///< Measure Button 1 level + BTN2, ///< Measure Button 2 level + BTN3, ///< Measure Button 3 level + DONE, ///< Calibration finished, thresholds saved + ERROR ///< Calibration failed (message in status) + }; + + /** @brief Snapshot of the calibration state for the web UI. */ + struct CalibrationStatus { + Step step = Step::IDLE; + uint16_t liveAdc = 0; ///< Current filtered ADC reading + uint16_t restingLevel = 0; ///< Measured resting level (0 until measured) + uint16_t s1 = 0; ///< Measured Button 1 level (0 until measured) + uint16_t s2 = 0; ///< Measured Button 2 level (0 until measured) + uint16_t s3 = 0; ///< Measured Button 3 level (0 until measured) + const char* message = ""; ///< Instruction or error text + }; + + /** @brief Initialize the calibration manager. */ + static void begin(); + + /** @brief Drive the calibration state machine. Call from PoolController::loop(). */ + static void loop(); + + /** @brief Start calibration. @return false if already active. */ + static bool start(); + + /** @brief Cancel calibration; old thresholds stay in NVS. */ + static void cancel(); + + /** @brief Current calibration status snapshot. */ + static CalibrationStatus getStatus(); + + /** @brief True while a calibration is running (used to suppress button callbacks). */ + static bool isActive(); + + // ── Test hooks (native tests only) ──────────────────────────────────── + /** @brief Override the ADC read function (default: analogRead on PIN_BUTTON_ADC). */ + static void setAdcReadForTest(uint16_t (*fn)()); + /** @brief Override the time function (default: millis). */ + static void setTimeForTest(uint32_t (*fn)()); + +private: + enum class State : std::uint8_t { + IDLE, RESTING, BTN1, BTN2, BTN3, COMPUTE, SAVE, DONE, ERROR + }; + + static void enterState(State s); + static void handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel); + static void computeThresholds(); + static void saveThresholds(); + static uint16_t readAdc(); + static uint32_t now(); + + static State state_; + static CalibrationStatus status_; + static uint16_t (*adcRead_)(); + static uint32_t (*timeFn_)(); + + // Measurement phase state + static uint32_t stepStartMs_; + static uint32_t sampleCount_; + static uint32_t sampleSum_; + static uint16_t lastReading_; + static uint16_t stableCount_; + static bool sampling_; +}; + +} // namespace PoolController \ No newline at end of file diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index 84f4238c..0318bb92 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -56,9 +56,10 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/OtaUpdater.cpp ${PROJ_ROOT}/src/LogCapture.cpp ${PROJ_ROOT}/src/LocalSettingsMenu.cpp - ${PROJ_ROOT}/src/Ky040Decoder.cpp +${PROJ_ROOT}/src/Ky040Decoder.cpp ${PROJ_ROOT}/src/OlimexEncoderHandler.cpp ${PROJ_ROOT}/src/NorviButtonHandler.cpp + ${PROJ_ROOT}/src/CalibrationManager.cpp ) # Mock sources (compiled once) @@ -84,6 +85,7 @@ set(TEST_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_webportal_logs.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_local_settings_menu.cpp ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ky040_decoder.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp ) add_executable(test_runner diff --git a/test/native/tests/test_calibration_manager.cpp b/test/native/tests/test_calibration_manager.cpp new file mode 100644 index 00000000..f91dc8ae --- /dev/null +++ b/test/native/tests/test_calibration_manager.cpp @@ -0,0 +1,93 @@ +#include +#include "CalibrationManager.hpp" + +extern void test_suite_end(const char *name, int passed, int failed); + +#define ASSERT_EQ(a, b) \ + do { \ + auto _a = (a); \ + auto _b = (b); \ + if (_a != _b) { \ + printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \ + return 1; \ + } \ + } while (0) + +using PoolController::CalibrationManager; + +// Test hooks: controllable ADC + clock +static uint16_t g_adc = 0; +static uint32_t g_now = 0; +static uint16_t fakeAdc() { return g_adc; } +static uint32_t fakeTime() { return g_now; } + +static int test_start_from_idle() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::start(), true); + ASSERT_EQ(CalibrationManager::isActive(), true); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_resting_measurement() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // Stable resting level: advance through the wait + sample phases + for (uint32_t t = 0; t < 2000; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1); + ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700); + return 0; +} + +static int test_timeout_retries_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + // No stable level: ADC oscillates wildly, never stabilizes + g_adc = 100; + for (uint32_t t = 0; t < 11000; t += 50) { + g_now = t; + g_adc = (g_adc + 500) % 4096; // never stable + CalibrationManager::loop(); + } + // Still in RESTING (retried), not advanced + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING); + return 0; +} + +static int test_cancel_from_step() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + CalibrationManager::cancel(); + ASSERT_EQ(CalibrationManager::isActive(), false); + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::IDLE); + return 0; +} + +int run_calibration_manager_tests() { + int failures = 0; + failures += test_start_from_idle(); + failures += test_resting_measurement(); + failures += test_timeout_retries_step(); + failures += test_cancel_from_step(); + test_suite_end("CalibrationManager", 4 - failures, failures); + if (failures == 0) { + printf(" CalibrationManager Tests: 4 passed, 0 failed\n"); + } + return failures; +} \ No newline at end of file diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp index e5f33827..30518b34 100644 --- a/test/native/tests/test_main.cpp +++ b/test/native/tests/test_main.cpp @@ -110,6 +110,7 @@ extern int run_logcapture_tests(); extern int run_webportal_logs_tests(); extern int run_local_settings_menu_tests(); extern int run_ky040_decoder_tests(); +extern int run_calibration_manager_tests(); int main() { printf("\n══════════════════════════════════════════════════\n"); @@ -128,6 +129,7 @@ int main() { total += run_webportal_logs_tests(); total += run_local_settings_menu_tests(); total += run_ky040_decoder_tests(); + total += run_calibration_manager_tests(); printf("\n══════════════════════════════════════════════════\n"); printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed); From 60399a24f695ce5f847794828e41ea8215dad8c5 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 22:57:45 +0200 Subject: [PATCH 04/22] test(calibration): cover threshold computation and error paths --- test/native/CMakeLists.txt | 14 +-- test/native/mocks/ConfigManager.cpp | 1 + test/native/mocks/ConfigManager.hpp | 3 +- .../native/tests/test_calibration_manager.cpp | 93 ++++++++++++++++++- 4 files changed, 102 insertions(+), 9 deletions(-) diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt index 0318bb92..1dc9b399 100644 --- a/test/native/CMakeLists.txt +++ b/test/native/CMakeLists.txt @@ -32,18 +32,22 @@ include_directories( ) # ── Wrappers ────────────────────────────────────────────────────────────── -# WebPortal, MqttPublisher, and Timer include class headers whose mock layout -# differs from production. Compiling from wrappers/ avoids GCC's -# "current-file-directory first" rule for #include "..." so the mock headers -# take effect. +# WebPortal, MqttPublisher, Timer, and CalibrationManager include class +# headers whose mock layout differs from production. Compiling from +# wrappers/ avoids GCC's "current-file-directory first" rule for +# #include "..." so the mock headers take effect. file(COPY ${PROJ_ROOT}/src/WebPortal.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/MqttPublisher.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) file(COPY ${PROJ_ROOT}/src/Timer.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) +file(COPY ${PROJ_ROOT}/src/CalibrationManager.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) +file(COPY ${PROJ_ROOT}/src/NorviButtonHandler.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers) set(WRAPPER_SOURCES ${CMAKE_CURRENT_BINARY_DIR}/wrappers/WebPortal.cpp ${CMAKE_CURRENT_BINARY_DIR}/wrappers/MqttPublisher.cpp ${CMAKE_CURRENT_BINARY_DIR}/wrappers/Timer.cpp + ${CMAKE_CURRENT_BINARY_DIR}/wrappers/CalibrationManager.cpp + ${CMAKE_CURRENT_BINARY_DIR}/wrappers/NorviButtonHandler.cpp ) # ── Service classes ─────────────────────────────────────────────────────── @@ -58,8 +62,6 @@ set(SERVICE_SOURCES ${PROJ_ROOT}/src/LocalSettingsMenu.cpp ${PROJ_ROOT}/src/Ky040Decoder.cpp ${PROJ_ROOT}/src/OlimexEncoderHandler.cpp - ${PROJ_ROOT}/src/NorviButtonHandler.cpp - ${PROJ_ROOT}/src/CalibrationManager.cpp ) # Mock sources (compiled once) diff --git a/test/native/mocks/ConfigManager.cpp b/test/native/mocks/ConfigManager.cpp index 67fd0a00..56e07f74 100644 --- a/test/native/mocks/ConfigManager.cpp +++ b/test/native/mocks/ConfigManager.cpp @@ -10,6 +10,7 @@ NtpConfig ConfigManager::_ntp; String ConfigManager::_adminPasswordHash = // SHA-256("admin") "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; bool ConfigManager::_configured = false; +bool ConfigManager::_saveFails = false; // ── Sensor Address Mapping ── uint8_t ConfigManager::_sensorSolarAddr[8] = {0}; diff --git a/test/native/mocks/ConfigManager.hpp b/test/native/mocks/ConfigManager.hpp index d1f2300e..d3dd8c6c 100644 --- a/test/native/mocks/ConfigManager.hpp +++ b/test/native/mocks/ConfigManager.hpp @@ -51,7 +51,8 @@ class ConfigManager { static void setConfigured(bool configured) { _configured = configured; } static bool begin() { return true; } static bool load() { return true; } - static bool save() { return true; } + static bool save() { return !_saveFails; } + static bool _saveFails; // test hook static void reset() { _adminPasswordHash = // gitleaks:allow SHA-256("admin") F("8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"); diff --git a/test/native/tests/test_calibration_manager.cpp b/test/native/tests/test_calibration_manager.cpp index f91dc8ae..b2ef989b 100644 --- a/test/native/tests/test_calibration_manager.cpp +++ b/test/native/tests/test_calibration_manager.cpp @@ -1,5 +1,6 @@ #include #include "CalibrationManager.hpp" +#include "ConfigManager.hpp" extern void test_suite_end(const char *name, int passed, int failed); @@ -79,15 +80,103 @@ static int test_cancel_from_step() { return 0; } +static int test_full_calibration_saves_thresholds() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + // Helper: hold a level for the wait + sample phases, then switch + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3400, 2000); // S1 + holdLevel(3700, 4000); // S2 + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE); + auto& s = PoolController::ConfigManager::getSettings(); + ASSERT_EQ(s.btn1Min, (2700 + 3400) / 2); + ASSERT_EQ(s.btn1Max, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Min, (3400 + 3700) / 2); + ASSERT_EQ(s.btn2Max, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Min, (3700 + 4095) / 2); + ASSERT_EQ(s.btn3Max, 4095); + ASSERT_EQ(s.btnNoPress, 4096); + return 0; +} + +static int test_non_ascending_levels_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); // resting + holdLevel(3700, 2000); // S1 (too high — user pressed wrong button) + holdLevel(3400, 4000); // S2 (below S1 → sanity check fails) + holdLevel(4095, 6000); // S3 + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + return 0; +} + +static int test_save_failure_error() { + CalibrationManager::setAdcReadForTest(fakeAdc); + CalibrationManager::setTimeForTest(fakeTime); + g_adc = 2700; g_now = 0; + CalibrationManager::begin(); + CalibrationManager::start(); + + auto holdLevel = [](uint16_t level, uint32_t startMs) { + g_adc = level; + for (uint32_t t = startMs; t < startMs + 1500; t += 50) { + g_now = t; + CalibrationManager::loop(); + } + }; + + holdLevel(2700, 0); + holdLevel(3400, 2000); + holdLevel(3700, 4000); + + // Force save failure before the final step completes → ERROR state + PoolController::ConfigManager::_saveFails = true; + holdLevel(4095, 6000); + PoolController::ConfigManager::_saveFails = false; + + ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR); + return 0; +} + int run_calibration_manager_tests() { int failures = 0; failures += test_start_from_idle(); failures += test_resting_measurement(); failures += test_timeout_retries_step(); failures += test_cancel_from_step(); - test_suite_end("CalibrationManager", 4 - failures, failures); + failures += test_full_calibration_saves_thresholds(); + failures += test_non_ascending_levels_error(); + failures += test_save_failure_error(); + test_suite_end("CalibrationManager", 7 - failures, failures); if (failures == 0) { - printf(" CalibrationManager Tests: 4 passed, 0 failed\n"); + printf(" CalibrationManager Tests: 7 passed, 0 failed\n"); } return failures; } \ No newline at end of file From 4607e9351777594168e7d4303de6edf8b08d075c Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 23:00:04 +0200 Subject: [PATCH 05/22] feat(calibration): suppress button actions during calibration --- src/CalibrationManager.cpp | 2 +- src/NorviButtonHandler.cpp | 7 +++++++ src/PoolController.cpp | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/CalibrationManager.cpp b/src/CalibrationManager.cpp index 82d93d11..8ef5f57d 100644 --- a/src/CalibrationManager.cpp +++ b/src/CalibrationManager.cpp @@ -54,7 +54,7 @@ void CalibrationManager::begin() { adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); }; } if (timeFn_ == nullptr) { - timeFn_ = millis; + timeFn_ = []() { return static_cast(millis()); }; } state_ = State::IDLE; status_ = CalibrationStatus{}; diff --git a/src/NorviButtonHandler.cpp b/src/NorviButtonHandler.cpp index d6a6a681..629d62f0 100644 --- a/src/NorviButtonHandler.cpp +++ b/src/NorviButtonHandler.cpp @@ -18,6 +18,7 @@ #include "NorviButtonHandler.hpp" #include +#include "CalibrationManager.hpp" #include "Config.hpp" #include "ConfigManager.hpp" #include "LogCapture.hpp" @@ -99,6 +100,12 @@ void NorviButtonHandler::applySettings() { // ═══════════════════════════════════════════════════════════════════════════ void NorviButtonHandler::loop() { + // Suppress button handling while calibration is running — the wizard + // owns the ADC input and button presses must not trigger actions. + if (CalibrationManager::isActive()) { + return; + } + const uint32_t now = millis(); // Sample at fixed intervals to reduce noise diff --git a/src/PoolController.cpp b/src/PoolController.cpp index 64129244..1f24946c 100644 --- a/src/PoolController.cpp +++ b/src/PoolController.cpp @@ -41,6 +41,7 @@ #ifdef NORVI_AE01_R #include "NorviOledDisplay.hpp" #include "NorviButtonHandler.hpp" +#include "CalibrationManager.hpp" #endif #if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI) @@ -294,6 +295,7 @@ auto PoolControllerContext::setup() -> void { // Initialize NORVI-specific peripherals (OLED display + front buttons) NorviOledDisplay::begin(); NorviButtonHandler::begin(); + CalibrationManager::begin(); // Wire button callbacks (S1=UP, S2=DOWN, S3=ACTION) // ── S1 (UP) ─────────────────────────────────────────────────────────── @@ -500,6 +502,7 @@ auto PoolControllerContext::loop() -> void { // Update NORVI OLED display and read front-panel buttons NorviOledDisplay::loop(); NorviButtonHandler::loop(); + CalibrationManager::loop(); #endif // Run drivers & logic rules From 0fd7381c4005008c82d190cb5a7d59cf202c0183 Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 23:02:10 +0200 Subject: [PATCH 06/22] feat(calibration): add REST endpoints for calibration wizard --- src/WebPortal.cpp | 52 +++++++++++++++++++++++ src/WebPortal.hpp | 6 +++ test/native/tests/test_webportal_json.cpp | 32 ++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/src/WebPortal.cpp b/src/WebPortal.cpp index 8fa4a59c..d2c02bf6 100644 --- a/src/WebPortal.cpp +++ b/src/WebPortal.cpp @@ -42,6 +42,7 @@ #ifdef NORVI_AE01_R #include "NorviButtonHandler.hpp" +#include "CalibrationManager.hpp" #endif namespace PoolController { @@ -211,6 +212,25 @@ void WebPortal::setupRoutes() { return; apiSaveConfig(); }); + +#ifdef NORVI_AE01_R + // Button calibration wizard (NORVI) + server_.on("/api/calibrate/start", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateStart(); + }); + server_.on("/api/calibrate/status", HTTP_GET, []() { + if (!handleAuthentication()) + return; + apiCalibrateStatus(); + }); + server_.on("/api/calibrate/cancel", HTTP_POST, []() { + if (!handleAuthentication()) + return; + apiCalibrateCancel(); + }); +#endif server_.on("/api/mode", HTTP_POST, []() { if (!handleAuthentication()) return; @@ -868,6 +888,38 @@ void WebPortal::apiSaveConfig() { server_.send(400, "text/plain", "Invalid Config Request"); } +#ifdef NORVI_AE01_R +// ── Button calibration (NORVI) ──────────────────────────────────────────── + +void WebPortal::apiCalibrateStart() { + if (!CalibrationManager::start()) { + server_.send(409, "text/plain", "Calibration already running"); + return; + } + server_.send(200, "text/plain", "OK"); +} + +void WebPortal::apiCalibrateStatus() { + const auto st = CalibrationManager::getStatus(); + JsonDocument doc; + doc["step"] = static_cast(st.step); + doc["live_adc"] = st.liveAdc; + doc["resting"] = st.restingLevel; + doc["s1"] = st.s1; + doc["s2"] = st.s2; + doc["s3"] = st.s3; + doc["message"] = st.message; + String json; + serializeJson(doc, json); + server_.send(200, "application/json", json); +} + +void WebPortal::apiCalibrateCancel() { + CalibrationManager::cancel(); + server_.send(200, "text/plain", "OK"); +} +#endif // NORVI_AE01_R + void WebPortal::apiSetMode() { if (!server_.hasArg("mode")) { server_.send(400, "application/json", "{\"status\":\"error\",\"message\":\"Missing mode\"}"); diff --git a/src/WebPortal.hpp b/src/WebPortal.hpp index 0831097b..21c29acc 100644 --- a/src/WebPortal.hpp +++ b/src/WebPortal.hpp @@ -130,6 +130,12 @@ class WebPortal { static void apiFsUpload(); /** @brief Streaming upload handler for /api/fs/upload multipart file data. */ static void handleFsUploadStream(); + /** @brief POST /api/calibrate/start — start the NORVI button calibration wizard. */ + static void apiCalibrateStart(); + /** @brief GET /api/calibrate/status — return calibration status as JSON. */ + static void apiCalibrateStatus(); + /** @brief POST /api/calibrate/cancel — cancel a running calibration. */ + static void apiCalibrateCancel(); static WebServer server_; static DNSServer dnsServer_; diff --git a/test/native/tests/test_webportal_json.cpp b/test/native/tests/test_webportal_json.cpp index 397ff9fb..45aadbeb 100644 --- a/test/native/tests/test_webportal_json.cpp +++ b/test/native/tests/test_webportal_json.cpp @@ -258,5 +258,37 @@ int run_webportal_json_tests() { passed++; } + // ── Test: calibration status JSON ── + { + test_begin("WebPortal::apiCalibrateStatus", "returns JSON with all fields"); + + JsonDocument doc; + doc["step"] = 1; + doc["live_adc"] = 2700; + doc["resting"] = 0; + doc["s1"] = 0; + doc["s2"] = 0; + doc["s3"] = 0; + doc["message"] = "Release all buttons — measuring resting level"; + + int errs = 0; + const char *requiredKeys[] = {"step", "live_adc", "resting", "s1", "s2", "s3", "message"}; + for (auto key : requiredKeys) { + if (!doc.containsKey(key)) { + char msg[128]; + snprintf(msg, sizeof(msg), "Missing required key: %s", key); + test_fail(__FILE__, __LINE__, msg); + errs++; + } + } + ASSERT_TRUE(doc["step"].is()); + ASSERT_TRUE(doc["live_adc"].is()); + ASSERT_TRUE(doc["message"].is()); + + test_suite_end("WebPortal::apiCalibrateStatus", errs == 0 ? 3 : 0, errs); + passed += (errs == 0) ? 1 : 0; + failed += errs; + } + return passed + failed; } From 747b5d1f3de2e03273b16ec569abf5d1af62512d Mon Sep 17 00:00:00 2001 From: Stephan Strittmatter Date: Sun, 16 Aug 2026 23:03:53 +0200 Subject: [PATCH 07/22] feat(calibration): add guided calibration wizard to web UI --- data/web/app.js | 49 +++++++++++++++++++++++++++++++++++++++++++++ data/web/index.html | 28 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/data/web/app.js b/data/web/app.js index 80777e2b..d9a0d616 100644 --- a/data/web/app.js +++ b/data/web/app.js @@ -651,6 +651,55 @@ async function saveControllerSettings() { } } +// ── Button Calibration Wizard ── + +let calibPollTimer = null; + +function showCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'flex'; +} + +function closeCalibrationModal() { + document.getElementById('calibrationModal').style.display = 'none'; + if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; } +} + +async function startCalibration() { + const res = await fetch('/api/calibrate/start', { method: 'POST' }); + if (!res.ok) { alert('Calibration could not be started.'); return; } + showCalibrationModal(); + calibPollTimer = setInterval(pollCalibrationStatus, 500); + pollCalibrationStatus(); +} + +async function pollCalibrationStatus() { + const res = await fetch('/api/calibrate/status'); + if (!res.ok) return; + const st = await res.json(); + document.getElementById('calibStepText').textContent = st.message || ''; + document.getElementById('calibLiveAdc').textContent = st.live_adc; + + const steps = ['calibP0', 'calibP1', 'calibP2', 'calibP3']; + const active = st.step; // 1=RESTING, 2=BTN1, 3=BTN2, 4=BTN3, 5=DONE, 6=ERROR + steps.forEach((id, i) => { + const el = document.getElementById(id); + el.style.color = (i + 1 === active) ? '#00e5ff' : (i + 1 < active ? '#4ade80' : 'var(--text-muted)'); + }); + + if (st.step === 5) { // DONE + closeCalibrationModal(); + loadConfig(); // refresh threshold fields + } else if (st.step === 6) { // ERROR + closeCalibrationModal(); + alert('Calibration failed: ' + (st.message || 'unknown error')); + } +} + +async function cancelCalibration() { + await fetch('/api/calibrate/cancel', { method: 'POST' }); + closeCalibrationModal(); +} + // ── Save Time Settings (Time Tab) ── async function saveTimeSettings() { diff --git a/data/web/index.html b/data/web/index.html index 2e2d309e..7ef21e89 100644 --- a/data/web/index.html +++ b/data/web/index.html @@ -39,6 +39,25 @@

+
+

🎯 Button Calibration

+

Release all buttons — measuring resting level

+
+ + ADC +
+
+ Resting + Button 1 + Button 2 + Button 3 +
+ +
+ +