From 34a243ec02e3e095f5d716d1df89426c5e3d04bc Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sat, 7 Mar 2026 16:29:03 -0500 Subject: [PATCH] feat: Introduce a Blockly-based visual rule editor with AST generation and add new assets for water button status. --- public/waterButtonStatus/script.js | 422 ++++++++++-------- public/waterButtonStatus/style.css | 666 ++++++++++++++++------------ public/waterButtonStatus/taster.png | Bin 0 -> 10048 bytes public/waterButtonStatus/valve.png | Bin 0 -> 11008 bytes rules/waterButtonLearnAndCall.js | 1 + waterButtonStatusServer.js | 149 ++++++- 6 files changed, 775 insertions(+), 463 deletions(-) create mode 100644 public/waterButtonStatus/taster.png create mode 100644 public/waterButtonStatus/valve.png 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 0000000000000000000000000000000000000000..eab2768ecf640d0e166b2f14ad7c4197d87c39ae GIT binary patch literal 10048 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 BWW@jg literal 0 HcmV?d00001 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; }