feat: Enhance water valve control with Telegram bot integration, allowing remote commands and user authorization. Add channel history querying and improve server functionality for rule execution and static file serving.
This commit is contained in:
@@ -1,11 +1,18 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -20,14 +27,31 @@ const STATE_FILE = path.join(__dirname, 'timer_state.json');
|
||||
const WATER_BUTTON_MAC = '08A6F773510C';
|
||||
const REMOTE_SWITCH_MAC = 'CC8DA243B0A0';
|
||||
|
||||
// Helper to set local light and sync remote switch inversely
|
||||
async function setLight(ctx, mac, on, brightness) {
|
||||
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on, brightness });
|
||||
// Remote switch is inverse: light off = switch on, light on = switch off
|
||||
await ctx.sendRPC(REMOTE_SWITCH_MAC, 'Switch.Set', { id: 0, on: !on });
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Load persisted state
|
||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||
|
||||
function loadPersistedState() {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
@@ -39,10 +63,8 @@ function loadPersistedState() {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Save state to file
|
||||
function saveState(state) {
|
||||
try {
|
||||
// Only save storedDuration per MAC
|
||||
const toSave = {};
|
||||
for (const [mac, data] of Object.entries(state)) {
|
||||
toSave[mac] = { storedDuration: data.storedDuration };
|
||||
@@ -53,7 +75,8 @@ function saveState(state) {
|
||||
}
|
||||
}
|
||||
|
||||
// State for devices (in memory, with persistence for storedDuration)
|
||||
// ── In-memory device state ────────────────────────────────────────────────────
|
||||
|
||||
const persistedState = loadPersistedState();
|
||||
const deviceState = new Map();
|
||||
|
||||
@@ -63,13 +86,279 @@ function getState(mac) {
|
||||
deviceState.set(mac, {
|
||||
countMode: false,
|
||||
countStart: 0,
|
||||
storedDuration: persisted.storedDuration || 5000, // Default 5 seconds
|
||||
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 <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 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);
|
||||
@@ -79,13 +368,13 @@ export default {
|
||||
timerActive: state.timer !== null
|
||||
};
|
||||
},
|
||||
|
||||
setConfig(key, value) {
|
||||
if (key === 'storedDuration') {
|
||||
const duration = parseInt(value, 10);
|
||||
if (duration > 0 && duration <= 300000) { // Max 5 minutes
|
||||
if (duration > 0 && duration <= 300000) {
|
||||
const state = getState(WATER_BUTTON_MAC);
|
||||
state.storedDuration = duration;
|
||||
// Persist
|
||||
persistedState[WATER_BUTTON_MAC] = { storedDuration: duration };
|
||||
saveState(persistedState);
|
||||
console.log(`[Rule] storedDuration set to ${duration}ms`);
|
||||
@@ -94,24 +383,26 @@ export default {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async run(ctx) {
|
||||
// Auto-on for water button when it connects (only if remote switch is online)
|
||||
// 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');
|
||||
|
||||
// Double flash to indicate both devices are connected
|
||||
ctx.log('Double flashing to confirm connection');
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 20 });
|
||||
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);
|
||||
}
|
||||
|
||||
// Turn light on after flash (ready state)
|
||||
await setLight(ctx, ctx.trigger.mac, true, 20);
|
||||
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 });
|
||||
@@ -119,35 +410,26 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-off for remote switch when it connects
|
||||
// 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 });
|
||||
|
||||
// Double flash the light
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await ctx.sendRPC(WATER_BUTTON_MAC, 'Light.Set', { id: 0, on: true, brightness: 20 });
|
||||
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);
|
||||
}
|
||||
|
||||
// Turn light on after flash
|
||||
await setLight(ctx, WATER_BUTTON_MAC, true, 20);
|
||||
await setLight(ctx.sendRPC, WATER_BUTTON_MAC, true, 95);
|
||||
return;
|
||||
}
|
||||
|
||||
// Turn off light when remote switch goes offline
|
||||
// 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 });
|
||||
|
||||
// Clear any pending timer
|
||||
const state = getState(WATER_BUTTON_MAC);
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
state.countMode = false;
|
||||
return;
|
||||
}
|
||||
@@ -161,33 +443,25 @@ export default {
|
||||
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`);
|
||||
|
||||
// Handle btn_down
|
||||
if (ctx.trigger.event === 'btn_down') {
|
||||
// Check if timer was active before clearing
|
||||
const timerWasActive = state.timer !== null;
|
||||
|
||||
// Clear any pending timer
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
|
||||
if (state.countMode) {
|
||||
// We're in count mode - calculate elapsed time and turn light on
|
||||
// Turn light off first (remote switch turns on)
|
||||
await setLight(ctx, mac, false, 0);
|
||||
|
||||
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`);
|
||||
|
||||
// Push status update to dashboard
|
||||
ctx.updateStatus({
|
||||
storedDuration: state.storedDuration,
|
||||
countMode: false,
|
||||
@@ -195,33 +469,27 @@ export default {
|
||||
lastAction: 'Duration saved'
|
||||
});
|
||||
|
||||
// Persist the new duration
|
||||
persistedState[mac] = { storedDuration: elapsed };
|
||||
saveState(persistedState);
|
||||
|
||||
// Turn light on immediately (remote switch turns off)
|
||||
await setLight(ctx, mac, true, 20);
|
||||
} else if (timerWasActive) {
|
||||
// Timer was running - cancel it and turn light on immediately
|
||||
ctx.log('Timer cancelled by button press. Turning light on.');
|
||||
await setLight(ctx, mac, true, 20);
|
||||
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);
|
||||
|
||||
// Push status update to dashboard
|
||||
ctx.updateStatus({
|
||||
storedDuration: state.storedDuration,
|
||||
countMode: false,
|
||||
timerActive: false,
|
||||
lastAction: 'Timer cancelled'
|
||||
});
|
||||
|
||||
} else {
|
||||
// Normal mode - turn off light and schedule it to turn on after stored duration
|
||||
await setLight(ctx, mac, false, 0);
|
||||
await setLight(ctx.sendRPC, mac, false, 0);
|
||||
ctx.log(`Light off. Will turn on in ${state.storedDuration}ms`);
|
||||
|
||||
// Capture updateStatus for use in timer callback
|
||||
const updateStatus = ctx.updateStatus;
|
||||
|
||||
// Push status update to dashboard
|
||||
updateStatus({
|
||||
storedDuration: state.storedDuration,
|
||||
countMode: false,
|
||||
@@ -230,11 +498,11 @@ export default {
|
||||
});
|
||||
|
||||
state.timer = setTimeout(async () => {
|
||||
ctx.log(`Timer elapsed. Turning light on.`);
|
||||
await setLight(ctx, mac, true, 20);
|
||||
ctx.log('Timer elapsed. Turning light on.');
|
||||
const rpc = global.__waterBotRPC;
|
||||
if (rpc) await setLight(rpc, mac, true, 95);
|
||||
state.timer = null;
|
||||
|
||||
// Push timer completed status
|
||||
updateStatus({
|
||||
storedDuration: state.storedDuration,
|
||||
countMode: false,
|
||||
@@ -245,33 +513,25 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle long_push - enter count mode
|
||||
// long_push — enter count mode
|
||||
if (ctx.trigger.event === 'long_push' && !state.countMode) {
|
||||
// Clear any pending timer
|
||||
if (state.timer) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
if (state.timer) { clearTimeout(state.timer); state.timer = null; }
|
||||
|
||||
ctx.log('Entering count mode...');
|
||||
|
||||
// Flash to confirm (don't sync remote during flash)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await ctx.sendRPC(mac, 'Light.Set', { id: 0, on: true, brightness: 20 });
|
||||
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);
|
||||
}
|
||||
|
||||
// Ensure light stays off during count mode (remote switch on)
|
||||
await setLight(ctx, mac, false, 0);
|
||||
await setLight(ctx.sendRPC, mac, false, 0);
|
||||
|
||||
// Enter count mode
|
||||
state.countMode = true;
|
||||
state.countStart = Date.now();
|
||||
ctx.log('Count mode active. Light stays off. Press button to set duration and turn on.');
|
||||
|
||||
// Push status update to dashboard
|
||||
ctx.updateStatus({
|
||||
storedDuration: state.storedDuration,
|
||||
countMode: true,
|
||||
|
||||
Reference in New Issue
Block a user