node-red-contrib-ccu 4.5.0 → 4.5.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/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ 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.5.2 (2026-10-02)
9
+
10
+ ### Fixed
11
+
12
+ - **No HmIP-RF events for up to 10 minutes after hmipserver restarted**
13
+ (B-37). hmipserver keeps its subscribers over a restart: it calls
14
+ `listDevices` and `newDevices` on them and answers their pings, but
15
+ delivers no event to such a kept subscription until it is subscribed
16
+ afresh. The liveness ping saw its PONG and was content, so the
17
+ subscription came back only with the 600 s silence timeout. A
18
+ `listDevices` or `newDevices` call that does not follow the connection's
19
+ own `init` now makes it subscribe again at once, and events are back
20
+ within seconds. A device paired while Node-RED runs costs one such `init`
21
+ as well.
22
+
23
+ ## 4.5.1 (2026-10-02)
24
+
25
+ ### Fixed
26
+
27
+ - **A burst of `getParamsetDescription Error: XML-RPC fault: Invalid device`
28
+ at the first start after an update** (B-36). A device deleted from the CCU
29
+ while Node-RED was not running stayed in the cached device table
30
+ (`ccu_<host>.json`) and in the values cache (`ccu_values_<host>.json`).
31
+ At the start its paramset descriptions were queued from the cached table;
32
+ hmipserver answered `-2 Invalid device`, a failed description was never
33
+ remembered, so every lookup queued it again (one item per channel and
34
+ paramset, duplicates included), each fault was logged twice without the
35
+ address, and each one threw away and rebuilt the interface's RPC client.
36
+ Seen on a CCU3 after a remote had been replaced: 150 failed calls in 40 s.
37
+ Now a description the interface refuses with a fault is asked for once
38
+ per address and paramset in a run, with one warning that names the
39
+ address, the paramset and the fault (a device paired anew is asked
40
+ again); the queue holds each description key once; `deleteDevices` drops
41
+ the device's queued fetches and its address from the type lists, and the
42
+ values cache is pruned against the device table at the start and a few
43
+ seconds after a `deleteDevices` or the ReGa's value read (the ReGa keeps
44
+ reporting the datapoints of a deleted device). An XML-RPC fault no longer
45
+ closes and recreates the RPC client: only a transport error does; the
46
+ fault is logged once, with the arguments of the call.
47
+
8
48
  ## 4.5.0 (2026-09-29)
9
49
 
10
50
  ### Added
@@ -23,6 +23,11 @@ const binrpc = require('binrpc');
23
23
 
24
24
  const pkg = require(path.join(__dirname, '..', 'package.json'));
25
25
 
