node-red-contrib-ccu 4.6.2 → 4.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,57 @@ Notable changes to node-red-contrib-ccu. Format follows
5
5
  user-visible symptom and the cause, not the commit list (the release notes
6
6
  append commits automatically).
7
7
 
8
+ ## 4.7.0 (2026-10-27)
9
+
10
+ ### Added
11
+
12
+ - **On openccu-lite the interfaces are reached through the system's lite-rpc**
13
+ (with [occulite-client](https://www.npmjs.com/package/occulite-client))
14
+ instead of XML-RPC/BIN-RPC clients and callback servers of this node's own -
15
+ the requests over the system's web port, the events from a stream the
16
+ connection opens, replayed after a break and re-read after a restart of the
17
+ system; one subscriber at the interface processes serves every program on
18
+ the system, and nothing registers with `init`. The credential is the
19
+ openccu-lite token of the connection node, or on the system itself
20
+ RedMatic's own token, which the system mints for it. A new setting
21
+ _openccu-lite interfaces_ in the connection node chooses: _auto_ (the
22
+ default; the CCU way on a CCU), _openccu-lite_ (lite-rpc only) or _CCU_ (the
23
+ direct connection with callbacks, on openccu-lite too). An existing
24
+ connection is moved to lite-rpc once, at its first start on openccu-lite,
25
+ and the log says so; a choice made in the node is never changed by a later
26
+ version. The value cache is filled from the system's state store at deploy,
27
+ so nodes with _cache_ get a value on openccu-lite as well. CUxD and CCU-Jack
28
+ keep their own connections. On a CCU, RaspberryMatic and OpenCCU nothing
29
+ changes.
30
+
31
+ ### Changed
32
+
33
+ - **An interface process that is not up yet is tried every 3 s for the first
34
+ 90 s, then every 15 s.** The 1, 2, 4, 8 s steps and the 15 s after them
35
+ missed hmipserver's ready moment on a booting system by up to 15 s (measured
36
+ on an openccu-lite Pi 4: hmipserver answers 50-55 s after the boot, the next
37
+ attempt came at 61 s). The same holds after a process went away. When one
38
+ interface appears, the others still waiting are tried at once, and the log
39
+ line that reports the connection says how long after the start (or after the
40
+ outage) it came. The metadata API's re-detection on openccu-lite follows the
41
+ same schedule.
42
+ - **The port of each interface can be set in the connection node**, beside its
43
+ checkbox: blank is the CCU's port for the transport as before (2001/42001 and
44
+ so on, the 3xxxx ports on the CCU itself), a number replaces it - for a CCU
45
+ behind port forwarding, or a simulator on another port.
46
+ - The test suite runs hm-simulator 1.4 on free ports, so it runs beside a CCU
47
+ or another suite, and drives the simulator's scenario API: devices paired
48
+ and removed while Node-RED runs, a ramp-time write, a service message, an
49
+ interface process that restarts and forgets its clients, TLS with basic
50
+ auth.
51
+ - **On openccu-lite the metadata stream reports as `Metadata`, not as
52
+ "Interface ReGaHSS".** The system has no ReGaHSS; the connection borrowed
53
+ that slot for the stream's state, so the log said "Interface ReGaHSS
54
+ connected" at every start and reconnect, right next to "this box has no
55
+ ReGaHSS". The log now says "Metadata stream connected", the nodes' status
56
+ counts the stream under that name, and the editors' interface lists do not
57
+ offer it.
58
+
8
59
  ## 4.6.2 (2026-10-23)
9
60
 
10
61
  ### Fixed
package/README.md CHANGED
@@ -36,8 +36,22 @@ BINRPC/XMLRPC works unchanged. Device, channel, room and function names come fro
36
36
  ReGaHSS; the connection node detects this at start (`GET /api/meta/v1/version`), nothing has to be configured, and
37
37
  `msg.channelName`, `msg.rooms`, `msg.functions` and the room/function filters keep their shape.
38
38
 
39
- - Node-RED **on the system** (RedMatic from its addon catalogue): the system's own read-only token is used, nothing to do.
40
- - Node-RED **elsewhere**: an API token of the system goes into the connection node's **openccu-lite token** field.
39
+ Since 4.7.0 the interface processes are reached through the system's **lite-rpc** as well, with
40
+ [occulite-client](https://www.npmjs.com/package/occulite-client): the requests over the system's web port, the events
41
+ from a stream the connection opens (replayed after a break, re-read after a restart of the system), one subscriber at
42
+ the processes for every program on the system, no callback server and no `init` of this node's own; the value cache is
43
+ filled from the system's state store at deploy. The connection node's **openccu-lite interfaces** setting chooses
44
+ _auto_ (the default: lite-rpc on openccu-lite, the CCU way on a CCU), _openccu-lite_ (lite-rpc only) or _CCU_ (the
45
+ direct connection with callbacks, on openccu-lite too); an existing connection is moved to lite-rpc once at its first
46
+ start on openccu-lite, the log says so, and a choice made in the node is never changed by a later version. CUxD and
47
+ CCU-Jack keep their own connections.
48
+
49
+ - Node-RED **on the system** (RedMatic from its addon catalogue): lite-rpc with RedMatic's own token, which the system
50
+ mints for it from RedMatic's manifest (RedMatic 9.13 or later); the names come with the system's read-only token.
51
+ Nothing to configure. An older RedMatic has no such token and connects the CCU way, the log says so.
52
+ - Node-RED **elsewhere**: an API token of the system goes into the connection node's **openccu-lite token** field -
53
+ for lite-rpc it needs the `rpc:*` scope of what the flow does (`rpc:read` for events and reads, `rpc:operate` for
54
+ `setValue`, `rpc:configure` for paramsets and links, `rpc:admin` for the rest; the rpc node may call anything).
41
55
  - **System variables, programs and HM-Script do not exist there.** The `ccu-sysvar`, `ccu-program`, `ccu-script` and
42
56
  `ccu-poll` nodes stay in the flows and answer every message with an error.
43
57
 
@@ -13,6 +13,12 @@
13
13
  cuxdEnabled: {value: false},
14
14
  jackEnabled: {value: false},
15
15
  jackPort: {value: 2121},
16
+ regaPort: {value: ''},
17
+ bcrfPort: {value: ''},
18
+ bcwiPort: {value: ''},
19
+ iprfPort: {value: ''},
20
+ virtPort: {value: ''},
21
+ cuxdPort: {value: ''},
16
22
 
17
23
  regaPoll: {value: true},
18
24
  regaInterval: {value: 30},
@@ -31,6 +37,7 @@
31
37
  username: {value: ''},
32
38
 
33
39
  metaPort: {value: ''},
40
+ connectionType: {value: 'auto'},
34
41
 
35
42
  queueTimeout: {value: 5000, required: true},
36
43
  queuePause: {value: 250, required: true},
@@ -223,12 +230,18 @@
223
230
  <div class="form-row">
224
231
  <label for="node-input-rfdEnabled"><i class="icon-tag"></i> Interfaces</label>
225
232
  <div style="width: 70%; display: inline-block; vertical-align: text-top;">
226
- <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-regaEnabled"> ReGaHSS</label><br>
227
- <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-bcrfEnabled"> BidCos-RF</label><br>
228
- <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-bcwiEnabled"> BidCos-Wired</label><br>
229
- <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-iprfEnabled"> HmIP-RF</label><br>
230
- <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-virtEnabled"> VirtualDevices</label><br>
231
- <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-cuxdEnabled"> CUxD</label><br>
233
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-regaEnabled"> ReGaHSS</label>
234
+ <input type="text" id="node-config-input-regaPort" class="ccu-port" style="width: 70px;" placeholder="8181"><br>
235
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-bcrfEnabled"> BidCos-RF</label>
236
+ <input type="text" id="node-config-input-bcrfPort" class="ccu-port" style="width: 70px;" placeholder="2001"><br>
237
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-bcwiEnabled"> BidCos-Wired</label>
238
+ <input type="text" id="node-config-input-bcwiPort" class="ccu-port" style="width: 70px;" placeholder="2000"><br>
239
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-iprfEnabled"> HmIP-RF</label>
240
+ <input type="text" id="node-config-input-iprfPort" class="ccu-port" style="width: 70px;" placeholder="2010"><br>
241
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-virtEnabled"> VirtualDevices</label>
242
+ <input type="text" id="node-config-input-virtPort" class="ccu-port" style="width: 70px;" placeholder="9292"><br>
243
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-cuxdEnabled"> CUxD</label>
244
+ <input type="text" id="node-config-input-cuxdPort" class="ccu-port" style="width: 70px;" placeholder="8701"><br>
232
245
  <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-jackEnabled"> CCU-Jack</label>
233
246
  <input type="text" id="node-config-input-jackPort" style="width: 70px;" placeholder="2121">
234
247
  </div>
@@ -300,10 +313,18 @@
300
313
  <label for="node-config-input-password"><i class="icon-tag"></i> Password</label>
301
314
  <input type="password" id="node-config-input-password">
302
315
  </div>
316
+ <div class="form-row">
317
+ <label for="node-config-input-connectionType"><i class="icon-tag"></i> openccu-lite interfaces</label>
318
+ <select id="node-config-input-connectionType" style="width: 70%;">
319
+ <option value="auto">auto: through the system's lite-rpc on openccu-lite (the default), the CCU way elsewhere</option>
320
+ <option value="openccu-lite">openccu-lite: lite-rpc only</option>
321
+ <option value="ccu">CCU: directly, with callbacks (XML-RPC/BIN-RPC) - on openccu-lite too</option>
322
+ </select>
323
+ </div>
303
324
  <div class="form-row">
304
325
  <label for="node-config-input-metaToken"><i class="icon-tag"></i> openccu-lite token</label>
305
326
  <input type="password" id="node-config-input-metaToken" placeholder="olt_...">
306
- <small>Only for openccu-lite (a CCU ignores this). Leave empty when Node-RED runs on the box.</small>
327
+ <small>Only for openccu-lite (a CCU ignores this). Leave empty when Node-RED runs on the system itself (RedMatic).</small>
307
328
  </div>
308
329
  <div class="form-row">
309
330
  <label for="node-config-input-metaPort"><i class="icon-tag"></i> openccu-lite port</label>
@@ -17,7 +17,8 @@ const nextport = require('./lib/nextport.js');
17
17
  const hmDiscover = require('./lib/discover.js');
18
18
  const {Rega} = require('homematic-rega'); // ES module - require(esm) needs Node >= 20.19 / >= 22.12
19
19
  const metaProvider = require('./lib/metaprovider.js');
20
- const {InitRetry, retryDelay} = require('./lib/initretry.js');
20
+ const {InitRetry, retryDelay, RETRY_INTERVAL} = require('./lib/initretry.js');
21
+ const lite = require('./lib/lite.js');
21
22
  const xmlrpc = require('homematic-xmlrpc');
22
23
  const binrpc = require('binrpc');
23
24
 
@@ -55,8 +56,20 @@ function execToCallback(promise, callback) {
55
56
  );
56
57
  }
