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.
- package/CHANGELOG.md +175 -0
- package/README.md +22 -0
- package/nodes/ccu-alexa.html +12 -14
- package/nodes/ccu-connection.html +63 -95
- package/nodes/ccu-connection.js +1009 -127
- package/nodes/ccu-get-value.html +18 -15
- package/nodes/ccu-get-value.js +16 -0
- package/nodes/ccu-homeassistant.html +0 -48
- package/nodes/ccu-mqtt.html +0 -8
- package/nodes/ccu-poll.html +0 -17
- package/nodes/ccu-program.html +0 -53
- package/nodes/ccu-rpc-event.html +0 -34
- package/nodes/ccu-rpc.html +0 -64
- package/nodes/ccu-script.html +0 -8
- package/nodes/ccu-set-value.html +0 -62
- package/nodes/ccu-set-value.js +6 -11
- package/nodes/ccu-signal.html +0 -64
- package/nodes/ccu-switch.html +12 -24
- package/nodes/ccu-sysvar.html +0 -64
- package/nodes/ccu-value.html +23 -95
- package/nodes/ccu-value.js +8 -2
- package/nodes/lib/initretry.js +11 -0
- package/nodes/lib/metaprovider.js +47 -19
- package/nodes/lib/party.js +103 -0
- package/nodes/lib/topic.js +22 -1
- package/nodes/lib/transport.js +80 -0
- package/nodes/locales/de/ccu-alexa.html +4 -0
- package/nodes/locales/de/ccu-connection.html +103 -0
- package/nodes/locales/de/ccu-get-value.html +16 -0
- package/nodes/locales/de/ccu-homeassistant.html +23 -0
- package/nodes/locales/de/ccu-mqtt.html +21 -0
- package/nodes/locales/de/ccu-poll.html +8 -0
- package/nodes/locales/de/ccu-program.html +26 -0
- package/nodes/locales/de/ccu-rpc-event.html +32 -0
- package/nodes/locales/de/ccu-rpc.html +31 -0
- package/nodes/locales/de/ccu-script.html +13 -0
- package/nodes/locales/de/ccu-set-value.html +46 -0
- package/nodes/locales/de/ccu-signal.html +29 -0
- package/nodes/locales/de/ccu-switch.html +18 -0
- package/nodes/locales/de/ccu-sysvar.html +43 -0
- package/nodes/locales/de/ccu-value.html +98 -0
- package/nodes/locales/de/ccu-value.json +1 -0
- package/nodes/locales/en-US/ccu-alexa.html +4 -0
- package/nodes/locales/en-US/ccu-connection.html +41 -0
- package/nodes/locales/en-US/ccu-get-value.html +13 -0
- package/nodes/locales/en-US/ccu-homeassistant.html +23 -0
- package/nodes/locales/en-US/ccu-mqtt.html +21 -0
- package/nodes/locales/en-US/ccu-poll.html +7 -0
- package/nodes/locales/en-US/ccu-program.html +25 -0
- package/nodes/locales/en-US/ccu-rpc-event.html +31 -0
- package/nodes/locales/en-US/ccu-rpc.html +30 -0
- package/nodes/locales/en-US/ccu-script.html +13 -0
- package/nodes/locales/en-US/ccu-set-value.html +45 -0
- package/nodes/locales/en-US/ccu-signal.html +29 -0
- package/nodes/locales/en-US/ccu-switch.html +6 -0
- package/nodes/locales/en-US/ccu-sysvar.html +42 -0
- package/nodes/locales/en-US/ccu-value.html +81 -0
- package/nodes/locales/en-US/ccu-value.json +1 -0
- package/package.json +2 -8
package/nodes/ccu-connection.js
CHANGED
|
@@ -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 ||
|
|
435
|
-
port: this.isLocal ? 32001 :
|
|
436
|
-
protocol: this.isLocal ||
|
|
437
|
-
auth
|
|
438
|
-
user
|
|
439
|
-
pass
|
|
440
|
-
tls
|
|
441
|
-
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 :
|
|
530
|
+
port: this.isLocal ? 32000 : tls ? 42000 : 2000,
|
|
449
531
|
protocol: this.isLocal ? 'binrpc' : 'http',
|
|
450
|
-
auth
|
|
451
|
-
user
|
|
452
|
-
pass
|
|
453
|
-
tls
|
|
454
|
-
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 :
|
|
543
|
+
port: this.isLocal ? 32010 : tls ? 42010 : 2010,
|
|
462
544
|
protocol: 'http',
|
|
463
|
-
auth
|
|
464
|
-
user
|
|
465
|
-
pass
|
|
466
|
-
tls
|
|
467
|
-
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 :
|
|
561
|
+
port: this.isLocal ? 39292 : tls ? 49292 : 9292,
|
|
476
562
|
path: 'groups',
|
|
477
563
|
protocol: 'http',
|
|
478
|
-
auth
|
|
479
|
-
user
|
|
480
|
-
pass
|
|
481
|
-
tls
|
|
482
|
-
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) || (
|
|
583
|
+
port: Number(config.jackPort) || (tls ? 2122 : 2121),
|
|
498
584
|
path: 'RPC3',
|
|
499
585
|
protocol: 'http',
|
|
500
|
-
auth
|
|
501
|
-
user
|
|
502
|
-
pass
|
|
503
|
-
tls
|
|
504
|
-
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 =
|
|
557
|
-
this.inSecure =
|
|
558
|
-
this.metaPort = Number.parseInt(config.metaPort, 10) || (
|
|
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 :
|
|
643
|
-
tls
|
|
644
|
-
insecure:
|
|
645
|
-
username:
|
|
646
|
-
password:
|
|
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.
|
|
665
|
-
.then((
|
|
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
|
|
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.
|
|
1295
|
-
if (
|
|
1296
|
-
return this.startMeta(
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
2418
|
+
const known = this.metadata.devices[iface][device.ADDRESS];
|
|
2419
|
+
if (!known) {
|
|
2170
2420
|
this.newDevice(iface, device);
|
|
2171
|
-
|
|
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
|
-
|
|
2432
|
+
summary.removed += 1;
|
|
2179
2433
|
}
|
|
2180
2434
|
});
|
|
2181
2435
|
|
|
2182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
2362
|
-
method = 'eventSingle';
|
|
2363
|
-
}
|
|
2821
|
+
const handler = method === 'event' ? 'eventSingle' : method;
|
|
2364
2822
|
|
|
2365
2823
|
if (isIterable(parameters)) {
|
|
2366
|
-
this.
|
|
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 (
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 [
|
|
2732
|
-
const iface = this.
|
|
3433
|
+
const [, devices] = parameters;
|
|
3434
|
+
const iface = this.rpcIface('newDevices', parameters);
|
|
2733
3435
|
|
|
2734
|
-
|
|
2735
|
-
|
|
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
|
-
|
|
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 [
|
|
2745
|
-
const iface = this.
|
|
3465
|
+
const [, devices] = parameters;
|
|
3466
|
+
const iface = this.rpcIface('deleteDevices', parameters);
|
|
2746
3467
|
|
|
2747
|
-
|
|
2748
|
-
|
|
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
|
-
|
|
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
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
2830
|
-
|
|
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'](
|
|
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
|
-
|
|
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
|
};
|