Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4c02da4
docs: add NORVI button calibration design spec
stritti Aug 16, 2026
e11e68c
docs(calibration): add implementation plan for button calibration
stritti Aug 16, 2026
5413bf6
feat(calibration): add CalibrationManager state machine with measurement
stritti Aug 16, 2026
60399a2
test(calibration): cover threshold computation and error paths
stritti Aug 16, 2026
4607e93
feat(calibration): suppress button actions during calibration
stritti Aug 16, 2026
0fd7381
feat(calibration): add REST endpoints for calibration wizard
stritti Aug 16, 2026
747b5d1
feat(calibration): add guided calibration wizard to web UI
stritti Aug 16, 2026
658ecc6
style(calibration): apply clang-format to CalibrationManager
stritti Aug 16, 2026
12551e1
style(calibration): fix clang-format and trailing newlines for CI
stritti Aug 16, 2026
f8f4d5f
docs(calibration): fix trailing newlines for editorconfig CI
stritti Aug 16, 2026
9a06cc2
fix(calibration): accept exact minimum gap and reject release levels
stritti Aug 16, 2026
d4faced
fix(calibration): space ADC samples across the intended window
stritti Aug 16, 2026
34892ed
fix(calibration): preserve settings in memory when persistence fails
stritti Aug 16, 2026
1e924ef
fix(buttons): suppress callbacks until release after calibration
stritti Aug 16, 2026
8b918b7
fix(web): hide calibration wizard on non-NORVI firmware
stritti Aug 16, 2026
05a3730
fix(calibration): restart sampling when the level stops matching
stritti Aug 16, 2026
5643c97
fix(web): resume an already-running calibration
stritti Aug 16, 2026
5fd5006
fix(web): avoid duplicate calibration polling loops
stritti Aug 16, 2026
94a2357
fix(web): keep the wizard open when cancellation is rejected
stritti Aug 16, 2026
46ef5f9
fix(calibration): revalidate the averaged level before advancing
stritti Aug 17, 2026
9e4932f
fix(web): reject login HTML from calibration API responses
stritti Aug 17, 2026
190d2a7
feat(web): guide calibration wizard through clear step-by-step flow
stritti Aug 17, 2026
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
201 changes: 201 additions & 0 deletions data/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ async function loadTelemetry() {
document.getElementById('solarThreshold').textContent = 'min ' + data.temp_min_solar.toFixed(1) + '°C';
}

// NORVI capability: hide the calibration wizard on non-NORVI firmware
// (the /api/calibrate/* routes are compiled out there).
const calibSection = document.getElementById('calibrationSection');
if (calibSection) {
calibSection.style.display = data.norvi ? '' : 'none';
}

// Pumpen — Toggle-Switches aktualisieren
if (data.pool_pump != null) {
setPumpSwitch('pool', data.pool_pump);
Expand Down Expand Up @@ -651,6 +658,200 @@ async function saveControllerSettings() {
}
}

// ── Button Calibration Wizard ──

let calibPollTimer = null;

function showCalibrationModal() {
document.getElementById('calibrationModal').style.display = 'flex';
}

function closeCalibrationModal() {
document.getElementById('calibrationModal').style.display = 'none';
if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; }
if (calibCloseTimer) { clearTimeout(calibCloseTimer); calibCloseTimer = null; }
calibMsgPending = null;
calibMsgShownAt = 0;
}

function startCalibrationPolling() {
// Guard against duplicate poll loops when the start button is activated
// twice before the first request completes.
if (calibPollTimer) { clearInterval(calibPollTimer); }
calibPollTimer = setInterval(pollCalibrationStatus, 500);
pollCalibrationStatus();
}

async function startCalibration() {
const res = await fetch('/api/calibrate/start', { method: 'POST' });
// handleAuthentication() serves the login page with HTTP 200 when the
// session expired, so verify an actual API response first.
const type = res.headers.get('content-type') || '';
if (type.includes('text/html')) {
showLoginForm();
alert('Session expired — please log in again.');
return;
}
if (res.status === 409) {
// Calibration is already running on the device (e.g. after a page
// reload or a lost start response) — resume the running wizard so the
// user can watch progress or cancel it instead of being stuck.
showCalibrationModal();
startCalibrationPolling();
return;
}
if (!res.ok) { alert('Calibration could not be started.'); return; }
Comment thread
stritti marked this conversation as resolved.
Comment thread
stritti marked this conversation as resolved.
showCalibrationModal();
startCalibrationPolling();
}

