diff --git a/data/web/style.css b/data/web/style.css
index 62700e84..4fbd5c7a 100644
--- a/data/web/style.css
+++ b/data/web/style.css
@@ -523,3 +523,134 @@ input:focus, select:focus {
color: var(--accent-blue);
}
#btnClearLogs { outline: none; }
+
+/* ── Calibration wizard ──────────────────────────────────────────────── */
+
+.calib-action { text-align: center; margin-bottom: 1.25rem; }
+
+.calib-guide {
+ color: var(--text-muted);
+ text-align: center;
+ font-size: 0.85rem;
+ margin: 0 0 1.25rem;
+}
+
+.calib-nextup {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+ margin: 0.5rem 0 0;
+ font-style: italic;
+}
+
+.calib-chip {
+ display: inline-block;
+ padding: 0.2rem 0.7rem;
+ border-radius: 999px;
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border: 1px solid var(--panel-border);
+ color: var(--text-muted);
+ margin-bottom: 0.6rem;
+}
+
+.calib-chip-sampling { color: #00e5ff; border-color: rgba(0, 229, 255, 0.45); animation: calib-pulse 1.2s ease-in-out infinite; }
+.calib-chip-saving { color: #00e5ff; border-color: rgba(0, 229, 255, 0.45); }
+.calib-chip-retry { color: #f59e0b; border-color: rgba(245, 158, 11, 0.45); }
+.calib-chip-done { color: #4ade80; border-color: rgba(74, 222, 128, 0.45); }
+.calib-chip-error { color: #ef4444; border-color: rgba(239, 68, 68, 0.45); }
+
+@keyframes calib-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.45; }
+}
+
+.calib-headline {
+ font-size: 1.35rem;
+ font-weight: 700;
+ color: #e2f0f7;
+ margin: 0 0 0.35rem;
+}
+
+.calib-detail { color: var(--text-muted); margin: 0; font-size: 0.85rem; }
+
+.calib-progress { display: flex; justify-content: space-between; margin-bottom: 1.25rem; }
+
+.calib-step { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; min-width: 0; }
+
+.calib-step-dot {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ font-weight: 700;
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--panel-border);
+ color: var(--text-muted);
+}
+
+.calib-step-label { font-size: 0.72rem; color: var(--text-muted); }
+
+.calib-step.active .calib-step-dot {
+ background: rgba(0, 229, 255, 0.15);
+ border-color: #00e5ff;
+ color: #00e5ff;
+ box-shadow: 0 0 10px rgba(0, 229, 255, 0.35);
+}
+
+.calib-step.active .calib-step-label { color: #00e5ff; font-weight: 600; }
+
+.calib-step.done .calib-step-dot {
+ background: rgba(74, 222, 128, 0.15);
+ border-color: #4ade80;
+ color: #4ade80;
+}
+
+.calib-step.done .calib-step-label { color: #4ade80; }
+
+.calib-meter { margin-bottom: 1.25rem; }
+
+.calib-meter-bar {
+ height: 10px;
+ border-radius: 999px;
+ overflow: hidden;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid var(--panel-border);
+}
+
+.calib-meter-fill {
+ height: 100%;
+ width: 0%;
+ background: linear-gradient(90deg, #00b4d8, #00e5ff);
+ transition: width 0.12s linear;
+}
+
+.calib-meter-fill.sampling { animation: calib-pulse 0.9s ease-in-out infinite; }
+
+.calib-meter-readout {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ margin-top: 0.4rem;
+ font-size: 0.85rem;
+}
+
+.calib-meter-readout #calibLiveAdc { font-weight: 700; color: #e2f0f7; }
+.calib-meter-unit { color: var(--text-muted); }
+
+.calib-cancel {
+ width: 100%;
+ padding: 0.6rem;
+ background: transparent;
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ color: var(--text-muted);
+ font-size: 0.85rem;
+ cursor: pointer;
+}
+
+.calib-cancel:hover { color: #e2f0f7; border-color: rgba(226, 240, 247, 0.35); }
diff --git a/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md b/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md
new file mode 100644
index 00000000..73ebad78
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-norvi-button-calibration.md
@@ -0,0 +1,1070 @@
+# NORVI Button Calibration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically.
+
+**Architecture:** A separate `CalibrationManager` module owns a calibration state machine (IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE/ERROR) with injectable ADC-read and time functions for native testing. `NorviButtonHandler::loop()` early-returns while calibration is active. WebPortal exposes three auth-protected REST endpoints; the web UI shows a modal wizard that polls the status endpoint.
+
+**Tech Stack:** C++17 (ESP32/Arduino, PlatformIO env `norvi_ae01_r`), ArduinoJson, native CMake test runner (`test/native`), vanilla JS/CSS web assets on LittleFS.
+
+## Global Constraints
+
+- All new C++ code is guarded by `#ifdef NORVI_AE01_R` (only compiled for the NORVI variant).
+- ADC is 12-bit: valid range 0–4095; `btnNoPress` sentinel stays 4096 (never changed by calibration).
+- Minimum gap between adjacent measured levels: 100 ADC.
+- Per-step timeout: 10 s; sampling: 20 readings at 50 ms intervals (1 s window), averaged.
+- `btn3Max` stays 4095 (full scale); `btnNoPress` stays 4096.
+- Thresholds are written to `ConfigManager::getSettings()` (fields `btn1Min`…`btnNoPress`) and persisted via `ConfigManager::save()`.
+- REST endpoints are auth-protected via `handleAuthentication()` (same pattern as `/api/config`).
+- Web UI: modal dialog pattern follows the existing `loginModal` (inline overlay, `display:flex/none`).
+- Native test build: `test/native/CMakeLists.txt` defines `NORVI_AE01_R`; mocks live in `test/native/mocks/` and shadow production headers.
+- Commit messages follow Conventional Commits (`feat:`, `fix:`, `test:`, `docs:`).
+
+---
+
+### Task 1: CalibrationManager core — state machine, measurement, hooks
+
+**Files:**
+- Create: `src/CalibrationManager.hpp`
+- Create: `src/CalibrationManager.cpp`
+- Create: `test/native/tests/test_calibration_manager.cpp`
+- Modify: `test/native/CMakeLists.txt` (add `CalibrationManager.cpp` + `NorviButtonHandler.cpp` to `SERVICE_SOURCES`, add test to `TEST_SOURCES`)
+- Modify: `test/native/mocks/ConfigManager.hpp` (add btn fields to `Settings` struct)
+- Modify: `test/native/tests/test_main.cpp` (register `run_calibration_manager_tests`)
+
+**Interfaces:**
+- Consumes: `ConfigManager::getSettings()` (mock provides `Settings` with btn fields), `analogRead` (mocked in `Arduino.h`), `millis` (mocked), `NorviButtonHandler::applySettings()` (compiled from `src/`).
+- Produces:
+ - `enum class CalibrationManager::Step { IDLE, RESTING, BTN1, BTN2, BTN3, DONE, ERROR }`
+ - `struct CalibrationManager::CalibrationStatus { Step step; uint16_t liveAdc; uint16_t restingLevel, s1, s2, s3; const char* message; }`
+ - `static void begin()`, `static void loop()`, `static bool start()`, `static void cancel()`, `static CalibrationStatus getStatus()`, `static bool isActive()`
+ - Test hooks: `static void setAdcReadForTest(uint16_t (*fn)())`, `static void setTimeForTest(uint32_t (*fn)())`
+
+- [ ] **Step 1: Add btn fields to the mock Settings struct**
+
+In `test/native/mocks/ConfigManager.hpp`, extend `struct Settings` (after `tempCircMaxRuntime`):
+
+```cpp
+ uint16_t btn1Min = 3100;
+ uint16_t btn1Max = 3520;
+ uint16_t btn2Min = 3520;
+ uint16_t btn2Max = 3880;
+ uint16_t btn3Min = 3880;
+ uint16_t btn3Max = 4095;
+ uint16_t btnNoPress = 4096;
+```
+
+- [ ] **Step 2: Write the failing test file**
+
+Create `test/native/tests/test_calibration_manager.cpp` following the `test_ky040_decoder.cpp` pattern (ASSERT_EQ macro, `run_calibration_manager_tests()` returning failure count, `test_suite_end`):
+
+```cpp
+#include
+#include "CalibrationManager.hpp"
+
+extern void test_suite_end(const char *name, int passed, int failed);
+
+#define ASSERT_EQ(a, b) \
+ do { \
+ auto _a = (a); \
+ auto _b = (b); \
+ if (_a != _b) { \
+ printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \
+ return 1; \
+ } \
+ } while (0)
+
+using PoolController::CalibrationManager;
+
+// Test hooks: controllable ADC + clock
+static uint16_t g_adc = 0;
+static uint32_t g_now = 0;
+static uint16_t fakeAdc() { return g_adc; }
+static uint32_t fakeTime() { return g_now; }
+
+static int test_start_from_idle() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ ASSERT_EQ(CalibrationManager::isActive(), false);
+ ASSERT_EQ(CalibrationManager::start(), true);
+ ASSERT_EQ(CalibrationManager::isActive(), true);
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING);
+ return 0;
+}
+
+static int test_resting_measurement() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+ // Stable resting level: advance through the wait + sample phases
+ for (uint32_t t = 0; t < 2000; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1);
+ ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700);
+ return 0;
+}
+
+static int test_timeout_retries_step() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+ // No stable level: ADC oscillates wildly, never stabilizes
+ g_adc = 100;
+ for (uint32_t t = 0; t < 11000; t += 50) {
+ g_now = t;
+ g_adc = (g_adc + 500) % 4096; // never stable
+ CalibrationManager::loop();
+ }
+ // Still in RESTING (retried), not advanced
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING);
+ return 0;
+}
+
+static int test_cancel_from_step() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+ CalibrationManager::cancel();
+ ASSERT_EQ(CalibrationManager::isActive(), false);
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::IDLE);
+ return 0;
+}
+
+int run_calibration_manager_tests() {
+ int failures = 0;
+ failures += test_start_from_idle();
+ failures += test_resting_measurement();
+ failures += test_timeout_retries_step();
+ failures += test_cancel_from_step();
+ test_suite_end("CalibrationManager", 4 - failures, failures);
+ if (failures == 0) {
+ printf(" CalibrationManager Tests: 4 passed, 0 failed\n");
+ }
+ return failures;
+}
+```
+
+- [ ] **Step 3: Register the test in the build**
+
+In `test/native/CMakeLists.txt`:
+- Add to `SERVICE_SOURCES`: `${PROJ_ROOT}/src/CalibrationManager.cpp` and `${PROJ_ROOT}/src/NorviButtonHandler.cpp` (needed because CalibrationManager calls `NorviButtonHandler::applySettings()`).
+- Add to `TEST_SOURCES`: `${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp`.
+
+In `test/native/tests/test_main.cpp`, add `extern int run_calibration_manager_tests();` near the other externs and `total += run_calibration_manager_tests();` in `main()`.
+
+- [ ] **Step 4: Run test to verify it fails**
+
+Run: `cmake -B build -S . && cmake --build build && ./build/test_runner`
+Expected: FAIL — `CalibrationManager.hpp` not found (header does not exist yet).
+
+- [ ] **Step 5: Create the header**
+
+Create `src/CalibrationManager.hpp`:
+
+```cpp
+// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter
+//
+// SPDX-License-Identifier: MIT
+
+/**
+ * @file CalibrationManager.hpp
+ * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons.
+ *
+ * Measures the resting level and each button level via the shared ADC input
+ * (GPIO32), computes the button thresholds at the midpoints between adjacent
+ * levels, and persists them through ConfigManager.
+ *
+ * @note Only available when the NORVI_AE01_R preprocessor macro is defined.
+ */
+
+#pragma once
+
+#include
+
+namespace PoolController {
+
+/**
+ * @brief State machine that drives the button calibration wizard.
+ *
+ * The wizard is driven from PoolController::loop() via loop(). The web UI
+ * polls getStatus() to render instructions and the live ADC value, and calls
+ * start()/cancel() to control the flow.
+ */
+class CalibrationManager {
+public:
+ /** @brief User-facing calibration steps. COMPUTE/SAVE are internal. */
+ enum class Step : std::uint8_t {
+ IDLE = 0, ///< Not calibrating
+ RESTING, ///< Measure resting level (no button pressed)
+ BTN1, ///< Measure Button 1 level
+ BTN2, ///< Measure Button 2 level
+ BTN3, ///< Measure Button 3 level
+ DONE, ///< Calibration finished, thresholds saved
+ ERROR ///< Calibration failed (message in status)
+ };
+
+ /** @brief Snapshot of the calibration state for the web UI. */
+ struct CalibrationStatus {
+ Step step = Step::IDLE;
+ uint16_t liveAdc = 0; ///< Current filtered ADC reading
+ uint16_t restingLevel = 0; ///< Measured resting level (0 until measured)
+ uint16_t s1 = 0; ///< Measured Button 1 level (0 until measured)
+ uint16_t s2 = 0; ///< Measured Button 2 level (0 until measured)
+ uint16_t s3 = 0; ///< Measured Button 3 level (0 until measured)
+ const char* message = ""; ///< Instruction or error text
+ };
+
+ /** @brief Initialize the calibration manager. */
+ static void begin();
+
+ /** @brief Drive the calibration state machine. Call from PoolController::loop(). */
+ static void loop();
+
+ /** @brief Start calibration. @return false if already active. */
+ static bool start();
+
+ /** @brief Cancel calibration; old thresholds stay in NVS. */
+ static void cancel();
+
+ /** @brief Current calibration status snapshot. */
+ static CalibrationStatus getStatus();
+
+ /** @brief True while a calibration is running (used to suppress button callbacks). */
+ static bool isActive();
+
+ // ── Test hooks (native tests only) ────────────────────────────────────
+ /** @brief Override the ADC read function (default: analogRead on PIN_BUTTON_ADC). */
+ static void setAdcReadForTest(uint16_t (*fn)());
+ /** @brief Override the time function (default: millis). */
+ static void setTimeForTest(uint32_t (*fn)());
+
+private:
+ enum class State : std::uint8_t {
+ IDLE, RESTING, BTN1, BTN2, BTN3, COMPUTE, SAVE, DONE, ERROR
+ };
+
+ static void enterState(State s);
+ static void handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel);
+ static void computeThresholds();
+ static void saveThresholds();
+ static uint16_t readAdc();
+ static uint32_t now();
+
+ static State state_;
+ static CalibrationStatus status_;
+ static uint16_t (*adcRead_)();
+ static uint32_t (*timeFn_)();
+
+ // Measurement phase state
+ static uint32_t stepStartMs_;
+ static uint32_t sampleCount_;
+ static uint32_t sampleSum_;
+ static uint16_t lastReading_;
+ static uint16_t stableCount_;
+ static bool sampling_;
+};
+
+} // namespace PoolController
+```
+
+- [ ] **Step 6: Create the implementation**
+
+Create `src/CalibrationManager.cpp`:
+
+```cpp
+// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter
+//
+// SPDX-License-Identifier: MIT
+
+/**
+ * @file CalibrationManager.cpp
+ * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons.
+ *
+ * @note This file is only compiled when `NORVI_AE01_R` is defined.
+ */
+
+#ifdef NORVI_AE01_R
+
+#include "CalibrationManager.hpp"
+
+#include
+#include "Config.hpp"
+#include "ConfigManager.hpp"
+#include "LogCapture.hpp"
+#include "NorviButtonHandler.hpp"
+
+namespace PoolController {
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+/// Per-step timeout: no stable level within this window → retry the step.
+static constexpr uint32_t STEP_TIMEOUT_MS{10000};
+/// Minimum gap between adjacent measured levels (ADC counts).
+static constexpr uint16_t MIN_LEVEL_GAP{100};
+/// Number of samples averaged per level (50 ms apart → 1 s window).
+static constexpr uint32_t SAMPLE_COUNT{20};
+/// Consecutive readings within this window count as "stable".
+static constexpr uint16_t STABILITY_WINDOW{50};
+/// Consecutive stable readings required before sampling starts.
+static constexpr uint8_t STABLE_READINGS{3};
+
+// ── Static members ─────────────────────────────────────────────────────────
+
+CalibrationManager::State CalibrationManager::state_{State::IDLE};
+CalibrationManager::CalibrationStatus CalibrationManager::status_{};
+uint16_t (*CalibrationManager::adcRead_)() = nullptr;
+uint32_t (*CalibrationManager::timeFn_)() = nullptr;
+uint32_t CalibrationManager::stepStartMs_{0};
+uint32_t CalibrationManager::sampleCount_{0};
+uint32_t CalibrationManager::sampleSum_{0};
+uint16_t CalibrationManager::lastReading_{0};
+uint16_t CalibrationManager::stableCount_{0};
+bool CalibrationManager::sampling_{false};
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::begin() {
+ if (adcRead_ == nullptr) {
+ adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); };
+ }
+ if (timeFn_ == nullptr) {
+ timeFn_ = millis;
+ }
+ state_ = State::IDLE;
+ status_ = CalibrationStatus{};
+ LOG_INFO("✓ CalibrationManager initialized\n");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::loop() {
+ if (state_ == State::IDLE || state_ == State::DONE || state_ == State::ERROR) {
+ return;
+ }
+
+ const uint32_t t = now();
+ status_.liveAdc = readAdc();
+
+ switch (state_) {
+ case State::RESTING:
+ handleMeasurementStep(State::RESTING, 0, status_.restingLevel);
+ break;
+ case State::BTN1:
+ handleMeasurementStep(State::BTN1, status_.restingLevel, status_.s1);
+ break;
+ case State::BTN2:
+ handleMeasurementStep(State::BTN2, status_.s1, status_.s2);
+ break;
+ case State::BTN3:
+ handleMeasurementStep(State::BTN3, status_.s2, status_.s3);
+ break;
+ case State::COMPUTE:
+ computeThresholds();
+ break;
+ case State::SAVE:
+ saveThresholds();
+ break;
+ default:
+ break;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::handleMeasurementStep(State step, uint16_t previousLevel, uint16_t& outLevel) {
+ const uint32_t t = now();
+
+ if (!sampling_) {
+ // ── Wait phase: look for a stable reading ──────────────────────────
+ if (t - stepStartMs_ >= STEP_TIMEOUT_MS) {
+ // Timeout → retry the step (stay in the same state, reset timer)
+ stepStartMs_ = t;
+ stableCount_ = 0;
+ status_.message = "No stable level detected — please try again";
+ LOG_WARN("Calibration step timeout, retrying\n");
+ return;
+ }
+
+ const uint16_t reading = readAdc();
+ const bool differsFromPrevious =
+ (previousLevel == 0) || (reading > previousLevel + MIN_LEVEL_GAP) ||
+ (reading < previousLevel - MIN_LEVEL_GAP);
+
+ if (differsFromPrevious &&
+ (stableCount_ == 0 ||
+ (reading > lastReading_ - STABILITY_WINDOW && reading < lastReading_ + STABILITY_WINDOW))) {
+ stableCount_++;
+ lastReading_ = reading;
+ } else {
+ stableCount_ = 0;
+ lastReading_ = reading;
+ }
+
+ if (stableCount_ >= STABLE_READINGS) {
+ // Stable → start sampling
+ sampling_ = true;
+ sampleCount_ = 0;
+ sampleSum_ = 0;
+ status_.message = "Level stable — sampling…";
+ }
+ return;
+ }
+
+ // ── Sample phase: collect SAMPLE_COUNT readings ──────────────────────
+ sampleSum_ += readAdc();
+ sampleCount_++;
+ if (sampleCount_ >= SAMPLE_COUNT) {
+ outLevel = static_cast(sampleSum_ / SAMPLE_COUNT);
+ sampling_ = false;
+ stableCount_ = 0;
+ stepStartMs_ = t;
+
+ switch (step) {
+ case State::RESTING:
+ status_.message = "Please press and hold Button 1";
+ enterState(State::BTN1);
+ break;
+ case State::BTN1:
+ status_.message = "Please press and hold Button 2";
+ enterState(State::BTN2);
+ break;
+ case State::BTN2:
+ status_.message = "Please press and hold Button 3";
+ enterState(State::BTN3);
+ break;
+ case State::BTN3:
+ status_.message = "Computing thresholds…";
+ enterState(State::COMPUTE);
+ break;
+ default:
+ break;
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::computeThresholds() {
+ const uint16_t resting = status_.restingLevel;
+ const uint16_t s1 = status_.s1;
+ const uint16_t s2 = status_.s2;
+ const uint16_t s3 = status_.s3;
+
+ // Sanity checks: strictly ascending with minimum gaps, S3 within ADC range
+ if (!(resting < s1 && s1 < s2 && s2 < s3) ||
+ (s1 - resting < MIN_LEVEL_GAP) || (s2 - s1 < MIN_LEVEL_GAP) || (s3 - s2 < MIN_LEVEL_GAP) ||
+ s3 > 4095) {
+ status_.step = Step::ERROR;
+ status_.message = "Levels not ascending or too close — please re-run calibration";
+ state_ = State::ERROR;
+ LOG_ERROR("Calibration failed: levels resting=%u s1=%u s2=%u s3=%u\n", resting, s1, s2, s3);
+ return;
+ }
+
+ auto& s = ConfigManager::getSettings();
+ s.btn1Min = (resting + s1) / 2;
+ s.btn1Max = (s1 + s2) / 2;
+ s.btn2Min = (s1 + s2) / 2;
+ s.btn2Max = (s2 + s3) / 2;
+ s.btn3Min = (s2 + s3) / 2;
+ s.btn3Max = 4095; // full scale stays
+ s.btnNoPress = 4096; // sentinel stays
+
+ state_ = State::SAVE;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::saveThresholds() {
+ if (!ConfigManager::save()) {
+ status_.step = Step::ERROR;
+ status_.message = "Failed to save thresholds — please retry";
+ state_ = State::ERROR;
+ LOG_ERROR("Calibration failed: ConfigManager::save() returned false\n");
+ return;
+ }
+ NorviButtonHandler::applySettings();
+ status_.step = Step::DONE;
+ status_.message = "Calibration complete — thresholds saved";
+ state_ = State::DONE;
+ LOG_INFO("✓ Calibration complete: btn1=%u-%u btn2=%u-%u btn3=%u-%u noPress=%u\n",
+ ConfigManager::getSettings().btn1Min, ConfigManager::getSettings().btn1Max,
+ ConfigManager::getSettings().btn2Min, ConfigManager::getSettings().btn2Max,
+ ConfigManager::getSettings().btn3Min, ConfigManager::getSettings().btn3Max,
+ ConfigManager::getSettings().btnNoPress);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+bool CalibrationManager::start() {
+ if (state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR) {
+ return false; // already running
+ }
+ status_ = CalibrationStatus{};
+ status_.step = Step::RESTING;
+ status_.message = "Release all buttons — measuring resting level";
+ enterState(State::RESTING);
+ LOG_INFO("Calibration started\n");
+ return true;
+}
+
+void CalibrationManager::cancel() {
+ if (state_ == State::IDLE) {
+ return;
+ }
+ status_ = CalibrationStatus{};
+ status_.step = Step::IDLE;
+ state_ = State::IDLE;
+ sampling_ = false;
+ stableCount_ = 0;
+ LOG_INFO("Calibration cancelled\n");
+}
+
+CalibrationManager::CalibrationStatus CalibrationManager::getStatus() {
+ return status_;
+}
+
+bool CalibrationManager::isActive() {
+ return state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::enterState(State s) {
+ state_ = s;
+ stepStartMs_ = now();
+ sampling_ = false;
+ stableCount_ = 0;
+ lastReading_ = 0;
+ switch (s) {
+ case State::RESTING: status_.step = Step::RESTING; break;
+ case State::BTN1: status_.step = Step::BTN1; break;
+ case State::BTN2: status_.step = Step::BTN2; break;
+ case State::BTN3: status_.step = Step::BTN3; break;
+ case State::DONE: status_.step = Step::DONE; break;
+ case State::ERROR: status_.step = Step::ERROR; break;
+ default: break;
+ }
+}
+
+uint16_t CalibrationManager::readAdc() {
+ return adcRead_ ? adcRead_() : 0;
+}
+
+uint32_t CalibrationManager::now() {
+ return timeFn_ ? timeFn_() : 0;
+}
+
+void CalibrationManager::setAdcReadForTest(uint16_t (*fn)()) {
+ adcRead_ = fn;
+}
+
+void CalibrationManager::setTimeForTest(uint32_t (*fn)()) {
+ timeFn_ = fn;
+}
+
+} // namespace PoolController
+
+#endif // NORVI_AE01_R
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run: `cmake -B build -S . && cmake --build build && ./build/test_runner`
+Expected: PASS — all 4 CalibrationManager tests pass, no regressions in other suites.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/CalibrationManager.hpp src/CalibrationManager.cpp test/native/tests/test_calibration_manager.cpp test/native/CMakeLists.txt test/native/mocks/ConfigManager.hpp test/native/tests/test_main.cpp
+git commit -m "feat(calibration): add CalibrationManager state machine with measurement"
+```
+
+---
+
+### Task 2: COMPUTE/SAVE tests — threshold math, error paths
+
+**Files:**
+- Modify: `test/native/tests/test_calibration_manager.cpp` (add tests)
+
+**Interfaces:**
+- Consumes: `CalibrationManager::start()`, `loop()`, `getStatus()`, test hooks from Task 1; `ConfigManager::getSettings()` (mock).
+- Produces: nothing new — verifies Task 1's COMPUTE/SAVE behavior.
+
+- [ ] **Step 1: Add a save-failure hook to the mock**
+
+In `test/native/mocks/ConfigManager.hpp`, change `save()` to:
+
+```cpp
+ static bool save() { return !_saveFails; }
+ static bool _saveFails; // test hook
+```
+
+In `test/native/mocks/ConfigManager.cpp`, add `bool ConfigManager::_saveFails = false;` near the other static definitions.
+
+- [ ] **Step 2: Write the failing tests**
+
+Append to `test/native/tests/test_calibration_manager.cpp`:
+
+```cpp
+static int test_full_calibration_saves_thresholds() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ // Helper: hold a level for the wait + sample phases, then switch
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0); // resting
+ holdLevel(3400, 2000); // S1
+ holdLevel(3700, 4000); // S2
+ holdLevel(4095, 6000); // S3
+
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE);
+ auto& s = PoolController::ConfigManager::getSettings();
+ ASSERT_EQ(s.btn1Min, (2700 + 3400) / 2);
+ ASSERT_EQ(s.btn1Max, (3400 + 3700) / 2);
+ ASSERT_EQ(s.btn2Min, (3400 + 3700) / 2);
+ ASSERT_EQ(s.btn2Max, (3700 + 4095) / 2);
+ ASSERT_EQ(s.btn3Min, (3700 + 4095) / 2);
+ ASSERT_EQ(s.btn3Max, 4095);
+ ASSERT_EQ(s.btnNoPress, 4096);
+ return 0;
+}
+
+static int test_non_ascending_levels_error() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0); // resting
+ holdLevel(3700, 2000); // S1 (too high — user pressed wrong button)
+ holdLevel(3400, 4000); // S2 (below S1 → sanity check fails)
+ holdLevel(4095, 6000); // S3
+
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR);
+ return 0;
+}
+
+static int test_save_failure_error() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700; g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0);
+ holdLevel(3400, 2000);
+ holdLevel(3700, 4000);
+ holdLevel(4095, 6000);
+
+ // Force save failure via the mock hook → ERROR state
+ PoolController::ConfigManager::_saveFails = true;
+ CalibrationManager::loop();
+ PoolController::ConfigManager::_saveFails = false;
+
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR);
+ return 0;
+}
+```
+
+- [ ] **Step 3: Register the new tests**
+
+Add the three new test functions to `run_calibration_manager_tests()` and bump the expected count from 4 to 7.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake -B build -S . && cmake --build build && ./build/test_runner`
+Expected: PASS — 7 CalibrationManager tests, no regressions.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add test/native/tests/test_calibration_manager.cpp test/native/mocks/ConfigManager.hpp test/native/mocks/ConfigManager.cpp
+git commit -m "test(calibration): cover threshold computation and error paths"
+```
+
+---
+
+### Task 3: Suppress button callbacks during calibration + wire into PoolController
+
+**Files:**
+- Modify: `src/NorviButtonHandler.cpp` (early-return in `loop()`)
+- Modify: `src/PoolController.cpp` (call `CalibrationManager::begin()` and `loop()`)
+
+**Interfaces:**
+- Consumes: `CalibrationManager::isActive()` (Task 1).
+- Produces: nothing new.
+
+- [ ] **Step 1: Early-return in NorviButtonHandler::loop()**
+
+In `src/NorviButtonHandler.cpp`, add the include and the guard at the top of `loop()`:
+
+```cpp
+#include "CalibrationManager.hpp"
+```
+
+```cpp
+void NorviButtonHandler::loop() {
+ // Suppress button handling while calibration is running — the wizard
+ // owns the ADC input and button presses must not trigger actions.
+ if (CalibrationManager::isActive()) {
+ return;
+ }
+
+ const uint32_t now = millis();
+ // ... existing body unchanged
+```
+
+- [ ] **Step 2: Wire CalibrationManager into PoolController**
+
+In `src/PoolController.cpp`, inside the `#ifdef NORVI_AE01_R` block near `NorviButtonHandler::begin()` (line ~296), add:
+
+```cpp
+ CalibrationManager::begin();
+```
+
+And in the `#ifdef NORVI_AE01_R` block in `loop()` (near line 502), add:
+
+```cpp
+ CalibrationManager::loop();
+```
+
+- [ ] **Step 3: Verify the firmware builds**
+
+Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r`
+Expected: SUCCESS.
+
+- [ ] **Step 4: Run native tests (no regressions)**
+
+Run: `cmake -B build -S . && cmake --build build && ./build/test_runner`
+Expected: PASS — all suites green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/NorviButtonHandler.cpp src/PoolController.cpp
+git commit -m "feat(calibration): suppress button actions during calibration"
+```
+
+---
+
+### Task 4: WebPortal REST endpoints
+
+**Files:**
+- Modify: `src/WebPortal.cpp` (add 3 routes + handlers)
+- Modify: `test/native/tests/test_webportal_json.cpp` (add calibration status JSON test)
+
+**Interfaces:**
+- Consumes: `CalibrationManager::start()`, `cancel()`, `getStatus()`, `isActive()` (Task 1).
+- Produces: REST endpoints `POST /api/calibrate/start`, `GET /api/calibrate/status`, `POST /api/calibrate/cancel`.
+
+- [ ] **Step 1: Add the routes**
+
+In `src/WebPortal.cpp` `setupRoutes()`, after the `/api/config` routes (line ~209), add:
+
+```cpp
+ // Button calibration wizard (NORVI)
+ server_.on("/api/calibrate/start", HTTP_POST, []() {
+ if (!handleAuthentication())
+ return;
+ apiCalibrateStart();
+ });
+ server_.on("/api/calibrate/status", HTTP_GET, []() {
+ if (!handleAuthentication())
+ return;
+ apiCalibrateStatus();
+ });
+ server_.on("/api/calibrate/cancel", HTTP_POST, []() {
+ if (!handleAuthentication())
+ return;
+ apiCalibrateCancel();
+ });
+```
+
+- [ ] **Step 2: Add the handlers**
+
+In `src/WebPortal.cpp`, add the handler implementations (near the other `api*` handlers, e.g. after `apiSaveConfig`):
+
+```cpp
+// ── Button calibration (NORVI) ────────────────────────────────────────────
+
+void WebPortal::apiCalibrateStart() {
+ if (!CalibrationManager::start()) {
+ server_.send(409, "text/plain", "Calibration already running");
+ return;
+ }
+ server_.send(200, "text/plain", "OK");
+}
+
+void WebPortal::apiCalibrateStatus() {
+ const auto st = CalibrationManager::getStatus();
+ JsonDocument doc;
+ doc["step"] = static_cast(st.step);
+ doc["live_adc"] = st.liveAdc;
+ doc["resting"] = st.restingLevel;
+ doc["s1"] = st.s1;
+ doc["s2"] = st.s2;
+ doc["s3"] = st.s3;
+ doc["message"] = st.message;
+ String json;
+ serializeJson(doc, json);
+ server_.send(200, "application/json", json);
+}
+
+void WebPortal::apiCalibrateCancel() {
+ CalibrationManager::cancel();
+ server_.send(200, "text/plain", "OK");
+}
+```
+
+Add the three method declarations to `src/WebPortal.hpp` (private section) and the `#include "CalibrationManager.hpp"` to `src/WebPortal.cpp` (guarded by `#ifdef NORVI_AE01_R` — the handlers are only compiled for the NORVI variant; wrap the route registrations and handler bodies in `#ifdef NORVI_AE01_R`).
+
+- [ ] **Step 3: Write the failing test**
+
+In `test/native/tests/test_webportal_json.cpp`, add a test that verifies the status JSON shape (following the existing `apiGetStatus` test pattern — construct the expected JSON inline and check keys/types):
+
+```cpp
+ // ── Test: calibration status JSON ──
+ {
+ test_begin("WebPortal::apiCalibrateStatus", "returns JSON with all fields");
+
+ JsonDocument doc;
+ doc["step"] = 1;
+ doc["live_adc"] = 2700;
+ doc["resting"] = 0;
+ doc["s1"] = 0;
+ doc["s2"] = 0;
+ doc["s3"] = 0;
+ doc["message"] = "Release all buttons — measuring resting level";
+
+ int errs = 0;
+ const char *requiredKeys[] = {"step", "live_adc", "resting", "s1", "s2", "s3", "message"};
+ for (auto key : requiredKeys) {
+ if (!doc.containsKey(key)) {
+ char msg[128];
+ snprintf(msg, sizeof(msg), "Missing required key: %s", key);
+ test_fail(__FILE__, __LINE__, msg);
+ errs++;
+ }
+ }
+ ASSERT_TRUE(doc["step"].is());
+ ASSERT_TRUE(doc["live_adc"].is());
+ ASSERT_TRUE(doc["message"].is());
+
+ test_suite_end("WebPortal::apiCalibrateStatus", errs == 0 ? 3 : 0, errs);
+ }
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cmake -B build -S . && cmake --build build && ./build/test_runner`
+Expected: PASS — calibration status JSON test passes, no regressions.
+
+- [ ] **Step 5: Verify the firmware builds**
+
+Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r`
+Expected: SUCCESS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/WebPortal.cpp src/WebPortal.hpp test/native/tests/test_webportal_json.cpp
+git commit -m "feat(calibration): add REST endpoints for calibration wizard"
+```
+
+---
+
+### Task 5: Web UI — start button, modal wizard, polling
+
+**Files:**
+- Modify: `data/web/index.html` (start button + modal markup)
+- Modify: `data/web/app.js` (wizard logic, polling, modal handling)
+
+**Interfaces:**
+- Consumes: REST endpoints from Task 4 (`POST /api/calibrate/start`, `GET /api/calibrate/status`, `POST /api/calibrate/cancel`).
+- Produces: `startCalibration()`, `pollCalibrationStatus()`, `cancelCalibration()`, `closeCalibrationModal()` (global functions used by inline `onclick` handlers).
+
+- [ ] **Step 1: Add the start button**
+
+In `data/web/index.html`, in the Button Thresholds section (after the `btnNoPress` input group, line ~356), add:
+
+```html
+
+
+
+ Guided wizard: press and hold each button in turn to measure its ADC level
+
+
+
+```
+
+- [ ] **Step 2: Add the modal markup**
+
+In `data/web/index.html`, after the `loginModal` div (line ~40), add the calibration modal (same inline-overlay pattern):
+
+```html
+
+
+
+
🎯 Button Calibration
+
Release all buttons — measuring resting level
+
+ —
+ ADC
+
+
+ Resting
+ Button 1
+ Button 2
+ Button 3
+
+
+
+
+```
+
+- [ ] **Step 3: Add the wizard logic to app.js**
+
+In `data/web/app.js`, add (near the other settings functions):
+
+```js
+// ── Button Calibration Wizard ──
+
+let calibPollTimer = null;
+
+function showCalibrationModal() {
+ document.getElementById('calibrationModal').style.display = 'flex';
+}
+
+function closeCalibrationModal() {
+ document.getElementById('calibrationModal').style.display = 'none';
+ if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; }
+}
+
+async function startCalibration() {
+ const res = await fetch('/api/calibrate/start', { method: 'POST' });
+ if (!res.ok) { alert('Calibration could not be started.'); return; }
+ showCalibrationModal();
+ calibPollTimer = setInterval(pollCalibrationStatus, 500);
+ pollCalibrationStatus();
+}
+
+async function pollCalibrationStatus() {
+ const res = await fetch('/api/calibrate/status');
+ if (!res.ok) return;
+ const st = await res.json();
+ document.getElementById('calibStepText').textContent = st.message || '';
+ document.getElementById('calibLiveAdc').textContent = st.live_adc;
+
+ const steps = ['calibP0', 'calibP1', 'calibP2', 'calibP3'];
+ const active = st.step; // 1=RESTING, 2=BTN1, 3=BTN2, 4=BTN3, 5=DONE, 6=ERROR
+ steps.forEach((id, i) => {
+ const el = document.getElementById(id);
+ el.style.color = (i + 1 === active) ? '#00e5ff' : (i + 1 < active ? '#4ade80' : 'var(--text-muted)');
+ });
+
+ if (st.step === 5) { // DONE
+ closeCalibrationModal();
+ loadConfig(); // refresh threshold fields
+ } else if (st.step === 6) { // ERROR
+ closeCalibrationModal();
+ alert('Calibration failed: ' + (st.message || 'unknown error'));
+ }
+}
+
+async function cancelCalibration() {
+ await fetch('/api/calibrate/cancel', { method: 'POST' });
+ closeCalibrationModal();
+}
+```
+
+- [ ] **Step 4: Verify the web assets are valid**
+
+Run: `node --check data/web/app.js`
+Expected: no syntax errors.
+
+- [ ] **Step 5: Verify the firmware builds**
+
+Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r`
+Expected: SUCCESS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add data/web/index.html data/web/app.js
+git commit -m "feat(calibration): add guided calibration wizard to web UI"
+```
+
+---
+
+### Task 6: Final verification
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Full native test run**
+
+Run: `cmake -B build -S . && cmake --build build && ./build/test_runner`
+Expected: all suites pass (including 7 CalibrationManager tests).
+
+- [ ] **Step 2: Firmware build for all environments**
+
+Run: `/home/openclaw/.platformio/penv/bin/pio run -e norvi_ae01_r`
+Expected: SUCCESS.
+
+- [ ] **Step 3: clang-format check**
+
+Run: `clang-format --dry-run --Werror --style=file:.clang-format src/CalibrationManager.hpp src/CalibrationManager.cpp src/NorviButtonHandler.cpp src/PoolController.cpp src/WebPortal.cpp`
+Expected: no output (all formatted). If errors, run `clang-format -i` on the offending files.
+
+- [ ] **Step 4: Manual device verification (optional, requires hardware)**
+
+Flash `norvi_ae01_r` to the device, open the web UI → Pool tab → Start Calibration, and verify:
+1. Modal opens with "Release all buttons — measuring resting level".
+2. Live ADC value updates every ~500 ms.
+3. Pressing and holding each button advances the wizard.
+4. On completion, the threshold fields show the new values.
+5. Cancel works and old thresholds remain.
+
+- [ ] **Step 5: Report**
+
+Summarize: files changed, test results, build results, and any manual verification performed.
diff --git a/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md b/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md
new file mode 100644
index 00000000..a8745eaa
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-16-norvi-button-calibration-design.md
@@ -0,0 +1,161 @@
+# Design: Automatic NORVI Button Threshold Calibration
+
+**Date:** 2026-08-16
+**Status:** Draft for user review
+**Scope:** Add a guided calibration wizard to the web UI that measures the NORVI AE01-R front-panel button ADC levels and derives the button thresholds automatically, replacing manual calibration.
+
+## Motivation
+
+The NORVI AE01-R front-panel buttons share a single analog input (GPIO32) and
+produce distinct ADC levels via a resistor ladder. The thresholds
+(`btn1Min`…`btnNoPress`) are currently calibrated manually: the values were
+measured once on 2026-08-16 and hard-coded as defaults, then made configurable
+through NVS and the web UI. Manual calibration requires reading live ADC values
+and computing midpoints by hand — error-prone and hardware-dependent.
+
+This design adds an automatic calibration flow: a guided wizard in the web UI
+that samples the resting level and each button level, computes the thresholds
+at the midpoints between adjacent levels, and persists them to NVS.
+
+## Design Decisions (from brainstorming)
+
+- **Trigger:** Guided wizard in the web UI (modal dialog), started via a
+ "Start Calibration" button in the Button Thresholds section.
+- **Sampling:** Press-and-hold; the device detects the level change itself and
+ samples ~20 readings over 1 s, then averages them. No user timing required.
+- **Resting level:** Measured first; `btn1Min` is set to the midpoint between
+ the resting level and S1.
+- **No-press sentinel:** `btnNoPress` stays at 4096 (no-op sentinel, S3 reads
+ full scale). Calibration never changes it.
+- **Error handling:** Per-step timeout (10 s) and minimum-gap check (100 ADC)
+ cause the step to be retried; the wizard can be cancelled at any time; old
+ thresholds remain untouched until a successful save.
+- **Live feedback:** The wizard polls the current ADC value every ~500 ms so the
+ user can see the level is stable and the button is detected.
+- **Architecture:** Separate `CalibrationManager` module with its own state
+ machine; `NorviButtonHandler` stays focused on detection.
+
+## Architecture
+
+### New module: `CalibrationManager`
+
+`src/CalibrationManager.hpp/.cpp` — compiled only when `NORVI_AE01_R` is
+defined.
+
+Owns the calibration state machine and reads the ADC directly on GPIO32
+(`PIN_BUTTON_ADC`) with its own simple averaging (20 samples over 1 s) and
+stability detection. Independent of the filtering inside `NorviButtonHandler`.
+
+**States:**
+
+```text
+IDLE → RESTING → BTN1 → BTN2 → BTN3 → COMPUTE → SAVE → DONE
+ └─────────────── ERROR (retry current step or cancel → IDLE)
+```
+
+**Public API (static):**
+
+- `begin()` — initialize.
+- `loop()` — drive the state machine; called from `PoolController::loop()`.
+- `start()` — begin calibration; only valid from `IDLE`.
+- `cancel()` — abort and return to `IDLE`; old thresholds stay in NVS.
+- `getStatus()` — current step, live ADC, measured levels so far, message.
+- `isActive()` — true while a calibration is running.
+
+**Status struct:**
+
+```cpp
+enum class Step { IDLE, RESTING, BTN1, BTN2, BTN3, DONE, ERROR };
+struct CalibrationStatus {
+ Step step;
+ uint16_t liveAdc; // current filtered ADC reading
+ uint16_t restingLevel; // measured so far (0 until measured)
+ uint16_t s1, s2, s3; // measured button levels (0 until measured)
+ const char* message; // instruction or error text
+};
+```
+
+### Button callback suppression
+
+`NorviButtonHandler::loop()` early-returns when `CalibrationManager::isActive()`
+is true, so no button callback can fire during calibration (no accidental pump
+toggle, mode cycle, or save-and-reboot). One-way dependency
+`NorviButtonHandler → CalibrationManager`, no cycle.
+
+### Threshold computation
+
+Midpoints between adjacent levels:
+
+```text
+btn1Min = (resting + S1) / 2 btn1Max = (S1 + S2) / 2
+btn2Min = (S1 + S2) / 2 btn2Max = (S2 + S3) / 2
+btn3Min = (S2 + S3) / 2 btn3Max = 4095 (full scale stays)
+btnNoPress = 4096 (sentinel, unchanged)
+```
+
+Sanity checks before saving:
+
+- Levels must be strictly ascending: `resting < S1 < S2 < S3`.
+- Minimum gap between adjacent levels: 100 ADC.
+- `S3` must be ≤ 4095.
+
+On success: `ConfigManager::save()` then `NorviButtonHandler::applySettings()`
+so the new thresholds apply to the running handler immediately (no reboot).
+
+## REST API (WebPortal.cpp, auth-protected)
+
+- `POST /api/calibrate/start` — start calibration (from IDLE).
+- `GET /api/calibrate/status` — JSON: `{ step, liveAdc, resting, s1, s2, s3, message }`.
+- `POST /api/calibrate/cancel` — cancel calibration.
+
+No "complete" endpoint: the state machine saves automatically when `COMPUTE`
+succeeds.
+
+## Web UI
+
+- **Button:** "Start Calibration" in the Button Thresholds (NORVI) section.
+- **Modal dialog** with:
+ - Step instructions ("Please press and hold Button 1…").
+ - Live ADC value (poll `GET /api/calibrate/status` every 500 ms).
+ - Progress indicator (Resting → S1 → S2 → S3).
+ - Cancel button.
+ - On success: close modal, reload config so the fields show the new values.
+ - On error: show message, offer retry of the current step.
+
+## Error Handling
+
+| Condition | Behavior |
+|---|---|
+| No stable level within 10 s | Retry current step, message "No stable level detected — please try again" |
+| Level too close to previous (< 100 ADC) | Retry current step, message "Level too close to previous — please check" |
+| Cancel (button or endpoint) | Return to IDLE, old thresholds untouched |
+| Save failure | ERROR state, old thresholds untouched |
+
+## Testing
+
+Native tests (no hardware), with an injectable ADC read function (test hook
+instead of `analogRead`):
+
+- **State machine:** transitions IDLE→RESTING→…→DONE; cancel from every step;
+ timeout behavior.
+- **Threshold computation:** midpoint math, ascending-level sanity checks,
+ minimum-gap enforcement, S3 = 4095 edge case.
+- **Error cases:** unstable level, too-close level, timeout → correct state and
+ message.
+- **Web UI:** manual verification on the device (wizard flow, live ADC, cancel).
+
+## Files
+
+New:
+
+- `src/CalibrationManager.hpp`
+- `src/CalibrationManager.cpp`
+- `test/native/tests/test_calibration_manager.cpp`
+
+Modified:
+
+- `src/NorviButtonHandler.cpp` — early-return in `loop()` when calibration active.
+- `src/WebPortal.cpp` — three calibration endpoints.
+- `data/web/index.html` — start button + modal markup.
+- `data/web/app.js` — wizard logic, polling, modal handling.
+- `test/native/CMakeLists.txt` — add test source.
diff --git a/src/CalibrationManager.cpp b/src/CalibrationManager.cpp
new file mode 100644
index 00000000..88add7da
--- /dev/null
+++ b/src/CalibrationManager.cpp
@@ -0,0 +1,368 @@
+// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter
+//
+// SPDX-License-Identifier: MIT
+
+/**
+ * @file CalibrationManager.cpp
+ * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons.
+ *
+ * @note This file is only compiled when `NORVI_AE01_R` is defined.
+ */
+
+#ifdef NORVI_AE01_R
+
+#include "CalibrationManager.hpp"
+
+#include
+#include "Config.hpp"
+#include "ConfigManager.hpp"
+#include "LogCapture.hpp"
+#include "NorviButtonHandler.hpp"
+
+namespace PoolController {
+
+// ── Constants ──────────────────────────────────────────────────────────────
+
+/// Per-step timeout: no stable level within this window → retry the step.
+static constexpr uint32_t STEP_TIMEOUT_MS{10000};
+/// Minimum gap between adjacent measured levels (ADC counts).
+static constexpr uint16_t MIN_LEVEL_GAP{100};
+/// Number of samples averaged per level (50 ms apart → 1 s window).
+static constexpr uint32_t SAMPLE_COUNT{20};
+/// Interval between individual samples during the sample phase.
+static constexpr uint32_t SAMPLE_INTERVAL_MS{50};
+/// Consecutive readings within this window count as "stable".
+static constexpr uint16_t STABILITY_WINDOW{50};
+/// Consecutive stable readings required before sampling starts.
+static constexpr uint8_t STABLE_READINGS{3};
+
+// ── Static members ─────────────────────────────────────────────────────────
+
+CalibrationManager::State CalibrationManager::state_{State::IDLE};
+CalibrationManager::CalibrationStatus CalibrationManager::status_{};
+uint16_t (*CalibrationManager::adcRead_)() = nullptr;
+uint32_t (*CalibrationManager::timeFn_)() = nullptr;
+uint32_t CalibrationManager::stepStartMs_{0};
+uint32_t CalibrationManager::sampleCount_{0};
+uint32_t CalibrationManager::sampleSum_{0};
+uint32_t CalibrationManager::lastSampleMs_{0};
+uint16_t CalibrationManager::lastReading_{0};
+uint16_t CalibrationManager::stableCount_{0};
+bool CalibrationManager::sampling_{false};
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::begin() {
+ if (adcRead_ == nullptr) {
+ adcRead_ = []() { return static_cast(analogRead(PIN_BUTTON_ADC)); };
+ }
+ if (timeFn_ == nullptr) {
+ timeFn_ = []() { return static_cast(millis()); };
+ }
+ state_ = State::IDLE;
+ status_ = CalibrationStatus{};
+ LOG_INFO("✓ CalibrationManager initialized\n");
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::loop() {
+ if (state_ == State::IDLE || state_ == State::DONE || state_ == State::ERROR) {
+ return;
+ }
+
+ const uint32_t t = now();
+ status_.liveAdc = readAdc();
+
+ switch (state_) {
+ case State::RESTING:
+ handleMeasurementStep(State::RESTING, 0, status_.restingLevel);
+ break;
+ case State::BTN1:
+ handleMeasurementStep(State::BTN1, status_.restingLevel, status_.s1);
+ break;
+ case State::BTN2:
+ handleMeasurementStep(State::BTN2, status_.s1, status_.s2);
+ break;
+ case State::BTN3:
+ handleMeasurementStep(State::BTN3, status_.s2, status_.s3);
+ break;
+ case State::COMPUTE:
+ computeThresholds();
+ break;
+ case State::SAVE:
+ saveThresholds();
+ break;
+ default:
+ break;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::handleMeasurementStep(State step, uint16_t previousLevel, uint16_t &outLevel) {
+ const uint32_t t = now();
+
+ if (!sampling_) {
+ // ── Wait phase: look for a stable reading ──────────────────────────
+ if (t - stepStartMs_ >= STEP_TIMEOUT_MS) {
+ // Timeout → retry the step (stay in the same state, reset timer)
+ stepStartMs_ = t;
+ stableCount_ = 0;
+ status_.message = "No stable level detected — please try again";
+ LOG_WARN("Calibration step timeout, retrying\n");
+ return;
+ }
+
+ const uint16_t reading = readAdc();
+ // Button levels are strictly ascending: accept only readings at least
+ // MIN_LEVEL_GAP above the previous level (inclusive). This rejects
+ // release levels (which drop back toward the resting level) and accepts
+ // hardware whose adjacent levels sit exactly at the minimum gap.
+ const bool differsFromPrevious = (previousLevel == 0) || (reading >= previousLevel + MIN_LEVEL_GAP);
+
+ if (differsFromPrevious &&
+ (stableCount_ == 0 || (reading > lastReading_ - STABILITY_WINDOW && reading < lastReading_ + STABILITY_WINDOW))) {
+ stableCount_++;
+ lastReading_ = reading;
+ } else {
+ stableCount_ = 0;
+ lastReading_ = reading;
+ }
+
+ if (stableCount_ >= STABLE_READINGS) {
+ // Stable → start sampling
+ sampling_ = true;
+ sampleCount_ = 0;
+ sampleSum_ = 0;
+ lastSampleMs_ = t;
+ status_.message = "Level stable — sampling…";
+ }
+ return;
+ }
+
+ // ── Sample phase: collect SAMPLE_COUNT readings ──────────────────────
+ // Gate on the sample interval so the readings span the intended window
+ // instead of being collected within a few loop iterations.
+ if (t - lastSampleMs_ >= SAMPLE_INTERVAL_MS) {
+ lastSampleMs_ = t;
+ const uint16_t reading = readAdc();
+ // Reject outliers: if the reading leaves the stability window around
+ // the level accepted in the wait phase (e.g. the button was released),
+ // restart the measurement instead of averaging a mixed level.
+ if (reading < lastReading_ - STABILITY_WINDOW || reading > lastReading_ + STABILITY_WINDOW) {
+ sampling_ = false;
+ stableCount_ = 0;
+ status_.message = "Level changed — please hold the button steady";
+ LOG_WARN("Calibration sample out of range, restarting step\n");
+ return;
+ }
+ sampleSum_ += reading;
+ sampleCount_++;
+ if (sampleCount_ >= SAMPLE_COUNT) {
+ const uint16_t average = static_cast(sampleSum_ / SAMPLE_COUNT);
+ sampling_ = false;
+ stableCount_ = 0;
+ stepStartMs_ = t;
+
+ // Revalidate the averaged level against the previous measurement:
+ // samples may drift within the stability window and produce an
+ // average below the minimum gap, which would otherwise fail the
+ // final sanity check in computeThresholds() and abort the wizard.
+ if (previousLevel != 0 && average < previousLevel + MIN_LEVEL_GAP) {
+ status_.message = "Level too close to the previous one — please try again";
+ LOG_WARN("Calibration average %u below minimum gap, retrying step\n", average);
+ return; // stay in this state; the wait phase restarts
+ }
+ outLevel = average;
+
+ switch (step) {
+ case State::RESTING:
+ status_.message = "Please press and hold Button 1";
+ enterState(State::BTN1);
+ break;
+ case State::BTN1:
+ status_.message = "Please press and hold Button 2";
+ enterState(State::BTN2);
+ break;
+ case State::BTN2:
+ status_.message = "Please press and hold Button 3";
+ enterState(State::BTN3);
+ break;
+ case State::BTN3:
+ status_.message = "Computing thresholds…";
+ enterState(State::COMPUTE);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::computeThresholds() {
+ const uint16_t resting = status_.restingLevel;
+ const uint16_t s1 = status_.s1;
+ const uint16_t s2 = status_.s2;
+ const uint16_t s3 = status_.s3;
+
+ // Sanity checks: strictly ascending with minimum gaps, S3 within ADC range
+ if (!(resting < s1 && s1 < s2 && s2 < s3) || (s1 - resting < MIN_LEVEL_GAP) || (s2 - s1 < MIN_LEVEL_GAP) ||
+ (s3 - s2 < MIN_LEVEL_GAP) || s3 > 4095) {
+ status_.step = Step::ERROR;
+ status_.message = "Levels not ascending or too close — please re-run calibration";
+ state_ = State::ERROR;
+ LOG_ERROR("Calibration failed: levels resting=%u s1=%u s2=%u s3=%u\n", resting, s1, s2, s3);
+ return;
+ }
+
+ state_ = State::SAVE;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::saveThresholds() {
+ auto &s = ConfigManager::getSettings();
+
+ // Snapshot the previous thresholds so they can be restored if persistence
+ // fails — the live Settings must not expose values that were never saved.
+ const uint16_t prevBtn1Min = s.btn1Min;
+ const uint16_t prevBtn1Max = s.btn1Max;
+ const uint16_t prevBtn2Min = s.btn2Min;
+ const uint16_t prevBtn2Max = s.btn2Max;
+ const uint16_t prevBtn3Min = s.btn3Min;
+ const uint16_t prevBtn3Max = s.btn3Max;
+ const uint16_t prevBtnNoPress = s.btnNoPress;
+
+ // Compute the new thresholds into temporaries first.
+ const uint16_t resting = status_.restingLevel;
+ const uint16_t s1 = status_.s1;
+ const uint16_t s2 = status_.s2;
+ const uint16_t s3 = status_.s3;
+ const uint16_t btn1Min = (resting + s1) / 2;
+ const uint16_t btn1Max = (s1 + s2) / 2;
+ const uint16_t btn2Min = (s1 + s2) / 2;
+ const uint16_t btn2Max = (s2 + s3) / 2;
+ const uint16_t btn3Min = (s2 + s3) / 2;
+
+ // Commit to the live Settings so save() persists them…
+ s.btn1Min = btn1Min;
+ s.btn1Max = btn1Max;
+ s.btn2Min = btn2Min;
+ s.btn2Max = btn2Max;
+ s.btn3Min = btn3Min;
+ s.btn3Max = 4095; // full scale stays
+ s.btnNoPress = 4096; // sentinel stays
+
+ if (!ConfigManager::save()) {
+ // …and restore the previous values if persistence failed.
+ s.btn1Min = prevBtn1Min;
+ s.btn1Max = prevBtn1Max;
+ s.btn2Min = prevBtn2Min;
+ s.btn2Max = prevBtn2Max;
+ s.btn3Min = prevBtn3Min;
+ s.btn3Max = prevBtn3Max;
+ s.btnNoPress = prevBtnNoPress;
+
+ status_.step = Step::ERROR;
+ status_.message = "Failed to save thresholds — please retry";
+ state_ = State::ERROR;
+ LOG_ERROR("Calibration failed: ConfigManager::save() returned false\n");
+ return;
+ }
+ NorviButtonHandler::applySettings();
+ status_.step = Step::DONE;
+ status_.message = "Calibration complete — thresholds saved";
+ state_ = State::DONE;
+ LOG_INFO("✓ Calibration complete: btn1=%u-%u btn2=%u-%u btn3=%u-%u noPress=%u\n", s.btn1Min, s.btn1Max, s.btn2Min, s.btn2Max,
+ s.btn3Min, s.btn3Max, s.btnNoPress);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+bool CalibrationManager::start() {
+ if (state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR) {
+ return false; // already running
+ }
+ status_ = CalibrationStatus{};
+ status_.step = Step::RESTING;
+ status_.message = "Release all buttons — measuring resting level";
+ enterState(State::RESTING);
+ LOG_INFO("Calibration started\n");
+ return true;
+}
+
+void CalibrationManager::cancel() {
+ if (state_ == State::IDLE) {
+ return;
+ }
+ status_ = CalibrationStatus{};
+ status_.step = Step::IDLE;
+ state_ = State::IDLE;
+ sampling_ = false;
+ stableCount_ = 0;
+ LOG_INFO("Calibration cancelled\n");
+}
+
+CalibrationManager::CalibrationStatus CalibrationManager::getStatus() {
+ return status_;
+}
+
+bool CalibrationManager::isActive() {
+ return state_ != State::IDLE && state_ != State::DONE && state_ != State::ERROR;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+
+void CalibrationManager::enterState(State s) {
+ state_ = s;
+ stepStartMs_ = now();
+ sampling_ = false;
+ stableCount_ = 0;
+ lastReading_ = 0;
+ lastSampleMs_ = stepStartMs_;
+ switch (s) {
+ case State::RESTING:
+ status_.step = Step::RESTING;
+ break;
+ case State::BTN1:
+ status_.step = Step::BTN1;
+ break;
+ case State::BTN2:
+ status_.step = Step::BTN2;
+ break;
+ case State::BTN3:
+ status_.step = Step::BTN3;
+ break;
+ case State::DONE:
+ status_.step = Step::DONE;
+ break;
+ case State::ERROR:
+ status_.step = Step::ERROR;
+ break;
+ default:
+ break;
+ }
+}
+
+uint16_t CalibrationManager::readAdc() {
+ return adcRead_ ? adcRead_() : 0;
+}
+
+uint32_t CalibrationManager::now() {
+ return timeFn_ ? timeFn_() : 0;
+}
+
+void CalibrationManager::setAdcReadForTest(uint16_t (*fn)()) {
+ adcRead_ = fn;
+}
+
+void CalibrationManager::setTimeForTest(uint32_t (*fn)()) {
+ timeFn_ = fn;
+}
+
+} // namespace PoolController
+
+#endif // NORVI_AE01_R
diff --git a/src/CalibrationManager.hpp b/src/CalibrationManager.hpp
new file mode 100644
index 00000000..1145ab65
--- /dev/null
+++ b/src/CalibrationManager.hpp
@@ -0,0 +1,102 @@
+// Copyright (c) 2018-2026 Smart Swimming Pool, Stephan Strittmatter
+//
+// SPDX-License-Identifier: MIT
+
+/**
+ * @file CalibrationManager.hpp
+ * @brief Guided ADC calibration for the NORVI AE01-R front-panel buttons.
+ *
+ * Measures the resting level and each button level via the shared ADC input
+ * (GPIO32), computes the button thresholds at the midpoints between adjacent
+ * levels, and persists them through ConfigManager.
+ *
+ * @note Only available when the NORVI_AE01_R preprocessor macro is defined.
+ */
+
+#pragma once
+
+#include
+
+namespace PoolController {
+
+/**
+ * @brief State machine that drives the button calibration wizard.
+ *
+ * The wizard is driven from PoolController::loop() via loop(). The web UI
+ * polls getStatus() to render instructions and the live ADC value, and calls
+ * start()/cancel() to control the flow.
+ */
+class CalibrationManager {
+public:
+ /** @brief User-facing calibration steps. COMPUTE/SAVE are internal. */
+ enum class Step : std::uint8_t {
+ IDLE = 0, ///< Not calibrating
+ RESTING, ///< Measure resting level (no button pressed)
+ BTN1, ///< Measure Button 1 level
+ BTN2, ///< Measure Button 2 level
+ BTN3, ///< Measure Button 3 level
+ DONE, ///< Calibration finished, thresholds saved
+ ERROR ///< Calibration failed (message in status)
+ };
+
+ /** @brief Snapshot of the calibration state for the web UI. */
+ struct CalibrationStatus {
+ Step step = Step::IDLE;
+ uint16_t liveAdc = 0; ///< Current filtered ADC reading
+ uint16_t restingLevel = 0; ///< Measured resting level (0 until measured)
+ uint16_t s1 = 0; ///< Measured Button 1 level (0 until measured)
+ uint16_t s2 = 0; ///< Measured Button 2 level (0 until measured)
+ uint16_t s3 = 0; ///< Measured Button 3 level (0 until measured)
+ const char *message = ""; ///< Instruction or error text
+ };
+
+ /** @brief Initialize the calibration manager. */
+ static void begin();
+
+ /** @brief Drive the calibration state machine. Call from PoolController::loop(). */
+ static void loop();
+
+ /** @brief Start calibration. @return false if already active. */
+ static bool start();
+
+ /** @brief Cancel calibration; old thresholds stay in NVS. */
+ static void cancel();
+
+ /** @brief Current calibration status snapshot. */
+ static CalibrationStatus getStatus();
+
+ /** @brief True while a calibration is running (used to suppress button callbacks). */
+ static bool isActive();
+
+ // ── Test hooks (native tests only) ────────────────────────────────────
+ /** @brief Override the ADC read function (default: analogRead on PIN_BUTTON_ADC). */
+ static void setAdcReadForTest(uint16_t (*fn)());
+ /** @brief Override the time function (default: millis). */
+ static void setTimeForTest(uint32_t (*fn)());
+
+private:
+ enum class State : std::uint8_t { IDLE, RESTING, BTN1, BTN2, BTN3, COMPUTE, SAVE, DONE, ERROR };
+
+ static void enterState(State s);
+ static void handleMeasurementStep(State step, uint16_t previousLevel, uint16_t &outLevel);
+ static void computeThresholds();
+ static void saveThresholds();
+ static uint16_t readAdc();
+ static uint32_t now();
+
+ static State state_;
+ static CalibrationStatus status_;
+ static uint16_t (*adcRead_)();
+ static uint32_t (*timeFn_)();
+
+ // Measurement phase state
+ static uint32_t stepStartMs_;
+ static uint32_t sampleCount_;
+ static uint32_t sampleSum_;
+ static uint32_t lastSampleMs_;
+ static uint16_t lastReading_;
+ static uint16_t stableCount_;
+ static bool sampling_;
+};
+
+} // namespace PoolController
diff --git a/src/NorviButtonHandler.cpp b/src/NorviButtonHandler.cpp
index d6a6a681..f6a0bd3b 100644
--- a/src/NorviButtonHandler.cpp
+++ b/src/NorviButtonHandler.cpp
@@ -18,6 +18,7 @@
#include "NorviButtonHandler.hpp"
#include
+#include "CalibrationManager.hpp"
#include "Config.hpp"
#include "ConfigManager.hpp"
#include "LogCapture.hpp"
@@ -51,6 +52,8 @@ NorviButtonHandler::ButtonLongPressCallback NorviButtonHandler::cbButton3Long_ =
uint32_t NorviButtonHandler::pressStartMs_ = 0;
uint32_t NorviButtonHandler::releasePendingMs_ = 0;
+bool NorviButtonHandler::calibWasActive_ = false;
+bool NorviButtonHandler::suppressCallbacks_ = false;
// ADC thresholds — defaults are the 2026-08-16 calibrated values; begin()
// overwrites them from ConfigManager (NVS) so they are user-configurable.
@@ -71,6 +74,9 @@ void NorviButtonHandler::begin() {
applySettings();
+ calibWasActive_ = false;
+ suppressCallbacks_ = false;
+
// Take an initial sample to let the ADC stabilise
analogRead(PIN_BUTTON_ADC);
delay(10);
@@ -99,6 +105,67 @@ void NorviButtonHandler::applySettings() {
// ═══════════════════════════════════════════════════════════════════════════
void NorviButtonHandler::loop() {
+ const bool calibActive = CalibrationManager::isActive();
+
+ // Enhanced ADC stability check: only accept values that are stable for 150ms
+ static uint16_t lastStableAdc_ = 0;
+ static uint32_t lastStableTime_ = 0;
+
+ // Calibration just finished (DONE/ERROR): the user may still be holding
+ // the button from the last wizard step. Reset press tracking and keep
+ // callbacks suppressed until the ADC returns to the no-press range for
+ // the debounce interval, so the held press cannot fire its callback.
+ if (!calibActive && calibWasActive_) {
+ suppressCallbacks_ = true;
+ currentButton_ = Button::NONE;
+ stableButton_ = Button::NONE;
+ pressStartMs_ = 0;
+ releasePendingMs_ = 0;
+ lastChangeMs_ = millis();
+ LOG_DEBUG("Calibration finished — callbacks suppressed until all buttons released\n");
+ }
+ calibWasActive_ = calibActive;
+
+ // Suppress button handling while calibration is running — the wizard
+ // owns the ADC input and button presses must not trigger actions.
+ if (calibActive) {
+ return;
+ }
+
+ // While suppression is armed, keep sampling so the release can be
+ // detected, but never fire callbacks.
+ if (suppressCallbacks_) {
+ const uint32_t now = millis();
+ if (now - lastSampleMs_ < SAMPLE_INTERVAL_MS) {
+ return;
+ }
+ lastSampleMs_ = now;
+ const uint16_t rawAdc = analogRead(PIN_BUTTON_ADC);
+ if (detectButton(rawAdc) == Button::NONE) {
+ if (releasePendingMs_ == 0) {
+ releasePendingMs_ = now;
+ } else if (now - releasePendingMs_ >= DEBOUNCE_MS) {
+ // Confirmed all-buttons-released — re-enable callbacks and
+ // re-baseline the filter so stale button-level samples cannot
+ // produce a spurious press.
+ suppressCallbacks_ = false;
+ releasePendingMs_ = 0;
+ for (uint8_t i = 0; i < ADC_FILTER_SIZE; i++) {
+ adcSamples_[i] = rawAdc;
+ }
+ adcSampleIndex_ = 0;
+ lastFilteredAdc_ = rawAdc;
+ lastStableAdc_ = rawAdc;
+ lastStableTime_ = now;
+ lastRaw_ = rawAdc;
+ LOG_DEBUG("All buttons released — callbacks re-enabled\n");
+ }
+ } else {
+ releasePendingMs_ = 0;
+ }
+ return;
+ }
+
const uint32_t now = millis();
// Sample at fixed intervals to reduce noise
@@ -110,10 +177,6 @@ void NorviButtonHandler::loop() {
// Read ADC
uint16_t rawAdc = analogRead(PIN_BUTTON_ADC);
- // Enhanced ADC stability check: only accept values that are stable for 150ms
- static uint16_t lastStableAdc_ = 0;
- static uint32_t lastStableTime_ = 0;
-
// Fast-attack: a genuine press/release changes the button range — even
// when the ADC delta is small (no-press ~2700 → S1 ~3400 = 700). Base the
// re-initialization on button-range transitions so every valid press is
diff --git a/src/NorviButtonHandler.hpp b/src/NorviButtonHandler.hpp
index fd2ab3b8..138c5be3 100644
--- a/src/NorviButtonHandler.hpp
+++ b/src/NorviButtonHandler.hpp
@@ -178,6 +178,15 @@ class NorviButtonHandler {
/// committed to `currentButton_` after DEBOUNCE_MS.
static uint32_t releasePendingMs_;
+ /// True while calibration was active on the previous loop() call.
+ /// Used to detect the calibration-finish transition.
+ static bool calibWasActive_;
+
+ /// While set, callbacks stay suppressed until a confirmed
+ /// all-buttons-released state is observed (armed when calibration
+ /// finishes while a button is still held).
+ static bool suppressCallbacks_;
+
// ── ADC thresholds (12-bit, 0–4095) ──────────────────────────────────
// Calibrated from live measurements on the NORVI AE01-R (2026-08-16):
// No press: ~2610–2990 (oscillates; must map to NONE)
diff --git a/src/PoolController.cpp b/src/PoolController.cpp
index 64129244..1f24946c 100644
--- a/src/PoolController.cpp
+++ b/src/PoolController.cpp
@@ -41,6 +41,7 @@
#ifdef NORVI_AE01_R
#include "NorviOledDisplay.hpp"
#include "NorviButtonHandler.hpp"
+#include "CalibrationManager.hpp"
#endif
#if defined(OLIMEX_ESP32_C6_EVB) && defined(HAS_LOCAL_TFT_UI)
@@ -294,6 +295,7 @@ auto PoolControllerContext::setup() -> void {
// Initialize NORVI-specific peripherals (OLED display + front buttons)
NorviOledDisplay::begin();
NorviButtonHandler::begin();
+ CalibrationManager::begin();
// Wire button callbacks (S1=UP, S2=DOWN, S3=ACTION)
// ── S1 (UP) ───────────────────────────────────────────────────────────
@@ -500,6 +502,7 @@ auto PoolControllerContext::loop() -> void {
// Update NORVI OLED display and read front-panel buttons
NorviOledDisplay::loop();
NorviButtonHandler::loop();
+ CalibrationManager::loop();
#endif
// Run drivers & logic rules
diff --git a/src/WebPortal.cpp b/src/WebPortal.cpp
index 8fa4a59c..88b08e17 100644
--- a/src/WebPortal.cpp
+++ b/src/WebPortal.cpp
@@ -42,6 +42,7 @@
#ifdef NORVI_AE01_R
#include "NorviButtonHandler.hpp"
+#include "CalibrationManager.hpp"
#endif
namespace PoolController {
@@ -211,6 +212,25 @@ void WebPortal::setupRoutes() {
return;
apiSaveConfig();
});
+
+#ifdef NORVI_AE01_R
+ // Button calibration wizard (NORVI)
+ server_.on("/api/calibrate/start", HTTP_POST, []() {
+ if (!handleAuthentication())
+ return;
+ apiCalibrateStart();
+ });
+ server_.on("/api/calibrate/status", HTTP_GET, []() {
+ if (!handleAuthentication())
+ return;
+ apiCalibrateStatus();
+ });
+ server_.on("/api/calibrate/cancel", HTTP_POST, []() {
+ if (!handleAuthentication())
+ return;
+ apiCalibrateCancel();
+ });
+#endif
server_.on("/api/mode", HTTP_POST, []() {
if (!handleAuthentication())
return;
@@ -465,6 +485,11 @@ void WebPortal::apiGetStatus() {
doc["local_ip"] = NetworkManager::getLocalIP();
doc["fw_version"] = FW_VERSION;
doc["authenticated"] = isClientAuthenticated();
+#ifdef NORVI_AE01_R
+ doc["norvi"] = true;
+#else
+ doc["norvi"] = false;
+#endif
// Current date/time in configured timezone
TimeChangeRule *tcr;
@@ -868,6 +893,38 @@ void WebPortal::apiSaveConfig() {
server_.send(400, "text/plain", "Invalid Config Request");
}
+#ifdef NORVI_AE01_R
+// ── Button calibration (NORVI) ────────────────────────────────────────────
+
+void WebPortal::apiCalibrateStart() {
+ if (!CalibrationManager::start()) {
+ server_.send(409, "text/plain", "Calibration already running");
+ return;
+ }
+ server_.send(200, "text/plain", "OK");
+}
+
+void WebPortal::apiCalibrateStatus() {
+ const auto st = CalibrationManager::getStatus();
+ JsonDocument doc;
+ doc["step"] = static_cast(st.step);
+ doc["live_adc"] = st.liveAdc;
+ doc["resting"] = st.restingLevel;
+ doc["s1"] = st.s1;
+ doc["s2"] = st.s2;
+ doc["s3"] = st.s3;
+ doc["message"] = st.message;
+ String json;
+ serializeJson(doc, json);
+ server_.send(200, "application/json", json);
+}
+
+void WebPortal::apiCalibrateCancel() {
+ CalibrationManager::cancel();
+ server_.send(200, "text/plain", "OK");
+}
+#endif // NORVI_AE01_R
+
void WebPortal::apiSetMode() {
if (!server_.hasArg("mode")) {
server_.send(400, "application/json", "{\"status\":\"error\",\"message\":\"Missing mode\"}");
diff --git a/src/WebPortal.hpp b/src/WebPortal.hpp
index 0831097b..21c29acc 100644
--- a/src/WebPortal.hpp
+++ b/src/WebPortal.hpp
@@ -130,6 +130,12 @@ class WebPortal {
static void apiFsUpload();
/** @brief Streaming upload handler for /api/fs/upload multipart file data. */
static void handleFsUploadStream();
+ /** @brief POST /api/calibrate/start — start the NORVI button calibration wizard. */
+ static void apiCalibrateStart();
+ /** @brief GET /api/calibrate/status — return calibration status as JSON. */
+ static void apiCalibrateStatus();
+ /** @brief POST /api/calibrate/cancel — cancel a running calibration. */
+ static void apiCalibrateCancel();
static WebServer server_;
static DNSServer dnsServer_;
diff --git a/test/native/CMakeLists.txt b/test/native/CMakeLists.txt
index 84f4238c..1dc9b399 100644
--- a/test/native/CMakeLists.txt
+++ b/test/native/CMakeLists.txt
@@ -32,18 +32,22 @@ include_directories(
)
# ── Wrappers ──────────────────────────────────────────────────────────────
-# WebPortal, MqttPublisher, and Timer include class headers whose mock layout
-# differs from production. Compiling from wrappers/ avoids GCC's
-# "current-file-directory first" rule for #include "..." so the mock headers
-# take effect.
+# WebPortal, MqttPublisher, Timer, and CalibrationManager include class
+# headers whose mock layout differs from production. Compiling from
+# wrappers/ avoids GCC's "current-file-directory first" rule for
+# #include "..." so the mock headers take effect.
file(COPY ${PROJ_ROOT}/src/WebPortal.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers)
file(COPY ${PROJ_ROOT}/src/MqttPublisher.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers)
file(COPY ${PROJ_ROOT}/src/Timer.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers)
+file(COPY ${PROJ_ROOT}/src/CalibrationManager.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers)
+file(COPY ${PROJ_ROOT}/src/NorviButtonHandler.cpp DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/wrappers)
set(WRAPPER_SOURCES
${CMAKE_CURRENT_BINARY_DIR}/wrappers/WebPortal.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrappers/MqttPublisher.cpp
${CMAKE_CURRENT_BINARY_DIR}/wrappers/Timer.cpp
+ ${CMAKE_CURRENT_BINARY_DIR}/wrappers/CalibrationManager.cpp
+ ${CMAKE_CURRENT_BINARY_DIR}/wrappers/NorviButtonHandler.cpp
)
# ── Service classes ───────────────────────────────────────────────────────
@@ -56,9 +60,8 @@ set(SERVICE_SOURCES
${PROJ_ROOT}/src/OtaUpdater.cpp
${PROJ_ROOT}/src/LogCapture.cpp
${PROJ_ROOT}/src/LocalSettingsMenu.cpp
- ${PROJ_ROOT}/src/Ky040Decoder.cpp
+${PROJ_ROOT}/src/Ky040Decoder.cpp
${PROJ_ROOT}/src/OlimexEncoderHandler.cpp
- ${PROJ_ROOT}/src/NorviButtonHandler.cpp
)
# Mock sources (compiled once)
@@ -84,6 +87,7 @@ set(TEST_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_webportal_logs.cpp
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_local_settings_menu.cpp
${CMAKE_CURRENT_SOURCE_DIR}/tests/test_ky040_decoder.cpp
+ ${CMAKE_CURRENT_SOURCE_DIR}/tests/test_calibration_manager.cpp
)
add_executable(test_runner
diff --git a/test/native/mocks/ConfigManager.cpp b/test/native/mocks/ConfigManager.cpp
index 67fd0a00..56e07f74 100644
--- a/test/native/mocks/ConfigManager.cpp
+++ b/test/native/mocks/ConfigManager.cpp
@@ -10,6 +10,7 @@ NtpConfig ConfigManager::_ntp;
String ConfigManager::_adminPasswordHash = // SHA-256("admin")
"8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918";
bool ConfigManager::_configured = false;
+bool ConfigManager::_saveFails = false;
// ── Sensor Address Mapping ──
uint8_t ConfigManager::_sensorSolarAddr[8] = {0};
diff --git a/test/native/mocks/ConfigManager.hpp b/test/native/mocks/ConfigManager.hpp
index d1f2300e..d3dd8c6c 100644
--- a/test/native/mocks/ConfigManager.hpp
+++ b/test/native/mocks/ConfigManager.hpp
@@ -51,7 +51,8 @@ class ConfigManager {
static void setConfigured(bool configured) { _configured = configured; }
static bool begin() { return true; }
static bool load() { return true; }
- static bool save() { return true; }
+ static bool save() { return !_saveFails; }
+ static bool _saveFails; // test hook
static void reset() {
_adminPasswordHash = // gitleaks:allow SHA-256("admin")
F("8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918");
diff --git a/test/native/tests/test_calibration_manager.cpp b/test/native/tests/test_calibration_manager.cpp
new file mode 100644
index 00000000..661bd3e5
--- /dev/null
+++ b/test/native/tests/test_calibration_manager.cpp
@@ -0,0 +1,355 @@
+#include
+#include "CalibrationManager.hpp"
+#include "ConfigManager.hpp"
+
+extern void test_suite_end(const char *name, int passed, int failed);
+
+#define ASSERT_EQ(a, b) \
+ do { \
+ auto _a = (a); \
+ auto _b = (b); \
+ if (_a != _b) { \
+ printf(" ✗ %s:%d expected equality\n", __FILE__, __LINE__); \
+ return 1; \
+ } \
+ } while (0)
+
+using PoolController::CalibrationManager;
+
+// Test hooks: controllable ADC + clock
+static uint16_t g_adc = 0;
+static uint32_t g_now = 0;
+static uint16_t fakeAdc() {
+ return g_adc;
+}
+static uint32_t fakeTime() {
+ return g_now;
+}
+
+static int test_start_from_idle() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ ASSERT_EQ(CalibrationManager::isActive(), false);
+ ASSERT_EQ(CalibrationManager::start(), true);
+ ASSERT_EQ(CalibrationManager::isActive(), true);
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING);
+ return 0;
+}
+
+static int test_resting_measurement() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+ // Stable resting level: advance through the wait + sample phases
+ for (uint32_t t = 0; t < 2000; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1);
+ ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700);
+ return 0;
+}
+
+static int test_timeout_retries_step() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+ // No stable level: ADC oscillates wildly, never stabilizes
+ g_adc = 100;
+ for (uint32_t t = 0; t < 11000; t += 50) {
+ g_now = t;
+ g_adc = (g_adc + 500) % 4096; // never stable
+ CalibrationManager::loop();
+ }
+ // Still in RESTING (retried), not advanced
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING);
+ return 0;
+}
+
+static int test_cancel_from_step() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+ CalibrationManager::cancel();
+ ASSERT_EQ(CalibrationManager::isActive(), false);
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::IDLE);
+ return 0;
+}
+
+static int test_full_calibration_saves_thresholds() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ // Helper: hold a level for the wait + sample phases, then switch
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0); // resting
+ holdLevel(3400, 2000); // S1
+ holdLevel(3700, 4000); // S2
+ holdLevel(4095, 6000); // S3
+
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE);
+ auto &s = PoolController::ConfigManager::getSettings();
+ ASSERT_EQ(s.btn1Min, (2700 + 3400) / 2);
+ ASSERT_EQ(s.btn1Max, (3400 + 3700) / 2);
+ ASSERT_EQ(s.btn2Min, (3400 + 3700) / 2);
+ ASSERT_EQ(s.btn2Max, (3700 + 4095) / 2);
+ ASSERT_EQ(s.btn3Min, (3700 + 4095) / 2);
+ ASSERT_EQ(s.btn3Max, 4095);
+ ASSERT_EQ(s.btnNoPress, 4096);
+ return 0;
+}
+
+static int test_sample_spacing_gate() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ // Wait phase: 3 stable readings → sampling starts at t=100
+ for (uint32_t t = 0; t <= 100; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING);
+
+ // Rapid loop() calls without time advance must not collect samples —
+ // the sample phase is gated on SAMPLE_INTERVAL_MS.
+ for (int i = 0; i < 100; i++) {
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::RESTING);
+
+ // Advance time in 50 ms steps → sampling completes over ~1 s window
+ for (uint32_t t = 150; t < 2000; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1);
+ ASSERT_EQ(CalibrationManager::getStatus().restingLevel, 2700);
+ return 0;
+}
+
+static int test_average_below_gap_retries_step() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0); // resting → BTN1 at t=1100
+
+ // BTN1: wait phase accepts 2800 (exactly at the minimum gap boundary),
+ // then the samples drift down to 2790 — still inside the stability
+ // window, but the average lands below previousLevel + MIN_LEVEL_GAP.
+ g_adc = 2800;
+ for (uint32_t t = 2000; t <= 2100; t += 50) { // wait t=2000..2100 → sampling
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ g_adc = 2790;
+ for (uint32_t t = 2150; t < 3150; t += 50) { // 20 samples t=2150..3100 → completes
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1);
+ ASSERT_EQ(CalibrationManager::getStatus().s1, 0);
+
+ // Press again well above the gap → completes normally
+ holdLevel(2900, 3500);
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN2);
+ ASSERT_EQ(CalibrationManager::getStatus().s1, 2900);
+ return 0;
+}
+
+static int test_sample_restart_on_level_change() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0); // resting → BTN1 at t=1100
+
+ // BTN1: stabilize at 3400, then release mid-sampling → restart
+ g_adc = 3400;
+ for (uint32_t t = 2000; t <= 2200; t += 50) { // wait t=2000..2100, samples t=2150,2200
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ g_adc = 2700; // release during sampling
+ for (uint32_t t = 2250; t < 3000; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN1);
+ ASSERT_EQ(CalibrationManager::getStatus().s1, 0);
+
+ // Press again → sampling completes with a clean level
+ holdLevel(3400, 3000);
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN2);
+ ASSERT_EQ(CalibrationManager::getStatus().s1, 3400);
+ return 0;
+}
+
+static int test_release_level_rejected() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0); // resting
+ holdLevel(3700, 2000); // S1
+ holdLevel(2700, 4000); // release level (below S1) — must be rejected
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::BTN2);
+ holdLevel(3900, 5500); // valid S2
+ holdLevel(4095, 8000); // S3
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE);
+ return 0;
+}
+
+static int test_exact_minimum_gap_accepted() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ // Every level sits exactly MIN_LEVEL_GAP (100) above the previous one —
+ // the inclusive boundary must accept these.
+ holdLevel(2700, 0); // resting
+ holdLevel(2800, 2000); // S1 = resting + 100
+ holdLevel(2900, 4000); // S2 = S1 + 100
+ holdLevel(3000, 6000); // S3 = S2 + 100
+
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::DONE);
+ return 0;
+}
+
+static int test_save_failure_error() {
+ CalibrationManager::setAdcReadForTest(fakeAdc);
+ CalibrationManager::setTimeForTest(fakeTime);
+ g_adc = 2700;
+ g_now = 0;
+ CalibrationManager::begin();
+ CalibrationManager::start();
+
+ auto holdLevel = [](uint16_t level, uint32_t startMs) {
+ g_adc = level;
+ for (uint32_t t = startMs; t < startMs + 1500; t += 50) {
+ g_now = t;
+ CalibrationManager::loop();
+ }
+ };
+
+ holdLevel(2700, 0);
+ holdLevel(3400, 2000);
+ holdLevel(3700, 4000);
+
+ // Snapshot the live thresholds before the failed save
+ auto &s = PoolController::ConfigManager::getSettings();
+ const uint16_t prevBtn1Min = s.btn1Min;
+ const uint16_t prevBtn1Max = s.btn1Max;
+ const uint16_t prevBtn2Min = s.btn2Min;
+ const uint16_t prevBtn2Max = s.btn2Max;
+ const uint16_t prevBtn3Min = s.btn3Min;
+ const uint16_t prevBtn3Max = s.btn3Max;
+ const uint16_t prevBtnNoPress = s.btnNoPress;
+
+ // Force save failure before the final step completes → ERROR state
+ PoolController::ConfigManager::_saveFails = true;
+ holdLevel(4095, 6000);
+ PoolController::ConfigManager::_saveFails = false;
+
+ ASSERT_EQ(CalibrationManager::getStatus().step, CalibrationManager::Step::ERROR);
+ // Live Settings must be untouched when persistence fails
+ ASSERT_EQ(s.btn1Min, prevBtn1Min);
+ ASSERT_EQ(s.btn1Max, prevBtn1Max);
+ ASSERT_EQ(s.btn2Min, prevBtn2Min);
+ ASSERT_EQ(s.btn2Max, prevBtn2Max);
+ ASSERT_EQ(s.btn3Min, prevBtn3Min);
+ ASSERT_EQ(s.btn3Max, prevBtn3Max);
+ ASSERT_EQ(s.btnNoPress, prevBtnNoPress);
+ return 0;
+}
+
+int run_calibration_manager_tests() {
+ int failures = 0;
+ failures += test_start_from_idle();
+ failures += test_resting_measurement();
+ failures += test_timeout_retries_step();
+ failures += test_cancel_from_step();
+ failures += test_full_calibration_saves_thresholds();
+ failures += test_sample_spacing_gate();
+ failures += test_sample_restart_on_level_change();
+ failures += test_average_below_gap_retries_step();
+ failures += test_release_level_rejected();
+ failures += test_exact_minimum_gap_accepted();
+ failures += test_save_failure_error();
+ test_suite_end("CalibrationManager", 11 - failures, failures);
+ if (failures == 0) {
+ printf(" CalibrationManager Tests: 11 passed, 0 failed\n");
+ }
+ return failures;
+}
diff --git a/test/native/tests/test_main.cpp b/test/native/tests/test_main.cpp
index e5f33827..30518b34 100644
--- a/test/native/tests/test_main.cpp
+++ b/test/native/tests/test_main.cpp
@@ -110,6 +110,7 @@ extern int run_logcapture_tests();
extern int run_webportal_logs_tests();
extern int run_local_settings_menu_tests();
extern int run_ky040_decoder_tests();
+extern int run_calibration_manager_tests();
int main() {
printf("\n══════════════════════════════════════════════════\n");
@@ -128,6 +129,7 @@ int main() {
total += run_webportal_logs_tests();
total += run_local_settings_menu_tests();
total += run_ky040_decoder_tests();
+ total += run_calibration_manager_tests();
printf("\n══════════════════════════════════════════════════\n");
printf(" Results: %d suites passed, %d suites failed\n", g_testsPassed, g_testsFailed);
diff --git a/test/native/tests/test_webportal_json.cpp b/test/native/tests/test_webportal_json.cpp
index 397ff9fb..45aadbeb 100644
--- a/test/native/tests/test_webportal_json.cpp
+++ b/test/native/tests/test_webportal_json.cpp
@@ -258,5 +258,37 @@ int run_webportal_json_tests() {
passed++;
}
+ // ── Test: calibration status JSON ──
+ {
+ test_begin("WebPortal::apiCalibrateStatus", "returns JSON with all fields");
+
+ JsonDocument doc;
+ doc["step"] = 1;
+ doc["live_adc"] = 2700;
+ doc["resting"] = 0;
+ doc["s1"] = 0;
+ doc["s2"] = 0;
+ doc["s3"] = 0;
+ doc["message"] = "Release all buttons — measuring resting level";
+
+ int errs = 0;
+ const char *requiredKeys[] = {"step", "live_adc", "resting", "s1", "s2", "s3", "message"};
+ for (auto key : requiredKeys) {
+ if (!doc.containsKey(key)) {
+ char msg[128];
+ snprintf(msg, sizeof(msg), "Missing required key: %s", key);
+ test_fail(__FILE__, __LINE__, msg);
+ errs++;
+ }
+ }
+ ASSERT_TRUE(doc["step"].is());
+ ASSERT_TRUE(doc["live_adc"].is());
+ ASSERT_TRUE(doc["message"].is());
+
+ test_suite_end("WebPortal::apiCalibrateStatus", errs == 0 ? 3 : 0, errs);
+ passed += (errs == 0) ? 1 : 0;
+ failed += errs;
+ }
+
return passed + failed;
}