node-red-contrib-ccu 4.1.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 (51) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/HANDOFF.md +67 -16
  3. package/README.md +42 -0
  4. package/ROADMAP.md +129 -47
  5. package/examples/home-assistant.json +127 -0
  6. package/nodes/ccu-alexa.html +3 -1
  7. package/nodes/ccu-connection.html +26 -0
  8. package/nodes/ccu-connection.js +124 -13
  9. package/nodes/ccu-display.js +29 -13
  10. package/nodes/ccu-get-value.html +3 -1
  11. package/nodes/ccu-homeassistant.html +334 -0
  12. package/nodes/ccu-homeassistant.js +272 -0
  13. package/nodes/ccu-mqtt.html +21 -4
  14. package/nodes/ccu-mqtt.js +188 -105
  15. package/nodes/ccu-rpc-event.html +6 -0
  16. package/nodes/ccu-rpc-event.js +31 -12
  17. package/nodes/ccu-set-value.html +38 -0
  18. package/nodes/ccu-set-value.js +59 -235
  19. package/nodes/ccu-signal.html +55 -1
  20. package/nodes/ccu-signal.js +52 -9
  21. package/nodes/ccu-switch.html +17 -4
  22. package/nodes/ccu-switch.js +28 -4
  23. package/nodes/ccu-sysvar.html +40 -1
  24. package/nodes/ccu-sysvar.js +78 -8
  25. package/nodes/ccu-value.html +27 -1
  26. package/nodes/ccu-value.js +69 -19
  27. package/nodes/lib/cast.js +59 -14
  28. package/nodes/lib/channelfilter.js +136 -0
  29. package/nodes/lib/dynconfig.js +81 -0
  30. package/nodes/lib/hadiscovery.js +846 -0
  31. package/nodes/lib/haroles.js +193 -0
  32. package/nodes/lib/localccu.js +81 -0
  33. package/nodes/lib/message.js +6 -2
  34. package/nodes/lib/valuestatus.js +145 -0
  35. package/nodes/locales/de/ccu-display.html +17 -0
  36. package/nodes/locales/de/ccu-rpc-event.json +1 -0
  37. package/nodes/locales/de/ccu-sysvar.json +3 -2
  38. package/nodes/locales/de/ccu-value.json +2 -1
  39. package/nodes/locales/en-US/ccu-display.html +17 -0
  40. package/nodes/locales/en-US/ccu-rpc-event.json +3 -2
  41. package/nodes/locales/en-US/ccu-switch.html +12 -0
  42. package/nodes/locales/en-US/ccu-sysvar.json +3 -2
  43. package/nodes/locales/en-US/ccu-value.json +2 -1
  44. package/package.json +2 -1
  45. package/patch-certainonly.py +51 -0
  46. package/roadmap-archive/B-16.md +31 -0
  47. package/roadmap-archive/B-2.md +69 -0
  48. package/roadmap-archive/B-3.md +57 -0
  49. package/roadmap-archive/B-4.md +44 -0
  50. package/roadmap-archive/B-5.md +57 -0
  51. package/roadmap-archive/README.md +11 -0
@@ -16,7 +16,9 @@
16
16
 
17
17
  regaPoll: {value: true},
18
18
  regaInterval: {value: 30},
19
+ regaMetaInterval: {value: 15},
19
20
 
21
+ rpcPing: {value: true},
20
22
  rpcPingTimeout: {value: 60},
21
23
  rpcInitAddress: {value: ''},
22
24
  rpcServerHost: {value: '', required: true},
@@ -39,6 +41,18 @@
39
41
  },
40
42
 
