node-red-contrib-ccu 4.2.0 → 4.4.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 (49) hide show
  1. package/.prettierignore +3 -0
  2. package/CHANGELOG.md +140 -0
  3. package/HANDOFF.md +46 -10
  4. package/README.md +71 -0
  5. package/ROADMAP.md +130 -47
  6. package/nodes/ccu-alexa.html +3 -1
  7. package/nodes/ccu-connection.html +51 -0
  8. package/nodes/ccu-connection.js +323 -16
  9. package/nodes/ccu-display.js +29 -13
  10. package/nodes/ccu-get-value.html +3 -1
  11. package/nodes/ccu-mqtt.html +21 -4
  12. package/nodes/ccu-mqtt.js +126 -102
  13. package/nodes/ccu-poll.js +6 -0
  14. package/nodes/ccu-rpc-event.html +6 -0
  15. package/nodes/ccu-rpc-event.js +31 -12
  16. package/nodes/ccu-set-value.html +38 -0
  17. package/nodes/ccu-set-value.js +59 -235
  18. package/nodes/ccu-signal.html +55 -1
  19. package/nodes/ccu-signal.js +52 -9
  20. package/nodes/ccu-switch.html +17 -4
  21. package/nodes/ccu-switch.js +28 -4
  22. package/nodes/ccu-sysvar.html +40 -1
  23. package/nodes/ccu-sysvar.js +78 -8
  24. package/nodes/ccu-value.html +27 -1
  25. package/nodes/ccu-value.js +69 -19
  26. package/nodes/lib/cast.js +59 -14
  27. package/nodes/lib/channelfilter.js +136 -0
  28. package/nodes/lib/dynconfig.js +81 -0
  29. package/nodes/lib/localccu.js +81 -0
  30. package/nodes/lib/message.js +6 -2
  31. package/nodes/lib/metaprovider.js +716 -0
  32. package/nodes/lib/valuestatus.js +145 -0
  33. package/nodes/locales/de/ccu-display.html +17 -0
  34. package/nodes/locales/de/ccu-rpc-event.json +1 -0
  35. package/nodes/locales/de/ccu-sysvar.json +3 -2
  36. package/nodes/locales/de/ccu-value.json +2 -1
  37. package/nodes/locales/en-US/ccu-display.html +17 -0
  38. package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
  39. package/nodes/locales/en-US/ccu-switch.html +12 -0
  40. package/nodes/locales/en-US/ccu-sysvar.json +3 -2
  41. package/nodes/locales/en-US/ccu-value.json +2 -1
  42. package/package.json +1 -1
  43. package/patch-certainonly.py +51 -0
  44. package/roadmap-archive/B-17.md +106 -0
  45. package/roadmap-archive/B-2.md +69 -0
  46. package/roadmap-archive/B-3.md +57 -0
  47. package/roadmap-archive/B-4.md +44 -0
  48. package/roadmap-archive/B-5.md +57 -0
  49. package/roadmap-archive/README.md +14 -0
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);
package/nodes/ccu-poll.js CHANGED
@@ -17,6 +17,12 @@ module.exports = function (RED) {
17
17
  this.ccu.register(this);
18
18
 
19
19
  this.on('input', (message, send, done) => {
20
+ if (this.ccu.metaMode) {
21
+ // openccu-lite has no ReGaHSS - nothing to poll (B-17)
22
+ done(this.ccu.regaMissingError('system variables and programs'));
23
+ return;
24
+ }
25
+
20
26
  this.ccu.regaPoll(); // TODO catch errors
21
27
  done();
22
28
  });
@@ -29,6 +29,7 @@
29
29
  change: {value: false},
30
30
  working: {value: false},
31
31
  cache: {value: false},
32
+ certainOnly: {value: false},
32
33
  topic: {value: '${CCU}/${Interface}/${channelName}/${datapoint}'}
33
34
  },
34
35
  inputs: 0,
@@ -596,6 +597,11 @@
596
597
  <input type="checkbox" id="node-input-cache">
597
598
  <span data-i18n="ccu-rpc-event.cache"></span>
598
599
  </label>
600
+
601
+ <label class="ccu-checkbox">
602
+ <input type="checkbox" id="node-input-certainOnly">
603
+ <span data-i18n="ccu-rpc-event.certainOnly"></span>
604
+ </label>
599
605
  </div>
600
606
  </div>
601
607
  <div class="form-row">
@@ -1,6 +1,20 @@
1
1
  const path = require('path');
2
2
 
3
3
  const statusHelper = require(path.join(__dirname, '/lib/status.js'));
