node-red-contrib-ccu 4.4.3 → 4.4.5
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 +52 -48
- package/README.md +10 -39
- package/nodes/ccu-connection.js +137 -35
- package/nodes/lib/initretry.js +207 -0
- package/nodes/lib/localccu.js +21 -3
- package/nodes/lib/status.js +15 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,23 +5,62 @@ 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.5 (2026-09-26)
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **A Node-RED started before rfd went through the CCU's proxy for good.**
|
|
13
|
+
The connection decides at start whether it runs on the CCU itself, and
|
|
14
|
+
looked only for a listener on rfd's direct port. On openccu-lite, where
|
|
15
|
+
RedMatic starts at boot before the interface processes (the early start),
|
|
16
|
+
that port is not open yet, so every interface went through lighttpd's
|
|
17
|
+
proxy ports (XML-RPC instead of BIN-RPC for BidCos) until the next restart.
|
|
18
|
+
A loopback host on a system that has the CCU's interface list
|
|
19
|
+
(`/etc/config/InterfacesList.xml`) is now taken as local as well.
|
|
20
|
+
- **Waiting for an interface process that is still starting is no warning**
|
|
21
|
+
(task 12). Before the interface was ever connected, a refused connection
|
|
22
|
+
is one info line (_"HmIP-RF not listening yet (connect ECONNREFUSED),
|
|
23
|
+
waiting for it - retrying in 1 s"_); another transport error, or a refused
|
|
24
|
+
connection after the interface had been connected, is still a warning, and
|
|
25
|
+
a fault answer an error.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **A failed `init` is retried after 1 s, 2 s, 4 s and 8 s, then every 15 s**
|
|
30
|
+
(task 12; was 2, 4, 8, 16 s, then every 30 s), the schedule of hm2mqtt.js and
|
|
31
|
+
the Homematic Manager.
|
|
32
|
+
|
|
33
|
+
## 4.4.4 (2026-09-17)
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **An interface stayed without events for minutes, or for good, when its
|
|
38
|
+
process did not answer the first `init`** (task 11). A failed `init` at
|
|
39
|
+
start (connection refused, timeout) was only retried by the ping
|
|
40
|
+
supervision, which does nothing without cached devices and waits the ping
|
|
41
|
+
timeout otherwise: 60 s, and 600 s for HmIP-RF. A Node-RED that starts
|
|
42
|
+
before the interface processes (a box still booting, a daemon restart) now
|
|
43
|
+
retries the `init` on its own after 2 s, 4 s, 8 s, 16 s and then every 30 s,
|
|
44
|
+
for every interface, until it succeeds or the node is closed or redeployed.
|
|
45
|
+
While it waits, the log has one warning (_"HmIP-RF not reachable yet
|
|
46
|
+
(connect ECONNREFUSED), retrying"_) instead of an error line, the success
|
|
47
|
+
after retries is logged (_"HmIP-RF connected after 3 attempts"_), and the
|
|
48
|
+
nodes show **waiting** (yellow ring) instead of disconnected. A fault answer
|
|
49
|
+
of a running process stays an error. After a successful `init` the ping and
|
|
50
|
+
re-`init` supervision works as before.
|
|
51
|
+
|
|
8
52
|
## 4.4.3 (2026-09-09)
|
|
9
53
|
|
|
10
54
|
### Fixed
|
|
11
55
|
|
|
12
|
-
- **Node-RED at 100 % CPU and unresponsive
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
ReGaHSS
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
ReGaHSS was down for a while (restart, backup restore); it just ended when
|
|
21
|
-
the port came back. Two changes: the ReGaHSS client is not created at all
|
|
22
|
-
while the box is an openccu-lite (and is closed when a box becomes one, or
|
|
23
|
-
created when it stops being one), and this release requires
|
|
24
|
-
[binrpc 4.3.0](https://github.com/hobbyquaker/binrpc/blob/master/CHANGELOG.md),
|
|
56
|
+
- **Node-RED at 100 % CPU and unresponsive while ReGaHSS is unreachable**
|
|
57
|
+
(B-27). binrpc up to 4.2.0 armed a new reconnect timer for both the `error`
|
|
58
|
+
and the `close` event of every refused connect, so the number of attempts to
|
|
59
|
+
the ReGaHSS port 31999 doubled every 2.5 s without bound: about 7,000 refused
|
|
60
|
+
connects per second, 30,000 pending timers and 68 million connects in under
|
|
61
|
+
three hours. On a CCU this hit whenever ReGaHSS was down for a while
|
|
62
|
+
(restart, backup restore) and ended when the port came back. This release
|
|
63
|
+
requires [binrpc 4.3.0](https://github.com/hobbyquaker/binrpc/blob/master/CHANGELOG.md),
|
|
25
64
|
which keeps a single reconnect timer per client with exponential backoff
|
|
26
65
|
(2.5 s doubling to 30 s) and has `close()`. The connection node now closes
|
|
27
66
|
its rpc clients when it is removed or redeployed, and when it replaces a
|
|
@@ -81,46 +120,11 @@ hang up` — became an **unhandled rejection**, which Node >= 15 turns
|
|
|
81
120
|
|
|
82
121
|
### Added
|
|
83
122
|
|
|
84
|
-
- **openccu-lite support** (roadmap B-17). [openccu-lite](https://github.com/hobbyquaker/openccu-lite)
|
|
85
|
-
is a Homematic CCU firmware without ReGaHSS: the interface processes
|
|
86
|
-
are unchanged, but nothing listens on 8181/8183, HM-Script is never
|
|
87
|
-
interpreted and there are no ReGa ids. The connection node now asks
|
|
88
|
-
the box `GET /api/meta/v1/version` when it starts and on every
|
|
89
|
-
reconnect of its name sync; a CCU3/RaspberryMatic/OpenCCU answers 404
|
|
90
|
-
and everything runs exactly as before, an openccu-lite answers with
|
|
91
|
-
its API version and device, channel, room and function names come
|
|
92
|
-
from its metadata API instead — a snapshot at start, then the box's
|
|
93
|
-
Server-Sent-Events change stream, so a rename in the box's UI reaches
|
|
94
|
-
running flows within a second without a redeploy. `msg.channelName`,
|
|
95
|
-
`msg.rooms`, `msg.functions` and the room/function filters keep the
|
|
96
|
-
shape they have on a CCU (rooms and functions are a tree there and
|
|
97
|
-
are flattened to the usual arrays of names, most specific first).
|
|
98
|
-
Nothing has to be configured: the same connection node works on both
|
|
99
|
-
kinds of box, which is what makes moving a backup between them
|
|
100
|
-
harmless.
|
|
101
|
-
- connection node: **openccu-lite token** and **openccu-lite port**
|
|
102
|
-
fields. The token is only needed when Node-RED does _not_ run on the
|
|
103
|
-
box — on the box the read-only token in
|
|
104
|
-
`/usr/local/etc/occulite/local-token` is picked up automatically, off
|
|
105
|
-
the box the administrator creates one on the box's _Users_ page. A
|
|
106
|
-
rejected or missing token is not fatal: the nodes work with addresses
|
|
107
|
-
instead of names, the connection logs it once and takes the names as
|
|
108
|
-
soon as a valid token is there. The port field is only for boxes
|
|
109
|
-
whose web server is not on 80/443. Both are ignored on a CCU.
|
|
110
123
|
- connection node: the cached `ccu_rega_<host>.json` now also holds the
|
|
111
124
|
room and function name lists, so the editor's room and function
|
|
112
125
|
pickers are filled right after a restart instead of only after the
|
|
113
126
|
first successful sync.
|
|
114
127
|
|
|
115
|
-
### Changed
|
|
116
|
-
|
|
117
|
-
- `ccu-sysvar`, `ccu-program`, `ccu-script` and `ccu-poll` stay in the
|
|
118
|
-
palette and in flows on openccu-lite, and stay accepted in the
|
|
119
|
-
editor. There is no ReGa DOM on such a box, so instead of failing the
|
|
120
|
-
connection they log one line when the connection starts and answer
|
|
121
|
-
every message with a clear error (`… are not available on this box
|
|
122
|
-
(openccu-lite has no ReGaHSS)`). On a CCU nothing about them changes.
|
|
123
|
-
|
|
124
128
|
## 4.3.0 (2026-09-04)
|
|
125
129
|
|
|
126
130
|
### Added
|
package/README.md
CHANGED
|
@@ -30,45 +30,16 @@ Some example flows can be found in the [RedMatic Wiki](https://github.com/rdmtc/
|
|
|
30
30
|
|
|
31
31
|
## openccu-lite
|
|
32
32
|
|
|
33
|
-
[openccu-lite](https://github.com/hobbyquaker/openccu-lite) is a Homematic CCU firmware **without
|
|
34
|
-
interface processes (`rfd`, `hs485d`, `hmipserver`) are the same, so everything these nodes do over
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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.
|
|
33
|
+
[openccu-lite](https://github.com/hobbyquaker/openccu-lite) is a Homematic CCU firmware based on OpenCCU **without
|
|
34
|
+
ReGaHSS**. The interface processes (`rfd`, `hs485d`, `hmipserver`) are the same, so everything these nodes do over
|
|
35
|
+
BINRPC/XMLRPC works unchanged. Device, channel, room and function names come from the system's metadata API instead of
|
|
36
|
+
ReGaHSS; the connection node detects this at start (`GET /api/meta/v1/version`), nothing has to be configured, and
|
|
37
|
+
`msg.channelName`, `msg.rooms`, `msg.functions` and the room/function filters keep their shape.
|
|
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.
|
|
41
|
+
- **System variables, programs and HM-Script do not exist there.** The `ccu-sysvar`, `ccu-program`, `ccu-script` and
|
|
42
|
+
`ccu-poll` nodes stay in the flows and answer every message with an error.
|
|
72
43
|
|
|
73
44
|
## Home Assistant
|
|
74
45
|
|
package/nodes/ccu-connection.js
CHANGED
|
@@ -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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
'Interface',
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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');
|
|
@@ -1895,12 +1979,10 @@ module.exports = function (RED) {
|
|
|
1895
1979
|
if (this.ifaceTypes[iface].init) {
|
|
1896
1980
|
return this.rpcInit(iface)
|
|
1897
1981
|
.then(() => {
|
|
1898
|
-
this.
|
|
1982
|
+
this.rpcInitSucceeded(iface);
|
|
1899
1983
|
})
|
|
1900
1984
|
.catch((error) => {
|
|
1901
|
-
this.
|
|
1902
|
-
this.hadTimeout.add(iface);
|
|
1903
|
-
this.setIfaceStatus(iface, false);
|
|
1985
|
+
this.rpcInitFailed(iface, error);
|
|
1904
1986
|
});
|
|
1905
1987
|
}
|
|
1906
1988
|
|
|
@@ -1939,7 +2021,7 @@ module.exports = function (RED) {
|
|
|
1939
2021
|
}
|
|
1940
2022
|
}
|
|
1941
2023
|
|
|
1942
|
-
createClient(iface) {
|
|
2024
|
+
createClient(iface, {quiet = false} = {}) {
|
|
1943
2025
|
return new Promise((resolve) => {
|
|
1944
2026
|
const {rpc, port, path, protocol, auth, user, pass, tls, inSecure} = this.ifaceTypes[iface];
|
|
1945
2027
|
const clientOptions = {};
|
|
@@ -1962,7 +2044,7 @@ module.exports = function (RED) {
|
|
|
1962
2044
|
this.clients[iface] = rpc.createClient(clientOptions);
|
|
1963
2045
|
}
|
|
1964
2046
|
|
|
1965
|
-
this.logger
|
|
2047
|
+
this.logger[quiet ? 'debug' : 'info'](
|
|
1966
2048
|
'rpc client ' +
|
|
1967
2049
|
iface +
|
|
1968
2050
|
' ' +
|
|
@@ -1989,7 +2071,7 @@ module.exports = function (RED) {
|
|
|
1989
2071
|
* @param iface
|
|
1990
2072
|
* @returns {Promise<any>}
|
|
1991
2073
|
*/
|
|
1992
|
-
rpcInit(iface) {
|
|
2074
|
+
rpcInit(iface, {retry = false} = {}) {
|
|
1993
2075
|
return new Promise((resolve, reject) => {
|
|
1994
2076
|
const initUrl = this.rpcServer(iface);
|
|
1995
2077
|
const hash = base62(crypto.createHash('sha1').update(initUrl).digest()).slice(0, 6);
|
|
@@ -1997,7 +2079,8 @@ module.exports = function (RED) {
|
|
|
1997
2079
|
this.lastEvent[iface] = now();
|
|
1998
2080
|
|
|
1999
2081
|
const {protocol, port} = this.ifaceTypes[iface];
|
|
2000
|
-
|
|
2082
|
+
// task 11: a retry logs its attempt at debug level
|
|
2083
|
+
this.logger[retry ? 'debug' : 'info'](
|
|
2001
2084
|
'init ' +
|
|
2002
2085
|
iface +
|
|
2003
2086
|
' (' +
|
|
@@ -2011,8 +2094,22 @@ module.exports = function (RED) {
|
|
|
2011
2094
|
' ' +
|
|
2012
2095
|
initId,
|
|
2013
2096
|
);
|
|
2014
|
-
|
|
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})
|
|
2015
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
|
+
|
|
2016
2113
|
if (iface === 'CUxD') {
|
|
2017
2114
|
this.getDevices(iface)
|
|
2018
2115
|
.then(() => resolve(iface))
|
|
@@ -2028,12 +2125,7 @@ module.exports = function (RED) {
|
|
|
2028
2125
|
resolve(iface);
|
|
2029
2126
|
}
|
|
2030
2127
|
})
|
|
2031
|
-
.catch((error) => reject(error))
|
|
2032
|
-
.finally(() => {
|
|
2033
|
-
if (this.ifaceTypes[iface].ping) {
|
|
2034
|
-
this.rpcCheckInit(iface);
|
|
2035
|
-
}
|
|
2036
|
-
});
|
|
2128
|
+
.catch((error) => reject(error));
|
|
2037
2129
|
});
|
|
2038
2130
|
}
|
|
2039
2131
|
|
|
@@ -2118,7 +2210,9 @@ module.exports = function (RED) {
|
|
|
2118
2210
|
this.hadTimeout.add(iface);
|
|
2119
2211
|
this.setIfaceStatus(iface, false);
|
|
2120
2212
|
this.logger.warn('ping timeout', iface, elapsed);
|
|
2121
|
-
this.rpcInit(iface)
|
|
2213
|
+
this.rpcInit(iface)
|
|
2214
|
+
.then(() => this.rpcInitSucceeded(iface))
|
|
2215
|
+
.catch((error) => this.rpcInitFailed(iface, error));
|
|
2122
2216
|
return;
|
|
2123
2217
|
}
|
|
2124
2218
|
|
|
@@ -2142,7 +2236,8 @@ module.exports = function (RED) {
|
|
|
2142
2236
|
this.logger.debug('rpcClose');
|
|
2143
2237
|
const calls = [];
|
|
2144
2238
|
Object.keys(this.clients).forEach((iface) => {
|
|
2145
|
-
|
|
2239
|
+
// an interface still waiting for its process never took our init
|
|
2240
|
+
if (this.ifaceTypes[iface].init && !this.ifaceWaiting[iface]) {
|
|
2146
2241
|
this.logger.debug('queue de-init ' + iface + ' ' + this.initUrl(iface));
|
|
2147
2242
|
calls.push(() => {
|
|
2148
2243
|
return new Promise((resolve) => {
|
|
@@ -3204,19 +3299,26 @@ module.exports = function (RED) {
|
|
|
3204
3299
|
* @param params
|
|
3205
3300
|
* @returns {Promise<any>}
|
|
3206
3301
|
*/
|
|
3207
|
-
methodCall(iface, method, parameters) {
|
|
3302
|
+
methodCall(iface, method, parameters, {quiet = false} = {}) {
|
|
3208
3303
|
return new Promise((resolve, reject) => {
|
|
3209
3304
|
if (this.clients[iface]) {
|
|
3210
3305
|
this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
|
|
3211
3306
|
this.clients[iface].methodCall(method, parameters, (err, res) => {
|
|
3212
3307
|
if (err) {
|
|
3213
|
-
this.logger
|
|
3308
|
+
this.logger[quiet ? 'debug' : 'error'](' <', iface, method, err);
|
|
3214
3309
|
this.closeClient(iface);
|
|
3215
|
-
|
|
3310
|
+
// a closed node creates no new client (it would reconnect forever)
|
|
3311
|
+
if (!this.closing) {
|
|
3312
|
+
this.createClient(iface, {quiet});
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3216
3315
|
reject(err);
|
|
3217
3316
|
} else if (res && res.faultCode) {
|
|
3218
|
-
this.logger
|
|
3219
|
-
|
|
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);
|
|
3220
3322
|
} else {
|
|
3221
3323
|
this.logger.debug(' <', iface, method, JSON.stringify(res));
|
|
3222
3324
|
resolve(res);
|
|
@@ -0,0 +1,207 @@
|
|
|
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, 1 s, 2 s, 4 s, 8 s and then every 15 s (task 12), until it
|
|
9
|
+
* succeeds or the node is closed.
|
|
10
|
+
*
|
|
11
|
+
* While the process is unreachable the interface is "waiting": one line, then
|
|
12
|
+
* debug. Before the interface was ever connected, a refused connection is
|
|
13
|
+
* info - the process is still starting (openccu-lite's early start starts
|
|
14
|
+
* Node-RED before rfd and hmipserver) - and every other transport error, or a
|
|
15
|
+
* refusal after it had been connected, is a warning. A fault answer (the
|
|
16
|
+
* process is up and refuses the init) stays an error, logged once per
|
|
17
|
+
* distinct fault.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const RETRY_DELAYS = [1000, 2000, 4000, 8000];
|
|
21
|
+
const RETRY_MAX_DELAY = 15000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The delay before the next attempt.
|
|
25
|
+
* @param {number} failures failed attempts so far (1 after the first failure)
|
|
26
|
+
* @returns {number} milliseconds
|
|
27
|
+
*/
|
|
28
|
+
function retryDelay(failures) {
|
|
29
|
+
if (failures >= 1 && failures <= RETRY_DELAYS.length) {
|
|
30
|
+
return RETRY_DELAYS[failures - 1];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return RETRY_MAX_DELAY;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the error is a fault answer of the interface process (it is up)
|
|
38
|
+
* rather than a transport error (refused, reset, timeout).
|
|
39
|
+
* @param {*} error
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
function isFault(error) {
|
|
43
|
+
return Boolean(error) && typeof error === 'object' && error.faultCode !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether nothing listens on the port (yet): the process is not started.
|
|
48
|
+
* @param {*} error
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function isRefused(error) {
|
|
52
|
+
return Boolean(error) && (error.code === 'ECONNREFUSED' || /ECONNREFUSED/.test(String(error.message || '')));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A short reason for the log line, e.g. "connect ECONNREFUSED".
|
|
57
|
+
* @param {*} error
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
function describeError(error) {
|
|
61
|
+
if (!error) {
|
|
62
|
+
return 'unknown error';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (isFault(error)) {
|
|
66
|
+
return 'fault ' + error.faultCode + (error.faultString ? ' ' + error.faultString : '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (error.code) {
|
|
70
|
+
return (error.syscall ? error.syscall + ' ' : 'connect ') + error.code;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return error.message || String(error);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
class InitRetry {
|
|
77
|
+
/**
|
|
78
|
+
* @param {object} options
|
|
79
|
+
* @param {string} options.iface interface name for the log lines
|
|
80
|
+
* @param {function(): Promise} options.attempt one init attempt
|
|
81
|
+
* @param {object} options.logger {debug, info, warn, error}
|
|
82
|
+
* @param {function(string)} options.onState 'waiting' | 'failed' | 'connected'
|
|
83
|
+
* @param {object} [options.timers] {setTimeout, clearTimeout}, for tests
|
|
84
|
+
*/
|
|
85
|
+
constructor({iface, attempt, logger, onState, timers}) {
|
|
86
|
+
this.iface = iface;
|
|
87
|
+
this.attempt = attempt;
|
|
88
|
+
this.logger = logger;
|
|
89
|
+
this.onState = onState || (() => {});
|
|
90
|
+
this.timers = timers || {setTimeout, clearTimeout};
|
|
91
|
+
this.timer = null;
|
|
92
|
+
this.stopped = false;
|
|
93
|
+
this.everConnected = false;
|
|
94
|
+
this.reset();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
reset() {
|
|
98
|
+
this.failures = 0;
|
|
99
|
+
this.warned = false;
|
|
100
|
+
this.faults = new Set();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** true while a retry is scheduled */
|
|
104
|
+
get pending() {
|
|
105
|
+
return this.timer !== null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* An init attempt failed: log it and schedule the next one.
|
|
110
|
+
* @param {*} error
|
|
111
|
+
*/
|
|
112
|
+
failed(error) {
|
|
113
|
+
if (this.stopped) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
this.failures += 1;
|
|
118
|
+
const delay = retryDelay(this.failures);
|
|
119
|
+
const seconds = delay / 1000;
|
|
120
|
+
const reason = describeError(error);
|
|
121
|
+
if (isFault(error)) {
|
|
122
|
+
if (this.faults.has(reason)) {
|
|
123
|
+
this.logger.debug(
|
|
124
|
+
'init ' + this.iface + ' failed again: ' + reason + ', retrying in ' + seconds + ' s',
|
|
125
|
+
);
|
|
126
|
+
} else {
|
|
127
|
+
this.faults.add(reason);
|
|
128
|
+
this.logger.error('init ' + this.iface + ' failed: ' + reason + ', retrying in ' + seconds + ' s');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.onState('failed');
|
|
132
|
+
} else {
|
|
133
|
+
if (this.warned) {
|
|
134
|
+
this.logger.debug(
|
|
135
|
+
this.iface + ' still not reachable (' + reason + '), next attempt in ' + seconds + ' s',
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
this.warned = true;
|
|
139
|
+
if (!this.everConnected && isRefused(error)) {
|
|
140
|
+
this.logger.info(
|
|
141
|
+
this.iface +
|
|
142
|
+
' not listening yet (' +
|
|
143
|
+
reason +
|
|
144
|
+
'), waiting for it - retrying in ' +
|
|
145
|
+
seconds +
|
|
146
|
+
' s',
|
|
147
|
+
);
|
|
148
|
+
} else {
|
|
149
|
+
this.logger.warn(this.iface + ' not reachable yet (' + reason + '), retrying');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.onState('waiting');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.clear();
|
|
157
|
+
this.timer = this.timers.setTimeout(() => {
|
|
158
|
+
this.timer = null;
|
|
159
|
+
this.run();
|
|
160
|
+
}, delay);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** one attempt now */
|
|
164
|
+
run() {
|
|
165
|
+
if (this.stopped) {
|
|
166
|
+
return Promise.resolve();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Promise.resolve()
|
|
170
|
+
.then(() => this.attempt())
|
|
171
|
+
.then(
|
|
172
|
+
() => this.succeeded(),
|
|
173
|
+
(error) => this.failed(error),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** an init attempt succeeded */
|
|
178
|
+
succeeded() {
|
|
179
|
+
if (this.stopped) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.clear();
|
|
184
|
+
if (this.failures > 0) {
|
|
185
|
+
this.logger.info(this.iface + ' connected after ' + (this.failures + 1) + ' attempts');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.everConnected = true;
|
|
189
|
+
this.reset();
|
|
190
|
+
this.onState('connected');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
clear() {
|
|
194
|
+
if (this.timer !== null) {
|
|
195
|
+
this.timers.clearTimeout(this.timer);
|
|
196
|
+
this.timer = null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** no more attempts (node closed or redeployed, interface disabled) */
|
|
201
|
+
stop() {
|
|
202
|
+
this.stopped = true;
|
|
203
|
+
this.clear();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = {InitRetry, retryDelay, isFault, isRefused, describeError, RETRY_DELAYS, RETRY_MAX_DELAY};
|
package/nodes/lib/localccu.js
CHANGED
|
@@ -17,7 +17,15 @@
|
|
|
17
17
|
Rather than chasing lighttpd's include chain, ask the kernel: a
|
|
18
18
|
listener on the direct port is exactly the condition that makes using
|
|
19
19
|
it correct. /proc/net/tcp is a plain file, so this stays synchronous -
|
|
20
|
-
the interface table is built in the connection node's constructor.
|
|
20
|
+
the interface table is built in the connection node's constructor.
|
|
21
|
+
|
|
22
|
+
A listener alone misses a start before rfd: openccu-lite starts an addon
|
|
23
|
+
that copes with it before the interface processes (its early start), so
|
|
24
|
+
the ports are not open yet and the connection went through the proxy for
|
|
25
|
+
good. The CCU's own interface list, /etc/config/InterfacesList.xml, is
|
|
26
|
+
there from the first second on every CCU firmware (CCU3, OpenCCU,
|
|
27
|
+
openccu-lite), so a loopback host with that file is the CCU we run on as
|
|
28
|
+
well. */
|
|
21
29
|
|
|
22
30
|
const fs = require('fs');
|
|
23
31
|
|
|
@@ -26,6 +34,9 @@ const LOCAL_PROBE_PORTS = [32001, 31999];
|
|
|
26
34
|
|
|
27
35
|
const PROC_NET_TCP = ['/proc/net/tcp', '/proc/net/tcp6'];
|
|
28
36
|
|
|
37
|
+
/** The CCU firmware's list of its interface processes. */
|
|
38
|
+
const INTERFACES_LIST = '/etc/config/InterfacesList.xml';
|
|
39
|
+
|
|
29
40
|
/** /proc/net/tcp connection state 0A = TCP_LISTEN. */
|
|
30
41
|
const TCP_LISTEN = '0A';
|
|
31
42
|
|
|
@@ -67,6 +78,7 @@ function listeningPorts(files) {
|
|
|
67
78
|
* @param {string} host the configured CCU host
|
|
68
79
|
* @param {object} [options]
|
|
69
80
|
* @param {string[]} [options.files] /proc files to read, for tests
|
|
81
|
+
* @param {string} [options.interfacesList] the CCU's interface list, for tests
|
|
70
82
|
* @returns {boolean}
|
|
71
83
|
*/
|
|
72
84
|
function isLocalCcu(host, options) {
|
|
@@ -75,7 +87,13 @@ function isLocalCcu(host, options) {
|
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
const ports = listeningPorts(options && options.files);
|
|
78
|
-
|
|
90
|
+
if (LOCAL_PROBE_PORTS.some((port) => ports.has(port))) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// the interface processes are not up yet (a start before them): the
|
|
95
|
+
// firmware's interface list still says this is the CCU
|
|
96
|
+
return fs.existsSync((options && options.interfacesList) || INTERFACES_LIST);
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
module.exports = {isLocalCcu, listeningPorts, LOCAL_PROBE_PORTS};
|
|
99
|
+
module.exports = {isLocalCcu, listeningPorts, LOCAL_PROBE_PORTS, INTERFACES_LIST};
|
package/nodes/lib/status.js
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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';
|