diff --git a/public/waterButtonStatus/script.js b/public/waterButtonStatus/script.js index d1f863d..486508c 100644 --- a/public/waterButtonStatus/script.js +++ b/public/waterButtonStatus/script.js @@ -1,201 +1,271 @@ const CIRC = 2 * Math.PI * 52; // ~326.73 - const ring = document.getElementById('ring'); - const cd = document.getElementById('countdown'); - const label = document.getElementById('label'); - const badge = document.getElementById('badge'); - const badgeTxt = document.getElementById('badge-text'); - const glow = document.getElementById('glow'); - const durInput = document.getElementById('dur-input'); - const btnRun = document.getElementById('btn-run'); - const btnOpen = document.getElementById('btn-open'); - const btnClose = document.getElementById('btn-close'); +const ring = document.getElementById('ring'); +const cd = document.getElementById('countdown'); +const label = document.getElementById('label'); +const badge = document.getElementById('badge'); +const badgeTxt = document.getElementById('badge-text'); +const glow = document.getElementById('glow'); +const durInput = document.getElementById('dur-input'); +const btnRun = document.getElementById('btn-run'); +const btnLearn = document.getElementById('btn-learn'); +const btnOpen = document.getElementById('btn-open'); +const btnClose = document.getElementById('btn-close'); - function fmt(ms) { - if (ms <= 0) return '0.0'; - const totalSec = ms / 1000; - if (totalSec < 60) return totalSec.toFixed(1); - const m = Math.floor(totalSec / 60); - const s = Math.floor(totalSec % 60); - return m + ':' + String(s).padStart(2, '0'); +function fmt(ms) { + if (ms <= 0) return '0.0'; + const totalSec = ms / 1000; + if (totalSec < 60) return totalSec.toFixed(1); + const m = Math.floor(totalSec / 60); + const s = Math.floor(totalSec % 60); + return m + ':' + String(s).padStart(2, '0'); +} + +function fmtDur(ms) { + const s = (ms / 1000).toFixed(1); + return s + 's'; +} + +let state = null; +let serverOffset = 0; // server time - client time + +async function poll() { + try { + const r = await fetch('/api/status'); + state = await r.json(); + // Compute clock offset so countdown works even with clock skew + serverOffset = state.now - Date.now(); + } catch (e) { /* retry next tick */ } +} + +function render() { + if (!state) return; + + // Sync duration input if user isn't editing it + if (document.activeElement !== durInput) { + const secs = Math.round(state.storedDuration / 1000); + if (durInput.value !== String(secs)) durInput.value = secs; } - function fmtDur(ms) { - const s = (ms / 1000).toFixed(1); - return s + 's'; + // Disable controls while timer is running or learning + const running = state.timerActive; + const learning = state.countMode; + btnRun.disabled = running || learning; + durInput.disabled = running || learning; + btnOpen.disabled = running || learning; + btnClose.disabled = running || learning; + + // Learn button toggles label and action + if (learning) { + btnLearn.textContent = '⏹ Stop Learn'; + btnLearn.onclick = doStopLearn; + btnLearn.classList.add('active-learn'); + } else { + btnLearn.textContent = 'πŸ“ Learn'; + btnLearn.onclick = doLearn; + btnLearn.classList.remove('active-learn'); + } + btnLearn.disabled = running; // can't start learn while timer runs + + if (state.timerActive && state.timerStartedAt && state.timerEndsAt) { + const total = state.timerEndsAt - state.timerStartedAt; + const serverNow = Date.now() + serverOffset; + const remaining = Math.max(0, state.timerEndsAt - serverNow); + const progress = total > 0 ? remaining / total : 0; + + ring.style.strokeDashoffset = CIRC * (1 - progress); + cd.textContent = fmt(remaining); + cd.className = 'countdown'; + label.textContent = 'REMAINING'; + badge.className = 'badge running'; + badgeTxt.textContent = 'Timer Running'; + glow.classList.add('active'); + } else if (state.countMode) { + const elapsed = state.countStart + ? Math.max(0, (Date.now() + serverOffset) - state.countStart) + : 0; + ring.style.strokeDashoffset = CIRC; + cd.textContent = fmt(elapsed); + cd.className = 'countdown'; + label.textContent = 'LEARNING'; + badge.className = 'badge counting'; + badgeTxt.textContent = 'Count Mode'; + glow.classList.remove('active'); + } else { + ring.style.strokeDashoffset = CIRC; + cd.textContent = 'β€”'; + cd.className = 'countdown idle'; + label.textContent = 'IDLE'; + badge.className = 'badge closed'; + badgeTxt.textContent = 'Idle'; + glow.classList.remove('active'); + } +} + +// Quindar tones via Web Audio API +let audioCtx = null; +let endBeeped = false; +let wasTimerActive = false; + +function playTone(freq) { + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const t = audioCtx.currentTime; + const osc = audioCtx.createOscillator(); + const gain = audioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + gain.gain.setValueAtTime(0.3, t); + gain.gain.setValueAtTime(0.3, t + 0.24); + gain.gain.linearRampToValueAtTime(0, t + 0.25); + osc.connect(gain); + gain.connect(audioCtx.destination); + osc.start(t); + osc.stop(t + 0.25); +} + +function checkBeep() { + if (!state) return; + const active = state.timerActive && state.timerStartedAt && state.timerEndsAt; + + // Start tone (2525 Hz) when timer begins + if (active && !wasTimerActive) { + playTone(2525); + endBeeped = false; } - let state = null; - let serverOffset = 0; // server time - client time - - async function poll() { - try { - const r = await fetch('/api/status'); - state = await r.json(); - // Compute clock offset so countdown works even with clock skew - serverOffset = state.now - Date.now(); - } catch(e) { /* retry next tick */ } - } - - function render() { - if (!state) return; - - // Sync duration input if user isn't editing it - if (document.activeElement !== durInput) { - const secs = Math.round(state.storedDuration / 1000); - if (durInput.value !== String(secs)) durInput.value = secs; + // End tone (2475 Hz) at 10s remaining + if (active) { + const serverNow = Date.now() + serverOffset; + const remaining = Math.max(0, state.timerEndsAt - serverNow); + if (remaining <= 10000 && remaining > 0 && !endBeeped) { + endBeeped = true; + playTone(2475); } + } else { + endBeeped = false; + } - // Disable controls while timer is running - const running = state.timerActive; - btnRun.disabled = running; - durInput.disabled = running; + wasTimerActive = !!active; +} - if (state.timerActive && state.timerStartedAt && state.timerEndsAt) { - const total = state.timerEndsAt - state.timerStartedAt; - const serverNow = Date.now() + serverOffset; - const remaining = Math.max(0, state.timerEndsAt - serverNow); - const progress = total > 0 ? remaining / total : 0; +// ── Wake Lock ── +let wakeLock = null; +let lockAcquired = false; - ring.style.strokeDashoffset = CIRC * (1 - progress); - cd.textContent = fmt(remaining); - cd.className = 'countdown'; - label.textContent = 'REMAINING'; - badge.className = 'badge running'; - badgeTxt.textContent = 'Timer Running'; - glow.classList.add('active'); - } else if (state.countMode) { - ring.style.strokeDashoffset = CIRC; - cd.textContent = '⏱'; - cd.className = 'countdown idle'; - label.textContent = 'LEARNING'; - badge.className = 'badge counting'; - badgeTxt.textContent = 'Count Mode'; - glow.classList.remove('active'); - } else { - ring.style.strokeDashoffset = CIRC; - cd.textContent = 'β€”'; - cd.className = 'countdown idle'; - label.textContent = 'IDLE'; - badge.className = 'badge closed'; - badgeTxt.textContent = 'Idle'; - glow.classList.remove('active'); +async function requestWakeLock() { + try { + if ('wakeLock' in navigator && !wakeLock) { + wakeLock = await navigator.wakeLock.request('screen'); + wakeLock.addEventListener('release', () => { wakeLock = null; }); } + } catch (e) { console.error('Wake Lock error:', e); } +} + +function releaseWakeLock() { + if (wakeLock) { wakeLock.release().catch(() => { }); wakeLock = null; } +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && lockAcquired) requestWakeLock(); +}); + +function checkWakeLock() { + if (!state) return; + const active = state.timerActive || state.countMode; + if (active && !lockAcquired) { + requestWakeLock(); + lockAcquired = true; + } else if (!active && lockAcquired) { + releaseWakeLock(); + lockAcquired = false; } +} - // Quindar tones via Web Audio API - let audioCtx = null; - let endBeeped = false; - let wasTimerActive = false; +// Poll server every 2s, render locally at 20fps for smooth countdown +poll(); +setInterval(poll, 2000); +setInterval(() => { render(); checkBeep(); checkWakeLock(); }, 50); - function playTone(freq) { - if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - const t = audioCtx.currentTime; - const osc = audioCtx.createOscillator(); - const gain = audioCtx.createGain(); - osc.type = 'sine'; - osc.frequency.value = freq; - gain.gain.setValueAtTime(0.3, t); - gain.gain.setValueAtTime(0.3, t + 0.24); - gain.gain.linearRampToValueAtTime(0, t + 0.25); - osc.connect(gain); - gain.connect(audioCtx.destination); - osc.start(t); - osc.stop(t + 0.25); +// ── Control actions ── +async function apiPost(path, body = {}) { + try { + const r = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const d = await r.json(); + if (!d.ok) console.error('API error:', d.error); + await poll(); + } catch (e) { console.error('API call failed:', e); } +} + +async function doRun() { + // Save duration first if changed + const secs = parseInt(durInput.value, 10); + if (secs > 0 && secs <= 300) { + await apiPost('/api/duration', { seconds: secs }); } + await apiPost('/api/run'); +} - function checkBeep() { - if (!state) return; - const active = state.timerActive && state.timerStartedAt && state.timerEndsAt; +function doOpen() { apiPost('/api/open'); } +function doClose() { apiPost('/api/close'); } - // Start tone (2525 Hz) when timer begins - if (active && !wasTimerActive) { - playTone(2525); - endBeeped = false; - } +async function doLearn() { await apiPost('/api/learn'); } +async function doStopLearn() { await apiPost('/api/stop-learn'); } - // End tone (2475 Hz) at 10s remaining - if (active) { - const serverNow = Date.now() + serverOffset; - const remaining = Math.max(0, state.timerEndsAt - serverNow); - if (remaining <= 10000 && remaining > 0 && !endBeeped) { - endBeeped = true; - playTone(2475); - } - } else { - endBeeped = false; - } - wasTimerActive = !!active; +// Also save duration on Enter or blur +durInput.addEventListener('change', () => { + const secs = parseInt(durInput.value, 10); + if (secs > 0 && secs <= 300) { + apiPost('/api/duration', { seconds: secs }); } +}); - // ── Wake Lock ── - let wakeLock = null; - let lockAcquired = false; - - async function requestWakeLock() { - try { - if ('wakeLock' in navigator && !wakeLock) { - wakeLock = await navigator.wakeLock.request('screen'); - wakeLock.addEventListener('release', () => { wakeLock = null; }); - } - } catch(e) { console.error('Wake Lock error:', e); } +// ── Button online status ── +const deviceDot = document.getElementById('device-dot'); +const deviceState = document.getElementById('device-state'); +const valveDot = document.getElementById('valve-dot'); +const valveState = document.getElementById('valve-state'); + +function updateDeviceWidget(dot, stateEl, online) { + if (online === true) { + dot.className = 'device-dot online'; + stateEl.className = 'device-state online'; + stateEl.textContent = 'Online'; + } else if (online === false) { + dot.className = 'device-dot offline'; + stateEl.className = 'device-state offline'; + stateEl.textContent = 'Offline'; + } else { + dot.className = 'device-dot'; + stateEl.className = 'device-state'; + stateEl.textContent = 'Unknown'; } - - function releaseWakeLock() { - if (wakeLock) { wakeLock.release().catch(()=>{}); wakeLock = null; } +} + +async function pollButtonOnline() { + try { + const r = await fetch('/api/button-online'); + const d = await r.json(); + updateDeviceWidget(deviceDot, deviceState, d.online); + } catch { + updateDeviceWidget(deviceDot, deviceState, null); } - - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'visible' && lockAcquired) requestWakeLock(); - }); - - function checkWakeLock() { - if (!state) return; - const active = state.timerActive || state.countMode; - if (active && !lockAcquired) { - requestWakeLock(); - lockAcquired = true; - } else if (!active && lockAcquired) { - releaseWakeLock(); - lockAcquired = false; - } +} + +async function pollValveOnline() { + try { + const r = await fetch('/api/valve-online'); + const d = await r.json(); + updateDeviceWidget(valveDot, valveState, d.online); + } catch { + updateDeviceWidget(valveDot, valveState, null); } +} - // Poll server every 2s, render locally at 20fps for smooth countdown - poll(); - setInterval(poll, 2000); - setInterval(() => { render(); checkBeep(); checkWakeLock(); }, 50); - - // ── Control actions ── - async function apiPost(path, body = {}) { - try { - const r = await fetch(path, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - const d = await r.json(); - if (!d.ok) console.error('API error:', d.error); - await poll(); - } catch(e) { console.error('API call failed:', e); } - } - - async function doRun() { - // Save duration first if changed - const secs = parseInt(durInput.value, 10); - if (secs > 0 && secs <= 300) { - await apiPost('/api/duration', { seconds: secs }); - } - await apiPost('/api/run'); - } - - function doOpen() { apiPost('/api/open'); } - function doClose() { apiPost('/api/close'); } - - // Also save duration on Enter or blur - durInput.addEventListener('change', () => { - const secs = parseInt(durInput.value, 10); - if (secs > 0 && secs <= 300) { - apiPost('/api/duration', { seconds: secs }); - } - }); +pollButtonOnline(); +pollValveOnline(); +setInterval(pollButtonOnline, 10000); +setInterval(pollValveOnline, 10000); diff --git a/public/waterButtonStatus/style.css b/public/waterButtonStatus/style.css index 5d37d74..5587849 100644 --- a/public/waterButtonStatus/style.css +++ b/public/waterButtonStatus/style.css @@ -1,315 +1,437 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); - * { margin: 0; padding: 0; box-sizing: border-box; } +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} - :root { - --bg: #0a0e1a; - --surface: #131829; - --border: rgba(255,255,255,0.06); - --text: #e2e8f0; - --text-dim: #64748b; - --blue: #3b82f6; - --cyan: #06b6d4; - --green: #22c55e; - --red: #ef4444; - --orange: #f59e0b; - } +:root { + --bg: #0a0e1a; + --surface: #131829; + --border: rgba(255, 255, 255, 0.06); + --text: #e2e8f0; + --text-dim: #64748b; + --blue: #3b82f6; + --cyan: #06b6d4; + --green: #22c55e; + --red: #ef4444; + --orange: #f59e0b; +} - body { - font-family: 'Inter', -apple-system, sans-serif; - background: var(--bg); - color: var(--text); - min-height: 100dvh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 1.5rem; - overflow: hidden; - } +body { + font-family: 'Inter', -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100dvh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + overflow: hidden; +} - .container { - width: 100%; - max-width: 380px; - display: flex; - flex-direction: column; - align-items: center; - gap: 2rem; - } +.container { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2rem; +} - h1 { - font-size: 1.1rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; - color: var(--text-dim); - } +h1 { + font-size: 1.1rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-dim); +} - /* ── Progress Ring ── */ - .ring-wrapper { - position: relative; - width: 260px; - height: 260px; - } +/* ── Progress Ring ── */ +.ring-wrapper { + position: relative; + width: 260px; + height: 260px; +} - .ring-svg { - width: 100%; - height: 100%; - transform: rotate(-90deg); - } +.ring-svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} - .ring-bg { - fill: none; - stroke: var(--border); - stroke-width: 8; - } +.ring-bg { + fill: none; + stroke: var(--border); + stroke-width: 8; +} - .ring-fg { - fill: none; - stroke: url(#grad); - stroke-width: 8; - stroke-linecap: round; - transition: stroke-dashoffset 0.4s ease; - } +.ring-fg { + fill: none; + stroke: url(#grad); + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset 0.4s ease; +} - .ring-center { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 0.3rem; - } +.ring-center { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.3rem; +} - .countdown { - font-size: 3.2rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1; - background: linear-gradient(135deg, var(--blue), var(--cyan)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } +.countdown { + font-size: 3.2rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1; + background: linear-gradient(135deg, var(--blue), var(--cyan)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} - .countdown.idle { - font-size: 1.6rem; - background: none; - -webkit-text-fill-color: var(--text-dim); - } +.countdown.idle { + font-size: 1.6rem; + background: none; + -webkit-text-fill-color: var(--text-dim); +} - .countdown-label { - font-size: 0.75rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--text-dim); - } +.countdown-label { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-dim); +} - /* ── Status Badge ── */ - .badge { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.6rem 1.4rem; - border-radius: 100px; - font-size: 0.85rem; - font-weight: 600; - letter-spacing: 0.02em; - border: 1px solid var(--border); - background: var(--surface); - } +/* ── Status Badge ── */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1.4rem; + border-radius: 100px; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid var(--border); + background: var(--surface); +} - .badge .dot { - width: 10px; - height: 10px; - border-radius: 50%; - flex-shrink: 0; - } +.badge .dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} - .badge.running .dot { - background: var(--cyan); - box-shadow: 0 0 8px var(--cyan); - animation: pulse-dot 1.5s ease-in-out infinite; - } +.badge.running .dot { + background: var(--cyan); + box-shadow: 0 0 8px var(--cyan); + animation: pulse-dot 1.5s ease-in-out infinite; +} - .badge.open .dot { - background: var(--green); - box-shadow: 0 0 8px var(--green); - } +.badge.open .dot { + background: var(--green); + box-shadow: 0 0 8px var(--green); +} - .badge.closed .dot { - background: var(--red); - box-shadow: 0 0 6px var(--red); - } +.badge.closed .dot { + background: var(--red); + box-shadow: 0 0 6px var(--red); +} - .badge.counting .dot { - background: var(--orange); - box-shadow: 0 0 8px var(--orange); - animation: pulse-dot 0.8s ease-in-out infinite; - } +.badge.counting .dot { + background: var(--orange); + box-shadow: 0 0 8px var(--orange); + animation: pulse-dot 0.8s ease-in-out infinite; +} - @keyframes pulse-dot { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } - } +@keyframes pulse-dot { - /* ── Info row ── */ - .info { - display: flex; - gap: 1.5rem; - } - - .info-item { - text-align: center; - } - - .info-value { - font-size: 1.3rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - - .info-label { - font-size: 0.7rem; - color: var(--text-dim); - text-transform: uppercase; - letter-spacing: 0.08em; - margin-top: 0.15rem; - } - - /* ── Glow effect behind ring ── */ - .glow { - position: absolute; - inset: 20px; - border-radius: 50%; - background: radial-gradient(circle, rgba(6,182,212,0.12) 0%, transparent 70%); - opacity: 0; - transition: opacity 0.6s ease; - pointer-events: none; - } - - .glow.active { + 0%, + 100% { opacity: 1; } - /* ── Controls ── */ - .controls { - display: flex; - gap: 0.6rem; - width: 100%; - justify-content: center; - flex-wrap: wrap; + 50% { + opacity: 0.4; } +} - .run-container { - display: flex; - width: 100%; - gap: 0.6rem; - } +/* ── Info row ── */ +.info { + display: flex; + gap: 1.5rem; +} - #btn-run { - flex-grow: 1; - font-size: 1.4rem; - padding: 1.2rem; - } +.info-item { + text-align: center; +} - .btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - padding: 0.65rem 1.2rem; - border-radius: 12px; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - font-family: inherit; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - transition: all 0.15s ease; - user-select: none; - -webkit-tap-highlight-color: transparent; - } +.info-value { + font-size: 1.3rem; + font-weight: 600; + font-variant-numeric: tabular-nums; +} - .btn:hover { - background: rgba(59,130,246,0.12); - border-color: var(--blue); - } +.info-label { + font-size: 0.7rem; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-top: 0.15rem; +} - .btn:active { - transform: scale(0.96); - } +/* ── Glow effect behind ring ── */ +.glow { + position: absolute; + inset: 20px; + border-radius: 50%; + background: radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, transparent 70%); + opacity: 0; + transition: opacity 0.6s ease; + pointer-events: none; +} - .btn.primary { - background: linear-gradient(135deg, var(--blue), var(--cyan)); - border-color: transparent; - color: #fff; - } +.glow.active { + opacity: 1; +} - .btn.primary:hover { - filter: brightness(1.15); - background: linear-gradient(135deg, var(--blue), var(--cyan)); - } +/* ── Controls ── */ +.controls { + display: flex; + gap: 0.6rem; + width: 100%; + justify-content: center; + flex-wrap: wrap; +} - .btn.danger { - border-color: rgba(239,68,68,0.3); - } +.run-container { + display: flex; + width: 100%; + gap: 0.6rem; +} - .btn.danger:hover { - background: rgba(239,68,68,0.15); - border-color: var(--red); - } +#btn-run { + flex-grow: 1; + font-size: 1.4rem; + padding: 1.2rem; +} - .btn.success { - border-color: rgba(34,197,94,0.3); - } +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.65rem 1.2rem; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + user-select: none; + -webkit-tap-highlight-color: transparent; +} - .btn.success:hover { - background: rgba(34,197,94,0.15); - border-color: var(--green); - } +.btn:hover { + background: rgba(59, 130, 246, 0.12); + border-color: var(--blue); +} - .btn:disabled { - opacity: 0.45; - pointer-events: none; - } +.btn:active { + transform: scale(0.96); +} - .dur-input-group { - display: flex; - align-items: center; - gap: 0.4rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 12px; - padding: 0 0.8rem; - transition: border-color 0.15s ease; - } +.btn.primary { + background: linear-gradient(135deg, var(--blue), var(--cyan)); + border-color: transparent; + color: #fff; +} - .dur-input-group:focus-within { - border-color: var(--blue); - } +.btn.primary:hover { + filter: brightness(1.15); + background: linear-gradient(135deg, var(--blue), var(--cyan)); +} - .dur-input { - width: 64px; - background: transparent; - border: none; - color: var(--text); - font-family: inherit; - font-size: 1.2rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - padding: 0.8rem 0; - outline: none; - text-align: right; - } +.btn.danger { + border-color: rgba(239, 68, 68, 0.3); +} - .dur-input::-webkit-inner-spin-button { opacity: 0.3; } +.btn.danger:hover { + background: rgba(239, 68, 68, 0.15); + border-color: var(--red); +} - .dur-unit { - font-size: 0.85rem; - color: var(--text-dim); - font-weight: 500; - } +.btn.warning { + border-color: rgba(245, 158, 11, 0.35); + color: var(--orange); +} + +.btn.warning:hover { + background: rgba(245, 158, 11, 0.15); + border-color: var(--orange); +} + +.btn.warning.active-learn { + background: var(--orange); + border-color: transparent; + color: #000; + animation: pulse-dot 1s ease-in-out infinite; +} + +.btn.success { + border-color: rgba(34, 197, 94, 0.3); +} + +.btn.success:hover { + background: rgba(34, 197, 94, 0.15); + border-color: var(--green); +} + +.btn:disabled { + opacity: 0.45; + pointer-events: none; +} + +.dur-input-group { + display: flex; + align-items: center; + gap: 0.4rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 0 0.8rem; + transition: border-color 0.15s ease; +} + +.dur-input-group:focus-within { + border-color: var(--blue); +} + +.dur-input { + width: 64px; + background: transparent; + border: none; + color: var(--text); + font-family: inherit; + font-size: 1.2rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 0.8rem 0; + outline: none; + text-align: right; +} + +.dur-input::-webkit-inner-spin-button { + opacity: 0.3; +} + +.dur-unit { + font-size: 0.85rem; + color: var(--text-dim); + font-weight: 500; +} + +/* ── Device Status Widgets ── */ +.devices-row { + display: flex; + gap: 0.6rem; + width: 100%; +} + +.device-status { + flex: 1; + display: flex; + align-items: center; + gap: 0.9rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 0.7rem 1.2rem; + width: 100%; +} + +.device-icon-wrap { + position: relative; + flex-shrink: 0; +} + +.device-icon { + width: 48px; + height: 48px; + object-fit: contain; + border-radius: 10px; + display: block; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.5)); +} + +.device-dot { + position: absolute; + bottom: -3px; + right: -3px; + width: 13px; + height: 13px; + border-radius: 50%; + border: 2px solid var(--bg); + background: var(--text-dim); + transition: background 0.4s ease, box-shadow 0.4s ease; +} + +.device-dot.online { + background: var(--green); + box-shadow: 0 0 7px var(--green); + animation: pulse-dot 2s ease-in-out infinite; +} + +.device-dot.offline { + background: var(--red); + box-shadow: 0 0 6px var(--red); +} + +.device-info { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.device-name { + font-size: 0.9rem; + font-weight: 600; + color: var(--text); +} + +.device-state { + font-size: 0.75rem; + font-weight: 500; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.06em; + transition: color 0.4s ease; +} + +.device-state.online { + color: var(--green); +} + +.device-state.offline { + color: var(--red); +} + +.device-desc { + font-size: 0.68rem; + color: var(--text-dim); + line-height: 1.35; + opacity: 0.75; +} \ No newline at end of file diff --git a/public/waterButtonStatus/taster.png b/public/waterButtonStatus/taster.png new file mode 100644 index 0000000..eab2768 Binary files /dev/null and b/public/waterButtonStatus/taster.png differ diff --git a/public/waterButtonStatus/valve.png b/public/waterButtonStatus/valve.png new file mode 100644 index 0000000..25f3ec7 Binary files /dev/null and b/public/waterButtonStatus/valve.png differ diff --git a/rules/waterButtonLearnAndCall.js b/rules/waterButtonLearnAndCall.js index 227a55f..2989e77 100644 --- a/rules/waterButtonLearnAndCall.js +++ b/rules/waterButtonLearnAndCall.js @@ -301,6 +301,7 @@ export default { startStatusServer({ getState, WATER_BUTTON_MAC, + REMOTE_SWITCH_MAC, setLight, persistedState, saveState diff --git a/waterButtonStatusServer.js b/waterButtonStatusServer.js index e6df8e2..8ec8f78 100644 --- a/waterButtonStatusServer.js +++ b/waterButtonStatusServer.js @@ -6,16 +6,45 @@ const STATUS_PORT = 8082; export function startStatusServer({ getState, WATER_BUTTON_MAC, + REMOTE_SWITCH_MAC, setLight, persistedState, saveState }) { + async function getButtonOnline() { + try { + const ctxGetState = global.__waterBotGetState; + if (!ctxGetState) return null; + const online = await ctxGetState(WATER_BUTTON_MAC, 'system', 'online'); + // DB stores booleans as strings 'true'/'false' + if (online === true || online === 'true') return true; + if (online === false || online === 'false') return false; + return null; + } catch { + return null; + } + } + + async function getValveOnline() { + try { + const ctxGetState = global.__waterBotGetState; + if (!ctxGetState) return null; + const online = await ctxGetState(REMOTE_SWITCH_MAC, 'system', 'online'); + if (online === true || online === 'true') return true; + if (online === false || online === 'false') return false; + return null; + } catch { + return null; + } + } + function getStatusJSON() { const state = getState(WATER_BUTTON_MAC); return { valveOpen: state.timer !== null || (!state.countMode && state.timer === null), timerActive: state.timer !== null, countMode: state.countMode, + countStart: state.countStart || null, storedDuration: state.storedDuration, timerStartedAt: state.timerStartedAt, timerEndsAt: state.timerEndsAt, @@ -35,6 +64,31 @@ export function startStatusServer({

