/** * 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 { 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 }); } 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 — 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); } 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; 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; } 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) { if (state.timer) { clearTimeout(state.timer); state.timer = 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...' }); } } };