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.
Files changed (45) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/HANDOFF.md +11 -3
  3. package/README.md +29 -0
  4. package/ROADMAP.md +112 -33
  5. package/nodes/ccu-alexa.html +3 -1
  6. package/nodes/ccu-connection.html +26 -0
  7. package/nodes/ccu-connection.js +87 -11
  8. package/nodes/ccu-display.js +29 -13
  9. package/nodes/ccu-get-value.html +3 -1
  10. package/nodes/ccu-mqtt.html +21 -4
  11. package/nodes/ccu-mqtt.js +126 -102
  12. package/nodes/ccu-rpc-event.html +6 -0
  13. package/nodes/ccu-rpc-event.js +31 -12
  14. package/nodes/ccu-set-value.html +38 -0
  15. package/nodes/ccu-set-value.js +59 -235
  16. package/nodes/ccu-signal.html +55 -1
  17. package/nodes/ccu-signal.js +52 -9
  18. package/nodes/ccu-switch.html +17 -4
  19. package/nodes/ccu-switch.js +28 -4
  20. package/nodes/ccu-sysvar.html +40 -1
  21. package/nodes/ccu-sysvar.js +78 -8
  22. package/nodes/ccu-value.html +27 -1
  23. package/nodes/ccu-value.js +69 -19
  24. package/nodes/lib/cast.js +59 -14
  25. package/nodes/lib/channelfilter.js +136 -0
  26. package/nodes/lib/dynconfig.js +81 -0
  27. package/nodes/lib/localccu.js +81 -0
  28. package/nodes/lib/message.js +6 -2
  29. package/nodes/lib/valuestatus.js +145 -0
  30. package/nodes/locales/de/ccu-display.html +17 -0
  31. package/nodes/locales/de/ccu-rpc-event.json +1 -0
  32. package/nodes/locales/de/ccu-sysvar.json +3 -2
  33. package/nodes/locales/de/ccu-value.json +2 -1
  34. package/nodes/locales/en-US/ccu-display.html +17 -0
  35. package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
  36. package/nodes/locales/en-US/ccu-switch.html +12 -0
  37. package/nodes/locales/en-US/ccu-sysvar.json +3 -2
  38. package/nodes/locales/en-US/ccu-value.json +2 -1
  39. package/package.json +1 -1
  40. package/patch-certainonly.py +51 -0
  41. package/roadmap-archive/B-2.md +69 -0
  42. package/roadmap-archive/B-3.md +57 -0
  43. package/roadmap-archive/B-4.md +44 -0
  44. package/roadmap-archive/B-5.md +57 -0
  45. package/roadmap-archive/README.md +11 -0
@@ -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.isLocal = false;
411
- if (config.host.startsWith('127.') || config.host === 'localhost') {
412
- try {
413
- const rfdConf = fs.readFileSync('/etc/lighttpd/conf.d/proxy.conf').toString();
414
- if (rfdConf.match(/"port"\s+=>\s+32001/)) {
415
- this.logger.info('local connection on ccu >= v3.41 detected');
416
- this.isLocal = true;
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 (['RSSI_DEVICE', 'RSSI_PEER'].includes(datapoint)) {
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
- this.logger.warn('rega poll already pending');
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;
@@ -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 (config.channelType === 'HM-Dis-EP-WM55') {
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] && !config['disable' + i]) {
156
- payload += convertString(message['line' + i] || config['line' + i]);
157
- if (config.channelType === 'HM-Dis-WM55') {
158
- payload += convertColor(message['color' + i] || config['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] || config['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 (config.channelType === 'HM-Dis-EP-WM55') {
168
- if (config.sound) {
169
- payload += ',0x14,' + config.sound + ',0x1C';
183
+ if (effective.channelType === 'HM-Dis-EP-WM55') {
184
+ if (effective.sound) {
185
+ payload += ',0x14,' + effective.sound + ',0x1C';
170
186
  }
171
187
 
172
- payload += ',' + config.repeat + ',0x1D,' + config.pause + ',0x16';
188
+ payload += ',' + effective.repeat + ',0x1D,' + effective.pause + ',0x16';
173
189
 
174
- if (config.signal) {
175
- payload += ',' + config.signal;
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(config.iface, config.channel, 'SUBMIT', payload)
198
+ .setValue(effective.iface, effective.channel, 'SUBMIT', payload)
183
199
  .then(() => {
184
200
  done();
185
201
  })
@@ -195,7 +195,9 @@
195
195
  arr.sort((a, b) => a.localeCompare(b));
196
196
  $nodeInputChannel.autocomplete('option', 'source', arr);
197
197
 
198
- if (!data[$nodeInputChannel.val().split(' ')[0]]) {
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
  }
@@ -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}/${command}/${callid}'},
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-topicInputRpc">RPC Response</label>
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
- if (!command) {
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
- setValue(filter, payload) {
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, true);
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
- if (filter.channelNameOrAddress) {
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
- if (!paramsetDescription[filter.param].OPERATIONS && 2) {
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 = this.paramCast(payload, paramsetDescription[filter.param]);
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
- if (filter.channelNameOrAddress) {
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
- if (!paramsetDescription[parameter].OPERATIONS && 2) {
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] = this.paramCast(payload[parameter], paramsetDescription[filter.param]);
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);