node-red-contrib-ccu 4.4.5 → 4.5.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/README.md +22 -0
  3. package/nodes/ccu-alexa.html +12 -14
  4. package/nodes/ccu-connection.html +63 -95
  5. package/nodes/ccu-connection.js +1009 -127
  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 +11 -0
  23. package/nodes/lib/metaprovider.js +47 -19
  24. package/nodes/lib/party.js +103 -0
  25. package/nodes/lib/topic.js +22 -1
  26. package/nodes/lib/transport.js +80 -0
  27. package/nodes/locales/de/ccu-alexa.html +4 -0
  28. package/nodes/locales/de/ccu-connection.html +103 -0
  29. package/nodes/locales/de/ccu-get-value.html +16 -0
  30. package/nodes/locales/de/ccu-homeassistant.html +23 -0
  31. package/nodes/locales/de/ccu-mqtt.html +21 -0
  32. package/nodes/locales/de/ccu-poll.html +8 -0
  33. package/nodes/locales/de/ccu-program.html +26 -0
  34. package/nodes/locales/de/ccu-rpc-event.html +32 -0
  35. package/nodes/locales/de/ccu-rpc.html +31 -0
  36. package/nodes/locales/de/ccu-script.html +13 -0
  37. package/nodes/locales/de/ccu-set-value.html +46 -0
  38. package/nodes/locales/de/ccu-signal.html +29 -0
  39. package/nodes/locales/de/ccu-switch.html +18 -0
  40. package/nodes/locales/de/ccu-sysvar.html +43 -0
  41. package/nodes/locales/de/ccu-value.html +98 -0
  42. package/nodes/locales/de/ccu-value.json +1 -0
  43. package/nodes/locales/en-US/ccu-alexa.html +4 -0
  44. package/nodes/locales/en-US/ccu-connection.html +41 -0
  45. package/nodes/locales/en-US/ccu-get-value.html +13 -0
  46. package/nodes/locales/en-US/ccu-homeassistant.html +23 -0
  47. package/nodes/locales/en-US/ccu-mqtt.html +21 -0
  48. package/nodes/locales/en-US/ccu-poll.html +7 -0
  49. package/nodes/locales/en-US/ccu-program.html +25 -0
  50. package/nodes/locales/en-US/ccu-rpc-event.html +31 -0
  51. package/nodes/locales/en-US/ccu-rpc.html +30 -0
  52. package/nodes/locales/en-US/ccu-script.html +13 -0
  53. package/nodes/locales/en-US/ccu-set-value.html +45 -0
  54. package/nodes/locales/en-US/ccu-signal.html +29 -0
  55. package/nodes/locales/en-US/ccu-switch.html +6 -0
  56. package/nodes/locales/en-US/ccu-sysvar.html +42 -0
  57. package/nodes/locales/en-US/ccu-value.html +81 -0
  58. package/nodes/locales/en-US/ccu-value.json +1 -0
  59. package/package.json +2 -8
