Files
shellySrv/rules/waterButtonLearnAndCall.js

576 lines
20 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 { startStatusServer } from '../waterButtonStatusServer.js';
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 ──────────────────────────────────────────────────────────
startStatusServer({
getState,
WATER_BUTTON_MAC,
setLight,
persistedState,
saveState
});