node-red-contrib-ccu 4.4.2 → 4.4.4

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,43 @@ 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.4.4 (2026-09-17)
9
+
10
+ ### Fixed
11
+
12
+ - **An interface stayed without events for minutes, or for good, when its
13
+ process did not answer the first `init`** (task 11). A failed `init` at
14
+ start (connection refused, timeout) was only retried by the ping
15
+ supervision, which does nothing without cached devices and waits the ping
16
+ timeout otherwise: 60 s, and 600 s for HmIP-RF. A Node-RED that starts
17
+ before the interface processes (a box still booting, a daemon restart) now
18
+ retries the `init` on its own after 2 s, 4 s, 8 s, 16 s and then every 30 s,
19
+ for every interface, until it succeeds or the node is closed or redeployed.
20
+ While it waits, the log has one warning (_"HmIP-RF not reachable yet
21
+ (connect ECONNREFUSED), retrying"_) instead of an error line, the success
22
+ after retries is logged (_"HmIP-RF connected after 3 attempts"_), and the
23
+ nodes show **waiting** (yellow ring) instead of disconnected. A fault answer
24
+ of a running process stays an error. After a successful `init` the ping and
25
+ re-`init` supervision works as before.
26
+
27
+ ## 4.4.3 (2026-09-09)
28
+
29
+ ### Fixed
30
+
31
+ - **Node-RED at 100 % CPU and unresponsive while ReGaHSS is unreachable**
32
+ (B-27). binrpc up to 4.2.0 armed a new reconnect timer for both the `error`
33
+ and the `close` event of every refused connect, so the number of attempts to
34
+ the ReGaHSS port 31999 doubled every 2.5 s without bound: about 7,000 refused
35
+ connects per second, 30,000 pending timers and 68 million connects in under
36
+ three hours. On a CCU this hit whenever ReGaHSS was down for a while
37
+ (restart, backup restore) and ended when the port came back. This release
38
+ requires [binrpc 4.3.0](https://github.com/hobbyquaker/binrpc/blob/master/CHANGELOG.md),
39
+ which keeps a single reconnect timer per client with exponential backoff
40
+ (2.5 s doubling to 30 s) and has `close()`. The connection node now closes
41
+ its rpc clients when it is removed or redeployed, and when it replaces a
42
+ client after a failed call — a replaced binrpc client used to keep
43
+ reconnecting in the background for the life of the process.
44
+
8
45
  ## 4.4.2 (2026-09-09)
9
46
 
10
47
  ### Fixed
@@ -58,46 +95,11 @@ hang up` — became an **unhandled rejection**, which Node >= 15 turns
58
95
 
59
96
  ### Added
60
97
 
61
- - **openccu-lite support** (roadmap B-17). [openccu-lite](https://github.com/hobbyquaker/openccu-lite)
62
- is a Homematic CCU firmware without ReGaHSS: the interface processes
63
- are unchanged, but nothing listens on 8181/8183, HM-Script is never
64
- interpreted and there are no ReGa ids. The connection node now asks
65
- the box `GET /api/meta/v1/version` when it starts and on every
66
- reconnect of its name sync; a CCU3/RaspberryMatic/OpenCCU answers 404
67
- and everything runs exactly as before, an openccu-lite answers with
68
- its API version and device, channel, room and function names come
69
- from its metadata API instead — a snapshot at start, then the box's
70
- Server-Sent-Events change stream, so a rename in the box's UI reaches
71
- running flows within a second without a redeploy. `msg.channelName`,
72
- `msg.rooms`, `msg.functions` and the room/function filters keep the
73
- shape they have on a CCU (rooms and functions are a tree there and
74
- are flattened to the usual arrays of names, most specific first).
75
- Nothing has to be configured: the same connection node works on both
76
- kinds of box, which is what makes moving a backup between them
77
- harmless.
78
- - connection node: **openccu-lite token** and **openccu-lite port**
79
- fields. The token is only needed when Node-RED does _not_ run on the
80
- box — on the box the read-only token in
81
- `/usr/local/etc/occulite/local-token` is picked up automatically, off
82
- the box the administrator creates one on the box's _Users_ page. A
83
- rejected or missing token is not fatal: the nodes work with addresses
84
- instead of names, the connection logs it once and takes the names as
85
- soon as a valid token is there. The port field is only for boxes
86
- whose web server is not on 80/443. Both are ignored on a CCU.
87
98
  - connection node: the cached `ccu_rega_<host>.json` now also holds the
88
99
  room and function name lists, so the editor's room and function
89
100
  pickers are filled right after a restart instead of only after the
90
101
  first successful sync.
91
102
 
92
- ### Changed
93
-
94
- - `ccu-sysvar`, `ccu-program`, `ccu-script` and `ccu-poll` stay in the
95
- palette and in flows on openccu-lite, and stay accepted in the
96
- editor. There is no ReGa DOM on such a box, so instead of failing the
97
- connection they log one line when the connection starts and answer
98
- every message with a clear error (`… are not available on this box
99
- (openccu-lite has no ReGaHSS)`). On a CCU nothing about them changes.
100
-
101
103
  ## 4.3.0 (2026-09-04)
102
104
 
103
105
  ### Added
package/README.md CHANGED
@@ -28,48 +28,6 @@ Some example flows can be found in the [RedMatic Wiki](https://github.com/rdmtc/
28
28
 
29
29
  **Starting with Version 3.x these Nodes need Node-RED >= 1.0 to work correctly**
30
30
 
31
- ## openccu-lite
32
-
33
- [openccu-lite](https://github.com/hobbyquaker/openccu-lite) is a Homematic CCU firmware **without ReGaHSS**. The
34
- interface processes (`rfd`, `hs485d`, `hmipserver`) are the same, so everything these nodes do over BINRPC/XMLRPC works
35
- unchanged — but there is no logic layer: nothing listens on 8181/8183, HM-Script is never interpreted, and there are no
36
- ReGa ids. Device, channel, room and function names come from the box's metadata API instead.
37
-
38
- Nothing has to be configured for this. When the connection node starts (and on every reconnect of its name sync) it
39
- asks the box `GET /api/meta/v1/version`; a CCU3, RaspberryMatic or OpenCCU answers 404 and the ReGaHSS path runs exactly
40
- as before, an openccu-lite answers with its API version and the node takes names, rooms and functions from there —
41
- loaded once as a snapshot and then kept current from the box's event stream, so a rename in the box's UI shows up in
42
- your flows within a second, without a redeploy. `msg.channelName`, `msg.rooms`, `msg.functions` and the room/function
43
- filters keep exactly the shape they have on a CCU. The same connection node configuration works on both, which is what
44
- makes moving a backup between the two harmless.
45
-
46
- **Credential.** Every metadata endpoint except the version probe needs a token:
47
-
48
- - Node-RED **on the box** (RedMatic): nothing to do — the box's own read-only token is read from
49
- `/usr/local/etc/occulite/local-token`.
50
- - Node-RED **elsewhere** (a PC, a container): create a token on the box's _Users_ page and paste it into the
51
- **openccu-lite token** field of the connection node. The **openccu-lite port** field next to it is only needed when
52
- the box's web server is not on port 80 (443 with TLS).
53
-
54
- Without a token the nodes still work, with addresses instead of names: the connection logs the rejected credential once
55
- and picks the names up on the next retry, as soon as a valid token is there.
56
-
57
- **What has no replacement on openccu-lite** (from openccu-lite's own porting guide):
58
-
59
- - **System variables** and **programs**: there is no ReGa DOM. The `ccu-sysvar`, `ccu-program` and `ccu-poll` nodes stay
60
- in the palette and in your flows — they are accepted, they never break the connection, and every message they get is
61
- answered with a clear error instead.
62
- - **`exec()` of HM-Script** — `dom.GetObject`, `system.GetSessionVarStr` and everything else the `ccu-script` node
63
- sends: gone, same handling as above.
64
- - **ReGa ids** (`dom.GetObject(1234)`): there are none. The metadata API identifies objects by
65
- `<interface>.<address>`; these nodes have always keyed on the address, so nothing changes for flows.
66
- - **Service messages / alarms** (system variables 40 and 41): interface-level state only.
67
- - **The CCU WebUI's JSON-RPC API** (`/api/homematic.cgi`, `Session.login`, `Device.listAll`): not present.
68
-
69
- Rooms and functions are a **tree** on openccu-lite (`room/eg/wohnzimmer`), not a flat list. They are flattened to the
70
- arrays these nodes have always published, most specific first: a channel in _Wohnzimmer_ below _Erdgeschoss_ gets
71
- `msg.rooms = ["Wohnzimmer", "Erdgeschoss"]` and `msg.room = "Wohnzimmer"`, so a room filter on either name matches.
72
-
73
31
  ## Home Assistant
74
32
 
75
33
  The `ccu-homeassistant` node publishes
@@ -15,6 +15,7 @@ const nextport = require('./lib/nextport.js');
15
15
  const hmDiscover = require('./lib/discover.js');
16
16
  const {Rega} = require('homematic-rega'); // ES module - require(esm) needs Node >= 20.19 / >= 22.12
17
17
  const metaProvider = require('./lib/metaprovider.js');
18
+ const {InitRetry} = require('./lib/initretry.js');
18
19
  const xmlrpc = require('homematic-xmlrpc');
19
20
  const binrpc = require('binrpc');
20
21
 
@@ -100,6 +101,7 @@ module.exports = function (RED) {
100
101
  object[iface] = {
101
102
  enabled: Boolean(config.ifaceTypes[iface].enabled),
102
103
  connected: Boolean(config.ifaceStatus[iface]),
104
+ waiting: Boolean(config.ifaceWaiting && config.ifaceWaiting[iface]),
103
105
  };
104
106
  });
105
107
  res.status(200).send(JSON.stringify(object));
@@ -535,6 +537,11 @@ module.exports = function (RED) {
535
537
  this.rpcPingEnabled = config.rpcPing === undefined ? true : Boolean(config.rpcPing);
536
538
  this.rpcPingTimer = {};
537
539
  this.ifaceStatus = {};
540
+ // task 11: interfaces whose init failed and is retried (InitRetry per iface),
541
+ // and which of them are waiting for an unreachable process
542
+ this.initRetry = {};
543
+ this.ifaceWaiting = {};
544
+ this.closing = false;
538
545
  this.serverError = {};
539
546
  this.queueTimeout = Number.parseInt(config.queueTimeout, 10) || 5000;
540
547
  this.queuePause = Number.parseInt(config.queuePause, 10) || 0;
@@ -799,29 +806,104 @@ module.exports = function (RED) {
799
806
  * @param iface
800
807
  * @param connected
801
808
  */
802
- setIfaceStatus(iface, connected) {
803
- if (this.ifaceStatus[iface] !== connected) {
804
- if (iface === 'ReGaHSS') {
805
- this.logger.info('Interface', iface, connected ? 'connected' : 'disconnected');
806
- } else {
807
- this.logger.info(
808
- 'Interface',
809
- iface,
810
- connected
811
- ? this.ifaceTypes[iface].protocol + ' port ' + this.ifaceTypes[iface].port + ' connected'
812
- : 'disconnected',
813
- );
809
+ setIfaceStatus(iface, connected, waiting = false) {
810
+ waiting = Boolean(waiting) && !connected;
811
+ const waitingChanged = Boolean(this.ifaceWaiting[iface]) !== waiting;
812
+ if (this.ifaceStatus[iface] !== connected || waitingChanged) {
813
+ if (this.ifaceStatus[iface] !== connected) {
814
+ if (iface === 'ReGaHSS') {
815
+ this.logger.info('Interface', iface, connected ? 'connected' : 'disconnected');
816
+ } else if (waiting) {
817
+ // task 11: the retry logs the one warn line, this stays quiet
818
+ this.logger.debug('Interface', iface, 'waiting');
819
+ } else {
820
+ this.logger.info(
821
+ 'Interface',
822
+ iface,
823
+ connected
824
+ ? this.ifaceTypes[iface].protocol +
825
+ ' port ' +
826
+ this.ifaceTypes[iface].port +
827
+ ' connected'
828
+ : 'disconnected',
829
+ );
830
+ }
814
831
  }
815
832
 
816
833
  this.ifaceStatus[iface] = !this.serverError[iface] && connected;
834
+ if (waiting) {
835
+ this.ifaceWaiting[iface] = true;
836
+ } else {
837
+ delete this.ifaceWaiting[iface];
838
+ }
839
+
817
840
  Object.keys(this.users).forEach((id) => {
818
841
  if (typeof this.users[id].setStatus === 'function') {
819
- this.users[id].setStatus({ifaceStatus: this.ifaceStatus});
842
+ this.users[id].setStatus({ifaceStatus: this.ifaceStatus, ifaceWaiting: this.ifaceWaiting});
820
843
  }
821
844
  });
822
845
  }
823
846
  }
824
847
 
848
+ /**
849
+ * task 11: the init retry of an interface, created on its first failure.
850
+ * @param iface
851
+ * @returns {InitRetry}
852
+ */
853
+ getInitRetry(iface) {
854
+ if (!this.initRetry[iface]) {
855
+ this.initRetry[iface] = new InitRetry({
856
+ iface,
857
+ logger: this.logger,
858
+ attempt: () => this.rpcInit(iface, {retry: true}),
859
+ onState: (state) => {
860
+ if (state === 'connected') {
861
+ this.setIfaceStatus(iface, true);
862
+ } else {
863
+ this.hadTimeout.add(iface);
864
+ this.setIfaceStatus(iface, false, state === 'waiting');
865
+ }
866
+ },
867
+ });
868
+ }
869
+
870
+ return this.initRetry[iface];
871
+ }
872
+
873
+ /**
874
+ * An init succeeded (the first one, a retry, or a re-init after a ping timeout).
875
+ * @param iface
876
+ */
877
+ rpcInitSucceeded(iface) {
878
+ if (this.initRetry[iface]) {
879
+ this.initRetry[iface].succeeded();
880
+ } else {
881
+ this.setIfaceStatus(iface, true);
882
+ }
883
+ }
884
+
885
+ /**
886
+ * An init failed: retry it with backoff, whether or not the interface has
887
+ * cached devices or a ping (task 11).
888
+ * @param iface
889
+ * @param error
890
+ */
891
+ rpcInitFailed(iface, error) {
892
+ if (this.closing || !this.ifaceTypes[iface] || !this.ifaceTypes[iface].enabled) {
893
+ return;
894
+ }
895
+
896
+ this.getInitRetry(iface).failed(error);
897
+ }
898
+
899
+ /** stop every scheduled init retry (close, redeploy) */
900
+ stopInitRetries() {
901
+ Object.keys(this.initRetry).forEach((iface) => {
902
+ this.initRetry[iface].stop();
903
+ });
904
+ this.initRetry = {};
905
+ }
906
+
825
907
  /**
826
908
  *
827
909
  * @returns {Promise<any>}
@@ -1026,6 +1108,8 @@ module.exports = function (RED) {
1026
1108
  */
1027
1109
  destructor(done) {
1028
1110
  this.logger.debug('ccu-connection destructor');
1111
+ this.closing = true;
1112
+ this.stopInitRetries();
1029
1113
  this.stats(false);
1030
1114
 
1031
1115
  this.logger.debug('clear regaPollTimeout');
@@ -1151,6 +1235,13 @@ module.exports = function (RED) {
1151
1235
  this.metaMode = true;
1152
1236
  this.lastMetaProbe = now();
1153
1237
  clearTimeout(this.regaPollTimeout);
1238
+ if (this.clients.ReGaHSS) {
1239
+ // the box became an openccu-lite while Node-RED was running
1240
+ // (recheckMeta): no ReGaHSS to talk to any more (B-27)
1241
+ this.logger.info('rpc client ReGaHSS closed: openccu-lite has no ReGaHSS');
1242
+ this.closeClient('ReGaHSS');
1243
+ }
1244
+
1154
1245
  this.logger.info(
1155
1246
  'openccu-lite detected (' +
1156
1247
  (info.implementation || 'meta api') +
@@ -1205,6 +1296,10 @@ module.exports = function (RED) {
1205
1296
  return this.startMeta(again);
1206
1297
  }
1207
1298
 
1299
+ if (this.ifaceTypes.ReGaHSS.enabled && !this.clients.ReGaHSS) {
1300
+ this.createClient('ReGaHSS').then(() => this.setIfaceStatus('ReGaHSS', true));
1301
+ }
1302
+
1208
1303
  return this.getRegaData().then(() => this.regaPoll());
1209
1304
  });
1210
1305
  },
@@ -1868,18 +1963,26 @@ module.exports = function (RED) {
1868
1963
  Object.keys(this.ifaceTypes).forEach((iface) => {
1869
1964
  const enabled = config[this.ifaceTypes[iface].conf + 'Enabled'];
1870
1965
  this.ifaceTypes[iface].enabled = enabled;
1966
+ if (enabled && iface === 'ReGaHSS' && this.metaMode) {
1967
+ // B-27: openccu-lite has no ReGaHSS, nothing listens on 31999.
1968
+ // A client would only reconnect forever (with binrpc < 4.3.0
1969
+ // exponentially, at 100 % CPU). The metadata api reports the
1970
+ // interface status instead; the client is created if the box
1971
+ // stops being an openccu-lite (onGone).
1972
+ this.logger.info('rpc client ReGaHSS not created: openccu-lite has no ReGaHSS');
1973
+ return;
1974
+ }
1975
+
1871
1976
  if (enabled) {
1872
1977
  this.createClient(iface)
1873
1978
  .then(() => {
1874
1979
  if (this.ifaceTypes[iface].init) {
1875
1980
  return this.rpcInit(iface)
1876
1981
  .then(() => {
1877
- this.setIfaceStatus(iface, true);
1982
+ this.rpcInitSucceeded(iface);
1878
1983
  })
1879
1984
  .catch((error) => {
1880
- this.logger.error('init', iface, error);
1881
- this.hadTimeout.add(iface);
1882
- this.setIfaceStatus(iface, false);
1985
+ this.rpcInitFailed(iface, error);
1883
1986
  });
1884
1987
  }
1885
1988
 
@@ -1896,7 +1999,29 @@ module.exports = function (RED) {
1896
1999
  * @param iface
1897
2000
  * @returns {Promise<any>}
1898
2001
  */
1899
- createClient(iface) {
2002
+ /**
2003
+ * Drop an rpc client and stop its reconnect timer (binrpc >= 4.3.0 has
2004
+ * close(); the xmlrpc client holds no timer). Without this a replaced
2005
+ * client kept reconnecting in the background for the life of the process.
2006
+ * @param iface
2007
+ */
2008
+ closeClient(iface) {
2009
+ const client = this.clients[iface];
2010
+ if (!client) {
2011
+ return;
2012
+ }
2013
+
2014
+ delete this.clients[iface];
2015
+ if (typeof client.close === 'function') {
2016
+ try {
2017
+ client.close();
2018
+ } catch (error) {
2019
+ this.logger.debug('close rpc client ' + iface + ': ' + error.message);
2020
+ }
2021
+ }
2022
+ }
2023
+
2024
+ createClient(iface, {quiet = false} = {}) {
1900
2025
  return new Promise((resolve) => {
1901
2026
  const {rpc, port, path, protocol, auth, user, pass, tls, inSecure} = this.ifaceTypes[iface];
1902
2027
  const clientOptions = {};
@@ -1919,7 +2044,7 @@ module.exports = function (RED) {
1919
2044
  this.clients[iface] = rpc.createClient(clientOptions);
1920
2045
  }
1921
2046
 
1922
- this.logger.info(
2047
+ this.logger[quiet ? 'debug' : 'info'](
1923
2048
  'rpc client ' +
1924
2049
  iface +
1925
2050
  ' ' +
@@ -1946,7 +2071,7 @@ module.exports = function (RED) {
1946
2071
  * @param iface
1947
2072
  * @returns {Promise<any>}
1948
2073
  */
1949
- rpcInit(iface) {
2074
+ rpcInit(iface, {retry = false} = {}) {
1950
2075
  return new Promise((resolve, reject) => {
1951
2076
  const initUrl = this.rpcServer(iface);
1952
2077
  const hash = base62(crypto.createHash('sha1').update(initUrl).digest()).slice(0, 6);
@@ -1954,7 +2079,8 @@ module.exports = function (RED) {
1954
2079
  this.lastEvent[iface] = now();
1955
2080
 
1956
2081
  const {protocol, port} = this.ifaceTypes[iface];
1957
- this.logger.info(
2082
+ // task 11: a retry logs its attempt at debug level
2083
+ this.logger[retry ? 'debug' : 'info'](
1958
2084
  'init ' +
1959
2085
  iface +
1960
2086
  ' (' +
@@ -1968,8 +2094,22 @@ module.exports = function (RED) {
1968
2094
  ' ' +
1969
2095
  initId,
1970
2096
  );
1971
- this.methodCall(iface, 'init', [initUrl, initId])
2097
+ if (retry) {
2098
+ // a fresh client: the one created after the failure (binrpc) may
2099
+ // still wait for its own reconnect timer and fail the write at once
2100
+ this.closeClient(iface);
2101
+ this.createClient(iface, {quiet: true});
2102
+ }
2103
+
2104
+ // a failed init is logged by the retry (InitRetry), not here
2105
+ this.methodCall(iface, 'init', [initUrl, initId], {quiet: true})
1972
2106
  .then(() => {
2107
+ // the ping/re-init liveness starts after a successful init only;
2108
+ // a failed one is retried by rpcInitFailed (task 11)
2109
+ if (this.ifaceTypes[iface].ping && !this.closing) {
2110
+ this.rpcCheckInit(iface);
2111
+ }
2112
+
1973
2113
  if (iface === 'CUxD') {
1974
2114
  this.getDevices(iface)
1975
2115
  .then(() => resolve(iface))
@@ -1985,12 +2125,7 @@ module.exports = function (RED) {
1985
2125
  resolve(iface);
1986
2126
  }
1987
2127
  })
1988
- .catch((error) => reject(error))
1989
- .finally(() => {
1990
- if (this.ifaceTypes[iface].ping) {
1991
- this.rpcCheckInit(iface);
1992
- }
1993
- });
2128
+ .catch((error) => reject(error));
1994
2129
  });
1995
2130
  }
1996
2131
 
@@ -2075,7 +2210,9 @@ module.exports = function (RED) {
2075
2210
  this.hadTimeout.add(iface);
2076
2211
  this.setIfaceStatus(iface, false);
2077
2212
  this.logger.warn('ping timeout', iface, elapsed);
2078
- this.rpcInit(iface).catch((error) => this.logger.error(error.message));
2213
+ this.rpcInit(iface)
2214
+ .then(() => this.rpcInitSucceeded(iface))
2215
+ .catch((error) => this.rpcInitFailed(iface, error));
2079
2216
  return;
2080
2217
  }
2081
2218
 
@@ -2099,7 +2236,8 @@ module.exports = function (RED) {
2099
2236
  this.logger.debug('rpcClose');
2100
2237
  const calls = [];
2101
2238
  Object.keys(this.clients).forEach((iface) => {
2102
- if (this.ifaceTypes[iface].init) {
2239
+ // an interface still waiting for its process never took our init
2240
+ if (this.ifaceTypes[iface].init && !this.ifaceWaiting[iface]) {
2103
2241
  this.logger.debug('queue de-init ' + iface + ' ' + this.initUrl(iface));
2104
2242
  calls.push(() => {
2105
2243
  return new Promise((resolve) => {
@@ -2166,6 +2304,15 @@ module.exports = function (RED) {
2166
2304
  });
2167
2305
  });
2168
2306
 
2307
+ // last: the clients themselves, so their reconnect timers die with the
2308
+ // node (a redeploy used to leave every binrpc client reconnecting)
2309
+ calls.push(() => {
2310
+ Object.keys(this.clients).forEach((iface) => {
2311
+ this.closeClient(iface);
2312
+ });
2313
+ return Promise.resolve();
2314
+ });
2315
+
2169
2316
  this.logger.debug('shutdown tasks: ' + calls.length);
2170
2317
  return calls.reduce((p, task) => p.then(task), Promise.resolve());
2171
2318
  }
@@ -3152,19 +3299,26 @@ module.exports = function (RED) {
3152
3299
  * @param params
3153
3300
  * @returns {Promise<any>}
3154
3301
  */
3155
- methodCall(iface, method, parameters) {
3302
+ methodCall(iface, method, parameters, {quiet = false} = {}) {
3156
3303
  return new Promise((resolve, reject) => {
3157
3304
  if (this.clients[iface]) {
3158
3305
  this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
3159
3306
  this.clients[iface].methodCall(method, parameters, (err, res) => {
3160
3307
  if (err) {
3161
- this.logger.error(' <', iface, method, err);
3162
- delete this.clients[iface];
3163
- this.createClient(iface);
3308
+ this.logger[quiet ? 'debug' : 'error'](' <', iface, method, err);
3309
+ this.closeClient(iface);
3310
+ // a closed node creates no new client (it would reconnect forever)
3311
+ if (!this.closing) {
3312
+ this.createClient(iface, {quiet});
3313
+ }
3314
+
3164
3315
  reject(err);
3165
3316
  } else if (res && res.faultCode) {
3166
- this.logger.error(' <', iface, method, JSON.stringify(res));
3167
- reject(new Error(res.faultString));
3317
+ this.logger[quiet ? 'debug' : 'error'](' <', iface, method, JSON.stringify(res));
3318
+ const fault = new Error(res.faultString);
3319
+ fault.faultCode = res.faultCode;
3320
+ fault.faultString = res.faultString;
3321
+ reject(fault);
3168
3322
  } else {
3169
3323
  this.logger.debug(' <', iface, method, JSON.stringify(res));
3170
3324
  resolve(res);
@@ -0,0 +1,181 @@
1
+ /*
2
+ * Retrying a failed rpc init, separate from the ping/re-init liveness.
3
+ *
4
+ * An interface process that does not answer yet (a box still booting, an
5
+ * addon started before the interfaces, a daemon restart) used to leave the
6
+ * interface without events: rpcCheckInit only runs with cached devices and
7
+ * then waits pingTimeout (600 s for HmIP-RF). Now a failed init schedules its
8
+ * own retry, 2 s, 4 s, 8 s, 16 s and then every 30 s, until it succeeds or the
9
+ * node is closed.
10
+ *
11
+ * While the process is unreachable the interface is "waiting": one warn line,
12
+ * then debug. A fault answer (the process is up and refuses the init) stays an
13
+ * error, logged once per distinct fault.
14
+ */
15
+
16
+ const RETRY_DELAYS = [2000, 4000, 8000, 16000];
17
+ const RETRY_MAX_DELAY = 30000;
18
+
19
+ /**
20
+ * The delay before the next attempt.
21
+ * @param {number} failures failed attempts so far (1 after the first failure)
22
+ * @returns {number} milliseconds
23
+ */
24
+ function retryDelay(failures) {
25
+ if (failures >= 1 && failures <= RETRY_DELAYS.length) {
26
+ return RETRY_DELAYS[failures - 1];
27
+ }
28
+
29
+ return RETRY_MAX_DELAY;
30
+ }
31
+
32
+ /**
33
+ * Whether the error is a fault answer of the interface process (it is up)
34
+ * rather than a transport error (refused, reset, timeout).
35
+ * @param {*} error
36
+ * @returns {boolean}
37
+ */
38
+ function isFault(error) {
39
+ return Boolean(error) && typeof error === 'object' && error.faultCode !== undefined;
40
+ }
41
+
42
+ /**
43
+ * A short reason for the log line, e.g. "connect ECONNREFUSED".
44
+ * @param {*} error
45
+ * @returns {string}
46
+ */
47
+ function describeError(error) {
48
+ if (!error) {
49
+ return 'unknown error';
50
+ }
51
+
52
+ if (isFault(error)) {
53
+ return 'fault ' + error.faultCode + (error.faultString ? ' ' + error.faultString : '');
54
+ }
55
+
56
+ if (error.code) {
57
+ return (error.syscall ? error.syscall + ' ' : 'connect ') + error.code;
58
+ }
59
+
60
+ return error.message || String(error);
61
+ }
62
+
63
+ class InitRetry {
64
+ /**
65
+ * @param {object} options
66
+ * @param {string} options.iface interface name for the log lines
67
+ * @param {function(): Promise} options.attempt one init attempt
68
+ * @param {object} options.logger {debug, info, warn, error}
69
+ * @param {function(string)} options.onState 'waiting' | 'failed' | 'connected'
70
+ * @param {object} [options.timers] {setTimeout, clearTimeout}, for tests
71
+ */
72
+ constructor({iface, attempt, logger, onState, timers}) {
73
+ this.iface = iface;
74
+ this.attempt = attempt;
75
+ this.logger = logger;
76
+ this.onState = onState || (() => {});
77
+ this.timers = timers || {setTimeout, clearTimeout};
78
+ this.timer = null;
79
+ this.stopped = false;
80
+ this.reset();
81
+ }
82
+
83
+ reset() {
84
+ this.failures = 0;
85
+ this.warned = false;
86
+ this.faults = new Set();
87
+ }
88
+
89
+ /** true while a retry is scheduled */
90
+ get pending() {
91
+ return this.timer !== null;
92
+ }
93
+
94
+ /**
95
+ * An init attempt failed: log it and schedule the next one.
96
+ * @param {*} error
97
+ */
98
+ failed(error) {
99
+ if (this.stopped) {
100
+ return;
101
+ }
102
+
103
+ this.failures += 1;
104
+ const delay = retryDelay(this.failures);
105
+ const seconds = delay / 1000;
106
+ const reason = describeError(error);
107
+ if (isFault(error)) {
108
+ if (this.faults.has(reason)) {
109
+ this.logger.debug(
110
+ 'init ' + this.iface + ' failed again: ' + reason + ', retrying in ' + seconds + ' s',
111
+ );
112
+ } else {
113
+ this.faults.add(reason);
114
+ this.logger.error('init ' + this.iface + ' failed: ' + reason + ', retrying in ' + seconds + ' s');
115
+ }
116
+
117
+ this.onState('failed');
118
+ } else {
119
+ if (this.warned) {
120
+ this.logger.debug(
121
+ this.iface + ' still not reachable (' + reason + '), next attempt in ' + seconds + ' s',
122
+ );
123
+ } else {
124
+ this.warned = true;
125
+ this.logger.warn(this.iface + ' not reachable yet (' + reason + '), retrying');
126
+ }
127
+
128
+ this.onState('waiting');
129
+ }
130
+
131
+ this.clear();
132
+ this.timer = this.timers.setTimeout(() => {
133
+ this.timer = null;
134
+ this.run();
135
+ }, delay);
136
+ }
137
+
138
+ /** one attempt now */
139
+ run() {
140
+ if (this.stopped) {
141
+ return Promise.resolve();
142
+ }
143
+
144
+ return Promise.resolve()
145
+ .then(() => this.attempt())
146
+ .then(
147
+ () => this.succeeded(),
148
+ (error) => this.failed(error),
149
+ );
150
+ }
151
+
152
+ /** an init attempt succeeded */
153
+ succeeded() {
154
+ if (this.stopped) {
155
+ return;
156
+ }
157
+
158
+ this.clear();
159
+ if (this.failures > 0) {
160
+ this.logger.info(this.iface + ' connected after ' + (this.failures + 1) + ' attempts');
161
+ }
162
+
163
+ this.reset();
164
+ this.onState('connected');
165
+ }
166
+
167
+ clear() {
168
+ if (this.timer !== null) {
169
+ this.timers.clearTimeout(this.timer);
170
+ this.timer = null;
171
+ }
172
+ }
173
+
174
+ /** no more attempts (node closed or redeployed, interface disabled) */
175
+ stop() {
176
+ this.stopped = true;
177
+ this.clear();
178
+ }
179
+ }
180
+
181
+ module.exports = {InitRetry, retryDelay, isFault, describeError, RETRY_DELAYS, RETRY_MAX_DELAY};
@@ -1,5 +1,10 @@
1
+ const WAITING = {fill: 'yellow', shape: 'ring', text: 'waiting'};
2
+
1
3
  module.exports = (that, data) => {
2
4
  data = data || {};
5
+ // task 11: an interface whose process does not answer yet is retried; the
6
+ // nodes show "waiting" (yellow ring) for it instead of "disconnected"
7
+ const waiting = data.ifaceWaiting || {};
3
8
  if (!that.iface) {
4
9
  if (data.ifaceStatus) {
5
10
  let status = 0;
@@ -9,8 +14,13 @@ module.exports = (that, data) => {
9
14
  }
10
15
  });
11
16
  if (status < 1) {
12
- that.status({fill: 'red', shape: 'dot', text: 'disconnected'});
13
- that.currentStatus = 'red';
17
+ if (Object.keys(waiting).some((s) => waiting[s])) {
18
+ that.status(WAITING);
19
+ that.currentStatus = 'waiting';
20
+ } else {
21
+ that.status({fill: 'red', shape: 'dot', text: 'disconnected'});
22
+ that.currentStatus = 'red';
23
+ }
14
24
  } else if (status === Object.keys(data.ifaceStatus).length) {
15
25
  if (that.currentStatus !== 'green') {
16
26
  that.status({fill: 'green', shape: 'dot', text: 'connected'});
@@ -29,6 +39,9 @@ module.exports = (that, data) => {
29
39
  that.status({fill: 'green', shape: 'dot', text: 'connected'});
30
40
  that.currentStatus = 'green';
31
41
  }
42
+ } else if (waiting[that.iface]) {
43
+ that.status(WAITING);
44
+ that.currentStatus = 'waiting';
32
45
  } else {
33
46
  that.status({fill: 'red', shape: 'dot', text: 'disconnected'});
34
47
  that.currentStatus = 'red';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ccu",
3
- "version": "4.4.2",
3
+ "version": "4.4.4",
4
4
  "license": "MIT",
5
5
  "main": "none",
6
6
  "keywords": [
@@ -50,7 +50,7 @@
50
50
  }
51
51
  ],
52
52
  "dependencies": {
53
- "binrpc": "^4.2.0",
53
+ "binrpc": "^4.3.0",
54
54
  "homematic-rega": "^2.0.1",
55
55
  "homematic-xmlrpc": "^2.0.0",
56
56
  "mqtt-wildcard": "^3.0.9"
@@ -75,7 +75,7 @@
75
75
  "overrides": {
76
76
  "hm-simulator": {
77
77
  "homematic-xmlrpc": "^2.0.0",
78
- "binrpc": "^4.2.0"
78
+ "binrpc": "^4.3.0"
79
79
  }
80
80
  },
81
81
  "engines": {