@@ -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;
@@ -573,6 +662,13 @@ module.exports = function (RED) {
573
662
 
574
663
  this.newParamsetDescriptionCount = 0;
575
664
  this.paramsetQueue = [];
665
+ // B-36: iface/address/paramset whose description the interface refused
666
+ // with a fault (-2 Invalid device for an address it does not know) - not
667
+ // asked again in this run, so a stale cache entry costs one call, not one
668
+ // per lookup
669
+ this.paramsetFailed = new Set();
670
+ this.valuesPruneTimer = null;
671
+ this.valuesPruneDelay = 5000;
576
672
  this.paramsQueue = [];
577
673
 
578
674
  this.paramsetFile = path.join(RED.settings.userDir || path.join(__dirname, '..'), 'paramsets.json');
@@ -612,6 +708,9 @@ module.exports = function (RED) {
612
708
  this.setValueThrottle = 500;
613
709
  this.setValueTimers = {};
614
710
  this.setValueCache = {};
711
+ // B-19: the value this connection last wrote to a datapoint, so the queued
712
+ // write's dedupe can tell the echo of its own write from another source
713
+ this.lastWrite = {};
615
714
  this.setValueQueue = [];
616
715
 
617
716
  this.lastEvent = {};
@@ -634,16 +733,17 @@ module.exports = function (RED) {
634
733
  this.loadMetadata();
635
734
  this.loadRegadata();
636
735
  this.loadValues();
736
+ this.pruneCaches();
637
737
 
638
738
  this.setContext();
639
739
 
640
740
  this.rega = new Rega({
641
741
  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,
742
+ port: this.isLocal ? 8183 : tls ? 48181 : 8181,
743
+ tls,
744
+ insecure: inSecure,
745
+ username: user,
746
+ password: pass,
647
747
  });
648
748
 
649
749
  this.enabledIfaces = [];
@@ -661,12 +761,15 @@ module.exports = function (RED) {
661
761
  // metadata api. It does not on a CCU/RaspberryMatic/OpenCCU,
662
762
  // where everything below runs exactly as before.
663
763
  this.lastMetaProbe = now();
664
- this.detectMeta()
665
- .then((info) => {
666
- if (info) {
667
- return this.startMeta(info);
764
+ this.detectMetaOutcome()
765
+ .then((outcome) => {
766
+ if (outcome.info) {
767
+ return this.startMeta(outcome.info);
668
768
  }
669
769
 
770
+ // B-28: a timeout or a refused connection decides nothing -
771
+ // the ReGa path starts, and the detection is repeated
772
+ this.scheduleMetaRedetect(outcome);
670
773
  return this.getRegaData().then(() => {
671
774
  this.regaPoll();
672
775
  });
@@ -686,6 +789,41 @@ module.exports = function (RED) {
686
789
  this.on('close', this.destructor);
687
790
  }
688
791
 
792
+ /**
793
+ * B-31: the password and the token, from the credentials or - once - from the
794
+ * plain properties of a flow written before 4.4.6.
795
+ * @param {object} config the node's configuration as deployed
796
+ * @returns {{password: string, metaToken: string}}
797
+ */
798
+ migrateSecrets(config) {
799
+ const credentials = this.credentials || {};
800
+ const result = {
801
+ password: credentials.password || config.password || '',
802
+ metaToken: credentials.metaToken || config.metaToken || '',
803
+ };
804
+ const plain = ['password', 'metaToken'].filter((key) => config[key] && !credentials[key]);
805
+ if (plain.length > 0) {
806
+ const moved = {...credentials};
807
+ plain.forEach((key) => {
808
+ moved[key] = config[key];
809
+ });
810
+ if (typeof RED.nodes.addCredentials === 'function') {
811
+ RED.nodes.addCredentials(this.id, moved);
812
+ this.warn(
813
+ 'the ' +
814
+ plain.join(' and ') +
815
+ ' of this connection came from the flow in plain text - moved into the credentials, the next deploy removes ' +
816
+ (plain.length > 1 ? 'them' : 'it') +
817
+ ' from flows.json',
818
+ );
819
+ } else {
820
+ this.warn('the ' + plain.join(' and ') + ' of this connection is stored in the flow in plain text');
821
+ }
822
+ }
823
+
824
+ return result;
825
+ }
826
+
689
827
  get logger() {
690
828
  return {
691
829
  trace: (...args) => {
@@ -807,6 +945,11 @@ module.exports = function (RED) {
807
945
  * @param connected
808
946
  */
809
947
  setIfaceStatus(iface, connected, waiting = false) {
948
+ if (!iface || !this.ifaceTypes[iface]) {
949
+ // B-32: never a status line for something that is not an interface of ours
950
+ return;
951
+ }
952
+
810
953
  waiting = Boolean(waiting) && !connected;
811
954
  const waitingChanged = Boolean(this.ifaceWaiting[iface]) !== waiting;
812
955
  if (this.ifaceStatus[iface] !== connected || waitingChanged) {
@@ -1115,6 +1258,8 @@ module.exports = function (RED) {
1115
1258
  this.logger.debug('clear regaPollTimeout');
1116
1259
  this.cancelRegaPoll = true;
1117
1260
  clearTimeout(this.regaPollTimeout);
1261
+ clearTimeout(this.metaRedetectTimeout);
1262
+ this.metaRedetectTimeout = null;
1118
1263
 
1119
1264
  if (this.meta) {
1120
1265
  this.logger.debug('stop metadata event stream');
@@ -1126,6 +1271,8 @@ module.exports = function (RED) {
1126
1271
  this.logger.debug('clear rpcPingTimer', iface);
1127
1272
  clearTimeout(this.rpcPingTimer[iface]);
1128
1273
  });
1274
+ Object.keys(this.liveness).forEach((iface) => this.stopLiveness(iface));
1275
+ clearTimeout(this.valuesPruneTimer);
1129
1276
 
1130
1277
  this.saveRegadata();
1131
1278
  this.saveValues();
@@ -1214,7 +1361,17 @@ module.exports = function (RED) {
1214
1361
  * @returns {Promise<object|null>}
1215
1362
  */
1216
1363
  detectMeta() {
1217
- return metaProvider.detect({
1364
+ return this.detectMetaOutcome().then((outcome) => outcome.info);
1365
+ }
1366
+
1367
+ /**
1368
+ * B-28: the detection with its outcome - `info` for an openccu-lite,
1369
+ * `inconclusive` when nothing was decided (timeout, refused connection,
1370
+ * the web server up but the api behind it not yet).
1371
+ * @returns {Promise<{info: object|null, inconclusive: boolean, reason?: string}>}
1372
+ */
1373
+ detectMetaOutcome() {
1374
+ return metaProvider.detectOutcome({
1218
1375
  host: this.host,
1219
1376
  port: this.metaPort,
1220
1377
  tls: Boolean(this.tlsEnabled),
@@ -1223,6 +1380,81 @@ module.exports = function (RED) {
1223
1380
  });
1224
1381
  }
1225
1382
 
1383
+ /**
1384
+ * B-28: an inconclusive detection (the box busy or still starting) used
1385
+ * to leave the connection in ReGa mode for good - the only re-detection
1386
+ * ran from rare error paths, at most every five minutes. Ask again after
1387
+ * 1, 2, 4, 8 s, then every 15 s, up to META_REDETECT_ATTEMPTS times;
1388
+ * after that the five-minute rule of recheckMeta() applies.
1389
+ * @param {{inconclusive: boolean, reason?: string}} outcome
1390
+ */
1391
+ scheduleMetaRedetect(outcome) {
1392
+ if (!outcome || !outcome.inconclusive || this.metaMode || this.cancelRegaPoll) {
1393
+ this.metaRedetectAttempt = 0;
1394
+ return;
1395
+ }
1396
+
1397
+ this.metaRedetectAttempt = (this.metaRedetectAttempt || 0) + 1;
1398
+ if (this.metaRedetectAttempt > META_REDETECT_ATTEMPTS) {
1399
+ this.logger.info(
1400
+ 'meta api detection still inconclusive (' +
1401
+ outcome.reason +
1402
+ ') after ' +
1403
+ META_REDETECT_ATTEMPTS +
1404
+ ' attempts - staying with the ReGaHSS, detecting again every five minutes',
1405
+ );
1406
+ this.metaRedetectAttempt = 0;
1407
+ return;
1408
+ }
1409
+
1410
+ const delay = retryDelay(this.metaRedetectAttempt);
1411
+ const line =
1412
+ 'meta api detection inconclusive (' +
1413
+ outcome.reason +
1414
+ ') - names come from the ReGaHSS until the box answers, detecting again in ' +
1415
+ delay / 1000 +
1416
+ ' s';
1417
+ if (this.metaRedetectAttempt === 1) {
1418
+ this.logger.info(line);
1419
+ } else {
1420
+ this.logger.debug(line);
1421
+ }
1422
+
1423
+ clearTimeout(this.metaRedetectTimeout);
1424
+ this.metaRedetectTimeout = setTimeout(() => this.redetectMeta(), delay);
1425
+ }
1426
+
1427
+ /** B-28: one scheduled re-detection. */
1428
+ redetectMeta() {
1429
+ this.metaRedetectTimeout = null;
1430
+ if (this.metaMode || this.cancelRegaPoll) {
1431
+ return;
1432
+ }
1433
+
1434
+ this.lastMetaProbe = now();
1435
+ this.detectMetaOutcome().then((outcome) => {
1436
+ if (this.metaMode || this.cancelRegaPoll) {
1437
+ return;
1438
+ }
1439
+
1440
+ if (outcome.info) {
1441
+ this.logger.info('meta api detected after ' + (this.metaRedetectAttempt + 1) + ' attempts');
1442
+ this.metaRedetectAttempt = 0;
1443
+ return this.startMeta(outcome.info);
1444
+ }
1445
+
1446
+ if (outcome.inconclusive) {
1447
+ this.scheduleMetaRedetect(outcome);
1448
+ return;
1449
+ }
1450
+
1451
+ this.metaRedetectAttempt = 0;
1452
+ this.logger.info(
1453
+ 'not an openccu-lite (' + outcome.reason + ') - names, rooms and functions come from the ReGaHSS',
1454
+ );
1455
+ });
1456
+ }
1457
+
1226
1458
  /**
1227
1459
  * Take names, rooms and functions from the openccu-lite metadata api
1228
1460
  * instead of from the ReGaHSS. Resolves as soon as the first snapshot
@@ -1291,11 +1523,14 @@ module.exports = function (RED) {
1291
1523
  this.meta = null;
1292
1524
  this.lastMetaProbe = 0;
1293
1525
  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);
1526
+ this.detectMetaOutcome().then((outcome) => {
1527
+ if (outcome.info) {
1528
+ return this.startMeta(outcome.info);
1297
1529
  }
1298
1530
 
1531
+ // B-28: an unreachable box is not a CCU yet - keep asking
1532
+ this.scheduleMetaRedetect(outcome);
1533
+
1299
1534
  if (this.ifaceTypes.ReGaHSS.enabled && !this.clients.ReGaHSS) {
1300
1535
  this.createClient('ReGaHSS').then(() => this.setIfaceStatus('ReGaHSS', true));
1301
1536
  }
@@ -1341,7 +1576,7 @@ module.exports = function (RED) {
1341
1576
  * picked up without a redeploy.
1342
1577
  */
1343
1578
  recheckMeta() {
1344
- if (this.metaMode || this.cancelRegaPoll || this.metaRecheckPending) {
1579
+ if (this.metaMode || this.cancelRegaPoll || this.metaRecheckPending || this.metaRedetectTimeout) {
1345
1580
  return;
1346
1581
  }
1347
1582
 
@@ -1416,6 +1651,8 @@ module.exports = function (RED) {
1416
1651
  });
1417
1652
  this.cachedValuesReceived = true;
1418
1653
  this.saveValues();
1654
+ // B-36: ReGa keeps the datapoints of a deleted device
1655
+ this.scheduleValuesPrune();
1419
1656
  resolve();
1420
1657
  }
1421
1658
  });
@@ -1663,9 +1900,16 @@ module.exports = function (RED) {
1663
1900
  this.regaPollPending = true;
1664
1901
  clearTimeout(this.regaPollTimeout);
1665
1902
  this.getRegaVariables()
1666
- .catch((error) => this.logger.error('getRegaVariables', error))
1903
+ .catch((error) => {
1904
+ this.logger.error('getRegaVariables', error);
1905
+ // B-28: the poll is what fails on a box without ReGaHSS
1906
+ this.recheckMeta();
1907
+ })
1667
1908
  .then(() => this.getRegaPrograms())
1668
- .catch((error) => this.logger.error('getRegaPrograms', error))
1909
+ .catch((error) => {
1910
+ this.logger.error('getRegaPrograms', error);
1911
+ this.recheckMeta();
1912
+ })
1669
1913
  .finally(() => {
1670
1914
  if (this.regaInterval && this.regaPollEnabled && !this.cancelRegaPoll) {
1671
1915
  //this.logger.trace('rega next poll in', this.regaInterval, 'seconds');
@@ -2108,6 +2352,7 @@ module.exports = function (RED) {
2108
2352
  // a failed one is retried by rpcInitFailed (task 11)
2109
2353
  if (this.ifaceTypes[iface].ping && !this.closing) {
2110
2354
  this.rpcCheckInit(iface);
2355
+ this.startLiveness(iface);
2111
2356
  }
2112
2357
 
2113
2358
  if (iface === 'CUxD') {
@@ -2163,32 +2408,241 @@ module.exports = function (RED) {
2163
2408
  }
2164
2409
 
2165
2410
  const knownDevices = [];
2166
- let change = false;
2411
+ const summary = {total: 0, added: 0, changed: 0, removed: 0};
2167
2412
  devices.forEach((device) => {
2413
+ if (!device || typeof device.ADDRESS !== 'string' || !device.TYPE) {
2414
+ return;
2415
+ }
2416
+
2168
2417
  knownDevices.push(device.ADDRESS);
2169
- if (!this.metadata.devices[iface][device.ADDRESS]) {
2418
+ const known = this.metadata.devices[iface][device.ADDRESS];
2419
+ if (!known) {
2170
2420
  this.newDevice(iface, device);
2171
- change = true;
2421
+ summary.added += 1;
2422
+ } else if (deviceChanged(known, device)) {
2423
+ // task 9: a TYPE/VERSION change since the cache was written
2424
+ this.newDevice(iface, device);
2425
+ summary.changed += 1;
2172
2426
  }
2173
2427
  });
2174
2428
 
2175
2429
  Object.keys(this.metadata.devices[iface]).forEach((addr) => {
2176
2430
  if (!knownDevices.includes(addr)) {
2177
2431
  this.deleteDevice(iface, addr);
2178
- change = true;
2432
+ summary.removed += 1;
2179
2433
  }
2180
2434
  });
2181
2435
 
2182
- if (change) {
2436
+ summary.total = Object.keys(this.metadata.devices[iface]).length;
2437
+ if (summary.added || summary.changed || summary.removed) {
2183
2438
  this.saveMetadata();
2184
2439
  }
2185
2440
 
2186
- resolve();
2441
+ if (summary.removed) {
2442
+ this.scheduleValuesPrune();
2443
+ }
2444
+
2445
+ resolve(summary);
2187
2446
  })
2188
2447
  .catch(reject);
2189
2448
  });
2190
2449
  }
2191
2450
 
2451
+ /**
2452
+ * task 9: re-read the device table of every connected interface from its
2453
+ * process (the button in the connection node's dialog). New and changed
2454
+ * devices get their paramset descriptions fetched, vanished ones go.
2455
+ * @returns {Promise<object>} per interface: total, added, changed, removed - or an error text
2456
+ */
2457
+ rereadDevices() {
2458
+ const ifaces = Object.keys(this.ifaceTypes).filter(
2459
+ (iface) => this.ifaceTypes[iface].enabled && this.ifaceTypes[iface].init && iface !== 'CUxD',
2460
+ );
2461
+ this.logger.info('re-reading device data:', ifaces.join(', '));
2462
+ return Promise.all(
2463
+ ifaces.map((iface) =>
2464
+ this.getDevices(iface)
2465
+ .then((summary) => [iface, summary])
2466
+ .catch((error) => [iface, {error: error.message || String(error)}]),
2467
+ ),
2468
+ ).then((entries) => {
2469
+ const summary = {};
2470
+ entries.forEach(([iface, result]) => {
2471
+ summary[iface] = result;
2472
+ });
2473
+ this.logger.info('device data re-read', JSON.stringify(summary));
2474
+ return summary;
2475
+ });
2476
+ }
2477
+
2478
+ /**
2479
+ * B-29: the liveness ping of an interface with a `pingInterval` (HmIP-RF).
2480
+ * hmipserver forgets its clients when it restarts and does not tell them:
2481
+ * `init` had succeeded, so nothing failed, and the events simply stopped
2482
+ * until the 600 s silence timeout of rpcCheckInit(). After `pingInterval`
2483
+ * seconds without an event a `ping` goes out; when no event (the PONG)
2484
+ * arrives within `pongTimeout` seconds the subscription is lost and `init`
2485
+ * is called again at once - task 11's retry then covers the time the
2486
+ * process is down. Only an event counts as proof; a device callback
2487
+ * (newDevices after a restart) triggers the ping instead. An interface
2488
+ * that never delivered an event since its init is not re-subscribed by
2489
+ * this (a callback address the CCU cannot reach would cost an init, and
2490
+ * hmipserver's full newDevices re-send, every 40 s).
2491
+ * @param iface
2492
+ */
2493
+ startLiveness(iface) {
2494
+ const {pingInterval} = this.ifaceTypes[iface] || {};
2495
+ if (!pingInterval || !this.rpcPingEnabled || this.closing) {
2496
+ return;
2497
+ }
2498
+
2499
+ this.stopLiveness(iface);
2500
+ this.liveness[iface] = {initAt: now(), pingAt: 0, timer: null, pongTimer: null, quietLogged: false};
2501
+ this.scheduleLiveness(iface, pingInterval * 1000);
2502
+ }
2503
+
2504
+ /** @param iface */
2505
+ stopLiveness(iface) {
2506
+ const state = this.liveness[iface];
2507
+ if (state) {
2508
+ clearTimeout(state.timer);
2509
+ clearTimeout(state.pongTimer);
2510
+ delete this.liveness[iface];
2511
+ }
2512
+ }
2513
+
2514
+ /**
2515
+ * @param iface
2516
+ * @param {number} delay ms until the next check
2517
+ */
2518
+ scheduleLiveness(iface, delay) {
2519
+ const state = this.liveness[iface];
2520
+ if (!state) {
2521
+ return;
2522
+ }
2523
+
2524
+ clearTimeout(state.timer);
2525
+ state.timer = setTimeout(() => this.livenessCheck(iface), Math.max(delay, 100));
2526
+ }
2527
+
2528
+ /** @param iface */
2529
+ livenessCheck(iface) {
2530
+ const state = this.liveness[iface];
2531
+ if (!state || this.closing) {
2532
+ return;
2533
+ }
2534
+
2535
+ const {pingInterval} = this.ifaceTypes[iface];
2536
+ const sinceEvent = now() - (this.lastRealEvent[iface] || 0);
2537
+ if (sinceEvent < pingInterval * 1000) {
2538
+ // events arrive: the subscription is alive, no ping needed
2539
+ this.scheduleLiveness(iface, pingInterval * 1000 - sinceEvent);
2540
+ return;
2541
+ }
2542
+
2543
+ this.livenessPing(iface);
2544
+ }
2545
+
2546
+ /** @param iface */
2547
+ livenessPing(iface) {
2548
+ const state = this.liveness[iface];
2549
+ if (!state || state.pongTimer || this.closing) {
2550
+ return;
2551
+ }
2552
+
2553
+ const {pongTimeout} = this.ifaceTypes[iface];
2554
+ clearTimeout(state.timer);
2555
+ state.pingAt = now();
2556
+ this.logger.debug('liveness ping', iface);
2557
+ state.pongTimer = setTimeout(() => this.livenessPongMissing(iface), pongTimeout * 1000);
2558
+ this.methodCall(iface, 'ping', ['nr'], {quiet: true}).catch((error) => {
2559
+ // the process itself does not answer: no need to wait for the PONG
2560
+ const current = this.liveness[iface];
2561
+ if (current && current.pongTimer) {
2562
+ clearTimeout(current.pongTimer);
2563
+ current.pongTimer = null;
2564
+ this.livenessLost(iface, 'ping failed: ' + (error && error.message ? error.message : error));
2565
+ }
2566
+ });
2567
+ }
2568
+
2569
+ /** the PONG did not arrive in time @param iface */
2570
+ livenessPongMissing(iface) {
2571
+ const state = this.liveness[iface];
2572
+ if (!state) {
2573
+ return;
2574
+ }
2575
+
2576
+ state.pongTimer = null;
2577
+ const {pingInterval, pongTimeout} = this.ifaceTypes[iface];
2578
+ if (!this.lastRealEvent[iface] || this.lastRealEvent[iface] < state.initAt) {
2579
+ // never an event since the init: do not re-subscribe on that alone
2580
+ if (!state.quietLogged) {
2581
+ state.quietLogged = true;
2582
+ this.logger.debug(
2583
+ 'liveness',
2584
+ iface,
2585
+ 'no PONG within ' + pongTimeout + ' s and no event since init - not subscribing again',
2586
+ );
2587
+ }
2588
+
2589
+ this.scheduleLiveness(iface, pingInterval * 1000);
2590
+ return;
2591
+ }
2592
+
2593
+ this.livenessLost(iface, 'no answer to a ping within ' + pongTimeout + ' s');
2594
+ }
2595
+
2596
+ /**
2597
+ * The subscription is lost: init again at once.
2598
+ * @param iface
2599
+ * @param {string} reason
2600
+ */
2601
+ livenessLost(iface, reason) {
2602
+ if (!this.liveness[iface] || this.closing) {
2603
+ return;
2604
+ }
2605
+
2606
+ this.stopLiveness(iface);
2607
+ this.logger.warn(iface + ': ' + reason + ' - subscribing again');
2608
+ this.hadTimeout.add(iface);
2609
+ this.setIfaceStatus(iface, false, true);
2610
+ this.rpcInit(iface)
2611
+ .then(() => {
2612
+ this.logger.info(iface + ' subscribed again');
2613
+ this.rpcInitSucceeded(iface);
2614
+ })
2615
+ .catch((error) => this.rpcInitFailed(iface, error));
2616
+ }
2617
+
2618
+ /**
2619
+ * An event arrived (PONG included): the subscription delivers.
2620
+ * @param iface
2621
+ */
2622
+ livenessEvent(iface) {
2623
+ this.lastRealEvent[iface] = now();
2624
+ const state = this.liveness[iface];
2625
+ if (state && state.pongTimer) {
2626
+ clearTimeout(state.pongTimer);
2627
+ state.pongTimer = null;
2628
+ this.scheduleLiveness(iface, this.ifaceTypes[iface].pingInterval * 1000);
2629
+ }
2630
+ }
2631
+
2632
+ /**
2633
+ * A device callback (newDevices, deleteDevices, ...) arrived. After a
2634
+ * restart hmipserver sends newDevices to the read-back handler about
2635
+ * 16 s in, before any event: ask for the PONG at once instead of
2636
+ * waiting for the interval.
2637
+ * @param iface
2638
+ */
2639
+ deviceCallback(iface) {
2640
+ const state = this.liveness[iface];
2641
+ if (state && !state.pongTimer && !this.closing) {
2642
+ this.livenessPing(iface);
2643
+ }
2644
+ }
2645
+
2192
2646
  /**
2193
2647
  *
2194
2648
  * @param iface
@@ -2198,15 +2652,21 @@ module.exports = function (RED) {
2198
2652
  return;
2199
2653
  }
2200
2654
 
2201
- if (!this.metadata.devices[iface] || !Object.keys(this.metadata.devices[iface]).length > 0) {
2202
- return;
2203
- }
2655
+ // task 9 (old B-11): the watchdog used to return here for an interface without
2656
+ // devices in the cache, so such an interface never pinged and never re-initialised
2657
+ // after a CCU reboot. It pings now; it re-initialises only when the interface has
2658
+ // devices or has ever delivered an event (a PONG counts) - a process that answers
2659
+ // no ping at all is not asked to init every timeout.
2660
+ const hasDevices = Boolean(
2661
+ this.metadata.devices[iface] && Object.keys(this.metadata.devices[iface]).length > 0,
2662
+ );
2663
+ const mayReinit = hasDevices || Boolean(this.lastRealEvent[iface]);
2204
2664
 
2205
2665
  clearTimeout(this.rpcPingTimer[iface]);
2206
2666
  const pingTimeout = this.ifaceTypes[iface].pingTimeout || this.rpcPingTimeout;
2207
2667
  const elapsed = Math.round((now() - (this.lastEvent[iface] || 0)) / 1000);
2208
2668
  this.logger.debug('rpcCheckInit', iface, elapsed, pingTimeout);
2209
- if (elapsed > pingTimeout) {
2669
+ if (elapsed > pingTimeout && mayReinit) {
2210
2670
  this.hadTimeout.add(iface);
2211
2671
  this.setIfaceStatus(iface, false);
2212
2672
  this.logger.warn('ping timeout', iface, elapsed);
@@ -2358,12 +2818,10 @@ module.exports = function (RED) {
2358
2818
  }
2359
2819
 
2360
2820
  this.logger.debug('rpc <', protocol, method, JSON.stringify(parameters));
2361
- if (method === 'event') {
2362
- method = 'eventSingle';
2363
- }
2821
+ const handler = method === 'event' ? 'eventSingle' : method;
2364
2822
 
2365
2823
  if (isIterable(parameters)) {
2366
- this.rpcMethods[method](err, parameters, callback);
2824
+ this.callRpcMethod(handler, err, parameters, callback);
2367
2825
  } else {
2368
2826
  this.logger.error(
2369
2827
  'rpc <',
@@ -2373,7 +2831,7 @@ module.exports = function (RED) {
2373
2831
  'params not iterable',
2374
2832
  JSON.stringify(parameters),
2375
2833
  );
2376
- callback(null, '');
2834
+ callback(null, this.rpcDefaultAnswer(method));
2377
2835
  }
2378
2836
  });
2379
2837
  });
@@ -2453,7 +2911,13 @@ module.exports = function (RED) {
2453
2911
  if (device && device.PARAMSETS) {
2454
2912
  device.PARAMSETS.forEach((paramset) => {
2455
2913
  const name = this.paramsetName(iface, device, paramset);
2456
- if (!this.paramsetDescriptions[name]) {
2914
+ if (
2915
+ !this.paramsetDescriptions[name] &&
2916
+ // B-36: once per description key in the queue, and never again
2917
+ // for an address/paramset the interface refused in this run
2918
+ !this.paramsetFailed.has(iface + '/' + device.ADDRESS + '/' + paramset) &&
2919
+ !(name && this.paramsetQueue.some((item) => item.name === name))
2920
+ ) {
2457
2921
  this.paramsetQueue.push({
2458
2922
  iface,
2459
2923
  name,
@@ -2489,7 +2953,7 @@ module.exports = function (RED) {
2489
2953
  clearTimeout(this.getParamsetTimeout);
2490
2954
  setImmediate(() => this.paramsetQueueShift());
2491
2955
  } else {
2492
- this.methodCall(iface, 'getParamsetDescription', [address, paramset])
2956
+ this.methodCall(iface, 'getParamsetDescription', [address, paramset], {quiet: true})
2493
2957
  .then((res) => {
2494
2958
  //this.logger.trace('paramsetDescription', name);
2495
2959
  this.newParamsetDescriptionCount += 1;
@@ -2501,7 +2965,31 @@ module.exports = function (RED) {
2501
2965
  this.saveParamsets();
2502
2966
  }
2503
2967
  })
2504
- .catch((error) => this.logger.error(error))
2968
+ .catch((error) => {
2969
+ // B-36: one line naming the address and the paramset; a
2970
+ // fault is remembered, a transport error may be retried
2971
+ if (error && error.faultCode !== undefined) {
2972
+ this.paramsetFailed.add(iface + '/' + address + '/' + paramset);
2973
+ this.logger.warn(
2974
+ 'getParamsetDescription',
2975
+ iface,
2976
+ address,
2977
+ paramset,
2978
+ 'fault',
2979
+ error.faultCode,
2980
+ error.faultString || error.message,
2981
+ '- not asked again until the device is added anew',
2982
+ );
2983
+ } else {
2984
+ this.logger.error(
2985
+ 'getParamsetDescription',
2986
+ iface,
2987
+ address,
2988
+ paramset,
2989
+ error && error.message ? error.message : error,
2990
+ );
2991
+ }
2992
+ })
2505
2993
  .then(() => {
2506
2994
  this.paramsetPending = false;
2507
2995
  clearTimeout(this.getParamsetTimeout);
@@ -2558,12 +3046,39 @@ module.exports = function (RED) {
2558
3046
  this.metadata.types[iface] = {};
2559
3047
  }
2560
3048
 
2561
- if (this.metadata.devices[iface][device.ADDRESS]) {
3049
+ const known = this.metadata.devices[iface][device.ADDRESS];
3050
+ if (known && deviceChanged(known, device)) {
3051
+ // task 9 (#146, #181): a channel whose TYPE (or the device's VERSION/FIRMWARE)
3052
+ // changed in place kept its old datapoints; the paramset key changes with
3053
+ // it, so the description is fetched anew below, and the old type list
3054
+ // forgets the address
3055
+ this.logger.info(
3056
+ 'device data changed',
3057
+ iface,
3058
+ device.ADDRESS,
3059
+ known.TYPE + '/' + (known.FIRMWARE || '-') + '/' + (known.VERSION || '-'),
3060
+ '->',
3061
+ device.TYPE + '/' + (device.FIRMWARE || '-') + '/' + (device.VERSION || '-'),
3062
+ );
3063
+ if (this.metadata.types[iface][known.TYPE]) {
3064
+ this.metadata.types[iface][known.TYPE] = this.metadata.types[iface][known.TYPE].filter(
3065
+ (addr) => addr !== device.ADDRESS,
3066
+ );
3067
+ if (this.metadata.types[iface][known.TYPE].length === 0) {
3068
+ delete this.metadata.types[iface][known.TYPE];
3069
+ }
3070
+ }
3071
+ } else if (known) {
2562
3072
  this.logger.trace('newDevice (already known)', iface, device.ADDRESS);
2563
3073
  } else {
2564
3074
  this.logger.debug('newDevice', iface, device.ADDRESS);
2565
3075
  }
2566
3076
 
3077
+ if (!known || deviceChanged(known, device)) {
3078
+ // B-36: a device added anew (re-paired, or its data changed) is asked again
3079
+ this.forgetParamsetFailures(iface, device.ADDRESS);
3080
+ }
3081
+
2567
3082
  this.metadata.devices[iface][device.ADDRESS] = device;
2568
3083
 
2569
3084
  if (!device.TYPE) {
@@ -2571,13 +3086,12 @@ module.exports = function (RED) {
2571
3086
  throw new Error('device type undefined: ' + JSON.stringify(device));
2572
3087
  }
2573
3088
 
2574
- if (
2575
- this.metadata.types[iface][device.TYPE] &&
2576
- !this.metadata.types[iface][device.TYPE].includes(device.ADDRESS)
2577
- ) {
2578
- this.metadata.types[iface][device.TYPE].push(device.ADDRESS);
2579
- } else {
3089
+ if (!this.metadata.types[iface][device.TYPE]) {
2580
3090
  this.metadata.types[iface][device.TYPE] = [device.ADDRESS];
3091
+ } else if (!this.metadata.types[iface][device.TYPE].includes(device.ADDRESS)) {
3092
+ // an address already listed stays listed with the others (the old
3093
+ // else branch replaced the whole list with it)
3094
+ this.metadata.types[iface][device.TYPE].push(device.ADDRESS);
2581
3095
  }
2582
3096
 
2583
3097
  if (device.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER') {
@@ -2594,7 +3108,116 @@ module.exports = function (RED) {
2594
3108
  */
2595
3109
  deleteDevice(iface, device) {
2596
3110
  this.logger.debug('deleteDevice', iface, device);
2597
- delete this.metadata.devices[iface][device];
3111
+ const known = this.metadata.devices[iface] && this.metadata.devices[iface][device];
3112
+ if (this.metadata.devices[iface]) {
3113
+ delete this.metadata.devices[iface][device];
3114
+ }
3115
+
3116
+ // B-36: nothing of a deleted device is fetched or listed by type any more -
3117
+ // its queued descriptions (with those of its channels) and its address in
3118
+ // metadata.types go with it. Its cached values go with the next
3119
+ // scheduleValuesPrune(): hmipserver has been seen to answer an init with
3120
+ // deleteDevices and newDevices for every device it has (eq-3/occu#45), so
3121
+ // a value dropped here could belong to a device that is back a moment later.
3122
+ const ofDevice = (address) => address === device || address.startsWith(device + ':');
3123
+ this.paramsetQueue = this.paramsetQueue.filter((item) => item.iface !== iface || !ofDevice(item.address));
3124
+ this.paramsQueue = this.paramsQueue.filter((item) => item.iface !== iface || !ofDevice(item.address));
3125
+
3126
+ const types = this.metadata.types && this.metadata.types[iface];
3127
+ if (types) {
3128
+ const typeNames = known && known.TYPE ? [known.TYPE] : Object.keys(types);
3129
+ typeNames.forEach((type) => {
3130
+ if (Array.isArray(types[type])) {
3131
+ types[type] = types[type].filter((addr) => addr !== device);
3132
+ if (types[type].length === 0) {
3133
+ delete types[type];
3134
+ }
3135
+ }
3136
+ });
3137
+ }
3138
+ }
3139
+
3140
+ /**
3141
+ * B-36: the device table decides which cached values and type lists are
3142
+ * kept - at the start the cache files may still hold a device that was
3143
+ * deleted while Node-RED was not running.
3144
+ */
3145
+ pruneCaches() {
3146
+ const types = (this.metadata && this.metadata.types) || {};
3147
+ Object.keys(types).forEach((iface) => {
3148
+ const devices = this.metadata.devices && this.metadata.devices[iface];
3149
+ if (!devices) {
3150
+ return;
3151
+ }
3152
+
3153
+ Object.keys(types[iface]).forEach((type) => {
3154
+ if (!Array.isArray(types[iface][type])) {
3155
+ return;
3156
+ }
3157
+
3158
+ types[iface][type] = types[iface][type].filter((addr) => devices[addr]);
3159
+ if (types[iface][type].length === 0) {
3160
+ delete types[iface][type];
3161
+ }
3162
+ });
3163
+ });
3164
+
3165
+ const pruned = this.pruneValues();
3166
+ if (pruned) {
3167
+ this.logger.info('values cache: dropped', pruned, 'values of devices not in the device table');
3168
+ this.saveValues();
3169
+ }
3170
+ }
3171
+
3172
+ /**
3173
+ * B-36: prune the values cache against the device table a few seconds after
3174
+ * the last deleteDevices or ReGa getValues - once hmipserver's
3175
+ * deleteDevices/newDevices pair of an init has settled, and after ReGa
3176
+ * reported the datapoints it keeps of a deleted device again.
3177
+ */
3178
+ scheduleValuesPrune() {
3179
+ clearTimeout(this.valuesPruneTimer);
3180
+ this.valuesPruneTimer = setTimeout(() => {
3181
+ this.valuesPruneTimer = null;
3182
+ const pruned = this.pruneValues();
3183
+ if (pruned) {
3184
+ this.logger.info('values cache: dropped', pruned, 'values of devices not in the device table');
3185
+ this.saveValues();
3186
+ }
3187
+ }, this.valuesPruneDelay);
3188
+ }
3189
+
3190
+ /**
3191
+ * B-36: drop cached values whose channel is not in its interface's device
3192
+ * table. An interface without a table (not read yet) keeps its values.
3193
+ * @returns {number} how many were dropped
3194
+ */
3195
+ pruneValues() {
3196
+ let pruned = 0;
3197
+ const tables = (this.metadata && this.metadata.devices) || {};
3198
+ Object.keys(this.values).forEach((datapointName) => {
3199
+ const [iface, channel] = datapointName.split('.');
3200
+ const devices = tables[iface];
3201
+ if (channel && devices && Object.keys(devices).length > 0 && !devices[channel]) {
3202
+ delete this.values[datapointName];
3203
+ pruned += 1;
3204
+ }
3205
+ });
3206
+ return pruned;
3207
+ }
3208
+
3209
+ /**
3210
+ * B-36: a device added anew is asked for its descriptions again.
3211
+ * @param iface
3212
+ * @param address device or channel address
3213
+ */
3214
+ forgetParamsetFailures(iface, address) {
3215
+ const prefix = iface + '/' + address;
3216
+ for (const key of this.paramsetFailed) {
3217
+ if (key.startsWith(prefix + '/') || key.startsWith(prefix + ':')) {
3218
+ this.paramsetFailed.delete(key);
3219
+ }
3220
+ }
2598
3221
  }
2599
3222
 
2600
3223
  /**
@@ -2607,10 +3230,11 @@ module.exports = function (RED) {
2607
3230
  if (this.metadata.devices[iface]) {
2608
3231
  Object.keys(this.metadata.devices[iface]).forEach((addr) => {
2609
3232
  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
- }
3233
+ // The CCU's own virtual remote control (HmIP-RCV-1, type HmIP-RCV-50) is listed
3234
+ // like any other device. It was left out since CCU3 firmware 3.43.15 (2019),
3235
+ // whose hmipserver failed on an answer that held it; current hmipservers do
3236
+ // not, and without it every init made hmipserver log a handleIDMigration
3237
+ // warning and send the whole virtual remote again with newDevices (task 13).
2614
3238
 
2615
3239
  if (dev.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER') {
2616
3240
  //this.paramsQueuePush(iface, dev);
@@ -2684,98 +3308,209 @@ module.exports = function (RED) {
2684
3308
  * @returns {*}
2685
3309
  */
2686
3310
  getIfaceFromIdInit(idInit) {
3311
+ if (typeof idInit !== 'string') {
3312
+ return null;
3313
+ }
3314
+
2687
3315
  if (idInit === 'CUxD') {
2688
3316
  return idInit;
2689
3317
  }
2690
3318
 
2691
3319
  const match = idInit.match(/^nr_[\da-zA-Z]{6}_([a-zA-Z-]+)$/);
2692
- return match && match[1];
3320
+ return match && this.ifaceTypes[match[1]] ? match[1] : null;
3321
+ }
3322
+
3323
+ /**
3324
+ * B-32: the interface a callback call belongs to, or null with a debug line when the
3325
+ * first parameter is not one of our own init ids. Anything on the system can connect
3326
+ * to the callback ports on the loopback; such a call is answered, never thrown on.
3327
+ * @param {string} method
3328
+ * @param {Array} parameters
3329
+ * @returns {string|null}
3330
+ */
3331
+ rpcIface(method, parameters) {
3332
+ const [idInit] = parameters;
3333
+ const iface = this.getIfaceFromIdInit(idInit);
3334
+ if (!iface) {
3335
+ this.logger.debug('rpc <', method, 'unknown interface id', JSON.stringify(idInit));
3336
+ }
3337
+
3338
+ return iface;
3339
+ }
3340
+
3341
+ /**
3342
+ * B-32: what a callback call is answered with when it cannot be handled.
3343
+ * @param {string} method
3344
+ * @returns {Array|string}
3345
+ */
3346
+ rpcDefaultAnswer(method) {
3347
+ if (method === 'system.listMethods') {
3348
+ return Object.keys(this.rpcMethods);
3349
+ }
3350
+
3351
+ if (method === 'listDevices' || method === 'system.multicall') {
3352
+ return [];
3353
+ }
3354
+
3355
+ return '';
3356
+ }
3357
+
3358
+ /**
3359
+ * B-32: run a callback handler so that a throw inside it is logged and answered
3360
+ * instead of ending the process (the RPC servers call us from an event emitter,
3361
+ * so an exception there is uncaught). The callback is answered exactly once.
3362
+ * @param {string} method the key in rpcMethods
3363
+ * @param {*} err
3364
+ * @param {Array} parameters
3365
+ * @param {function} callback
3366
+ */
3367
+ callRpcMethod(method, err, parameters, callback) {
3368
+ let answered = false;
3369
+ const answer = (error, result) => {
3370
+ if (!answered) {
3371
+ answered = true;
3372
+ callback(error, result);
3373
+ }
3374
+ };
3375
+
3376
+ try {
3377
+ this.rpcMethods[method](err, parameters, answer);
3378
+ } catch (error) {
3379
+ this.logger.error(
3380
+ 'rpc <',
3381
+ method,
3382
+ 'failed:',
3383
+ error && error.message ? error.message : String(error),
3384
+ JSON.stringify(parameters),
3385
+ );
3386
+ answer(null, this.rpcDefaultAnswer(method === 'eventSingle' ? 'event' : method));
3387
+ }
2693
3388
  }
2694
3389
 
2695
3390
  get rpcMethods() {
2696
3391
  return {
2697
3392
  'system.listMethods': (_, parameters, callback) => {
2698
- const [idInit] = parameters;
2699
- const iface = this.getIfaceFromIdInit(idInit);
2700
- this.lastEvent[iface] = now();
2701
- this.setIfaceStatus(iface, true);
3393
+ const iface = this.rpcIface('system.listMethods', parameters);
2702
3394
  const res = Object.keys(this.rpcMethods);
3395
+ if (iface) {
3396
+ this.lastEvent[iface] = now();
3397
+ this.setIfaceStatus(iface, true);
3398
+ }
3399
+
2703
3400
  this.logger.debug(' >', iface, 'system.listMethods', JSON.stringify(res));
2704
3401
  callback(null, res);
2705
3402
  },
2706
3403
  setReadyConfig: (_, parameters, callback) => {
2707
- const [idInit] = parameters;
2708
- const iface = this.getIfaceFromIdInit(idInit);
3404
+ const iface = this.rpcIface('setReadyConfig', parameters);
2709
3405
  this.logger.debug(' >', iface, 'setReadyConfig ""');
2710
3406
  callback(null, '');
2711
3407
  },
2712
3408
  updateDevice: (_, parameters, callback) => {
2713
- const [idInit] = parameters;
2714
- const iface = this.getIfaceFromIdInit(idInit);
3409
+ const iface = this.rpcIface('updateDevice', parameters);
2715
3410
  this.logger.debug(' >', iface, 'updateDevice ""');
2716
3411
  callback(null, '');
3412
+ if (iface) {
3413
+ this.deviceCallback(iface);
3414
+ }
2717
3415
  },
2718
3416
  replaceDevice: (_, parameters, callback) => {
2719
- const [idInit] = parameters;
2720
- const iface = this.getIfaceFromIdInit(idInit);
3417
+ const iface = this.rpcIface('replaceDevice', parameters);
2721
3418
  this.logger.debug(' >', iface, 'replaceDevice ""');
2722
3419
  callback(null, '');
3420
+ if (iface) {
3421
+ this.deviceCallback(iface);
3422
+ }
2723
3423
  },
2724
3424
  readdedDevice: (_, parameters, callback) => {
2725
- const [idInit] = parameters;
2726
- const iface = this.getIfaceFromIdInit(idInit);
3425
+ const iface = this.rpcIface('readdedDevice', parameters);
2727
3426
  this.logger.debug(' >', iface, 'readdedDevice ""');
2728
3427
  callback(null, '');
3428
+ if (iface) {
3429
+ this.deviceCallback(iface);
3430
+ }
2729
3431
  },
2730
3432
  newDevices: (_, parameters, callback) => {
2731
- const [idInit, devices] = parameters;
2732
- const iface = this.getIfaceFromIdInit(idInit);
3433
+ const [, devices] = parameters;
3434
+ const iface = this.rpcIface('newDevices', parameters);
2733
3435
 
2734
- devices.forEach((device) => {
2735
- this.newDevice(iface, device);
2736
- });
3436
+ let changed = false;
3437
+ if (iface && Array.isArray(devices)) {
3438
+ devices.forEach((device) => {
3439
+ if (device && typeof device.ADDRESS === 'string' && device.TYPE) {
3440
+ this.newDevice(iface, device);
3441
+ changed = true;
3442
+ } else {
3443
+ this.logger.warn(
3444
+ 'newDevices',
3445
+ iface,
3446
+ 'skipping malformed entry',
3447
+ JSON.stringify(device),
3448
+ );
3449
+ }
3450
+ });
3451
+ }
2737
3452
 
2738
3453
  this.logger.debug(' >', iface, 'newDevices ""');
2739
3454
  callback(null, '');
2740
3455
 
2741
- this.saveMetadata();
3456
+ if (changed) {
3457
+ this.saveMetadata();
3458
+ }
3459
+
3460
+ if (iface) {
3461
+ this.deviceCallback(iface);
3462
+ }
2742
3463
  },
2743
3464
  deleteDevices: (_, parameters, callback) => {
2744
- const [idInit, devices] = parameters;
2745
- const iface = this.getIfaceFromIdInit(idInit);
3465
+ const [, devices] = parameters;
3466
+ const iface = this.rpcIface('deleteDevices', parameters);
2746
3467
 
2747
- devices.forEach((device) => {
2748
- this.deleteDevice(iface, device);
2749
- });
3468
+ let changed = false;
3469
+ if (iface && Array.isArray(devices) && this.metadata.devices[iface]) {
3470
+ devices.forEach((device) => {
3471
+ if (typeof device === 'string' && this.metadata.devices[iface][device]) {
3472
+ this.deleteDevice(iface, device);
3473
+ changed = true;
3474
+ }
3475
+ });
3476
+ }
2750
3477
 
2751
3478
  this.logger.debug(' >', iface, 'deleteDevices ""');
2752
3479
  callback(null, '');
2753
3480
 
2754
- this.saveMetadata();
3481
+ if (changed) {
3482
+ this.saveMetadata();
3483
+ this.scheduleValuesPrune();
3484
+ }
3485
+
3486
+ if (iface) {
3487
+ this.deviceCallback(iface);
3488
+ }
2755
3489
  },
2756
3490
  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) || [];
3491
+ const iface = this.rpcIface('listDevices', parameters);
3492
+ let res = [];
3493
+ if (iface) {
3494
+ this.lastEvent[iface] = now();
3495
+ this.setIfaceStatus(iface, true);
3496
+ res = this.listDevices(iface) || [];
3497
+ }
3498
+
2762
3499
  this.logger.debug(' >', iface, 'listDevices', JSON.stringify(res));
2763
3500
  callback(null, res);
2764
3501
  },
2765
3502
  event: (_, parameters, callback) => {
2766
- const [idInit] = parameters;
2767
- const iface = this.getIfaceFromIdInit(idInit);
3503
+ const iface = this.rpcIface('event', parameters);
2768
3504
  this.logger.debug(' >', iface, 'event ""');
2769
3505
  this.publishEvent(parameters);
2770
3506
  callback(null, '');
2771
3507
  },
2772
3508
  eventSingle: (_, parameters, callback) => {
2773
- const [idInit] = parameters;
2774
- const iface = this.getIfaceFromIdInit(idInit);
3509
+ const iface = this.rpcIface('event', parameters);
2775
3510
  this.logger.debug(' >', iface, 'event ""');
2776
3511
  this.publishEvent(parameters);
2777
3512
 
2778
- if (parameters[2] !== 'PONG') {
3513
+ if (iface && parameters[2] !== 'PONG') {
2779
3514
  if (this.rxCounters[iface]) {
2780
3515
  this.rxCounters[iface] += 1;
2781
3516
  } else {
@@ -2796,8 +3531,8 @@ module.exports = function (RED) {
2796
3531
  if (isIterable(parameters[0])) {
2797
3532
  parameters[0].forEach((call) => {
2798
3533
  if (call && call.methodName === 'event') {
2799
- queue.push(call);
2800
3534
  if (isIterable(call.params)) {
3535
+ queue.push(call);
2801
3536
  const [idInit, , datapoint, value] = call.params;
2802
3537
  if (datapoint !== 'PONG') {
2803
3538
  pong = false;
@@ -2819,16 +3554,32 @@ module.exports = function (RED) {
2819
3554
  }
2820
3555
  }
2821
3556
 
2822
- iface = this.getIfaceFromIdInit(idInit);
3557
+ iface = this.getIfaceFromIdInit(idInit) || iface;
3558
+ } else {
3559
+ this.logger.debug(
3560
+ 'rpc <',
3561
+ 'event',
3562
+ 'params not iterable',
3563
+ JSON.stringify(call.params),
3564
+ );
2823
3565
  }
2824
3566
 
2825
3567
  result.push('');
2826
- } else if (call && this.rpcMethods[call.methodName]) {
3568
+ } else if (
3569
+ call &&
3570
+ call.methodName !== 'system.multicall' &&
3571
+ this.rpcMethods[call.methodName]
3572
+ ) {
2827
3573
  pong = false;
2828
3574
  if (isIterable(call.params)) {
2829
- this.rpcMethods[call.methodName](call.methodName, call.params, (_, res) =>
2830
- result.push(res),
2831
- );
3575
+ let answered = false;
3576
+ this.callRpcMethod(call.methodName, null, call.params, (_, res) => {
3577
+ answered = true;
3578
+ result.push(res);
3579
+ });
3580
+ if (!answered) {
3581
+ result.push(this.rpcDefaultAnswer(call.methodName));
3582
+ }
2832
3583
  } else {
2833
3584
  this.logger.error(
2834
3585
  'rpc <',
@@ -2836,7 +3587,11 @@ module.exports = function (RED) {
2836
3587
  'params not iterable',
2837
3588
  JSON.stringify(call.params),
2838
3589
  );
3590
+ result.push(this.rpcDefaultAnswer(call.methodName));
2839
3591
  }
3592
+ } else {
3593
+ // B-32: an unknown or nested method keeps the answer's shape
3594
+ result.push('');
2840
3595
  }
2841
3596
  });
2842
3597
  queue.forEach((call) => {
@@ -3063,7 +3818,14 @@ module.exports = function (RED) {
3063
3818
  const [idInit, channel, datapoint, payload] = parameters;
3064
3819
  const iface = this.getIfaceFromIdInit(idInit);
3065
3820
 
3821
+ if (!iface || typeof channel !== 'string' || typeof datapoint !== 'string') {
3822
+ // B-32: not one of our interfaces, or not an event's shape - nothing to publish
3823
+ this.logger.debug('rpc <', 'event', 'ignored', JSON.stringify(parameters));
3824
+ return;
3825
+ }
3826
+
3066
3827
  this.lastEvent[iface] = now();
3828
+ this.livenessEvent(iface);
3067
3829
  if (this.hadTimeout.has(iface)) {
3068
3830
  this.setIfaceStatus(iface, true);
3069
3831
  }
@@ -3304,7 +4066,25 @@ module.exports = function (RED) {
3304
4066
  if (this.clients[iface]) {
3305
4067
  this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
3306
4068
  this.clients[iface].methodCall(method, parameters, (err, res) => {
3307
- if (err) {
4069
+ if (err && err.faultCode !== undefined) {
4070
+ // B-36: homematic-xmlrpc delivers a fault as an Error with
4071
+ // faultCode/faultString. The process answered - the client is
4072
+ // fine and stays; one line, with the arguments that were refused.
4073
+ this.logger[quiet ? 'debug' : 'error'](
4074
+ ' <',
4075
+ iface,
4076
+ method,
4077
+ JSON.stringify(parameters),
4078
+ 'fault',
4079
+ err.faultCode,
4080
+ err.faultString,
4081
+ );
4082
+ if (err.faultString === undefined) {
4083
+ err.faultString = err.message;
4084
+ }
4085
+
4086
+ reject(err);
4087
+ } else if (err) {
3308
4088
  this.logger[quiet ? 'debug' : 'error'](' <', iface, method, err);
3309
4089
  this.closeClient(iface);
3310
4090
  // a closed node creates no new client (it would reconnect forever)
@@ -3314,7 +4094,15 @@ module.exports = function (RED) {
3314
4094
 
3315
4095
  reject(err);
3316
4096
  } else if (res && res.faultCode) {
3317
- this.logger[quiet ? 'debug' : 'error'](' <', iface, method, JSON.stringify(res));
4097
+ this.logger[quiet ? 'debug' : 'error'](
4098
+ ' <',
4099
+ iface,
4100
+ method,
4101
+ JSON.stringify(parameters),
4102
+ 'fault',
4103
+ res.faultCode,
4104
+ res.faultString,
4105
+ );
3318
4106
  const fault = new Error(res.faultString);
3319
4107
  fault.faultCode = res.faultCode;
3320
4108
  fault.faultString = res.faultString;
@@ -3371,7 +4159,14 @@ module.exports = function (RED) {
3371
4159
  const datapointName = iface + '.' + address + '.' + datapoint;
3372
4160
  const currentValue = this.values[datapointName] && this.values[datapointName].value;
3373
4161
  const cache = this.values[datapointName] && this.values[datapointName].cache;
3374
- if (force || value !== currentValue || cache || datapoint.startsWith('PRESS_')) {
4162
+ // B-19: a cached value that is the echo of our own last write does not
4163
+ // prove the actuator is there - a blind moved by hand keeps its control
4164
+ // channel's set point (#151). Only a value from another source dedupes:
4165
+ // one that differs from what we wrote last, or one reported before it.
4166
+ const lastWrite = this.lastWrite[datapointName];
4167
+ const cachedTs = this.values[datapointName] && this.values[datapointName].ts;
4168
+ const ownEcho = Boolean(lastWrite) && lastWrite.value === currentValue && cachedTs > lastWrite.ts;
4169
+ if (force || value !== currentValue || cache || ownEcho || datapoint.startsWith('PRESS_')) {
3375
4170
  this.setValueQueue.push({iface, address, datapoint, value, burst, resolve, reject});
3376
4171
  this.setValueShiftQueue();
3377
4172
  } else {
@@ -3433,8 +4228,16 @@ module.exports = function (RED) {
3433
4228
  * @returns {Promise<any>}
3434
4229
  */
3435
4230
  setValue(iface, address, datapoint, value, burst) {
4231
+ this.lastWrite[iface + '.' + address + '.' + datapoint] = {value, ts: now()};
3436
4232
  const device = this.metadata.devices[iface] && this.metadata.devices[iface][address];
3437
4233
  const description = this.paramsetDescriptions[this.paramsetName(iface, device, 'VALUES')];
4234
+ if (isPartyWrite(datapoint, description)) {
4235
+ // an HmIP thermostat takes PARTY_TIME_START, PARTY_TIME_END and
4236
+ // PARTY_SET_POINT_TEMPERATURE only together - one alone resets the
4237
+ // other two (#156, #161): write all three in one putParamset
4238
+ return this.setPartyValues(iface, address, datapoint, value);
4239
+ }
4240
+
3438
4241
  const combined = combinedParameterValue(datapoint, value, description);
3439
4242
  if (combined !== null) {
3440
4243
  // HmIP actuators accept but ignore a lone LEVEL_2 write; the CCU
@@ -3477,6 +4280,80 @@ module.exports = function (RED) {
3477
4280
  });
3478
4281
  }
3479
4282
 
4283
+ /**
4284
+ * Party mode on an HmIP thermostat (task 2, #156, #161): the written
4285
+ * datapoint merged with the current values of the other two - our own
4286
+ * last write when it is newer than the cache, else the cached value,
4287
+ * else the values read from the interface process (hmipserver answers
4288
+ * getParamset from its own state) - into one putParamset on VALUES.
4289
+ * @param {string} iface
4290
+ * @param {string} address the channel
4291
+ * @param {string} datapoint the one being written
4292
+ * @param {*} value
4293
+ * @returns {Promise<any>}
4294
+ */
4295
+ setPartyValues(iface, address, datapoint, value) {
4296
+ const current = {};
4297
+ for (const name of PARTY_DATAPOINTS) {
4298
+ if (name === datapoint) {
4299
+ continue;
4300
+ }
4301
+
4302
+ const key = iface + '.' + address + '.' + name;
4303
+ const cached = this.values[key];
4304
+ const written = this.lastWrite[key];
4305
+ if (written && (!cached || !(cached.ts > written.ts))) {
4306
+ current[name] = written.value;
4307
+ } else if (cached && cached.value !== undefined) {
4308
+ current[name] = cached.value;
4309
+ }
4310
+ }
4311
+
4312
+ let merged = mergePartyValues(datapoint, value, current);
4313
+ const read =
4314
+ merged.missing.length > 0
4315
+ ? this.methodCall(iface, 'getParamset', [address, 'VALUES']).then((values) => {
4316
+ merged.missing.forEach((name) => {
4317
+ if (values && values[name] !== undefined) {
4318
+ current[name] = values[name];
4319
+ }
4320
+ });
4321
+ merged = mergePartyValues(datapoint, value, current);
4322
+ })
4323
+ : Promise.resolve();
4324
+
4325
+ return read.then(() => {
4326
+ if (merged.missing.length > 0) {
4327
+ throw new Error(
4328
+ 'party mode: no current value for ' +
4329
+ merged.missing.join(', ') +
4330
+ ' on ' +
4331
+ iface +
4332
+ ' ' +
4333
+ address,
4334
+ );
4335
+ }
4336
+
4337
+ const parameters = {};
4338
+ Object.keys(merged.values).forEach((name) => {
4339
+ parameters[name] = this.paramCast(iface, address, 'VALUES', name, merged.values[name]);
4340
+ this.lastWrite[iface + '.' + address + '.' + name] = {value: merged.values[name], ts: now()};
4341
+ });
4342
+ this.logger.debug('setValue', datapoint, value, '-> putParamset VALUES', JSON.stringify(parameters));
4343
+ return this.methodCall(iface, 'putParamset', [address, 'VALUES', parameters]).catch((error) => {
4344
+ this.logger.error(
4345
+ 'rpc >',
4346
+ iface,
4347
+ 'putParamset',
4348
+ JSON.stringify([address, 'VALUES', parameters]),
4349
+ '<',
4350
+ error,
4351
+ );
4352
+ throw error;
4353
+ });
4354
+ });
4355
+ }
4356
+
3480
4357
  /**
3481
4358
  *
3482
4359
  * @param id
@@ -3540,5 +4417,10 @@ module.exports = function (RED) {
3540
4417
  }
3541
4418
  }
3542
4419
 
3543
- RED.nodes.registerType('ccu-connection', CcuConnectionNode, {});
4420
+ RED.nodes.registerType('ccu-connection', CcuConnectionNode, {
4421
+ credentials: {
4422
+ password: {type: 'password'},
4423
+ metaToken: {type: 'password'},
4424
+ },
4425
+ });
3544
4426
  };