26
+ // B-37: how long after our init the process's listDevices/newDevices still belong to it -
27
+ // hmipserver answers the init at once and calls listDevices and newDevices afterwards. Such a
28
+ // call later than this is the process calling on its own: it restarted and kept the subscription.
29
+ const INIT_GRACE_MS = 30000;
30
+
26
31
  /**
27
32
  * check if an object is iterable
28
33
  * @link https://stackoverflow.com/a/37837872
@@ -630,6 +635,10 @@ module.exports = function (RED) {
630
635
  // and which of them are waiting for an unreachable process
631
636
  this.initRetry = {};
632
637
  this.ifaceWaiting = {};
638
+ // B-37: per interface {inFlight, at, reinit} of our own init, and how long after it the
639
+ // process's listDevices/newDevices still answer it (see keptSubscription())
640
+ this.initState = {};
641
+ this.initGrace = INIT_GRACE_MS;
633
642
  this.closing = false;
634
643
  this.serverError = {};
635
644
  this.queueTimeout = Number.parseInt(config.queueTimeout, 10) || 5000;
@@ -662,6 +671,13 @@ module.exports = function (RED) {
662
671
 
663
672
  this.newParamsetDescriptionCount = 0;
664
673
  this.paramsetQueue = [];
674
+ // B-36: iface/address/paramset whose description the interface refused
675
+ // with a fault (-2 Invalid device for an address it does not know) - not
676
+ // asked again in this run, so a stale cache entry costs one call, not one
677
+ // per lookup
678
+ this.paramsetFailed = new Set();
679
+ this.valuesPruneTimer = null;
680
+ this.valuesPruneDelay = 5000;
665
681
  this.paramsQueue = [];
666
682
 
667
683
  this.paramsetFile = path.join(RED.settings.userDir || path.join(__dirname, '..'), 'paramsets.json');
@@ -726,6 +742,7 @@ module.exports = function (RED) {
726
742
  this.loadMetadata();
727
743
  this.loadRegadata();
728
744
  this.loadValues();
745
+ this.pruneCaches();
729
746
 
730
747
  this.setContext();
731
748
 
@@ -1264,6 +1281,7 @@ module.exports = function (RED) {
1264
1281
  clearTimeout(this.rpcPingTimer[iface]);
1265
1282
  });
1266
1283
  Object.keys(this.liveness).forEach((iface) => this.stopLiveness(iface));
1284
+ clearTimeout(this.valuesPruneTimer);
1267
1285
 
1268
1286
  this.saveRegadata();
1269
1287
  this.saveValues();
@@ -1642,6 +1660,8 @@ module.exports = function (RED) {
1642
1660
  });
1643
1661
  this.cachedValuesReceived = true;
1644
1662
  this.saveValues();
1663
+ // B-36: ReGa keeps the datapoints of a deleted device
1664
+ this.scheduleValuesPrune();
1645
1665
  resolve();
1646
1666
  }
1647
1667
  });
@@ -2334,9 +2354,12 @@ module.exports = function (RED) {
2334
2354
  this.createClient(iface, {quiet: true});
2335
2355
  }
2336
2356
 
2357
+ // B-37: listDevices/newDevices while this init runs or shortly after belong to it
2358
+ this.initState[iface] = {inFlight: true, at: 0, reinit: false};
2337
2359
  // a failed init is logged by the retry (InitRetry), not here
2338
2360
  this.methodCall(iface, 'init', [initUrl, initId], {quiet: true})
2339
2361
  .then(() => {
2362
+ this.initState[iface] = {inFlight: false, at: now(), reinit: false};
2340
2363
  // the ping/re-init liveness starts after a successful init only;
2341
2364
  // a failed one is retried by rpcInitFailed (task 11)
2342
2365
  if (this.ifaceTypes[iface].ping && !this.closing) {
@@ -2359,10 +2382,57 @@ module.exports = function (RED) {
2359
2382
  resolve(iface);
2360
2383
  }
2361
2384
  })
2362
- .catch((error) => reject(error));
2385
+ .catch((error) => {
2386
+ this.initState[iface] = {inFlight: false, at: 0, reinit: false};
2387
+ reject(error);
2388
+ });
2363
2389
  });
2364
2390
  }
2365
2391
 
2392
+ /**
2393
+ * B-37: the interface process called listDevices or newDevices. Outside our own init it
2394
+ * restarted and kept the subscription: hmipserver restores its subscribers from its
2395
+ * handlers file, calls listDevices and newDevices on them and answers their pings (the
2396
+ * PONG arrives, so neither the liveness ping nor the 600 s watchdog fires), but delivers
2397
+ * no event to such a kept subscription until a fresh init (openccu-lite B-286, measured).
2398
+ * So init again at once. A device paired while Node-RED runs costs one init the same way.
2399
+ * @param iface
2400
+ * @param {string} method
2401
+ * @returns {boolean} whether a fresh init was started
2402
+ */
2403
+ keptSubscription(iface, method) {
2404
+ const type = this.ifaceTypes[iface];
2405
+ const state = this.initState[iface];
2406
+ if (this.closing || !type || !type.enabled || !type.init) {
2407
+ return false;
2408
+ }
2409
+
2410
+ // never subscribed, an init running or already started for this restart
2411
+ if (!state || state.inFlight || state.reinit || !state.at) {
2412
+ return false;
2413
+ }
2414
+
2415
+ if (this.initRetry[iface] && this.initRetry[iface].pending) {
2416
+ return false;
2417
+ }
2418
+
2419
+ if (now() - state.at < this.initGrace) {
2420
+ return false;
2421
+ }
2422
+
2423
+ state.reinit = true;
2424
+ this.logger.info(
2425
+ iface +
2426
+ ': ' +
2427
+ method +
2428
+ ' outside our init - the interface process restarted and kept the subscription, subscribing afresh',
2429
+ );
2430
+ this.rpcInit(iface)
2431
+ .then(() => this.rpcInitSucceeded(iface))
2432
+ .catch((error) => this.rpcInitFailed(iface, error));
2433
+ return true;
2434
+ }
2435
+
2366
2436
  /**
2367
2437
  * Returns the links of a specific channel
2368
2438
  * @param {String} iface
@@ -2427,6 +2497,10 @@ module.exports = function (RED) {
2427
2497
  this.saveMetadata();
2428
2498
  }
2429
2499
 
2500
+ if (summary.removed) {
2501
+ this.scheduleValuesPrune();
2502
+ }
2503
+
2430
2504
  resolve(summary);
2431
2505
  })
2432
2506
  .catch(reject);
@@ -2896,7 +2970,13 @@ module.exports = function (RED) {
2896
2970
  if (device && device.PARAMSETS) {
2897
2971
  device.PARAMSETS.forEach((paramset) => {
2898
2972
  const name = this.paramsetName(iface, device, paramset);
2899
- if (!this.paramsetDescriptions[name]) {
2973
+ if (
2974
+ !this.paramsetDescriptions[name] &&
2975
+ // B-36: once per description key in the queue, and never again
2976
+ // for an address/paramset the interface refused in this run
2977
+ !this.paramsetFailed.has(iface + '/' + device.ADDRESS + '/' + paramset) &&
2978
+ !(name && this.paramsetQueue.some((item) => item.name === name))
2979
+ ) {
2900
2980
  this.paramsetQueue.push({
2901
2981
  iface,
2902
2982
  name,
@@ -2932,7 +3012,7 @@ module.exports = function (RED) {
2932
3012
  clearTimeout(this.getParamsetTimeout);
2933
3013
  setImmediate(() => this.paramsetQueueShift());
2934
3014
  } else {
2935
- this.methodCall(iface, 'getParamsetDescription', [address, paramset])
3015
+ this.methodCall(iface, 'getParamsetDescription', [address, paramset], {quiet: true})
2936
3016
  .then((res) => {
2937
3017
  //this.logger.trace('paramsetDescription', name);
2938
3018
  this.newParamsetDescriptionCount += 1;
@@ -2944,7 +3024,31 @@ module.exports = function (RED) {
2944
3024
  this.saveParamsets();
2945
3025
  }
2946
3026
  })
2947
- .catch((error) => this.logger.error(error))
3027
+ .catch((error) => {
3028
+ // B-36: one line naming the address and the paramset; a
3029
+ // fault is remembered, a transport error may be retried
3030
+ if (error && error.faultCode !== undefined) {
3031
+ this.paramsetFailed.add(iface + '/' + address + '/' + paramset);
3032
+ this.logger.warn(
3033
+ 'getParamsetDescription',
3034
+ iface,
3035
+ address,
3036
+ paramset,
3037
+ 'fault',
3038
+ error.faultCode,
3039
+ error.faultString || error.message,
3040
+ '- not asked again until the device is added anew',
3041
+ );
3042
+ } else {
3043
+ this.logger.error(
3044
+ 'getParamsetDescription',
3045
+ iface,
3046
+ address,
3047
+ paramset,
3048
+ error && error.message ? error.message : error,
3049
+ );
3050
+ }
3051
+ })
2948
3052
  .then(() => {
2949
3053
  this.paramsetPending = false;
2950
3054
  clearTimeout(this.getParamsetTimeout);
@@ -3029,6 +3133,11 @@ module.exports = function (RED) {
3029
3133
  this.logger.debug('newDevice', iface, device.ADDRESS);
3030
3134
  }
3031
3135
 
3136
+ if (!known || deviceChanged(known, device)) {
3137
+ // B-36: a device added anew (re-paired, or its data changed) is asked again
3138
+ this.forgetParamsetFailures(iface, device.ADDRESS);
3139
+ }
3140
+
3032
3141
  this.metadata.devices[iface][device.ADDRESS] = device;
3033
3142
 
3034
3143
  if (!device.TYPE) {
@@ -3036,13 +3145,12 @@ module.exports = function (RED) {
3036
3145
  throw new Error('device type undefined: ' + JSON.stringify(device));
3037
3146
  }
3038
3147
 
3039
- if (
3040
- this.metadata.types[iface][device.TYPE] &&
3041
- !this.metadata.types[iface][device.TYPE].includes(device.ADDRESS)
3042
- ) {
3043
- this.metadata.types[iface][device.TYPE].push(device.ADDRESS);
3044
- } else {
3148
+ if (!this.metadata.types[iface][device.TYPE]) {
3045
3149
  this.metadata.types[iface][device.TYPE] = [device.ADDRESS];
3150
+ } else if (!this.metadata.types[iface][device.TYPE].includes(device.ADDRESS)) {
3151
+ // an address already listed stays listed with the others (the old
3152
+ // else branch replaced the whole list with it)
3153
+ this.metadata.types[iface][device.TYPE].push(device.ADDRESS);
3046
3154
  }
3047
3155
 
3048
3156
  if (device.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER') {
@@ -3059,7 +3167,116 @@ module.exports = function (RED) {
3059
3167
  */
