node-red-contrib-ccu 4.5.0 → 4.5.1

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,31 @@ 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.1 (2026-10-02)
9
+
10
+ ### Fixed
11
+
12
+ - **A burst of `getParamsetDescription Error: XML-RPC fault: Invalid device`
13
+ at the first start after an update** (B-36). A device deleted from the CCU
14
+ while Node-RED was not running stayed in the cached device table
15
+ (`ccu_<host>.json`) and in the values cache (`ccu_values_<host>.json`).
16
+ At the start its paramset descriptions were queued from the cached table;
17
+ hmipserver answered `-2 Invalid device`, a failed description was never
18
+ remembered, so every lookup queued it again (one item per channel and
19
+ paramset, duplicates included), each fault was logged twice without the
20
+ address, and each one threw away and rebuilt the interface's RPC client.
21
+ Seen on a CCU3 after a remote had been replaced: 150 failed calls in 40 s.
22
+ Now a description the interface refuses with a fault is asked for once
23
+ per address and paramset in a run, with one warning that names the
24
+ address, the paramset and the fault (a device paired anew is asked
25
+ again); the queue holds each description key once; `deleteDevices` drops
26
+ the device's queued fetches and its address from the type lists, and the
27
+ values cache is pruned against the device table at the start and a few
28
+ seconds after a `deleteDevices` or the ReGa's value read (the ReGa keeps
29
+ reporting the datapoints of a deleted device). An XML-RPC fault no longer
30
+ closes and recreates the RPC client: only a transport error does; the
31
+ fault is logged once, with the arguments of the call.
32
+
8
33
  ## 4.5.0 (2026-09-29)
9
34
 
10
35
  ### Added
@@ -662,6 +662,13 @@ module.exports = function (RED) {
662
662
 
663
663
  this.newParamsetDescriptionCount = 0;
664
664
  this.paramsetQueue = [];
665
+ // B-36: iface/address/paramset whose description the interface refused
666
+ // with a fault (-2 Invalid device for an address it does not know) - not
667
+ // asked again in this run, so a stale cache entry costs one call, not one
668
+ // per lookup
669
+ this.paramsetFailed = new Set();
670
+ this.valuesPruneTimer = null;
671
+ this.valuesPruneDelay = 5000;
665
672
  this.paramsQueue = [];
666
673
 
667
674
  this.paramsetFile = path.join(RED.settings.userDir || path.join(__dirname, '..'), 'paramsets.json');
@@ -726,6 +733,7 @@ module.exports = function (RED) {
726
733
  this.loadMetadata();
727
734
  this.loadRegadata();
728
735
  this.loadValues();
736
+ this.pruneCaches();
729
737
 
730
738
  this.setContext();
731
739
 
@@ -1264,6 +1272,7 @@ module.exports = function (RED) {
1264
1272
  clearTimeout(this.rpcPingTimer[iface]);
1265
1273
  });
1266
1274
  Object.keys(this.liveness).forEach((iface) => this.stopLiveness(iface));
1275
+ clearTimeout(this.valuesPruneTimer);
1267
1276
 
1268
1277
  this.saveRegadata();
1269
1278
  this.saveValues();
@@ -1642,6 +1651,8 @@ module.exports = function (RED) {
1642
1651
  });
1643
1652
  this.cachedValuesReceived = true;
1644
1653
  this.saveValues();
1654
+ // B-36: ReGa keeps the datapoints of a deleted device
1655
+ this.scheduleValuesPrune();
1645
1656
  resolve();
1646
1657
  }
1647
1658
  });
@@ -2427,6 +2438,10 @@ module.exports = function (RED) {
2427
2438
  this.saveMetadata();
2428
2439
  }
2429
2440
 
2441
+ if (summary.removed) {
2442
+ this.scheduleValuesPrune();
2443
+ }
2444
+
2430
2445
  resolve(summary);
2431
2446
  })
2432
2447
  .catch(reject);
