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:
sebseb7
2026-02-27 11:22:49 -05:00
parent 63544160d8
commit 7d96ed29c4
5 changed files with 862 additions and 80 deletions

View File

@@ -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, {