309 lines
11 KiB
JavaScript
309 lines
11 KiB
JavaScript
/**
|
|
* 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 { startTelegramBot } from '../waterButtonTelegramBot.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
|
|
|
|
|
|
|
|
// 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);
|
|
}
|
|
|
|
|
|
startTelegramBot({
|
|
getState,
|
|
WATER_BUTTON_MAC,
|
|
REMOTE_SWITCH_MAC,
|
|
setLight,
|
|
persistedState,
|
|
saveState
|
|
});
|
|
|
|
// ── 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,
|
|
REMOTE_SWITCH_MAC,
|
|
setLight,
|
|
persistedState,
|
|
saveState
|
|
});
|