3060
3168
  deleteDevice(iface, device) {
3061
3169
  this.logger.debug('deleteDevice', iface, device);
3062
- delete this.metadata.devices[iface][device];
3170
+ const known = this.metadata.devices[iface] && this.metadata.devices[iface][device];
3171
+ if (this.metadata.devices[iface]) {
3172
+ delete this.metadata.devices[iface][device];
3173
+ }
3174
+
3175
+ // B-36: nothing of a deleted device is fetched or listed by type any more -
3176
+ // its queued descriptions (with those of its channels) and its address in
3177
+ // metadata.types go with it. Its cached values go with the next
3178
+ // scheduleValuesPrune(): hmipserver has been seen to answer an init with
3179
+ // deleteDevices and newDevices for every device it has (eq-3/occu#45), so
3180
+ // a value dropped here could belong to a device that is back a moment later.
3181
+ const ofDevice = (address) => address === device || address.startsWith(device + ':');
3182
+ this.paramsetQueue = this.paramsetQueue.filter((item) => item.iface !== iface || !ofDevice(item.address));
3183
+ this.paramsQueue = this.paramsQueue.filter((item) => item.iface !== iface || !ofDevice(item.address));
3184
+
3185
+ const types = this.metadata.types && this.metadata.types[iface];
3186
+ if (types) {
3187
+ const typeNames = known && known.TYPE ? [known.TYPE] : Object.keys(types);
3188
+ typeNames.forEach((type) => {
3189
+ if (Array.isArray(types[type])) {
3190
+ types[type] = types[type].filter((addr) => addr !== device);
3191
+ if (types[type].length === 0) {
3192
+ delete types[type];
3193
+ }
3194
+ }
3195
+ });
3196
+ }
3197
+ }
3198
+
3199
+ /**
3200
+ * B-36: the device table decides which cached values and type lists are
3201
+ * kept - at the start the cache files may still hold a device that was
3202
+ * deleted while Node-RED was not running.
3203
+ */
3204
+ pruneCaches() {
3205
+ const types = (this.metadata && this.metadata.types) || {};
3206
+ Object.keys(types).forEach((iface) => {
3207
+ const devices = this.metadata.devices && this.metadata.devices[iface];
3208
+ if (!devices) {
3209
+ return;
3210
+ }
3211
+
3212
+ Object.keys(types[iface]).forEach((type) => {
3213
+ if (!Array.isArray(types[iface][type])) {
3214
+ return;
3215
+ }
3216
+
3217
+ types[iface][type] = types[iface][type].filter((addr) => devices[addr]);
3218
+ if (types[iface][type].length === 0) {
3219
+ delete types[iface][type];
3220
+ }
3221
+ });
3222
+ });
3223
+
3224
+ const pruned = this.pruneValues();
3225
+ if (pruned) {
3226
+ this.logger.info('values cache: dropped', pruned, 'values of devices not in the device table');
3227
+ this.saveValues();
3228
+ }
3229
+ }
3230
+
3231
+ /**
3232
+ * B-36: prune the values cache against the device table a few seconds after
3233
+ * the last deleteDevices or ReGa getValues - once hmipserver's
3234
+ * deleteDevices/newDevices pair of an init has settled, and after ReGa
3235
+ * reported the datapoints it keeps of a deleted device again.
3236
+ */
3237
+ scheduleValuesPrune() {
3238
+ clearTimeout(this.valuesPruneTimer);
3239
+ this.valuesPruneTimer = setTimeout(() => {
3240
+ this.valuesPruneTimer = null;
3241
+ const pruned = this.pruneValues();
3242
+ if (pruned) {
3243
+ this.logger.info('values cache: dropped', pruned, 'values of devices not in the device table');
3244
+ this.saveValues();
3245
+ }
3246
+ }, this.valuesPruneDelay);
3247
+ }
3248
+
3249
+ /**
3250
+ * B-36: drop cached values whose channel is not in its interface's device
3251
+ * table. An interface without a table (not read yet) keeps its values.
3252
+ * @returns {number} how many were dropped
3253
+ */
3254
+ pruneValues() {
3255
+ let pruned = 0;
3256
+ const tables = (this.metadata && this.metadata.devices) || {};
3257
+ Object.keys(this.values).forEach((datapointName) => {
3258
+ const [iface, channel] = datapointName.split('.');
3259
+ const devices = tables[iface];
3260
+ if (channel && devices && Object.keys(devices).length > 0 && !devices[channel]) {
3261
+ delete this.values[datapointName];
3262
+ pruned += 1;
3263
+ }
3264
+ });
3265
+ return pruned;
3266
+ }
3267
+
3268
+ /**
3269
+ * B-36: a device added anew is asked for its descriptions again.
3270
+ * @param iface
3271
+ * @param address device or channel address
3272
+ */
3273
+ forgetParamsetFailures(iface, address) {
3274
+ const prefix = iface + '/' + address;
3275
+ for (const key of this.paramsetFailed) {
3276
+ if (key.startsWith(prefix + '/') || key.startsWith(prefix + ':')) {
3277
+ this.paramsetFailed.delete(key);
3278
+ }
3279
+ }
3063
3280
  }
