node-red-contrib-influxdb3 1.0.2 → 1.0.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/README.md +9 -0
- package/__tests__/influxdb3.test.js +786 -0
- package/influxdb3.html +16 -1
- package/influxdb3.js +285 -188
- package/package.json +6 -7
- package/test/point-api.test.js +56 -0
package/influxdb3.html
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
defaults: {
|
|
8
8
|
name: { value: '' },
|
|
9
9
|
host: { value: '', required: true },
|
|
10
|
-
database: { value: '', required: true }
|
|
10
|
+
database: { value: '', required: true },
|
|
11
|
+
tlsRejectUnauthorized: { value: true },
|
|
12
|
+
caCertPath: { value: '' }
|
|
11
13
|
},
|
|
12
14
|
credentials: {
|
|
13
15
|
token: { type: 'password' }
|
|
@@ -35,6 +37,15 @@
|
|
|
35
37
|
<label for="node-config-input-database"><i class="fa fa-database"></i> Database</label>
|
|
36
38
|
<input type="text" id="node-config-input-database" placeholder="my-database">
|
|
37
39
|
</div>
|
|
40
|
+
<div class="form-row">
|
|
41
|
+
<label for="node-config-input-tlsRejectUnauthorized"><i class="fa fa-lock"></i> Verify TLS</label>
|
|
42
|
+
<input type="checkbox" id="node-config-input-tlsRejectUnauthorized" style="width:auto;">
|
|
43
|
+
<span>Reject unauthorized certificates</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="form-row">
|
|
46
|
+
<label for="node-config-input-caCertPath"><i class="fa fa-certificate"></i> CA Cert Path</label>
|
|
47
|
+
<input type="text" id="node-config-input-caCertPath" placeholder="C:\\path\\to\\root-ca.pem">
|
|
48
|
+
</div>
|
|
38
49
|
</script>
|
|
39
50
|
|
|
40
51
|
<script type="text/html" data-help-name="influxdb3-config">
|
|
@@ -50,6 +61,10 @@
|
|
|
50
61
|
<dd>The authentication token for accessing InfluxDB v3</dd>
|
|
51
62
|
<dt>Database <span class="property-type">string</span></dt>
|
|
52
63
|
<dd>The default database (bucket) name to write to</dd>
|
|
64
|
+
<dt>Verify TLS <span class="property-type">boolean</span></dt>
|
|
65
|
+
<dd>When unchecked, sets <code>NODE_TLS_REJECT_UNAUTHORIZED=0</code> for the Node-RED process</dd>
|
|
66
|
+
<dt>CA Cert Path <span class="property-type">string</span></dt>
|
|
67
|
+
<dd>Optional filesystem path used to set <code>NODE_EXTRA_CA_CERTS</code> for custom root CAs</dd>
|
|
53
68
|
</dl>
|
|
54
69
|
</script>
|
|
55
70
|
|
package/influxdb3.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* InfluxDB v3 nodes for Node-RED
|
|
3
|
+
* @module node-red-contrib-influxdb3
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
6
|
module.exports = function(RED) {
|
|
7
|
+
// @influxdata/influxdb3-client Point API:
|
|
8
|
+
// Point.setIntegerField(name, value)
|
|
9
|
+
// Point.setFloatField(name, value)
|
|
10
|
+
// Point.setStringField(name, value)
|
|
11
|
+
// Point.setBooleanField(name, value)
|
|
12
|
+
// Point.setTag(name, value)
|
|
13
|
+
// Point.setTimestamp(date)
|
|
14
|
+
// Point.toLineProtocol()
|
|
6
15
|
const { InfluxDBClient, Point } = require('@influxdata/influxdb3-client');
|
|
7
16
|
|
|
8
17
|
/**
|
|
9
18
|
* Normalize host URL to ensure it has trailing slash
|
|
19
|
+
* @param {string} host
|
|
20
|
+
* @returns {string}
|
|
10
21
|
*/
|
|
11
22
|
function normalizeHost(host) {
|
|
12
23
|
if (!host || typeof host !== 'string') {
|
|
@@ -15,120 +26,91 @@ module.exports = function(RED) {
|
|
|
15
26
|
return host.endsWith('/') ? host : host + '/';
|
|
16
27
|
}
|
|
17
28
|
|
|
18
|
-
/**
|
|
19
|
-
* Process a field value and add it to a Point
|
|
20
|
-
*/
|
|
21
|
-
function addFieldToPoint(point, key, value, integerFields) {
|
|
22
|
-
if (value === null || value === undefined) {
|
|
23
|
-
return false;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// Handle string with 'i' suffix for integers (e.g., "42i")
|
|
27
|
-
if (typeof value === 'string' && /^-?\d+i$/.test(value)) {
|
|
28
|
-
const intValue = parseInt(value.slice(0, -1), 10);
|
|
29
|
-
// Validate parsed value
|
|
30
|
-
if (!isNaN(intValue) && isFinite(intValue)) {
|
|
31
|
-
point.setIntegerField(key, intValue);
|
|
32
|
-
return true;
|
|
33
|
-
}
|
|
34
|
-
} else if (typeof value === 'number') {
|
|
35
|
-
// Validate number
|
|
36
|
-
if (!isFinite(value)) {
|
|
37
|
-
RED.log.warn(`Skipping field '${key}': value is not finite (${value})`);
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Default to float for all numbers (safe default)
|
|
42
|
-
// Use integer only if explicitly marked in 'integers' array
|
|
43
|
-
if (integerFields.has(key)) {
|
|
44
|
-
const intValue = Math.floor(value);
|
|
45
|
-
if (intValue !== value) {
|
|
46
|
-
RED.log.warn(`Field '${key}': truncating ${value} to ${intValue} for integer field`);
|
|
47
|
-
}
|
|
48
|
-
point.setIntegerField(key, intValue);
|
|
49
|
-
} else {
|
|
50
|
-
point.setFloatField(key, value);
|
|
51
|
-
}
|
|
52
|
-
return true;
|
|
53
|
-
} else if (typeof value === 'boolean') {
|
|
54
|
-
point.setBooleanField(key, value);
|
|
55
|
-
return true;
|
|
56
|
-
} else if (typeof value === 'string') {
|
|
57
|
-
point.setStringField(key, value);
|
|
58
|
-
return true;
|
|
59
|
-
} else {
|
|
60
|
-
// Complex types (arrays, objects) are not supported
|
|
61
|
-
RED.log.warn(`Skipping field '${key}': unsupported type ${typeof value}`);
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
29
|
/**
|
|
69
30
|
* Configuration node to hold InfluxDB v3 connection details
|
|
31
|
+
* @param {object} config
|
|
70
32
|
*/
|
|
71
33
|
function InfluxDB3ConfigNode(config) {
|
|
72
34
|
RED.nodes.createNode(this, config);
|
|
73
|
-
|
|
35
|
+
|
|
36
|
+
/** @type {string} */
|
|
74
37
|
this.host = config.host;
|
|
38
|
+
/** @type {string} */
|
|
75
39
|
this.database = config.database;
|
|
40
|
+
/** @type {string} */
|
|
76
41
|
this.name = config.name;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
42
|
+
/** @type {boolean} */
|
|
43
|
+
this.tlsRejectUnauthorized = config.tlsRejectUnauthorized !== false;
|
|
44
|
+
/** @type {string} */
|
|
45
|
+
this.caCertPath = config.caCertPath;
|
|
46
|
+
|
|
47
|
+
// Store token as a credential (populated by Node-RED runtime)
|
|
48
|
+
/** @type {string} */
|
|
49
|
+
if (!this.credentials) {
|
|
50
|
+
RED.log.warn('InfluxDB v3 config: credentials object is undefined');
|
|
51
|
+
}
|
|
52
|
+
this.token = this.credentials ? this.credentials.token : undefined;
|
|
53
|
+
|
|
81
54
|
// Client instance (will be created on demand)
|
|
55
|
+
/** @type {InfluxDBClient|null} */
|
|
82
56
|
this.client = null;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
57
|
+
|
|
58
|
+
const configNode = this;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get or create a client instance
|
|
62
|
+
* @returns {InfluxDBClient}
|
|
63
|
+
*/
|
|
64
|
+
configNode.getClient = function() {
|
|
65
|
+
if (!configNode.client) {
|
|
66
|
+
if (!configNode.host) {
|
|
89
67
|
throw new Error('InfluxDB host is not configured');
|
|
90
68
|
}
|
|
91
|
-
if (!
|
|
69
|
+
if (!configNode.token) {
|
|
92
70
|
throw new Error('InfluxDB token is not configured');
|
|
93
71
|
}
|
|
94
|
-
if (!
|
|
72
|
+
if (!configNode.database) {
|
|
95
73
|
throw new Error('InfluxDB database is not configured');
|
|
96
74
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
RED.log.info(`InfluxDB v3: Client created successfully`);
|
|
110
|
-
} catch (error) {
|
|
111
|
-
RED.log.error(`InfluxDB v3: Failed to create client - ${error.message}`);
|
|
112
|
-
throw new Error(`Failed to create InfluxDB client: ${error.message}`);
|
|
75
|
+
|
|
76
|
+
const normalizedHost = normalizeHost(configNode.host);
|
|
77
|
+
|
|
78
|
+
if (!configNode.tlsRejectUnauthorized) {
|
|
79
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
80
|
+
RED.log.warn('InfluxDB v3: TLS certificate verification is disabled for this process.');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (configNode.caCertPath) {
|
|
84
|
+
process.env.NODE_EXTRA_CA_CERTS = configNode.caCertPath;
|
|
85
|
+
RED.log.info(`InfluxDB v3: Using extra CA certificates from ${configNode.caCertPath}`);
|
|
113
86
|
}
|
|
87
|
+
|
|
88
|
+
RED.log.info(`InfluxDB v3: Connecting to ${normalizedHost} with database ${configNode.database}`);
|
|
89
|
+
|
|
90
|
+
configNode.client = new InfluxDBClient({
|
|
91
|
+
host: normalizedHost,
|
|
92
|
+
token: configNode.token,
|
|
93
|
+
database: configNode.database
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
RED.log.info('InfluxDB v3: Client created successfully');
|
|
114
97
|
}
|
|
115
|
-
return
|
|
98
|
+
return configNode.client;
|
|
116
99
|
};
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (this.client) {
|
|
100
|
+
|
|
101
|
+
configNode.on('close', function() {
|
|
102
|
+
if (configNode.client) {
|
|
121
103
|
try {
|
|
122
104
|
RED.log.info('InfluxDB v3: Closing client connection');
|
|
123
|
-
|
|
124
|
-
this.client = null;
|
|
105
|
+
configNode.client.close();
|
|
125
106
|
} catch (error) {
|
|
126
107
|
RED.log.warn(`InfluxDB v3: Error closing client - ${error.message}`);
|
|
127
108
|
}
|
|
109
|
+
configNode.client = null;
|
|
128
110
|
}
|
|
129
111
|
});
|
|
130
112
|
}
|
|
131
|
-
|
|
113
|
+
|
|
132
114
|
RED.nodes.registerType('influxdb3-config', InfluxDB3ConfigNode, {
|
|
133
115
|
credentials: {
|
|
134
116
|
token: { type: 'password' }
|
|
@@ -137,62 +119,244 @@ module.exports = function(RED) {
|
|
|
137
119
|
|
|
138
120
|
/**
|
|
139
121
|
* InfluxDB v3 Write Node
|
|
122
|
+
* @param {object} config
|
|
140
123
|
*/
|
|
141
124
|
function InfluxDB3WriteNode(config) {
|
|
142
125
|
RED.nodes.createNode(this, config);
|
|
143
|
-
|
|
126
|
+
|
|
144
127
|
this.influxdb = RED.nodes.getNode(config.influxdb);
|
|
145
128
|
this.measurement = config.measurement;
|
|
146
129
|
this.database = config.database;
|
|
147
|
-
|
|
130
|
+
|
|
148
131
|
const node = this;
|
|
149
132
|
let statusTimeout = null;
|
|
150
|
-
|
|
151
|
-
if (!
|
|
152
|
-
|
|
153
|
-
|
|
133
|
+
|
|
134
|
+
if (!node.influxdb) {
|
|
135
|
+
node.error('InfluxDB v3 config not set');
|
|
136
|
+
node.status({ fill: 'red', shape: 'dot', text: 'no config' });
|
|
154
137
|
return;
|
|
155
138
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Safely serialize a value for diagnostic logging.
|
|
142
|
+
* Handles circular references and large objects.
|
|
143
|
+
* @param {*} value
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
function safeStringify(value) {
|
|
147
|
+
try {
|
|
148
|
+
return JSON.stringify(value);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return `[unserializable: ${e.message}]`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Process a field value and add it to a Point.
|
|
156
|
+
* Returns true if the field was added, false if it was skipped.
|
|
157
|
+
*
|
|
158
|
+
* Uses the type-specific Point methods:
|
|
159
|
+
* - point.setFloatField(name, value) for numbers (default)
|
|
160
|
+
* - point.setIntegerField(name, value) for integers
|
|
161
|
+
* - point.setStringField(name, value) for strings
|
|
162
|
+
* - point.setBooleanField(name, value) for booleans
|
|
163
|
+
*
|
|
164
|
+
* @param {Point} point - The InfluxDB Point to add the field to
|
|
165
|
+
* @param {string} key - The field name
|
|
166
|
+
* @param {*} value - The field value
|
|
167
|
+
* @param {Set<string>} integerFields - Set of field names to treat as integers
|
|
168
|
+
* @param {string} measurement - Measurement name for diagnostic context
|
|
169
|
+
* @returns {boolean} true if the field was added successfully
|
|
170
|
+
*/
|
|
171
|
+
function addFieldToPoint(point, key, value, integerFields, measurement) {
|
|
172
|
+
const context = measurement ? ` (measurement: '${measurement}')` : '';
|
|
173
|
+
|
|
174
|
+
if (value === null || value === undefined) {
|
|
175
|
+
node.warn(`Skipping field '${key}': value is ${value}${context}`);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (typeof value === 'object') {
|
|
180
|
+
const typeName = Array.isArray(value)
|
|
181
|
+
? 'Array'
|
|
182
|
+
: (value.constructor ? value.constructor.name : 'object');
|
|
183
|
+
node.warn(
|
|
184
|
+
`Skipping field '${key}': unsupported type 'object' (${typeName})${context}. ` +
|
|
185
|
+
`Actual value: ${safeStringify(value)}. ` +
|
|
186
|
+
`The value for field '${key}' must be a number, string, or boolean.`
|
|
187
|
+
);
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (typeof value === 'string') {
|
|
192
|
+
// Check for integer suffix e.g. "42i"
|
|
193
|
+
if (/^-?\d+i$/.test(value)) {
|
|
194
|
+
point.setIntegerField(key, parseInt(value.slice(0, -1), 10));
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
point.setStringField(key, value);
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (typeof value === 'boolean') {
|
|
202
|
+
point.setBooleanField(key, value);
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (typeof value === 'number') {
|
|
207
|
+
if (!isFinite(value)) {
|
|
208
|
+
node.warn(
|
|
209
|
+
`Skipping field '${key}': numeric value is ${value} (not finite)${context}. ` +
|
|
210
|
+
`Check the source data for NaN or Infinity.`
|
|
211
|
+
);
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (integerFields && integerFields.has(key)) {
|
|
215
|
+
if (!Number.isInteger(value)) {
|
|
216
|
+
node.warn(
|
|
217
|
+
`Field '${key}' is marked as integer but value is ${value}${context}. ` +
|
|
218
|
+
`Value will be truncated to ${Math.floor(value)} using Math.floor.`
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
point.setIntegerField(key, Math.floor(value));
|
|
222
|
+
} else {
|
|
223
|
+
point.setFloatField(key, value);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
node.warn(
|
|
229
|
+
`Skipping field '${key}': unsupported type '${typeof value}'${context}. ` +
|
|
230
|
+
`Value: ${safeStringify(value)}`
|
|
231
|
+
);
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Set node status with optional auto-clear
|
|
237
|
+
* @param {object} status
|
|
238
|
+
* @param {number} [clearAfterMs=0]
|
|
239
|
+
*/
|
|
240
|
+
function setStatus(status, clearAfterMs) {
|
|
159
241
|
if (statusTimeout) {
|
|
160
242
|
clearTimeout(statusTimeout);
|
|
161
243
|
statusTimeout = null;
|
|
162
244
|
}
|
|
163
|
-
|
|
245
|
+
|
|
164
246
|
node.status(status);
|
|
165
|
-
|
|
166
|
-
if (clearAfterMs > 0) {
|
|
167
|
-
statusTimeout = setTimeout(()
|
|
247
|
+
|
|
248
|
+
if (clearAfterMs && clearAfterMs > 0) {
|
|
249
|
+
statusTimeout = setTimeout(function() {
|
|
168
250
|
node.status({});
|
|
169
251
|
statusTimeout = null;
|
|
170
252
|
}, clearAfterMs);
|
|
171
253
|
}
|
|
172
254
|
}
|
|
173
|
-
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build line protocol from an object payload.
|
|
258
|
+
* @param {object} msg - The incoming Node-RED message
|
|
259
|
+
* @returns {{lineProtocol: string}|{error: string}} result or error
|
|
260
|
+
*/
|
|
261
|
+
function buildLineProtocol(msg) {
|
|
262
|
+
const measurement = msg.measurement || node.measurement;
|
|
263
|
+
|
|
264
|
+
if (!measurement) {
|
|
265
|
+
return { error: 'Measurement not specified' };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const point = new Point(measurement);
|
|
269
|
+
|
|
270
|
+
// Add tags
|
|
271
|
+
if (msg.payload.tags && typeof msg.payload.tags === 'object' && !Array.isArray(msg.payload.tags)) {
|
|
272
|
+
for (const [key, value] of Object.entries(msg.payload.tags)) {
|
|
273
|
+
if (value !== null && value !== undefined) {
|
|
274
|
+
point.setTag(key, String(value));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Get list of fields that should be treated as integers
|
|
280
|
+
const integerFields = new Set(msg.payload.integers || []);
|
|
281
|
+
let fieldCount = 0;
|
|
282
|
+
|
|
283
|
+
// Add fields
|
|
284
|
+
if (msg.payload.fields && typeof msg.payload.fields === 'object' && !Array.isArray(msg.payload.fields)) {
|
|
285
|
+
// Explicit fields object
|
|
286
|
+
for (const [key, value] of Object.entries(msg.payload.fields)) {
|
|
287
|
+
if (addFieldToPoint(point, key, value, integerFields, measurement)) {
|
|
288
|
+
fieldCount++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
// Simplified format: treat all non-reserved properties as fields
|
|
293
|
+
const reservedKeys = new Set(['tags', 'timestamp', 'integers', 'fields']);
|
|
294
|
+
for (const [key, value] of Object.entries(msg.payload)) {
|
|
295
|
+
if (!reservedKeys.has(key)) {
|
|
296
|
+
if (addFieldToPoint(point, key, value, integerFields, measurement)) {
|
|
297
|
+
fieldCount++;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (fieldCount === 0) {
|
|
304
|
+
return {
|
|
305
|
+
error: 'No valid fields to write - all fields were skipped or payload had no fields. ' +
|
|
306
|
+
'Payload was: ' + safeStringify(msg.payload)
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Handle timestamp — use nullish coalescing to preserve falsy-but-valid values like 0
|
|
311
|
+
const ts = (msg.payload.timestamp !== null && msg.payload.timestamp !== undefined)
|
|
312
|
+
? msg.payload.timestamp
|
|
313
|
+
: msg.timestamp;
|
|
314
|
+
if (ts !== null && ts !== undefined) {
|
|
315
|
+
if (ts instanceof Date && !isNaN(ts.getTime())) {
|
|
316
|
+
point.setTimestamp(ts);
|
|
317
|
+
} else if (typeof ts === 'number' && isFinite(ts) && ts >= 0) {
|
|
318
|
+
point.setTimestamp(new Date(ts));
|
|
319
|
+
} else if (typeof ts === 'string' && ts.trim() !== '') {
|
|
320
|
+
const parsed = new Date(ts);
|
|
321
|
+
if (!isNaN(parsed.getTime())) {
|
|
322
|
+
point.setTimestamp(parsed);
|
|
323
|
+
} else {
|
|
324
|
+
node.warn(`Invalid timestamp string: '${ts}'`);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
node.warn(`Invalid timestamp: ${safeStringify(ts)} (type: ${typeof ts})`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const lp = point.toLineProtocol();
|
|
332
|
+
|
|
333
|
+
if (!lp || lp.trim() === '') {
|
|
334
|
+
return { error: 'Generated line protocol is empty' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { lineProtocol: lp };
|
|
338
|
+
}
|
|
339
|
+
|
|
174
340
|
// Process incoming messages
|
|
175
341
|
node.on('input', async function(msg, send, done) {
|
|
176
342
|
// For Node-RED 0.x compatibility
|
|
177
|
-
send = send || function() { node.send
|
|
178
|
-
done = done || function(err) {
|
|
179
|
-
if (err) {
|
|
180
|
-
node.error(err, msg);
|
|
181
|
-
}
|
|
343
|
+
send = send || function(m) { node.send(m); };
|
|
344
|
+
done = done || function(err) {
|
|
345
|
+
if (err) { node.error(err, msg); }
|
|
182
346
|
};
|
|
183
|
-
|
|
347
|
+
|
|
184
348
|
try {
|
|
185
349
|
const client = node.influxdb.getClient();
|
|
186
|
-
|
|
350
|
+
|
|
187
351
|
// Determine the database to use
|
|
188
352
|
const targetDatabase = msg.database || node.database || node.influxdb.database;
|
|
189
|
-
|
|
353
|
+
|
|
190
354
|
if (!targetDatabase) {
|
|
191
355
|
throw new Error('Database not specified');
|
|
192
356
|
}
|
|
193
|
-
|
|
357
|
+
|
|
194
358
|
let lineProtocol;
|
|
195
|
-
|
|
359
|
+
|
|
196
360
|
// Check if msg.payload is already in line protocol format
|
|
197
361
|
if (typeof msg.payload === 'string') {
|
|
198
362
|
lineProtocol = msg.payload.trim();
|
|
@@ -200,96 +364,29 @@ module.exports = function(RED) {
|
|
|
200
364
|
throw new Error('Line protocol string is empty');
|
|
201
365
|
}
|
|
202
366
|
} else if (msg.payload && typeof msg.payload === 'object' && !Array.isArray(msg.payload)) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (!measurement) {
|
|
207
|
-
throw new Error('Measurement not specified');
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
const point = new Point(measurement);
|
|
211
|
-
|
|
212
|
-
// Add tags
|
|
213
|
-
if (msg.payload.tags && typeof msg.payload.tags === 'object' && !Array.isArray(msg.payload.tags)) {
|
|
214
|
-
for (const [key, value] of Object.entries(msg.payload.tags)) {
|
|
215
|
-
if (value !== null && value !== undefined) {
|
|
216
|
-
point.setTag(key, String(value));
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Get list of fields that should be treated as integers
|
|
222
|
-
const integerFields = new Set(msg.payload.integers || []);
|
|
223
|
-
let fieldCount = 0;
|
|
224
|
-
|
|
225
|
-
// Add fields
|
|
226
|
-
if (msg.payload.fields && typeof msg.payload.fields === 'object' && !Array.isArray(msg.payload.fields)) {
|
|
227
|
-
// Explicit fields object
|
|
228
|
-
for (const [key, value] of Object.entries(msg.payload.fields)) {
|
|
229
|
-
if (addFieldToPoint(point, key, value, integerFields)) {
|
|
230
|
-
fieldCount++;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
} else {
|
|
234
|
-
// Simplified format: treat all non-reserved properties as fields
|
|
235
|
-
const reservedKeys = new Set(['tags', 'timestamp', 'integers', 'fields']);
|
|
236
|
-
for (const [key, value] of Object.entries(msg.payload)) {
|
|
237
|
-
if (!reservedKeys.has(key)) {
|
|
238
|
-
if (addFieldToPoint(point, key, value, integerFields)) {
|
|
239
|
-
fieldCount++;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (fieldCount === 0) {
|
|
246
|
-
throw new Error('No valid fields to write - at least one field is required');
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Add timestamp if provided
|
|
250
|
-
if (msg.payload.timestamp) {
|
|
251
|
-
const ts = msg.payload.timestamp;
|
|
252
|
-
if (ts instanceof Date && !isNaN(ts.getTime())) {
|
|
253
|
-
point.setTimestamp(ts);
|
|
254
|
-
} else if (typeof ts === 'number' && isFinite(ts) && ts > 0) {
|
|
255
|
-
point.setTimestamp(new Date(ts));
|
|
256
|
-
} else {
|
|
257
|
-
node.warn(`Invalid timestamp in payload: ${ts}`);
|
|
258
|
-
}
|
|
259
|
-
} else if (msg.timestamp) {
|
|
260
|
-
const ts = msg.timestamp;
|
|
261
|
-
if (ts instanceof Date && !isNaN(ts.getTime())) {
|
|
262
|
-
point.setTimestamp(ts);
|
|
263
|
-
} else if (typeof ts === 'number' && isFinite(ts) && ts > 0) {
|
|
264
|
-
point.setTimestamp(new Date(ts));
|
|
265
|
-
} else {
|
|
266
|
-
node.warn(`Invalid timestamp in msg: ${ts}`);
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
lineProtocol = point.toLineProtocol();
|
|
271
|
-
|
|
272
|
-
if (!lineProtocol || lineProtocol.trim() === '') {
|
|
273
|
-
throw new Error('Generated line protocol is empty');
|
|
367
|
+
const result = buildLineProtocol(msg);
|
|
368
|
+
if (result.error) {
|
|
369
|
+
throw new Error(result.error);
|
|
274
370
|
}
|
|
371
|
+
lineProtocol = result.lineProtocol;
|
|
275
372
|
} else {
|
|
276
373
|
throw new Error('Invalid payload format. Expected string (line protocol) or object with fields');
|
|
277
374
|
}
|
|
278
|
-
|
|
375
|
+
|
|
279
376
|
// Write to InfluxDB
|
|
280
377
|
await client.write(lineProtocol, targetDatabase);
|
|
281
|
-
|
|
378
|
+
|
|
282
379
|
setStatus({ fill: 'green', shape: 'dot', text: 'written' }, 3000);
|
|
283
|
-
|
|
380
|
+
|
|
284
381
|
send(msg);
|
|
285
382
|
done();
|
|
286
|
-
|
|
383
|
+
|
|
287
384
|
} catch (error) {
|
|
288
385
|
setStatus({ fill: 'red', shape: 'dot', text: 'error' });
|
|
289
386
|
done(error);
|
|
290
387
|
}
|
|
291
388
|
});
|
|
292
|
-
|
|
389
|
+
|
|
293
390
|
node.on('close', function() {
|
|
294
391
|
if (statusTimeout) {
|
|
295
392
|
clearTimeout(statusTimeout);
|
|
@@ -298,6 +395,6 @@ module.exports = function(RED) {
|
|
|
298
395
|
node.status({});
|
|
299
396
|
});
|
|
300
397
|
}
|
|
301
|
-
|
|
398
|
+
|
|
302
399
|
RED.nodes.registerType('influxdb3-write', InfluxDB3WriteNode);
|
|
303
400
|
};
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-influxdb3",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Node-RED nodes for InfluxDB v3 integration",
|
|
5
5
|
"main": "influxdb3.js",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "
|
|
7
|
+
"test": "jest"
|
|
8
8
|
},
|
|
9
9
|
"keywords": [
|
|
10
10
|
"node-red",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
23
|
-
"url": "https://github.com/stuartb55/nodered-influxdb3.git"
|
|
23
|
+
"url": "git+https://github.com/stuartb55/nodered-influxdb3.git"
|
|
24
24
|
},
|
|
25
25
|
"homepage": "https://github.com/stuartb55/nodered-influxdb3#readme",
|
|
26
26
|
"bugs": {
|
|
@@ -33,10 +33,9 @@
|
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@influxdata/influxdb3-client": "^
|
|
36
|
+
"@influxdata/influxdb3-client": "^2.0.0"
|
|
37
37
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"jest": "^30.2.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
-
|