4
+ const {valueStatus} = require(path.join(__dirname, '/lib/valuestatus.js'));
5
+
6
+ const FILTER_ATTRIBUTES = [
7
+ 'rooms',
8
+ 'functions',
9
+ 'device',
10
+ 'deviceName',
11
+ 'deviceType',
12
+ 'channel',
13
+ 'channelName',
14
+ 'channelType',
15
+ 'channelIndex',
16
+ 'datapoint',
17
+ ];
4
18
 
5
19
  module.exports = function (RED) {
6
20
  class CcuRpcEventNode {
@@ -21,20 +35,15 @@ module.exports = function (RED) {
21
35
  iface: config.iface,
22
36
  };
23
37
 
38
+ // opt-in: only present when the option is enabled, so existing
39
+ // subscriptions keep receiving uncertain values (#96)
40
+ if (config.certainOnly) {
41
+ filter.uncertain = false;
42
+ }
43
+
24
44
  this.ccu.register(this);
25
45
 
26
- [
27
- 'rooms',
28
- 'functions',
29
- 'device',
30
- 'deviceName',
31
- 'deviceType',
32
- 'channel',
33
- 'channelName',
34
- 'channelType',
35
- 'channelIndex',
36
- 'datapoint',
37
- ].forEach((attr) => {
46
+ FILTER_ATTRIBUTES.forEach((attr) => {
38
47
  if (!config[attr]) {
39
48
  return;
40
49
  }
@@ -45,8 +54,14 @@ module.exports = function (RED) {
45
54
  filter[attr] = config[attr];
46
55
  }
47
56
  });
57
+
58
+ // The node used to show nothing at all; show what came through
59
+ // last and how long ago (#52)
60
+ this.valueStatus = valueStatus(this);
61
+
48
62
  this.idSubscription = this.ccu.subscribe(filter, (message) => {
49
63
  message.topic = this.ccu.topicReplace(config.topic, message);
64
+ this.valueStatus.set(message.topic + ' = ' + message.payload, message.ts);
50
65
  this.send(message);
51
66
  });
52
67
  this.on('close', this._destructor);
@@ -54,6 +69,10 @@ module.exports = function (RED) {
54
69
 
55
70
  _destructor(done) {
56
71
  this.debug('ccu-rpc-event close');
72
+ if (this.valueStatus) {
73
+ this.valueStatus.stop();
74
+ }
75
+
57
76
  this.ccu.unsubscribe(this.idSubscription);
58
77
  this.ccu.deregister(this);
59
78
  done();
@@ -624,8 +624,46 @@
624
624
 
625
625
  <h3>Input</h3>
626
626
 
627
+
628
+ <h3>Dynamische Konfiguration</h3>
629
+ <p><code>msg.config</code> ist ein Objekt, dessen Eigenschaften die Konfiguration dieses Nodes für genau diese Nachricht überschreiben. Die gespeicherte Konfiguration bleibt unverändert.</p>
630
+ <ul>
631
+ <li><code>iface</code></li>
632
+ <li><code>rooms</code></li>
633
+ <li><code>functions</code></li>
634
+ <li><code>device</code></li>
635
+ <li><code>deviceType</code></li>
636
+ <li><code>deviceName</code></li>
637
+ <li><code>channel</code></li>
638
+ <li><code>channelType</code></li>
639
+ <li><code>channelIndex</code></li>
640
+ <li><code>channelName</code></li>
641
+ <li><code>datapoint</code></li>
642
+ <li><code>force</code></li>
643
+ <li><code>sowie die zugehörigen <code>...Rx</code>-Selektoren</code></li>
644
+ </ul>
645
+ <p>Zusätzlich füllen gleichnamige Eigenschaften der Nachricht selbst (<code>msg.rooms</code> usw.) leer gelassene Felder — so war es schon immer.</p>
627
646
  </script>
628
647
 
629
648
  <script type="text/x-red" data-help-name="ccu-set-value" lang="en-US">
630
649
  <p>Control multiple devices at once.</p>
650
+
651
+ <h3>Dynamic configuration</h3>
652
+ <p><code>msg.config</code> is an object whose properties override this node's configuration for that one message. The stored configuration is never modified.</p>
653
+ <ul>
654
+ <li><code>iface</code></li>
655
+ <li><code>rooms</code></li>
656
+ <li><code>functions</code></li>
657
+ <li><code>device</code></li>
658
+ <li><code>deviceType</code></li>
659
+ <li><code>deviceName</code></li>
660
+ <li><code>channel</code></li>
661
+ <li><code>channelType</code></li>
662
+ <li><code>channelIndex</code></li>
663
+ <li><code>channelName</code></li>
664
+ <li><code>datapoint</code></li>
665
+ <li><code>force</code></li>
666
+ <li><code>and the matching <code>...Rx</code> selectors</code></li>
667
+ </ul>
668
+ <p>Properties of the message itself (<code>msg.rooms</code> and so on) also fill in fields left empty in the dialog, as they always did.</p>
631
669
  </script>