57
58
 
58
- /** B-28: how often an inconclusive metadata api detection is repeated (1, 2, 4, 8 s, then every 15 s) */
59
- const META_REDETECT_ATTEMPTS = 20;
59
+ /**
60
+ * task 16: the pseudo-interface that carries the metadata stream's state on openccu-lite in
61
+ * ifaceTypes/ifaceStatus, where the ReGaHSS slot was borrowed before - the nodes' status logic
62
+ * works on those tables, and the system has no ReGaHSS to report. Never an RPC interface: no
63
+ * client, no init, not offered in the editors' interface lists (pseudo).
64
+ */
65
+ const META_IFACE = 'Metadata';
66
+
67
+ /**
68
+ * B-28: how often an inconclusive metadata api detection is repeated on the init retry's schedule
69
+ * (task 17: every 3 s for 90 s, then every 15 s - 41 attempts reach the same 255 s the 1/2/4/8/15 s
70
+ * steps covered in 20)
71
+ */
72
+ const META_REDETECT_ATTEMPTS = 41;
60
73
 
61
74
  /**
62
75
  * task 9: has a device description changed in a way that changes its paramsets?
@@ -167,6 +180,11 @@ module.exports = function (RED) {
167
180
  switch (request.query.type) {
168
181
  case 'ifaces': {
169
182
  Object.keys(config.ifaceTypes).forEach((iface) => {
183
+ if (config.ifaceTypes[iface].pseudo) {
184
+ // task 16: the metadata stream is not an interface a node could address
185
+ return;
186
+ }
187
+
170
188
  object[iface] = {
171
189
  enabled: Boolean(config.ifaceTypes[iface].enabled),
172
190
  connected: Boolean(config.ifaceStatus[iface]),
@@ -512,6 +530,8 @@ module.exports = function (RED) {
512
530
  const pass = auth ? config.password : undefined;
513
531
 
514
532
  this.ifaceTypes = {
533
+ // task 15: a port set in the dialog wins over the default of the transport (a CCU
534
+ // behind port forwarding, hm-simulator on a free port); blank is the default
515
535
  ReGaHSS: {
516
536
  conf: 'rega',
517
537
  rpc: this.isLocal ? binrpc : xmlrpc,
@@ -523,7 +543,7 @@ module.exports = function (RED) {
523
543
  'BidCos-RF': {
524
544
  conf: 'bcrf',
525
545
  rpc: this.isLocal || transport.bcrfBinRpc ? binrpc : xmlrpc,
526
- port: this.isLocal ? 32001 : tls ? 42001 : 2001,
546
+ port: Number(config.bcrfPort) || (this.isLocal ? 32001 : tls ? 42001 : 2001),
527
547
  protocol: this.isLocal || transport.bcrfBinRpc ? 'binrpc' : 'http',
528
548
  auth,
529
549
  user,
@@ -536,7 +556,7 @@ module.exports = function (RED) {
536
556
  'BidCos-Wired': {
537
557
  conf: 'bcwi',
538
558
  rpc: this.isLocal ? binrpc : xmlrpc,
539
- port: this.isLocal ? 32000 : tls ? 42000 : 2000,
559
+ port: Number(config.bcwiPort) || (this.isLocal ? 32000 : tls ? 42000 : 2000),
540
560
  protocol: this.isLocal ? 'binrpc' : 'http',
541
561
  auth,
542
562
  user,
@@ -549,7 +569,7 @@ module.exports = function (RED) {
549
569
  'HmIP-RF': {
550
570
  conf: 'iprf',
551
571
  rpc: xmlrpc,
552
- port: this.isLocal ? 32010 : tls ? 42010 : 2010,
572
+ port: Number(config.iprfPort) || (this.isLocal ? 32010 : tls ? 42010 : 2010),
553
573
  protocol: 'http',
554
574
  auth,
555
575
  user,
@@ -567,7 +587,7 @@ module.exports = function (RED) {
567
587
  VirtualDevices: {
568
588
  conf: 'virt',
569
589
  rpc: xmlrpc,
570
- port: this.isLocal ? 39292 : tls ? 49292 : 9292,
590
+ port: Number(config.virtPort) || (this.isLocal ? 39292 : tls ? 49292 : 9292),
571
591
  path: 'groups',
572
592
  protocol: 'http',
573
593
  auth,
@@ -581,7 +601,7 @@ module.exports = function (RED) {
581
601
  CUxD: {
582
602
  conf: 'cuxd',
583
603
  rpc: binrpc,
584
- port: 8701,
604
+ port: Number(config.cuxdPort) || 8701,
585
605
  protocol: 'binrpc',
586
606
  init: true,
587
607
  ping: true,
@@ -636,8 +656,10 @@ module.exports = function (RED) {
636
656
  this.lastRealEvent = {};
637
657
  this.ifaceStatus = {};
638
658
  // task 11: interfaces whose init failed and is retried (InitRetry per iface),
639
- // and which of them are waiting for an unreachable process
659
+ // and which of them are waiting for an unreachable process; task 17: when this
660
+ // node started, for the retry's "connected … after the start" line
640
661
  this.initRetry = {};
662
+ this.startedAt = Date.now();
641
663
  this.ifaceWaiting = {};
642
664
  // B-37: per interface {inFlight, at, reinit} of our own init, and how long after it the
643
665
  // process's listDevices/newDevices still answer it (see keptSubscription())
@@ -659,6 +681,17 @@ module.exports = function (RED) {
659
681
  this.inSecure = inSecure;
660
682
  this.metaPort = Number.parseInt(config.metaPort, 10) || (tls ? 443 : 80);
661
683
  this.metaToken = config.metaToken || metaProvider.readLocalToken();
684
+ // task 14: the way to the interfaces on openccu-lite - `auto` (the default) goes through
685
+ // the system's lite-rpc where the system is openccu-lite and a credential allows it,
686
+ // `openccu-lite` insists on it, `ccu` keeps the direct connection with callbacks there
687
+ // too. The credential for lite-rpc: the node's token, else the addon's own (RedMatic's,
688
+ // minted by the system); the local token reads names only and is no credential here.
689
+ this.connectionType = ['ccu', 'openccu-lite'].includes(config.connectionType)
690
+ ? config.connectionType
691
+ : 'auto';
692
+ this.liteToken = config.metaToken || lite.readAddonToken();
693
+ this.lite = null;
694
+ this.liteIfaces = [];
662
695
  this.metaMode = false;
663
696
  this.meta = null;
664
697
  // #167: minutes between re-reads of channel names, rooms and
@@ -742,6 +775,12 @@ module.exports = function (RED) {
742
775
  RED.settings.userDir || path.join(__dirname, '..'),
743
776
  'ccu_values_' + this.host + '.json',
744
777
  );
778
+ // task 14: written once, when a connection of type `auto` first went through lite-rpc -
779
+ // the one-time move is said in the log once, not at every start
780
+ this.liteMarkerFile = path.join(
781
+ RED.settings.userDir || path.join(__dirname, '..'),
782
+ 'ccu_lite_' + this.host + '.json',
783
+ );
745
784
 
746
785
  this.loadMetadata();
747
786
  this.loadRegadata();
@@ -752,7 +791,7 @@ module.exports = function (RED) {
752
791
 
753
792
  this.rega = new Rega({
754
793
  host: this.host,
755
- port: this.isLocal ? 8183 : tls ? 48181 : 8181,
794
+ port: Number(config.regaPort) || (this.isLocal ? 8183 : tls ? 48181 : 8181),
756
795
  tls,
757
796
  insecure: inSecure,
758
797
  username: user,
@@ -804,7 +843,7 @@ module.exports = function (RED) {
804
843
 
805
844
  /**
806
845
  * B-31: the password and the token, from the credentials or - once - from the
807
- * plain properties of a flow written before 4.4.6.
846
+ * plain properties of a flow written before 4.5.0.
808
847
  * @param {object} config the node's configuration as deployed
809
848
  * @returns {{password: string, metaToken: string}}
810
849
  */
