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:
@@ -7,3 +7,7 @@ TAPO_PASSWORD=your-password
|
||||
TAPO_BROADCAST_ADDR=192.168.3.255
|
||||
TAPO_DISCOVERY_INTERVAL=300000
|
||||
TAPO_POLL_INTERVAL=10000
|
||||
|
||||
# Telegram bot token for water valve rule (waterButtonLearnAndCall.js)
|
||||
WATER_VALVE_TELEGRAM_TOKEN=your-telegram-bot-token
|
||||
WATER_VALVE_TELEGRAM_PASSWORD=your-secret-password
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ devices.db
|
||||
rules/timer_state.json
|
||||
.env
|
||||
tapo/
|
||||
rules/telegram_authorized_users.json
|
||||
server.log
|
||||
@@ -6,6 +6,13 @@
|
||||
* - 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,
|
||||
|
||||
119
server.js
119
server.js
@@ -1,10 +1,12 @@
|
||||
import 'dotenv/config';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initRuleEngine, loadRules, runRules, watchRules } from './rule_engine.js';
|
||||
import { Interpreter } from './ruleUITest/interpreter.js'; // Import for manual execution
|
||||
import { broadcastEvent, broadcastRuleUpdate, startStatusServer } from './status_server.js';
|
||||
import { TapoManager } from './tapo_client.js';
|
||||
|
||||
@@ -171,9 +173,99 @@ function checkAndLogEvent(mac, component, field, type, event, connectionId = nul
|
||||
});
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ port: 8080 });
|
||||
const ruleContext = {
|
||||
getState: (mac, component, field) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(`SELECT e.event, c.type FROM events e
|
||||
JOIN channels c ON e.channel_id = c.id
|
||||
WHERE c.mac = ? AND c.component = ? AND c.field = ?
|
||||
ORDER BY e.id DESC LIMIT 1`, [mac, component, field], (err, row) => {
|
||||
if (err) resolve(null);
|
||||
else resolve(row ? row.event : null); // Simple cast?
|
||||
});
|
||||
});
|
||||
},
|
||||
setOutput: (mac, component, on) => sendRPCToDevice(mac, 'Switch.Set', { id: parseInt(component.split(':')[1]), on: !!on }),
|
||||
setLevel: (mac, component, level) => sendRPCToDevice(mac, 'Light.Set', { id: parseInt(component.split(':')[1]), brightness: parseInt(level) }),
|
||||
notify: (msg) => console.log(`[RuleNotify] ${msg}`),
|
||||
log: (msg) => console.log(`[RuleLog] ${msg}`)
|
||||
};
|
||||
|
||||
console.log('Shelly Agent Server listening on port 8080');
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// Basic Static File Serving
|
||||
if (req.method === 'GET') {
|
||||
let filePath = '.' + req.url;
|
||||
if (filePath === './rule-editor') filePath = './ruleUITest/index.html';
|
||||
|
||||
// Map root requests to ruleUITest internal files if referred
|
||||
if (req.url.startsWith('/blocks.js')) filePath = './ruleUITest/blocks.js';
|
||||
|
||||
const extname = path.extname(filePath);
|
||||
let contentType = 'text/html';
|
||||
switch (extname) {
|
||||
case '.js': contentType = 'text/javascript'; break;
|
||||
case '.css': contentType = 'text/css'; break;
|
||||
case '.json': contentType = 'application/json'; break;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.lstatSync(filePath).isFile()) {
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Error: ' + error.code);
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content, 'utf-8');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// API Routes
|
||||
if (req.method === 'GET' && req.url === '/api/channels') {
|
||||
db.all("SELECT DISTINCT mac, component, field, type FROM channels", [], (err, rows) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(rows));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/execute-rule') {
|
||||
let body = '';
|
||||
req.on('data', chunk => { body += chunk.toString(); });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const ast = JSON.parse(body);
|
||||
const logs = [];
|
||||
// Capture logs
|
||||
const ctx = { ...ruleContext, log: (msg) => logs.push(String(msg)), notify: (msg) => logs.push('Notify: ' + msg) };
|
||||
|
||||
const interpreter = new Interpreter(ctx);
|
||||
await interpreter.execute(ast);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', logs }));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
server.listen(8080, () => {
|
||||
console.log('Shelly Agent Server listening on port 8080 (HTTP + WS)');
|
||||
console.log('Rule Editor available at http://localhost:8080/rule-editor');
|
||||
});
|
||||
|
||||
// Initialize and load rules
|
||||
initRuleEngine(db, sendRPCToDevice, broadcastRuleUpdate);
|
||||
@@ -324,6 +416,19 @@ wss.on('connection', (ws, req) => {
|
||||
checkAndLogEvent(possibleMac, key, 'input', 'boolean', eventVal, connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Log humidity
|
||||
if (key.startsWith('humidity') && typeof value.rh !== 'undefined') {
|
||||
if (possibleMac) {
|
||||
checkAndLogEvent(possibleMac, key, 'rh', 'range', value.rh, connectionId);
|
||||
}
|
||||
}
|
||||
// Log temperature
|
||||
if (key.startsWith('temperature') && typeof value.tC !== 'undefined') {
|
||||
if (possibleMac) {
|
||||
checkAndLogEvent(possibleMac, key, 'tC', 'range', value.tC, connectionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log extracted RSSI from NotifyFullStatus
|
||||
@@ -419,6 +524,16 @@ wss.on('connection', (ws, req) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for humidity updates
|
||||
if (key.startsWith('humidity') && typeof value.rh !== 'undefined') {
|
||||
checkAndLogEvent(mac, key, 'rh', 'range', value.rh, connectionId);
|
||||
}
|
||||
|
||||
// Check for temperature updates
|
||||
if (key.startsWith('temperature') && typeof value.tC !== 'undefined') {
|
||||
checkAndLogEvent(mac, key, 'tC', 'range', value.tC, connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for wifi updates in NotifyStatus
|
||||
|
||||
403
status_server.js
403
status_server.js
@@ -62,6 +62,52 @@ function getStatusData() {
|
||||
});
|
||||
}
|
||||
|
||||
// Query channel history with time range filter
|
||||
function getChannelHistory(channelId, range = '24h') {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Calculate the start timestamp based on range
|
||||
const now = new Date();
|
||||
let startTime;
|
||||
if (range === '1week') {
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else {
|
||||
// Default to 24h
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT c.component, c.field, c.type, c.mac,
|
||||
e.event, e.timestamp
|
||||
FROM channels c
|
||||
LEFT JOIN events e ON e.channel_id = c.id
|
||||
WHERE c.id = ? AND (e.timestamp IS NULL OR e.timestamp >= ?)
|
||||
ORDER BY e.timestamp ASC
|
||||
`;
|
||||
|
||||
db.all(sql, [channelId, startTime.toISOString()], (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else if (rows.length === 0) {
|
||||
reject(new Error('Channel not found'));
|
||||
} else {
|
||||
const channel = {
|
||||
id: channelId,
|
||||
component: rows[0].component,
|
||||
field: rows[0].field,
|
||||
type: rows[0].type,
|
||||
mac: rows[0].mac
|
||||
};
|
||||
const events = rows
|
||||
.filter(r => r.timestamp !== null)
|
||||
.map(r => ({
|
||||
event: r.event,
|
||||
timestamp: r.timestamp
|
||||
}));
|
||||
resolve({ channel, events });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Broadcast event to all connected WebSocket clients
|
||||
export function broadcastEvent(mac, component, field, type, event) {
|
||||
const message = JSON.stringify({
|
||||
@@ -103,6 +149,8 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IoT Status</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f0f14;
|
||||
@@ -481,6 +529,144 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Clickable channel rows */
|
||||
.channels-table tbody tr {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.channels-table tbody tr:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 900px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(10px);
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active .modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.range-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.range-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.range-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.range-btn.active {
|
||||
background: var(--accent-blue);
|
||||
border-color: var(--accent-blue);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -510,6 +696,31 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
<div id="rules-container" class="rules-grid"></div>
|
||||
|
||||
<!-- Chart Modal -->
|
||||
<div id="chart-modal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<div class="modal-title" id="modal-title">Channel History</div>
|
||||
<div class="modal-subtitle" id="modal-subtitle"></div>
|
||||
</div>
|
||||
<button class="modal-close" onclick="closeChartModal()">
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="range-toggle">
|
||||
<button class="range-btn active" data-range="24h" onclick="setChartRange('24h')">24 Hours</button>
|
||||
<button class="range-btn" data-range="1week" onclick="setChartRange('1week')">1 Week</button>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<canvas id="history-chart"></canvas>
|
||||
<div class="chart-loading" id="chart-loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
<script>
|
||||
@@ -779,7 +990,7 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
</thead>
|
||||
<tbody>
|
||||
\${device.channels.map(ch => \`
|
||||
<tr>
|
||||
<tr data-channel-id="\${ch.id}" onclick="openChartModal(\${ch.id}, '\${device.mac}', '\${ch.component}', '\${ch.field}')">
|
||||
<td class="channel-component">\${ch.component}</td>
|
||||
<td class="channel-field">\${ch.field}</td>
|
||||
<td class="channel-event" data-channel="\${ch.component}_\${ch.field}">\${ch.event ?? '-'}</td>
|
||||
@@ -892,6 +1103,175 @@ const dashboardHTML = `<!DOCTYPE html>
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Chart modal functions
|
||||
let historyChart = null;
|
||||
let currentChannelId = null;
|
||||
let currentRange = '24h';
|
||||
|
||||
function openChartModal(channelId, mac, component, field) {
|
||||
currentChannelId = channelId;
|
||||
currentRange = '24h';
|
||||
|
||||
// Update modal content
|
||||
document.getElementById('modal-title').textContent = \`\${component}.\${field}\`;
|
||||
document.getElementById('modal-subtitle').textContent = mac;
|
||||
|
||||
// Reset range buttons
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.range === '24h');
|
||||
});
|
||||
|
||||
// Show modal
|
||||
document.getElementById('chart-modal').classList.add('active');
|
||||
|
||||
// Load chart data
|
||||
loadChartData(channelId, '24h');
|
||||
}
|
||||
|
||||
function closeChartModal() {
|
||||
document.getElementById('chart-modal').classList.remove('active');
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
historyChart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setChartRange(range) {
|
||||
if (range === currentRange) return;
|
||||
currentRange = range;
|
||||
|
||||
// Update button states
|
||||
document.querySelectorAll('.range-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.range === range);
|
||||
});
|
||||
|
||||
// Reload data
|
||||
loadChartData(currentChannelId, range);
|
||||
}
|
||||
|
||||
async function loadChartData(channelId, range) {
|
||||
const loadingEl = document.getElementById('chart-loading');
|
||||
loadingEl.style.display = 'flex';
|
||||
loadingEl.textContent = 'Loading...';
|
||||
|
||||
try {
|
||||
const res = await fetch(\`/api/channel-history/\${channelId}?range=\${range}\`);
|
||||
if (!res.ok) throw new Error('Failed to load data');
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (data.events.length === 0) {
|
||||
loadingEl.textContent = 'No data for this time range';
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
historyChart = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
loadingEl.style.display = 'none';
|
||||
renderChart(data.events, data.channel.type);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading chart data:', err);
|
||||
loadingEl.textContent = 'Error loading data';
|
||||
}
|
||||
}
|
||||
|
||||
function renderChart(events, channelType) {
|
||||
const ctx = document.getElementById('history-chart').getContext('2d');
|
||||
|
||||
// Destroy existing chart
|
||||
if (historyChart) {
|
||||
historyChart.destroy();
|
||||
}
|
||||
|
||||
// Process data based on type
|
||||
const isBoolean = channelType === 'boolean';
|
||||
const data = events.map(e => ({
|
||||
x: new Date(e.timestamp),
|
||||
y: isBoolean ? (e.event === 'true' || e.event === true ? 1 : 0) : parseFloat(e.event)
|
||||
}));
|
||||
|
||||
// Chart config
|
||||
const config = {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: data,
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: isBoolean ? 0 : 0.3,
|
||||
stepped: isBoolean ? 'before' : false,
|
||||
pointRadius: data.length > 100 ? 0 : 3,
|
||||
pointHoverRadius: 5,
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
if (isBoolean) {
|
||||
return context.raw.y === 1 ? 'true' : 'false';
|
||||
}
|
||||
return context.raw.y.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
time: {
|
||||
unit: currentRange === '24h' ? 'hour' : 'day',
|
||||
displayFormats: {
|
||||
hour: 'HH:mm',
|
||||
day: 'MMM dd'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0b0'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: isBoolean,
|
||||
max: isBoolean ? 1.1 : undefined,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a0a0b0',
|
||||
callback: isBoolean ? (val) => val === 1 ? 'true' : val === 0 ? 'false' : '' : undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
historyChart = new Chart(ctx, config);
|
||||
}
|
||||
|
||||
// Close modal on overlay click or Escape key
|
||||
document.getElementById('chart-modal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'chart-modal') closeChartModal();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeChartModal();
|
||||
});
|
||||
|
||||
connectWebSocket();
|
||||
</script>
|
||||
</body>
|
||||
@@ -916,6 +1296,27 @@ const server = http.createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// API endpoint for channel history
|
||||
const historyMatch = url.pathname.match(/^\/api\/channel-history\/(\d+)$/);
|
||||
if (historyMatch) {
|
||||
const channelId = parseInt(historyMatch[1], 10);
|
||||
const range = url.searchParams.get('range') || '24h';
|
||||
|
||||
try {
|
||||
const data = await getChannelHistory(channelId, range);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache'
|
||||
});
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (err) {
|
||||
console.error('[Status] Error fetching channel history:', err);
|
||||
res.writeHead(err.message === 'Channel not found' ? 404 : 500);
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve dashboard
|
||||
if (url.pathname === '/' || url.pathname === '/index.html') {
|
||||
res.writeHead(200, {
|
||||
|
||||
Reference in New Issue
Block a user