node-red-contrib-ccu 4.4.4 → 4.5.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +35 -0
  3. package/nodes/ccu-alexa.html +12 -14
  4. package/nodes/ccu-connection.html +63 -95
  5. package/nodes/ccu-connection.js +812 -115
  6. package/nodes/ccu-get-value.html +18 -15
  7. package/nodes/ccu-get-value.js +16 -0
  8. package/nodes/ccu-homeassistant.html +0 -48
  9. package/nodes/ccu-mqtt.html +0 -8
  10. package/nodes/ccu-poll.html +0 -17
  11. package/nodes/ccu-program.html +0 -53
  12. package/nodes/ccu-rpc-event.html +0 -34
  13. package/nodes/ccu-rpc.html +0 -64
  14. package/nodes/ccu-script.html +0 -8
  15. package/nodes/ccu-set-value.html +0 -62
  16. package/nodes/ccu-set-value.js +6 -11
  17. package/nodes/ccu-signal.html +0 -64
  18. package/nodes/ccu-switch.html +12 -24
  19. package/nodes/ccu-sysvar.html +0 -64
  20. package/nodes/ccu-value.html +23 -95
  21. package/nodes/ccu-value.js +8 -2
  22. package/nodes/lib/initretry.js +46 -9
  23. package/nodes/lib/localccu.js +21 -3
  24. package/nodes/lib/metaprovider.js +47 -19
  25. package/nodes/lib/party.js +103 -0
  26. package/nodes/lib/topic.js +22 -1
  27. package/nodes/lib/transport.js +80 -0
  28. package/nodes/locales/de/ccu-alexa.html +4 -0
  29. package/nodes/locales/de/ccu-connection.html +103 -0
  30. package/nodes/locales/de/ccu-get-value.html +16 -0
  31. package/nodes/locales/de/ccu-homeassistant.html +23 -0
  32. package/nodes/locales/de/ccu-mqtt.html +21 -0
  33. package/nodes/locales/de/ccu-poll.html +8 -0
  34. package/nodes/locales/de/ccu-program.html +26 -0
  35. package/nodes/locales/de/ccu-rpc-event.html +32 -0
  36. package/nodes/locales/de/ccu-rpc.html +31 -0
  37. package/nodes/locales/de/ccu-script.html +13 -0
  38. package/nodes/locales/de/ccu-set-value.html +46 -0
  39. package/nodes/locales/de/ccu-signal.html +29 -0
  40. package/nodes/locales/de/ccu-switch.html +18 -0
  41. package/nodes/locales/de/ccu-sysvar.html +43 -0
  42. package/nodes/locales/de/ccu-value.html +98 -0
  43. package/nodes/locales/de/ccu-value.json +1 -0
  44. package/nodes/locales/en-US/ccu-alexa.html +4 -0
  45. package/nodes/locales/en-US/ccu-connection.html +41 -0
  46. package/nodes/locales/en-US/ccu-get-value.html +13 -0
  47. package/nodes/locales/en-US/ccu-homeassistant.html +23 -0
  48. package/nodes/locales/en-US/ccu-mqtt.html +21 -0
  49. package/nodes/locales/en-US/ccu-poll.html +7 -0
  50. package/nodes/locales/en-US/ccu-program.html +25 -0
  51. package/nodes/locales/en-US/ccu-rpc-event.html +31 -0
  52. package/nodes/locales/en-US/ccu-rpc.html +30 -0
  53. package/nodes/locales/en-US/ccu-script.html +13 -0
  54. package/nodes/locales/en-US/ccu-set-value.html +45 -0
  55. package/nodes/locales/en-US/ccu-signal.html +29 -0
  56. package/nodes/locales/en-US/ccu-switch.html +6 -0
  57. package/nodes/locales/en-US/ccu-sysvar.html +42 -0
  58. package/nodes/locales/en-US/ccu-value.html +81 -0
  59. package/nodes/locales/en-US/ccu-value.json +1 -0
  60. package/package.json +1 -1
@@ -7,15 +7,17 @@ const crypto = require('crypto');
7
7
  const base62 = require('./lib/base62.js').toBase62;
8
8
  const {castValue, castSysvar} = require('./lib/cast.js');
9
9
  const {combinedParameterValue} = require('./lib/combined.js');
10
+ const {isPartyWrite, mergePartyValues, PARTY_DATAPOINTS} = require('./lib/party.js');
10
11
  const {createMessage} = require('./lib/message.js');
11
12
  const {topicReplace} = require('./lib/topic.js');
12
13
  const {isLocalCcu} = require('./lib/localccu.js');
14
+ const {resolveTransport} = require('./lib/transport.js');
13
15
  const {bestMatch} = require('./lib/similarity.js');
14
16
  const nextport = require('./lib/nextport.js');
15
17
  const hmDiscover = require('./lib/discover.js');
16
18
  const {Rega} = require('homematic-rega'); // ES module - require(esm) needs Node >= 20.19 / >= 22.12
17
19
  const metaProvider = require('./lib/metaprovider.js');
18
- const {InitRetry} = require('./lib/initretry.js');
20
+ const {InitRetry, retryDelay} = require('./lib/initretry.js');
19
21
  const xmlrpc = require('homematic-xmlrpc');
20
22
  const binrpc = require('binrpc');
21
23
 
@@ -44,6 +46,24 @@ function execToCallback(promise, callback) {
44
46
  );
45
47
  }
46
48
 
49
+ /** B-28: how often an inconclusive metadata api detection is repeated (1, 2, 4, 8 s, then every 15 s) */
50
+ const META_REDETECT_ATTEMPTS = 20;
51
+
52
+ /**
53
+ * task 9: has a device description changed in a way that changes its paramsets?
54
+ * (the paramset key is TYPE/FIRMWARE/VERSION of the device and the channel TYPE)
55
+ * @param {object} known the cached description
56
+ * @param {object} device the one the process reports
57
+ * @returns {boolean}
58
+ */
59
+ function deviceChanged(known, device) {
60
+ return ['TYPE', 'VERSION', 'FIRMWARE', 'PARENT_TYPE'].some(
61
+ (key) =>
62
+ (known[key] === undefined ? '' : String(known[key])) !==
63
+ (device[key] === undefined ? '' : String(device[key])),
64
+ );
65
+ }
66
+
47
67
  function isIterable(object) {
48
68
  return object != null && typeof object[Symbol.iterator] === 'function' && typeof object.forEach === 'function';
49
69
  }
@@ -85,6 +105,42 @@ module.exports = function (RED) {
85
105
  });
86
106
  ccu.network.listen.push('0.0.0.0');
87
107
 
