diff --git a/rules/waterButtonLearnAndCall.js b/rules/waterButtonLearnAndCall.js index bc91f72..2e53017 100644 --- a/rules/waterButtonLearnAndCall.js +++ b/rules/waterButtonLearnAndCall.js @@ -17,6 +17,7 @@ import fs from 'fs'; import path from 'path'; +import http from 'http'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); @@ -45,34 +46,34 @@ 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 }); + 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); + try { + if (fs.existsSync(STATE_FILE)) { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } - return {}; + } 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); + 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 ──────────────────────────────────────────────────── @@ -81,37 +82,39 @@ 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 - }); - } - return deviceState.get(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); + try { + if (fs.existsSync(AUTH_FILE)) { + return new Set(JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'))); } - return new Set(); + } 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); - } + try { + fs.writeFileSync(AUTH_FILE, JSON.stringify([...users], null, 2)); + } catch (err) { + console.error('[TelegramBot] Error saving authorized users:', err); + } } const authorizedUsers = loadAuthorizedUsers(); @@ -119,425 +122,1151 @@ 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(); + 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 - }; + 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'; - } + 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) - }); + 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 }); + 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; + 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 — Set timer (e.g. 30s, 2m, 10000)\n` + - ` — 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 \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 1–300.', 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; - 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; - 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; - 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; - 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); + 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 reply(chatId, 'Unknown command. Use the buttons below or send /help.', durSec); + 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 — Set timer (e.g. 30s, 2m, 10000)\n` + + ` — 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 \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 1–300.', 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; + 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'] - }); + while (BOT_GEN === global.__waterBotGen) { + try { + const data = await telegramRequest('getUpdates', { + offset, + timeout: 25, + allowed_updates: ['message'] + }); - if (!data.ok) { - await sleep(5000); - continue; - } + 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); + 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)`); + 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)); + botLoop().catch(err => console.error('[TelegramBot] Fatal error:', err)); } else { - console.error('[TelegramBot] WATER_VALVE_TELEGRAM_TOKEN not set in .env — bot disabled'); + console.error('[TelegramBot] WATER_VALVE_TELEGRAM_TOKEN not set in .env — bot disabled'); } // ── Rule export ─────────────────────────────────────────────────────────────── export default { - getStatus() { + 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); - return { + 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: state.countMode, - timerActive: state.timer !== null - }; - }, + countMode: false, + timerActive: false, + lastAction: 'Timer completed' + }); + }, state.storedDuration); + } + } - 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; - }, + // 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; - async run(ctx) { - // Make sendRPC and getState available to the Telegram bot handlers - global.__waterBotRPC = ctx.sendRPC; - global.__waterBotGetState = ctx.getState; + ctx.log('Entering count mode...'); - // ── Online / offline events ────────────────────────────────────────── + 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); + } - // 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; + 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 = ` + + + + +Water Timer + + + +
+

💧 Water Timer

+ +
+
+ + + + + + + + + + +
+
+
IDLE
+
+
+ +
+ + Loading… +
+ +
+
+
+ + sec +
+ +
+ + +
+
+ + + +`; + +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; } - // 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); + try { + if (url.pathname === '/api/run') { if (state.timer) { clearTimeout(state.timer); state.timer = null; } state.countMode = false; - 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; + 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' })); - 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.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; - - 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) { + } 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' })); - 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); + } 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 })); - 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...' - }); + } 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();