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 +25 -0
- package/nodes/ccu-connection.js +197 -12
- package/package.json +2 -8
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
|
package/nodes/ccu-connection.js
CHANGED
|
@@ -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 (
|
|
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) =>
|
|
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
|
-
|
|
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'](
|
|
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.
|
|
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": "^
|
|
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
|
},
|