@@ -2896,7 +2911,13 @@ module.exports = function (RED) {
2896
2911
  if (device && device.PARAMSETS) {
2897
2912
  device.PARAMSETS.forEach((paramset) => {
2898
2913
  const name = this.paramsetName(iface, device, paramset);
2899
- if (!this.paramsetDescriptions[name]) {
2914
+ if (
2915
+ !this.paramsetDescriptions[name] &&
2916
+ // B-36: once per description key in the queue, and never again
2917
+ // for an address/paramset the interface refused in this run
2918
+ !this.paramsetFailed.has(iface + '/' + device.ADDRESS + '/' + paramset) &&
2919
+ !(name && this.paramsetQueue.some((item) => item.name === name))
2920
+ ) {
2900
2921
  this.paramsetQueue.push({
2901
2922
  iface,
2902
2923
  name,
@@ -2932,7 +2953,7 @@ module.exports = function (RED) {
2932
2953
  clearTimeout(this.getParamsetTimeout);
2933
2954
  setImmediate(() => this.paramsetQueueShift());
2934
2955
  } else {
2935
- this.methodCall(iface, 'getParamsetDescription', [address, paramset])
2956
+ this.methodCall(iface, 'getParamsetDescription', [address, paramset], {quiet: true})
2936
2957
  .then((res) => {
2937
2958
  //this.logger.trace('paramsetDescription', name);
2938
2959
  this.newParamsetDescriptionCount += 1;
@@ -2944,7 +2965,31 @@ module.exports = function (RED) {
2944
2965
  this.saveParamsets();
2945
2966
  }
2946
2967
  })
2947
- .catch((error) => this.logger.error(error))
2968
+ .catch((error) => {
2969
+ // B-36: one line naming the address and the paramset; a
2970
+ // fault is remembered, a transport error may be retried
2971
+ if (error && error.faultCode !== undefined) {
2972
+ this.paramsetFailed.add(iface + '/' + address + '/' + paramset);
2973
+ this.logger.warn(
2974
+ 'getParamsetDescription',
2975
+ iface,
2976
+ address,
2977
+ paramset,
2978
+ 'fault',
2979
+ error.faultCode,
2980
+ error.faultString || error.message,
2981
+ '- not asked again until the device is added anew',
2982
+ );
2983
+ } else {
2984
+ this.logger.error(
2985
+ 'getParamsetDescription',
2986
+ iface,
2987
+ address,
2988
+ paramset,
2989
+ error && error.message ? error.message : error,
2990
+ );
2991
+ }
2992
+ })
2948
2993
  .then(() => {
2949
2994
  this.paramsetPending = false;
2950
2995
  clearTimeout(this.getParamsetTimeout);
@@ -3029,6 +3074,11 @@ module.exports = function (RED) {
3029
3074
  this.logger.debug('newDevice', iface, device.ADDRESS);
3030
3075
  }
3031
3076
 
3077
+ if (!known || deviceChanged(known, device)) {
3078
+ // B-36: a device added anew (re-paired, or its data changed) is asked again
3079
+ this.forgetParamsetFailures(iface, device.ADDRESS);
3080
+ }
3081
+
3032
3082
  this.metadata.devices[iface][device.ADDRESS] = device;
3033
3083
 
3034
3084
  if (!device.TYPE) {
@@ -3036,13 +3086,12 @@ module.exports = function (RED) {
3036
3086
  throw new Error('device type undefined: ' + JSON.stringify(device));
3037
3087
  }
3038
3088
 
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 {
3089
+ if (!this.metadata.types[iface][device.TYPE]) {
3045
3090
  this.metadata.types[iface][device.TYPE] = [device.ADDRESS];
3091
+ } else if (!this.metadata.types[iface][device.TYPE].includes(device.ADDRESS)) {
3092
+ // an address already listed stays listed with the others (the old
3093
+ // else branch replaced the whole list with it)
3094
+ this.metadata.types[iface][device.TYPE].push(device.ADDRESS);
3046
3095
  }
3047
3096
 
3048
3097
  if (device.TYPE === 'MULTI_MODE_INPUT_TRANSMITTER') {
@@ -3059,7 +3108,116 @@ module.exports = function (RED) {
3059
3108
  */
3060
3109
  deleteDevice(iface, device) {
3061
3110
  this.logger.debug('deleteDevice', iface, device);
3062
- delete this.metadata.devices[iface][device];
3111
+ const known = this.metadata.devices[iface] && this.metadata.devices[iface][device];
3112
+ if (this.metadata.devices[iface]) {
3113
+ delete this.metadata.devices[iface][device];
3114
+ }
3115
+
3116
+ // B-36: nothing of a deleted device is fetched or listed by type any more -
3117
+ // its queued descriptions (with those of its channels) and its address in
3118
+ // metadata.types go with it. Its cached values go with the next
3119
+ // scheduleValuesPrune(): hmipserver has been seen to answer an init with
3120
+ // deleteDevices and newDevices for every device it has (eq-3/occu#45), so
3121
+ // a value dropped here could belong to a device that is back a moment later.
3122
+ const ofDevice = (address) => address === device || address.startsWith(device + ':');
3123
+ this.paramsetQueue = this.paramsetQueue.filter((item) => item.iface !== iface || !ofDevice(item.address));
3124
+ this.paramsQueue = this.paramsQueue.filter((item) => item.iface !== iface || !ofDevice(item.address));
3125
+
3126
+ const types = this.metadata.types && this.metadata.types[iface];
3127
+ if (types) {
3128
+ const typeNames = known && known.TYPE ? [known.TYPE] : Object.keys(types);
3129
+ typeNames.forEach((type) => {
3130
+ if (Array.isArray(types[type])) {
3131
+ types[type] = types[type].filter((addr) => addr !== device);
3132
+ if (types[type].length === 0) {
3133
+ delete types[type];
3134
+ }
3135
+ }
3136
+ });
3137
+ }
3138
+ }
3139
+
3140
+ /**
3141
+ * B-36: the device table decides which cached values and type lists are
3142
+ * kept - at the start the cache files may still hold a device that was
3143
+ * deleted while Node-RED was not running.
3144
+ */
3145
+ pruneCaches() {
3146
+ const types = (this.metadata && this.metadata.types) || {};
3147
+ Object.keys(types).forEach((iface) => {
3148
+ const devices = this.metadata.devices && this.metadata.devices[iface];
3149
+ if (!devices) {
3150
+ return;
3151
+ }
3152
+
3153
+ Object.keys(types[iface]).forEach((type) => {
3154
+ if (!Array.isArray(types[iface][type])) {
3155
+ return;
3156
+ }
3157
+
3158
+ types[iface][type] = types[iface][type].filter((addr) => devices[addr]);
3159
+ if (types[iface][type].length === 0) {
3160
+ delete types[iface][type];
3161
+ }
3162
+ });
3163
+ });
3164
+
3165
+ const pruned = this.pruneValues();
3166
+ if (pruned) {
3167
+ this.logger.info('values cache: dropped', pruned, 'values of devices not in the device table');
3168
+ this.saveValues();
3169
+ }
3170
+ }
3171
+
3172
+ /**
3173
+ * B-36: prune the values cache against the device table a few seconds after
3174
+ * the last deleteDevices or ReGa getValues - once hmipserver's
3175
+ * deleteDevices/newDevices pair of an init has settled, and after ReGa
3176
+ * reported the datapoints it keeps of a deleted device again.
3177
+ */
3178
+ scheduleValuesPrune() {
3179
+ clearTimeout(this.valuesPruneTimer);
3180
+ this.valuesPruneTimer = setTimeout(() => {
3181
+ this.valuesPruneTimer = null;
3182
+ const pruned = this.pruneValues();
3183
+ if (pruned) {
3184
+ this.logger.info('values cache: dropped', pruned, 'values of devices not in the device table');
3185
+ this.saveValues();
3186
+ }
3187
+ }, this.valuesPruneDelay);
3188
+ }
3189
+
3190
+ /**
3191
+ * B-36: drop cached values whose channel is not in its interface's device
3192
+ * table. An interface without a table (not read yet) keeps its values.
3193
+ * @returns {number} how many were dropped
3194
+ */
3195
+ pruneValues() {
3196
+ let pruned = 0;
3197
+ const tables = (this.metadata && this.metadata.devices) || {};
3198
+ Object.keys(this.values).forEach((datapointName) => {
3199
+ const [iface, channel] = datapointName.split('.');
3200
+ const devices = tables[iface];
3201
+ if (channel && devices && Object.keys(devices).length > 0 && !devices[channel]) {
3202
+ delete this.values[datapointName];
3203
+ pruned += 1;
3204
+ }
3205
+ });
3206
+ return pruned;
3207
+ }
3208
+
3209
+ /**
3210
+ * B-36: a device added anew is asked for its descriptions again.
3211
+ * @param iface
3212
+ * @param address device or channel address
3213
+ */
3214
+ forgetParamsetFailures(iface, address) {
3215
+ const prefix = iface + '/' + address;
3216
+ for (const key of this.paramsetFailed) {
3217
+ if (key.startsWith(prefix + '/') || key.startsWith(prefix + ':')) {
3218
+ this.paramsetFailed.delete(key);
3219
+ }
3220
+ }
3063
3221
  }
3064
3222
 
3065
3223
  /**
@@ -3322,6 +3480,7 @@ module.exports = function (RED) {
3322
3480
 
3323
3481
  if (changed) {
3324
3482
  this.saveMetadata();
3483
+ this.scheduleValuesPrune();
3325
3484
  }
3326
3485
 
3327
3486
  if (iface) {
@@ -3907,7 +4066,25 @@ module.exports = function (RED) {
3907
4066
  if (this.clients[iface]) {
3908
4067
  this.logger.debug('rpc >', iface, method, JSON.stringify(parameters));
3909
4068
  this.clients[iface].methodCall(method, parameters, (err, res) => {
3910
- if (err) {
4069
+ if (err && err.faultCode !== undefined) {
4070
+ // B-36: homematic-xmlrpc delivers a fault as an Error with
4071
+ // faultCode/faultString. The process answered - the client is
4072
+ // fine and stays; one line, with the arguments that were refused.
4073
+ this.logger[quiet ? 'debug' : 'error'](
4074
+ ' <',
4075
+ iface,
4076
+ method,
4077
+ JSON.stringify(parameters),
4078
+ 'fault',
4079
+ err.faultCode,
4080
+ err.faultString,
4081
+ );
4082
+ if (err.faultString === undefined) {
4083
+ err.faultString = err.message;
4084
+ }
4085
+
4086
+ reject(err);
4087
+ } else if (err) {
3911
4088
  this.logger[quiet ? 'debug' : 'error'](' <', iface, method, err);
3912
4089
  this.closeClient(iface);
3913
4090
  // a closed node creates no new client (it would reconnect forever)
@@ -3917,7 +4094,15 @@ module.exports = function (RED) {
3917
4094
 
3918
4095
  reject(err);
3919
4096
  } else if (res && res.faultCode) {
3920
- this.logger[quiet ? 'debug' : 'error'](' <', iface, method, JSON.stringify(res));
4097
+ this.logger[quiet ? 'debug' : 'error'](
4098
+ ' <',
4099
+ iface,
4100
+ method,
4101
+ JSON.stringify(parameters),
4102
+ 'fault',
4103
+ res.faultCode,
4104
+ res.faultString,
4105
+ );
3921
4106
  const fault = new Error(res.faultString);
3922
4107
  fault.faultCode = res.faultCode;
3923
4108
  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.1",
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
  },