Files
shellySrv/rules/waterButtonLearnAndCall.js

1273 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Timer light rule
*
* - btn_down: Light goes off
* - long_push: Flash to confirm, enter "count mode" - counts seconds until next btn_down, then light goes on
* - Normal btn_down (no long_push): Light goes off, then turns back on after the stored count elapsed
*
* Also syncs remote switch CC8DA243B0A0 inversely (light off = switch on, light on = switch off)
*
* Telegram bot (remote control):
* /open — Open valve
* /close — Close valve
* /run — Close valve then reopen after stored duration
* /duration — Get or set timer duration (e.g. 30s, 2m, 10000)
* /status — Show current status
*/
import fs from 'fs';
import path from 'path';
import http from 'http';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const STATE_FILE = path.join(__dirname, 'timer_state.json');
const WATER_BUTTON_MAC = '08A6F773510C';
const REMOTE_SWITCH_MAC = 'CC8DA243B0A0';
// Telegram
const BOT_TOKEN = process.env.WATER_VALVE_TELEGRAM_TOKEN;
const BOT_PASSWORD = process.env.WATER_VALVE_TELEGRAM_PASSWORD;
const TELEGRAM_API = `https://api.telegram.org/bot${BOT_TOKEN}`;
const AUTH_FILE = path.join(__dirname, 'telegram_authorized_users.json');
// Generation counter — incremented on each hot reload to stop the old poll loop
const BOT_GEN = Date.now();
if (!global.__waterBotGen) global.__waterBotGen = 0;
global.__waterBotGen = BOT_GEN;
// sendRPC / getState persisted across hot reloads via globals (populated on first ctx event)
if (!global.__waterBotRPC) global.__waterBotRPC = null;
if (!global.__waterBotGetState) global.__waterBotGetState = null;
// ── Device helpers ────────────────────────────────────────────────────────────
async function setLight(sendRPC, mac, on, brightness) {
await sendRPC(mac, 'Light.Set', { id: 0, on, brightness });
// Remote switch is inverse: light off → switch on, light on → switch off
await sendRPC(REMOTE_SWITCH_MAC, 'Switch.Set', { id: 0, on: !on });
}
// ── Persistence ───────────────────────────────────────────────────────────────
function loadPersistedState() {
try {
if (fs.existsSync(STATE_FILE)) {
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
}
} catch (err) {
console.error('Error loading state:', err);
}
return {};
}
function saveState(state) {
try {
const toSave = {};
for (const [mac, data] of Object.entries(state)) {
toSave[mac] = { storedDuration: data.storedDuration };
}
fs.writeFileSync(STATE_FILE, JSON.stringify(toSave, null, 2));
} catch (err) {
console.error('Error saving state:', err);
}
}
// ── In-memory device state ────────────────────────────────────────────────────
const persistedState = loadPersistedState();
const deviceState = new Map();
function getState(mac) {
if (!deviceState.has(mac)) {
const persisted = persistedState[mac] || {};
deviceState.set(mac, {
countMode: false,
countStart: 0,
storedDuration: persisted.storedDuration || 5000,
timer: null,
timerStartedAt: null,
timerEndsAt: null
});
}
return deviceState.get(mac);
}
// ── Telegram auth ─────────────────────────────────────────────────────────────
function loadAuthorizedUsers() {
try {
if (fs.existsSync(AUTH_FILE)) {
return new Set(JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8')));
}
} catch (err) {
console.error('[TelegramBot] Error loading authorized users:', err);
}
return new Set();
}
function saveAuthorizedUsers(users) {
try {
fs.writeFileSync(AUTH_FILE, JSON.stringify([...users], null, 2));
} catch (err) {
console.error('[TelegramBot] Error saving authorized users:', err);
}
}
const authorizedUsers = loadAuthorizedUsers();
// ── Telegram bot ──────────────────────────────────────────────────────────────
async function telegramRequest(method, params = {}) {
const res = await fetch(`${TELEGRAM_API}/${method}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
return res.json();
}
function menuKeyboard(durSec) {
return {
keyboard: [
[{ text: '🔓 Open valve' }, { text: '🔒 Close valve' }],
[{ text: `💧 Run timer (${durSec}s)` }, { text: '📊 Status' }]
],
resize_keyboard: true,
persistent: true
};
}
async function getConnectionStatus() {
try {
const getState = global.__waterBotGetState;
if (!getState) return '❓ Button\n❓ Valve';
const [buttonOnline, valveOnline] = await Promise.all([
getState(WATER_BUTTON_MAC, 'system', 'online'),
getState(REMOTE_SWITCH_MAC, 'system', 'online')
]);
const btn = buttonOnline === true ? '✅' : buttonOnline === false ? '❌ OFFLINE' : '❓';
const vlv = valveOnline === true ? '✅' : valveOnline === false ? '❌ OFFLINE' : '❓';
return `${btn} Button\n${vlv} Valve`;
} catch {
return '❓ Button\n❓ Valve';
}
}
async function reply(chatId, text, durSec) {
const connStatus = await getConnectionStatus();
return telegramRequest('sendMessage', {
chat_id: chatId,
text: `${connStatus}\n\n${text}`,
reply_markup: menuKeyboard(durSec)
});
}
async function sendMessage(chatId, text) {
return telegramRequest('sendMessage', { chat_id: chatId, text });
}
function parseDuration(raw) {
if (!raw) return null;
if (raw.endsWith('m')) return Math.round(parseFloat(raw) * 60000);
if (raw.endsWith('s')) return Math.round(parseFloat(raw) * 1000);
const ms = parseInt(raw, 10);
return isNaN(ms) ? null : ms;
}
async function handleBotMessage(msg) {
const chatId = msg.chat.id;
const text = (msg.text || '').trim();
const parts = text.split(/\s+/);
// Strip @botname suffix that Telegram adds in groups
const cmd = parts[0].toLowerCase().split('@')[0];
// ── Authorization gate ───────────────────────────────────────────────────
if (!authorizedUsers.has(chatId)) {
if (text === BOT_PASSWORD) {
authorizedUsers.add(chatId);
saveAuthorizedUsers(authorizedUsers);
console.log(`[TelegramBot] New authorized user: ${chatId} (${msg.from?.username || msg.from?.first_name || 'unknown'})`);
const state = getState(WATER_BUTTON_MAC);
await reply(chatId, 'Access granted. Use the buttons below or type a number (seconds) to set the duration.', (state.storedDuration / 1000).toFixed(1));
} else {
await sendMessage(chatId, 'Please enter the password to use this bot.');
}
return;
}
const state = getState(WATER_BUTTON_MAC);
const durSec = (state.storedDuration / 1000).toFixed(1);
// ── Plain number → set duration in seconds ───────────────────────────────
const numericMatch = text.match(/^(\d+(?:\.\d+)?)$/);
if (numericMatch) {
const ms = Math.round(parseFloat(numericMatch[1]) * 1000);
if (ms <= 0 || ms > 300000) {
await reply(chatId, 'Duration must be between 1 and 300 seconds.', durSec);
return;
}
state.storedDuration = ms;
persistedState[WATER_BUTTON_MAC] = { storedDuration: ms };
saveState(persistedState);
const newSec = (ms / 1000).toFixed(1);
await reply(chatId, `Duration set to ${newSec}s.`, newSec);
return;
}
// ── Menu button labels map to commands ───────────────────────────────────
const buttonMap = {
'🔓 open valve': '/open',
'🔒 close valve': '/close',
'📊 status': '/status'
};
// Run timer button label includes dynamic duration — match by prefix
const normalised = text.toLowerCase();
const resolvedCmd = buttonMap[normalised]
|| (normalised.startsWith('💧 run timer') ? '/run' : cmd);
if (resolvedCmd === '/start' || resolvedCmd === '/help') {
await reply(chatId,
`💧 Water valve bot\n\n` +
`Use the buttons below, or:\n` +
`/open — Open valve\n` +
`/close — Close valve\n` +
`/run — Open then close after ${durSec}s\n` +
`/duration <value> — Set timer (e.g. 30s, 2m, 10000)\n` +
`<number> — Set duration in seconds (e.g. 45)\n` +
`/status — Current status`,
durSec
);
return;
}
if (resolvedCmd === '/status') {
await reply(chatId,
`Status:\n` +
`• Duration: ${state.storedDuration}ms (${durSec}s)\n` +
`• Count mode: ${state.countMode}\n` +
`• Timer active: ${state.timer !== null}`,
durSec
);
return;
}
if (resolvedCmd === '/duration') {
if (!parts[1]) {
await reply(chatId,
`Current duration: ${durSec}s\n` +
`Type a number (seconds) or use /duration <value>\n` +
`Examples: 30 /duration 30s /duration 2m`,
durSec
);
return;
}
const ms = parseDuration(parts[1]);
if (!ms || ms <= 0 || ms > 300000) {
await reply(chatId, 'Invalid duration. Use e.g. 30s, 2m, or seconds 1300.', durSec);
return;
}
state.storedDuration = ms;
persistedState[WATER_BUTTON_MAC] = { storedDuration: ms };
saveState(persistedState);
const newSec = (ms / 1000).toFixed(1);
await reply(chatId, `Duration set to ${newSec}s.`, newSec);
return;
}
const sendRPC = global.__waterBotRPC;
if (!sendRPC) {
await reply(chatId, 'Not ready yet — waiting for first device event. Try again in a moment.', durSec);
return;
}
if (resolvedCmd === '/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); // light off = switch on = valve open
await reply(chatId, 'Valve opened.', durSec);
} else if (resolvedCmd === '/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); // light on = switch off = valve closed
await reply(chatId, 'Valve closed.', durSec);
} else if (resolvedCmd === '/run') {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.countMode = false;
await setLight(sendRPC, WATER_BUTTON_MAC, false, 0); // open valve
const duration = state.storedDuration;
state.timerStartedAt = Date.now();
state.timerEndsAt = Date.now() + duration;
await reply(chatId, `Valve opened. Closing in ${(duration / 1000).toFixed(1)}s…`, durSec);
state.timer = setTimeout(async () => {
try {
const rpc = global.__waterBotRPC;
if (rpc) await setLight(rpc, WATER_BUTTON_MAC, true, 95); // close valve
state.timer = null;
state.timerStartedAt = null;
state.timerEndsAt = null;
await reply(chatId, 'Timer elapsed — valve closed.', (getState(WATER_BUTTON_MAC).storedDuration / 1000).toFixed(1));
} catch (err) {
console.error('[TelegramBot] Timer callback error:', err);
}
}, duration);
} else {
await reply(chatId, 'Unknown command. Use the buttons below or send /help.', durSec);
}
}
async function botLoop() {
console.log(`[TelegramBot] Starting (gen ${BOT_GEN})`);
let offset = 0;
while (BOT_GEN === global.__waterBotGen) {
try {
const data = await telegramRequest('getUpdates', {
offset,
timeout: 25,
allowed_updates: ['message']
});
if (!data.ok) {
await sleep(5000);
continue;
}
for (const update of data.result || []) {
offset = update.update_id + 1;
if (update.message) {
handleBotMessage(update.message).catch(err =>
console.error('[TelegramBot] Handler error:', err)
);
}
}
} catch (err) {
console.error('[TelegramBot] Polling error:', err.message);
await sleep(5000);
}
}
console.log(`[TelegramBot] Stopped (gen ${BOT_GEN} superseded)`);
}
// Start long-polling immediately (non-blocking)
if (BOT_TOKEN) {
botLoop().catch(err => console.error('[TelegramBot] Fatal error:', err));
} else {
console.error('[TelegramBot] WATER_VALVE_TELEGRAM_TOKEN not set in .env — bot disabled');
}
// ── Rule export ───────────────────────────────────────────────────────────────
export default {
getStatus() {
const state = getState(WATER_BUTTON_MAC);
return {
storedDuration: state.storedDuration,
countMode: state.countMode,
timerActive: state.timer !== null
};
},
setConfig(key, value) {
if (key === 'storedDuration') {
const duration = parseInt(value, 10);
if (duration > 0 && duration <= 300000) {
const state = getState(WATER_BUTTON_MAC);
state.storedDuration = duration;
persistedState[WATER_BUTTON_MAC] = { storedDuration: duration };
saveState(persistedState);
console.log(`[Rule] storedDuration set to ${duration}ms`);
return true;
}
}
return false;
},
async run(ctx) {
// Make sendRPC and getState available to the Telegram bot handlers
global.__waterBotRPC = ctx.sendRPC;
global.__waterBotGetState = ctx.getState;
// ── Online / offline events ──────────────────────────────────────────
// Water button comes online — turn light on if remote switch is also online
if (ctx.trigger.mac === WATER_BUTTON_MAC && ctx.trigger.field === 'online' && ctx.trigger.event === true) {
const remoteSwitchConnected = await ctx.getState(REMOTE_SWITCH_MAC, 'system', 'online');
if (remoteSwitchConnected === true) {
ctx.log('Water button connected - remote switch online, turning light on');
for (let i = 0; i < 2; i++) {
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 95 });
await sleep(200);
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
await sleep(200);
}
await setLight(ctx.sendRPC, ctx.trigger.mac, true, 95);
} else {
ctx.log('Water button connected - remote switch offline, keeping light off');
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
}
return;
}
// Remote switch comes online
if (ctx.trigger.mac === REMOTE_SWITCH_MAC && ctx.trigger.field === 'online' && ctx.trigger.event === true) {
ctx.log('Remote switch connected - turning switch off, flashing light, then turning light on');
await ctx.sendRPC(REMOTE_SWITCH_MAC, 'Switch.Set', { id: 0, on: false });
for (let i = 0; i < 2; i++) {
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 95 });
await sleep(200);
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
await sleep(200);
}
await setLight(ctx.sendRPC, WATER_BUTTON_MAC, true, 95);
return;
}
// Remote switch goes offline — turn light off and cancel any timer
if (ctx.trigger.mac === REMOTE_SWITCH_MAC && ctx.trigger.field === 'online' && ctx.trigger.event === false) {
ctx.log('Remote switch went offline - turning light off');
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: false, brightness: 0 });
const state = getState(WATER_BUTTON_MAC);
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.countMode = false;
state.timerStartedAt = null;
state.timerEndsAt = null;
return;
}
if (ctx.trigger.field !== 'button') return;
// Ignore button events if remote switch is offline
const remoteSwitchOnline = await ctx.getState(REMOTE_SWITCH_MAC, 'system', 'online');
if (remoteSwitchOnline !== true) {
ctx.log('Button ignored - remote switch is offline');
return;
}
// ── Button events ────────────────────────────────────────────────────
const mac = ctx.trigger.mac;
const state = getState(mac);
ctx.log(`Event: ${ctx.trigger.event}, countMode: ${state.countMode}, storedDuration: ${state.storedDuration}ms`);
if (ctx.trigger.event === 'btn_down') {
const timerWasActive = state.timer !== null;
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.timerStartedAt = null;
state.timerEndsAt = null;
if (state.countMode) {
await setLight(ctx.sendRPC, mac, false, 0);
const elapsed = Date.now() - state.countStart;
state.storedDuration = elapsed;
state.countMode = false;
ctx.log(`Count mode ended. Stored duration: ${elapsed}ms`);
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Duration saved'
});
persistedState[mac] = { storedDuration: elapsed };
saveState(persistedState);
await setLight(ctx.sendRPC, mac, true, 95);
} else if (timerWasActive) {
ctx.log('Timer cancelled by button press. Turning light on.');
await setLight(ctx.sendRPC, mac, true, 95);
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Timer cancelled'
});
} else {
await setLight(ctx.sendRPC, mac, false, 0);
ctx.log(`Light off. Will turn on in ${state.storedDuration}ms`);
const updateStatus = ctx.updateStatus;
updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: true,
lastAction: 'Timer started'
});
state.timerStartedAt = Date.now();
state.timerEndsAt = Date.now() + state.storedDuration;
state.timer = setTimeout(async () => {
ctx.log('Timer elapsed. Turning light on.');
const rpc = global.__waterBotRPC;
if (rpc) await setLight(rpc, mac, true, 95);
state.timer = null;
state.timerStartedAt = null;
state.timerEndsAt = null;
updateStatus({
storedDuration: state.storedDuration,
countMode: false,
timerActive: false,
lastAction: 'Timer completed'
});
}, state.storedDuration);
}
}
// long_push — enter count mode
if (ctx.trigger.event === 'long_push' && !state.countMode) {
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
state.timerStartedAt = null;
state.timerEndsAt = null;
ctx.log('Entering count mode...');
for (let i = 0; i < 2; i++) {
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on: true, brightness: 95 });
await sleep(200);
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on: false, brightness: 0 });
await sleep(200);
}
await setLight(ctx.sendRPC, mac, false, 0);
state.countMode = true;
state.countStart = Date.now();
ctx.log('Count mode active. Light stays off. Press button to set duration and turn on.');
ctx.updateStatus({
storedDuration: state.storedDuration,
countMode: true,
timerActive: false,
lastAction: 'Counting...'
});
}
}
};
// ── HTTP Status Page ──────────────────────────────────────────────────────────
const STATUS_PORT = 8082;
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>
<style>
@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;
}
</style>
</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>
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 });
}
});
</script>
</body>
</html>`;
function startStatusServer() {
// 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 === '/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;
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;
}
startStatusServer();