node-red-contrib-ccu 4.2.0 → 4.3.0
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 +96 -0
- package/HANDOFF.md +11 -3
- package/README.md +29 -0
- package/ROADMAP.md +112 -33
- package/nodes/ccu-alexa.html +3 -1
- package/nodes/ccu-connection.html +26 -0
- package/nodes/ccu-connection.js +87 -11
- package/nodes/ccu-display.js +29 -13
- package/nodes/ccu-get-value.html +3 -1
- package/nodes/ccu-mqtt.html +21 -4
- package/nodes/ccu-mqtt.js +126 -102
- package/nodes/ccu-rpc-event.html +6 -0
- package/nodes/ccu-rpc-event.js +31 -12
- package/nodes/ccu-set-value.html +38 -0
- package/nodes/ccu-set-value.js +59 -235
- package/nodes/ccu-signal.html +55 -1
- package/nodes/ccu-signal.js +52 -9
- package/nodes/ccu-switch.html +17 -4
- package/nodes/ccu-switch.js +28 -4
- package/nodes/ccu-sysvar.html +40 -1
- package/nodes/ccu-sysvar.js +78 -8
- package/nodes/ccu-value.html +27 -1
- package/nodes/ccu-value.js +69 -19
- package/nodes/lib/cast.js +59 -14
- package/nodes/lib/channelfilter.js +136 -0
- package/nodes/lib/dynconfig.js +81 -0
- package/nodes/lib/localccu.js +81 -0
- package/nodes/lib/message.js +6 -2
- package/nodes/lib/valuestatus.js +145 -0
- package/nodes/locales/de/ccu-display.html +17 -0
- package/nodes/locales/de/ccu-rpc-event.json +1 -0
- package/nodes/locales/de/ccu-sysvar.json +3 -2
- package/nodes/locales/de/ccu-value.json +2 -1
- package/nodes/locales/en-US/ccu-display.html +17 -0
- package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
- package/nodes/locales/en-US/ccu-switch.html +12 -0
- package/nodes/locales/en-US/ccu-sysvar.json +3 -2
- package/nodes/locales/en-US/ccu-value.json +2 -1
- package/package.json +1 -1
- package/patch-certainonly.py +51 -0
- package/roadmap-archive/B-2.md +69 -0
- package/roadmap-archive/B-3.md +57 -0
- package/roadmap-archive/B-4.md +44 -0
- package/roadmap-archive/B-5.md +57 -0
- package/roadmap-archive/README.md +11 -0
package/nodes/ccu-connection.js
CHANGED
|
@@ -9,6 +9,7 @@ const {castValue, castSysvar} = require('./lib/cast.js');
|
|
|
9
9
|
const {combinedParameterValue} = require('./lib/combined.js');
|
|
10
10
|
const {createMessage} = require('./lib/message.js');
|
|
11
11
|
const {topicReplace} = require('./lib/topic.js');
|
|
12
|
+
const {isLocalCcu} = require('./lib/localccu.js');
|
|
12
13
|
const {bestMatch} = require('./lib/similarity.js');
|
|
13
14
|
const nextport = require('./lib/nextport.js');
|
|
14
15
|
const hmDiscover = require('./lib/discover.js');
|
|
@@ -407,15 +408,13 @@ module.exports = function (RED) {
|
|
|
407
408
|
|
|
408
409
|
this.checkDuplicateConfig(config);
|
|
409
410
|
|
|
410
|
-
this.
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
} catch {}
|
|
411
|
+
// B-4: this used to grep /etc/lighttpd/conf.d/proxy.conf for the
|
|
412
|
+
// direct port. That file is a bare include on current firmware, so
|
|
413
|
+
// the check never matched and every local install went through the
|
|
414
|
+
// proxy. Ask the kernel for a listener instead.
|
|
415
|
+
this.isLocal = isLocalCcu(config.host);
|
|
416
|
+
if (this.isLocal) {
|
|
417
|
+
this.logger.info('local connection on ccu >= v3.41 detected');
|
|
419
418
|
}
|
|
420
419
|
|
|
421
420
|
this.ifaceTypes = {
|
|
@@ -529,6 +528,10 @@ module.exports = function (RED) {
|
|
|
529
528
|
this.rpcBinPort = Number.parseInt(config.rpcBinPort, 10);
|
|
530
529
|
this.rpcXmlPort = Number.parseInt(config.rpcXmlPort, 10);
|
|
531
530
|
this.rpcPingTimeout = Number.parseInt(config.rpcPingTimeout, 10) || 60;
|
|
531
|
+
// #44: opt out of the periodic ping/timeout supervision. Off means
|
|
532
|
+
// no pings and no timeout-triggered re-init for this connection -
|
|
533
|
+
// reconnecting after a CCU restart is then up to the CCU.
|
|
534
|
+
this.rpcPingEnabled = config.rpcPing === undefined ? true : Boolean(config.rpcPing);
|
|
532
535
|
this.rpcPingTimer = {};
|
|
533
536
|
this.ifaceStatus = {};
|
|
534
537
|
this.serverError = {};
|
|
@@ -539,6 +542,10 @@ module.exports = function (RED) {
|
|
|
539
542
|
|
|
540
543
|
this.regaEnabled = config.regaEnabled;
|
|
541
544
|
this.regaPollEnabled = config.regaPoll;
|
|
545
|
+
// #167: minutes between re-reads of channel names, rooms and
|
|
546
|
+
// functions. 0 switches it off.
|
|
547
|
+
this.regaMetaInterval =
|
|
548
|
+
config.regaMetaInterval === undefined ? 15 : Number.parseInt(config.regaMetaInterval, 10) || 0;
|
|
542
549
|
this.regaInterval = Number.parseInt(config.regaInterval, 10);
|
|
543
550
|
this.hadTimeout = new Set();
|
|
544
551
|
|
|
@@ -632,6 +639,7 @@ module.exports = function (RED) {
|
|
|
632
639
|
});
|
|
633
640
|
|
|
634
641
|
if (config.regaEnabled) {
|
|
642
|
+
this.lastRegaDataRefresh = now();
|
|
635
643
|
this.getRegaData().then(() => {
|
|
636
644
|
this.regaPoll();
|
|
637
645
|
this.initIfaces(config);
|
|
@@ -1084,7 +1092,14 @@ module.exports = function (RED) {
|
|
|
1084
1092
|
const ts = dp.ts;
|
|
1085
1093
|
const [iface, channel, datapoint] = dp.name.split('.');
|
|
1086
1094
|
if (this.enabledIfaces.includes(iface) && datapoint) {
|
|
1087
|
-
if (
|
|
1095
|
+
if (
|
|
1096
|
+
['RSSI_DEVICE', 'RSSI_PEER'].includes(datapoint) &&
|
|
1097
|
+
typeof dp.value === 'number' &&
|
|
1098
|
+
dp.value > 127
|
|
1099
|
+
) {
|
|
1100
|
+
// the ReGa reports the unsigned byte; without
|
|
1101
|
+
// the guard an already-signed value or the 0
|
|
1102
|
+
// for "never received" became -318 / -256 (#183)
|
|
1088
1103
|
dp.value -= 256;
|
|
1089
1104
|
}
|
|
1090
1105
|
|
|
@@ -1320,7 +1335,12 @@ module.exports = function (RED) {
|
|
|
1320
1335
|
regaPoll() {
|
|
1321
1336
|
//this.logger.trace('regaPoll');
|
|
1322
1337
|
if (this.regaPollPending) {
|
|
1323
|
-
|
|
1338
|
+
// #166: writing several variables at once used to lose all but
|
|
1339
|
+
// the first immediate re-poll, so their new values only showed
|
|
1340
|
+
// up at the next scheduled one (30 s by default). Remember the
|
|
1341
|
+
// request and run exactly one more poll afterwards.
|
|
1342
|
+
this.logger.debug('rega poll already pending, will repeat');
|
|
1343
|
+
this.regaPollAgain = true;
|
|
1324
1344
|
} else {
|
|
1325
1345
|
this.regaPollPending = true;
|
|
1326
1346
|
clearTimeout(this.regaPollTimeout);
|
|
@@ -1342,6 +1362,14 @@ module.exports = function (RED) {
|
|
|
1342
1362
|
|
|
1343
1363
|
this.firstRegaPollDone = true;
|
|
1344
1364
|
this.regaPollPending = false;
|
|
1365
|
+
|
|
1366
|
+
if (this.regaPollAgain && !this.cancelRegaPoll) {
|
|
1367
|
+
this.regaPollAgain = false;
|
|
1368
|
+
this.regaPoll();
|
|
1369
|
+
} else {
|
|
1370
|
+
this.regaPollAgain = false;
|
|
1371
|
+
this.refreshRegaDataIfDue();
|
|
1372
|
+
}
|
|
1345
1373
|
});
|
|
1346
1374
|
}
|
|
1347
1375
|
}
|
|
@@ -1481,6 +1509,35 @@ module.exports = function (RED) {
|
|
|
1481
1509
|
* Poll ReGaHSS variables and call subscription callbacks
|
|
1482
1510
|
* @returns {Promise}
|
|
1483
1511
|
*/
|
|
1512
|
+
/**
|
|
1513
|
+
* Re-read the ReGa metadata that only ever got fetched once, in the
|
|
1514
|
+
* constructor: channel names, rooms and functions. A room or function
|
|
1515
|
+
* added in the CCU WebUI was invisible until the flow was redeployed
|
|
1516
|
+
* (#167). Cheap enough for a slow schedule - three small scripts.
|
|
1517
|
+
* @returns {Promise<any>}
|
|
1518
|
+
*/
|
|
1519
|
+
refreshRegaData() {
|
|
1520
|
+
this.lastRegaDataRefresh = now();
|
|
1521
|
+
this.logger.debug('refreshRegaData');
|
|
1522
|
+
return this.getRegaChannels()
|
|
1523
|
+
.then(() => this.getRegaRooms())
|
|
1524
|
+
.then(() => this.getRegaFunctions())
|
|
1525
|
+
.then(() => this.saveRegadata())
|
|
1526
|
+
.catch((error) => this.logger.error('refreshRegaData', error));
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/** Runs refreshRegaData() when the configured interval has elapsed. */
|
|
1530
|
+
refreshRegaDataIfDue() {
|
|
1531
|
+
if (!this.regaMetaInterval || this.cancelRegaPoll) {
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const elapsed = now() - (this.lastRegaDataRefresh || 0);
|
|
1536
|
+
if (elapsed >= this.regaMetaInterval * 60000) {
|
|
1537
|
+
this.refreshRegaData();
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1484
1541
|
getRegaVariables() {
|
|
1485
1542
|
return new Promise((resolve, reject) => {
|
|
1486
1543
|
this.logger.debug('getRegaVariables');
|
|
@@ -1766,6 +1823,10 @@ module.exports = function (RED) {
|
|
|
1766
1823
|
* @param iface
|
|
1767
1824
|
*/
|
|
1768
1825
|
rpcCheckInit(iface) {
|
|
1826
|
+
if (!this.rpcPingEnabled) {
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1769
1830
|
if (!this.metadata.devices[iface] || !Object.keys(this.metadata.devices[iface]).length > 0) {
|
|
1770
1831
|
return;
|
|
1771
1832
|
}
|
|
@@ -2509,6 +2570,7 @@ module.exports = function (RED) {
|
|
|
2509
2570
|
'change',
|
|
2510
2571
|
'cache',
|
|
2511
2572
|
'stable',
|
|
2573
|
+
'uncertain',
|
|
2512
2574
|
'iface',
|
|
2513
2575
|
'device',
|
|
2514
2576
|
'deviceType',
|
|
@@ -2714,6 +2776,15 @@ module.exports = function (RED) {
|
|
|
2714
2776
|
let matchChange;
|
|
2715
2777
|
let matchStable;
|
|
2716
2778
|
|
|
2779
|
+
// Checked before the attribute loop below: that loop may break
|
|
2780
|
+
// early for a whitelisted datapoint, and this decision has to be
|
|
2781
|
+
// made for every message regardless of key order. Only present in
|
|
2782
|
+
// the filter when a node opted in, so existing subscriptions keep
|
|
2783
|
+
// receiving values the ReGa could not timestamp (#96).
|
|
2784
|
+
if (filter && filter.uncertain === false && message.uncertain) {
|
|
2785
|
+
return false;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2717
2788
|
if (filter) {
|
|
2718
2789
|
const arrayAttr = Object.keys(filter);
|
|
2719
2790
|
|
|
@@ -2753,6 +2824,11 @@ module.exports = function (RED) {
|
|
|
2753
2824
|
continue;
|
|
2754
2825
|
}
|
|
2755
2826
|
|
|
2827
|
+
if (attr === 'uncertain') {
|
|
2828
|
+
// already decided above, and not a value to compare
|
|
2829
|
+
continue;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2756
2832
|
if (this.callbackWhitelists[message.datapointName].has(id)) {
|
|
2757
2833
|
if (matchCache && matchChange && matchStable) {
|
|
2758
2834
|
break;
|
package/nodes/ccu-display.js
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
const path = require('path');
|
|
2
2
|
|
|
3
3
|
const statusHelper = require(path.join(__dirname, '/lib/status.js'));
|
|
4
|
+
const {effectiveConfig} = require(path.join(__dirname, '/lib/dynconfig.js'));
|
|
5
|
+
|
|
6
|
+
/** Everything a message may override through msg.config (B-2, #80 #185). */
|
|
7
|
+
const DYNAMIC_KEYS = [
|
|
8
|
+
'iface',
|
|
9
|
+
'channel',
|
|
10
|
+
'channelType',
|
|
11
|
+
'sound',
|
|
12
|
+
'repeat',
|
|
13
|
+
'pause',
|
|
14
|
+
'signal',
|
|
15
|
+
...[1, 2, 3, 4, 5, 6].flatMap((i) => ['line' + i, 'color' + i, 'icon' + i, 'disable' + i]),
|
|
16
|
+
];
|
|
4
17
|
|
|
5
18
|
module.exports = function (RED) {
|
|
6
19
|
class CcuDisplay {
|
|
@@ -143,43 +156,46 @@ module.exports = function (RED) {
|
|
|
143
156
|
}
|
|
144
157
|
|
|
145
158
|
this.on('input', (message, send, done) => {
|
|
159
|
+
// msg.line1 & co. keep working as before; msg.config
|
|
160
|
+
// additionally reaches the channel and the EP-WM55 settings
|
|
161
|
+
const {config: effective} = effectiveConfig(config, message, DYNAMIC_KEYS);
|
|
146
162
|
let payload = '0x02';
|
|
147
163
|
|
|
148
164
|
let countLines = 6; // HM-Dis-WM55
|
|
149
|
-
if (
|
|
165
|
+
if (effective.channelType === 'HM-Dis-EP-WM55') {
|
|
150
166
|
payload += ',0x0A';
|
|
151
167
|
countLines = 3;
|
|
152
168
|
}
|
|
153
169
|
|
|
154
170
|
for (let i = 1; i <= countLines; i++) {
|
|
155
|
-
if (!message['disable' + i] && !
|
|
156
|
-
payload += convertString(message['line' + i] ||
|
|
157
|
-
if (
|
|
158
|
-
payload += convertColor(message['color' + i] ||
|
|
171
|
+
if (!message['disable' + i] && !effective['disable' + i]) {
|
|
172
|
+
payload += convertString(message['line' + i] || effective['line' + i]);
|
|
173
|
+
if (effective.channelType === 'HM-Dis-WM55') {
|
|
174
|
+
payload += convertColor(message['color' + i] || effective['color' + i]);
|
|
159
175
|
}
|
|
160
176
|
|
|
161
|
-
payload += convertIcon(message['icon' + i] ||
|
|
177
|
+
payload += convertIcon(message['icon' + i] || effective['icon' + i]);
|
|
162
178
|
}
|
|
163
179
|
|
|
164
180
|
payload += ',0x0A';
|
|
165
181
|
}
|
|
166
182
|
|
|
167
|
-
if (
|
|
168
|
-
if (
|
|
169
|
-
payload += ',0x14,' +
|
|
183
|
+
if (effective.channelType === 'HM-Dis-EP-WM55') {
|
|
184
|
+
if (effective.sound) {
|
|
185
|
+
payload += ',0x14,' + effective.sound + ',0x1C';
|
|
170
186
|
}
|
|
171
187
|
|
|
172
|
-
payload += ',' +
|
|
188
|
+
payload += ',' + effective.repeat + ',0x1D,' + effective.pause + ',0x16';
|
|
173
189
|
|
|
174
|
-
if (
|
|
175
|
-
payload += ',' +
|
|
190
|
+
if (effective.signal) {
|
|
191
|
+
payload += ',' + effective.signal;
|
|
176
192
|
}
|
|
177
193
|
}
|
|
178
194
|
|
|
179
195
|
payload += ',0x03';
|
|
180
196
|
|
|
181
197
|
this.ccu
|
|
182
|
-
.setValue(
|
|
198
|
+
.setValue(effective.iface, effective.channel, 'SUBMIT', payload)
|
|
183
199
|
.then(() => {
|
|
184
200
|
done();
|
|
185
201
|
})
|
package/nodes/ccu-get-value.html
CHANGED
|
@@ -195,7 +195,9 @@
|
|
|
195
195
|
arr.sort((a, b) => a.localeCompare(b));
|
|
196
196
|
$nodeInputChannel.autocomplete('option', 'source', arr);
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
// ${ENV} placeholders are substituted by Node-RED before the node is
|
|
199
|
+
// constructed (subflow parameters, #158) - do not clear them
|
|
200
|
+
if (!/^\$\{[^}]+\}$/.test($nodeInputChannel.val()) && !data[$nodeInputChannel.val().split(' ')[0]]) {
|
|
199
201
|
$nodeInputChannel.val('');
|
|
200
202
|
$nodeInputDatapoint.val('');
|
|
201
203
|
}
|
package/nodes/ccu-mqtt.html
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
topicOutputEvent: {value: 'hm/status/${channelName}/${datapoint}'},
|
|
12
12
|
topicInputSetValue: {value: 'hm/set/${channelNameOrAddress}/${datapoint}'},
|
|
13
|
+
topicInputGet: {value: 'hm/get/${channelNameOrAddress}/${datapoint}'},
|
|
13
14
|
|
|
14
15
|
topicOutputSysvar: {value: 'hm/status/${name}'},
|
|
15
16
|
topicInputSysvar: {value: 'hm/set/${name}'},
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
topicInputPutParam: {value: 'hm/paramset/${channelNameOrAddress}/${paramset}/${param}'},
|
|
18
19
|
topicInputPutParamset: {value: 'hm/paramset/${channelNameOrAddress}/${paramset}'},
|
|
19
20
|
|
|
20
|
-
topicInputRpc: {value: 'hm/rpc/${iface}/${method}/${
|
|
21
|
+
topicInputRpc: {value: 'hm/rpc/${iface}/${method}/${callid}'},
|
|
21
22
|
topicOutputRpc: {value: 'hm/response/${callid}'},
|
|
22
23
|
|
|
23
24
|
topicCounters: {value: 'hm/status/counter/${iface}/${rxtx}'},
|
|
@@ -36,6 +37,20 @@
|
|
|
36
37
|
labelStyle() {
|
|
37
38
|
return this.name ? 'node_label_italic' : '';
|
|
38
39
|
},
|
|
40
|
+
oneditprepare() {
|
|
41
|
+
// The RPC command was never implemented, so its topic template
|
|
42
|
+
// carried a meaningless ${command} segment. Nothing can have
|
|
43
|
+
// depended on it - migrate nodes that still hold the old default.
|
|
44
|
+
if (this.topicInputRpc === 'hm/rpc/${iface}/${method}/${command}/${callid}') {
|
|
45
|
+
this.topicInputRpc = 'hm/rpc/${iface}/${method}/${callid}';
|
|
46
|
+
$('#node-input-topicInputRpc').val(this.topicInputRpc);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (typeof this.topicInputGet === 'undefined') {
|
|
50
|
+
this.topicInputGet = 'hm/get/${channelNameOrAddress}/${datapoint}';
|
|
51
|
+
$('#node-input-topicInputGet').val(this.topicInputGet);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
39
54
|
oneditsave() {
|
|
40
55
|
}
|
|
41
56
|
});
|
|
@@ -84,6 +99,10 @@
|
|
|
84
99
|
<label for="node-input-topicInputSetValue">setValue</label>
|
|
85
100
|
<input type="text" id="node-input-topicInputSetValue">
|
|
86
101
|
</div>
|
|
102
|
+
<div class="form-row">
|
|
103
|
+
<label for="node-input-topicInputGet">get</label>
|
|
104
|
+
<input type="text" id="node-input-topicInputGet">
|
|
105
|
+
</div>
|
|
87
106
|
<div class="form-row">
|
|
88
107
|
<label for="node-input-topicOutputSysvar">Sysvar/Program Change</label>
|
|
89
108
|
<input type="text" id="node-input-topicOutputSysvar">
|
|
@@ -100,16 +119,14 @@
|
|
|
100
119
|
<label for="node-input-topicInputPutParamset">putParamset</label>
|
|
101
120
|
<input type="text" id="node-input-topicInputPutParamset">
|
|
102
121
|
</div>
|
|
103
|
-
<!--
|
|
104
122
|
<div class="form-row">
|
|
105
123
|
<label for="node-input-topicInputRpc">RPC</label>
|
|
106
124
|
<input type="text" id="node-input-topicInputRpc">
|
|
107
125
|
</div>
|
|
108
126
|
<div class="form-row">
|
|
109
|
-
<label for="node-input-
|
|
127
|
+
<label for="node-input-topicOutputRpc">RPC Response</label>
|
|
110
128
|
<input type="text" id="node-input-topicOutputRpc">
|
|
111
129
|
</div>
|
|
112
|
-
-->
|
|
113
130
|
<div class="form-row">
|
|
114
131
|
<label for="node-input-topicCounters">Message Counters</label>
|
|
115
132
|
<input type="text" id="node-input-topicCounters">
|
package/nodes/ccu-mqtt.js
CHANGED
|
@@ -2,6 +2,7 @@ const path = require('path');
|
|
|
2
2
|
const mw = require('mqtt-wildcard');
|
|
3
3
|
|
|
4
4
|
const statusHelper = require(path.join(__dirname, '/lib/status.js'));
|
|
5
|
+
const {castValue} = require(path.join(__dirname, '/lib/cast.js'));
|
|
5
6
|
|
|
6
7
|
/** HM thermostats: CONTROL_MODE is read-only, the modes are set through actions */
|
|
7
8
|
const HM_MODES = {
|
|
@@ -35,7 +36,10 @@ module.exports = function (RED) {
|
|
|
35
36
|
this.topicInputPutParam = config.topicInputPutParam;
|
|
36
37
|
this.topicInputPutParamset = config.topicInputPutParamset;
|
|
37
38
|
|
|
39
|
+
this.topicInputGet = config.topicInputGet;
|
|
40
|
+
|
|
38
41
|
this.topicInputRpc = config.topicInputRpc;
|
|
42
|
+
this.topicOutputRpc = config.topicOutputRpc;
|
|
39
43
|
|
|
40
44
|
this.topicCounters = config.topicCounters;
|
|
41
45
|
this.rxCounters = {};
|
|
@@ -179,6 +183,7 @@ module.exports = function (RED) {
|
|
|
179
183
|
this.debug('input ' + topic + ' ' + JSON.stringify(payload).slice(0, 40));
|
|
180
184
|
|
|
181
185
|
const topicList = {
|
|
186
|
+
get: this.topicInputGet,
|
|
182
187
|
setValue: this.topicInputSetValue,
|
|
183
188
|
sysvar: this.topicInputSysvar,
|
|
184
189
|
putParam: this.topicInputPutParam,
|
|
@@ -189,7 +194,8 @@ module.exports = function (RED) {
|
|
|
189
194
|
let command;
|
|
190
195
|
let filter;
|
|
191
196
|
Object.keys(topicList).forEach((key) => {
|
|
192
|
-
|
|
197
|
+
// a topic left empty in the editor disables that command
|
|
198
|
+
if (!command && topicList[key]) {
|
|
193
199
|
const parts = topicList[key].split('/');
|
|
194
200
|
const patternArray = [];
|
|
195
201
|
const placeholders = [];
|
|
@@ -220,29 +226,129 @@ module.exports = function (RED) {
|
|
|
220
226
|
}
|
|
221
227
|
}
|
|
222
228
|
|
|
223
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Resolve `channelNameOrAddress` to a channel address and its
|
|
231
|
+
* interface, reporting the same errors the commands used to report
|
|
232
|
+
* inline.
|
|
233
|
+
* @param {object} filter parsed from the topic; `channel` is filled in
|
|
234
|
+
* @param {boolean} [exact] pass through to findChannel
|
|
235
|
+
* @returns {string|null} the interface, or null when unresolvable
|
|
236
|
+
*/
|
|
237
|
+
resolveChannel(filter, exact) {
|
|
224
238
|
if (filter.channelNameOrAddress) {
|
|
225
239
|
if (this.ccu.channelNames[filter.channelNameOrAddress]) {
|
|
226
240
|
filter.channel = filter.channelNameOrAddress;
|
|
227
241
|
} else {
|
|
228
|
-
filter.channel = this.ccu.findChannel(filter.channelNameOrAddress,
|
|
242
|
+
filter.channel = this.ccu.findChannel(filter.channelNameOrAddress, exact);
|
|
229
243
|
}
|
|
230
244
|
|
|
231
245
|
if (!filter.channel) {
|
|
232
246
|
this.error('channel ' + filter.channelNameOrAddress + ' not found');
|
|
233
|
-
return;
|
|
247
|
+
return null;
|
|
234
248
|
}
|
|
235
249
|
}
|
|
236
250
|
|
|
237
251
|
if (!filter.channel) {
|
|
238
252
|
this.error('channel undefined');
|
|
239
|
-
return;
|
|
253
|
+
return null;
|
|
240
254
|
}
|
|
241
255
|
|
|
242
256
|
const iface = this.ccu.findIface(filter.channel);
|
|
243
257
|
|
|
244
258
|
if (!iface) {
|
|
245
259
|
this.error('no interface found for channel ' + filter.channel);
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return iface;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Republish the last known value of a datapoint on its status topic,
|
|
268
|
+
* so a flow can ask for a refresh over MQTT instead of waiting for the
|
|
269
|
+
* next event (#115). An empty datapoint republishes the whole channel.
|
|
270
|
+
* @param {object} filter
|
|
271
|
+
*/
|
|
272
|
+
get(filter) {
|
|
273
|
+
const iface = this.resolveChannel(filter, true);
|
|
274
|
+
if (!iface) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const prefix = iface + '.' + filter.channel + '.';
|
|
279
|
+
const names = filter.datapoint
|
|
280
|
+
? [prefix + filter.datapoint]
|
|
281
|
+
: Object.keys(this.ccu.values).filter((name) => name.startsWith(prefix));
|
|
282
|
+
|
|
283
|
+
if (names.length === 0) {
|
|
284
|
+
this.error('unknown datapoint ' + prefix + (filter.datapoint || '#'));
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
names.forEach((name) => {
|
|
289
|
+
const value = this.ccu.values[name];
|
|
290
|
+
if (value) {
|
|
291
|
+
this.event({...value});
|
|
292
|
+
} else {
|
|
293
|
+
this.error('unknown datapoint ' + name);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Call an arbitrary RPC method on an interface and publish the result
|
|
300
|
+
* (#22). The topic supplies interface, method and a caller-chosen id
|
|
301
|
+
* that the response topic echoes; the payload is the parameter array
|
|
302
|
+
* (a single value is accepted and wrapped).
|
|
303
|
+
* @param {object} filter
|
|
304
|
+
* @param {*} payload
|
|
305
|
+
*/
|
|
306
|
+
rpc(filter, payload) {
|
|
307
|
+
if (!filter.iface) {
|
|
308
|
+
this.error('interface undefined');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!filter.method) {
|
|
313
|
+
this.error('method undefined');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let parameters = payload;
|
|
318
|
+
if (parameters === undefined || parameters === null || parameters === '') {
|
|
319
|
+
parameters = [];
|
|
320
|
+
} else if (!Array.isArray(parameters)) {
|
|
321
|
+
parameters = [parameters];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const respond = (result, error) => {
|
|
325
|
+
if (!this.topicOutputRpc) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
this.send({
|
|
330
|
+
topic: this.ccu.topicReplace(this.topicOutputRpc, {
|
|
331
|
+
iface: filter.iface,
|
|
332
|
+
method: filter.method,
|
|
333
|
+
callid: filter.callid,
|
|
334
|
+
}),
|
|
335
|
+
payload: error ? {error: error.message} : {result},
|
|
336
|
+
retain: false,
|
|
337
|
+
});
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
this.ccu
|
|
341
|
+
.methodCall(filter.iface, filter.method, parameters)
|
|
342
|
+
.then((result) => respond(result))
|
|
343
|
+
.catch((error) => {
|
|
344
|
+
this.error(error.message);
|
|
345
|
+
respond(undefined, error);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
setValue(filter, payload) {
|
|
350
|
+
const iface = this.resolveChannel(filter, true);
|
|
351
|
+
if (!iface) {
|
|
246
352
|
return;
|
|
247
353
|
}
|
|
248
354
|
|
|
@@ -313,28 +419,8 @@ module.exports = function (RED) {
|
|
|
313
419
|
}
|
|
314
420
|
|
|
315
421
|
putParam(filter, payload) {
|
|
316
|
-
|
|
317
|
-
if (this.ccu.channelNames[filter.channelNameOrAddress]) {
|
|
318
|
-
filter.channel = filter.channelNameOrAddress;
|
|
319
|
-
} else {
|
|
320
|
-
filter.channel = this.ccu.findChannel(filter.channelNameOrAddress);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
if (!filter.channel) {
|
|
324
|
-
this.error('channel ' + filter.channelNameOrAddress + ' not found');
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (!filter.channel) {
|
|
330
|
-
this.error('channel undefined');
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const iface = this.ccu.findIface(filter.channel);
|
|
335
|
-
|
|
422
|
+
const iface = this.resolveChannel(filter);
|
|
336
423
|
if (!iface) {
|
|
337
|
-
this.error('no interface found for channel ' + filter.channel);
|
|
338
424
|
return;
|
|
339
425
|
}
|
|
340
426
|
|
|
@@ -345,11 +431,14 @@ module.exports = function (RED) {
|
|
|
345
431
|
);
|
|
346
432
|
const paramsetDescription = this.ccu.paramsetDescriptions[psName];
|
|
347
433
|
if (paramsetDescription && paramsetDescription[filter.param]) {
|
|
348
|
-
|
|
434
|
+
// was `!(OPERATIONS) && 2`, which is always false - the check
|
|
435
|
+
// never fired and the write went out regardless (B-3)
|
|
436
|
+
if (!(paramsetDescription[filter.param].OPERATIONS & 2)) {
|
|
349
437
|
this.error('param ' + filter.param + ' not writeable');
|
|
438
|
+
return;
|
|
350
439
|
}
|
|
351
440
|
|
|
352
|
-
payload =
|
|
441
|
+
payload = castValue(payload, paramsetDescription[filter.param], {clamp: true});
|
|
353
442
|
} else {
|
|
354
443
|
this.warn('unknown paramset/param ' + filter.paramset + ' ' + filter.param);
|
|
355
444
|
}
|
|
@@ -368,28 +457,8 @@ module.exports = function (RED) {
|
|
|
368
457
|
return;
|
|
369
458
|
}
|
|
370
459
|
|
|
371
|
-
|
|
372
|
-
if (this.ccu.channelNames[filter.channelNameOrAddress]) {
|
|
373
|
-
filter.channel = filter.channelNameOrAddress;
|
|
374
|
-
} else {
|
|
375
|
-
filter.channel = this.ccu.findChannel(filter.channelNameOrAddress);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (!filter.channel) {
|
|
379
|
-
this.error('channel ' + filter.channelNameOrAddress + ' not found');
|
|
380
|
-
return;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (!filter.channel) {
|
|
385
|
-
this.error('channel undefined');
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const iface = this.ccu.findIface(filter.channel);
|
|
390
|
-
|
|
460
|
+
const iface = this.resolveChannel(filter);
|
|
391
461
|
if (!iface) {
|
|
392
|
-
this.error('no interface found for channel ' + filter.channel);
|
|
393
462
|
return;
|
|
394
463
|
}
|
|
395
464
|
|
|
@@ -404,11 +473,17 @@ module.exports = function (RED) {
|
|
|
404
473
|
|
|
405
474
|
Object.keys(payload).forEach((parameter) => {
|
|
406
475
|
if (paramsetDescription && paramsetDescription[parameter]) {
|
|
407
|
-
|
|
476
|
+
// was `!(OPERATIONS) && 2` (always false), and the cast
|
|
477
|
+
// used paramsetDescription[filter.param], which is
|
|
478
|
+
// undefined here - so every value went out uncast (B-3)
|
|
479
|
+
if (!(paramsetDescription[parameter].OPERATIONS & 2)) {
|
|
408
480
|
this.error('param ' + parameter + ' not writeable');
|
|
481
|
+
return;
|
|
409
482
|
}
|
|
410
483
|
|
|
411
|
-
paramset[parameter] =
|
|
484
|
+
paramset[parameter] = castValue(payload[parameter], paramsetDescription[parameter], {
|
|
485
|
+
clamp: true,
|
|
486
|
+
});
|
|
412
487
|
} else {
|
|
413
488
|
this.warn('unknown paramset/param ' + filter.paramset + ' ' + parameter);
|
|
414
489
|
paramset[parameter] = payload[parameter];
|
|
@@ -419,57 +494,6 @@ module.exports = function (RED) {
|
|
|
419
494
|
.methodCall(iface, 'putParamset', [filter.channel, filter.paramset, paramset])
|
|
420
495
|
.catch((error) => this.error(error.message));
|
|
421
496
|
}
|
|
422
|
-
|
|
423
|
-
paramCast(value, paramset) {
|
|
424
|
-
switch (paramset && paramset.TYPE) {
|
|
425
|
-
case 'BOOL':
|
|
426
|
-
// Fallthrough by intention
|
|
427
|
-
case 'ACTION':
|
|
428
|
-
// OMG this is so ugly...
|
|
429
|
-
if (value === 'false') {
|
|
430
|
-
value = false;
|
|
431
|
-
} else if (!isNaN(value)) {
|
|
432
|
-
// Make sure that the string "0" gets casted to boolean false
|
|
433
|
-
value = Number(value);
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
value = Boolean(value);
|
|
437
|
-
break;
|
|
438
|
-
case 'FLOAT':
|
|
439
|
-
value = Number.parseFloat(value);
|
|
440
|
-
if (value < paramset.MIN) {
|
|
441
|
-
value = paramset.MIN;
|
|
442
|
-
} else if (value > paramset.MAX) {
|
|
443
|
-
value = paramset.MAX;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
value = {explicitDouble: value};
|
|
447
|
-
break;
|
|
448
|
-
case 'ENUM':
|
|
449
|
-
if (typeof value === 'string') {
|
|
450
|
-
if (paramset.ENUM && paramset.ENUM.includes(value)) {
|
|
451
|
-
value = paramset.ENUM.indexOf(value);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
// Fallthrough by intention
|
|
456
|
-
case 'INTEGER':
|
|
457
|
-
value = Number.parseInt(value, 10);
|
|
458
|
-
if (value < paramset.MIN) {
|
|
459
|
-
value = paramset.MIN;
|
|
460
|
-
} else if (value > paramset.MAX) {
|
|
461
|
-
value = paramset.MAX;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
break;
|
|
465
|
-
case 'STRING':
|
|
466
|
-
value = String(value);
|
|
467
|
-
break;
|
|
468
|
-
default:
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return value;
|
|
472
|
-
}
|
|
473
497
|
}
|
|
474
498
|
|
|
475
499
|
RED.nodes.registerType('ccu-mqtt', CcuMqttNode);
|