node-red-contrib-ccu 4.7.0 → 4.7.2

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/.mocharc.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "require": "test/hooks.js",
3
+ "exit": true
4
+ }
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.2 (2026-10-29)
9
+
10
+ ### Fixed
11
+
12
+ - **On openccu-lite the metadata file was written once per interface at every
13
+ start** (`metadata saved to …/ccu_<host>.json`, four lines within a second
14
+ on a system with four interfaces), although nothing had changed. The
15
+ system's stream hands the connection every interface's whole device list
16
+ when it connects, in the shape of a `newDevices` call, and the connection
17
+ saved whenever that list was not empty - on a CCU `newDevices` only ever
18
+ names devices the connection did not know. The file is now written only
19
+ when a device is new or its data changed, a burst of changes is one write
20
+ (and one log line), and the cache files (metadata, names, values) are
21
+ written atomically - a temporary file and a rename - so a power cut while
22
+ writing leaves the old file, not a truncated one.
23
+ - **On openccu-lite the start log's `Interfaces:` line listed ReGaHSS**, right
24
+ after the line saying the system has no ReGaHSS; it leaves it out now (the
25
+ mqtt node's per-interface counters as well). Log and dialog texts say "the
26
+ system" where they said "the box".
27
+
28
+ ### Changed
29
+
30
+ - The README names RedMatic 9.15.1 as the version whose token makes lite-rpc
31
+ the default on the system (not 9.13).
32
+ - The release workflow's flow-library step no longer trusts the library's
33
+ "already at latest version" while its page still shows the previous
34
+ release; it asks again after 2, 5, 10 and 20 minutes and warns only after
35
+ that.
36
+
37
+ ## 4.7.1 (2026-10-28)
38
+
39
+ ### Fixed
40
+
41
+ - **On openccu-lite every write of a decimal value failed** - a dimmer's or a
42
+ blind's `LEVEL`, a thermostat's set point, an `ON_TIME`, through _value_,
43
+ _set value_, _rpc_ and the other nodes alike. A `FLOAT` datapoint is sent as
44
+ a typed double, which over XML-RPC is homematic-xmlrpc's
45
+ `{explicitDouble: n}`; the lite-rpc connection of 4.7.0 passed that object
46
+ on as it was, so the interface process got a struct where a number belongs
47
+ and refused it (`Failure` from rfd and hs485d,
48
+ `-5 Invalid parameter or value` from hmipserver). The lite-rpc connection
49
+ now sends lite-rpc's own typed double, `{double: n}` (or the plain number
50
+ on a system that does not announce it) - in `setValue`, in a paramset of
51
+ `putParamset` and in a multicall. The direct connection to a CCU is unchanged. Until this version
52
+ the workaround was _CCU_ in the connection node's _openccu-lite interfaces_.
53
+ - **Errors of the lite-rpc connection said less than they should:** a fault
54
+ of the interface process was logged as `fault -1 undefined` and reached the
55
+ flow as `OccuLiteError: Failure`, and a token without the needed rights did
56
+ not say which right it lacks. The fault now carries its code and text, and a
57
+ refused call names the missing scope.
58
+
8
59
  ## 4.7.0 (2026-10-27)
9
60
 
10
61
  ### Added
package/README.md CHANGED
@@ -47,7 +47,7 @@ start on openccu-lite, the log says so, and a choice made in the node is never c
47
47
  CCU-Jack keep their own connections.
48
48
 
49
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.
50
+ mints for it from RedMatic's manifest (RedMatic 9.15.1 or later); the names come with the system's read-only token.
51
51
  Nothing to configure. An older RedMatic has no such token and connects the CCU way, the log says so.
52
52
  - Node-RED **elsewhere**: an API token of the system goes into the connection node's **openccu-lite token** field -
