node-red-contrib-ccu 4.3.0 → 4.4.1
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/.prettierignore +3 -0
- package/CHANGELOG.md +72 -0
- package/HANDOFF.md +54 -12
- package/README.md +42 -0
- package/ROADMAP.md +19 -15
- package/nodes/ccu-connection.html +25 -0
- package/nodes/ccu-connection.js +252 -11
- package/nodes/ccu-mqtt.js +12 -3
- package/nodes/ccu-poll.js +8 -1
- package/nodes/lib/metaprovider.js +716 -0
- package/package.json +1 -1
- package/roadmap-archive/B-17.md +106 -0
- package/roadmap-archive/README.md +3 -0
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openccu-lite metadata provider (roadmap B-17).
|
|
3
|
+
*
|
|
4
|
+
* openccu-lite is a Homematic CCU firmware without ReGaHSS: nothing listens on
|
|
5
|
+
* 8181/8183, there is no HM-Script interpreter and there are no ReGa ids. What
|
|
6
|
+
* it has instead is occulited's metadata API - a small JSON store of names and
|
|
7
|
+
* enums served under `/api/meta/v1/` - plus a Server-Sent-Events change stream.
|
|
8
|
+
*
|
|
9
|
+
* This module is the counterpart of the `getRega*()` methods in
|
|
10
|
+
* ccu-connection.js: it fills the very same maps (`channelNames`,
|
|
11
|
+
* `channelRooms`, `channelFunctions`) and the same `rooms` / `functions` name
|
|
12
|
+
* arrays, so nothing downstream (message.js, channelfilter.js, the editor
|
|
13
|
+
* endpoints) can tell the two apart. Everything ReGa-only - system variables,
|
|
14
|
+
* programs, `exec()` - has no replacement and stays empty.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately built on node:http / node:https rather than global fetch: the
|
|
17
|
+
* connection node already offers "ignore invalid TLS certificates", which fetch
|
|
18
|
+
* cannot express per request without pulling in undici, and a Node stream is
|
|
19
|
+
* the natural shape for SSE. No new dependency either way.
|
|
20
|
+
*
|
|
21
|
+
* @see https://github.com/hobbyquaker/openccu-lite `docs/meta-api.md`
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const http = require('http');
|
|
26
|
+
const https = require('https');
|
|
27
|
+
|
|
28
|
+
const API_PATH = '/api/meta/v1';
|
|
29
|
+
|
|
30
|
+
/** Where occulited keeps the box's own read-only token (role `user`). */
|
|
31
|
+
const LOCAL_TOKEN_FILE = '/usr/local/etc/occulite/local-token';
|
|
32
|
+
|
|
33
|
+
const NOOP = () => {};
|
|
34
|
+
const SILENT = {trace: NOOP, debug: NOOP, info: NOOP, warn: NOOP, error: NOOP};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `<interface>.<address>` -> `<address>`. node-red-contrib-ccu keys everything
|
|
38
|
+
* by the bare address, interface names never contain a dot.
|
|
39
|
+
* @param {string} ref
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
function refToAddress(ref) {
|
|
43
|
+
const index = ref.indexOf('.');
|
|
44
|
+
return index === -1 ? ref : ref.slice(index + 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `<interface>.<address>` -> `<interface>`.
|
|
49
|
+
* @param {string} ref
|
|
50
|
+
* @returns {string}
|
|
51
|
+
*/
|
|
52
|
+
function refToIface(ref) {
|
|
53
|
+
const index = ref.indexOf('.');
|
|
54
|
+
return index === -1 ? '' : ref.slice(0, index);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Flatten an enum tree into a path -> {name, depth, order} map.
|
|
59
|
+
* `room/eg/wohnzimmer` and `room/eg` both end up in it, which is what makes a
|
|
60
|
+
* channel in a leaf room a member of its parent room as well.
|
|
61
|
+
* @param {string} enumId
|
|
62
|
+
* @param {Array} tree
|
|
63
|
+
* @returns {Map<string, {name: string, depth: number, order: number}>}
|
|
64
|
+
*/
|
|
65
|
+
function flattenEnum(enumId, tree) {
|
|
66
|
+
const map = new Map();
|
|
67
|
+
let order = 0;
|
|
68
|
+
const walk = (nodes, prefix, depth) => {
|
|
69
|
+
if (!Array.isArray(nodes)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const node of nodes) {
|
|
74
|
+
if (!node || typeof node.id !== 'string') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const path = prefix + '/' + node.id;
|
|
79
|
+
map.set(path, {name: typeof node.name === 'string' && node.name ? node.name : node.id, depth, order});
|
|
80
|
+
order += 1;
|
|
81
|
+
walk(node.children, path, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
walk(tree, enumId, 1);
|
|
86
|
+
return map;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The names of the nodes an object belongs to, most specific first, ancestors
|
|
91
|
+
* included, duplicates by name removed.
|
|
92
|
+
* @param {Map} flat
|
|
93
|
+
* @param {Array<string>} paths
|
|
94
|
+
* @param {string} enumId
|
|
95
|
+
* @returns {Array<string>}
|
|
96
|
+
*/
|
|
97
|
+
function memberNames(flat, paths, enumId) {
|
|
98
|
+
const found = new Map();
|
|
99
|
+
for (const path of paths) {
|
|
100
|
+
if (typeof path !== 'string' || !path.startsWith(enumId + '/')) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let current = path;
|
|
105
|
+
while (current.includes('/')) {
|
|
106
|
+
const node = flat.get(current);
|
|
107
|
+
if (node && !found.has(current)) {
|
|
108
|
+
found.set(current, node);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
current = current.slice(0, current.lastIndexOf('/'));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sorted = [...found.values()].sort((a, b) => b.depth - a.depth || a.order - b.order);
|
|
116
|
+
const names = [];
|
|
117
|
+
for (const node of sorted) {
|
|
118
|
+
if (!names.includes(node.name)) {
|
|
119
|
+
names.push(node.name);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return names;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Derive everything ccu-connection.js needs from a metadata document (the body
|
|
128
|
+
* of `GET /api/meta/v1/snapshot`, which is also the file format on the box).
|
|
129
|
+
* Pure - this is what the fixture corpus is run against.
|
|
130
|
+
* @param {object} document
|
|
131
|
+
* @returns {{channelNames: object, channelRooms: object, channelFunctions: object, rooms: Array, functions: Array, ifaces: object}}
|
|
132
|
+
*/
|
|
133
|
+
function buildNames(document) {
|
|
134
|
+
const result = {
|
|
135
|
+
channelNames: {},
|
|
136
|
+
channelRooms: {},
|
|
137
|
+
channelFunctions: {},
|
|
138
|
+
rooms: [],
|
|
139
|
+
functions: [],
|
|
140
|
+
ifaces: {},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
if (!document || typeof document !== 'object') {
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const enums = document.enums && typeof document.enums === 'object' ? document.enums : {};
|
|
148
|
+
const flatRooms = flattenEnum('room', enums.room && enums.room.tree);
|
|
149
|
+
const flatFunctions = flattenEnum('function', enums.function && enums.function.tree);
|
|
150
|
+
|
|
151
|
+
const listNames = (flat) => {
|
|
152
|
+
const names = [];
|
|
153
|
+
for (const node of [...flat.values()].sort((a, b) => a.order - b.order)) {
|
|
154
|
+
if (!names.includes(node.name)) {
|
|
155
|
+
names.push(node.name);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return names;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
result.rooms = listNames(flatRooms);
|
|
163
|
+
result.functions = listNames(flatFunctions);
|
|
164
|
+
|
|
165
|
+
const objects = document.objects && typeof document.objects === 'object' ? document.objects : {};
|
|
166
|
+
for (const ref of Object.keys(objects)) {
|
|
167
|
+
const object = objects[ref];
|
|
168
|
+
if (!object || typeof object !== 'object') {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const address = refToAddress(ref);
|
|
173
|
+
const iface = refToIface(ref);
|
|
174
|
+
if (iface) {
|
|
175
|
+
result.ifaces[address] = iface;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof object.name === 'string' && object.name) {
|
|
179
|
+
result.channelNames[address] = object.name;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const paths = Array.isArray(object.enums) ? object.enums : [];
|
|
183
|
+
const rooms = memberNames(flatRooms, paths, 'room');
|
|
184
|
+
if (rooms.length > 0) {
|
|
185
|
+
result.channelRooms[address] = rooms;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const functions = memberNames(flatFunctions, paths, 'function');
|
|
189
|
+
if (functions.length > 0) {
|
|
190
|
+
result.channelFunctions[address] = functions;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Read the box's local token. Present only when Node-RED runs on an
|
|
199
|
+
* openccu-lite box (RedMatic); off the box the user configures a token.
|
|
200
|
+
* @param {string} [file]
|
|
201
|
+
* @returns {string} the token, or '' when there is none
|
|
202
|
+
*/
|
|
203
|
+
function readLocalToken(file = LOCAL_TOKEN_FILE) {
|
|
204
|
+
try {
|
|
205
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
206
|
+
const [first] = content.split('\n');
|
|
207
|
+
return first.trim();
|
|
208
|
+
} catch {
|
|
209
|
+
return '';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One HTTP request against the metadata API.
|
|
215
|
+
* @param {object} options
|
|
216
|
+
* @returns {Promise<{status: number, headers: object, res: object, body: Promise<string>}>}
|
|
217
|
+
*/
|
|
218
|
+
function request(options) {
|
|
219
|
+
const {host, port, tls, insecure, path, token, timeout = 10000, idleTimeout, accept = 'application/json'} = options;
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const headers = {accept, 'user-agent': 'node-red-contrib-ccu'};
|
|
222
|
+
if (token) {
|
|
223
|
+
headers.authorization = 'Bearer ' + token;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const lib = tls ? https : http;
|
|
227
|
+
const requestOptions = {
|
|
228
|
+
host,
|
|
229
|
+
port,
|
|
230
|
+
path,
|
|
231
|
+
method: 'GET',
|
|
232
|
+
headers,
|
|
233
|
+
...(tls ? {rejectUnauthorized: !insecure} : {}),
|
|
234
|
+
};
|
|
235
|
+
if (timeout > 0) {
|
|
236
|
+
// as an option, not request.setTimeout(): only this form arms the
|
|
237
|
+
// timer before the socket is connected, so an unreachable box does
|
|
238
|
+
// not hang for the OS' connect timeout
|
|
239
|
+
requestOptions.timeout = timeout;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const request_ = lib.request(requestOptions, (res) => {
|
|
243
|
+
resolve({status: res.statusCode, headers: res.headers, res, request: request_});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
request_.on('timeout', () => {
|
|
247
|
+
request_.destroy(new Error('timeout'));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
if (idleTimeout !== undefined) {
|
|
251
|
+
// the event stream may legitimately stay silent between heartbeats,
|
|
252
|
+
// so the connect timeout above must not keep applying to it
|
|
253
|
+
request_.on('socket', (socket) => {
|
|
254
|
+
if (socket.connecting) {
|
|
255
|
+
socket.once('connect', () => socket.setTimeout(idleTimeout));
|
|
256
|
+
} else {
|
|
257
|
+
socket.setTimeout(idleTimeout);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
request_.on('error', reject);
|
|
262
|
+
request_.end();
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Read a response body to a string (bounded - the snapshot of a big
|
|
268
|
+
* installation is a few hundred kB).
|
|
269
|
+
* @param {object} res
|
|
270
|
+
* @returns {Promise<string>}
|
|
271
|
+
*/
|
|
272
|
+
function readBody(res) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
let body = '';
|
|
275
|
+
res.setEncoding('utf8');
|
|
276
|
+
res.on('data', (chunk) => {
|
|
277
|
+
body += chunk;
|
|
278
|
+
});
|
|
279
|
+
res.on('end', () => resolve(body));
|
|
280
|
+
res.on('error', reject);
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Feature detection: `GET /api/meta/v1/version` answers on openccu-lite and
|
|
286
|
+
* needs no credential; a CCU / RaspberryMatic / OpenCCU answers 404 or HTML.
|
|
287
|
+
* Never rejects - a box that is simply unreachable is not openccu-lite either.
|
|
288
|
+
* @param {object} options host, port, tls, insecure, timeout, logger
|
|
289
|
+
* @returns {Promise<object|null>} the version document, or null
|
|
290
|
+
*/
|
|
291
|
+
async function detect(options) {
|
|
292
|
+
const {logger = SILENT} = options;
|
|
293
|
+
try {
|
|
294
|
+
const {status, res} = await request({
|
|
295
|
+
...options,
|
|
296
|
+
path: API_PATH + '/version',
|
|
297
|
+
token: undefined,
|
|
298
|
+
timeout: options.timeout || 5000,
|
|
299
|
+
});
|
|
300
|
+
const body = await readBody(res);
|
|
301
|
+
if (status !== 200) {
|
|
302
|
+
logger.debug('meta api detection: status ' + status);
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const info = JSON.parse(body);
|
|
307
|
+
if (!info || info.api !== 'meta' || typeof info.version !== 'number') {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return info;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
logger.debug('meta api detection: ' + error.message);
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Loads the metadata snapshot and follows the change stream. Public surface
|
|
320
|
+
* mirrors what ccu-connection.js needs: start(), stop() and two callbacks.
|
|
321
|
+
*/
|
|
322
|
+
class MetaProvider {
|
|
323
|
+
/**
|
|
324
|
+
* @param {object} options
|
|
325
|
+
*/
|
|
326
|
+
constructor(options = {}) {
|
|
327
|
+
this.host = options.host;
|
|
328
|
+
this.port = options.port || (options.tls ? 443 : 80);
|
|
329
|
+
this.tls = Boolean(options.tls);
|
|
330
|
+
this.insecure = Boolean(options.insecure);
|
|
331
|
+
this.token = options.token || '';
|
|
332
|
+
this.logger = options.logger || SILENT;
|
|
333
|
+
this.onNames = options.onNames || NOOP;
|
|
334
|
+
this.onStatus = options.onStatus || NOOP;
|
|
335
|
+
// called when the box stopped answering the version probe altogether -
|
|
336
|
+
// it is not an openccu-lite any more (restored backup, firmware swap)
|
|
337
|
+
this.onGone = options.onGone || NOOP;
|
|
338
|
+
this.goneAfter = options.goneAfter === undefined ? 3 : options.goneAfter;
|
|
339
|
+
this.requestTimeout = options.requestTimeout || 10000;
|
|
340
|
+
this.retryMin = options.retryMin === undefined ? 5000 : options.retryMin;
|
|
341
|
+
this.retryMax = options.retryMax === undefined ? 60000 : options.retryMax;
|
|
342
|
+
this.emitDelay = options.emitDelay === undefined ? 100 : options.emitDelay;
|
|
343
|
+
this.streamTimeout = options.streamTimeout === undefined ? 95000 : options.streamTimeout;
|
|
344
|
+
|
|
345
|
+
this.document = null;
|
|
346
|
+
this.revision = 0;
|
|
347
|
+
this.stopped = true;
|
|
348
|
+
this.connected = false;
|
|
349
|
+
this.retry = 0;
|
|
350
|
+
this.unauthorizedLogged = false;
|
|
351
|
+
this.stream = null;
|
|
352
|
+
this.retryTimeout = null;
|
|
353
|
+
this.emitTimeout = null;
|
|
354
|
+
this.cycles = 0;
|
|
355
|
+
this.missedProbes = 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Snapshot, then the event stream. Errors are retried, never thrown.
|
|
360
|
+
*/
|
|
361
|
+
start() {
|
|
362
|
+
this.stopped = false;
|
|
363
|
+
this.retry = 0;
|
|
364
|
+
this._cycle();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Stops the stream and every pending timer.
|
|
369
|
+
*/
|
|
370
|
+
stop() {
|
|
371
|
+
this.stopped = true;
|
|
372
|
+
clearTimeout(this.retryTimeout);
|
|
373
|
+
clearTimeout(this.emitTimeout);
|
|
374
|
+
this.retryTimeout = null;
|
|
375
|
+
this.emitTimeout = null;
|
|
376
|
+
if (this.stream) {
|
|
377
|
+
this.stream.destroy();
|
|
378
|
+
this.stream = null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
this._setConnected(false);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* @param {boolean} connected
|
|
386
|
+
*/
|
|
387
|
+
_setConnected(connected) {
|
|
388
|
+
if (this.connected !== connected) {
|
|
389
|
+
this.connected = connected;
|
|
390
|
+
this.onStatus(connected);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Schedule the next attempt with a bounded exponential backoff.
|
|
396
|
+
*/
|
|
397
|
+
_scheduleRetry() {
|
|
398
|
+
if (this.stopped || this.retryTimeout) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const delay = Math.min(this.retryMin * 2 ** this.retry, this.retryMax);
|
|
403
|
+
this.retry += 1;
|
|
404
|
+
this.logger.debug('meta api retry in ' + delay + ' ms');
|
|
405
|
+
this.retryTimeout = setTimeout(() => {
|
|
406
|
+
this.retryTimeout = null;
|
|
407
|
+
this._cycle();
|
|
408
|
+
}, delay);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* One full attempt: snapshot, then follow the stream.
|
|
413
|
+
*/
|
|
414
|
+
async _cycle() {
|
|
415
|
+
if (this.stopped) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// the version probe again on every reconnect, as at startup: it needs no
|
|
420
|
+
// credential, and it is how a box that is no longer an openccu-lite is
|
|
421
|
+
// noticed without a Node-RED restart
|
|
422
|
+
if (this.cycles > 0) {
|
|
423
|
+
const info = await detect({
|
|
424
|
+
host: this.host,
|
|
425
|
+
port: this.port,
|
|
426
|
+
tls: this.tls,
|
|
427
|
+
insecure: this.insecure,
|
|
428
|
+
logger: this.logger,
|
|
429
|
+
});
|
|
430
|
+
if (info) {
|
|
431
|
+
this.missedProbes = 0;
|
|
432
|
+
} else {
|
|
433
|
+
this.missedProbes += 1;
|
|
434
|
+
if (this.missedProbes >= this.goneAfter) {
|
|
435
|
+
this.logger.warn(
|
|
436
|
+
'the box stopped answering the openccu-lite metadata api (' +
|
|
437
|
+
this.missedProbes +
|
|
438
|
+
' probes) - falling back to detection from scratch',
|
|
439
|
+
);
|
|
440
|
+
this.stop();
|
|
441
|
+
this.onGone();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
this.cycles += 1;
|
|
448
|
+
const ok = await this._loadSnapshot();
|
|
449
|
+
if (!ok) {
|
|
450
|
+
this._setConnected(false);
|
|
451
|
+
this._scheduleRetry();
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
this.retry = 0;
|
|
456
|
+
this._openStream();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* `GET /snapshot`. A 401/403 degrades to "no names": logged once, retried
|
|
461
|
+
* on the next cycle, never fatal.
|
|
462
|
+
* @returns {Promise<boolean>}
|
|
463
|
+
*/
|
|
464
|
+
async _loadSnapshot() {
|
|
465
|
+
try {
|
|
466
|
+
const {status, res} = await request({
|
|
467
|
+
host: this.host,
|
|
468
|
+
port: this.port,
|
|
469
|
+
tls: this.tls,
|
|
470
|
+
insecure: this.insecure,
|
|
471
|
+
path: API_PATH + '/snapshot',
|
|
472
|
+
token: this.token,
|
|
473
|
+
timeout: this.requestTimeout,
|
|
474
|
+
});
|
|
475
|
+
const body = await readBody(res);
|
|
476
|
+
if (status === 401 || status === 403) {
|
|
477
|
+
if (!this.unauthorizedLogged) {
|
|
478
|
+
this.unauthorizedLogged = true;
|
|
479
|
+
this.logger.error(
|
|
480
|
+
'openccu-lite metadata api rejected the credential (' +
|
|
481
|
+
status +
|
|
482
|
+
'). Running without names - configure a token created on the box (Users page) in the ccu-connection node.',
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (status !== 200) {
|
|
490
|
+
this.logger.warn('meta api snapshot: status ' + status);
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const document = JSON.parse(body);
|
|
495
|
+
this.unauthorizedLogged = false;
|
|
496
|
+
this._applyDocument(document);
|
|
497
|
+
this._setConnected(true);
|
|
498
|
+
return true;
|
|
499
|
+
} catch (error) {
|
|
500
|
+
this.logger.warn('meta api snapshot: ' + error.message);
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* @param {object} document
|
|
507
|
+
*/
|
|
508
|
+
_applyDocument(document) {
|
|
509
|
+
this.document = document;
|
|
510
|
+
this.revision = Number(document.revision) || 0;
|
|
511
|
+
this._emit();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Rebuild the derived maps and hand them to the connection node.
|
|
516
|
+
*/
|
|
517
|
+
_emit() {
|
|
518
|
+
clearTimeout(this.emitTimeout);
|
|
519
|
+
this.emitTimeout = null;
|
|
520
|
+
const names = buildNames(this.document);
|
|
521
|
+
names.revision = this.revision;
|
|
522
|
+
this.onNames(names);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Coalesce a burst of events into one update.
|
|
527
|
+
*/
|
|
528
|
+
_scheduleEmit() {
|
|
529
|
+
if (this.emitTimeout) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
this.emitTimeout = setTimeout(() => {
|
|
534
|
+
this.emitTimeout = null;
|
|
535
|
+
if (!this.stopped) {
|
|
536
|
+
this._emit();
|
|
537
|
+
}
|
|
538
|
+
}, this.emitDelay);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Follow `GET /events/sse?since=<revision>`.
|
|
543
|
+
*/
|
|
544
|
+
_openStream() {
|
|
545
|
+
if (this.stopped) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
request({
|
|
550
|
+
host: this.host,
|
|
551
|
+
port: this.port,
|
|
552
|
+
tls: this.tls,
|
|
553
|
+
insecure: this.insecure,
|
|
554
|
+
path: API_PATH + '/events/sse?since=' + this.revision,
|
|
555
|
+
token: this.token,
|
|
556
|
+
accept: 'text/event-stream',
|
|
557
|
+
timeout: this.requestTimeout,
|
|
558
|
+
idleTimeout: this.streamTimeout,
|
|
559
|
+
})
|
|
560
|
+
.then(({status, res}) => {
|
|
561
|
+
if (this.stopped) {
|
|
562
|
+
res.destroy();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (status !== 200) {
|
|
567
|
+
res.resume();
|
|
568
|
+
this.logger.warn('meta api event stream: status ' + status);
|
|
569
|
+
this._setConnected(false);
|
|
570
|
+
this._scheduleRetry();
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this.logger.debug('openccu-lite metadata event stream open (revision ' + this.revision + ')');
|
|
575
|
+
this.stream = res;
|
|
576
|
+
|
|
577
|
+
let buffer = '';
|
|
578
|
+
res.setEncoding('utf8');
|
|
579
|
+
res.on('data', (chunk) => {
|
|
580
|
+
buffer += chunk;
|
|
581
|
+
let index = buffer.indexOf('\n\n');
|
|
582
|
+
while (index !== -1) {
|
|
583
|
+
const frame = buffer.slice(0, index);
|
|
584
|
+
buffer = buffer.slice(index + 2);
|
|
585
|
+
this._onFrame(frame);
|
|
586
|
+
index = buffer.indexOf('\n\n');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (buffer.length > 1_000_000) {
|
|
590
|
+
buffer = '';
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
const done = () => {
|
|
594
|
+
if (this.stream === res) {
|
|
595
|
+
this.stream = null;
|
|
596
|
+
this._scheduleRetry();
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
res.on('end', done);
|
|
601
|
+
res.on('close', done);
|
|
602
|
+
res.on('error', (error) => {
|
|
603
|
+
this.logger.debug('meta api event stream: ' + error.message);
|
|
604
|
+
done();
|
|
605
|
+
});
|
|
606
|
+
})
|
|
607
|
+
.catch((error) => {
|
|
608
|
+
this.logger.warn('meta api event stream: ' + error.message);
|
|
609
|
+
this._setConnected(false);
|
|
610
|
+
this._scheduleRetry();
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* One SSE frame. Comments (the 30 s heartbeat) carry no data line.
|
|
616
|
+
* @param {string} frame
|
|
617
|
+
*/
|
|
618
|
+
_onFrame(frame) {
|
|
619
|
+
const data = frame
|
|
620
|
+
.split('\n')
|
|
621
|
+
.filter((line) => line.startsWith('data:'))
|
|
622
|
+
.map((line) => line.slice(5).trim())
|
|
623
|
+
.join('\n');
|
|
624
|
+
if (!data) {
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
let event;
|
|
629
|
+
try {
|
|
630
|
+
event = JSON.parse(data);
|
|
631
|
+
} catch {
|
|
632
|
+
this.logger.debug('meta api event stream: unparsable frame');
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
this._onEvent(event);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Apply one change event. Object changes are applied in place; anything
|
|
641
|
+
* that can rewrite member paths (enum and node events, an import, a gap in
|
|
642
|
+
* the revisions, an explicit resync) re-reads the snapshot.
|
|
643
|
+
* @param {object} event
|
|
644
|
+
*/
|
|
645
|
+
_onEvent(event) {
|
|
646
|
+
if (!event || typeof event !== 'object') {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const revision = Number(event.revision) || 0;
|
|
651
|
+
const gap = revision > 0 && this.revision > 0 && revision > this.revision + 1;
|
|
652
|
+
|
|
653
|
+
if (event.kind === 'resync' || event.kind === 'import' || gap) {
|
|
654
|
+
this.logger.debug('meta api resnapshot (' + (gap ? 'revision gap' : event.kind) + ')');
|
|
655
|
+
this._loadSnapshot().catch((error) => this.logger.warn('meta api snapshot: ' + error.message));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (revision > this.revision) {
|
|
660
|
+
this.revision = revision;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (!this.document) {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
switch (event.kind) {
|
|
668
|
+
case 'object.updated': {
|
|
669
|
+
if (typeof event.ref === 'string' && event.value) {
|
|
670
|
+
this.document.objects = this.document.objects || {};
|
|
671
|
+
this.document.objects[event.ref] = event.value;
|
|
672
|
+
this._scheduleEmit();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
case 'object.deleted': {
|
|
679
|
+
if (typeof event.ref === 'string' && this.document.objects) {
|
|
680
|
+
delete this.document.objects[event.ref];
|
|
681
|
+
this._scheduleEmit();
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
case 'enum.created':
|
|
688
|
+
case 'enum.updated':
|
|
689
|
+
case 'enum.deleted':
|
|
690
|
+
case 'node.created':
|
|
691
|
+
case 'node.updated':
|
|
692
|
+
case 'node.deleted':
|
|
693
|
+
case 'node.moved': {
|
|
694
|
+
// rooms and functions changed shape - member paths may have been
|
|
695
|
+
// rewritten in the same revision, so take a fresh snapshot
|
|
696
|
+
this._loadSnapshot().catch((error) => this.logger.warn('meta api snapshot: ' + error.message));
|
|
697
|
+
break;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
default:
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
module.exports = {
|
|
706
|
+
API_PATH,
|
|
707
|
+
LOCAL_TOKEN_FILE,
|
|
708
|
+
MetaProvider,
|
|
709
|
+
buildNames,
|
|
710
|
+
detect,
|
|
711
|
+
flattenEnum,
|
|
712
|
+
memberNames,
|
|
713
|
+
readLocalToken,
|
|
714
|
+
refToAddress,
|
|
715
|
+
refToIface,
|
|
716
|
+
};
|