From 65d4ff7b621427cf5cc4e82aae39a5a8482627ea Mon Sep 17 00:00:00 2001 From: sebseb7 Date: Sat, 7 Mar 2026 15:53:57 -0500 Subject: [PATCH] feat: Implement web-based water button status UI with a dedicated server and associated rule UI test files. --- public/waterButtonStatus/script.js | 201 ++++++++++++++++++ public/waterButtonStatus/style.css | 315 +++++++++++++++++++++++++++++ rules/waterButtonLearnAndCall.js | 4 +- waterButtonStatusServer.js | 208 +++++++++++++++++++ 4 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 public/waterButtonStatus/script.js create mode 100644 public/waterButtonStatus/style.css create mode 100644 waterButtonStatusServer.js diff --git a/public/waterButtonStatus/script.js b/public/waterButtonStatus/script.js new file mode 100644 index 0000000..d1f863d --- /dev/null +++ b/public/waterButtonStatus/script.js @@ -0,0 +1,201 @@ +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'); + + 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; + } + + // Disable controls while timer is running + const running = state.timerActive; + btnRun.disabled = running; + durInput.disabled = running; + + 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) { + 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'); + } + } + + // 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; + } + + // 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; + } + + // ── 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); } + } + + 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; + } + } + + // 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 }); + } + }); diff --git a/public/waterButtonStatus/style.css b/public/waterButtonStatus/style.css new file mode 100644 index 0000000..5d37d74 --- /dev/null +++ b/public/waterButtonStatus/style.css @@ -0,0 +1,315 @@ +@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; } + + :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; + } + + .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); + } + + /* ── Progress Ring ── */ + .ring-wrapper { + position: relative; + width: 260px; + height: 260px; + } + + .ring-svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); + } + + .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-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.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); + } + + /* ── 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.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.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; + } + + @keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + + /* ── 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 { + opacity: 1; + } + + /* ── Controls ── */ + .controls { + display: flex; + gap: 0.6rem; + width: 100%; + justify-content: center; + flex-wrap: wrap; + } + + .run-container { + display: flex; + width: 100%; + gap: 0.6rem; + } + + #btn-run { + flex-grow: 1; + font-size: 1.4rem; + padding: 1.2rem; + } + + .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:hover { + background: rgba(59,130,246,0.12); + border-color: var(--blue); + } + + .btn:active { + transform: scale(0.96); + } + + .btn.primary { + background: linear-gradient(135deg, var(--blue), var(--cyan)); + border-color: transparent; + color: #fff; + } + + .btn.primary:hover { + filter: brightness(1.15); + background: linear-gradient(135deg, var(--blue), var(--cyan)); + } + + .btn.danger { + border-color: rgba(239,68,68,0.3); + } + + .btn.danger:hover { + background: rgba(239,68,68,0.15); + border-color: var(--red); + } + + .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; + } diff --git a/rules/waterButtonLearnAndCall.js b/rules/waterButtonLearnAndCall.js index 616ff28..fcc7e9b 100644 --- a/rules/waterButtonLearnAndCall.js +++ b/rules/waterButtonLearnAndCall.js @@ -17,9 +17,8 @@ import fs from 'fs'; import path from 'path'; -import http from 'http'; +import { startStatusServer } from '../waterButtonStatusServer.js'; import { fileURLToPath } from 'url'; -import { startStatusServer } from './waterTimerWebservice.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -570,7 +569,6 @@ export default { startStatusServer({ getState, WATER_BUTTON_MAC, - getSendRPC: () => global.__waterBotRPC, setLight, persistedState, saveState diff --git a/waterButtonStatusServer.js b/waterButtonStatusServer.js new file mode 100644 index 0000000..e6df8e2 --- /dev/null +++ b/waterButtonStatusServer.js @@ -0,0 +1,208 @@ +import fs from 'fs'; +import http from 'http'; + +const STATUS_PORT = 8082; + +export function startStatusServer({ + getState, + WATER_BUTTON_MAC, + setLight, + persistedState, + saveState +}) { + 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, + storedDuration: state.storedDuration, + timerStartedAt: state.timerStartedAt, + timerEndsAt: state.timerEndsAt, + now: Date.now() + }; + } + + const statusPageHTML = ` + + + + +Water Timer + + + +
+

💧 Water Timer

+ +
+
+ + + + + + + + + + +
+
+
IDLE
+
+
+ +
+ + Loading… +
+ +
+
+
+ + sec +
+ +
+ + +
+
+ + + +`; + + // Prevent duplicate listeners across hot reloads + if (global.__waterStatusServer) { + try { global.__waterStatusServer.close(); } catch (e) { } + } + + const srv = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + + const jsonHeaders = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + '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 === '/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') { + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify(getStatusJSON())); + return; + } + + // ── POST command endpoints ── + if (req.method === 'POST' && url.pathname.startsWith('/api/')) { + let body = ''; + 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); + + 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; } + state.countMode = false; + await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); + const duration = state.storedDuration; + state.timerStartedAt = Date.now(); + state.timerEndsAt = Date.now() + duration; + state.timer = setTimeout(async () => { + try { + const rpc = global.__waterBotRPC; + if (rpc) await setLight(rpc, WATER_BUTTON_MAC, true, 95); + state.timer = null; + state.timerStartedAt = null; + state.timerEndsAt = null; + } catch (err) { + console.error('[WaterStatus] Timer callback error:', err); + } + }, duration); + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'run', duration })); + + } else if (url.pathname === '/api/open') { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + state.countMode = false; + state.timerStartedAt = null; + state.timerEndsAt = null; + await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'open' })); + + } else if (url.pathname === '/api/close') { + if (state.timer) { clearTimeout(state.timer); state.timer = null; } + state.countMode = false; + state.timerStartedAt = null; + state.timerEndsAt = null; + await setLight(sendRPC, WATER_BUTTON_MAC, true, 95); + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'close' })); + + } else if (url.pathname === '/api/duration') { + const data = JSON.parse(body || '{}'); + const ms = Math.round((data.seconds || 0) * 1000); + if (ms <= 0 || ms > 300000) { + res.writeHead(400, jsonHeaders); + res.end(JSON.stringify({ ok: false, error: 'Duration must be 1-300s' })); + return; + } + state.storedDuration = ms; + persistedState[WATER_BUTTON_MAC] = { storedDuration: ms }; + saveState(persistedState); + res.writeHead(200, jsonHeaders); + res.end(JSON.stringify({ ok: true, action: 'duration', ms })); + + } else { + res.writeHead(404, jsonHeaders); + res.end(JSON.stringify({ ok: false, error: 'Unknown endpoint' })); + } + } catch (err) { + console.error('[WaterStatus] API error:', err); + res.writeHead(500, jsonHeaders); + res.end(JSON.stringify({ ok: false, error: err.message })); + } + }); + return; + } + + // Serve HTML status page + res.writeHead(200, { + 'Content-Type': 'text/html', + 'Cache-Control': 'no-cache' + }); + res.end(statusPageHTML); + }); + + srv.listen(STATUS_PORT, () => { + console.log(`[WaterStatus] Status page at http://localhost:${STATUS_PORT}`); + }); + + global.__waterStatusServer = srv; +}