Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion data/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,13 @@ function validateSettings() {
{ id: 'tempCircThreshold', name: 'Circ. Temp Threshold', min: 0, max: 40, type: 'float' },
{ id: 'tempCircFactor', name: 'Circ. Temp Factor', min: 0, max: 120, type: 'int' },
{ id: 'tempCircMaxRuntime', name: 'Circ. Max Runtime', min: 60, max: 1440, type: 'int' },
{ id: 'btn1Min', name: 'Button 1 Min ADC', min: 0, max: 4095, type: 'int' },
{ id: 'btn1Max', name: 'Button 1 Max ADC', min: 0, max: 4095, type: 'int' },
{ id: 'btn2Min', name: 'Button 2 Min ADC', min: 0, max: 4095, type: 'int' },
{ id: 'btn2Max', name: 'Button 2 Max ADC', min: 0, max: 4095, type: 'int' },
{ id: 'btn3Min', name: 'Button 3 Min ADC', min: 0, max: 4095, type: 'int' },
{ id: 'btn3Max', name: 'Button 3 Max ADC', min: 0, max: 4095, type: 'int' },
{ id: 'btnNoPress', name: 'No-Press Threshold', min: 0, max: 4096, type: 'int' },
];
for (const f of fields) {
const el = document.getElementById(f.id);
Expand All @@ -572,6 +579,25 @@ function validateSettings() {
return false;
}
}
// Button ADC thresholds must form coherent, non-overlapping ranges
const btnVal = (id) => parseInt(document.getElementById(id).value, 10);
const b1Min = btnVal('btn1Min'), b1Max = btnVal('btn1Max');
const b2Min = btnVal('btn2Min'), b2Max = btnVal('btn2Max');
const b3Min = btnVal('btn3Min'), b3Max = btnVal('btn3Max');
if (b1Min >= b1Max || b2Min >= b2Max || b3Min >= b3Max) {
alert('Each button Min must be less than its Max.');
return false;
}
if (b1Max > b2Min || b2Max > b3Min) {
alert('Button ADC ranges must not overlap.');
return false;
}
Comment thread
stritti marked this conversation as resolved.
// No-press threshold must sit above every button range, otherwise
// detectButton() checks THRESH_NO_PRESS first and masks those readings as NONE.
if (btnVal('btnNoPress') <= b3Max) {
alert('No-Press Threshold must be above all button ranges.');
return false;
}
return true;
}