// ── Calibration wizard UI state ──

const CALIB_STEP_HEADLINES = {
1: 'Release all buttons',
2: 'Press and hold Button 1',
3: 'Press and hold Button 2',
4: 'Press and hold Button 3',
5: 'Calibration complete',
6: 'Calibration failed'
};

const CALIB_NEXT_UP = {
1: 'Next: hold Button 1',
2: 'Next: hold Button 2',
3: 'Next: hold Button 3',
4: 'Then the levels are computed and saved automatically.'
};

// Keep every status text readable: a new message is only shown once the
// previous one has been on screen for at least MIN_MSG_MS milliseconds.
const CALIB_MIN_MSG_MS = 1200;
let calibMsgShownAt = 0;
let calibMsgPending = null;
let calibCloseTimer = null;

function showCalibrationMessage(text) {
const el = document.getElementById('calibStepText');
if (!el) return;
const now = Date.now();
if (now - calibMsgShownAt < CALIB_MIN_MSG_MS) {
// Current text still on screen — remember the newest one, promote later
calibMsgPending = text;
return;
}
if (calibMsgPending) { text = calibMsgPending; calibMsgPending = null; }
if (text !== el.textContent) {
el.textContent = text;
calibMsgShownAt = now;
}
}

function calibPhase(message) {
if (!message) return 'waiting';
if (message.includes('Computing')) return 'saving';
if (message.includes('sampling')) return 'sampling';
if (message.includes('try again') || message.includes('too close') || message.includes('changed')) return 'retry';
if (message.includes('complete')) return 'done';
return 'waiting';
}

function updateCalibrationUi(st) {
const phase = calibPhase(st.message || '');
const step = st.step;

// Bold instruction headline for the current action
let headline = CALIB_STEP_HEADLINES[step] || 'Calibration';
if (phase === 'saving') {
headline = 'Computing thresholds…';
} else if (phase === 'retry') {
headline = 'Try again — hold steady';
} else if (phase === 'sampling' && step >= 2 && step <= 4) {
headline = 'Keep holding Button ' + (step - 1);
} else if (phase === 'sampling' && step === 1) {
headline = 'Hold steady…';
}
const headlineEl = document.getElementById('calibActionHeadline');
if (headlineEl) headlineEl.textContent = headline;

// State chip
const chipLabels = { waiting: 'Waiting', sampling: 'Measuring', saving: 'Saving', retry: 'Retry', done: 'Done', error: 'Error' };
const chip = document.getElementById('calibStateChip');
if (chip) {
chip.textContent = chipLabels[phase] || chipLabels.waiting;
chip.className = 'calib-chip calib-chip-' + phase;
}

// Firmware detail line (throttled so it stays readable)
showCalibrationMessage(st.message || '');

// What happens next
const nextUp = document.getElementById('calibNextUp');
if (nextUp) nextUp.textContent = CALIB_NEXT_UP[step] || '';

// Live ADC meter (0-4095)
document.getElementById('calibLiveAdc').textContent = st.live_adc;
const fill = document.getElementById('calibMeterFill');
if (fill) {
fill.style.width = Math.min(100, Math.round((st.live_adc / 4095) * 100)) + '%';
fill.classList.toggle('sampling', phase === 'sampling');
}

// Step progress: current highlighted, completed marked with a check
const steps = ['calibP0', 'calibP1', 'calibP2', 'calibP3'];
steps.forEach((id, i) => {
const el = document.getElementById(id);
if (!el) return;
const done = (i + 1) < step;
const active = (i + 1) === step;
el.classList.toggle('active', active);
el.classList.toggle('done', done);
const dot = el.querySelector('.calib-step-dot');
if (dot) dot.textContent = done ? '✓' : (i + 1);
});
}

async function pollCalibrationStatus() {
const res = await fetch('/api/calibrate/status');
if (!res.ok) return;
// The login page is served with HTTP 200 on session expiry — stop
// polling instead of trying to parse HTML as JSON.
const type = res.headers.get('content-type') || '';
if (type.includes('text/html')) {
if (calibPollTimer) { clearInterval(calibPollTimer); calibPollTimer = null; }
showLoginForm();
alert('Session expired — please log in again to continue calibration.');
return;
}
const st = await res.json();
updateCalibrationUi(st);

if (st.step === 5) { // DONE — show the success state briefly, then close
if (!calibCloseTimer) {
calibCloseTimer = setTimeout(() => {
calibCloseTimer = null;
closeCalibrationModal();
loadConfig(); // refresh threshold fields
}, 1500);
}
} else if (st.step === 6) { // ERROR
closeCalibrationModal();
alert('Calibration failed: ' + (st.message || 'unknown error'));
}
}