41
43
  oneditprepare() {
44
+ // pre-4.3.0 config nodes have neither property - show the defaults
45
+ // the runtime uses rather than an unchecked box / empty field
46
+ if (typeof this.rpcPing === 'undefined') {
47
+ this.rpcPing = true;
48
+ $('#node-config-input-rpcPing').prop('checked', true);
49
+ }
50
+
51
+ if (typeof this.regaMetaInterval === 'undefined') {
52
+ this.regaMetaInterval = 15;
53
+ $('#node-config-input-regaMetaInterval').val(15);
54
+ }
55
+
42
56
  const $nodeConfigInputHost = $('#node-config-input-host');
43
57
  const $nodeConfigInputName = $('#node-config-input-name');
44
58
  const $nodeConfigInputRpcServerHost = $('#node-config-input-rpcServerHost');
@@ -178,6 +192,13 @@
178
192
  <input type="text" id="node-config-input-jackPort" style="width: 70px;" placeholder="2121">
179
193
  </div>
180
194
  </div>
195
+ <div class="form-row">
196
+ <label for="node-config-input-rpcPing"><i class="icon-tag"></i> RPC ping</label>
197
+ <div style="width: 70%; display: inline-block; vertical-align: text-top;">
198
+ <label class="ccu-checkbox"><input type="checkbox" id="node-config-input-rpcPing"> Enabled</label><br>
199
+ <small>Off disables the periodic ping and the timeout-triggered re-init for this connection.</small>
200
+ </div>
201
+ </div>
181
202
  <div class="form-row">
182
203
  <label for="node-config-input-rpcPingTimeout"><i class="icon-tag"></i> RPC ping timeout (s)</label>
183
204
  <input type="text" id="node-config-input-rpcPingTimeout">
@@ -192,6 +213,11 @@
192
213
  <label for="node-config-input-regaInterval"><i class="icon-tag"></i> Rega poll interval (s)</label>
193
214
  <input type="text" id="node-config-input-regaInterval">
194
215
  </div>
216
+ <div class="form-row">
217
+ <label for="node-config-input-regaMetaInterval"><i class="icon-tag"></i> Names/rooms refresh (min)</label>
218
+ <input type="text" id="node-config-input-regaMetaInterval">
219
+ <small>How often channel names, rooms and functions are re-read. 0 = never (the behaviour before 4.3.0).</small>
220
+ </div>
195
221
  <div class="form-row">
196
222
  <label for="node-config-input-queueTimeout"><i class="icon-tag"></i> Queue timeout (ms)</label>
197
223
  <input type="text" id="node-config-input-queueTimeout">
@@ -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');
@@ -208,6 +209,39 @@ module.exports = function (RED) {
208
209
  break;
209
210
  }
210
211
 
212
+ case 'devices': {
213
+ // ccu-homeassistant editor: every device (not channel) of every, or the
214
+ // given, interface with its channels
215
+ const all = (config.metadata && config.metadata.devices) || {};
216
+ const ifaces = request.query.iface ? [request.query.iface] : Object.keys(all);
217
+ ifaces.forEach((iface) => {
218
+ const devices = all[iface] || {};
219
+ Object.keys(devices).forEach((addr) => {
220
+ const device = devices[addr];
221
+ if (!device || device.PARENT) {
222
+ return;
223
+ }
224
+
225
+ object[addr] = {
226
+ name: config.channelNames[addr],
227
+ type: device.TYPE,
228
+ iface,
229
+ firmware: device.FIRMWARE,
230
+ channels: (device.CHILDREN || [])
231
+ .filter((ch) => devices[ch])
232
+ .map((ch) => ({
233
+ address: ch,
234
+ name: config.channelNames[ch],
235
+ type: devices[ch].TYPE,
236
+ })),
237
+ };
238
+ });
239
+ });
240
+
241
+ res.status(200).send(JSON.stringify(object));
242
+ break;
243
+ }
244
+
211
245
  case 'rooms':
212
246
  res.status(200).send(
213
247
  JSON.stringify({
@@ -374,15 +408,13 @@ module.exports = function (RED) {
374
408
 
375
409
  this.checkDuplicateConfig(config);
376
410
 
377
- this.isLocal = false;
378
- if (config.host.startsWith('127.') || config.host === 'localhost') {
379
- try {
380
- const rfdConf = fs.readFileSync('/etc/lighttpd/conf.d/proxy.conf').toString();
381
- if (rfdConf.match(/"port"\s+=>\s+32001/)) {
382
- this.logger.info('local connection on ccu >= v3.41 detected');
383
- this.isLocal = true;
384
- }
385
- } 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');
386
418
  }
387
419
 
388
420
  this.ifaceTypes = {
@@ -496,6 +528,10 @@ module.exports = function (RED) {
496
528
  this.rpcBinPort = Number.parseInt(config.rpcBinPort, 10);
497
529
  this.rpcXmlPort = Number.parseInt(config.rpcXmlPort, 10);
498
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);
499
535
  this.rpcPingTimer = {};
500
536
  this.ifaceStatus = {};
501
537
  this.serverError = {};
@@ -506,6 +542,10 @@ module.exports = function (RED) {
506
542
 
507
543
  this.regaEnabled = config.regaEnabled;
508
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;
509
549
  this.regaInterval = Number.parseInt(config.regaInterval, 10);
510
550
  this.hadTimeout = new Set();
511
551
 
@@ -599,6 +639,7 @@ module.exports = function (RED) {
599
639
  });
600
640
 
601
641
  if (config.regaEnabled) {
642
+ this.lastRegaDataRefresh = now();
602
643
  this.getRegaData().then(() => {
603
644
  this.regaPoll();
604
645
  this.initIfaces(config);
@@ -688,8 +729,10 @@ module.exports = function (RED) {
688
729
  * @returns {*}
689
730
  */
690
731
  deregister(node, done) {
691
- delete node.users[node.id];
692
- return done();
732
+ delete this.users[node.id];
733
+ if (typeof done === 'function') {
734
+ done();
735
+ }
693
736
  }
694
737
 
695
738
  /**
@@ -1049,7 +1092,14 @@ module.exports = function (RED) {
1049
1092
  const ts = dp.ts;
1050
1093
  const [iface, channel, datapoint] = dp.name.split('.');
1051
1094
  if (this.enabledIfaces.includes(iface) && datapoint) {
1052
- 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)
1053
1103
  dp.value -= 256;
1054
1104
  }
1055
1105
 
@@ -1285,7 +1335,12 @@ module.exports = function (RED) {
1285
1335
  regaPoll() {
1286
1336
  //this.logger.trace('regaPoll');
1287
1337
  if (this.regaPollPending) {
1288
- 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;
1289
1344
  } else {
1290
1345
  this.regaPollPending = true;
1291
1346
  clearTimeout(this.regaPollTimeout);
@@ -1307,6 +1362,14 @@ module.exports = function (RED) {
1307
1362
 
1308
1363
  this.firstRegaPollDone = true;
1309
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
+ }
1310
1373
  });
1311
1374
  }
1312
1375
  }
@@ -1446,6 +1509,35 @@ module.exports = function (RED) {
1446
1509
  * Poll ReGaHSS variables and call subscription callbacks
1447
1510
  * @returns {Promise}
1448
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
+
1449
1541
  getRegaVariables() {
1450
1542
  return new Promise((resolve, reject) => {
1451
1543
  this.logger.debug('getRegaVariables');
@@ -1731,6 +1823,10 @@ module.exports = function (RED) {
1731
1823
  * @param iface
1732
1824
  */
1733
1825
  rpcCheckInit(iface) {
1826
+ if (!this.rpcPingEnabled) {
1827
+ return;
1828
+ }
1829
+
1734
1830
  if (!this.metadata.devices[iface] || !Object.keys(this.metadata.devices[iface]).length > 0) {
1735
1831
  return;
1736
1832
  }
@@ -2474,6 +2570,7 @@ module.exports = function (RED) {
2474
2570
  'change',
2475
2571
  'cache',
2476
2572
  'stable',
2573
+ 'uncertain',
2477
2574
  'iface',
2478
2575
  'device',
2479
2576
  'deviceType',
@@ -2679,6 +2776,15 @@ module.exports = function (RED) {
2679
2776
  let matchChange;
2680
2777
  let matchStable;
2681
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
+
2682
2788
  if (filter) {
2683
2789
  const arrayAttr = Object.keys(filter);
2684
2790
 
@@ -2718,6 +2824,11 @@ module.exports = function (RED) {
2718
2824
  continue;
2719
2825
  }
2720
2826
 
2827
+ if (attr === 'uncertain') {
2828
+ // already decided above, and not a value to compare
2829
+ continue;
2830
+ }
2831
+
2721
2832
  if (this.callbackWhitelists[message.datapointName].has(id)) {
2722
2833
  if (matchCache && matchChange && matchStable) {
2723
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
  }