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 CHANGED
@@ -1,30 +1,38 @@
1
1
  {
2
- "name": "signalk-usage",
3
- "version": "0.1.3",
4
- "description": "Track electrical and tank usage",
5
- "main": "plugin/index.js",
6
- "keywords": [
7
- "signalk-node-server-plugin",
8
- "signalk-category-utility",
9
- "signalk",
10
- "usage",
11
- "reporting",
12
- "analytics",
13
- "tanks",
14
- "batteries",
15
- "influxdb"
16
- ],
17
- "author": "Oliver Fernander",
18
- "license": "MIT",
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/ofernander/signalk-usage.git"
22
- },
23
- "dependencies": {
24
- "@influxdata/influxdb-client": "^1.33.2"
25
- },
26
- "engines": {
27
- "node": ">=16.0.0"
28
- },
29
- "signalk-plugin-enabled-by-default": false
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/influx-client');
3
- const UsageCoordinator = require('./lib/usage-coordinator');
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
- // API endpoint to get usage data
124
+ // Register routes
124
125
  plugin.registerWithRouter = function (router) {
125
- router.get('/usage', (req, res) => {
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;
@@ -101,10 +101,10 @@ Publisher.prototype.publishTankageItems = function(items, deltas, meta) {
101
101
  return;
102
102
  }
103
103
 
104
- // Publish totals with 1 decimal place
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, 1)
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, 1)
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
- // If user provided a custom name, use it in the path
228
- // Otherwise use the full SignalK path
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
+ };
@@ -49,8 +49,8 @@ module.exports = {
49
49
  },
50
50
  name: {
51
51
  type: 'string',
52
- title: 'Name',
53
- description: 'Display name (optional, defaults to path)'
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: '3m' },
62
- { range: '7d', aggregation: '5m' },
63
- { range: '30d', aggregation: '10m' }
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 Source',
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: 'Display name (optional, defaults to path)'
102
+ title: 'Display Name',
103
+ description: 'Optional friendly name for this item'
107
104
  },
108
105
  directionality: {
109
106
  type: 'string',
110
- title: 'Direction',
111
- enum: ['', 'producer', 'consumer', 'bidirectional-normal', 'bidirectional-reversed'],
112
- description: 'Energy flow direction (blank = auto-detect)',
113
- default: ''
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: '3m' },
122
- { range: '7d', aggregation: '5m' },
123
- { range: '30d', aggregation: '10m' }
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
- // Sum all increases and decreases from aggregated data
174
- // No threshold needed - aggregation already smoothed out boat motion
175
- let totalAdded = 0;
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
- for (let i = 1; i < dataPoints.length; i++) {
179
- const prev = dataPoints[i - 1];
180
- const curr = dataPoints[i];
181
- const change = curr.value - prev.value;
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;