async function cancelCalibration() {
const res = await fetch('/api/calibrate/cancel', { method: 'POST' });
// handleAuthentication() serves the login page with HTTP 200 when the
// session expired, so verify an actual API response before closing.
const type = res.headers.get('content-type') || '';
if (res.ok && !type.includes('text/html')) {
closeCalibrationModal();
} else {
showLoginForm();
alert('Session expired — please log in again to cancel calibration.');
}
}

// ── Save Time Settings (Time Tab) ──

async function saveTimeSettings() {
Expand Down
41 changes: 41 additions & 0 deletions data/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,38 @@ <h2 style="color: #00e5ff; text-align: center; margin: 0 0 0.25rem; font-size: 1
</div>
</div>

<!-- Calibration Modal (guided wizard) -->
<div id="calibrationModal" style="display: none; position: fixed; inset: 0; z-index: 100; background: rgba(6,18,30,0.85); backdrop-filter: blur(8px); align-items: center; justify-content: center; padding: 2rem;">
<div style="background: var(--glass-bg); border: 1px solid var(--panel-border); border-radius: 16px; padding: 2rem; max-width: 420px; width: 100%;">
<h2 style="color: #00e5ff; text-align: center; margin: 0 0 0.5rem; font-size: 1.4rem;">🎯 Button Calibration</h2>
<p class="calib-guide">Four measurements run one after another. Keep the button pressed until its step turns green.</p>

<!-- Current action: what to do right now -->
<div class="calib-action">
<span id="calibStateChip" class="calib-chip calib-chip-wait">Waiting</span>
<div id="calibActionHeadline" class="calib-headline">Release all buttons</div>
<p id="calibStepText" class="calib-detail">Release all buttons — measuring resting level</p>
<p id="calibNextUp" class="calib-nextup">Next: hold Button 1</p>
</div>

<!-- Step progress -->
<div class="calib-progress">
<div class="calib-step active" id="calibP0"><span class="calib-step-dot">1</span><span class="calib-step-label">Resting</span></div>
<div class="calib-step" id="calibP1"><span class="calib-step-dot">2</span><span class="calib-step-label">Button 1</span></div>
<div class="calib-step" id="calibP2"><span class="calib-step-dot">3</span><span class="calib-step-label">Button 2</span></div>
<div class="calib-step" id="calibP3"><span class="calib-step-dot">4</span><span class="calib-step-label">Button 3</span></div>
</div>

<!-- Live ADC meter -->
<div class="calib-meter">
<div class="calib-meter-bar"><div id="calibMeterFill" class="calib-meter-fill"></div></div>
<div class="calib-meter-readout"><span id="calibLiveAdc">—</span><span class="calib-meter-unit">ADC</span></div>
</div>

<button onclick="cancelCalibration()" class="calib-cancel">Cancel</button>
</div>
</div>

<!-- Bottom Tab Bar (iOS 26 Style) -->
<nav class="tab-bar" id="tabBar">
<button class="tab-bar-item active" data-tab="dashboard" onclick="switchTab('dashboard')">
Expand Down Expand Up @@ -354,6 +386,15 @@ <h2>🔘 Button Thresholds (NORVI)</h2>
<div style="height:1px;"></div>
</div>
</div>
<div class="telemetry-grid" id="calibrationSection">
<div class="input-group">
<button type="button" onclick="startCalibration()" style="width: 100%; padding: 0.75rem; background: linear-gradient(135deg, #00b4d8, #00e5ff); border: none; border-radius: 8px; color: #000; font-weight: 600; font-size: 1rem; cursor: pointer;">🎯 Start Calibration</button>
<span class="input-hint">About a minute · Release, then press and hold Button 1, 2, 3 in turn</span>
</div>
<div class="input-group" style="visibility:hidden;">
<div style="height:1px;"></div>
</div>
</div>

<h2>⏱️ Timer Schedule</h2>
<div class="telemetry-grid">
Expand Down
Loading
Loading