3064
3281
 
3065
3282
  /**
@@ -3299,7 +3516,8 @@ module.exports = function (RED) {
3299
3516
  this.saveMetadata();
3300
3517
  }
3301
3518
 
3302
- if (iface) {
3519
+ // B-37: a restart that kept the subscription gets a fresh init, no ping
3520
+ if (iface && !this.keptSubscription(iface, 'newDevices')) {
3303
3521
  this.deviceCallback(iface);
3304
3522
  }
3305
3523
  },
@@ -3322,6 +3540,7 @@ module.exports = function (RED) {
3322
3540
 
3323
3541
  if (changed) {
3324
3542
  this.saveMetadata();
3543
+ this.scheduleValuesPrune();
3325
3544
  }
3326
3545
 
3327
3546
  if (iface) {
@@ -3339,6 +3558,9 @@ module.exports = function (RED) {
3339
3558
 
3340
3559
  this.logger.debug(' >', iface, 'listDevices', JSON.stringify(res));
3341
3560
  callback(null, res);
3561
+ if (iface) {
3562
+ this.keptSubscription(iface, 'listDevices');
3563
+ }
3342
3564
  },
3343
3565
  event: (_, parameters, callback) => {
3344
3566
  const iface = this.rpcIface('event', parameters);
@@ -3907,7 +4129,25 @@ module.exports = function (RED) {
3907
4129
  if (this.clients[iface]) {
3908
4130
  this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
3909
4131
  this.clients[iface].methodCall(method, parameters, (err, res) => {
3910
- if (err) {
4132
+ if (err && err.faultCode !== undefined) {
4133
+ // B-36: homematic-xmlrpc delivers a fault as an Error with
4134
+ // faultCode/faultString. The process answered - the client is
4135
+ // fine and stays; one line, with the arguments that were refused.
4136
+ this.logger[quiet ? 'debug' : 'error'](
4137
+ ' <',
4138
+ iface,
4139
+ method,
4140
+ JSON.stringify(parameters),
4141
+ 'fault',
4142
+ err.faultCode,
4143
+ err.faultString,
4144
+ );
4145
+ if (err.faultString === undefined) {
4146
+ err.faultString = err.message;
4147
+ }
4148
+
4149
+ reject(err);
4150
+ } else if (err) {
3911
4151
  this.logger[quiet ? 'debug' : 'error'](' <', iface, method, err);
3912
4152
  this.closeClient(iface);
3913
4153
  // a closed node creates no new client (it would reconnect forever)
@@ -3917,7 +4157,15 @@ module.exports = function (RED) {
3917
4157
 
3918
4158
  reject(err);
3919
4159
  } else if (res && res.faultCode) {
3920
- this.logger[quiet ? 'debug' : 'error'](' <', iface, method, JSON.stringify(res));
4160
+ this.logger[quiet ? 'debug' : 'error'](
4161
+ ' <',
4162
+ iface,
4163
+ method,
4164
+ JSON.stringify(parameters),
4165
+ 'fault',
4166
+ res.faultCode,
4167
+ res.faultString,
4168
+ );
3921
4169
  const fault = new Error(res.faultString);
3922
4170
  fault.faultCode = res.faultCode;
3923
4171
  fault.faultString = res.faultString;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-ccu",
3
- "version": "4.5.0",
3
+ "version": "4.5.2",
4
4
  "license": "MIT",
5
5
  "main": "none",
6
6
  "keywords": [
@@ -64,7 +64,7 @@
64
64
  "eslint-config-prettier": "^10.1.8",
65
65
  "eslint-plugin-html": "^8.2.0",
66
66
  "globals": "^16.5.0",
67
- "hm-simulator": "^0.1.1",
67
+ "hm-simulator": "^1.3.1",
68
68
  "mocha": "^11.7.1",
69
69
  "node-red": "^4.1.0",
70
70
  "node-red-node-test-helper": "^0.3.6",
@@ -72,12 +72,6 @@
72
72
  "prettier": "^3.9.6",
73
73
  "should": "^13.2.3"
74
74
  },
75
- "overrides": {
76
- "hm-simulator": {
77
- "homematic-xmlrpc": "^2.0.0",
78
- "binrpc": "^4.3.0"
79
- }
80
- },
81
75
  "engines": {
82
76
  "node": "^20.19 || ^22.12 || >=24"
83
77
  },