diff --git a/public/dashboard.js b/public/dashboard.js
deleted file mode 100644
index cb13bd9..0000000
--- a/public/dashboard.js
+++ /dev/null
@@ -1,257 +0,0 @@
-const scriptPath = document.currentScript ? document.currentScript.src : window.location.href;
-const API_BASE = scriptPath.substring(0, scriptPath.lastIndexOf('/') + 1) + 'api/';
-
-// Store device info globally to simplify reload
-let filteredDevices = [];
-// Grouped: { "ControllerName": [ {dev info...}, {dev info...} ] }
-let groupedDevices = {};
-let currentRange = 'day';
-let chartInstances = {};
-
-async function init() {
- setupControls();
- await loadDevices();
-
- // Auto-refresh data every 60 seconds
- setInterval(loadData, 60000);
-}
-
-function setupControls() {
- // Range Buttons
- document.querySelectorAll('.range-btn').forEach(btn => {
- btn.addEventListener('click', (e) => {
- document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
- e.target.classList.add('active');
- currentRange = e.target.dataset.range;
- loadData();
- });
- });
-}
-
-/**
- * Fetch and setup device containers (Grouped by Controller)
- */
-async function loadDevices() {
- try {
- const res = await fetch(`${API_BASE}devices`);
- const rawDevices = await res.json();
- const container = document.getElementById('devicesContainer');
- container.innerHTML = '';
-
- if (rawDevices.length === 0) {
- container.innerHTML = '
No devices found.
';
- return;
- }
-
- // Group by Controller Name
- groupedDevices = rawDevices.reduce((acc, dev) => {
- if (!acc[dev.dev_name]) acc[dev.dev_name] = [];
- acc[dev.dev_name].push(dev);
- return acc;
- }, {});
-
- // Create Section per Controller
- for (const [controllerName, ports] of Object.entries(groupedDevices)) {
- const safeControllerName = controllerName.replace(/\s+/g, '_');
-
- const section = document.createElement('div');
- section.className = 'controller-section';
-
- // Generate Ports HTML
- let portsHtml = '';
- ports.forEach(port => {
- const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
-
- portsHtml += `
-
-
${port.port_name || 'Port ' + port.port}
-
-
-
-
- `;
- });
-
- section.innerHTML = `
-
-
-
-
-
Environment (Temp / Humidity)
-
-
-
-
-
-
-
- ${portsHtml}
-
- `;
- container.appendChild(section);
- }
-
- // Trigger initial data load
- loadData();
-
- } catch (err) {
- console.error("Failed to load devices", err);
- }
-}
-
-/**
- * Fetch data and render
- */
-async function loadData() {
- for (const [controllerName, ports] of Object.entries(groupedDevices)) {
- const safeControllerName = controllerName.replace(/\s+/g, '_');
-
- // 1. Fetch Environment Data (Use first port as representative source)
- if (ports.length > 0) {
- const firstPort = ports[0];
- try {
- const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${firstPort.port}&range=${currentRange}`);
- const data = await res.json();
- renderEnvChart(safeControllerName, data);
- } catch (err) {
- console.error(`Failed to load env data for ${controllerName}`, err);
- }
- }
-
- // 2. Fetch Level Data for EACH port
- for (const port of ports) {
- const safePortId = `${controllerName}_${port.port}`.replace(/\s+/g, '_');
- try {
- const res = await fetch(`${API_BASE}history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${currentRange}`);
- const data = await res.json();
-
- const isLight = port.port_name && port.port_name.toLowerCase().includes('light');
- renderLevelChart(safePortId, data, isLight);
- } catch (err) {
- console.error(`Failed to load level data for ${controllerName}:${port.port}`, err);
- }
- }
- }
-}
-
-function formatDateLabel(timestamp) {
- const date = new Date(timestamp);
- if (currentRange === 'day') {
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- } else {
- return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
- date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- }
-}
-
-function renderEnvChart(safeName, data) {
- const labels = data.map(d => formatDateLabel(d.timestamp));
- const ctx = document.getElementById(`env_${safeName}`).getContext('2d');
-
- updateChart(`env_${safeName}`, ctx, labels, [
- {
- label: 'Temperature (°C)',
- data: data.map(d => d.temp_c),
- borderColor: '#ff6384',
- yAxisID: 'y'
- },
- {
- label: 'Humidity (%)',
- data: data.map(d => d.humidity),
- borderColor: '#36a2eb',
- yAxisID: 'y1'
- }
- ], {
- scales: {
- y: {
- type: 'linear',
- display: true,
- position: 'left',
- suggestedMin: 15,
- title: { display: true, text: 'Temp (°C)' }
- },
- y1: {
- type: 'linear',
- display: true,
- position: 'right',
- grid: { drawOnChartArea: false },
- suggestedMin: 30,
- suggestedMax: 80,
- title: { display: true, text: 'Humidity (%)' }
- }
- }
- });
-}
-
-function renderLevelChart(safeId, data, isLight) {
- const labels = data.map(d => formatDateLabel(d.timestamp));
- const ctx = document.getElementById(`level_${safeId}`).getContext('2d');
- const levelLabel = isLight ? 'Brightness' : 'Fan Speed';
- const levelColor = isLight ? '#ffcd56' : '#9966ff';
-
- updateChart(`level_${safeId}`, ctx, labels, [{
- label: levelLabel,
- data: data.map(d => d.fan_speed),
- borderColor: levelColor,
- backgroundColor: levelColor,
- stepped: true
- }], {
- scales: {
- y: {
- suggestedMin: 0,
- suggestedMax: 10,
- ticks: { stepSize: 1 }
- }
- }
- });
-}
-
-function updateChart(id, ctx, labels, datasets, extraOptions = {}) {
- if (chartInstances[id]) {
- chartInstances[id].destroy();
- }
-
- const defaultOptions = {
- responsive: true,
- maintainAspectRatio: false,
- interaction: {
- mode: 'index',
- intersect: false,
- },
- plugins: {
- legend: { position: 'top' }
- },
- scales: {
- x: {
- ticks: {
- maxRotation: 0,
- autoSkip: true,
- maxTicksLimit: 12
- }
- }
- }
- };
-
- // Merge scales specifically
- const mergedScales = { ...defaultOptions.scales, ...(extraOptions.scales || {}) };
-
- // Merge options
- const options = {
- ...defaultOptions,
- ...extraOptions,
- scales: mergedScales
- };
-
- chartInstances[id] = new Chart(ctx, {
- type: 'line',
- data: {
- labels: labels,
- datasets: datasets.map(ds => ({ ...ds, borderWidth: 2, tension: 0.3, pointRadius: 1 }))
- },
- options: options
- });
-}
-
-document.addEventListener('DOMContentLoaded', init);
diff --git a/public/index.html b/public/index.html
deleted file mode 100644
index e210b85..0000000
--- a/public/index.html
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
-
- AC Inf
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/public/style.css b/public/style.css
deleted file mode 100644
index 117cf92..0000000
--- a/public/style.css
+++ /dev/null
@@ -1,202 +0,0 @@
-:root {
- --bg-color: #f4f4f9;
- --card-bg: #ffffff;
- --text-color: #333;
- --accent-color: #2c974b;
- --border-color: #ddd;
-}
-
-body {
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
- background-color: var(--bg-color);
- color: var(--text-color);
- margin: 0;
- padding: 20px;
-}
-
-.container {
- max-width: 1200px;
- margin: 0 auto;
-}
-
-header {
- text-align: center;
- margin-bottom: 30px;
-}
-
-.controls {
- display: flex;
- justify-content: space-between;
- align-items: center;
- background: var(--card-bg);
- padding: 15px 20px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- margin-bottom: 20px;
-}
-
-select {
- padding: 8px 12px;
- font-size: 16px;
- border: 1px solid var(--border-color);
- border-radius: 4px;
- min-width: 200px;
-}
-
-.time-range {
- display: flex;
- gap: 10px;
-}
-
-.range-btn {
- padding: 8px 16px;
- border: 1px solid var(--accent-color);
- background: transparent;
- color: var(--accent-color);
- border-radius: 4px;
- cursor: pointer;
- font-weight: 500;
- transition: all 0.2s;
-}
-
-.range-btn.active,
-.range-btn:hover {
- background: var(--accent-color);
- color: white;
-}
-
-.charts-container {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
- gap: 20px;
-}
-
-.chart-wrapper {
- background: var(--card-bg);
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-h3 {
- margin-top: 0;
- margin-bottom: 15px;
- font-size: 1.1em;
- color: #666;
-}
-
-canvas {
- width: 100% !important;
- height: 300px !important;
-}
-
-/* Multi-Device Layout */
-.device-section {
- margin-bottom: 40px;
-}
-
-.device-header {
- background: #e9ecef;
- padding: 10px 15px;
- border-radius: 6px;
- margin-bottom: 20px;
- border-left: 5px solid #2c3e50;
-}
-
-.charts-row {
- display: flex;
- flex-wrap: wrap;
- gap: 20px;
-}
-
-.chart-wrapper {
- flex: 1;
- min-width: 400px;
- /* Ensure 2 columns on wide screens */
- background: white;
- padding: 15px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.canvas-container {
- position: relative;
- height: 300px;
- width: 100%;
-}
-
-.device-divider {
- border: 0;
- height: 1px;
- background: #ddd;
- margin: 40px 0;
-}
-
-/* Range Buttons Active State */
-.range-btn.active {
- background-color: #2c3e50;
- color: white;
-}
-
-/* Grouped Controller Layout */
-.controller-section {
- background: #fff;
- border-radius: 8px;
- padding: 20px;
- margin-bottom: 40px;
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
-}
-
-.controller-header {
- border-bottom: 2px solid #f0f0f0;
- margin-bottom: 20px;
- padding-bottom: 10px;
-}
-
-.controller-header h2 {
- margin: 0;
- color: #2c3e50;
-}
-
-/* Environment Chart (Full Width) */
-.environment-row {
- margin-bottom: 30px;
-}
-
-.chart-wrapper.wide {
- width: 100%;
- min-width: unset;
- box-shadow: none;
- /* Inside controller card */
- padding: 0;
-}
-
-/* Ports Grid */
-.ports-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
- gap: 20px;
-}
-
-.port-card {
- background: #f8f9fa;
- border: 1px solid #e9ecef;
- border-radius: 6px;
- padding: 15px;
-}
-
-.port-card h4 {
- margin-top: 0;
- margin-bottom: 10px;
- font-size: 1.1em;
- color: #495057;
-}
-
-.canvas-container.small {
- height: 200px;
-}
-
-/* Override chart height for small containers */
-.port-card canvas {
- height: 200px !important;
-}
\ No newline at end of file
diff --git a/server.js b/server.js
index 1baae3d..5901c5b 100644
--- a/server.js
+++ b/server.js
@@ -486,8 +486,15 @@ function evaluateRules(readings) {
// Let's insert every poll to have history graph? Or just changes?
// Graphs need continuous data. Let's insert every poll for now (small scale).
- db.prepare('INSERT INTO output_log (dev_name, port, state, level, rule_id, rule_name) VALUES (?, ?, ?, ?, ?, ?)')
- .run(val.devName || 'Unknown', val.port || 0, val.state, val.level, val.ruleId, val.ruleName);
+ // Log only if changed
+ let shouldLog = false;
+ if (!prev) shouldLog = true;
+ else if (prev.state !== val.state || (val.state === 1 && prev.level !== val.level)) shouldLog = true;
+
+ if (shouldLog) {
+ db.prepare('INSERT INTO output_log (dev_name, port, state, level, rule_id, rule_name) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(val.devName || 'Unknown', val.port || 0, val.state, val.level, val.ruleId, val.ruleName);
+ }
// Detect Change for Alarms
if (prev) {
@@ -1193,42 +1200,187 @@ app.get('/api/devices', (req, res) => {
// API: History
app.get('/api/history', (req, res) => {
try {
- const { devName, port, range } = req.query;
+ const { devName, port, range, offset = 0 } = req.query;
if (!devName || !port) return res.status(400).json({ error: 'Missing devName or port' });
- let timeFilter;
+ const off = parseInt(offset, 10) || 0;
+ let bucketSize; // seconds
+
switch (range) {
- case 'week': timeFilter = "-7 days"; break;
- case 'month': timeFilter = "-30 days"; break;
- case 'day': default: timeFilter = "-24 hours"; break;
+ case 'week':
+ bucketSize = 15 * 60;
+ break;
+ case 'month':
+ bucketSize = 60 * 60;
+ break;
+ case 'day': default:
+ bucketSize = 3 * 60;
+ break;
}
+ // Calculate time modifiers using offset in seconds
+ let durationSec;
+ if (range === 'week') durationSec = 7 * 24 * 3600;
+ else if (range === 'month') durationSec = 30 * 24 * 3600;
+ else durationSec = 24 * 3600; // day
+
+ const endOffsetSec = off * durationSec;
+ const startOffsetSec = (off + 1) * durationSec;
+
+ const endMod = `-${endOffsetSec} seconds`;
+ const startMod = `-${startOffsetSec} seconds`;
+
+ // Select raw data
+ // Select raw data
const stmt = db.prepare(`
- SELECT timestamp || 'Z' as timestamp, temp_c, humidity, vpd, fan_speed, on_speed
+ SELECT strftime('%s', timestamp) as ts, temp_c, humidity, fan_speed
FROM readings
- WHERE dev_name = ? AND port = ? AND timestamp >= datetime('now', ?)
+ WHERE dev_name = ? AND port = ?
+ AND timestamp >= datetime('now', ?)
+ AND timestamp < datetime('now', ?)
ORDER BY timestamp ASC
`);
- const rows = stmt.all(devName, parseInt(port, 10), timeFilter);
- res.json(rows);
+ const rows = stmt.all(devName, parseInt(port, 10), startMod, endMod);
+
+ if (rows.length === 0) return res.json({ start: 0, step: bucketSize, temps: [], hums: [], levels: [] });
+
+ // Aggregate into buckets
+ const startTs = parseInt(rows[0].ts);
+ // Align start to bucket
+ const roundedStart = Math.floor(startTs / bucketSize) * bucketSize;
+
+ const buckets = new Map();
+
+ rows.forEach(r => {
+ const ts = parseInt(r.ts);
+ const bucketKey = Math.floor(ts / bucketSize) * bucketSize;
+
+ if (!buckets.has(bucketKey)) {
+ buckets.set(bucketKey, { count: 0, tempSum: 0, humSum: 0, levelSum: 0 });
+ }
+
+ const b = buckets.get(bucketKey);
+ b.count++;
+ if (r.temp_c !== null) b.tempSum += r.temp_c;
+ if (r.humidity !== null) b.humSum += r.humidity;
+ if (r.fan_speed !== null) b.levelSum += r.fan_speed;
+ });
+
+ const temps = [];
+ const hums = [];
+ const levels = [];
+
+ // Fill gaps if strictly needed?
+ // For dense array, we need continuous steps from Start.
+ // Let's find max TS to know length.
+ const lastRow = rows[rows.length - 1];
+ const endTs = parseInt(lastRow.ts);
+ const roundedEnd = Math.floor(endTs / bucketSize) * bucketSize;
+
+ const numBuckets = (roundedEnd - roundedStart) / bucketSize + 1;
+
+ for (let i = 0; i < numBuckets; i++) {
+ const currentTs = roundedStart + (i * bucketSize);
+ const b = buckets.get(currentTs);
+
+ if (b && b.count > 0) {
+ // Formatting to 1 decimal place
+ temps.push(parseFloat((b.tempSum / b.count).toFixed(1)));
+ hums.push(parseFloat((b.humSum / b.count).toFixed(1)));
+ // Level: round to nearest int? or keep decimal? User said "averaged values".
+ // Usually levels are int, but average might be 5.5. Let's keep 1 decimal for smoothness or round?
+ // Charts handle decimals.
+ levels.push(parseFloat((b.levelSum / b.count).toFixed(1)));
+ } else {
+ // Gap -> null
+ temps.push(null);
+ hums.push(null);
+ levels.push(null);
+ }
+ }
+
+ res.json({
+ start: roundedStart,
+ step: bucketSize,
+ temps,
+ hums,
+ levels
+ });
+
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
});
-// API: Output History (New)
+// API: Output History (Compressed)
app.get('/api/outputs/history', (req, res) => {
try {
+ const { range, offset = 0 } = req.query;
+ const off = parseInt(offset, 10) || 0;
+
+ // Calculate duration in seconds
+ let durationSec;
+ if (range === 'week') durationSec = 7 * 24 * 3600;
+ else if (range === 'month') durationSec = 30 * 24 * 3600;
+ else durationSec = 24 * 3600; // day
+
+ const endOffsetSec = off * durationSec;
+ const startOffsetSec = (off + 1) * durationSec;
+
+ const endMod = `-${endOffsetSec} seconds`;
+ const startMod = `-${startOffsetSec} seconds`;
+
const stmt = db.prepare(`
- SELECT * FROM output_log
- WHERE timestamp > datetime('now', '-24 hours')
+ SELECT timestamp, dev_name, port, state, level
+ FROM output_log
+ WHERE timestamp >= datetime('now', ?)
+ AND timestamp < datetime('now', ?)
ORDER BY timestamp ASC
`);
- const rows = stmt.all();
- res.json(rows);
+
+ const rows = stmt.all(startMod, endMod);
+
+ // Compress: Group by "Dev:Port" -> [[ts, state, level], ...]
+ const compressed = {};
+
+ rows.forEach(r => {
+ const key = `${r.dev_name}:${r.port}`;
+ if (!compressed[key]) compressed[key] = [];
+
+ // Convert timestamp to epoch ms for client (saves parsing there) or seconds?
+ // Client uses `new Date(ts).getTime()`. Let's give them epoch milliseconds to be consistent with other efficient APIs.
+ // SQLite 'timestamp' is string "YYYY-MM-DD HH:MM:SS".
+ // We can use strftime('%s', timestamp) * 1000 in SQL or parse here.
+ // Let's parse here to be safe with timezones if needed, effectively assuming UTC/server time.
+ // Actually, querying SQL for epoch is faster.
+ // Let's treat the existing ROWs which are strings.
+ const ts = new Date(r.timestamp + 'Z').getTime(); // Append Z to force UTC if missing
+
+ const lvl = r.level === null ? 0 : r.level;
+
+ // Check last entry for this key
+ const lastEntry = compressed[key][compressed[key].length - 1];
+
+ if (!lastEntry) {
+ // First entry, always add
+ compressed[key].push([ts, r.state, lvl]);
+ } else {
+ // Compare with last entry: [ts, state, level]
+ const lastState = lastEntry[1];
+ const lastLvl = lastEntry[2];
+
+ if (r.state != lastState || lvl != lastLvl) {
+ // State changed, add new point
+ compressed[key].push([ts, r.state, lvl]);
+ }
+ }
+ });
+
+ res.json(compressed);
} catch (error) {
+ console.error(error);
res.status(500).json({ error: error.message });
}
});
diff --git a/src/client/ControllerCard.js b/src/client/ControllerCard.js
index e836686..fbd5bef 100644
--- a/src/client/ControllerCard.js
+++ b/src/client/ControllerCard.js
@@ -21,7 +21,8 @@ class ControllerCard extends Component {
componentDidUpdate(prevProps) {
if (prevProps.controllerName !== this.props.controllerName ||
- prevProps.range !== this.props.range) {
+ prevProps.range !== this.props.range ||
+ prevProps.offset !== this.props.offset) {
this.fetchData();
}
}
@@ -33,13 +34,13 @@ class ControllerCard extends Component {
}
fetchData = async () => {
- const { controllerName, ports, range } = this.props;
+ const { controllerName, ports, range, offset } = this.props;
try {
if (ports.length === 0) return;
// Fetch all ports concurrently
const promises = ports.map(port =>
- fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}`)
+ fetch(`api/history?devName=${encodeURIComponent(controllerName)}&port=${port.port}&range=${range}&offset=${offset || 0}`)
.then(res => res.json())
.then(data => ({ port: port.port, data }))
);
@@ -81,7 +82,7 @@ class ControllerCard extends Component {
{t('controller.environment')}
-
+
@@ -101,7 +102,7 @@ class ControllerCard extends Component {
{port.port_name || `${t('controller.port')} ${port.port}`}
-
+
diff --git a/src/client/Dashboard.js b/src/client/Dashboard.js
index 934cfa4..a6c6ca1 100644
--- a/src/client/Dashboard.js
+++ b/src/client/Dashboard.js
@@ -9,25 +9,62 @@ class Dashboard extends Component {
constructor(props) {
super(props);
this.state = {
- range: 'day' // 'day', 'week', 'month'
+ range: 'day', // 'day', 'week', 'month'
+ offset: 0
};
}
setRange = (range) => {
- this.setState({ range });
+ this.setState({ range, offset: 0 });
+ };
+
+ setOffset = (offset) => {
+ if (offset < 0) offset = 0;
+ this.setState({ offset });
};
render() {
const { i18n: { t }, devicesCtx: { devices, groupedDevices, loading, error } } = this.props;
- const { range } = this.state;
+ const { range, offset } = this.state;
+
+ const nowMs = Date.now();
+ const durationSec = (range === 'week' ? 7 * 24 * 3600 : (range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
+ const endMs = nowMs - (offset * durationSec * 1000);
+ const startMs = endMs - (durationSec * 1000);
+
+ const dateOpts = { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
+ const dateRangeLabel = `${new Date(startMs).toLocaleString([], dateOpts)} - ${new Date(endMs).toLocaleString([], dateOpts)}`;
if (loading) return {t('dashboard.loading')};
if (error) return {error};
return (
-
+
+
+ {dateRangeLabel}
+
+
+
@@ -55,10 +99,11 @@ class Dashboard extends Component {
controllerName={controllerName}
ports={ports}
range={range}
+ offset={this.state.offset}
/>
))}
-
+
);
}
diff --git a/src/client/EnvChart.js b/src/client/EnvChart.js
index 49bf783..b616aa4 100644
--- a/src/client/EnvChart.js
+++ b/src/client/EnvChart.js
@@ -22,7 +22,7 @@ ChartJS.register(
);
class EnvChart extends Component {
- formatDateLabel = (timestamp) => {
+ formatTime = (timestamp) => {
const { range } = this.props;
const date = new Date(timestamp);
if (range === 'day') {
@@ -36,35 +36,58 @@ class EnvChart extends Component {
render() {
const { data } = this.props;
- if (!data || data.length === 0) return null;
+ if (!data || !data.temps || data.temps.length === 0) return null;
+
+ const { start, step, temps, hums } = data;
+
+ // Generate points: x (timestamp ms), y (value)
+ // start is in seconds, step in seconds.
+ // We can just map the index.
+ const startTimeMs = start * 1000;
+ const stepMs = step * 1000;
+
+ const tempPoints = temps.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val }));
+ const humPoints = hums.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val }));
+
+ // Filter out nulls if charts line breaks are desired on gaps
+ // Chart.js handles nulls by breaking the line, which is usually desired.
const chartData = {
- labels: data.map(d => this.formatDateLabel(d.timestamp)),
datasets: [
{
label: 'Temperature (°C)',
- data: data.map(d => d.temp_c),
+ data: tempPoints,
borderColor: '#ff6384',
backgroundColor: '#ff6384',
yAxisID: 'y',
tension: 0.4,
pointRadius: 0,
- borderWidth: 2
+ borderWidth: 2,
+ spanGaps: true // or false if we want breaks
},
{
label: 'Humidity (%)',
- data: data.map(d => d.humidity),
+ data: humPoints,
borderColor: '#36a2eb',
backgroundColor: '#36a2eb',
yAxisID: 'y1',
tension: 0.4,
pointRadius: 0,
- borderWidth: 2
+ borderWidth: 2,
+ spanGaps: true
},
],
};
+ const nowMs = Date.now();
+ const durationSec = (this.props.range === 'week' ? 7 * 24 * 3600 : (this.props.range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
+
+ // Use offset to calculate exact window
+ const endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000);
+ const startMs = endMs - (durationSec * 1000);
+
const options = {
+ animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: {
@@ -73,10 +96,14 @@ class EnvChart extends Component {
},
scales: {
x: {
+ type: 'linear',
+ min: startMs,
+ max: endMs,
ticks: {
maxRotation: 0,
autoSkip: true,
- maxTicksLimit: 12
+ maxTicksLimit: 12,
+ callback: (value) => this.formatTime(value)
}
},
y: {
@@ -96,6 +123,18 @@ class EnvChart extends Component {
suggestedMax: 80,
},
},
+ plugins: {
+ tooltip: {
+ callbacks: {
+ title: (context) => {
+ if (context.length > 0) {
+ return this.formatTime(context[0].parsed.x);
+ }
+ return '';
+ }
+ }
+ }
+ }
};
return ;
diff --git a/src/client/LevelChart.js b/src/client/LevelChart.js
index bdfa97e..22e1b91 100644
--- a/src/client/LevelChart.js
+++ b/src/client/LevelChart.js
@@ -22,7 +22,7 @@ ChartJS.register(
);
class LevelChart extends Component {
- formatDateLabel = (timestamp) => {
+ formatTime = (timestamp) => {
const { range } = this.props;
const date = new Date(timestamp);
if (range === 'day') {
@@ -36,24 +36,30 @@ class LevelChart extends Component {
render() {
const { data, isLight, isCO2 } = this.props;
- if (!data || data.length === 0) return null;
+ if (!data || !data.levels || data.levels.length === 0) return null;
+
+ const { start, step, levels } = data;
+ const startTimeMs = start * 1000;
+ const stepMs = step * 1000;
+
+ const points = levels.map((val, i) => ({ x: startTimeMs + (i * stepMs), y: val }));
// Determine label and color based on sensor type
const levelLabel = isCO2 ? 'CO2 (ppm)' : (isLight ? 'Brightness' : 'Fan Speed');
const levelColor = isCO2 ? '#4caf50' : (isLight ? '#ffcd56' : '#9966ff');
const chartData = {
- labels: data.map(d => this.formatDateLabel(d.timestamp)),
datasets: [
{
label: levelLabel,
- data: data.map(d => d.fan_speed),
+ data: points,
borderColor: levelColor,
backgroundColor: levelColor,
stepped: !isCO2, // CO2 uses smooth lines
tension: isCO2 ? 0.4 : 0, // Smooth for CO2, stepped for others
borderWidth: 2,
- pointRadius: 0
+ pointRadius: 0,
+ spanGaps: true
},
],
};
@@ -63,7 +69,15 @@ class LevelChart extends Component {
? { suggestedMin: 200, suggestedMax: 900 }
: { suggestedMin: 0, suggestedMax: 10, ticks: { stepSize: 1 } };
+ const nowMs = Date.now();
+ const durationSec = (this.props.range === 'week' ? 7 * 24 * 3600 : (this.props.range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
+
+ // Use offset to calculate exact window
+ const endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000);
+ const startMs = endMs - (durationSec * 1000);
+
const options = {
+ animation: false,
responsive: true,
maintainAspectRatio: false,
interaction: {
@@ -72,14 +86,30 @@ class LevelChart extends Component {
},
scales: {
x: {
+ type: 'linear',
+ min: startMs,
+ max: endMs,
ticks: {
maxRotation: 0,
autoSkip: true,
- maxTicksLimit: 8
+ maxTicksLimit: 8,
+ callback: (value) => this.formatTime(value)
}
},
y: yScale
},
+ plugins: {
+ tooltip: {
+ callbacks: {
+ title: (context) => {
+ if (context.length > 0) {
+ return this.formatTime(context[0].parsed.x);
+ }
+ return '';
+ }
+ }
+ }
+ }
};
return ;
diff --git a/src/client/OutputChart.js b/src/client/OutputChart.js
index 3036deb..32fff95 100644
--- a/src/client/OutputChart.js
+++ b/src/client/OutputChart.js
@@ -40,7 +40,7 @@ class OutputChart extends Component {
}
componentDidUpdate(prevProps) {
- if (prevProps.range !== this.props.range) {
+ if (prevProps.range !== this.props.range || prevProps.offset !== this.props.offset) {
this.fetchData();
}
}
@@ -52,9 +52,9 @@ class OutputChart extends Component {
}
fetchData = async () => {
- const { range } = this.props;
+ const { range, offset } = this.props;
try {
- const res = await fetch(`api/outputs/history?range=${range || 'day'}`);
+ const res = await fetch(`api/outputs/history?range=${range || 'day'}&offset=${offset || 0}`);
const logs = await res.json();
this.setState({ data: logs, loading: false });
} catch (err) {
@@ -63,32 +63,45 @@ class OutputChart extends Component {
}
};
+ formatTime = (timestamp) => {
+ const { range } = this.props;
+ const date = new Date(timestamp);
+ if (range === 'day') {
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ } else {
+ return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' }) + ' ' +
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ };
+
render() {
- const { devices = [] } = this.props;
+ const { devices = [], range } = this.props;
const { data, loading } = this.state;
if (loading) return ;
if (!data || data.length === 0) return null;
- // Group data by "Device:Port"
- const groupedData = {};
- data.forEach(log => {
- const key = `${log.dev_name}:${log.port}`;
- if (!groupedData[key]) groupedData[key] = [];
- groupedData[key].push(log);
- });
+ // Data is now a dictionary: { "Dev:Port": [ [ts, state, level], ... ] }
+ const groupedData = data;
// Gruvbox Palette
const gruvboxColors = ['#fb4934', '#b8bb26', '#fabd2f', '#83a598', '#d3869b', '#8ec07c', '#fe8019', '#928374'];
+ const nowMs = Date.now();
+ const durationSec = (range === 'week' ? 7 * 24 * 3600 : (range === 'month' ? 30 * 24 * 3600 : 24 * 3600));
+
+ const endMs = nowMs - ((this.props.offset || 0) * durationSec * 1000);
+ const startMs = endMs - (durationSec * 1000);
+
// Generate datasets
const datasets = Object.keys(groupedData).map((key, index) => {
const logs = groupedData[key];
const color = gruvboxColors[index % gruvboxColors.length];
+ const offset = index * 0.15; // Visual offset to prevent overlap
// Resolve Label
let label = key;
- const isTapo = key.includes('tapo') || key.includes('Plug'); // Simple check
+ const isTapo = key.includes('tapo') || key.includes('Plug');
if (devices && devices.length > 0) {
const [dName, pNum] = key.split(':');
@@ -99,68 +112,41 @@ class OutputChart extends Component {
}
}
- // Fallback friendly name for Tapo if not found in devices list (which comes from readings)
- // Check if key looks like "tapo-xxx:0"
if (label === key && key.startsWith('tapo-')) {
- // "tapo-001:0" -> "Tapo Plug 001"
const parts2 = key.split(':');
const tapoId = parts2[0].replace('tapo-', '');
label = `Tapo Plug ${tapoId}`;
}
+ // Map [ts, state, level] -> {x, y}
+ const points = logs.map(d => ({
+ x: d[0], // Already epoch ms
+ y: (d[1] === 0 ? 0 : (d[2] || 10)) + offset
+ }));
+
+ // Extend line to end of window (or "now")
+ // If offset is 0, endMs is now. If offset > 0, endMs is historical.
+ // But visually we want the line to extend to the right edge of the chart.
+ if (points.length > 0) {
+ const lastPoint = points[points.length - 1];
+ points.push({
+ x: endMs, // Extend to end of current view window
+ y: lastPoint.y
+ });
+ }
+
return {
label: label,
- data: logs.map(d => ({
- x: new Date(d.timestamp).getTime(),
- y: d.state === 0 ? 0 : (d.level || 10)
- })),
+ data: points,
borderColor: color,
backgroundColor: color,
- stepped: true,
+ stepped: true, // Crucial for sparse data
pointRadius: 0,
borderWidth: 2,
- // Custom property to identify binary devices in tooltip
isBinary: isTapo
};
});
- // Create a time axis based on the data range
- const allTimestamps = [...new Set(data.map(d => d.timestamp))].sort();
-
- // We need to normalize data to these timestamps for "Line" chart
- const chartLabels = allTimestamps.map(ts => {
- const date = new Date(ts);
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
- });
-
- // We need to map *each* dataset's data to align with `chartLabels`.
- const alignedDatasets = datasets.map((ds, index) => {
- const alignedData = [];
- let lastValue = 0; // Default off
- const offset = index * 0.15; // Small offset to avoid overlap
-
- // Populate alignedData matching `allTimestamps`
- allTimestamps.forEach(ts => {
- // Find if we have a log at this specific timestamp
- const timeMs = new Date(ts).getTime();
- const exactLog = ds.data.find(d => Math.abs(d.x - timeMs) < 1000); // 1s tolerance
-
- if (exactLog) {
- lastValue = exactLog.y;
- }
- // Apply offset to the value for visualization
- // If value is 0 (OFF), we might still want to offset it?
- // Or only if ON? The user said "levels are on top of each other".
- // Even OFF lines might overlap. Let's offset everything.
- alignedData.push(lastValue + offset);
- });
-
- return {
- ...ds,
- data: alignedData
- };
- });
-
const options = {
responsive: true,
maintainAspectRatio: false,
@@ -170,16 +156,20 @@ class OutputChart extends Component {
},
scales: {
x: {
+ type: 'linear',
+ min: startMs,
+ max: endMs,
ticks: {
maxRotation: 0,
autoSkip: true,
- maxTicksLimit: 12
+ maxTicksLimit: 12,
+ callback: (value) => this.formatTime(value)
}
},
y: {
type: 'linear',
min: 0,
- max: 12, // Increased max to accommodate offsets
+ max: 12,
ticks: {
stepSize: 1,
callback: (val) => {
@@ -203,13 +193,18 @@ class OutputChart extends Component {
},
tooltip: {
callbacks: {
+ title: (context) => {
+ if (context.length > 0) {
+ return this.formatTime(context[0].parsed.x);
+ }
+ return '';
+ },
label: (context) => {
// Round down to ignore the offset
- const val = Math.floor(context.raw);
+ const val = Math.floor(context.raw.y || context.raw);
const ds = context.dataset;
if (val === 0) return `${ds.label}: OFF`;
- // If it's binary (Tapo) or max level, show ON
if (val === 10 || ds.isBinary) return `${ds.label}: ON`;
return `${ds.label}: Level ${val}`;
@@ -222,7 +217,7 @@ class OutputChart extends Component {
return (
-
+
);
diff --git a/src/client/index.html b/src/client/index.html
index fe6f62b..6a3c843 100644
--- a/src/client/index.html
+++ b/src/client/index.html
@@ -5,6 +5,7 @@
Tischlerei Dashboard
+
diff --git a/verify_api.js b/verify_api.js
new file mode 100644
index 0000000..0d3f462
--- /dev/null
+++ b/verify_api.js
@@ -0,0 +1,108 @@
+import http from 'http';
+
+const OPTIONS = {
+ hostname: '127.0.0.1',
+ port: 3905,
+ path: '/api/history?devName=Test&port=1&range=day',
+ method: 'GET'
+};
+
+const req = http.request(OPTIONS, (res) => {
+ let data = '';
+ res.on('data', (chunk) => {
+ data += chunk;
+ });
+ res.on('end', () => {
+ console.log('Status Code:', res.statusCode);
+ try {
+ const json = JSON.parse(data);
+ console.log('Response Keys:', Object.keys(json));
+ if (json.start !== undefined && Array.isArray(json.temps)) {
+ console.log('SUCCESS: API returned compressed structure.');
+ console.log('Step Size:', json.step);
+ console.log('Temps Length:', json.temps.length);
+ } else {
+ console.log('FAILURE: API returned unexpected structure.', json);
+ }
+ } catch (e) {
+ console.error('Failed to parse JSON:', e);
+ console.log('Raw Data:', data);
+ }
+ });
+});
+
+req.on('error', (e) => {
+ console.error('Request error (server might not be running):', e.message);
+});
+
+req.end();
+
+const OUTPUT_OPTIONS = {
+ hostname: '127.0.0.1',
+ port: 3905,
+ path: '/api/outputs/history?range=day',
+ method: 'GET'
+};
+
+const reqOut = http.request(OUTPUT_OPTIONS, (res) => {
+ let data = '';
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => {
+ console.log('Output History Status Code:', res.statusCode);
+ try {
+ const json = JSON.parse(data);
+ const keys = Object.keys(json);
+ console.log('Output Keys:', keys);
+ if (keys.length > 0) {
+ const firstKey = keys[0];
+ const entries = json[firstKey];
+ if (Array.isArray(entries) && entries.length > 0 && Array.isArray(entries[0])) {
+ console.log('SUCCESS: Output API returned compressed dictionary.');
+ console.log('Sample Entry:', entries[0]);
+ console.log(`Entries count for ${firstKey}: ${entries.length}`);
+ } else {
+ console.log('FAILURE: Output API format incorrect.', entries[0]);
+ }
+ } else {
+ console.log('WARNING: Output API returned empty object (no history).');
+ }
+ } catch (e) {
+ console.error('Failed to parse Output JSON:', e);
+ }
+ });
+});
+
+reqOut.on('error', (e) => {
+ console.error('Output Request error:', e.message);
+});
+
+reqOut.end();
+
+// Test offset
+const OFFSET_OPTIONS = {
+ hostname: '127.0.0.1',
+ port: 3905,
+ path: '/api/history?devName=Tent&port=1&range=day&offset=1',
+ method: 'GET'
+};
+
+const reqOffset = http.request(OFFSET_OPTIONS, (res) => {
+ let data = '';
+ res.on('data', (chunk) => { data += chunk; });
+ res.on('end', () => {
+ console.log('Offset History Status Code:', res.statusCode);
+ try {
+ const json = JSON.parse(data);
+ if (json.start && json.start > 0) {
+ const startDate = new Date(json.start * 1000);
+ console.log(`Offset 1 Start Time: ${json.start} (${startDate.toISOString()})`);
+ // Check if it's roughly 48h ago (since offset 1 day means window start is -48h)
+ // vs offset 0 start is -24h.
+ const now = Date.now() / 1000;
+ const hoursDiff = (now - json.start) / 3600;
+ console.log(`Hours difference from now: ${hoursDiff.toFixed(1)}h (Should be ~48h)`);
+ }
+ } catch (e) { console.error(e); }
+ });
+});
+reqOffset.end();
diff --git a/webpack.config.js b/webpack.config.js
index 09e3593..c34d754 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -11,7 +11,7 @@ export default {
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.[contenthash].js',
- publicPath: '/ac/',
+ publicPath: '/',
clean: true // Clean dist folder on rebuild
},
module: {