108
+ /**
109
+ * task 9: the editor's channel pickers ask while the connection may still be
110
+ * initialising - an empty answer then means "not yet", not "nothing". The
111
+ * header lets the dialog show "loading" and ask again instead of emptying
112
+ * the picker (and the configured channel with it).
113
+ * @param {object} config the connection node
114
+ * @param {string} [iface] the interface asked for, or every enabled one
115
+ * @returns {boolean}
116
+ */
117
+ function deviceDataLoading(config, iface) {
118
+ const ifaces = iface ? [iface] : Object.keys(config.ifaceTypes).filter((i) => config.ifaceTypes[i].enabled);
119
+ const connecting = ifaces.some(
120
+ (i) =>
121
+ config.ifaceTypes[i] &&
122
+ config.ifaceTypes[i].init &&
123
+ config.ifaceTypes[i].enabled &&
124
+ !config.ifaceStatus[i],
125
+ );
126
+ const fetching = Boolean(config.paramsetPending) || (config.paramsetQueue && config.paramsetQueue.length > 0);
127
+ return connecting || fetching;
128
+ }
129
+
130
+ // task 9: "re-read device data" from the connection node's dialog
131
+ RED.httpAdmin.post('/ccu/:id/reread', RED.auth.needsPermission('ccu.write'), (request, res) => {
132
+ const config = RED.nodes.getNode(request.params.id);
133
+ if (!config || typeof config.rereadDevices !== 'function') {
134
+ res.status(404).send(JSON.stringify({error: 'connection not deployed'}));
135
+ return;
136
+ }
137
+
138
+ config
139
+ .rereadDevices()
140
+ .then((summary) => res.status(200).send(JSON.stringify(summary)))
141
+ .catch((error) => res.status(500).send(JSON.stringify({error: error.message})));
142
+ });
143
+
88
144
  RED.httpAdmin.get('/ccu', RED.auth.needsPermission('ccu.read'), (request, res) => {
89
145
  if (request.query.config && request.query.config !== '_ADD_') {
90
146
  const config = RED.nodes.getNode(request.query.config);
@@ -95,6 +151,10 @@ module.exports = function (RED) {
95
151
 
96
152
  const object = {};
97
153
 
154
+ if (['channels', 'tree'].includes(request.query.type) && deviceDataLoading(config, request.query.iface)) {
155
+ res.set('X-CCU-Loading', '1');
156
+ }
157
+
98
158
  switch (request.query.type) {
99
159
  case 'ifaces': {
100
160
  Object.keys(config.ifaceTypes).forEach((iface) => {
@@ -407,6 +467,13 @@ module.exports = function (RED) {
407
467
  constructor(config) {
408
468
  RED.nodes.createNode(this, config);
409
469
 
470
+ // B-31: the CCU password and the openccu-lite token come from Node-RED's
471
+ // credentials. A flow written by an older version still carries them as
472
+ // plain properties: use them once and move them into the credentials, so
473
+ // the next deploy drops them from flows.json.
474
+ const secrets = this.migrateSecrets(config);
475
+ config = {...config, password: secrets.password, metaToken: secrets.metaToken};
476
+
410
477
  this.logger.debug('ccu-connection', config.host);
411
478
 
412
479
  this.checkDuplicateConfig(config);
@@ -420,6 +487,21 @@ module.exports = function (RED) {
420
487
  this.logger.info('local connection on ccu >= v3.41 detected');
421
488
  }
422
489
 
490
+ // task 5 (#27): TLS and credentials belong to the CCU's web server, which
491
+ // fronts the interface processes for the LAN; a local connection reaches
492
+ // them directly and uses neither, BIN-RPC has neither (lib/transport.js)
493
+ const transport = resolveTransport({
494
+ isLocal: this.isLocal,
495
+ tls: config.tls,
496
+ authentication: config.authentication,
497
+ inSecure: config.inSecure,
498
+ bcrfBinRpc: config.bcrfBinRpc,
499
+ });
500
+ transport.notes.forEach((note) => this.logger[note.level](note.message));
501
+ const {tls, auth, inSecure} = transport;
502
+ const user = auth ? config.username : undefined;
503
+ const pass = auth ? config.password : undefined;
504
+
423
505
  this.ifaceTypes = {
424
506
  ReGaHSS: {
425
507
  conf: 'rega',
@@ -431,55 +513,59 @@ module.exports = function (RED) {
431
513
  },
432
514
  'BidCos-RF': {
433
515
  conf: 'bcrf',
434
- rpc: this.isLocal || config.bcrfBinRpc ? binrpc : xmlrpc,
435
- port: this.isLocal ? 32001 : config.tls ? 42001 : 2001,
436
- protocol: this.isLocal || config.bcrfBinRpc ? 'binrpc' : 'http',
437
- auth: config.authentication,
438
- user: config.username,
439
- pass: config.password,
440
- tls: config.tls,
441
- inSecure: config.inSecure,
516
+ rpc: this.isLocal || transport.bcrfBinRpc ? binrpc : xmlrpc,
517
+ port: this.isLocal ? 32001 : tls ? 42001 : 2001,
518
+ protocol: this.isLocal || transport.bcrfBinRpc ? 'binrpc' : 'http',
519
+ auth,
520
+ user,
521
+ pass,
522
+ tls,
523
+ inSecure,
442
524
  init: true,
443
525
  ping: true,
444
526
  },
445
527
  'BidCos-Wired': {
446
528
  conf: 'bcwi',
447
529
  rpc: this.isLocal ? binrpc : xmlrpc,
448
- port: this.isLocal ? 32000 : config.tls ? 42000 : 2000,
530
+ port: this.isLocal ? 32000 : tls ? 42000 : 2000,
449
531
  protocol: this.isLocal ? 'binrpc' : 'http',
450
- auth: config.authentication,
451
- user: config.username,
452
- pass: config.password,
453
- tls: config.tls,
454
- inSecure: config.inSecure,
532
+ auth,
533
+ user,
534
+ pass,
535
+ tls,
536
+ inSecure,
455
537
  init: true,
456
538
  ping: true,
457
539
  },
458
540
  'HmIP-RF': {
459
541
  conf: 'iprf',
460
542
  rpc: xmlrpc,
461
- port: this.isLocal ? 32010 : config.tls ? 42010 : 2010,
543
+ port: this.isLocal ? 32010 : tls ? 42010 : 2010,
462
544
  protocol: 'http',
463
- auth: config.authentication,
464
- user: config.username,
465
- pass: config.password,
466
- tls: config.tls,
467
- inSecure: config.inSecure,
545
+ auth,
546
+ user,
547
+ pass,
548
+ tls,
549
+ inSecure,
468
550
  init: true,
469
551
  ping: true, // Todo https://github.com/eq-3/occu/issues/42 - should be fixed, but isn't
470
552
  pingTimeout: 600, // Overwrites ccu-connection config
553
+ // B-29: hmipserver forgets its clients when it restarts and does not tell them;
554
+ // a ping every 30 s without an event, and init again when no PONG comes within 10 s
555
+ pingInterval: 30,
556
+ pongTimeout: 10,
471
557
  },
472
558
  VirtualDevices: {
473
559
  conf: 'virt',
474
560
  rpc: xmlrpc,
475
- port: this.isLocal ? 39292 : config.tls ? 49292 : 9292,
561
+ port: this.isLocal ? 39292 : tls ? 49292 : 9292,
476
562
  path: 'groups',
477
563
  protocol: 'http',
478
- auth: config.authentication,
479
- user: config.username,
480
- pass: config.password,
481
- tls: config.tls,
482
- inSecure: config.inSecure,
564
+ auth,
565
+ user,
566
+ pass,
567
+ tls,
568
+ inSecure,
483
569
  init: true,
484
570
  ping: false, // Todo ?
485
571
  },
@@ -494,14 +580,14 @@ module.exports = function (RED) {
494
580
  'CCU-Jack': {
495
581
  conf: 'jack',
496
582
  rpc: xmlrpc,
497
- port: Number(config.jackPort) || (config.tls ? 2122 : 2121),
583
+ port: Number(config.jackPort) || (tls ? 2122 : 2121),
498
584
  path: 'RPC3',
499
585
  protocol: 'http',
500
- auth: config.authentication,
501
- user: config.username,
502
- pass: config.password,
503
- tls: config.tls,
504
- inSecure: config.inSecure,
586
+ auth,
587
+ user,
588
+ pass,
589
+ tls,
590
+ inSecure,
505
591
  init: true,
506
592
  ping: false,
507
593
  },
@@ -536,6 +622,9 @@ module.exports = function (RED) {
536
622
  // reconnecting after a CCU restart is then up to the CCU.
537
623
  this.rpcPingEnabled = config.rpcPing === undefined ? true : Boolean(config.rpcPing);
538
624
  this.rpcPingTimer = {};
625
+ // B-29: the liveness ping per interface, and when an event (PONG included) last arrived
626
+ this.liveness = {};
627
+ this.lastRealEvent = {};
539
628
  this.ifaceStatus = {};
540
629
  // task 11: interfaces whose init failed and is retried (InitRetry per iface),
541
630
  // and which of them are waiting for an unreachable process
@@ -553,9 +642,9 @@ module.exports = function (RED) {
553
642
  // B-17 openccu-lite: the metadata api sits behind the box's
554
643
  // lighttpd, i.e. on the plain http(s) port. The field exists for
555
644
  // reverse proxies and tests, it is empty in every normal install.
556
- this.tlsEnabled = Boolean(config.tls);
557
- this.inSecure = Boolean(config.inSecure);
558
- this.metaPort = Number.parseInt(config.metaPort, 10) || (config.tls ? 443 : 80);
645
+ this.tlsEnabled = tls;
646
+ this.inSecure = inSecure;
647
+ this.metaPort = Number.parseInt(config.metaPort, 10) || (tls ? 443 : 80);
559
648
  this.metaToken = config.metaToken || metaProvider.readLocalToken();
560
649
  this.metaMode = false;
561
650
  this.meta = null;
@@ -612,6 +701,9 @@ module.exports = function (RED) {
612
701
  this.setValueThrottle = 500;
613
702
  this.setValueTimers = {};
614
703
  this.setValueCache = {};
704
+ // B-19: the value this connection last wrote to a datapoint, so the queued
705
+ // write's dedupe can tell the echo of its own write from another source
706
+ this.lastWrite = {};
615
707
  this.setValueQueue = [];
616
708
 
617
709
  this.lastEvent = {};
@@ -639,11 +731,11 @@ module.exports = function (RED) {
639
731
 
640
732
  this.rega = new Rega({
641
733
  host: this.host,
642
- port: this.isLocal ? 8183 : config.tls ? 48181 : 8181,
643
- tls: config.tls,
644
- insecure: config.inSecure,
645
- username: config.authentication ? config.username : undefined,
646
- password: config.authentication ? config.password : undefined,
734
+ port: this.isLocal ? 8183 : tls ? 48181 : 8181,
735
+ tls,
736
+ insecure: inSecure,
737
+ username: user,
738
+ password: pass,
647
739
  });
648
740
 
649
741
  this.enabledIfaces = [];
@@ -661,12 +753,15 @@ module.exports = function (RED) {
661
753
  // metadata api. It does not on a CCU/RaspberryMatic/OpenCCU,
662
754
  // where everything below runs exactly as before.
663
755
  this.lastMetaProbe = now();
664
- this.detectMeta()
665
- .then((info) => {
666
- if (info) {
667
- return this.startMeta(info);
756
+ this.detectMetaOutcome()
757
+ .then((outcome) => {
758
+ if (outcome.info) {
759
+ return this.startMeta(outcome.info);
668
760
  }
669
761
 
762
+ // B-28: a timeout or a refused connection decides nothing -
763
+ // the ReGa path starts, and the detection is repeated
764
+ this.scheduleMetaRedetect(outcome);
670
765
  return this.getRegaData().then(() => {
671
766
  this.regaPoll();
672
767
  });
@@ -686,6 +781,41 @@ module.exports = function (RED) {
686
781
  this.on('close', this.destructor);
687
782
  }
688
783
 
784
+ /**
785
+ * B-31: the password and the token, from the credentials or - once - from the
786
+ * plain properties of a flow written before 4.4.6.
787
+ * @param {object} config the node's configuration as deployed
788
+ * @returns {{password: string, metaToken: string}}
789
+ */
790
+ migrateSecrets(config) {
791
+ const credentials = this.credentials || {};
792
+ const result = {
793
+ password: credentials.password || config.password || '',
794
+ metaToken: credentials.metaToken || config.metaToken || '',
795
+ };
796
+ const plain = ['password', 'metaToken'].filter((key) => config[key] && !credentials[key]);
797
+ if (plain.length > 0) {
798
+ const moved = {...credentials};
799
+ plain.forEach((key) => {
800
+ moved[key] = config[key];
801
+ });
802
+ if (typeof RED.nodes.addCredentials === 'function') {
803
+ RED.nodes.addCredentials(this.id, moved);
804
+ this.warn(
805
+ 'the ' +
806
+ plain.join(' and ') +
807
+ ' of this connection came from the flow in plain text - moved into the credentials, the next deploy removes ' +
808
+ (plain.length > 1 ? 'them' : 'it') +
809
+ ' from flows.json',
810
+ );
811
+ } else {
812
+ this.warn('the ' + plain.join(' and ') + ' of this connection is stored in the flow in plain text');
813
+ }
814
+ }
815
+
816
+ return result;
817
+ }
818
+
689
819
  get logger() {
690
820
  return {
691
821
  trace: (...args) => {
@@ -807,6 +937,11 @@ module.exports = function (RED) {
807
937
  * @param connected
808
938
  */
809
939
  setIfaceStatus(iface, connected, waiting = false) {
940
+ if (!iface || !this.ifaceTypes[iface]) {
941
+ // B-32: never a status line for something that is not an interface of ours
942
+ return;
943
+ }
944
+
810
945
  waiting = Boolean(waiting) && !connected;
811
946
  const waitingChanged = Boolean(this.ifaceWaiting[iface]) !== waiting;
812
947
  if (this.ifaceStatus[iface] !== connected || waitingChanged) {
@@ -1115,6 +1250,8 @@ module.exports = function (RED) {
1115
1250
  this.logger.debug('clear regaPollTimeout');
1116
1251
  this.cancelRegaPoll = true;
1117
1252
  clearTimeout(this.regaPollTimeout);
1253
+ clearTimeout(this.metaRedetectTimeout);
1254
+ this.metaRedetectTimeout = null;
1118
1255
 
1119
1256
  if (this.meta) {
1120
1257
  this.logger.debug('stop metadata event stream');
@@ -1126,6 +1263,7 @@ module.exports = function (RED) {
1126
1263
  this.logger.debug('clear rpcPingTimer', iface);
1127
1264
  clearTimeout(this.rpcPingTimer[iface]);
1128
1265
  });
1266
+ Object.keys(this.liveness).forEach((iface) => this.stopLiveness(iface));
1129
1267
 
1130
1268
  this.saveRegadata();
1131
1269
  this.saveValues();
@@ -1214,7 +1352,17 @@ module.exports = function (RED) {
1214
1352
  * @returns {Promise<object|null>}
1215
1353
  */
1216
1354
  detectMeta() {
1217
- return metaProvider.detect({
1355
+ return this.detectMetaOutcome().then((outcome) => outcome.info);
1356
+ }
1357
+
1358
+ /**
1359
+ * B-28: the detection with its outcome - `info` for an openccu-lite,
1360
+ * `inconclusive` when nothing was decided (timeout, refused connection,
1361
+ * the web server up but the api behind it not yet).
1362
+ * @returns {Promise<{info: object|null, inconclusive: boolean, reason?: string}>}
1363
+ */
1364
+ detectMetaOutcome() {
1365
+ return metaProvider.detectOutcome({
1218
1366
  host: this.host,
1219
1367
  port: this.metaPort,
1220
1368
  tls: Boolean(this.tlsEnabled),
@@ -1223,6 +1371,81 @@ module.exports = function (RED) {
1223
1371
  });
1224
1372
  }
1225
1373
 
1374
+ /**
1375
+ * B-28: an inconclusive detection (the box busy or still starting) used
1376
+ * to leave the connection in ReGa mode for good - the only re-detection
1377
+ * ran from rare error paths, at most every five minutes. Ask again after
1378
+ * 1, 2, 4, 8 s, then every 15 s, up to META_REDETECT_ATTEMPTS times;
1379
+ * after that the five-minute rule of recheckMeta() applies.
1380
+ * @param {{inconclusive: boolean, reason?: string}} outcome
1381
+ */
1382
+ scheduleMetaRedetect(outcome) {
1383
+ if (!outcome || !outcome.inconclusive || this.metaMode || this.cancelRegaPoll) {
1384
+ this.metaRedetectAttempt = 0;
1385
+ return;
1386
+ }
1387
+
1388
+ this.metaRedetectAttempt = (this.metaRedetectAttempt || 0) + 1;
1389
+ if (this.metaRedetectAttempt > META_REDETECT_ATTEMPTS) {
1390
+ this.logger.info(
1391
+ 'meta api detection still inconclusive (' +
1392
+ outcome.reason +
1393
+ ') after ' +
1394
+ META_REDETECT_ATTEMPTS +
1395
+ ' attempts - staying with the ReGaHSS, detecting again every five minutes',
1396
+ );
1397
+ this.metaRedetectAttempt = 0;
1398
+ return;
1399
+ }
1400
+
1401
+ const delay = retryDelay(this.metaRedetectAttempt);
1402
+ const line =
1403
+ 'meta api detection inconclusive (' +
1404
+ outcome.reason +
1405
+ ') - names come from the ReGaHSS until the box answers, detecting again in ' +
1406
+ delay / 1000 +
1407
+ ' s';
1408
+ if (this.metaRedetectAttempt === 1) {
1409
+ this.logger.info(line);
1410
+ } else {
1411
+ this.logger.debug(line);
1412
+ }
1413
+
1414
+ clearTimeout(this.metaRedetectTimeout);
1415
+ this.metaRedetectTimeout = setTimeout(() => this.redetectMeta(), delay);
1416
+ }
1417
+
1418
+ /** B-28: one scheduled re-detection. */
1419
+ redetectMeta() {
1420
+ this.metaRedetectTimeout = null;
1421
+ if (this.metaMode || this.cancelRegaPoll) {
1422
+ return;
1423
+ }
1424
+
1425
+ this.lastMetaProbe = now();
1426
+ this.detectMetaOutcome().then((outcome) => {
1427
+ if (this.metaMode || this.cancelRegaPoll) {
1428
+ return;
1429
+ }
1430
+
1431
+ if (outcome.info) {
1432
+ this.logger.info('meta api detected after ' + (this.metaRedetectAttempt + 1) + ' attempts');
1433
+ this.metaRedetectAttempt = 0;
1434
+ return this.startMeta(outcome.info);
1435
+ }
1436
+
1437
+ if (outcome.inconclusive) {
1438
+ this.scheduleMetaRedetect(outcome);
1439
+ return;
1440
+ }
1441
+
1442
+ this.metaRedetectAttempt = 0;
1443
+ this.logger.info(
1444
+ 'not an openccu-lite (' + outcome.reason + ') - names, rooms and functions come from the ReGaHSS',
1445
+ );
1446
+ });
1447
+ }
1448
+
1226
1449
  /**
1227
1450
  * Take names, rooms and functions from the openccu-lite metadata api
1228
1451
  * instead of from the ReGaHSS. Resolves as soon as the first snapshot
@@ -1291,11 +1514,14 @@ module.exports = function (RED) {
1291
1514
  this.meta = null;
1292
1515
  this.lastMetaProbe = 0;
1293
1516
  this.logger.warn('this box no longer answers as openccu-lite - detecting again');
1294
- this.detectMeta().then((again) => {
1295
- if (again) {
1296
- return this.startMeta(again);
1517
+ this.detectMetaOutcome().then((outcome) => {
1518
+ if (outcome.info) {
1519
+ return this.startMeta(outcome.info);
1297
1520
  }
1298
1521
 
1522
+ // B-28: an unreachable box is not a CCU yet - keep asking
1523
+ this.scheduleMetaRedetect(outcome);
1524
+
1299
1525
  if (this.ifaceTypes.ReGaHSS.enabled && !this.clients.ReGaHSS) {
1300
1526
  this.createClient('ReGaHSS').then(() => this.setIfaceStatus('ReGaHSS', true));
1301
1527
  }
@@ -1341,7 +1567,7 @@ module.exports = function (RED) {
1341
1567
  * picked up without a redeploy.
1342
1568
  */
1343
1569
  recheckMeta() {
1344
- if (this.metaMode || this.cancelRegaPoll || this.metaRecheckPending) {
1570
+ if (this.metaMode || this.cancelRegaPoll || this.metaRecheckPending || this.metaRedetectTimeout) {
1345
1571
  return;
1346
1572
  }
1347
1573
 
@@ -1663,9 +1889,16 @@ module.exports = function (RED) {
1663
1889
  this.regaPollPending = true;
1664
1890
  clearTimeout(this.regaPollTimeout);
1665
1891
  this.getRegaVariables()
1666
- .catch((error) => this.logger.error('getRegaVariables', error))
1892
+ .catch((error) => {
1893
+ this.logger.error('getRegaVariables', error);
1894
+ // B-28: the poll is what fails on a box without ReGaHSS
1895
+ this.recheckMeta();
1896
+ })
1667
1897
  .then(() => this.getRegaPrograms())
1668
- .catch((error) => this.logger.error('getRegaPrograms', error))
1898
+ .catch((error) => {
1899
+ this.logger.error('getRegaPrograms', error);
1900
+ this.recheckMeta();
1901
+ })
1669
1902
  .finally(() => {
1670
1903
  if (this.regaInterval && this.regaPollEnabled && !this.cancelRegaPoll) {
1671
1904
  //this.logger.trace('rega next poll in', this.regaInterval, 'seconds');
@@ -2108,6 +2341,7 @@ module.exports = function (RED) {
2108
2341
  // a failed one is retried by rpcInitFailed (task 11)
2109
2342
  if (this.ifaceTypes[iface].ping && !this.closing) {
2110
2343
  this.rpcCheckInit(iface);
2344
+ this.startLiveness(iface);
2111
2345
  }
2112
2346
 
2113
2347
  if (iface === 'CUxD') {
@@ -2163,32 +2397,237 @@ module.exports = function (RED) {
2163
2397
  }
2164
2398
 
2165
2399
  const knownDevices = [];
2166
- let change = false;
2400
+ const summary = {total: 0, added: 0, changed: 0, removed: 0};
2167
2401
  devices.forEach((device) => {
2402
+ if (!device || typeof device.ADDRESS !== 'string' || !device.TYPE) {
2403
+ return;
2404
+ }
2405
+
2168
2406
  knownDevices.push(device.ADDRESS);
2169
- if (!this.metadata.devices[iface][device.ADDRESS]) {
2407
+ const known = this.metadata.devices[iface][device.ADDRESS];
2408
+ if (!known) {
2170
2409
  this.newDevice(iface, device);
2171
- change = true;
2410
+ summary.added += 1;
2411
+ } else if (deviceChanged(known, device)) {
2412
+ // task 9: a TYPE/VERSION change since the cache was written
2413
+ this.newDevice(iface, device);
2414
+ summary.changed += 1;
2172
2415
  }
2173
2416
  });
2174
2417
 
2175
2418
  Object.keys(this.metadata.devices[iface]).forEach((addr) => {
2176
2419
  if (!knownDevices.includes(addr)) {
2177
2420
  this.deleteDevice(iface, addr);
2178
- change = true;
2421
+ summary.removed += 1;
2179
2422
  }
2180
2423
  });
2181
2424
 
2182
- if (change) {
2425
+ summary.total = Object.keys(this.metadata.devices[iface]).length;
2426
+ if (summary.added || summary.changed || summary.removed) {
2183
2427
  this.saveMetadata();
2184
2428
  }
2185
2429
 
2186
- resolve();
2430
+ resolve(summary);
2187
2431
  })
2188
2432
  .catch(reject);
2189
2433
  });
2190
2434
  }
2191
2435
 
2436
+ /**
2437
+ * task 9: re-read the device table of every connected interface from its
2438
+ * process (the button in the connection node's dialog). New and changed
2439
+ * devices get their paramset descriptions fetched, vanished ones go.
2440
+ * @returns {Promise<object>} per interface: total, added, changed, removed - or an error text
2441
+ */
2442
+ rereadDevices() {
2443
+ const ifaces = Object.keys(this.ifaceTypes).filter(
2444
+ (iface) => this.ifaceTypes[iface].enabled && this.ifaceTypes[iface].init && iface !== 'CUxD',
2445
+ );
2446
+ this.logger.info('re-reading device data:', ifaces.join(', '));
2447
+ return Promise.all(
2448
+ ifaces.map((iface) =>
2449
+ this.getDevices(iface)
2450
+ .then((summary) => [iface, summary])
2451
+ .catch((error) => [iface, {error: error.message || String(error)}]),
2452
+ ),
2453
+ ).then((entries) => {
2454
+ const summary = {};
2455
+ entries.forEach(([iface, result]) => {
2456
+ summary[iface] = result;
2457
+ });
2458
+ this.logger.info('device data re-read', JSON.stringify(summary));
2459
+ return summary;
2460
+ });
2461
+ }
2462
+
2463
+ /**
2464
+ * B-29: the liveness ping of an interface with a `pingInterval` (HmIP-RF).
2465
+ * hmipserver forgets its clients when it restarts and does not tell them:
2466
+ * `init` had succeeded, so nothing failed, and the events simply stopped
2467
+ * until the 600 s silence timeout of rpcCheckInit(). After `pingInterval`
2468
+ * seconds without an event a `ping` goes out; when no event (the PONG)
2469
+ * arrives within `pongTimeout` seconds the subscription is lost and `init`
2470
+ * is called again at once - task 11's retry then covers the time the
2471
+ * process is down. Only an event counts as proof; a device callback
2472
+ * (newDevices after a restart) triggers the ping instead. An interface
2473
+ * that never delivered an event since its init is not re-subscribed by
2474
+ * this (a callback address the CCU cannot reach would cost an init, and
2475
+ * hmipserver's full newDevices re-send, every 40 s).
2476
+ * @param iface
2477
+ */
2478
+ startLiveness(iface) {
2479
+ const {pingInterval} = this.ifaceTypes[iface] || {};
2480
+ if (!pingInterval || !this.rpcPingEnabled || this.closing) {
2481
+ return;
2482
+ }
2483
+
2484
+ this.stopLiveness(iface);
2485
+ this.liveness[iface] = {initAt: now(), pingAt: 0, timer: null, pongTimer: null, quietLogged: false};
2486
+ this.scheduleLiveness(iface, pingInterval * 1000);
2487
+ }
2488
+
2489
+ /** @param iface */
2490
+ stopLiveness(iface) {
2491
+ const state = this.liveness[iface];
2492
+ if (state) {
2493
+ clearTimeout(state.timer);
2494
+ clearTimeout(state.pongTimer);
2495
+ delete this.liveness[iface];
2496
+ }
2497
+ }
2498
+
2499
+ /**
2500
+ * @param iface
2501
+ * @param {number} delay ms until the next check
2502
+ */
2503
+ scheduleLiveness(iface, delay) {
2504
+ const state = this.liveness[iface];
2505
+ if (!state) {
2506
+ return;
2507
+ }
2508
+
2509
+ clearTimeout(state.timer);
2510
+ state.timer = setTimeout(() => this.livenessCheck(iface), Math.max(delay, 100));
2511
+ }
2512
+
2513
+ /** @param iface */
2514
+ livenessCheck(iface) {
2515
+ const state = this.liveness[iface];
2516
+ if (!state || this.closing) {
2517
+ return;
2518
+ }
2519
+
2520
+ const {pingInterval} = this.ifaceTypes[iface];
2521
+ const sinceEvent = now() - (this.lastRealEvent[iface] || 0);
2522
+ if (sinceEvent < pingInterval * 1000) {
2523
+ // events arrive: the subscription is alive, no ping needed
2524
+ this.scheduleLiveness(iface, pingInterval * 1000 - sinceEvent);
2525
+ return;
2526
+ }
2527
+
2528
+ this.livenessPing(iface);
2529
+ }
2530
+
2531
+ /** @param iface */
2532
+ livenessPing(iface) {
2533
+ const state = this.liveness[iface];
2534
+ if (!state || state.pongTimer || this.closing) {
2535
+ return;
2536
+ }
2537
+
2538
+ const {pongTimeout} = this.ifaceTypes[iface];
2539
+ clearTimeout(state.timer);
2540
+ state.pingAt = now();
2541
+ this.logger.debug('liveness ping', iface);
2542
+ state.pongTimer = setTimeout(() => this.livenessPongMissing(iface), pongTimeout * 1000);
2543
+ this.methodCall(iface, 'ping', ['nr'], {quiet: true}).catch((error) => {
2544
+ // the process itself does not answer: no need to wait for the PONG
2545
+ const current = this.liveness[iface];
2546
+ if (current && current.pongTimer) {
2547
+ clearTimeout(current.pongTimer);
2548
+ current.pongTimer = null;
2549
+ this.livenessLost(iface, 'ping failed: ' + (error && error.message ? error.message : error));
2550
+ }
2551
+ });
2552
+ }
2553
+
2554
+ /** the PONG did not arrive in time @param iface */
2555
+ livenessPongMissing(iface) {
2556
+ const state = this.liveness[iface];
2557
+ if (!state) {
2558
+ return;
2559
+ }
2560
+
2561
+ state.pongTimer = null;
2562
+ const {pingInterval, pongTimeout} = this.ifaceTypes[iface];
2563
+ if (!this.lastRealEvent[iface] || this.lastRealEvent[iface] < state.initAt) {
2564
+ // never an event since the init: do not re-subscribe on that alone
2565
+ if (!state.quietLogged) {
2566
+ state.quietLogged = true;
2567
+ this.logger.debug(
2568
+ 'liveness',
2569
+ iface,
2570
+ 'no PONG within ' + pongTimeout + ' s and no event since init - not subscribing again',
2571
+ );
2572
+ }
2573
+
2574
+ this.scheduleLiveness(iface, pingInterval * 1000);
2575
+ return;
2576
+ }
2577
+
2578
+ this.livenessLost(iface, 'no answer to a ping within ' + pongTimeout + ' s');
2579
+ }
2580
+
2581
+ /**
2582
+ * The subscription is lost: init again at once.
2583
+ * @param iface
2584
+ * @param {string} reason
2585
+ */
2586
+ livenessLost(iface, reason) {
2587
+ if (!this.liveness[iface] || this.closing) {
2588
+ return;
2589
+ }
2590
+
2591
+ this.stopLiveness(iface);
2592
+ this.logger.warn(iface + ': ' + reason + ' - subscribing again');
2593
+ this.hadTimeout.add(iface);
2594
+ this.setIfaceStatus(iface, false, true);
2595
+ this.rpcInit(iface)
2596
+ .then(() => {
2597
+ this.logger.info(iface + ' subscribed again');
2598
+ this.rpcInitSucceeded(iface);
2599
+ })
2600
+ .catch((error) => this.rpcInitFailed(iface, error));
2601
+ }
2602
+
2603
+ /**
2604
+ * An event arrived (PONG included): the subscription delivers.
2605
+ * @param iface
2606
+ */
2607
+ livenessEvent(iface) {
2608
+ this.lastRealEvent[iface] = now();
2609
+ const state = this.liveness[iface];
2610
+ if (state && state.pongTimer) {
2611
+ clearTimeout(state.pongTimer);
2612
+ state.pongTimer = null;
2613
+ this.scheduleLiveness(iface, this.ifaceTypes[iface].pingInterval * 1000);
2614
+ }
2615
+ }
2616
+
2617
+ /**
2618
+ * A device callback (newDevices, deleteDevices, ...) arrived. After a
2619
+ * restart hmipserver sends newDevices to the read-back handler about
2620
+ * 16 s in, before any event: ask for the PONG at once instead of
2621
+ * waiting for the interval.
2622
+ * @param iface
2623
+ */
2624
+ deviceCallback(iface) {
2625
+ const state = this.liveness[iface];
2626
+ if (state && !state.pongTimer && !this.closing) {
2627
+ this.livenessPing(iface);
2628
+ }
2629
+ }
2630
+
2192
2631
  /**
2193
2632
  *
2194
2633
  * @param iface
@@ -2198,15 +2637,21 @@ module.exports = function (RED) {
2198
2637
  return;
2199
2638
  }
2200
2639
 
2201
- if (!this.metadata.devices[iface] || !Object.keys(this.metadata.devices[iface]).length > 0) {
2202
- return;
2203
- }
2640
+ // task 9 (old B-11): the watchdog used to return here for an interface without
2641
+ // devices in the cache, so such an interface never pinged and never re-initialised
2642
+ // after a CCU reboot. It pings now; it re-initialises only when the interface has
2643
+ // devices or has ever delivered an event (a PONG counts) - a process that answers
2644
+ // no ping at all is not asked to init every timeout.
2645
+ const hasDevices = Boolean(
2646
+ this.metadata.devices[iface] && Object.keys(this.metadata.devices[iface]).length > 0,
2647
+ );
2648
+ const mayReinit = hasDevices || Boolean(this.lastRealEvent[iface]);
2204
2649
 
2205
2650
  clearTimeout(this.rpcPingTimer[iface]);
2206
2651
  const pingTimeout = this.ifaceTypes[iface].pingTimeout || this.rpcPingTimeout;
2207
2652
  const elapsed = Math.round((now() - (this.lastEvent[iface] || 0)) / 1000);
2208
2653
  this.logger.debug('rpcCheckInit', iface, elapsed, pingTimeout);
2209
- if (elapsed > pingTimeout) {
2654
+ if (elapsed > pingTimeout && mayReinit) {
2210
2655
  this.hadTimeout.add(iface);
2211
2656
  this.setIfaceStatus(iface, false);
2212
2657
  this.logger.warn('ping timeout', iface, elapsed);
@@ -2358,12 +2803,10 @@ module.exports = function (RED) {
2358
2803
  }
2359
2804
 
2360
2805
  this.logger.debug('rpc <', protocol, method, JSON.stringify(parameters));
2361
- if (method === 'event') {
2362
- method = 'eventSingle';
2363
- }
2806
+ const handler = method === 'event' ? 'eventSingle' : method;
2364
2807
 
2365
2808
  if (isIterable(parameters)) {
2366
- this.rpcMethods[method](err, parameters, callback);
2809
+ this.callRpcMethod(handler, err, parameters, callback);
2367
2810
  } else {
2368
2811
  this.logger.error(
2369
2812
  'rpc <',
@@ -2373,7 +2816,7 @@ module.exports = function (RED) {
2373
2816
  'params not iterable',
2374
2817
  JSON.stringify(parameters),
2375
2818
  );
2376
- callback(null, '');
2819
+ callback(null, this.rpcDefaultAnswer(method));
2377
2820
  }
2378
2821
  });
2379
2822
  });
@@ -2558,7 +3001,29 @@ module.exports = function (RED) {
2558
3001
  this.metadata.types[iface] = {};
2559
3002
  }
2560
3003
 
2561
- if (this.metadata.devices[iface][device.ADDRESS]) {
3004
+ const known = this.metadata.devices[iface][device.ADDRESS];
3005
+ if (known && deviceChanged(known, device)) {
3006
+ // task 9 (#146, #181): a channel whose TYPE (or the device's VERSION/FIRMWARE)
3007
+ // changed in place kept its old datapoints; the paramset key changes with
3008
+ // it, so the description is fetched anew below, and the old type list
3009
+ // forgets the address
3010
+ this.logger.info(
3011
+ 'device data changed',
3012
+ iface,
3013
+ device.ADDRESS,
3014
+ known.TYPE + '/' + (known.FIRMWARE || '-') + '/' + (known.VERSION || '-'),
3015
+ '->',
3016
+ device.TYPE + '/' + (device.FIRMWARE || '-') + '/' + (device.VERSION || '-'),
3017
+ );
3018
+ if (this.metadata.types[iface][known.TYPE]) {
3019
+ this.metadata.types[iface][known.TYPE] = this.metadata.types[iface][known.TYPE].filter(
3020
+ (addr) => addr !== device.ADDRESS,
3021
+ );
3022
+ if (this.metadata.types[iface][known.TYPE].length === 0) {
3023
+ delete this.metadata.types[iface][known.TYPE];
3024
+ }
3025
+ }
3026
+ } else if (known) {
2562
3027
  this.logger.trace('newDevice (already known)', iface, device.ADDRESS);
2563
3028
  } else {
2564
3029
  this.logger.debug('newDevice', iface, device.ADDRESS);
@@ -2607,10 +3072,11 @@ module.exports = function (RED) {
2607
3072
  if (this.metadata.devices[iface]) {
2608
3073
  Object.keys(this.metadata.devices[iface]).forEach((addr) => {
2609
3074
  const dev = this.metadata.devices[iface][addr];
2610
- if (dev.TYPE === 'HmIP-RCV-50' || dev.PARENT_TYPE === 'HmIP-RCV-50') {
2611
- // Würgaround for Firmware 3.43.15
2612
- return;
2613
- }
3075
+ // The CCU's own virtual remote control (HmIP-RCV-1, type HmIP-RCV-50) is listed
3076
+ // like any other device. It was left out since CCU3 firmware 3.43.15 (2019),
3077
+ // whose hmipserver failed on an answer that held it; current hmipservers do
3078
+ // not, and without it every init made hmipserver log a handleIDMigration
3079
+ // warning and send the whole virtual remote again with newDevices (task 13).
2614
3080
 
2615
3081
  if (dev.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER') {
2616
3082
  //this.paramsQueuePush(iface, dev);
@@ -2684,98 +3150,208 @@ module.exports = function (RED) {
2684
3150
  * @returns {*}
2685
3151
  */
2686
3152
  getIfaceFromIdInit(idInit) {
3153
+ if (typeof idInit !== 'string') {
3154
+ return null;
3155
+ }
3156
+
2687
3157
  if (idInit === 'CUxD') {
2688
3158
  return idInit;
2689
3159
  }
2690
3160
 
2691
3161
  const match = idInit.match(/^nr_[\da-zA-Z]{6}_([a-zA-Z-]+)$/);
2692
- return match && match[1];
3162
+ return match && this.ifaceTypes[match[1]] ? match[1] : null;
3163
+ }
3164
+
3165
+ /**
3166
+ * B-32: the interface a callback call belongs to, or null with a debug line when the
3167
+ * first parameter is not one of our own init ids. Anything on the system can connect
3168
+ * to the callback ports on the loopback; such a call is answered, never thrown on.
3169
+ * @param {string} method
3170
+ * @param {Array} parameters
3171
+ * @returns {string|null}
3172
+ */
3173
+ rpcIface(method, parameters) {
3174
+ const [idInit] = parameters;
3175
+ const iface = this.getIfaceFromIdInit(idInit);
3176
+ if (!iface) {
3177
+ this.logger.debug('rpc <', method, 'unknown interface id', JSON.stringify(idInit));
3178
+ }
3179
+
3180
+ return iface;
3181
+ }
3182
+
3183
+ /**
3184
+ * B-32: what a callback call is answered with when it cannot be handled.
3185
+ * @param {string} method
3186
+ * @returns {Array|string}
3187
+ */
3188
+ rpcDefaultAnswer(method) {
3189
+ if (method === 'system.listMethods') {
3190
+ return Object.keys(this.rpcMethods);
3191
+ }
3192
+
3193
+ if (method === 'listDevices' || method === 'system.multicall') {
3194
+ return [];
3195
+ }
3196
+
3197
+ return '';
3198
+ }
3199
+
3200
+ /**
3201
+ * B-32: run a callback handler so that a throw inside it is logged and answered
3202
+ * instead of ending the process (the RPC servers call us from an event emitter,
3203
+ * so an exception there is uncaught). The callback is answered exactly once.
3204
+ * @param {string} method the key in rpcMethods
3205
+ * @param {*} err
3206
+ * @param {Array} parameters
3207
+ * @param {function} callback
3208
+ */
3209
+ callRpcMethod(method, err, parameters, callback) {
3210
+ let answered = false;
3211
+ const answer = (error, result) => {
3212
+ if (!answered) {
3213
+ answered = true;
3214
+ callback(error, result);
3215
+ }
3216
+ };
3217
+
3218
+ try {
3219
+ this.rpcMethods[method](err, parameters, answer);
3220
+ } catch (error) {
3221
+ this.logger.error(
3222
+ 'rpc <',
3223
+ method,
3224
+ 'failed:',
3225
+ error && error.message ? error.message : String(error),
3226
+ JSON.stringify(parameters),
3227
+ );
3228
+ answer(null, this.rpcDefaultAnswer(method === 'eventSingle' ? 'event' : method));
3229
+ }
2693
3230
  }
2694
3231
 
2695
3232
  get rpcMethods() {
2696
3233
  return {
2697
3234
  'system.listMethods': (_, parameters, callback) => {
2698
- const [idInit] = parameters;
2699
- const iface = this.getIfaceFromIdInit(idInit);
2700
- this.lastEvent[iface] = now();
2701
- this.setIfaceStatus(iface, true);
3235
+ const iface = this.rpcIface('system.listMethods', parameters);
2702
3236
  const res = Object.keys(this.rpcMethods);
3237
+ if (iface) {
3238
+ this.lastEvent[iface] = now();
3239
+ this.setIfaceStatus(iface, true);
3240
+ }
3241
+
2703
3242
  this.logger.debug(' >', iface, 'system.listMethods', JSON.stringify(res));
2704
3243
  callback(null, res);
2705
3244
  },
2706
3245
  setReadyConfig: (_, parameters, callback) => {
2707
- const [idInit] = parameters;
2708
- const iface = this.getIfaceFromIdInit(idInit);
3246
+ const iface = this.rpcIface('setReadyConfig', parameters);
2709
3247
  this.logger.debug(' >', iface, 'setReadyConfig ""');
2710
3248
  callback(null, '');
2711
3249
  },
2712
3250
  updateDevice: (_, parameters, callback) => {
2713
- const [idInit] = parameters;
2714
- const iface = this.getIfaceFromIdInit(idInit);
3251
+ const iface = this.rpcIface('updateDevice', parameters);
2715
3252
  this.logger.debug(' >', iface, 'updateDevice ""');
2716
3253
  callback(null, '');
3254
+ if (iface) {
3255
+ this.deviceCallback(iface);
3256
+ }
2717
3257
  },
2718
3258
  replaceDevice: (_, parameters, callback) => {
2719
- const [idInit] = parameters;
2720
- const iface = this.getIfaceFromIdInit(idInit);
3259
+ const iface = this.rpcIface('replaceDevice', parameters);
2721
3260
  this.logger.debug(' >', iface, 'replaceDevice ""');
2722
3261
  callback(null, '');
3262
+ if (iface) {
3263
+ this.deviceCallback(iface);
3264
+ }
2723
3265
  },
2724
3266
  readdedDevice: (_, parameters, callback) => {
2725
- const [idInit] = parameters;
2726
- const iface = this.getIfaceFromIdInit(idInit);
3267
+ const iface = this.rpcIface('readdedDevice', parameters);
2727
3268
  this.logger.debug(' >', iface, 'readdedDevice ""');
2728
3269
  callback(null, '');
3270
+ if (iface) {
3271
+ this.deviceCallback(iface);
3272
+ }
2729
3273
  },
2730
3274
  newDevices: (_, parameters, callback) => {
2731
- const [idInit, devices] = parameters;
2732
- const iface = this.getIfaceFromIdInit(idInit);
3275
+ const [, devices] = parameters;
3276
+ const iface = this.rpcIface('newDevices', parameters);
2733
3277
 
2734
- devices.forEach((device) => {
2735
- this.newDevice(iface, device);
2736
- });
3278
+ let changed = false;
3279
+ if (iface && Array.isArray(devices)) {
3280
+ devices.forEach((device) => {
3281
+ if (device && typeof device.ADDRESS === 'string' && device.TYPE) {
3282
+ this.newDevice(iface, device);
3283
+ changed = true;
3284
+ } else {
3285
+ this.logger.warn(
3286
+ 'newDevices',
3287
+ iface,
3288
+ 'skipping malformed entry',
3289
+ JSON.stringify(device),
3290
+ );
3291
+ }
3292
+ });
3293
+ }
2737
3294
 
2738
3295
  this.logger.debug(' >', iface, 'newDevices ""');
2739
3296
  callback(null, '');
2740
3297
 
2741
- this.saveMetadata();
3298
+ if (changed) {
3299
+ this.saveMetadata();
3300
+ }
3301
+
3302
+ if (iface) {
3303
+ this.deviceCallback(iface);
3304
+ }
2742
3305
  },
2743
3306
  deleteDevices: (_, parameters, callback) => {
2744
- const [idInit, devices] = parameters;
2745
- const iface = this.getIfaceFromIdInit(idInit);
3307
+ const [, devices] = parameters;
3308
+ const iface = this.rpcIface('deleteDevices', parameters);
2746
3309
 
2747
- devices.forEach((device) => {
2748
- this.deleteDevice(iface, device);
2749
- });
3310
+ let changed = false;
3311
+ if (iface && Array.isArray(devices) && this.metadata.devices[iface]) {
3312
+ devices.forEach((device) => {
3313
+ if (typeof device === 'string' && this.metadata.devices[iface][device]) {
3314
+ this.deleteDevice(iface, device);
3315
+ changed = true;
3316
+ }
3317
+ });
3318
+ }
2750
3319
 
2751
3320
  this.logger.debug(' >', iface, 'deleteDevices ""');
2752
3321
  callback(null, '');
2753
3322
 
2754
- this.saveMetadata();
3323
+ if (changed) {
3324
+ this.saveMetadata();
3325
+ }
3326
+
3327
+ if (iface) {
3328
+ this.deviceCallback(iface);
3329
+ }
2755
3330
  },
2756
3331
  listDevices: (_, parameters, callback) => {
2757
- const [idInit] = parameters;
2758
- const iface = this.getIfaceFromIdInit(idInit);
2759
- this.lastEvent[iface] = now();
2760
- this.setIfaceStatus(iface, true);
2761
- const res = this.listDevices(iface) || [];
3332
+ const iface = this.rpcIface('listDevices', parameters);
3333
+ let res = [];
3334
+ if (iface) {
3335
+ this.lastEvent[iface] = now();
3336
+ this.setIfaceStatus(iface, true);
3337
+ res = this.listDevices(iface) || [];
3338
+ }
3339
+
2762
3340
  this.logger.debug(' >', iface, 'listDevices', JSON.stringify(res));
2763
3341
  callback(null, res);
2764
3342
  },
2765
3343
  event: (_, parameters, callback) => {
2766
- const [idInit] = parameters;
2767
- const iface = this.getIfaceFromIdInit(idInit);
3344
+ const iface = this.rpcIface('event', parameters);
2768
3345
  this.logger.debug(' >', iface, 'event ""');
2769
3346
  this.publishEvent(parameters);
2770
3347
  callback(null, '');
2771
3348
  },
2772
3349
  eventSingle: (_, parameters, callback) => {
2773
- const [idInit] = parameters;
2774
- const iface = this.getIfaceFromIdInit(idInit);
3350
+ const iface = this.rpcIface('event', parameters);
2775
3351
  this.logger.debug(' >', iface, 'event ""');
2776
3352
  this.publishEvent(parameters);
2777
3353
 
2778
- if (parameters[2] !== 'PONG') {
3354
+ if (iface && parameters[2] !== 'PONG') {
2779
3355
  if (this.rxCounters[iface]) {
2780
3356
  this.rxCounters[iface] += 1;
2781
3357
  } else {
@@ -2796,8 +3372,8 @@ module.exports = function (RED) {
2796
3372
  if (isIterable(parameters[0])) {
2797
3373
  parameters[0].forEach((call) => {
2798
3374
  if (call && call.methodName === 'event') {
2799
- queue.push(call);
2800
3375
  if (isIterable(call.params)) {
3376
+ queue.push(call);
2801
3377
  const [idInit, , datapoint, value] = call.params;
2802
3378
  if (datapoint !== 'PONG') {
2803
3379
  pong = false;
@@ -2819,16 +3395,32 @@ module.exports = function (RED) {
2819
3395
  }
2820
3396
  }
2821
3397
 
2822
- iface = this.getIfaceFromIdInit(idInit);
3398
+ iface = this.getIfaceFromIdInit(idInit) || iface;
3399
+ } else {
3400
+ this.logger.debug(
3401
+ 'rpc <',
3402
+ 'event',
3403
+ 'params not iterable',
3404
+ JSON.stringify(call.params),
3405
+ );
2823
3406
  }
2824
3407
 
2825
3408
  result.push('');
2826
- } else if (call && this.rpcMethods[call.methodName]) {
3409
+ } else if (
3410
+ call &&
3411
+ call.methodName !== 'system.multicall' &&
3412
+ this.rpcMethods[call.methodName]
3413
+ ) {
2827
3414
  pong = false;
2828
3415
  if (isIterable(call.params)) {
2829
- this.rpcMethods[call.methodName](call.methodName, call.params, (_, res) =>
2830
- result.push(res),
2831
- );
3416
+ let answered = false;
3417
+ this.callRpcMethod(call.methodName, null, call.params, (_, res) => {
3418
+ answered = true;
3419
+ result.push(res);
3420
+ });
3421
+ if (!answered) {
3422
+ result.push(this.rpcDefaultAnswer(call.methodName));
3423
+ }
2832
3424
  } else {
2833
3425
  this.logger.error(
2834
3426
  'rpc <',
@@ -2836,7 +3428,11 @@ module.exports = function (RED) {
2836
3428
  'params not iterable',
2837
3429
  JSON.stringify(call.params),
2838
3430
  );
3431
+ result.push(this.rpcDefaultAnswer(call.methodName));
2839
3432
  }
3433
+ } else {
3434
+ // B-32: an unknown or nested method keeps the answer's shape
3435
+ result.push('');
2840
3436
  }
2841
3437
  });
2842
3438
  queue.forEach((call) => {
@@ -3063,7 +3659,14 @@ module.exports = function (RED) {
3063
3659
  const [idInit, channel, datapoint, payload] = parameters;
3064
3660
  const iface = this.getIfaceFromIdInit(idInit);
3065
3661
 
3662
+ if (!iface || typeof channel !== 'string' || typeof datapoint !== 'string') {
3663
+ // B-32: not one of our interfaces, or not an event's shape - nothing to publish
3664
+ this.logger.debug('rpc <', 'event', 'ignored', JSON.stringify(parameters));
3665
+ return;
3666
+ }
3667
+
3066
3668
  this.lastEvent[iface] = now();
3669
+ this.livenessEvent(iface);
3067
3670
  if (this.hadTimeout.has(iface)) {
3068
3671
  this.setIfaceStatus(iface, true);
3069
3672
  }
@@ -3371,7 +3974,14 @@ module.exports = function (RED) {
3371
3974
  const datapointName = iface + '.' + address + '.' + datapoint;
3372
3975
  const currentValue = this.values[datapointName] && this.values[datapointName].value;
3373
3976
  const cache = this.values[datapointName] && this.values[datapointName].cache;
3374
- if (force || value !== currentValue || cache || datapoint.startsWith('PRESS_')) {
3977
+ // B-19: a cached value that is the echo of our own last write does not
3978
+ // prove the actuator is there - a blind moved by hand keeps its control
3979
+ // channel's set point (#151). Only a value from another source dedupes:
3980
+ // one that differs from what we wrote last, or one reported before it.
3981
+ const lastWrite = this.lastWrite[datapointName];
3982
+ const cachedTs = this.values[datapointName] && this.values[datapointName].ts;
3983
+ const ownEcho = Boolean(lastWrite) && lastWrite.value === currentValue && cachedTs > lastWrite.ts;
3984
+ if (force || value !== currentValue || cache || ownEcho || datapoint.startsWith('PRESS_')) {
3375
3985
  this.setValueQueue.push({iface, address, datapoint, value, burst, resolve, reject});
3376
3986
  this.setValueShiftQueue();
3377
3987
  } else {
@@ -3433,8 +4043,16 @@ module.exports = function (RED) {
3433
4043
  * @returns {Promise<any>}
3434
4044
  */
3435
4045
  setValue(iface, address, datapoint, value, burst) {
4046
+ this.lastWrite[iface + '.' + address + '.' + datapoint] = {value, ts: now()};
3436
4047
  const device = this.metadata.devices[iface] && this.metadata.devices[iface][address];
3437
4048
  const description = this.paramsetDescriptions[this.paramsetName(iface, device, 'VALUES')];
4049
+ if (isPartyWrite(datapoint, description)) {
4050
+ // an HmIP thermostat takes PARTY_TIME_START, PARTY_TIME_END and
4051
+ // PARTY_SET_POINT_TEMPERATURE only together - one alone resets the
4052
+ // other two (#156, #161): write all three in one putParamset
4053
+ return this.setPartyValues(iface, address, datapoint, value);
4054
+ }
4055
+
3438
4056
  const combined = combinedParameterValue(datapoint, value, description);
3439
4057
  if (combined !== null) {
3440
4058
  // HmIP actuators accept but ignore a lone LEVEL_2 write; the CCU
@@ -3477,6 +4095,80 @@ module.exports = function (RED) {
3477
4095
  });
3478
4096
  }
3479
4097
 
4098
+ /**
4099
+ * Party mode on an HmIP thermostat (task 2, #156, #161): the written
4100
+ * datapoint merged with the current values of the other two - our own
4101
+ * last write when it is newer than the cache, else the cached value,
4102
+ * else the values read from the interface process (hmipserver answers
4103
+ * getParamset from its own state) - into one putParamset on VALUES.
4104
+ * @param {string} iface
4105
+ * @param {string} address the channel
4106
+ * @param {string} datapoint the one being written
4107
+ * @param {*} value
4108
+ * @returns {Promise<any>}
4109
+ */
4110
+ setPartyValues(iface, address, datapoint, value) {
4111
+ const current = {};
4112
+ for (const name of PARTY_DATAPOINTS) {
4113
+ if (name === datapoint) {
4114
+ continue;
4115
+ }
4116
+
4117
+ const key = iface + '.' + address + '.' + name;
4118
+ const cached = this.values[key];
4119
+ const written = this.lastWrite[key];
4120
+ if (written && (!cached || !(cached.ts > written.ts))) {
4121
+ current[name] = written.value;
4122
+ } else if (cached && cached.value !== undefined) {
4123
+ current[name] = cached.value;
4124
+ }
4125
+ }
4126
+
4127
+ let merged = mergePartyValues(datapoint, value, current);
4128
+ const read =
4129
+ merged.missing.length > 0
4130
+ ? this.methodCall(iface, 'getParamset', [address, 'VALUES']).then((values) => {
4131
+ merged.missing.forEach((name) => {
4132
+ if (values && values[name] !== undefined) {
4133
+ current[name] = values[name];
4134
+ }
4135
+ });
4136
+ merged = mergePartyValues(datapoint, value, current);
4137
+ })
4138
+ : Promise.resolve();
4139
+
4140
+ return read.then(() => {
4141
+ if (merged.missing.length > 0) {
4142
+ throw new Error(
4143
+ 'party mode: no current value for ' +
4144
+ merged.missing.join(', ') +
4145
+ ' on ' +
4146
+ iface +
4147
+ ' ' +
4148
+ address,
4149
+ );
4150
+ }
4151
+
4152
+ const parameters = {};
4153
+ Object.keys(merged.values).forEach((name) => {
4154
+ parameters[name] = this.paramCast(iface, address, 'VALUES', name, merged.values[name]);
4155
+ this.lastWrite[iface + '.' + address + '.' + name] = {value: merged.values[name], ts: now()};
4156
+ });
4157
+ this.logger.debug('setValue', datapoint, value, '-> putParamset VALUES', JSON.stringify(parameters));
4158
+ return this.methodCall(iface, 'putParamset', [address, 'VALUES', parameters]).catch((error) => {
4159
+ this.logger.error(
4160
+ 'rpc >',
4161
+ iface,
4162
+ 'putParamset',
4163
+ JSON.stringify([address, 'VALUES', parameters]),
4164
+ '<',
4165
+ error,
4166
+ );
4167
+ throw error;
4168
+ });
4169
+ });
4170
+ }
4171
+
3480
4172
  /**
3481
4173
  *
3482
4174
  * @param id
@@ -3540,5 +4232,10 @@ module.exports = function (RED) {
3540
4232
  }
3541
4233
  }
3542
4234
 
3543
- RED.nodes.registerType('ccu-connection', CcuConnectionNode, {});
4235
+ RED.nodes.registerType('ccu-connection', CcuConnectionNode, {
4236
+ credentials: {
4237
+ password: {type: 'password'},
4238
+ metaToken: {type: 'password'},
4239
+ },
4240
+ });
3544
4241
  };