feat: Implement web-based water button status UI with a dedicated server and associated rule UI test files.
This commit is contained in:
208
waterButtonStatusServer.js
Normal file
208
waterButtonStatusServer.js
Normal file
@@ -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 = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>Water Timer</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>💧 Water Timer</h1>
|
||||
|
||||
<div class="ring-wrapper">
|
||||
<div class="glow" id="glow"></div>
|
||||
<svg class="ring-svg" viewBox="0 0 120 120">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#06b6d4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle class="ring-bg" cx="60" cy="60" r="52"/>
|
||||
<circle class="ring-fg" id="ring" cx="60" cy="60" r="52"
|
||||
stroke-dasharray="326.73" stroke-dashoffset="326.73"/>
|
||||
</svg>
|
||||
<div class="ring-center">
|
||||
<div class="countdown idle" id="countdown">—</div>
|
||||
<div class="countdown-label" id="label">IDLE</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badge closed" id="badge">
|
||||
<span class="dot"></span>
|
||||
<span id="badge-text">Loading…</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="run-container">
|
||||
<div class="dur-input-group">
|
||||
<input type="number" class="dur-input" id="dur-input" min="1" max="300" step="1" value="">
|
||||
<span class="dur-unit">sec</span>
|
||||
</div>
|
||||
<button class="btn primary" id="btn-run" onclick="doRun()">▶ Run</button>
|
||||
</div>
|
||||
<button class="btn success" id="btn-open" onclick="doOpen()">🔓 Open</button>
|
||||
<button class="btn danger" id="btn-close" onclick="doClose()">🔒 Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/script.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user