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