53
53
  for lite-rpc it needs the `rpc:*` scope of what the flow does (`rpc:read` for events and reads, `rpc:operate` for
@@ -329,6 +329,6 @@
329
329
  <div class="form-row">
330
330
  <label for="node-config-input-metaPort"><i class="icon-tag"></i> openccu-lite port</label>
331
331
  <input type="text" id="node-config-input-metaPort" placeholder="80 / 443">
332
- <small>Only needed when the box's web server is not on the default port.</small>
332
+ <small>Only needed when the system's web server is not on the default port.</small>
333
333
  </div>
334
334
  </script>
@@ -19,6 +19,7 @@ const {Rega} = require('homematic-rega'); // ES module - require(esm) needs Node
19
19
  const metaProvider = require('./lib/metaprovider.js');
20
20
  const {InitRetry, retryDelay, RETRY_INTERVAL} = require('./lib/initretry.js');
21
21
  const lite = require('./lib/lite.js');
22
+ const {writeJsonAtomic, DebouncedSave} = require('./lib/cachefile.js');
22
23
  const xmlrpc = require('homematic-xmlrpc');
23
24
  const binrpc = require('binrpc');
24
25
 
@@ -781,6 +782,9 @@ module.exports = function (RED) {
781
782
  RED.settings.userDir || path.join(__dirname, '..'),
782
783
  'ccu_lite_' + this.host + '.json',
783
784
  );
785
+ // task 24: the device lists of all interfaces arrive within a second at start -
786
+ // one write of the metadata file for the burst, flushed at close
787
+ this.metadataSave = new DebouncedSave(() => this.writeMetadata(), 1000);
784
788
 
785
789
  this.loadMetadata();
786
790
  this.loadRegadata();
@@ -1130,11 +1134,30 @@ module.exports = function (RED) {
1130
1134
  * @returns {Promise<any>}
1131
1135
  */
1132
1136
  saveMetadata() {
1133
- return new Promise((resolve) => {
1134
- fs.writeFileSync(this.metadataFile, JSON.stringify(this.metadata));
1135
- this.logger.info('metadata saved to', this.metadataFile);
1136
- resolve();
1137
- });
1137
+ // task 24: a burst (every interface's device list at start) is one write and one line
1138
+ this.metadataSave.request();
1139
+ return Promise.resolve();
1140
+ }
1141
+
1142
+ /** writes the metadata file now (the debounce's write, and the flush at close) */
1143
+ writeMetadata() {
1144
+ this.writeCache(this.metadataFile, 'metadata', this.metadata);
1145
+ }
1146
+
1147
+ /**
1148
+ * task 24: one of the cache files, written atomically; a failure is logged, never thrown
1149
+ * (the caches are a convenience, the connection works without them)
1150
+ * @param {string} file
1151
+ * @param {string} what for the log line
1152
+ * @param {*} data
1153
+ */
1154
+ writeCache(file, what, data) {
1155
+ try {
1156
+ writeJsonAtomic(file, data);
1157
+ this.logger.info(what + ' saved to', file);
1158
+ } catch (error) {
1159
+ this.logger.error(what + ' not saved to ' + file + ': ' + error.message);
1160
+ }
1138
1161
  }
1139
1162
 
1140
1163
  /**
@@ -1142,25 +1165,19 @@ module.exports = function (RED) {
1142
1165
  * @returns {Promise<any>}
1143
1166
  */
1144
1167
  saveRegadata() {
1145
- return new Promise((resolve) => {
1146
- fs.writeFileSync(
1147
- this.regadataFile,
1148
- JSON.stringify({
1149
- channelNames: this.channelNames,
1150
- regaIdChannel: this.regaIdChannel,
1151
- regaChannels: this.regaChannels,
1152
- channelRooms: this.channelRooms,
1153
- channelFunctions: this.channelFunctions,
1154
- rooms: this.rooms,
1155
- functions: this.functions,
1156
- groups: this.groups,
1157
- sysvar: this.sysvar,
1158
- program: this.program,
1159
- }),
1160
- );
1161
- this.logger.info('regadata saved to', this.regadataFile);
1162
- resolve();
1168
+ this.writeCache(this.regadataFile, 'regadata', {
1169
+ channelNames: this.channelNames,
1170
+ regaIdChannel: this.regaIdChannel,
1171
+ regaChannels: this.regaChannels,
1172
+ channelRooms: this.channelRooms,
1173
+ channelFunctions: this.channelFunctions,
1174
+ rooms: this.rooms,
1175
+ functions: this.functions,
1176
+ groups: this.groups,
1177
+ sysvar: this.sysvar,
1178
+ program: this.program,
1163
1179
  });
1180
+ return Promise.resolve();
1164
1181
  }
1165
1182
 
1166
1183
  /**
@@ -1168,16 +1185,8 @@ module.exports = function (RED) {
1168
1185
  * @returns {Promise<any>}
1169
1186
  */
1170
1187
  saveValues() {
1171
- return new Promise((resolve) => {
1172
- fs.writeFileSync(
1173
- this.valuesFile,
1174
- JSON.stringify({
1175
- values: this.values,
1176
- }),
1177
- );
1178
- this.logger.info('values saved to', this.valuesFile);
1179
- resolve();
1180
- });
1188
+ this.writeCache(this.valuesFile, 'values', {values: this.values});
1189
+ return Promise.resolve();
1181
1190
  }
1182
1191
 
1183
1192
  /**
@@ -1353,6 +1362,7 @@ module.exports = function (RED) {
1353
1362
  Object.keys(this.liveness).forEach((iface) => this.stopLiveness(iface));
1354
1363
  clearTimeout(this.valuesPruneTimer);
1355
1364
 
1365
+ this.metadataSave.flush();
1356
1366
  this.saveRegadata();
1357
1367
  this.saveValues();
1358
1368
 
@@ -1491,7 +1501,7 @@ module.exports = function (RED) {
1491
1501
  const line =
1492
1502
  'meta api detection inconclusive (' +
1493
1503
  outcome.reason +
1494
- ') - names come from the ReGaHSS until the box answers, detecting again in ' +
1504
+ ') - names come from the ReGaHSS until the system answers, detecting again in ' +
1495
1505
  delay / 1000 +
1496
1506
  ' s';
1497
1507
  if (this.metaRedetectAttempt === 1) {
@@ -1568,6 +1578,8 @@ module.exports = function (RED) {
1568
1578
  delete this.ifaceStatus.ReGaHSS;
1569
1579
  delete this.ifaceWaiting.ReGaHSS;
1570
1580
  this.ifaceStatus[META_IFACE] = null;
1581
+ // task 25: the system has no ReGaHSS - the "Interfaces:" line and the counters leave it out
1582
+ this.enabledIfaces = this.enabledIfaces.filter((iface) => iface !== 'ReGaHSS');
1571
1583
 
1572
1584
  this.logger.info(
1573
1585
  'openccu-lite detected (' +
@@ -1577,12 +1589,12 @@ module.exports = function (RED) {
1577
1589
  ') - names, rooms and functions come from its metadata api',
1578
1590
  );
1579
1591
  this.logger.info(
1580
- 'this box has no ReGaHSS: system variables, programs and HM-Script are not available (sysvar, program, poll and script nodes stay idle)',
1592
+ 'this system has no ReGaHSS: system variables, programs and HM-Script are not available (sysvar, program, poll and script nodes stay idle)',
1581
1593
  );
1582
1594
 
1583
1595
  if (info.version > 1) {
1584
1596
  this.logger.warn(
1585
- 'the box speaks metadata api version ' +
1597
+ 'the system speaks metadata api version ' +
1586
1598
  info.version +
1587
1599
  ', this version of node-red-contrib-ccu implements version 1',
1588
1600
  );
@@ -1590,7 +1602,7 @@ module.exports = function (RED) {
1590
1602
 
1591
1603
  if (!this.metaToken) {
1592
1604
  this.logger.warn(
1593
- 'no metadata api token: create one on the box (Users page) and enter it in the connection node, or run on the box where ' +
1605
+ 'no metadata api token: create one on the system (Users page) and enter it in the connection node, or run on the system where ' +
1594
1606
  metaProvider.LOCAL_TOKEN_FILE +
1595
1607
  ' is readable. Without it the nodes work with addresses only.',
1596
1608
  );
@@ -1631,10 +1643,13 @@ module.exports = function (RED) {
1631
1643
  delete this.ifaceStatus[META_IFACE];
1632
1644
  if (this.ifaceTypes.ReGaHSS.enabled) {
1633
1645
  this.ifaceStatus.ReGaHSS = null;
1646
+ if (!this.enabledIfaces.includes('ReGaHSS')) {
1647
+ this.enabledIfaces.unshift('ReGaHSS');
1648
+ }
1634
1649
  }
1635
1650
 
1636
1651
  this.pushStatus();
1637
- this.logger.warn('this box no longer answers as openccu-lite - detecting again');
1652
+ this.logger.warn('this system no longer answers as openccu-lite - detecting again');
1638
1653
  this.detectMetaOutcome().then((outcome) => {
1639
1654
  if (outcome.info) {
1640
1655
  return this.startMeta(outcome.info);
@@ -1717,7 +1732,7 @@ module.exports = function (RED) {
1717
1732
  * @returns {Error}
1718
1733
  */
1719
1734
  regaMissingError(feature) {
1720
- return new Error(feature + ' are not available on this box (openccu-lite has no ReGaHSS)');
1735
+ return new Error(feature + ' are not available on this system (openccu-lite has no ReGaHSS)');
1721
1736
  }
1722
1737
 
1723
1738
  /**
@@ -3456,9 +3471,12 @@ module.exports = function (RED) {
3456
3471
  }
3457
3472
 
3458
3473
  /**
3459
- *
3474
+ * Takes a device description into the device table.
3460
3475
  * @param iface
3461
3476
  * @param device
3477
+ * @returns {boolean} whether the table changed: the device is new, or its data changed
3478
+ * (a description equal to the cached one, as the lite-rpc stream's snapshot sends at
3479
+ * every connect, changes nothing - task 24)
3462
3480
  */
3463
3481
  newDevice(iface, device) {
3464
3482
  if (!this.metadata.devices[iface]) {
@@ -3497,7 +3515,8 @@ module.exports = function (RED) {
3497
3515
  this.logger.debug('newDevice', iface, device.ADDRESS);
3498
3516
  }
3499
3517
 
3500
- if (!known || deviceChanged(known, device)) {
3518
+ const changed = !known || deviceChanged(known, device);
3519
+ if (changed) {
3501
3520
  // B-36: a device added anew (re-paired, or its data changed) is asked again
3502
3521
  this.forgetParamsetFailures(iface, device.ADDRESS);
3503
3522
  }
@@ -3522,6 +3541,7 @@ module.exports = function (RED) {
3522
3541
  }
3523
3542
 
3524
3543
  this.paramsetQueuePush(iface, device);
3544
+ return changed;
3525
3545
  }
3526
3546
 
3527
3547
  /**
@@ -3860,8 +3880,9 @@ module.exports = function (RED) {
3860
3880
  if (iface && Array.isArray(devices)) {
3861
3881
  devices.forEach((device) => {
3862
3882
  if (device && typeof device.ADDRESS === 'string' && device.TYPE) {
3863
- this.newDevice(iface, device);
3864
- changed = true;
3883
+ if (this.newDevice(iface, device)) {
3884
+ changed = true;
3885
+ }
3865
3886
  } else {
3866
3887
  this.logger.warn(
3867
3888
  'newDevices',
@@ -0,0 +1,105 @@
1
+ /*
2
+ * The connection's cache files (metadata, regadata, values) on disk.
3
+ *
4
+ * Written atomically: the JSON goes to a temporary file beside the target and is renamed over it,
5
+ * so a crash or a power cut while writing leaves the old file, never a truncated one (the files
6
+ * are read with JSON.parse at the next start, and a half-written one would throw the cache away).
7
+ *
8
+ * A burst of changes is written once: `DebouncedSave` collects the requests of a short window into
9
+ * one write - at start every interface reports its device list within the same second, and before
10
+ * there was one full rewrite and one log line per interface.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ /**
17
+ * Writes `data` as JSON to `file` by way of a temporary file and a rename; an existing file's mode
18
+ * is kept.
19
+ * @param {string} file
20
+ * @param {*} data
21
+ * @param {string|number} [indent] JSON.stringify's third argument
22
+ */
23
+ function writeJsonAtomic(file, data, indent) {
24
+ const temporary = path.join(
25
+ path.dirname(file),
26
+ '.' + path.basename(file) + '.' + process.pid + '.' + Date.now().toString(36) + '.tmp',
27
+ );
28
+ try {
29
+ fs.writeFileSync(temporary, JSON.stringify(data, null, indent));
30
+ // the rename replaces the inode: an existing file's mode is kept (0600 on a system that
31
+ // made it so), a new one gets the umask's
32
+ let mode;
33
+ try {
34
+ mode = fs.statSync(file).mode & 0o777;
35
+ } catch {}
36
+
37
+ if (mode !== undefined) {
38
+ fs.chmodSync(temporary, mode);
39
+ }
40
+
41
+ fs.renameSync(temporary, file);
42
+ } catch (error) {
43
+ try {
44
+ fs.unlinkSync(temporary);
45
+ } catch {}
46
+
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ class DebouncedSave {
52
+ /**
53
+ * @param {Function} write does the write; called once per burst
54
+ * @param {number} [delay] the window, ms (default 1000)
55
+ */
56
+ constructor(write, delay = 1000) {
57
+ this.write = write;
58
+ this.delay = delay;
59
+ this.timer = null;
60
+ this.pending = false;
61
+ }
62
+
63
+ /** asks for a write; one write happens `delay` ms after the first request of a burst */
64
+ request() {
65
+ this.pending = true;
66
+ if (this.timer) {
67
+ return;
68
+ }
69
+
70
+ this.timer = setTimeout(() => {
71
+ this.timer = null;
72
+ this.flush();
73
+ }, this.delay);
74
+ if (typeof this.timer.unref === 'function') {
75
+ this.timer.unref();
76
+ }
77
+ }
78
+
79
+ /** writes now when a request is pending (close, redeploy) */
80
+ flush() {
81
+ if (this.timer) {
82
+ clearTimeout(this.timer);
83
+ this.timer = null;
84
+ }
85
+
86
+ if (!this.pending) {
87
+ return;
88
+ }
89
+
90
+ this.pending = false;
91
+ this.write();
92
+ }
93
+
94
+ /** drops a pending request without writing */
95
+ cancel() {
96
+ if (this.timer) {
97
+ clearTimeout(this.timer);
98
+ this.timer = null;
99
+ }
100
+
101
+ this.pending = false;
102
+ }
103
+ }
104
+
105
+ module.exports = {writeJsonAtomic, DebouncedSave};
package/nodes/lib/lite.js CHANGED
@@ -80,7 +80,26 @@ function readAddonToken(file = module.exports.ADDON_TOKEN_FILE) {
80
80
  * @returns {boolean}
81
81
  */
82
82
  function isFault(error) {
83
- return Boolean(error) && error.kind === 'rpc-fault' && error.faultCode !== undefined;
83
+ return kindOf(error) === 'rpc-fault' && error.faultCode !== undefined;
84
+ }
85
+
86
+ /**
87
+ * The client's error kind: occulite-client's OccuLiteError carries it as `code` (rpc-fault,
88
+ * forbidden, timeout ...); `kind` is ours, on an error translated before. A Node system error's
89
+ * `code` (ECONNREFUSED ...) is not a kind.
90
+ * @param {*} error
91
+ * @returns {string|undefined}
92
+ */
93
+ function kindOf(error) {
94
+ if (!error) {
95
+ return undefined;
96
+ }
97
+
98
+ if (error.kind) {
99
+ return error.kind;
100
+ }
101
+
102
+ return error.name === 'OccuLiteError' && typeof error.code === 'string' ? error.code : undefined;
84
103
  }
85
104
 
86
105
  /**
@@ -97,7 +116,8 @@ function translateError(error) {
97
116
  return fault;
98
117
  }
99
118
 
100
- if (error && error.kind === 'forbidden') {
119
+ const kind = kindOf(error);
120
+ if (kind === 'forbidden') {
101
121
  const refused = new Error(
102
122
  'the openccu-lite token may not call this method' + (error.scope ? ' (needs ' + error.scope + ')' : ''),
103
123
  );
@@ -106,15 +126,47 @@ function translateError(error) {
106
126
  return refused;
107
127
  }
108
128
 
109
- if (error && error.kind) {
110
- const translated = new Error(error.message || error.kind);
111
- translated.kind = error.kind;
129
+ if (kind) {
130
+ const translated = new Error(error.message || kind);
131
+ translated.kind = kind;
112
132
  return translated;
113
133
  }
114
134
 
115
135
  return error instanceof Error ? error : new Error(String(error));
116
136
  }
117
137
 
138
+ /**
139
+ * B-41: cast.js marks a FLOAT as homematic-xmlrpc's `{explicitDouble: n}` (the XML-RPC client's
140
+ * `<double>`). lite-rpc's JSON path does not know that marker and would hand the process a struct
141
+ * where a double belongs - hs485d and hmipserver refuse it. Its own marker is `{double: n}`
142
+ * (capability `json_double`); a system without it gets the plain number. Walks the whole
143
+ * parameter list: the top level, structs and paramsets, a multicall's calls.
144
+ * @param {*} value
145
+ * @param {boolean} jsonDouble whether the system announces `json_double`
146
+ * @returns {*} the value for lite-rpc
147
+ */
148
+ function liteValue(value, jsonDouble) {
149
+ if (Array.isArray(value)) {
150
+ return value.map((item) => liteValue(item, jsonDouble));
151
+ }
152
+
153
+ if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
154
+ const keys = Object.keys(value);
155
+ if (keys.length === 1 && keys[0] === 'explicitDouble') {
156
+ const number = Number(value.explicitDouble);
157
+ return jsonDouble ? {double: number} : number;
158
+ }
159
+
160
+ const out = {};
161
+ keys.forEach((key) => {
162
+ out[key] = liteValue(value[key], jsonDouble);
163
+ });
164
+ return out;
165
+ }
166
+
167
+ return value;
168
+ }
169
+
118
170
  const RPC_TIERS = ['rpc:operate', 'rpc:configure', 'rpc:admin'];
119
171
 
120
172
  /**
@@ -263,7 +315,8 @@ class LiteConnection {
263
315
  return Promise.reject(new Error('lite-rpc client not started'));
264
316
  }
265
317
 
266
- return this.box.call(iface, method, parameters || []).then(
318
+ const jsonDouble = Boolean(this.box.capabilities && this.box.capabilities.json_double);
319
+ return this.box.call(iface, method, liteValue(parameters || [], jsonDouble)).then(
267
320
  (result) => (result === null || result === undefined ? '' : result),
268
321
  (error) => {
269
322
  throw translateError(error);
@@ -302,6 +355,7 @@ module.exports = {
302
355
  ADDON_TOKEN_FILE,
303
356
  CALLBACK_METHODS,
304
357
  readAddonToken,
358
+ liteValue,
305
359
  tierAwareTransport,
306
360
  translateError,
307
361
  isFault,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ccu",
3
- "version": "4.7.0",
3
+ "version": "4.7.2",
4
4
  "license": "MIT",
5
5
  "main": "none",
6
6
  "keywords": [