πŸ’§ Water Timer

+
+
+
+ Button + +
+
+ Button + Checking… + Short press: run timer · Long press: start learn, short press to stop +
+
+
+
+ Valve + +
+
+ Valve + Checking… + ⚠️ Connect according to flow direction mark +
+
+
+
@@ -66,6 +120,7 @@ export function startStatusServer({ sec
+
@@ -81,7 +136,7 @@ export function startStatusServer({ try { global.__waterStatusServer.close(); } catch (e) { } } - const srv = http.createServer((req, res) => { + const srv = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); const jsonHeaders = { @@ -90,19 +145,45 @@ export function startStatusServer({ 'Access-Control-Allow-Origin': '*' }; - if (url.pathname === '/style.css') { - res.writeHead(200, { 'Content-Type': 'text/css' }); - res.end(fs.readFileSync(new URL('./public/waterButtonStatus/style.css', import.meta.url))); - return; - } + if (url.pathname === '/style.css') { + res.writeHead(200, { 'Content-Type': 'text/css' }); + res.end(fs.readFileSync(new URL('./public/waterButtonStatus/style.css', import.meta.url))); + return; + } - if (url.pathname === '/script.js') { - res.writeHead(200, { 'Content-Type': 'application/javascript' }); - res.end(fs.readFileSync(new URL('./public/waterButtonStatus/script.js', import.meta.url))); - return; - } + if (url.pathname === '/script.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(fs.readFileSync(new URL('./public/waterButtonStatus/script.js', import.meta.url))); + return; + } - if (url.pathname === '/api/status') { + if (url.pathname === '/taster.png') { + res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' }); + res.end(fs.readFileSync(new URL('./public/waterButtonStatus/taster.png', import.meta.url))); + return; + } + + if (url.pathname === '/api/button-online') { + const online = await getButtonOnline(); + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ online })); + return; + } + + if (url.pathname === '/valve.png') { + res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' }); + res.end(fs.readFileSync(new URL('./public/waterButtonStatus/valve.png', import.meta.url))); + return; + } + + if (url.pathname === '/api/valve-online') { + const online = await getValveOnline(); + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ online })); + return; + } + + if (url.pathname === '/api/status') { res.writeHead(200, jsonHeaders); res.end(JSON.stringify(getStatusJSON())); return; @@ -114,17 +195,55 @@ export function startStatusServer({ req.on('data', c => body += c); req.on('end', async () => { const sendRPC = global.__waterBotRPC; - - // Wait, inside the route we need `state` which uses `getState(WATER_BUTTON_MAC)` - // The original code did: const state = getState(WATER_BUTTON_MAC); const state = getState(WATER_BUTTON_MAC); + // ── Learn / Stop-learn: work regardless of device connection ── + if (url.pathname === '/api/learn') { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + state.timerStartedAt = null; + state.timerEndsAt = null; + state.countMode = true; + state.countStart = Date.now(); + if (sendRPC) { + try { await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); } catch (e) { + console.warn('[WaterStatus] learn: setLight failed:', e.message); + } + } + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'learn' })); + return; + } + + if (url.pathname === '/api/stop-learn') { + if (!state.countMode) { + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'stop-learn', skipped: true })); + return; + } + const elapsed = Date.now() - (state.countStart || Date.now()); + state.countMode = false; + state.countStart = 0; + state.storedDuration = elapsed; + persistedState[WATER_BUTTON_MAC] = { storedDuration: elapsed }; + saveState(persistedState); + if (sendRPC) { + try { await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); } catch (e) { + console.warn('[WaterStatus] stop-learn: setLight failed:', e.message); + } + } + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'stop-learn', storedDuration: elapsed })); + return; + } + + // ── All other commands require a live device connection ── if (!sendRPC) { res.writeHead(503, jsonHeaders); res.end(JSON.stringify({ ok: false, error: 'Not ready β€” no device connection' })); return; } + try { if (url.pathname === '/api/run') { if (state.timer) { clearTimeout(state.timer); state.timer = null; }