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 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
+ }
@@ -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;