signalk-usage 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +37 -29
- package/plugin/index.js +5 -24
- package/plugin/lib/{influx-client.js → influxClient.js} +39 -0
- package/plugin/lib/{power-engine.js → powerEngine.js} +29 -0
- package/plugin/lib/publisher.js +5 -18
- package/plugin/lib/routes.js +140 -0
- package/plugin/lib/schema.js +30 -24
- package/plugin/lib/{tankage-engine.js → tankageEngine.js} +14 -15
- package/plugin/lib/usageCoordinator.js +152 -0
- package/public/assets/icons/signalk-usage-icon_72x72.png +0 -0
- package/public/css/core.css +678 -0
- package/public/index.html +161 -0
- package/public/js/main.js +431 -0
- package/public/js/ui.js +382 -0
- package/public/js/ws.js +108 -0
- package/plugin/lib/usage-coordinator.js +0 -81
package/package.json
CHANGED
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
2
|
+
"name": "signalk-usage",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Track electrical and tank usage with web UI",
|
|
5
|
+
"main": "plugin/index.js",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"signalk-node-server-plugin",
|
|
8
|
+
"signalk-webapp",
|
|
9
|
+
"signalk-category-utility",
|
|
10
|
+
"signalk",
|
|
11
|
+
"usage",
|
|
12
|
+
"reporting",
|
|
13
|
+
"analytics",
|
|
14
|
+
"tanks",
|
|
15
|
+
"batteries",
|
|
16
|
+
"influxdb"
|
|
17
|
+
],
|
|
18
|
+
"author": {
|
|
19
|
+
"name": "Oliver Fernander",
|
|
20
|
+
"email": "oliver@fernander.net"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/ofernander/signalk-usage.git"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@influxdata/influxdb-client": "^1.33.2"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=16.0.0"
|
|
32
|
+
},
|
|
33
|
+
"signalk": {
|
|
34
|
+
"appIcon": "./assets/icons/signalk-usage-icon_72x72.png",
|
|
35
|
+
"displayName": "SignalK-Usage"
|
|
36
|
+
},
|
|
37
|
+
"signalk-plugin-enabled-by-default": true
|
|
38
|
+
}
|
package/plugin/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const schema = require('./lib/schema');
|
|
2
|
-
const InfluxClient = require('./lib/
|
|
3
|
-
const UsageCoordinator = require('./lib/
|
|
2
|
+
const InfluxClient = require('./lib/influxClient');
|
|
3
|
+
const UsageCoordinator = require('./lib/usageCoordinator');
|
|
4
4
|
const Publisher = require('./lib/publisher');
|
|
5
|
+
const routes = require('./lib/routes');
|
|
5
6
|
|
|
6
7
|
module.exports = function (app) {
|
|
7
8
|
let plugin = {
|
|
@@ -120,29 +121,9 @@ module.exports = function (app) {
|
|
|
120
121
|
}
|
|
121
122
|
};
|
|
122
123
|
|
|
123
|
-
//
|
|
124
|
+
// Register routes
|
|
124
125
|
plugin.registerWithRouter = function (router) {
|
|
125
|
-
router
|
|
126
|
-
try {
|
|
127
|
-
const data = plugin.usageCoordinator.getUsageData();
|
|
128
|
-
res.json(data);
|
|
129
|
-
} catch (err) {
|
|
130
|
-
res.status(500).json({ error: err.message });
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
router.get('/usage/:path', (req, res) => {
|
|
135
|
-
try {
|
|
136
|
-
const data = plugin.usageCoordinator.getUsageForPath(req.params.path);
|
|
137
|
-
if (!data) {
|
|
138
|
-
res.status(404).json({ error: 'Path not found' });
|
|
139
|
-
} else {
|
|
140
|
-
res.json(data);
|
|
141
|
-
}
|
|
142
|
-
} catch (err) {
|
|
143
|
-
res.status(500).json({ error: err.message });
|
|
144
|
-
}
|
|
145
|
-
});
|
|
126
|
+
routes(router, app, plugin);
|
|
146
127
|
};
|
|
147
128
|
|
|
148
129
|
return plugin;
|
|
@@ -100,6 +100,45 @@ InfluxClient.prototype.queryPathRaw = async function(path, range) {
|
|
|
100
100
|
});
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
+
InfluxClient.prototype.queryPathCustomRange = async function(path, start, end, aggregation) {
|
|
104
|
+
const query = `
|
|
105
|
+
from(bucket: "${this.config.bucket}")
|
|
106
|
+
|> range(start: ${start}, stop: ${end})
|
|
107
|
+
|> filter(fn: (r) => r._measurement == "${path}")
|
|
108
|
+
|> filter(fn: (r) => r._field == "value")
|
|
109
|
+
|> filter(fn: (r) => r.self == "true")
|
|
110
|
+
|> aggregateWindow(every: ${aggregation}, fn: mean, createEmpty: false)
|
|
111
|
+
|> sort(columns: ["_time"])
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
this.app.debug(`Executing custom range query for ${path}:`);
|
|
115
|
+
this.app.debug(query);
|
|
116
|
+
|
|
117
|
+
const self = this;
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const results = [];
|
|
121
|
+
|
|
122
|
+
self.queryApi.queryRows(query, {
|
|
123
|
+
next: (row, tableMeta) => {
|
|
124
|
+
const obj = tableMeta.toObject(row);
|
|
125
|
+
results.push({
|
|
126
|
+
timestamp: new Date(obj._time).getTime(),
|
|
127
|
+
value: obj._value
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
error: (err) => {
|
|
131
|
+
self.app.debug(`Custom query error for ${path}: ${err.message}`);
|
|
132
|
+
reject(err);
|
|
133
|
+
},
|
|
134
|
+
complete: () => {
|
|
135
|
+
self.app.debug(`Custom query complete for ${path}: ${results.length} points`);
|
|
136
|
+
resolve(results);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
};
|
|
141
|
+
|
|
103
142
|
InfluxClient.prototype.getAggregateWindow = function(range) {
|
|
104
143
|
if (range === '-1h' || range === '-15m') return '1m';
|
|
105
144
|
if (range === '-6h') return '5m';
|
|
@@ -290,6 +290,21 @@ PowerEngine.prototype.autoDetectDirectionalityType = function(path) {
|
|
|
290
290
|
}
|
|
291
291
|
};
|
|
292
292
|
|
|
293
|
+
PowerEngine.prototype.autoDetectBidirectionalPolarity = function(positiveEnergyWh, negativeEnergyWh) {
|
|
294
|
+
// For bidirectional items, detect which direction is which based on magnitudes
|
|
295
|
+
// If positive energy is significantly larger, it's likely normal polarity
|
|
296
|
+
// If negative energy is larger, it's likely reversed
|
|
297
|
+
|
|
298
|
+
if (positiveEnergyWh > negativeEnergyWh * 1.5) {
|
|
299
|
+
return 'normal'; // Positive = generated, Negative = consumed
|
|
300
|
+
} else if (negativeEnergyWh > positiveEnergyWh * 1.5) {
|
|
301
|
+
return 'reversed'; // Positive = consumed, Negative = generated
|
|
302
|
+
} else {
|
|
303
|
+
// Similar magnitudes - default to normal
|
|
304
|
+
return 'normal';
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
|
|
293
308
|
PowerEngine.prototype.applyDirectionality = function(path, directionality, positiveEnergyWh, negativeEnergyWh) {
|
|
294
309
|
let consumedWh, generatedWh, appliedDirectionality;
|
|
295
310
|
|
|
@@ -312,6 +327,20 @@ PowerEngine.prototype.applyDirectionality = function(path, directionality, posit
|
|
|
312
327
|
}
|
|
313
328
|
break;
|
|
314
329
|
|
|
330
|
+
case 'bidirectional':
|
|
331
|
+
// Simple bidirectional - use auto-detection to determine normal vs reversed
|
|
332
|
+
const detectedDirection = this.autoDetectBidirectionalPolarity(positiveEnergyWh, negativeEnergyWh);
|
|
333
|
+
if (detectedDirection === 'normal') {
|
|
334
|
+
consumedWh = negativeEnergyWh;
|
|
335
|
+
generatedWh = positiveEnergyWh;
|
|
336
|
+
appliedDirectionality = 'bidirectional (auto-detected normal)';
|
|
337
|
+
} else {
|
|
338
|
+
consumedWh = positiveEnergyWh;
|
|
339
|
+
generatedWh = negativeEnergyWh;
|
|
340
|
+
appliedDirectionality = 'bidirectional (auto-detected reversed)';
|
|
341
|
+
}
|
|
342
|
+
break;
|
|
343
|
+
|
|
315
344
|
case 'bidirectional-normal':
|
|
316
345
|
consumedWh = negativeEnergyWh;
|
|
317
346
|
generatedWh = positiveEnergyWh;
|
package/plugin/lib/publisher.js
CHANGED
|
@@ -101,10 +101,10 @@ Publisher.prototype.publishTankageItems = function(items, deltas, meta) {
|
|
|
101
101
|
return;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
// Publish totals with
|
|
104
|
+
// Publish totals with 3 decimal places for better precision when converting to L/gal
|
|
105
105
|
deltas.push({
|
|
106
106
|
path: `${basePath}.consumed.${period}`,
|
|
107
|
-
value: this.round(periodData.consumed || 0,
|
|
107
|
+
value: this.round(periodData.consumed || 0, 3)
|
|
108
108
|
});
|
|
109
109
|
meta.push({
|
|
110
110
|
path: `${basePath}.consumed.${period}`,
|
|
@@ -113,22 +113,12 @@ Publisher.prototype.publishTankageItems = function(items, deltas, meta) {
|
|
|
113
113
|
|
|
114
114
|
deltas.push({
|
|
115
115
|
path: `${basePath}.added.${period}`,
|
|
116
|
-
value: this.round(periodData.added || 0,
|
|
116
|
+
value: this.round(periodData.added || 0, 3)
|
|
117
117
|
});
|
|
118
118
|
meta.push({
|
|
119
119
|
path: `${basePath}.added.${period}`,
|
|
120
120
|
value: { units: unit }
|
|
121
121
|
});
|
|
122
|
-
|
|
123
|
-
// Publish rates with 4 decimal places (more precision needed)
|
|
124
|
-
deltas.push({
|
|
125
|
-
path: `${basePath}.consumptionRate.${period}`,
|
|
126
|
-
value: this.round(periodData.consumptionRate || 0, 4)
|
|
127
|
-
});
|
|
128
|
-
meta.push({
|
|
129
|
-
path: `${basePath}.consumptionRate.${period}`,
|
|
130
|
-
value: { units: `${unit}/h` }
|
|
131
|
-
});
|
|
132
122
|
});
|
|
133
123
|
});
|
|
134
124
|
};
|
|
@@ -224,11 +214,8 @@ Publisher.prototype.publishPowerItems = function(items, deltas, meta) {
|
|
|
224
214
|
};
|
|
225
215
|
|
|
226
216
|
Publisher.prototype.getBasePath = function(item) {
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
if (item.name && item.name !== item.path) {
|
|
230
|
-
return 'usage.' + item.name;
|
|
231
|
-
}
|
|
217
|
+
// Always use the full SignalK path for deltas
|
|
218
|
+
// Display name is only for UI presentation
|
|
232
219
|
return 'usage.' + item.path;
|
|
233
220
|
};
|
|
234
221
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
|
|
3
|
+
module.exports = function(router, app, plugin) {
|
|
4
|
+
// Serve static files from public directory
|
|
5
|
+
router.use('/', (req, res, next) => {
|
|
6
|
+
const express = require('express');
|
|
7
|
+
const staticHandler = express.static(path.join(__dirname, '../../public'));
|
|
8
|
+
staticHandler(req, res, next);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Serve index.html at root
|
|
12
|
+
router.get('/', (req, res) => {
|
|
13
|
+
res.sendFile(path.join(__dirname, '../../public/index.html'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
// Get all usage data
|
|
17
|
+
router.get('/api/usage', (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!plugin.usageCoordinator) {
|
|
20
|
+
return res.status(503).json({ error: 'Plugin not initialized' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const data = plugin.usageCoordinator.getUsageData();
|
|
24
|
+
res.json(data);
|
|
25
|
+
} catch (err) {
|
|
26
|
+
app.debug(`Error getting usage data: ${err.message}`);
|
|
27
|
+
res.status(500).json({ error: err.message });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Get usage for specific path
|
|
32
|
+
router.get('/api/usage/:path', (req, res) => {
|
|
33
|
+
try {
|
|
34
|
+
if (!plugin.usageCoordinator) {
|
|
35
|
+
return res.status(503).json({ error: 'Plugin not initialized' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const data = plugin.usageCoordinator.getUsageForPath(req.params.path);
|
|
39
|
+
if (!data) {
|
|
40
|
+
res.status(404).json({ error: 'Path not found' });
|
|
41
|
+
} else {
|
|
42
|
+
res.json(data);
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
app.debug(`Error getting usage for path: ${err.message}`);
|
|
46
|
+
res.status(500).json({ error: err.message });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Custom time range query endpoint
|
|
51
|
+
router.post('/api/query', (req, res) => {
|
|
52
|
+
(async () => {
|
|
53
|
+
try {
|
|
54
|
+
const { path, start, end, aggregation } = req.body;
|
|
55
|
+
|
|
56
|
+
if (!path || !start || !end) {
|
|
57
|
+
return res.status(400).json({
|
|
58
|
+
error: 'Missing required parameters: path, start, end'
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!plugin.influxClient) {
|
|
63
|
+
return res.status(503).json({ error: 'InfluxDB client not initialized' });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Calculate time range
|
|
67
|
+
const startTime = new Date(start);
|
|
68
|
+
const endTime = new Date(end);
|
|
69
|
+
const rangeMs = endTime - startTime;
|
|
70
|
+
|
|
71
|
+
// Use provided aggregation or calculate default
|
|
72
|
+
const aggWindow = aggregation || plugin.usageCoordinator.calculateAggregation(rangeMs);
|
|
73
|
+
|
|
74
|
+
app.debug(`Custom query: ${path} from ${start} to ${end}, aggregation: ${aggWindow}`);
|
|
75
|
+
|
|
76
|
+
// Query data from InfluxDB
|
|
77
|
+
const dataPoints = await plugin.influxClient.queryPathCustomRange(
|
|
78
|
+
path,
|
|
79
|
+
start,
|
|
80
|
+
end,
|
|
81
|
+
aggWindow
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (!dataPoints || dataPoints.length === 0) {
|
|
85
|
+
return res.json({
|
|
86
|
+
data: [],
|
|
87
|
+
start,
|
|
88
|
+
end,
|
|
89
|
+
aggregation: aggWindow,
|
|
90
|
+
message: 'No data found for the specified range'
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Determine item type
|
|
95
|
+
const itemConfig = plugin.usageCoordinator.findItemConfig(path);
|
|
96
|
+
const isPower = itemConfig && itemConfig.type === 'power';
|
|
97
|
+
|
|
98
|
+
let result = {
|
|
99
|
+
data: dataPoints,
|
|
100
|
+
start,
|
|
101
|
+
end,
|
|
102
|
+
aggregation: aggWindow,
|
|
103
|
+
type: isPower ? 'power' : 'tankage'
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Calculate energy if it's a power item
|
|
107
|
+
if (isPower) {
|
|
108
|
+
result.energy = plugin.usageCoordinator.calculateEnergyFromData(
|
|
109
|
+
dataPoints,
|
|
110
|
+
itemConfig
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
res.json(result);
|
|
115
|
+
|
|
116
|
+
} catch (err) {
|
|
117
|
+
app.debug(`Custom query error: ${err.message}`);
|
|
118
|
+
res.status(500).json({ error: err.message });
|
|
119
|
+
}
|
|
120
|
+
})();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Get configuration
|
|
124
|
+
router.get('/api/config', (req, res) => {
|
|
125
|
+
try {
|
|
126
|
+
if (!plugin.usageCoordinator) {
|
|
127
|
+
return res.status(503).json({ error: 'Plugin not initialized' });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
res.json({
|
|
131
|
+
power: plugin.usageCoordinator.options.power || [],
|
|
132
|
+
tankage: plugin.usageCoordinator.options.tankage || [],
|
|
133
|
+
reporting: plugin.usageCoordinator.options.reporting || {}
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
app.debug(`Error getting config: ${err.message}`);
|
|
137
|
+
res.status(500).json({ error: err.message });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
};
|
package/plugin/lib/schema.js
CHANGED
|
@@ -49,8 +49,8 @@ module.exports = {
|
|
|
49
49
|
},
|
|
50
50
|
name: {
|
|
51
51
|
type: 'string',
|
|
52
|
-
title: 'Name',
|
|
53
|
-
description: '
|
|
52
|
+
title: 'Display Name',
|
|
53
|
+
description: 'Optional friendly name for this tank'
|
|
54
54
|
},
|
|
55
55
|
periods: {
|
|
56
56
|
type: 'array',
|
|
@@ -58,9 +58,9 @@ module.exports = {
|
|
|
58
58
|
format: 'table',
|
|
59
59
|
default: [
|
|
60
60
|
{ range: '1h', aggregation: '1m' },
|
|
61
|
-
{ range: '24h', aggregation: '
|
|
62
|
-
{ range: '7d', aggregation: '
|
|
63
|
-
{ range: '30d', aggregation: '
|
|
61
|
+
{ range: '24h', aggregation: '15m' },
|
|
62
|
+
{ range: '7d', aggregation: '1h' },
|
|
63
|
+
{ range: '30d', aggregation: '4h' }
|
|
64
64
|
],
|
|
65
65
|
items: {
|
|
66
66
|
type: 'object',
|
|
@@ -69,19 +69,16 @@ module.exports = {
|
|
|
69
69
|
properties: {
|
|
70
70
|
range: {
|
|
71
71
|
type: 'string',
|
|
72
|
-
title: 'Range'
|
|
72
|
+
title: 'Range',
|
|
73
|
+
description: 'Time range (e.g., 30s, 1h, 24h, 7d, 30d)'
|
|
73
74
|
},
|
|
74
75
|
aggregation: {
|
|
75
76
|
type: 'string',
|
|
76
|
-
title: 'Aggregation'
|
|
77
|
+
title: 'Aggregation',
|
|
78
|
+
description: 'Data aggregation window (e.g., 5s, 1m, 15m, 1h)'
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
|
-
},
|
|
81
|
-
enabled: {
|
|
82
|
-
type: 'boolean',
|
|
83
|
-
title: 'Enabled',
|
|
84
|
-
default: true
|
|
85
82
|
}
|
|
86
83
|
}
|
|
87
84
|
}
|
|
@@ -92,7 +89,7 @@ module.exports = {
|
|
|
92
89
|
default: [],
|
|
93
90
|
items: {
|
|
94
91
|
type: 'object',
|
|
95
|
-
title: 'Power
|
|
92
|
+
title: 'Power Item',
|
|
96
93
|
required: ['path', 'periods'],
|
|
97
94
|
properties: {
|
|
98
95
|
path: {
|
|
@@ -102,15 +99,15 @@ module.exports = {
|
|
|
102
99
|
},
|
|
103
100
|
name: {
|
|
104
101
|
type: 'string',
|
|
105
|
-
title: 'Name',
|
|
106
|
-
description: '
|
|
102
|
+
title: 'Display Name',
|
|
103
|
+
description: 'Optional friendly name for this item'
|
|
107
104
|
},
|
|
108
105
|
directionality: {
|
|
109
106
|
type: 'string',
|
|
110
|
-
title: '
|
|
111
|
-
enum: ['
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
title: 'Directionality',
|
|
108
|
+
enum: ['producer', 'consumer', 'bidirectional', 'auto'],
|
|
109
|
+
default: 'auto',
|
|
110
|
+
description: 'How power flows: producer (generates), consumer (uses), bidirectional (both), or auto (detect from data)'
|
|
114
111
|
},
|
|
115
112
|
periods: {
|
|
116
113
|
type: 'array',
|
|
@@ -118,9 +115,9 @@ module.exports = {
|
|
|
118
115
|
format: 'table',
|
|
119
116
|
default: [
|
|
120
117
|
{ range: '1h', aggregation: '1m' },
|
|
121
|
-
{ range: '24h', aggregation: '
|
|
122
|
-
{ range: '7d', aggregation: '
|
|
123
|
-
{ range: '30d', aggregation: '
|
|
118
|
+
{ range: '24h', aggregation: '15m' },
|
|
119
|
+
{ range: '7d', aggregation: '1h' },
|
|
120
|
+
{ range: '30d', aggregation: '4h' }
|
|
124
121
|
],
|
|
125
122
|
items: {
|
|
126
123
|
type: 'object',
|
|
@@ -129,11 +126,13 @@ module.exports = {
|
|
|
129
126
|
properties: {
|
|
130
127
|
range: {
|
|
131
128
|
type: 'string',
|
|
132
|
-
title: 'Range'
|
|
129
|
+
title: 'Range',
|
|
130
|
+
description: 'Time range (e.g., 30m, 1h, 24h, 7d, 30d)'
|
|
133
131
|
},
|
|
134
132
|
aggregation: {
|
|
135
133
|
type: 'string',
|
|
136
|
-
title: 'Aggregation'
|
|
134
|
+
title: 'Aggregation',
|
|
135
|
+
description: 'Data aggregation window (e.g., 30s, 1m, 15m, 1h)'
|
|
137
136
|
}
|
|
138
137
|
}
|
|
139
138
|
}
|
|
@@ -161,6 +160,13 @@ module.exports = {
|
|
|
161
160
|
title: 'Cache Results',
|
|
162
161
|
default: true,
|
|
163
162
|
description: 'Cache calculated results to reduce InfluxDB queries'
|
|
163
|
+
},
|
|
164
|
+
unitPreference: {
|
|
165
|
+
type: 'string',
|
|
166
|
+
title: 'Volume Units (Web UI)',
|
|
167
|
+
enum: ['metric', 'imperial'],
|
|
168
|
+
default: 'metric',
|
|
169
|
+
description: 'Display volumes in Liters (metric) or Gallons (imperial) in the web interface'
|
|
164
170
|
}
|
|
165
171
|
}
|
|
166
172
|
}
|
|
@@ -170,24 +170,23 @@ TankageEngine.prototype.calculateUsageForPeriod = async function(item, period) {
|
|
|
170
170
|
};
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
//
|
|
174
|
-
// No
|
|
175
|
-
|
|
173
|
+
// Simple net change approach: first reading vs last reading
|
|
174
|
+
// No summing between points, no threshold needed
|
|
175
|
+
const netChange = last.value - first.value;
|
|
176
|
+
|
|
177
|
+
this.app.debug(`TankageEngine: ${path} - Start: ${first.value.toFixed(4)} m³, End: ${last.value.toFixed(4)} m³, Net Change: ${netChange.toFixed(4)} m³ (${(netChange / 0.00378541).toFixed(2)} gal)`);
|
|
178
|
+
|
|
179
|
+
// If net is negative: consumed (tank went down)
|
|
180
|
+
// If net is positive: added (tank went up)
|
|
176
181
|
let totalConsumed = 0;
|
|
182
|
+
let totalAdded = 0;
|
|
177
183
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (change > 0) {
|
|
184
|
-
totalAdded += change;
|
|
185
|
-
} else if (change < 0) {
|
|
186
|
-
totalConsumed += Math.abs(change);
|
|
187
|
-
}
|
|
184
|
+
if (netChange < 0) {
|
|
185
|
+
totalConsumed = Math.abs(netChange);
|
|
186
|
+
} else if (netChange > 0) {
|
|
187
|
+
totalAdded = netChange;
|
|
188
188
|
}
|
|
189
|
-
|
|
190
|
-
this.app.debug(`TankageEngine: ${path} - Added: ${totalAdded.toFixed(4)}, Consumed: ${totalConsumed.toFixed(4)} from ${dataPoints.length} aggregated points`);
|
|
189
|
+
// If netChange === 0, both stay 0
|
|
191
190
|
|
|
192
191
|
usage.consumed = totalConsumed;
|
|
193
192
|
usage.added = totalAdded;
|