Expand All @@ -589,6 +615,13 @@ async function saveControllerSettings() {
const circFactor = document.getElementById('tempCircFactor').value;
const circMaxRuntime = document.getElementById('tempCircMaxRuntime').value;
const tz = document.getElementById('timezone').value;
const btn1Min = document.getElementById('btn1Min').value;
const btn1Max = document.getElementById('btn1Max').value;
const btn2Min = document.getElementById('btn2Min').value;
const btn2Max = document.getElementById('btn2Max').value;
const btn3Min = document.getElementById('btn3Min').value;
const btn3Max = document.getElementById('btn3Max').value;
const btnNoPress = document.getElementById('btnNoPress').value;
Comment thread
stritti marked this conversation as resolved.

// Validate time fields included alongside pool fields
const green = parseInt(document.getElementById('timeLossGreen').value, 10);
Expand All @@ -608,7 +641,7 @@ async function saveControllerSettings() {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'type=settings&mode=' + mode + '&interval=' + interval + '&max_pool=' + maxPool + '&min_solar=' + minSolar + '&hysteresis=' + hysteresis + '&circ_threshold=' + circThreshold + '&circ_factor=' + circFactor + '&circ_max_runtime=' + circMaxRuntime + '&timezone=' + tz + '&green=' + green + '&red=' + red + timerParams() + '&ntp_server=' + ntpServer
body: 'type=settings&mode=' + mode + '&interval=' + interval + '&max_pool=' + maxPool + '&min_solar=' + minSolar + '&hysteresis=' + hysteresis + '&circ_threshold=' + circThreshold + '&circ_factor=' + circFactor + '&circ_max_runtime=' + circMaxRuntime + '&timezone=' + tz + '&green=' + green + '&red=' + red + timerParams() + '&ntp_server=' + ntpServer + '&btn1_min=' + btn1Min + '&btn1_max=' + btn1Max + '&btn2_min=' + btn2Min + '&btn2_max=' + btn2Max + '&btn3_min=' + btn3Min + '&btn3_max=' + btn3Max + '&btn_no_press=' + btnNoPress
});
if (res.status === 200) {
document.getElementById('poolThreshold').textContent = 'max ' + parseFloat(maxPool).toFixed(1) + '°C';
Expand Down Expand Up @@ -729,6 +762,13 @@ async function loadConfig() {
document.getElementById('ntpServer').value = data.ntp.server;
document.getElementById('timeLossGreen').value = data.settings.time_loss_green_hours;
document.getElementById('timeLossRed').value = data.settings.time_loss_red_hours;
document.getElementById('btn1Min').value = data.settings.btn1_min;
document.getElementById('btn1Max').value = data.settings.btn1_max;
document.getElementById('btn2Min').value = data.settings.btn2_min;
document.getElementById('btn2Max').value = data.settings.btn2_max;
document.getElementById('btn3Min').value = data.settings.btn3_min;
document.getElementById('btn3Max').value = data.settings.btn3_max;
document.getElementById('btnNoPress').value = data.settings.btn_no_press;
const pad2 = (n) => n.toString().padStart(2, '0');
document.getElementById('timerStart').value = pad2(data.settings.timer_start_hour) + ':' + pad2(data.settings.timer_start_min);
document.getElementById('timerEnd').value = pad2(data.settings.timer_end_hour) + ':' + pad2(data.settings.timer_end_min);
Expand Down
48 changes: 48 additions & 0 deletions data/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,54 @@ <h2>🌡️ Temperature-Based Circulation</h2>
</div>
</div>

<h2>🔘 Button Thresholds (NORVI)</h2>
<div class="telemetry-grid">
<div class="input-group">
<label for="btn1Min">Button 1 Min ADC</label>
<input type="number" id="btn1Min" min="0" max="4095" step="1" value="3100">
<span class="input-hint">Lower bound of Button 1 range · Measured: ~3275–3456</span>
</div>
<div class="input-group">
<label for="btn1Max">Button 1 Max ADC</label>
<input type="number" id="btn1Max" min="0" max="4095" step="1" value="3520">
<span class="input-hint">Upper bound of Button 1 range</span>
</div>
</div>
<div class="telemetry-grid">
<div class="input-group">
<label for="btn2Min">Button 2 Min ADC</label>
<input type="number" id="btn2Min" min="0" max="4095" step="1" value="3520">
<span class="input-hint">Lower bound of Button 2 range · Measured: ~3579–3765</span>
</div>
<div class="input-group">
<label for="btn2Max">Button 2 Max ADC</label>
<input type="number" id="btn2Max" min="0" max="4095" step="1" value="3880">
<span class="input-hint">Upper bound of Button 2 range</span>
</div>
</div>
<div class="telemetry-grid">
<div class="input-group">
<label for="btn3Min">Button 3 Min ADC</label>
<input type="number" id="btn3Min" min="0" max="4095" step="1" value="3880">
<span class="input-hint">Lower bound of Button 3 range · Measured: ~4095 (full scale)</span>
</div>
<div class="input-group">
<label for="btn3Max">Button 3 Max ADC</label>
<input type="number" id="btn3Max" min="0" max="4095" step="1" value="4095">
<span class="input-hint">Upper bound of Button 3 range</span>
</div>
</div>
<div class="telemetry-grid">
<div class="input-group">
<label for="btnNoPress">No-Press Threshold</label>
<input type="number" id="btnNoPress" min="0" max="4096" step="1" value="4096">
<span class="input-hint">Values above this are ignored · 4096 = no-op (S3 reads full scale)</span>
</div>
<div class="input-group" style="visibility:hidden;">
<div style="height:1px;"></div>
</div>
</div>

<h2>⏱️ Timer Schedule</h2>
<div class="telemetry-grid">
<div class="input-group">
Expand Down
21 changes: 21 additions & 0 deletions src/ConfigManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ static constexpr const char *kSetRed = "set_red";
static constexpr const char *kSetCircThresh = "set_circth";
static constexpr const char *kSetCircFactor = "set_circfa";
static constexpr const char *kSetCircMax = "set_circmx";
static constexpr const char *kSetBtn1Min = "set_btn1min";
static constexpr const char *kSetBtn1Max = "set_btn1max";
static constexpr const char *kSetBtn2Min = "set_btn2min";
static constexpr const char *kSetBtn2Max = "set_btn2max";
static constexpr const char *kSetBtn3Min = "set_btn3min";
static constexpr const char *kSetBtn3Max = "set_btn3max";
static constexpr const char *kSetBtnNoPress = "set_btnnop";
static constexpr const char *kAdmPass = "adm_pass";
static constexpr const char *kCfgConfigured = "cfg_configured";

Expand Down Expand Up @@ -108,6 +115,13 @@ bool ConfigManager::load() {
settings_.tempCircThreshold = prefs.getDouble(kSetCircThresh, 24.0);
settings_.tempCircFactor = prefs.getUShort(kSetCircFactor, 30);
settings_.tempCircMaxRuntime = prefs.getUShort(kSetCircMax, 720);
settings_.btn1Min = prefs.getUShort(kSetBtn1Min, 3100);
settings_.btn1Max = prefs.getUShort(kSetBtn1Max, 3520);
settings_.btn2Min = prefs.getUShort(kSetBtn2Min, 3520);
settings_.btn2Max = prefs.getUShort(kSetBtn2Max, 3880);
settings_.btn3Min = prefs.getUShort(kSetBtn3Min, 3880);
settings_.btn3Max = prefs.getUShort(kSetBtn3Max, 4095);
settings_.btnNoPress = prefs.getUShort(kSetBtnNoPress, 4096);

adminPasswordHash_ = prefs.getString(kAdmPass, kDefaultPasswordHash);
configured_ = prefs.getBool(kCfgConfigured, false);
Expand Down Expand Up @@ -147,6 +161,13 @@ bool ConfigManager::save() {
prefs.putDouble(kSetCircThresh, settings_.tempCircThreshold);
prefs.putUShort(kSetCircFactor, settings_.tempCircFactor);
prefs.putUShort(kSetCircMax, settings_.tempCircMaxRuntime);
prefs.putUShort(kSetBtn1Min, settings_.btn1Min);
prefs.putUShort(kSetBtn1Max, settings_.btn1Max);
prefs.putUShort(kSetBtn2Min, settings_.btn2Min);
prefs.putUShort(kSetBtn2Max, settings_.btn2Max);
prefs.putUShort(kSetBtn3Min, settings_.btn3Min);
prefs.putUShort(kSetBtn3Max, settings_.btn3Max);
prefs.putUShort(kSetBtnNoPress, settings_.btnNoPress);

prefs.putString(kAdmPass, adminPasswordHash_);
prefs.putBool(kCfgConfigured, configured_);
Expand Down
9 changes: 8 additions & 1 deletion src/ConfigManager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,14 @@ struct ControllerSettings {
String opMode = "auto";
long timeLossGreenHours = 1;
long timeLossRedHours = 24;
int timezoneIndex = 0; ///< Index into TimeClientHelper timezone table
int timezoneIndex = 0; ///< Index into TimeClientHelper timezone table
uint16_t btn1Min = 3100; ///< NORVI button 1 ADC range min
uint16_t btn1Max = 3520; ///< NORVI button 1 ADC range max
uint16_t btn2Min = 3520; ///< NORVI button 2 ADC range min
uint16_t btn2Max = 3880; ///< NORVI button 2 ADC range max
uint16_t btn3Min = 3880; ///< NORVI button 3 ADC range min
uint16_t btn3Max = 4095; ///< NORVI button 3 ADC range max
uint16_t btnNoPress = 4096; ///< NORVI no-press threshold (no-op, > ADC max)
};

/**
Expand Down
28 changes: 27 additions & 1 deletion src/NorviButtonHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

#include <Arduino.h>
#include "Config.hpp"
#include "ConfigManager.hpp"
#include "LogCapture.hpp"

namespace PoolController {
Expand Down Expand Up @@ -51,13 +52,25 @@ NorviButtonHandler::ButtonLongPressCallback NorviButtonHandler::cbButton3Long_ =
uint32_t NorviButtonHandler::pressStartMs_ = 0;
uint32_t NorviButtonHandler::releasePendingMs_ = 0;

// ADC thresholds — defaults are the 2026-08-16 calibrated values; begin()
// overwrites them from ConfigManager (NVS) so they are user-configurable.
uint16_t NorviButtonHandler::THRESH_BTN1_MIN{3100};
uint16_t NorviButtonHandler::THRESH_BTN1_MAX{3520};
uint16_t NorviButtonHandler::THRESH_BTN2_MIN{3520};
uint16_t NorviButtonHandler::THRESH_BTN2_MAX{3880};
uint16_t NorviButtonHandler::THRESH_BTN3_MIN{3880};
uint16_t NorviButtonHandler::THRESH_BTN3_MAX{4095};
uint16_t NorviButtonHandler::THRESH_NO_PRESS{4096};

// ═══════════════════════════════════════════════════════════════════════════

void NorviButtonHandler::begin() {
LOG_INFO("• NorviButtonHandler initializing on ADC GPIO%d...\n", PIN_BUTTON_ADC);

pinMode(PIN_BUTTON_ADC, INPUT);

applySettings();

// Take an initial sample to let the ADC stabilise
analogRead(PIN_BUTTON_ADC);
delay(10);
Expand All @@ -72,6 +85,19 @@ void NorviButtonHandler::begin() {

// ═══════════════════════════════════════════════════════════════════════════

void NorviButtonHandler::applySettings() {
// Load configurable ADC thresholds from NVS (defaults = calibrated values)
THRESH_BTN1_MIN = ConfigManager::getSettings().btn1Min;
THRESH_BTN1_MAX = ConfigManager::getSettings().btn1Max;
THRESH_BTN2_MIN = ConfigManager::getSettings().btn2Min;
THRESH_BTN2_MAX = ConfigManager::getSettings().btn2Max;
THRESH_BTN3_MIN = ConfigManager::getSettings().btn3Min;
THRESH_BTN3_MAX = ConfigManager::getSettings().btn3Max;
THRESH_NO_PRESS = ConfigManager::getSettings().btnNoPress;
}

// ═══════════════════════════════════════════════════════════════════════════

void NorviButtonHandler::loop() {
const uint32_t now = millis();

Expand All @@ -89,7 +115,7 @@ void NorviButtonHandler::loop() {
static uint32_t lastStableTime_ = 0;

// Fast-attack: a genuine press/release changes the button range — even
// when the ADC delta is small (no-press 3800S3 3700 = 100). Base the
// when the ADC delta is small (no-press ~2700S1 ~3400 = 700). Base the
// re-initialization on button-range transitions so every valid press is
// caught within one sample instead of a full moving-average window. The
// transition also restarts the stability window, so a single-sample glitch
Expand Down
45 changes: 30 additions & 15 deletions src/NorviButtonHandler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,11 @@ namespace PoolController {
* ~50 ms intervals, applies hysteresis for noise rejection, and fires
* callbacks on press events.
*
* Default thresholds (12-bit ADC, 0–4095):
* Button 1: 200 – 1200
* Button 2: 14002500
* Button 3: 27003700
* No press: > 3800
* Calibrated thresholds (12-bit ADC, 0–4095) from live measurements:
* No press: < 3100 (resting level oscillates ~2610–2990)
* Button 1: 31003520
* Button 2: 35203880
* Button 3: 3880 – 4095
*/
class NorviButtonHandler {
public:
Expand All @@ -62,6 +62,13 @@ class NorviButtonHandler {
*/
static void begin();

/**
* @brief Reload ADC thresholds from ConfigManager (NVS).
* Called after settings changes so new thresholds apply to the running
* handler without a reboot.
*/
static void applySettings();

/**
* @brief Sample and debounce buttons.
* Must be called regularly from PoolController::loop().
Expand Down Expand Up @@ -172,16 +179,24 @@ class NorviButtonHandler {
static uint32_t releasePendingMs_;

// ── ADC thresholds (12-bit, 0–4095) ──────────────────────────────────
// These are typical ranges for the NORVI AE01-R resistor ladder.
// Adjust if needed based on serial debug output.

static constexpr uint16_t THRESH_BTN1_MIN{200};
static constexpr uint16_t THRESH_BTN1_MAX{1200};
static constexpr uint16_t THRESH_BTN2_MIN{1400};
static constexpr uint16_t THRESH_BTN2_MAX{2500};
static constexpr uint16_t THRESH_BTN3_MIN{2700};
static constexpr uint16_t THRESH_BTN3_MAX{3700};
static constexpr uint16_t THRESH_NO_PRESS{3800};
// Calibrated from live measurements on the NORVI AE01-R (2026-08-16):
// No press: ~2610–2990 (oscillates; must map to NONE)
// Button 1: ~3275–3456
// Button 2: ~3579–3765
// Button 3: ~4095 (full scale)
// The resistor ladder pulls the ADC pin UP toward VCC on press.
// Boundaries sit at the midpoints between adjacent levels.
// THRESH_NO_PRESS is a no-op (4096 > ADC max) — S3 reads full scale.
// Values are loaded from ConfigManager (NVS) in begin(); the defaults
// below are the calibrated values and can be overridden via the web UI.

static uint16_t THRESH_BTN1_MIN;
static uint16_t THRESH_BTN1_MAX;
static uint16_t THRESH_BTN2_MIN;
static uint16_t THRESH_BTN2_MAX;
static uint16_t THRESH_BTN3_MIN;
static uint16_t THRESH_BTN3_MAX;
static uint16_t THRESH_NO_PRESS;
};

} // namespace PoolController
31 changes: 31 additions & 0 deletions src/WebPortal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
#include "Version.h"
#include "LogCapture.hpp"

#ifdef NORVI_AE01_R
#include "NorviButtonHandler.hpp"
#endif

namespace PoolController {

// File-scoped state for streaming LittleFS upload (used by handleFsUploadStream)
Expand Down Expand Up @@ -666,6 +670,13 @@ void WebPortal::apiGetConfig() {
settingsObj["timezone"] = ConfigManager::getSettings().timezoneIndex;
settingsObj["time_loss_green_hours"] = ConfigManager::getSettings().timeLossGreenHours;
settingsObj["time_loss_red_hours"] = ConfigManager::getSettings().timeLossRedHours;
settingsObj["btn1_min"] = ConfigManager::getSettings().btn1Min;
settingsObj["btn1_max"] = ConfigManager::getSettings().btn1Max;
settingsObj["btn2_min"] = ConfigManager::getSettings().btn2Min;
settingsObj["btn2_max"] = ConfigManager::getSettings().btn2Max;
settingsObj["btn3_min"] = ConfigManager::getSettings().btn3Min;
settingsObj["btn3_max"] = ConfigManager::getSettings().btn3Max;
settingsObj["btn_no_press"] = ConfigManager::getSettings().btnNoPress;
settingsObj["timer_start_hour"] = operationModeNode.getTimerSetting().timerStartHour;
settingsObj["timer_start_min"] = operationModeNode.getTimerSetting().timerStartMinutes;
settingsObj["timer_end_hour"] = operationModeNode.getTimerSetting().timerEndHour;
Expand Down Expand Up @@ -750,12 +761,32 @@ void WebPortal::apiSaveConfig() {
ConfigManager::getSettings().tempCircFactor = server_.arg("circ_factor").toInt();
if (server_.hasArg("circ_max_runtime"))
ConfigManager::getSettings().tempCircMaxRuntime = server_.arg("circ_max_runtime").toInt();
if (server_.hasArg("btn1_min"))
ConfigManager::getSettings().btn1Min = server_.arg("btn1_min").toInt();
Comment thread
stritti marked this conversation as resolved.
if (server_.hasArg("btn1_max"))
ConfigManager::getSettings().btn1Max = server_.arg("btn1_max").toInt();
if (server_.hasArg("btn2_min"))
ConfigManager::getSettings().btn2Min = server_.arg("btn2_min").toInt();
if (server_.hasArg("btn2_max"))
ConfigManager::getSettings().btn2Max = server_.arg("btn2_max").toInt();
if (server_.hasArg("btn3_min"))
ConfigManager::getSettings().btn3Min = server_.arg("btn3_min").toInt();
if (server_.hasArg("btn3_max"))
ConfigManager::getSettings().btn3Max = server_.arg("btn3_max").toInt();
if (server_.hasArg("btn_no_press"))
ConfigManager::getSettings().btnNoPress = server_.arg("btn_no_press").toInt();
ConfigManager::getSettings().timezoneIndex = server_.arg("timezone").toInt();
ConfigManager::getSettings().timeLossGreenHours = server_.arg("green").toInt();
ConfigManager::getSettings().timeLossRedHours = server_.arg("red").toInt();

ConfigManager::save();

#ifdef NORVI_AE01_R
// Apply button thresholds to the running handler immediately so the
// new values take effect without a reboot (P2 review fix).
NorviButtonHandler::applySettings();
#endif

// Apply timezone change to running clock immediately (P2)
setTimezoneIndex(ConfigManager::getSettings().timezoneIndex);

Expand Down
1 change: 1 addition & 0 deletions test/native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ set(SERVICE_SOURCES
${PROJ_ROOT}/src/LocalSettingsMenu.cpp
${PROJ_ROOT}/src/Ky040Decoder.cpp
${PROJ_ROOT}/src/OlimexEncoderHandler.cpp
${PROJ_ROOT}/src/NorviButtonHandler.cpp
)

# Mock sources (compiled once)
Expand Down
7 changes: 7 additions & 0 deletions test/native/mocks/ConfigManager.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ struct Settings {
float tempCircThreshold = 24.0f;
int tempCircFactor = 30;
int tempCircMaxRuntime = 720;
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;
};

struct WiFiConfig {
Expand Down
Loading