hm2mqtt 3.5.1 → 3.6.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/lib/meta.js ADDED
@@ -0,0 +1,724 @@
1
+ /**
2
+ * The openccu-lite side: device and channel names, rooms and functions from the metadata API of a
3
+ * box that has no ReGaHSS (`GET /api/meta/v1/...`, served by occulited through lighttpd).
4
+ *
5
+ * Same public surface as lib/rega.js's `RegaSync` — `channelName()`, `channelAddress()`,
6
+ * `rooms()`, `functions()`, `syncNames()`, the `names` event — so index.js and everything below it
7
+ * cannot tell the two apart and the published payloads do not change. What ReGa had and this box
8
+ * has not (system variables, programs, HM-Script, the value cache) is empty here: `sysvars` and
9
+ * `programs` stay `{}` and `hasVariable()`/`hasProgram()` answer false, which is what makes the
10
+ * variable and program topics silently absent instead of failing.
11
+ *
12
+ * The startup call is `/snapshot`; everything after it comes from `/events/sse`, so a rename in the
13
+ * box's UI arrives within a second without polling. Node 20 has no `EventSource` and the package
14
+ * takes no new dependency for one: the stream is node:http, which also gives us the socket timeout
15
+ * (the box sends an SSE heartbeat every 30 s) and `--ccu-insecure` for TLS.
16
+ */
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import http from 'node:http';
21
+ import https from 'node:https';
22
+ import {EventEmitter} from 'node:events';
23
+ import {indexNames, resolveAddress} from './names.js';
24
+
25
+ /** everything the metadata API serves lives below this path */
26
+ export const API_PATH = '/api/meta/v1';
27
+ /** the box's own read-only credential, root-readable, for programs running on it */
28
+ export const LOCAL_TOKEN_FILE = '/usr/local/etc/occulite/local-token';
29
+
30
+ const DETECT_TIMEOUT_MS = 3000;
31
+ const REQUEST_TIMEOUT_MS = 30000;
32
+ /** the box sends an SSE comment every 30 s; three missed heartbeats are a dead connection */
33
+ const STREAM_IDLE_MS = 95000;
34
+ const RECONNECT_MIN_MS = 2000;
35
+ const RECONNECT_MAX_MS = 300000;
36
+ /** a burst of events (a bulk edit, an import) becomes one snapshot read */
37
+ const COALESCE_MS = 250;
38
+ const MAX_BODY = 64 * 1024 * 1024;
39
+
40
+ /**
41
+ * @param {string} url
42
+ * @param {object} [options]
43
+ * @returns {Promise<import('node:http').IncomingMessage>}
44
+ */
45
+ function requestStream(url, {headers = {}, insecure = false, timeout = 0, signal} = {}) {
46
+ return new Promise((resolve, reject) => {
47
+ let target;
48
+ try {
49
+ target = new URL(url);
50
+ } catch {
51
+ reject(new Error(`invalid url: ${url}`));
52
+ return;
53
+ }
54
+ const client = target.protocol === 'https:' ? https : http;
55
+ const request = client.request(
56
+ target,
57
+ {method: 'GET', headers, signal, ...(insecure ? {rejectUnauthorized: false} : {})},
58
+ resolve,
59
+ );
60
+ if (timeout > 0) {
61
+ request.setTimeout(timeout, () => request.destroy(new Error(`no answer for ${timeout} ms`)));
62
+ }
63
+ request.on('error', reject);
64
+ request.end();
65
+ });
66
+ }
67
+
68
+ function readBody(res) {
69
+ return new Promise((resolve, reject) => {
70
+ let body = '';
71
+ res.setEncoding('utf8');
72
+ res.on('data', (chunk) => {
73
+ body += chunk;
74
+ if (body.length > MAX_BODY) {
75
+ res.destroy(new Error('answer too large'));
76
+ }
77
+ });
78
+ res.on('end', () => resolve(body));
79
+ res.on('error', reject);
80
+ });
81
+ }
82
+
83
+ /** GET a JSON document; a non-200 becomes an Error carrying `status` and the API's error code. */
84
+ async function getJson(url, options = {}) {
85
+ const res = await requestStream(url, {timeout: REQUEST_TIMEOUT_MS, ...options});
86
+ const body = await readBody(res);
87
+ if (res.statusCode !== 200) {
88
+ let code = '';
89
+ try {
90
+ code = JSON.parse(body).error || '';
91
+ } catch {
92
+ // a proxy or a CCU answering HTML: the status is all we have
93
+ }
94
+ const error = new Error(`${url} answered ${res.statusCode}${code ? ' ' + code : ''}`);
95
+ error.status = res.statusCode;
96
+ error.code = code;
97
+ throw error;
98
+ }
99
+ return JSON.parse(body);
100
+ }
101
+
102
+ /**
103
+ * Feature detection (the one call that needs no credential): openccu-lite answers the version
104
+ * document here, a CCU/RaspberryMatic/OpenCCU answers 404 or HTML.
105
+ * @param {string} baseUrl e.g. `http://ccu`
106
+ * @returns {Promise<{ok: boolean, reachable: boolean, version?: object, reason?: string}>}
107
+ */
108
+ export async function detectMeta(baseUrl, {insecure = false, timeout = DETECT_TIMEOUT_MS} = {}) {
109
+ try {
110
+ const doc = await getJson(`${baseUrl}${API_PATH}/version`, {insecure, timeout});
111
+ if (doc && doc.api === 'meta' && Number(doc.version) >= 1) {
112
+ return {ok: true, reachable: true, version: doc};
113
+ }
114
+ return {ok: false, reachable: true, reason: 'not the metadata API'};
115
+ } catch (error) {
116
+ // an answer we could not parse still proves that something is listening - that is a CCU,
117
+ // and re-probing it forever would be pointless; a connection error is not.
118
+ const reachable = typeof error.status === 'number' || error instanceof SyntaxError;
119
+ return {ok: false, reachable, reason: error.message};
120
+ }
121
+ }
122
+
123
+ /** The box's own token, when we are running on the box. */
124
+ export function readLocalToken(file = LOCAL_TOKEN_FILE) {
125
+ try {
126
+ const token = fs.readFileSync(file, 'utf8').trim();
127
+ return token || undefined;
128
+ } catch {
129
+ return undefined;
130
+ }
131
+ }
132
+
133
+ /** `<enum>/<id>/<id>` -> the name of the node it points at, for every node of every enum. */
134
+ export function enumLabels(enums) {
135
+ const labels = new Map();
136
+ const walk = (nodes, prefix) => {
137
+ for (const node of nodes || []) {
138
+ if (!node || typeof node.id !== 'string') {
139
+ continue;
140
+ }
141
+ const nodePath = `${prefix}/${node.id}`;
142
+ labels.set(nodePath, typeof node.name === 'string' && node.name !== '' ? node.name : node.id);
143
+ walk(node.children, nodePath);
144
+ }
145
+ };
146
+ for (const [id, def] of Object.entries(enums || {})) {
147
+ walk(def && def.tree, id);
148
+ }
149
+ return labels;
150
+ }
151
+
152
+ /** The address of a ref: `HmIP-RF.0001D3C99C7D4B:3` -> `0001D3C99C7D4B:3`. */
153
+ export function addressOf(ref) {
154
+ const dot = typeof ref === 'string' ? ref.indexOf('.') : -1;
155
+ return dot > 0 ? ref.slice(dot + 1) : '';
156
+ }
157
+
158
+ /**
159
+ * The metadata document as hm2mqtt sees it: names by address, and rooms and functions as arrays of
160
+ * names — the shape ReGa's `getRooms()`/`getFunctions()` produced, so nothing downstream changes.
161
+ *
162
+ * Rooms and functions are trees here (`room/eg/wohnzimmer`) and a member of a node is implicitly a
163
+ * member of its parents. Only the node the object is actually in is named, not its ancestors: that
164
+ * is what a CCU would have reported, and `${room}` in a topic template must not suddenly become
165
+ * two levels (H-48).
166
+ */
167
+ export function deriveNames(objects, enums) {
168
+ const labels = enumLabels(enums);
169
+ const channelNames = {};
170
+ const channelRooms = {};
171
+ const channelFunctions = {};
172
+ for (const [ref, object] of Object.entries(objects || {})) {
173
+ if (!object || typeof object !== 'object') {
174
+ continue;
175
+ }
176
+ const address = addressOf(ref);
177
+ if (!address) {
178
+ continue;
179
+ }
180
+ if (typeof object.name === 'string' && object.name !== '') {
181
+ channelNames[address] = object.name;
182
+ }
183
+ for (const nodePath of object.enums || []) {
184
+ const name = labels.get(nodePath);
185
+ if (!name) {
186
+ continue;
187
+ }
188
+ const target = nodePath.startsWith('room/')
189
+ ? channelRooms
190
+ : nodePath.startsWith('function/')
191
+ ? channelFunctions
192
+ : null;
193
+ if (!target) {
194
+ // floor and whatever else the user created: hm2mqtt has no field for them
195
+ continue;
196
+ }
197
+ const list = (target[address] = target[address] || []);
198
+ if (!list.includes(name)) {
199
+ list.push(name);
200
+ }
201
+ }
202
+ }
203
+ return {channelNames, channelRooms, channelFunctions};
204
+ }
205
+
206
+ export class MetaSync extends EventEmitter {
207
+ /**
208
+ * @param {object} o
209
+ * @param {string} o.url base url of the box, e.g. `http://ccu` (no path)
210
+ * @param {string} [o.token] API token (`olt_…`) or session id; default: the box's local token
211
+ * @param {string} [o.tokenFile] where the local token lives
212
+ * @param {boolean} [o.insecure] accept a self-signed certificate (--ccu-insecure)
213
+ * @param {boolean} [o.fixedUrl] the url was configured explicitly and must not follow the ccu ip
214
+ * @param {object} [o.metadata] Metadata (to tell an address from a name on set topics)
215
+ * @param {string} [o.stateDir]
216
+ * @param {Object<string, string>} [o.nameFile] {address: name} overriding the box's names
217
+ * @param {object} o.log
218
+ */
219
+ constructor({
220
+ url,
221
+ token,
222
+ tokenFile = LOCAL_TOKEN_FILE,
223
+ insecure = false,
224
+ fixedUrl = false,
225
+ metadata,
226
+ stateDir,
227
+ nameFile,
228
+ log,
229
+ }) {
230
+ super();
231
+ this.label = 'occulite';
232
+ this.url = String(url || '').replace(/\/+$/, '');
233
+ this.tokenFile = tokenFile;
234
+ this.token = token || readLocalToken(tokenFile);
235
+ this.insecure = insecure;
236
+ this.fixedUrl = fixedUrl;
237
+ this.metadata = metadata;
238
+ this.stateDir = stateDir;
239
+ this.nameFile = nameFile || {};
240
+ this.log = log;
241
+ /** the store as served, kept so a restart without the box still has the names */
242
+ this.objects = {};
243
+ this.enums = {};
244
+ this.revision = 0;
245
+ this.channelNames = {};
246
+ this.addresses = {};
247
+ this.channelRooms = {};
248
+ this.channelFunctions = {};
249
+ /** ReGa concepts without a counterpart here; kept so the lookups below stay valid */
250
+ this.sysvars = {};
251
+ this.programs = {};
252
+ this.synced = false;
253
+ this.stopped = false;
254
+ this.warnedAuth = false;
255
+ this.backoff = RECONNECT_MIN_MS;
256
+ this.streamRes = null;
257
+ this.streamPending = false;
258
+ this.streamAbort = null;
259
+ this.streamTimer = null;
260
+ this.refreshTimer = null;
261
+ this.applyTimer = null;
262
+ this.refreshing = false;
263
+ this.refreshAgain = false;
264
+ }
265
+
266
+ /*
267
+ * persistence — same place and purpose as rega.json, one file further so switching a
268
+ * configuration between a CCU and openccu-lite does not overwrite the other one's cache
269
+ */
270
+
271
+ file() {
272
+ return this.stateDir ? path.join(this.stateDir, 'meta.json') : null;
273
+ }
274
+
275
+ load() {
276
+ const file = this.file();
277
+ if (!file) {
278
+ return;
279
+ }
280
+ try {
281
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
282
+ this.objects = data.objects || {};
283
+ this.enums = data.enums || {};
284
+ this.revision = Number(data.revision) || 0;
285
+ this.rebuild();
286
+ this.log.info('loaded', Object.keys(this.channelNames).length, 'names from', file);
287
+ } catch (err) {
288
+ if (err.code !== 'ENOENT') {
289
+ this.log.warn('cannot read', file, '-', err.message);
290
+ }
291
+ }
292
+ }
293
+
294
+ save() {
295
+ const file = this.file();
296
+ if (!file) {
297
+ return;
298
+ }
299
+ try {
300
+ fs.mkdirSync(this.stateDir, {recursive: true});
301
+ fs.writeFileSync(file, JSON.stringify({revision: this.revision, objects: this.objects, enums: this.enums}));
302
+ } catch (err) {
303
+ this.log.warn('cannot save', file, '-', err.message);
304
+ }
305
+ }
306
+
307
+ /*
308
+ * lookups — the surface index.js calls
309
+ */
310
+
311
+ channelName(address) {
312
+ return this.channelNames[address];
313
+ }
314
+
315
+ channelAddress(nameOrAddress, devices = false) {
316
+ return resolveAddress(this, nameOrAddress, devices);
317
+ }
318
+
319
+ rooms(address) {
320
+ return this.channelRooms[address];
321
+ }
322
+
323
+ functions(address) {
324
+ return this.channelFunctions[address];
325
+ }
326
+
327
+ hasVariable() {
328
+ return false;
329
+ }
330
+
331
+ hasProgram() {
332
+ return false;
333
+ }
334
+
335
+ /*
336
+ * the box
337
+ */
338
+
339
+ /** The CCU address is resolved once at start (H-20); follow it, unless a url was configured. */
340
+ setHost(ip) {
341
+ if (this.fixedUrl || !ip) {
342
+ return;
343
+ }
344
+ try {
345
+ const url = new URL(this.url);
346
+ url.hostname = ip;
347
+ this.url = url.toString().replace(/\/+$/, '');
348
+ } catch {
349
+ // an unparsable url stays as it is; the requests below will say so
350
+ }
351
+ }
352
+
353
+ headers(extra = {}) {
354
+ return {
355
+ accept: 'application/json',
356
+ ...(this.token ? {authorization: `Bearer ${this.token}`} : {}),
357
+ ...extra,
358
+ };
359
+ }
360
+
361
+ get(endpoint) {
362
+ return getJson(`${this.url}${API_PATH}${endpoint}`, {headers: this.headers(), insecure: this.insecure});
363
+ }
364
+
365
+ /*
366
+ * names, rooms, functions
367
+ */
368
+
369
+ /** Reads the snapshot and follows the change stream. Emits 'names'. */
370
+ async syncNames() {
371
+ this.stopped = false;
372
+ this.startStream();
373
+ await this.refresh({announce: true});
374
+ }
375
+
376
+ /** ReGa's variable/program poll has no counterpart here; the stream is the update path. */
377
+ async poll() {
378
+ this.startStream();
379
+ }
380
+
381
+ startPolling() {
382
+ this.startStream();
383
+ return Promise.resolve();
384
+ }
385
+
386
+ stopPolling() {
387
+ this.stopped = true;
388
+ for (const timer of [this.streamTimer, this.refreshTimer, this.applyTimer]) {
389
+ if (timer) {
390
+ clearTimeout(timer);
391
+ }
392
+ }
393
+ this.streamTimer = this.refreshTimer = this.applyTimer = null;
394
+ this.closeStream();
395
+ }
396
+
397
+ /** Fetches the snapshot and applies it. Throws once, and retries by itself afterwards. */
398
+ async refresh({announce = false} = {}) {
399
+ if (this.refreshTimer) {
400
+ clearTimeout(this.refreshTimer);
401
+ this.refreshTimer = null;
402
+ }
403
+ // one snapshot at a time: the stream and an event burst both ask for one
404
+ if (this.refreshing) {
405
+ this.refreshAgain = true;
406
+ return;
407
+ }
408
+ this.refreshing = true;
409
+ // scheduled in the finally, where `refreshing` is false again: a failure retries with the
410
+ // backoff, an event that overtook the snapshot as soon as the burst is over
411
+ let retry = null;
412
+ try {
413
+ let doc;
414
+ try {
415
+ doc = await this.get('/snapshot');
416
+ } catch (err) {
417
+ if (err.status === 401 || err.status === 403) {
418
+ this.denied(err);
419
+ return;
420
+ }
421
+ retry = this.backoff;
422
+ throw err;
423
+ }
424
+ this.accepted();
425
+ // events that arrived while the snapshot was in flight are newer than it
426
+ const overtaken = this.revision > (Number(doc.revision) || 0);
427
+ const changed = this.applySnapshot(doc);
428
+ this.save();
429
+ if (announce || changed) {
430
+ this.logSummary();
431
+ }
432
+ this.emit('names');
433
+ this.emit('polled');
434
+ if (overtaken) {
435
+ this.refreshAgain = true;
436
+ }
437
+ } finally {
438
+ this.refreshing = false;
439
+ if (this.refreshAgain || retry !== null) {
440
+ this.refreshAgain = false;
441
+ this.scheduleRefresh(retry === null ? COALESCE_MS : retry);
442
+ }
443
+ }
444
+ }
445
+
446
+ applySnapshot(doc) {
447
+ this.objects = (doc && doc.objects) || {};
448
+ this.enums = (doc && doc.enums) || {};
449
+ this.revision = Number(doc && doc.revision) || 0;
450
+ this.synced = true;
451
+ return this.rebuild();
452
+ }
453
+
454
+ /** Derives names, rooms and functions from the store; true when anything changed. */
455
+ rebuild() {
456
+ const before = JSON.stringify([this.channelNames, this.channelRooms, this.channelFunctions]);
457
+ const derived = deriveNames(this.objects, this.enums);
458
+ this.channelNames = derived.channelNames;
459
+ this.channelRooms = derived.channelRooms;
460
+ this.channelFunctions = derived.channelFunctions;
461
+ this.addresses = indexNames(this.channelNames, this.nameFile);
462
+ return JSON.stringify([this.channelNames, this.channelRooms, this.channelFunctions]) !== before;
463
+ }
464
+
465
+ logSummary() {
466
+ const labels = [...enumLabels(this.enums).keys()];
467
+ this.log.info(
468
+ 'occulite:',
469
+ Object.keys(this.channelNames).length,
470
+ 'names,',
471
+ labels.filter((p) => p.startsWith('room/')).length,
472
+ 'rooms,',
473
+ labels.filter((p) => p.startsWith('function/')).length,
474
+ 'functions (revision ' + this.revision + ')',
475
+ );
476
+ }
477
+
478
+ scheduleRefresh(delay = this.backoff) {
479
+ if (this.stopped || this.refreshTimer) {
480
+ return;
481
+ }
482
+ if (this.refreshing) {
483
+ this.refreshAgain = true;
484
+ return;
485
+ }
486
+ this.refreshTimer = setTimeout(() => {
487
+ this.refreshTimer = null;
488
+ this.refresh().catch((err) => this.log.debug('occulite: snapshot failed:', err.message));
489
+ }, delay);
490
+ }
491
+
492
+ /** Rebuild after object events, coalesced: a bulk edit is one 'names'. */
493
+ scheduleApply() {
494
+ if (this.stopped || this.applyTimer) {
495
+ return;
496
+ }
497
+ this.applyTimer = setTimeout(() => {
498
+ this.applyTimer = null;
499
+ if (this.rebuild()) {
500
+ this.save();
501
+ this.log.debug('occulite: names updated (revision', this.revision + ')');
502
+ this.emit('names');
503
+ }
504
+ }, 50);
505
+ }
506
+
507
+ /*
508
+ * the change stream
509
+ */
510
+
511
+ startStream() {
512
+ if (this.stopped || this.streamRes || this.streamPending || !this.url) {
513
+ return;
514
+ }
515
+ if (this.streamTimer) {
516
+ clearTimeout(this.streamTimer);
517
+ this.streamTimer = null;
518
+ }
519
+ this.streamPending = true;
520
+ // ?since replays what we missed; without a snapshot there is nothing to replay from
521
+ const since = this.synced && this.revision > 0 ? `?since=${this.revision}` : '';
522
+ const controller = new AbortController();
523
+ this.streamAbort = controller;
524
+ requestStream(`${this.url}${API_PATH}/events/sse${since}`, {
525
+ headers: this.headers({accept: 'text/event-stream'}),
526
+ insecure: this.insecure,
527
+ timeout: STREAM_IDLE_MS,
528
+ signal: controller.signal,
529
+ }).then(
530
+ (res) => this.onStream(res),
531
+ (err) => this.onStreamEnd(err),
532
+ );
533
+ }
534
+
535
+ onStream(res) {
536
+ this.streamPending = false;
537
+ if (this.stopped) {
538
+ res.destroy();
539
+ return;
540
+ }
541
+ if (res.statusCode !== 200) {
542
+ res.resume();
543
+ const error = new Error(`event stream answered ${res.statusCode}`);
544
+ error.status = res.statusCode;
545
+ if (res.statusCode === 401 || res.statusCode === 403) {
546
+ this.denied(error);
547
+ }
548
+ this.onStreamEnd(error);
549
+ return;
550
+ }
551
+ this.streamRes = res;
552
+ this.backoff = RECONNECT_MIN_MS;
553
+ this.accepted();
554
+ this.log.debug('occulite: event stream open');
555
+ this.emit('polled');
556
+ if (!this.synced) {
557
+ // the stream is up before the first snapshot: fetch it, unless syncNames() already is
558
+ this.scheduleRefresh(0);
559
+ }
560
+ let buffer = '';
561
+ res.setEncoding('utf8');
562
+ res.on('data', (chunk) => {
563
+ buffer += chunk;
564
+ let index;
565
+ while ((index = buffer.search(/\r?\n\r?\n/)) !== -1) {
566
+ const [separator] = buffer.slice(index).match(/^\r?\n\r?\n/);
567
+ const frame = buffer.slice(0, index);
568
+ buffer = buffer.slice(index + separator.length);
569
+ this.onFrame(frame);
570
+ }
571
+ if (buffer.length > MAX_BODY) {
572
+ buffer = '';
573
+ }
574
+ });
575
+ const end = (err) => {
576
+ if (this.streamRes === res) {
577
+ this.streamRes = null;
578
+ this.onStreamEnd(err);
579
+ }
580
+ };
581
+ res.on('end', () => end(null));
582
+ res.on('close', () => end(null));
583
+ res.on('error', end);
584
+ }
585
+
586
+ onStreamEnd(err) {
587
+ this.streamPending = false;
588
+ this.streamRes = null;
589
+ this.streamAbort = null;
590
+ if (this.stopped) {
591
+ return;
592
+ }
593
+ this.log.debug('occulite: event stream closed', err ? '(' + err.message + ')' : '');
594
+ this.emitError(err || new Error('event stream closed'));
595
+ if (this.streamTimer) {
596
+ return;
597
+ }
598
+ this.streamTimer = setTimeout(() => {
599
+ this.streamTimer = null;
600
+ this.startStream();
601
+ }, this.backoff);
602
+ this.backoff = Math.min(this.backoff * 2, RECONNECT_MAX_MS);
603
+ }
604
+
605
+ closeStream() {
606
+ if (this.streamAbort) {
607
+ this.streamAbort.abort();
608
+ this.streamAbort = null;
609
+ }
610
+ if (this.streamRes) {
611
+ this.streamRes.destroy();
612
+ this.streamRes = null;
613
+ }
614
+ this.streamPending = false;
615
+ }
616
+
617
+ /** One SSE frame: the `data:` lines are the event, a comment (heartbeat) has none. */
618
+ onFrame(frame) {
619
+ const data = frame
620
+ .split(/\r?\n/)
621
+ .filter((line) => line.startsWith('data:'))
622
+ .map((line) => line.slice(5).replace(/^ /, ''))
623
+ .join('\n');
624
+ if (data === '') {
625
+ return;
626
+ }
627
+ let event;
628
+ try {
629
+ event = JSON.parse(data);
630
+ } catch {
631
+ this.log.debug('occulite: unparsable event', data.slice(0, 200));
632
+ return;
633
+ }
634
+ this.onEvent(event);
635
+ }
636
+
637
+ onEvent(event) {
638
+ if (!event || typeof event !== 'object') {
639
+ return;
640
+ }
641
+ if (event.kind === 'resync') {
642
+ this.log.debug('occulite: resync requested by the box');
643
+ this.revision = Number(event.revision) || this.revision;
644
+ this.scheduleRefresh(0);
645
+ return;
646
+ }
647
+ const revision = Number(event.revision);
648
+ if (Number.isFinite(revision)) {
649
+ if (this.synced && this.revision > 0 && revision > this.revision + 1) {
650
+ this.log.debug('occulite: revision gap', this.revision, '->', revision);
651
+ this.revision = revision;
652
+ this.scheduleRefresh(0);
653
+ return;
654
+ }
655
+ this.revision = revision;
656
+ }
657
+ switch (event.kind) {
658
+ case 'object.updated':
659
+ if (event.ref && event.value) {
660
+ this.objects[event.ref] = event.value;
661
+ this.scheduleApply();
662
+ }
663
+ break;
664
+ case 'object.deleted':
665
+ if (event.ref) {
666
+ delete this.objects[event.ref];
667
+ this.scheduleApply();
668
+ }
669
+ break;
670
+ default:
671
+ // enum.*, node.* and import change the names of rooms or the paths of their
672
+ // members: the snapshot is the cheapest correct answer
673
+ this.scheduleRefresh(COALESCE_MS);
674
+ }
675
+ }
676
+
677
+ /*
678
+ * the credential
679
+ */
680
+
681
+ /** 'error' on an EventEmitter with no listener throws; the stream may start before index.js listens */
682
+ emitError(err) {
683
+ if (this.listenerCount('error') > 0) {
684
+ this.emit('error', err);
685
+ }
686
+ }
687
+
688
+ denied(err) {
689
+ this.emitError(err);
690
+ if (this.warnedAuth) {
691
+ return;
692
+ }
693
+ this.warnedAuth = true;
694
+ this.log.warn(
695
+ `occulite: the metadata API rejected the credential (${err.status}) - running without names, rooms and functions;`,
696
+ this.token
697
+ ? 'the token was revoked or belongs to another box - create a new one on the box under Benutzer and set --meta-token'
698
+ : `no token configured and none in ${this.tokenFile} - create an API token on the box under Benutzer and set --meta-token`,
699
+ );
700
+ }
701
+
702
+ accepted() {
703
+ if (this.warnedAuth) {
704
+ this.warnedAuth = false;
705
+ this.log.info('occulite: the metadata API accepts the credential again');
706
+ }
707
+ }
708
+
709
+ /*
710
+ * what this box does not have (kept so a misrouted set says why instead of throwing a TypeError)
711
+ */
712
+
713
+ async setVariable() {
714
+ throw new Error('system variables are not available on this box (no ReGaHSS)');
715
+ }
716
+
717
+ async programActive() {
718
+ throw new Error('programs are not available on this box (no ReGaHSS)');
719
+ }
720
+
721
+ async programExecute() {
722
+ throw new Error('programs are not available on this box (no ReGaHSS)');
723
+ }
724
+ }