@@ -967,7 +1006,10 @@ module.exports = function (RED) {
967
1006
  const waitingChanged = Boolean(this.ifaceWaiting[iface]) !== waiting;
968
1007
  if (this.ifaceStatus[iface] !== connected || waitingChanged) {
969
1008
  if (this.ifaceStatus[iface] !== connected) {
970
- if (iface === 'ReGaHSS') {
1009
+ if (this.ifaceTypes[iface].pseudo) {
1010
+ // task 16: the stream of the system's metadata api, not an interface process
1011
+ this.logger.info(iface, 'stream', connected ? 'connected' : 'disconnected');
1012
+ } else if (iface === 'ReGaHSS') {
971
1013
  this.logger.info('Interface', iface, connected ? 'connected' : 'disconnected');
972
1014
  } else if (waiting) {
973
1015
  // task 11: the retry logs the one warn line, this stays quiet
@@ -977,7 +1019,9 @@ module.exports = function (RED) {
977
1019
  'Interface',
978
1020
  iface,
979
1021
  connected
980
- ? this.ifaceTypes[iface].protocol +
1022
+ ? this.lite && this.lite.has(iface)
1023
+ ? 'lite-rpc connected'
1024
+ : this.ifaceTypes[iface].protocol +
981
1025
  ' port ' +
982
1026
  this.ifaceTypes[iface].port +
983
1027
  ' connected'
@@ -993,11 +1037,7 @@ module.exports = function (RED) {
993
1037
  delete this.ifaceWaiting[iface];
994
1038
  }
995
1039
 
996
- Object.keys(this.users).forEach((id) => {
997
- if (typeof this.users[id].setStatus === 'function') {
998
- this.users[id].setStatus({ifaceStatus: this.ifaceStatus, ifaceWaiting: this.ifaceWaiting});
999
- }
1000
- });
1040
+ this.pushStatus();
1001
1041
  }
1002
1042
  }
1003
1043
 
@@ -1011,6 +1051,7 @@ module.exports = function (RED) {
1011
1051
  this.initRetry[iface] = new InitRetry({
1012
1052
  iface,
1013
1053
  logger: this.logger,
1054
+ startedAt: this.startedAt,
1014
1055
  attempt: () => this.rpcInit(iface, {retry: true}),
1015
1056
  onState: (state) => {
1016
1057
  if (state === 'connected') {
@@ -1026,6 +1067,15 @@ module.exports = function (RED) {
1026
1067
  return this.initRetry[iface];
1027
1068
  }
1028
1069
 
1070
+ /** the interface tables as they are now, to every node of this connection */
1071
+ pushStatus() {
1072
+ Object.keys(this.users).forEach((id) => {
1073
+ if (typeof this.users[id].setStatus === 'function') {
1074
+ this.users[id].setStatus({ifaceStatus: this.ifaceStatus, ifaceWaiting: this.ifaceWaiting});
1075
+ }
1076
+ });
1077
+ }
1078
+
1029
1079
  /**
1030
1080
  * An init succeeded (the first one, a retry, or a re-init after a ping timeout).
1031
1081
  * @param iface
@@ -1036,6 +1086,21 @@ module.exports = function (RED) {
1036
1086
  } else {
1037
1087
  this.setIfaceStatus(iface, true);
1038
1088
  }
1089
+
1090
+ this.retryMissingNow(iface);
1091
+ }
1092
+
1093
+ /**
1094
+ * task 17: an interface appeared - every other one whose init retry is waiting on its timer
1095
+ * is tried at once (rfd and hmipserver come up close together on a booting system).
1096
+ * @param {string} iface the one that appeared
1097
+ */
1098
+ retryMissingNow(iface) {
1099
+ Object.keys(this.initRetry).forEach((other) => {
1100
+ if (other !== iface) {
1101
+ this.initRetry[other].poke();
1102
+ }
1103
+ });
1039
1104
  }
1040
1105
 
1041
1106
  /**
@@ -1266,6 +1331,7 @@ module.exports = function (RED) {
1266
1331
  this.logger.debug('ccu-connection destructor');
1267
1332
  this.closing = true;
1268
1333
  this.stopInitRetries();
1334
+ this.stopLite();
1269
1335
  this.stats(false);
1270
1336
 
1271
1337
  this.logger.debug('clear regaPollTimeout');
@@ -1396,9 +1462,10 @@ module.exports = function (RED) {
1396
1462
  /**
1397
1463
  * B-28: an inconclusive detection (the box busy or still starting) used
1398
1464
  * to leave the connection in ReGa mode for good - the only re-detection
1399
- * ran from rare error paths, at most every five minutes. Ask again after
1400
- * 1, 2, 4, 8 s, then every 15 s, up to META_REDETECT_ATTEMPTS times;
1401
- * after that the five-minute rule of recheckMeta() applies.
1465
+ * ran from rare error paths, at most every five minutes. Ask again on the
1466
+ * init retry's schedule (every 3 s for 90 s, then every 15 s - task 17), up
1467
+ * to META_REDETECT_ATTEMPTS times; after that the five-minute rule of
1468
+ * recheckMeta() applies.
1402
1469
  * @param {{inconclusive: boolean, reason?: string}} outcome
1403
1470
  */
1404
1471
  scheduleMetaRedetect(outcome) {
@@ -1420,7 +1487,7 @@ module.exports = function (RED) {
1420
1487
  return;
1421
1488
  }
1422
1489
 
1423
- const delay = retryDelay(this.metaRedetectAttempt);
1490
+ const delay = retryDelay((this.metaRedetectAttempt - 1) * RETRY_INTERVAL);
1424
1491
  const line =
1425
1492
  'meta api detection inconclusive (' +
1426
1493
  outcome.reason +
@@ -1487,6 +1554,21 @@ module.exports = function (RED) {
1487
1554
  this.closeClient('ReGaHSS');
1488
1555
  }
1489
1556
 
1557
+ // task 16: the metadata stream's state is the Metadata pseudo-interface's, not the
1558
+ // ReGaHSS slot's - the system has none, and "Interface ReGaHSS connected" misled
1559
+ this.ifaceTypes[META_IFACE] = {
1560
+ conf: 'meta',
1561
+ pseudo: true,
1562
+ init: false,
1563
+ ping: false,
1564
+ protocol: this.tlsEnabled ? 'https' : 'http',
1565
+ port: this.metaPort,
1566
+ enabled: true,
1567
+ };
1568
+ delete this.ifaceStatus.ReGaHSS;
1569
+ delete this.ifaceWaiting.ReGaHSS;
1570
+ this.ifaceStatus[META_IFACE] = null;
1571
+
1490
1572
  this.logger.info(
1491
1573
  'openccu-lite detected (' +
1492
1574
  (info.implementation || 'meta api') +
@@ -1529,12 +1611,29 @@ module.exports = function (RED) {
1529
1611
  resolve();
1530
1612
  },
1531
1613
  onStatus: (connected) => {
1532
- this.setIfaceStatus('ReGaHSS', connected);
1614
+ this.setIfaceStatus(META_IFACE, connected);
1533
1615
  },
1534
1616
  onGone: () => {
1535
1617
  this.metaMode = false;
1536
1618
  this.meta = null;
1537
1619
  this.lastMetaProbe = 0;
1620
+ if (this.lite) {
1621
+ // task 14: lite-rpc went with the system's answer; the CCU way needs a deploy
1622
+ this.stopLite();
1623
+ this.liteIfaces.forEach((iface) => this.setIfaceStatus(iface, false));
1624
+ this.logger.warn(
1625
+ 'lite-rpc closed: the system no longer answers as openccu-lite - deploy again to reach the interfaces the CCU way',
1626
+ );
1627
+ }
1628
+
1629
+ // task 16: the ReGaHSS slot is the interface again
1630
+ delete this.ifaceTypes[META_IFACE];
1631
+ delete this.ifaceStatus[META_IFACE];
1632
+ if (this.ifaceTypes.ReGaHSS.enabled) {
1633
+ this.ifaceStatus.ReGaHSS = null;
1634
+ }
1635
+
1636
+ this.pushStatus();
1538
1637
  this.logger.warn('this box no longer answers as openccu-lite - detecting again');
1539
1638
  this.detectMetaOutcome().then((outcome) => {
1540
1639
  if (outcome.info) {
@@ -2217,9 +2316,16 @@ module.exports = function (RED) {
2217
2316
  * @param config
2218
2317
  */
2219
2318
  initIfaces(config) {
2319
+ // task 14: the interfaces that go through lite-rpc get no client and no init of their own
2320
+ const liteIfaces = this.liteInterfaces(config);
2321
+ this.liteIfaces = liteIfaces;
2220
2322
  Object.keys(this.ifaceTypes).forEach((iface) => {
2221
2323
  const enabled = config[this.ifaceTypes[iface].conf + 'Enabled'];
2222
2324
  this.ifaceTypes[iface].enabled = enabled;
2325
+ if (enabled && liteIfaces.includes(iface)) {
2326
+ return;
2327
+ }
2328
+
2223
2329
  if (enabled && iface === 'ReGaHSS' && this.metaMode) {
2224
2330
  // B-27: openccu-lite has no ReGaHSS, nothing listens on 31999.
2225
2331
  // A client would only reconnect forever (with binrpc < 4.3.0
@@ -2249,6 +2355,249 @@ module.exports = function (RED) {
2249
2355
  }
2250
2356
  });
2251
2357
  this.logger.info('Interfaces:', this.enabledIfaces.join(', '));
2358
+ if (liteIfaces.length > 0) {
2359
+ this.startLite(liteIfaces);
2360
+ }
2361
+ }
2362
+
2363
+ /**
2364
+ * task 14: which of the enabled interfaces go through the system's lite-rpc - the ones
2365
+ * lite-rpc carries (not CUxD, not CCU-Jack), on an openccu-lite system, with a credential,
2366
+ * unless the connection type says `ccu`.
2367
+ * @param {object} config the node's config
2368
+ * @returns {string[]}
2369
+ */
2370
+ liteInterfaces(config) {
2371
+ const enabled = lite.LITE_INTERFACES.filter((iface) => config[this.ifaceTypes[iface].conf + 'Enabled']);
2372
+ if (this.connectionType === 'ccu') {
2373
+ if (this.metaMode) {
2374
+ this.logger.info(
2375
+ 'openccu-lite: the direct connection with callbacks by choice (connection type CCU) - the interfaces are reached on their own ports',
2376
+ );
2377
+ }
2378
+
2379
+ return [];
2380
+ }
2381
+
2382
+ if (!this.metaMode) {
2383
+ if (this.connectionType === 'openccu-lite') {
2384
+ this.logger.warn(
2385
+ 'connection type openccu-lite, but the system did not answer as openccu-lite - the interfaces are reached the CCU way until it does',
2386
+ );
2387
+ }
2388
+
2389
+ return [];
2390
+ }
2391
+
2392
+ if (!this.liteToken) {
2393
+ this.logger.info(
2394
+ 'openccu-lite without a credential for lite-rpc (no token in the connection node, no addon token in ' +
2395
+ lite.ADDON_TOKEN_FILE +
2396
+ ') - the interfaces are reached directly, with callbacks',
2397
+ );
2398
+ return [];
2399
+ }
2400
+
2401
+ if (enabled.length === 0) {
2402
+ return [];
2403
+ }
2404
+
2405
+ return enabled;
2406
+ }
2407
+
2408
+ /**
2409
+ * task 14: the init id the callback table expects for an interface - the same shape rpcInit
2410
+ * registers, so getIfaceFromIdInit() recognises the stream's calls.
2411
+ * @param {string} iface
2412
+ * @returns {string}
2413
+ */
2414
+ liteInitId(iface) {
2415
+ const hash = base62(crypto.createHash('sha1').update(this.initUrl(iface)).digest()).slice(0, 6);
2416
+ return 'nr_' + hash + '_' + iface;
2417
+ }
2418
+
2419
+ /**
2420
+ * task 14: the interfaces through the system's lite-rpc with occulite-client - one client,
2421
+ * the stream's events into the same rpcMethods table the callback servers feed, the
2422
+ * requests through methodCall. The one-time move of an `auto` connection is said once
2423
+ * (the marker file); `ccu` in the connection node is never moved.
2424
+ * @param {string[]} ifaces
2425
+ */
2426
+ startLite(ifaces) {
2427
+ if (this.connectionType === 'auto') {
2428
+ let marker = null;
2429
+ try {
2430
+ marker = JSON.parse(fs.readFileSync(this.liteMarkerFile, 'utf8'));
2431
+ } catch {
2432
+ marker = null;
2433
+ }
2434
+
2435
+ if (!marker) {
2436
+ this.logger.info(
2437
+ 'openccu-lite: ' +
2438
+ ifaces.join(', ') +
2439
+ " go through the system's lite-rpc from now on, the default on openccu-lite - moved once; connection type CCU in the connection node keeps the direct connection with callbacks, and is never changed again",
2440
+ );
2441
+ try {
2442
+ fs.writeFileSync(
2443
+ this.liteMarkerFile,
2444
+ JSON.stringify({migrated: new Date().toISOString(), host: this.host}),
2445
+ );
2446
+ } catch (error) {
2447
+ this.logger.warn('cannot write ' + this.liteMarkerFile + ': ' + error.message);
2448
+ }
2449
+ }
2450
+ }
2451
+
2452
+ const url = (this.tlsEnabled ? 'https' : 'http') + '://' + this.host + ':' + this.metaPort;
2453
+ const running = (iface) => this.lite && this.lite.running(iface) !== false;
2454
+ this.lite = new lite.LiteConnection({
2455
+ url,
2456
+ token: this.liteToken,
2457
+ insecure: Boolean(this.inSecure),
2458
+ interfaces: ifaces,
2459
+ initId: (iface) => this.liteInitId(iface),
2460
+ logger: this.logger,
2461
+ createClient: this.liteCreateClient,
2462
+ onCallback: (method, err, parameters, callback) => {
2463
+ if (err) {
2464
+ this.logger.error('rpc <', 'lite', method, err);
2465
+ }
2466
+
2467
+ this.logger.debug('rpc <', 'lite', method, JSON.stringify(parameters));
2468
+ const handler = method === 'event' ? 'eventSingle' : method;
2469
+ if (isIterable(parameters)) {
2470
+ this.callRpcMethod(handler, err, parameters, callback);
2471
+ } else {
2472
+ this.logger.error(
2473
+ 'rpc <',
2474
+ 'lite',
2475
+ 'method',
2476
+ method,
2477
+ 'params not iterable',
2478
+ JSON.stringify(parameters),
2479
+ );
2480
+ callback(null, this.rpcDefaultAnswer(method));
2481
+ }
2482
+ },
2483
+ onInterface: (iface, state) => {
2484
+ if (!ifaces.includes(iface) || this.closing) {
2485
+ return;
2486
+ }
2487
+
2488
+ this.logger.debug('lite-rpc interface', iface, state);
2489
+ if (state === 'up' || state === 'added' || state === 'restarted') {
2490
+ this.lastEvent[iface] = now();
2491
+ this.setIfaceStatus(iface, true);
2492
+ if (state === 'restarted' || state === 'added') {
2493
+ this.getDevices(iface).catch(() => {});
2494
+ }
2495
+ } else {
2496
+ this.setIfaceStatus(iface, false, true);
2497
+ }
2498
+ },
2499
+ onStatus: (state) => {
2500
+ if (this.closing) {
2501
+ return;
2502
+ }
2503
+
2504
+ this.logger.debug('lite-rpc client', state);
2505
+ if (state === 'ready') {
2506
+ ifaces.forEach((iface) => this.setIfaceStatus(iface, running(iface), !running(iface)));
2507
+ } else if (state === 'degraded' || state === 'offline' || state === 'connecting') {
2508
+ ifaces.forEach((iface) => this.setIfaceStatus(iface, false, true));
2509
+ } else if (state === 'closed') {
2510
+ ifaces.forEach((iface) => this.setIfaceStatus(iface, false));
2511
+ }
2512
+ },
2513
+ onValues: (values) => this.applyLiteValues(values),
2514
+ });
2515
+ this.logger.info(
2516
+ 'lite-rpc: ' + ifaces.join(', ') + ' through ' + url + ' (occulite-client), no callback server',
2517
+ );
2518
+ ifaces.forEach((iface) => this.setIfaceStatus(iface, false, true));
2519
+ this.lite
2520
+ .start()
2521
+ .then(() => {
2522
+ if (this.closing || !this.lite) {
2523
+ return;
2524
+ }
2525
+
2526
+ const up = ifaces.filter((iface) => running(iface));
2527
+ this.logger.info('lite-rpc connected: ' + (up.join(', ') || 'no interface running'));
2528
+ return Promise.all(
2529
+ up.map((iface) =>
2530
+ this.getDevices(iface)
2531
+ .then(() => {
2532
+ if (['BidCos-RF', 'BidCos-Wired', 'HmIP-RF'].includes(iface)) {
2533
+ return this.methodCall(iface, 'getLinks', [], {quiet: true})
2534
+ .then((res) => {
2535
+ this.links[iface] = res;
2536
+ })
2537
+ .catch(() => {});
2538
+ }
2539
+ })
2540
+ .catch((error) => {
2541
+ this.logger.warn('lite-rpc ' + iface + ' listDevices: ' + error.message);
2542
+ }),
2543
+ ),
2544
+ );
2545
+ })
2546
+ .catch((error) => {
2547
+ if (this.closing) {
2548
+ return;
2549
+ }
2550
+
2551
+ this.logger.warn('lite-rpc: ' + (error.message || error) + ' - the client keeps trying');
2552
+ ifaces.forEach((iface) => this.setIfaceStatus(iface, false, true));
2553
+ });
2554
+ }
2555
+
2556
+ /**
2557
+ * task 14: the state store's picture into the values cache at connect (what a CCU's ReGa
2558
+ * getValues gave): the nodes with "cache" get a value at deploy on openccu-lite as well.
2559
+ * @param {Map<string, {value: *, ts: number, lc?: number, confirmed: boolean}>} values box.values
2560
+ */
2561
+ applyLiteValues(values) {
2562
+ if (!values || typeof values.forEach !== 'function') {
2563
+ return;
2564
+ }
2565
+
2566
+ let count = 0;
2567
+ values.forEach((entry, ref) => {
2568
+ const [iface, channel, datapoint] = String(ref).split('.');
2569
+ if (!this.liteIfaces.includes(iface) || !channel || !datapoint) {
2570
+ return;
2571
+ }
2572
+
2573
+ const ts = entry.ts || 0;
2574
+ const message = this.createMessage(iface, channel, datapoint, entry.value, {
2575
+ cache: true,
2576
+ change: false,
2577
+ working: false,
2578
+ uncertain: entry.confirmed === false,
2579
+ ts,
2580
+ lc: entry.lc || ts,
2581
+ });
2582
+ this.values[message.datapointName] = message;
2583
+ count += 1;
2584
+ if (!datapoint.startsWith('PRESS_')) {
2585
+ this.callCallbacks(message);
2586
+ }
2587
+ });
2588
+ this.logger.debug('lite-rpc: ' + count + ' values from the state store');
2589
+ this.cachedValuesReceived = true;
2590
+ this.saveValues();
2591
+ this.scheduleValuesPrune();
2592
+ }
2593
+
2594
+ /** task 14: close the lite-rpc client, if any */
2595
+ stopLite() {
2596
+ if (this.lite) {
2597
+ this.logger.debug('lite-rpc client closing');
2598
+ this.lite.close();
2599
+ this.lite = null;
2600
+ }
2252
2601
  }
2253
2602
 
2254
2603
  /**
@@ -4141,6 +4490,31 @@ module.exports = function (RED) {
4141
4490
  */
4142
4491
  methodCall(iface, method, parameters, {quiet = false} = {}) {
4143
4492
  return new Promise((resolve, reject) => {
4493
+ if (this.lite && this.lite.has(iface)) {
4494
+ // task 14: through the system's lite-rpc; a fault keeps its code, a scope the
4495
+ // token lacks is said as such - the interface stays connected either way
4496
+ this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
4497
+ this.lite.call(iface, method, parameters).then(
4498
+ (res) => {
4499
+ this.logger.debug(' <', iface, method, JSON.stringify(res));
4500
+ resolve(res);
4501
+ },
4502
+ (error) => {
4503
+ this.logger[quiet ? 'debug' : 'error'](
4504
+ ' <',
4505
+ iface,
4506
+ method,
4507
+ JSON.stringify(parameters),
4508
+ error.faultCode === undefined
4509
+ ? error.message
4510
+ : 'fault ' + error.faultCode + ' ' + error.faultString,
4511
+ );
4512
+ reject(error);
4513
+ },
4514
+ );
4515
+ return;
4516
+ }
4517
+
4144
4518
  if (this.clients[iface]) {
4145
4519
  this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
4146
4520
  this.clients[iface].methodCall(method, parameters, (err, res) => {
@@ -5,8 +5,13 @@
5
5
  * addon started before the interfaces, a daemon restart) used to leave the
6
6
  * interface without events: rpcCheckInit only runs with cached devices and
7
7
  * then waits pingTimeout (600 s for HmIP-RF). Now a failed init schedules its
8
- * own retry, 1 s, 2 s, 4 s, 8 s and then every 15 s (task 12), until it
9
- * succeeds or the node is closed.
8
+ * own retry (task 12) until it succeeds or the node is closed: every 3 s for
9
+ * the first 90 s after the interface was first missed (the start, or the
10
+ * moment a connected process went away), then every 15 s (task 17 - the
11
+ * 1/2/4/8/15 s steps of before hit hmipserver's ready moment, ~50-55 s after
12
+ * an openccu-lite boot, only at their 61 s tick). When one interface appears
13
+ * the others that are waiting are tried at once (poke): rfd and hmipserver
14
+ * come up close together.
10
15
  *
11
16
  * While the process is unreachable the interface is "waiting": one line, then
12
17
  * debug. Before the interface was ever connected, a refused connection is
@@ -17,20 +22,22 @@
17
22
  * distinct fault.
18
23
  */
19
24
 
20
- const RETRY_DELAYS = [1000, 2000, 4000, 8000];
25
+ const RETRY_INTERVAL = 3000;
26
+ const RETRY_WINDOW = 90000;
21
27
  const RETRY_MAX_DELAY = 15000;
22
28
 
23
29
  /**
24
- * The delay before the next attempt.
25
- * @param {number} failures failed attempts so far (1 after the first failure)
26
- * @returns {number} milliseconds
30
+ * The delay before the next attempt (task 17).
31
+ * @param {number} elapsed milliseconds since the interface was first missed
32
+ * @returns {number} milliseconds: 3 s within the first 90 s, then 15 s
27
33
  */
28
- function retryDelay(failures) {
29
- if (failures >= 1 && failures <= RETRY_DELAYS.length) {
30
- return RETRY_DELAYS[failures - 1];
31
- }
34
+ function retryDelay(elapsed) {
35
+ return elapsed < RETRY_WINDOW ? RETRY_INTERVAL : RETRY_MAX_DELAY;
36
+ }
32
37
 
33
- return RETRY_MAX_DELAY;
38
+ /** @param {number} ms @returns {string} seconds with one decimal, e.g. "54.0 s" */
39
+ function seconds(ms) {
40
+ return (Math.max(0, ms) / 1000).toFixed(1) + ' s';
34
41
  }
35
42
 
36
43
  /**
@@ -92,14 +99,19 @@ class InitRetry {
92
99
  * @param {object} options.logger {debug, info, warn, error}
93
100
  * @param {function(string)} options.onState 'waiting' | 'failed' | 'connected'
94
101
  * @param {object} [options.timers] {setTimeout, clearTimeout}, for tests
102
+ * @param {function(): number} [options.now] the clock, for tests
103
+ * @param {number} [options.startedAt] when the program (the connection node) started, for the log line
95
104
  */
96
- constructor({iface, attempt, logger, onState, timers}) {
105
+ constructor({iface, attempt, logger, onState, timers, now, startedAt}) {
97
106
  this.iface = iface;
98
107
  this.attempt = attempt;
99
108
  this.logger = logger;
100
109
  this.onState = onState || (() => {});
101
110
  this.timers = timers || {setTimeout, clearTimeout};
111
+ this.now = now || Date.now;
112
+ this.startedAt = startedAt === undefined ? this.now() : startedAt;
102
113
  this.timer = null;
114
+ this.running = false;
103
115
  this.stopped = false;
104
116
  this.everConnected = false;
105
117
  this.reset();
@@ -109,6 +121,8 @@ class InitRetry {
109
121
  this.failures = 0;
110
122
  this.warned = false;
111
123
  this.faults = new Set();
124
+ // task 17: when the process was first missed - the 90 s of 3 s steps count from here
125
+ this.missedAt = null;
112
126
  }
113
127
 
114
128
  /** true while a retry is scheduled */
@@ -125,8 +139,12 @@ class InitRetry {
125
139
  return;
126
140
  }
127
141
 
142
+ if (this.failures === 0) {
143
+ this.missedAt = this.now();
144
+ }
145
+
128
146
  this.failures += 1;
129
- const delay = retryDelay(this.failures);
147
+ const delay = retryDelay(this.now() - this.missedAt);
130
148
  const seconds = delay / 1000;
131
149
  const reason = describeError(error);
132
150
  if (isFault(error)) {
@@ -177,14 +195,38 @@ class InitRetry {
177
195
  return Promise.resolve();
178
196
  }
179
197
 
198
+ this.running = true;
180
199
  return Promise.resolve()
181
200
  .then(() => this.attempt())
182
201
  .then(
183
- () => this.succeeded(),
184
- (error) => this.failed(error),
202
+ () => {
203
+ this.running = false;
204
+ this.succeeded();
205
+ },
206
+ (error) => {
207
+ this.running = false;
208
+ this.failed(error);
209
+ },
185
210
  );
186
211
  }
187
212
 
213
+ /**
214
+ * task 17: another interface just appeared - try this one now instead of at its timer
215
+ * (rfd and hmipserver come up close together). Nothing while an attempt is running or
216
+ * none is scheduled.
217
+ * @returns {boolean} whether an attempt was started
218
+ */
219
+ poke() {
220
+ if (this.stopped || this.running || this.timer === null) {
221
+ return false;
222
+ }
223
+
224
+ this.clear();
225
+ this.logger.debug(this.iface + ': another interface appeared, trying now');
226
+ this.run();
227
+ return true;
228
+ }
229
+
188
230
  /** an init attempt succeeded */
189
231
  succeeded() {
190
232
  if (this.stopped) {
@@ -193,7 +235,11 @@ class InitRetry {
193
235
 
194
236
  this.clear();
195
237
  if (this.failures > 0) {
196
- this.logger.info(this.iface + ' connected after ' + (this.failures + 1) + ' attempts');
238
+ // task 17: one line with the time, so a log shows how close the start got to the process's ready moment
239
+ const since = this.everConnected
240
+ ? seconds(this.now() - this.missedAt) + ' after it went missing'
241
+ : seconds(this.now() - this.startedAt) + ' after the start';
242
+ this.logger.info(this.iface + ' connected after ' + (this.failures + 1) + ' attempts, ' + since);
197
243
  }
198
244
 
199
245
  this.everConnected = true;
@@ -215,4 +261,13 @@ class InitRetry {
215
261
  }
216
262
  }
217
263
 
218
- module.exports = {InitRetry, retryDelay, isFault, isRefused, describeError, RETRY_DELAYS, RETRY_MAX_DELAY};
264
+ module.exports = {
265
+ InitRetry,
266
+ retryDelay,
267
+ isFault,
268
+ isRefused,
269
+ describeError,
270
+ RETRY_INTERVAL,
271
+ RETRY_WINDOW,
272
+ RETRY_MAX_DELAY,
273
+ };
@@ -0,0 +1,309 @@
1
+ /*
2
+ * task 14: the interfaces of an openccu-lite system through its lite-rpc, with occulite-client,
3
+ * instead of XML-RPC/BIN-RPC clients and callback servers of our own.
4
+ *
5
+ * One client per connection node: the requests go to /api/rpc/v1 with the credential, the events
6
+ * come from the stream the client opens (resumed after a break, re-read after a restart of the
7
+ * system), and `callbackEvents` turns them into the very calls a callback server would make -
8
+ * event, system.multicall, newDevices, deleteDevices, updateDevice, replaceDevice, readdedDevice
9
+ * with the same argument lists - so the connection's rpcMethods table feeds from it unchanged.
10
+ * No init, no ping, no callback ports for these interfaces. CUxD and CCU-Jack are not carried by
11
+ * lite-rpc and keep their own clients.
12
+ *
13
+ * The credential: the openccu-lite token of the connection node, else the addon's own token the
14
+ * system mints for RedMatic from its manifest (/run/occulite/addon-tokens/redmatic.api). The local
15
+ * token (meta:read alone) is no credential for lite-rpc.
16
+ *
17
+ * occulite-client is ESM; it is loaded here, lazily, so a CCU user never loads it and a Node too
18
+ * old for require(esm) gets a message instead of a crash (B-32's lesson).
19
+ */
20
+
21
+ const fs = require('fs');
22
+
23
+ const LITE_INTERFACES = ['BidCos-RF', 'BidCos-Wired', 'HmIP-RF', 'VirtualDevices'];
24
+ const ADDON_TOKEN_FILE = '/run/occulite/addon-tokens/redmatic.api';
25
+
26
+ /** the stream's events as the callback server's methods, see callbackEvents() */
27
+ const CALLBACK_METHODS = [
28
+ 'event',
29
+ 'system.multicall',
30
+ 'newDevices',
31
+ 'deleteDevices',
32
+ 'updateDevice',
33
+ 'replaceDevice',
34
+ 'readdedDevice',
35
+ ];
36
+
37
+ let modules = null;
38
+
39
+ /**
40
+ * occulite-client, loaded once. Throws with a readable message where it cannot be loaded.
41
+ * @returns {{OccuLite: Function, callbackEvents: Function, nodeTransport: Function}}
42
+ */
43
+ function load() {
44
+ if (!modules) {
45
+ try {
46
+ const {OccuLite} = require('occulite-client');
47
+ const {callbackEvents} = require('occulite-client/callbacks');
48
+ const {nodeTransport} = require('occulite-client/node');
49
+ modules = {OccuLite, callbackEvents, nodeTransport};
50
+ } catch (error) {
51
+ throw new Error(
52
+ 'occulite-client cannot be loaded (' +
53
+ (error.message || error) +
54
+ '): lite-rpc needs Node.js 20.19, 22.12 or newer; the direct connection is used instead',
55
+ );
56
+ }
57
+ }
58
+
59
+ return modules;
60
+ }
61
+
62
+ /**
63
+ * The addon's own token on an openccu-lite system (RedMatic's, minted from its manifest), or
64
+ * undefined where there is none.
65
+ * @param {string} [file] the token file, for tests
66
+ * @returns {string|undefined}
67
+ */
68
+ function readAddonToken(file = module.exports.ADDON_TOKEN_FILE) {
69
+ try {
70
+ const token = fs.readFileSync(file, 'utf8').trim();
71
+ return token === '' ? undefined : token;
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Whether an error of the client is an RPC fault of the interface process (the process answered).
79
+ * @param {*} error
80
+ * @returns {boolean}
81
+ */
82
+ function isFault(error) {
83
+ return Boolean(error) && error.kind === 'rpc-fault' && error.faultCode !== undefined;
84
+ }
85
+
86
+ /**
87
+ * The client's error as the connection reports it: a fault keeps its code and string, a refused
88
+ * scope says which, everything else its kind.
89
+ * @param {*} error
90
+ * @returns {Error}
91
+ */
92
+ function translateError(error) {
93
+ if (isFault(error)) {
94
+ const fault = new Error(error.faultString || error.message);
95
+ fault.faultCode = error.faultCode;
96
+ fault.faultString = error.faultString || error.message;
97
+ return fault;
98
+ }
99
+
100
+ if (error && error.kind === 'forbidden') {
101
+ const refused = new Error(
102
+ 'the openccu-lite token may not call this method' + (error.scope ? ' (needs ' + error.scope + ')' : ''),
103
+ );
104
+ refused.kind = 'forbidden';
105
+ refused.scope = error.scope;
106
+ return refused;
107
+ }
108
+
109
+ if (error && error.kind) {
110
+ const translated = new Error(error.message || error.kind);
111
+ translated.kind = error.kind;
112
+ return translated;
113
+ }
114
+
115
+ return error instanceof Error ? error : new Error(String(error));
116
+ }
117
+
118
+ const RPC_TIERS = ['rpc:operate', 'rpc:configure', 'rpc:admin'];
119
+
120
+ /**
121
+ * occulite-client B-3: the client decides "names only" from the literal `rpc:read` in
122
+ * GET /api/auth/v1/state, but a token made with a higher tier (rpc:admin, say) carries that tier
123
+ * alone - the system includes the lower ones. The transport spells `rpc:read` out for the client
124
+ * where a tier implies it. Goes once the package fixes it.
125
+ * @param {object} transport occulite-client's Transport
126
+ * @returns {object} the same transport
127
+ */
128
+ function tierAwareTransport(transport) {
129
+ return {
130
+ request: (method, url, headers, body, signal) =>
131
+ transport.request(method, url, headers, body, signal).then((response) => {
132
+ if (response.status !== 200 || !/\/api\/auth\/v1\/state(\?|$)/.test(String(url))) {
133
+ return response;
134
+ }
135
+
136
+ let parsed;
137
+ try {
138
+ parsed = JSON.parse(response.body);
139
+ } catch {
140
+ return response;
141
+ }
142
+
143
+ const scopes = Array.isArray(parsed && parsed.scopes) ? parsed.scopes : [];
144
+ if (scopes.includes('rpc:read') || !scopes.some((scope) => RPC_TIERS.includes(scope))) {
145
+ return response;
146
+ }
147
+
148
+ return {...response, body: JSON.stringify({...parsed, scopes: [...scopes, 'rpc:read']})};
149
+ }),
150
+ stream: (url, headers, signal) => transport.stream(url, headers, signal),
151
+ };
152
+ }
153
+
154
+ class LiteConnection {
155
+ /**
156
+ * @param {object} options
157
+ * @param {string} options.url the system, scheme and authority
158
+ * @param {string} options.token the credential
159
+ * @param {boolean} [options.insecure] accept any certificate
160
+ * @param {string[]} options.interfaces the interfaces to follow (the enabled ones lite-rpc carries)
161
+ * @param {function(string): string} options.initId the init id the connection's callback table expects per interface
162
+ * @param {function(string, *, Array, Function)} options.onCallback (method, err, params, answer): a callback server's call
163
+ * @param {function(string, string)} options.onInterface (iface, state): up | down | restarted | added | removed
164
+ * @param {function(string)} options.onStatus the client's state: connecting | ready | degraded | offline | closed
165
+ * @param {function(Map)} [options.onValues] box.values once ready (the state store's picture)
166
+ * @param {object} options.logger {debug, info, warn, error}
167
+ * @param {number} [options.timeout] per request, ms
168
+ * @param {Function} [options.createClient] for tests, in place of new OccuLite()
169
+ */
170
+ constructor(options) {
171
+ this.options = options;
172
+ this.interfaces = [...options.interfaces];
173
+ this.logger = options.logger;
174
+ this.box = null;
175
+ this.server = null;
176
+ this.closed = false;
177
+ this.ready = false;
178
+ }
179
+
180
+ /**
181
+ * @param {string} iface
182
+ * @returns {boolean} whether this interface goes through lite-rpc
183
+ */
184
+ has(iface) {
185
+ return this.interfaces.includes(iface);
186
+ }
187
+
188
+ /**
189
+ * Opens the client. Resolves once the first picture is loaded, rejects with the client's
190
+ * reason (unauthenticated, forbidden, unreachable, timeout ...); the stream keeps trying by
191
+ * itself afterwards.
192
+ * @returns {Promise<void>}
193
+ */
194
+ start() {
195
+ const {OccuLite, callbackEvents, nodeTransport} = load();
196
+ const debug = (message) => this.logger.debug('occulite-client: ' + message);
197
+ const clientOptions = {
198
+ url: this.options.url,
199
+ auth: {token: this.options.token},
200
+ transport: tierAwareTransport(nodeTransport(this.options.insecure ? {insecure: true} : {})),
201
+ interfaces: this.interfaces,
202
+ values: true,
203
+ meta: false,
204
+ devices: true,
205
+ timeout: this.options.timeout || 30000,
206
+ log: {debug, info: debug, warn: debug, error: debug},
207
+ };
208
+ const box = this.options.createClient ? this.options.createClient(clientOptions) : new OccuLite(clientOptions);
209
+ this.box = box;
210
+ box.on('status', (event) => {
211
+ if (this.closed || this.box !== box) {
212
+ return;
213
+ }
214
+
215
+ const state = event && event.state ? event.state : event;
216
+ this.ready = state === 'ready';
217
+ this.options.onStatus(state);
218
+ });
219
+ box.on('interface', (event) => {
220
+ if (!this.closed && this.box === box) {
221
+ this.options.onInterface(event.interface, event.state);
222
+ }
223
+ });
224
+ box.on('resync', () => {
225
+ if (!this.closed && this.box === box && this.options.onValues) {
226
+ this.options.onValues(box.values);
227
+ }
228
+ });
229
+ // the stream's events as a callback server's calls, into the connection's own table
230
+ this.server = callbackEvents(box, {initId: this.options.initId});
231
+ CALLBACK_METHODS.forEach((method) => {
232
+ this.server.on(method, (err, parameters, callback) => {
233
+ if (this.closed || this.box !== box) {
234
+ callback(null, '');
235
+ return;
236
+ }
237
+
238
+ this.options.onCallback(method, err, parameters, callback);
239
+ });
240
+ });
241
+
242
+ return box.ready().then(() => {
243
+ if (this.closed || this.box !== box) {
244
+ return;
245
+ }
246
+
247
+ this.ready = true;
248
+ if (this.options.onValues) {
249
+ this.options.onValues(box.values);
250
+ }
251
+ });
252
+ }
253
+
254
+ /**
255
+ * Any method of an interface process through lite-rpc; the credential's tier decides.
256
+ * @param {string} iface
257
+ * @param {string} method
258
+ * @param {Array} parameters
259
+ * @returns {Promise<*>}
260
+ */
261
+ call(iface, method, parameters) {
262
+ if (!this.box) {
263
+ return Promise.reject(new Error('lite-rpc client not started'));
264
+ }
265
+
266
+ return this.box.call(iface, method, parameters || []).then(
267
+ (result) => (result === null || result === undefined ? '' : result),
268
+ (error) => {
269
+ throw translateError(error);
270
+ },
271
+ );
272
+ }
273
+
274
+ /**
275
+ * Whether the system says this interface runs (the client's live list).
276
+ * @param {string} iface
277
+ * @returns {boolean|undefined} undefined while the list is not known
278
+ */
279
+ running(iface) {
280
+ if (!this.box || !Array.isArray(this.box.interfaces)) {
281
+ return undefined;
282
+ }
283
+
284
+ const entry = this.box.interfaces.find((info) => info.name === iface);
285
+ return entry ? entry.running !== false : false;
286
+ }
287
+
288
+ /** Closes the stream; nothing is de-registered, because nothing was registered. */
289
+ close() {
290
+ this.closed = true;
291
+ this.ready = false;
292
+ if (this.box) {
293
+ this.box.close();
294
+ this.box = null;
295
+ }
296
+ }
297
+ }
298
+
299
+ module.exports = {
300
+ LiteConnection,
301
+ LITE_INTERFACES,
302
+ ADDON_TOKEN_FILE,
303
+ CALLBACK_METHODS,
304
+ readAddonToken,
305
+ tierAwareTransport,
306
+ translateError,
307
+ isFault,
308
+ load,
309
+ };
@@ -33,6 +33,7 @@
33
33
  <li>VirtualDevices: Heizungsgruppen, in CCU eingebundene Systeme wie Osram Lightify</li>
34
34
  <li>CUxD</li>
35
35
  <li>CCU-Jack: XMLRPC-Schnittstelle des <a href="https://github.com/mdzio/ccu-jack" target="_blank">CCU-Jack</a> Addons (Port konfigurierbar, Standard 2121 bzw. 2122 mit TLS)</li>
36
+ <li>Das Portfeld neben jeder Schnittstelle: leer ist der Port der CCU für den Transport (2001/42001 usw., die 3xxxx-Ports auf der CCU selbst); eine Zahl ersetzt ihn, etwa für eine CCU hinter einer Portweiterleitung oder einen Simulator auf einem anderen Port.</li>
36
37
  </ul>
37
38
 
38
39
  </li>
@@ -65,6 +66,7 @@ Weiteres unter <a target="_blank" href="https://nodered.org/docs/user-guide/cont
65
66
  </ul>
66
67
  <ul>
67
68
  <li><b>openccu-lite Port</b>: Nur nötig, wenn der Webserver des Systems nicht auf Port 80 bzw. 443 (TLS) läuft.</li>
69
+ <li><b>openccu-lite Schnittstellen</b>: Wie die Schnittstellenprozesse auf einem openccu-lite-System erreicht werden. <i>auto</i> (Voreinstellung) geht dort über das <b>lite-rpc</b> des Systems - die Aufrufe über seinen Webport, die Ereignisse aus einem Stream, den dieser Node öffnet; ein Abonnent an den Prozessen für alle Programme auf dem System, die Ereignisse werden nach einer Unterbrechung nachgeliefert, kein eigener Callback-Server und kein eigenes <code>init</code> - und auf einer CCU, RaspberryMatic oder OpenCCU den CCU-Weg. Das Zugangsmerkmal ist das openccu-lite-Token oben, auf dem System selbst das eigene Token von RedMatic, das das System dafür erzeugt; ohne eines wird die direkte Verbindung benutzt, das Log sagt es. <i>CCU</i> behält auch auf openccu-lite die direkte Verbindung mit Rückrufen (CUxD geht immer so; lite-rpc trägt es nicht). Ein Flow aus einer Version vor 4.7.0 wird beim ersten Start auf openccu-lite einmal auf lite-rpc umgestellt - das Log sagt es - und eine hier getroffene Wahl ändert keine spätere Version mehr. Die Nodes sysvar, program, script und poll bleiben auf openccu-lite so oder so ohne Funktion.</li>
68
70
  </ul>
69
71
  <ul>
70
72
  <li><b>Device data / Re-read device data</b>: Liest die Geräteliste jeder verbundenen Schnittstelle neu ein.
@@ -91,7 +93,7 @@ Weiteres unter <a target="_blank" href="https://nodered.org/docs/user-guide/cont
91
93
  solange die CCU ihr selbst ausgestelltes Zertifikat verwendet.</li>
92
94
  <li>Username: Ein Benutzer der sich an der CCU anmelden darf.</li>
93
95
  <li>Password: Das Passwort des Benutzers. Es wird in den Node-RED-Credentials gespeichert
94
- (verschlüsselt in <code>flows_cred.json</code>), nicht im Flow. Ein Flow aus einer Version vor 4.4.6,
96
+ (verschlüsselt in <code>flows_cred.json</code>), nicht im Flow. Ein Flow aus einer Version vor 4.5.0,
95
97
  der es noch im Klartext enthält, wird beim ersten Start übernommen; der nächste Deploy entfernt es aus
96
98
  <code>flows.json</code>.</li>
97
99
  </ul>
@@ -18,6 +18,7 @@
18
18
  <li>VirtualDevices: heating groups and systems the CCU integrates, such as Osram Lightify</li>
19
19
  <li>CUxD</li>
20
20
  <li>CCU-Jack: the XML-RPC interface of the <a href="https://github.com/mdzio/ccu-jack" target="_blank">CCU-Jack</a> addon (port configurable, 2121 by default, 2122 with TLS)</li>
21
+ <li>The port field beside each interface: blank is the CCU's port for the transport (2001/42001 and so on, the 3xxxx ports on the CCU itself); a number replaces it, for a CCU behind port forwarding or a simulator on another port.</li>
21
22
  </ul>
22
23
  </li>
23
24
  <li><b>RPC ping</b> and <b>RPC ping timeout (s)</b>: How often the connection to the interface processes is checked with a ping, and after how many seconds without an answer it subscribes again. Off means no pings and no timeout-triggered re-init for this connection.</li>
@@ -27,6 +28,7 @@
27
28
  <li><b>Context Store</b>: The Node-RED context store the states of all devices and variables are kept in. See <a target="_blank" href="https://nodered.org/docs/user-guide/context">Working with context</a> in the Node-RED documentation.</li>
28
29
  <li><b>openccu-lite token</b>: Only for <a href="https://github.com/hobbyquaker/openccu-lite" target="_blank">openccu-lite</a>, a CCU firmware without ReGaHSS. Names, rooms and functions come from the system's metadata API there, which needs a token made under <i>Users</i> on the system. When Node-RED runs on the system itself (RedMatic) the field stays empty: the local token is read automatically. The token is kept, like the password, in Node-RED's credentials (encrypted in <code>flows_cred.json</code>, never in the flow, never in an export). A CCU, RaspberryMatic or OpenCCU ignores the field. openccu-lite has no system variables, programs or HM-Script; those nodes stay in the flow and report an error per message.</li>
29
30
  <li><b>openccu-lite port</b>: Only needed when the system's web server is not on port 80 (443 with TLS).</li>
31
+ <li><b>openccu-lite interfaces</b>: How the interface processes are reached on an openccu-lite system. <i>auto</i> (the default) goes through the system's <b>lite-rpc</b> there - the requests over its web port, the events from a stream this node opens, one subscriber at the processes for every program on the system, the events replayed after a break, no callback server and no <code>init</code> of its own - and the CCU way on a CCU, RaspberryMatic or OpenCCU. The credential is the openccu-lite token above, or on the system itself RedMatic's own token, which the system mints for it; without one the direct connection is used and the log says so. <i>CCU</i> keeps the direct connection with callbacks on openccu-lite as well (CUxD always goes that way; lite-rpc does not carry it). A flow from before 4.7.0 is moved to lite-rpc once at its first start on openccu-lite - the log says so - and a choice made here is never changed by a later version. The sysvar, program, script and poll nodes stay unavailable on openccu-lite either way.</li>
30
32
  <li><b>Device data / Re-read device data</b>: Reads the device list of every connected interface again. New and changed devices (a channel whose type changed, say) get their datapoints fetched anew, removed ones leave the table. A device the CCU reports with a changed type or version is re-read without the button as well. The button needs a deployed connection.</li>
31
33
  <li><b>Authentication</b>: Encryption and login for a CCU on the network (Node-RED not on the CCU). Both belong to the CCU's web server, which fronts the interface processes: on a CCU3 TLS and authentication are switched on in the firewall settings, on openccu-lite under <i>Remote access</i> (classic RPC). When Node-RED runs on the system itself (RedMatic; address <code>localhost</code> / <code>127.0.0.1</code>) the processes are reached directly on the loopback, BidCos over BIN-RPC, without TLS and without a login; these settings are then not used.
32
34
  <ul>
@@ -34,7 +36,7 @@
34
36
  <li>TLS/SSL: Transport encryption. The interfaces are reached over the CCU's TLS ports (42000, 42001, 42010, 49292, ReGaHSS 48181, CCU-Jack 2122), openccu-lite's metadata API over 443. BIN-RPC has no TLS: with TLS on, BidCos-RF always goes over XML-RPC.</li>
35
37
  <li>Ignore invalid TLS Certificates: Node-RED accepts an untrusted certificate too. Needed as long as the CCU uses its self-signed certificate.</li>
36
38
  <li>Username: A user allowed to log in to the CCU.</li>
37
- <li>Password: The user's password. It is kept in Node-RED's credentials (encrypted in <code>flows_cred.json</code>), not in the flow. A flow from a version before 4.4.6 that still carries it in plain text is taken over at the first start; the next deploy removes it from <code>flows.json</code>.</li>
39
+ <li>Password: The user's password. It is kept in Node-RED's credentials (encrypted in <code>flows_cred.json</code>), not in the flow. A flow from a version before 4.5.0 that still carries it in plain text is taken over at the first start; the next deploy removes it from <code>flows.json</code>.</li>
38
40
  </ul>
39
41
  The other direction stays unencrypted: the CCU sends its events to the XML-RPC or BIN-RPC server Node-RED opens for it (<i>Listen Address</i>, <i>BINRPC</i> and <i>XMLRPC listening port</i>) without TLS and without a login - it cannot log in there or verify our certificate. Those ports should be open in the Node-RED host's firewall for the CCU only.</li>
40
42
  </ul>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ccu",
3
- "version": "4.6.2",
3
+ "version": "4.7.0",
4
4
  "license": "MIT",
5
5
  "main": "none",
6
6
  "keywords": [
@@ -53,7 +53,8 @@
53
53
  "binrpc": "^4.3.0",
54
54
  "homematic-rega": "^2.0.1",
55
55
  "homematic-xmlrpc": "^2.0.0",
56
- "mqtt-wildcard": "^3.0.9"
56
+ "mqtt-wildcard": "^3.0.9",
57
+ "occulite-client": "^0.3.0"
57
58
  },
58
59
  "deprecated": false,
59
60
  "description": "Node-RED Nodes for the Homematic CCU",
@@ -64,7 +65,7 @@
64
65
  "eslint-config-prettier": "^10.1.8",
65
66
  "eslint-plugin-html": "^8.2.0",
66
67
  "globals": "^16.5.0",
67
- "hm-simulator": "^1.3.1",
68
+ "hm-simulator": "^1.4.0",
68
69
  "mocha": "^11.7.1",
69
70
  "node-red": "^4.1.0",
70
71
  "node-red-node-test-helper": "^0.3.6",