signalk-usage 0.1.0
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/README.md +18 -0
- package/package.json +30 -0
- package/plugin/index.js +149 -0
- package/plugin/lib/influx-client.js +171 -0
- package/plugin/lib/power-engine.js +359 -0
- package/plugin/lib/publisher.js +241 -0
- package/plugin/lib/schema.js +168 -0
- package/plugin/lib/tankage-engine.js +242 -0
- package/plugin/lib/usage-coordinator.js +81 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# signalk-usage
|
|
2
|
+
|
|
3
|
+
Uses infuxDB data to caculate tank and electrical usage
|
|
4
|
+
|
|
5
|
+
## Dependencies
|
|
6
|
+
|
|
7
|
+
- SignalK server with signalk-to-influxdb2 plugin
|
|
8
|
+
- InfluxDB 2.x
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
Install via SignalK Appstore or:
|
|
13
|
+
```bash
|
|
14
|
+
cd ~/.signalk
|
|
15
|
+
npm install signalk-usage
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Configure InfluxDB connection and paths in plugin settings, then restart SignalK.
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "signalk-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
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": "https://github.com/yourusername/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
|
+
}
|
package/plugin/index.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const schema = require('./lib/schema');
|
|
2
|
+
const InfluxClient = require('./lib/influx-client');
|
|
3
|
+
const UsageCoordinator = require('./lib/usage-coordinator');
|
|
4
|
+
const Publisher = require('./lib/publisher');
|
|
5
|
+
|
|
6
|
+
module.exports = function (app) {
|
|
7
|
+
let plugin = {
|
|
8
|
+
id: 'signalk-usage',
|
|
9
|
+
name: 'SignalK Usage',
|
|
10
|
+
description: 'Report Electrical and Tank Usage',
|
|
11
|
+
schema: schema,
|
|
12
|
+
|
|
13
|
+
influxClient: null,
|
|
14
|
+
usageCoordinator: null,
|
|
15
|
+
publisher: null,
|
|
16
|
+
updateTimer: null
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
plugin.start = function (options, restartPlugin) {
|
|
20
|
+
try {
|
|
21
|
+
app.debug('Starting SignalK Usage plugin');
|
|
22
|
+
|
|
23
|
+
// Validate configuration
|
|
24
|
+
if (!options.influx) {
|
|
25
|
+
app.setPluginError('InfluxDB configuration is required');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tankageItems = options.tankage || [];
|
|
30
|
+
const powerItems = options.power || [];
|
|
31
|
+
|
|
32
|
+
if (tankageItems.length === 0 && powerItems.length === 0) {
|
|
33
|
+
app.setPluginError('At least one tankage or power item must be configured');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Initialize InfluxDB client (read-only)
|
|
38
|
+
plugin.influxClient = new InfluxClient(
|
|
39
|
+
options.influx,
|
|
40
|
+
app
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Test connection
|
|
44
|
+
plugin.influxClient.ping()
|
|
45
|
+
.then(() => {
|
|
46
|
+
app.debug('InfluxDB connection successful');
|
|
47
|
+
|
|
48
|
+
// Initialize usage coordinator
|
|
49
|
+
plugin.usageCoordinator = new UsageCoordinator(
|
|
50
|
+
app,
|
|
51
|
+
plugin.influxClient,
|
|
52
|
+
options
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Start periodic updates
|
|
56
|
+
const updateInterval = (options.reporting?.updateInterval || 60) * 1000;
|
|
57
|
+
|
|
58
|
+
// Initial calculation
|
|
59
|
+
plugin.usageCoordinator.calculateAll()
|
|
60
|
+
.then(() => {
|
|
61
|
+
app.debug('Initial usage calculation complete');
|
|
62
|
+
|
|
63
|
+
// Initialize publisher
|
|
64
|
+
plugin.publisher = new Publisher(
|
|
65
|
+
app,
|
|
66
|
+
plugin.usageCoordinator,
|
|
67
|
+
options
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
plugin.publisher.start();
|
|
71
|
+
|
|
72
|
+
app.setPluginStatus('Running');
|
|
73
|
+
})
|
|
74
|
+
.catch(err => {
|
|
75
|
+
app.setPluginError(`Initial calculation failed: ${err.message}`);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Set up periodic recalculation
|
|
79
|
+
plugin.updateTimer = setInterval(() => {
|
|
80
|
+
plugin.usageCoordinator.calculateAll()
|
|
81
|
+
.catch(err => {
|
|
82
|
+
app.debug(`Calculation error: ${err.message}`);
|
|
83
|
+
});
|
|
84
|
+
}, updateInterval);
|
|
85
|
+
|
|
86
|
+
})
|
|
87
|
+
.catch(err => {
|
|
88
|
+
app.setPluginError(`InfluxDB connection failed: ${err.message}`);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
} catch (err) {
|
|
92
|
+
app.setPluginError(`Failed to start: ${err.message}`);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
plugin.stop = function () {
|
|
97
|
+
try {
|
|
98
|
+
app.debug('Stopping SignalK Usage plugin');
|
|
99
|
+
|
|
100
|
+
if (plugin.updateTimer) {
|
|
101
|
+
clearInterval(plugin.updateTimer);
|
|
102
|
+
plugin.updateTimer = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (plugin.publisher) {
|
|
106
|
+
plugin.publisher.stop();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (plugin.usageCoordinator) {
|
|
110
|
+
plugin.usageCoordinator.stop();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (plugin.influxClient) {
|
|
114
|
+
plugin.influxClient.close();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
app.setPluginStatus('Stopped');
|
|
118
|
+
} catch (err) {
|
|
119
|
+
app.setPluginError(`Error stopping plugin: ${err.message}`);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// API endpoint to get usage data
|
|
124
|
+
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
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return plugin;
|
|
149
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const { InfluxDB } = require('@influxdata/influxdb-client');
|
|
2
|
+
const { PingAPI } = require('@influxdata/influxdb-client-apis');
|
|
3
|
+
|
|
4
|
+
function InfluxClient(config, app) {
|
|
5
|
+
this.config = config;
|
|
6
|
+
this.app = app;
|
|
7
|
+
this.client = new InfluxDB({
|
|
8
|
+
url: config.url,
|
|
9
|
+
token: config.token
|
|
10
|
+
});
|
|
11
|
+
this.queryApi = this.client.getQueryApi(config.org);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
InfluxClient.prototype.ping = async function() {
|
|
15
|
+
try {
|
|
16
|
+
const pingAPI = new PingAPI(this.client);
|
|
17
|
+
await pingAPI.getPing();
|
|
18
|
+
return true;
|
|
19
|
+
} catch (err) {
|
|
20
|
+
this.app.debug(`InfluxDB ping failed: ${err.message}`);
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
InfluxClient.prototype.queryPath = async function(path, range, aggregation) {
|
|
26
|
+
// Use provided aggregation window or fall back to smart default
|
|
27
|
+
const window = aggregation || this.getAggregateWindow(range);
|
|
28
|
+
|
|
29
|
+
const query = `
|
|
30
|
+
from(bucket: "${this.config.bucket}")
|
|
31
|
+
|> range(start: ${range})
|
|
32
|
+
|> filter(fn: (r) => r._measurement == "${path}")
|
|
33
|
+
|> filter(fn: (r) => r._field == "value")
|
|
34
|
+
|> filter(fn: (r) => r.self == "true")
|
|
35
|
+
|> aggregateWindow(every: ${window}, fn: mean, createEmpty: false)
|
|
36
|
+
|> sort(columns: ["_time"])
|
|
37
|
+
`;
|
|
38
|
+
|
|
39
|
+
const self = this;
|
|
40
|
+
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const results = [];
|
|
43
|
+
|
|
44
|
+
self.queryApi.queryRows(query, {
|
|
45
|
+
next: (row, tableMeta) => {
|
|
46
|
+
const obj = tableMeta.toObject(row);
|
|
47
|
+
results.push({
|
|
48
|
+
timestamp: new Date(obj._time),
|
|
49
|
+
value: obj._value
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
error: (err) => {
|
|
53
|
+
self.app.debug(`Query error for ${path}: ${err.message}`);
|
|
54
|
+
reject(err);
|
|
55
|
+
},
|
|
56
|
+
complete: () => {
|
|
57
|
+
self.app.debug(`Query complete for ${path}: ${results.length} points (window: ${window})`);
|
|
58
|
+
resolve(results);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
InfluxClient.prototype.queryPathRaw = async function(path, range) {
|
|
65
|
+
// Query without aggregation for accurate energy integration
|
|
66
|
+
const query = `
|
|
67
|
+
from(bucket: "${this.config.bucket}")
|
|
68
|
+
|> range(start: ${range})
|
|
69
|
+
|> filter(fn: (r) => r._measurement == "${path}")
|
|
70
|
+
|> filter(fn: (r) => r._field == "value")
|
|
71
|
+
|> filter(fn: (r) => r.self == "true")
|
|
72
|
+
|> sort(columns: ["_time"])
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
this.app.debug(`Executing raw query for ${path}:`);
|
|
76
|
+
this.app.debug(query);
|
|
77
|
+
|
|
78
|
+
const self = this;
|
|
79
|
+
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const results = [];
|
|
82
|
+
|
|
83
|
+
self.queryApi.queryRows(query, {
|
|
84
|
+
next: (row, tableMeta) => {
|
|
85
|
+
const obj = tableMeta.toObject(row);
|
|
86
|
+
results.push({
|
|
87
|
+
timestamp: new Date(obj._time).getTime(), // Convert to milliseconds
|
|
88
|
+
value: obj._value
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
error: (err) => {
|
|
92
|
+
self.app.debug(`Query error for ${path}: ${err.message}`);
|
|
93
|
+
reject(err);
|
|
94
|
+
},
|
|
95
|
+
complete: () => {
|
|
96
|
+
self.app.debug(`Raw query complete for ${path}: ${results.length} points`);
|
|
97
|
+
resolve(results);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
InfluxClient.prototype.getAggregateWindow = function(range) {
|
|
104
|
+
if (range === '-1h' || range === '-15m') return '1m';
|
|
105
|
+
if (range === '-6h') return '5m';
|
|
106
|
+
if (range === '-12h') return '10m';
|
|
107
|
+
if (range === '-24h') return '15m';
|
|
108
|
+
if (range === '-7d') return '1h';
|
|
109
|
+
if (range === '-30d') return '4h';
|
|
110
|
+
if (range === '-90d') return '12h';
|
|
111
|
+
return '1m';
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
InfluxClient.prototype.getFirstAndLast = async function(path, range) {
|
|
115
|
+
const query = `
|
|
116
|
+
from(bucket: "${this.config.bucket}")
|
|
117
|
+
|> range(start: ${range})
|
|
118
|
+
|> filter(fn: (r) => r._measurement == "${path}")
|
|
119
|
+
|> filter(fn: (r) => r._field == "value")
|
|
120
|
+
|> filter(fn: (r) => r.self == "true")
|
|
121
|
+
`;
|
|
122
|
+
|
|
123
|
+
this.app.debug(`Executing query for ${path}:`);
|
|
124
|
+
this.app.debug(query);
|
|
125
|
+
|
|
126
|
+
const firstQuery = query + '\n|> first()';
|
|
127
|
+
const lastQuery = query + '\n|> last()';
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const [first, last] = await Promise.all([
|
|
131
|
+
this.executeSingleValueQuery(firstQuery),
|
|
132
|
+
this.executeSingleValueQuery(lastQuery)
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
this.app.debug(`Query results for ${path}: first=${first ? first.value : 'null'}, last=${last ? last.value : 'null'}`);
|
|
136
|
+
|
|
137
|
+
return { first, last };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
this.app.debug(`Error getting first/last for ${path}: ${err.message}`);
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
InfluxClient.prototype.executeSingleValueQuery = function(query) {
|
|
145
|
+
const self = this;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
let result = null;
|
|
148
|
+
|
|
149
|
+
self.queryApi.queryRows(query, {
|
|
150
|
+
next: (row, tableMeta) => {
|
|
151
|
+
const obj = tableMeta.toObject(row);
|
|
152
|
+
result = {
|
|
153
|
+
timestamp: new Date(obj._time),
|
|
154
|
+
value: obj._value
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
error: reject,
|
|
158
|
+
complete: () => resolve(result)
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
InfluxClient.prototype.close = function() {
|
|
164
|
+
try {
|
|
165
|
+
this.app.debug('InfluxDB client closed');
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.app.debug(`Error closing InfluxDB client: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
module.exports = InfluxClient;
|