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
@@ -9,10 +9,12 @@ 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');
15
16
  const {Rega} = require('homematic-rega'); // ES module - require(esm) needs Node >= 20.19 / >= 22.12
17
+ const metaProvider = require('./lib/metaprovider.js');
16
18
  const xmlrpc = require('homematic-xmlrpc');
17
19
  const binrpc = require('binrpc');
18
20
 
@@ -407,15 +409,13 @@ module.exports = function (RED) {
407
409
 
408
410
  this.checkDuplicateConfig(config);
409
411
 
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 {}
412
+ // B-4: this used to grep /etc/lighttpd/conf.d/proxy.conf for the
413
+ // direct port. That file is a bare include on current firmware, so
414
+ // the check never matched and every local install went through the
415
+ // proxy. Ask the kernel for a listener instead.
416
+ this.isLocal = isLocalCcu(config.host);
417
+ if (this.isLocal) {
418
+ this.logger.info('local connection on ccu >= v3.41 detected');
419
419
  }
420
420
 
421
421
  this.ifaceTypes = {
@@ -529,6 +529,10 @@ module.exports = function (RED) {
529
529
  this.rpcBinPort = Number.parseInt(config.rpcBinPort, 10);
530
530
  this.rpcXmlPort = Number.parseInt(config.rpcXmlPort, 10);
531
531
  this.rpcPingTimeout = Number.parseInt(config.rpcPingTimeout, 10) || 60;
532
+ // #44: opt out of the periodic ping/timeout supervision. Off means
533
+ // no pings and no timeout-triggered re-init for this connection -
534
+ // reconnecting after a CCU restart is then up to the CCU.
535
+ this.rpcPingEnabled = config.rpcPing === undefined ? true : Boolean(config.rpcPing);
532
536
  this.rpcPingTimer = {};
533
537
  this.ifaceStatus = {};
534
538
  this.serverError = {};
@@ -539,6 +543,19 @@ module.exports = function (RED) {
539
543
 
540
544
  this.regaEnabled = config.regaEnabled;
541
545
  this.regaPollEnabled = config.regaPoll;
546
+ // B-17 openccu-lite: the metadata api sits behind the box's
547
+ // lighttpd, i.e. on the plain http(s) port. The field exists for
548
+ // reverse proxies and tests, it is empty in every normal install.
549
+ this.tlsEnabled = Boolean(config.tls);
550
+ this.inSecure = Boolean(config.inSecure);
551
+ this.metaPort = Number.parseInt(config.metaPort, 10) || (config.tls ? 443 : 80);
552
+ this.metaToken = config.metaToken || metaProvider.readLocalToken();
553
+ this.metaMode = false;
554
+ this.meta = null;
555
+ // #167: minutes between re-reads of channel names, rooms and
556
+ // functions. 0 switches it off.
557
+ this.regaMetaInterval =
558
+ config.regaMetaInterval === undefined ? 15 : Number.parseInt(config.regaMetaInterval, 10) || 0;
542
559
  this.regaInterval = Number.parseInt(config.regaInterval, 10);
543
560
  this.hadTimeout = new Set();
544
561
 
@@ -632,10 +649,27 @@ module.exports = function (RED) {
632
649
  });
633
650
 
634
651
  if (config.regaEnabled) {
635
- this.getRegaData().then(() => {
636
- this.regaPoll();
637
- this.initIfaces(config);
638
- });
652
+ this.lastRegaDataRefresh = now();
653
+ // B-17: ask the box once whether it speaks the openccu-lite
654
+ // metadata api. It does not on a CCU/RaspberryMatic/OpenCCU,
655
+ // where everything below runs exactly as before.
656
+ this.lastMetaProbe = now();
657
+ this.detectMeta()
658
+ .then((info) => {
659
+ if (info) {
660
+ return this.startMeta(info);
661
+ }
662
+
663
+ return this.getRegaData().then(() => {
664
+ this.regaPoll();
665
+ });
666
+ })
667
+ .catch((error) => {
668
+ this.logger.error('name sync ' + error.message);
669
+ })
670
+ .then(() => {
671
+ this.initIfaces(config);
672
+ });
639
673
  } else {
640
674
  this.initIfaces(config);
641
675
  }
@@ -814,6 +848,8 @@ module.exports = function (RED) {
814
848
  regaChannels: this.regaChannels,
815
849
  channelRooms: this.channelRooms,
816
850
  channelFunctions: this.channelFunctions,
851
+ rooms: this.rooms,
852
+ functions: this.functions,
817
853
  groups: this.groups,
818
854
  sysvar: this.sysvar,
819
855
  program: this.program,
@@ -877,6 +913,16 @@ module.exports = function (RED) {
877
913
  this.channelRooms = regadata.channelRooms;
878
914
  this.channelFunctions = regadata.channelFunctions;
879
915
  this.groups = regadata.groups;
916
+ // written since 4.4.0 - the editor's room/function pickers
917
+ // were empty until the first successful sync before that
918
+ if (Array.isArray(regadata.rooms)) {
919
+ this.rooms = regadata.rooms;
920
+ }
921
+
922
+ if (Array.isArray(regadata.functions)) {
923
+ this.functions = regadata.functions;
924
+ }
925
+
880
926
  /*
881
927
  this.sysvar = regadata.sysvar;
882
928
  Object.keys(this.sysvar).forEach(s => {
@@ -986,6 +1032,12 @@ module.exports = function (RED) {
986
1032
  this.cancelRegaPoll = true;
987
1033
  clearTimeout(this.regaPollTimeout);
988
1034
 
1035
+ if (this.meta) {
1036
+ this.logger.debug('stop metadata event stream');
1037
+ this.meta.stop();
1038
+ this.meta = null;
1039
+ }
1040
+
989
1041
  Object.keys(this.rpcPingTimer).forEach((iface) => {
990
1042
  this.logger.debug('clear rpcPingTimer', iface);
991
1043
  clearTimeout(this.rpcPingTimer[iface]);
@@ -1065,7 +1117,165 @@ module.exports = function (RED) {
1065
1117
  .then(() => this.getRegaFunctions())
1066
1118
  .then(() => this.getRegaValues())
1067
1119
  .then(() => this.getGroupsData())
1068
- .catch(this.logger.error);
1120
+ .catch((error) => {
1121
+ this.logger.error(error);
1122
+ this.recheckMeta();
1123
+ });
1124
+ }
1125
+
1126
+ /**
1127
+ * B-17: is this box an openccu-lite? `GET /api/meta/v1/version` needs
1128
+ * no credential and answers only there - a CCU replies 404 or HTML.
1129
+ * Never rejects; an unreachable box is simply "no".
1130
+ * @returns {Promise<object|null>}
1131
+ */
1132
+ detectMeta() {
1133
+ return metaProvider.detect({
1134
+ host: this.host,
1135
+ port: this.metaPort,
1136
+ tls: Boolean(this.tlsEnabled),
1137
+ insecure: Boolean(this.inSecure),
1138
+ logger: this.logger,
1139
+ });
1140
+ }
1141
+
1142
+ /**
1143
+ * Take names, rooms and functions from the openccu-lite metadata api
1144
+ * instead of from the ReGaHSS. Resolves as soon as the first snapshot
1145
+ * has been applied (or after 5 s, so a missing credential does not hold
1146
+ * up the interfaces).
1147
+ * @param {object} info the /version document
1148
+ * @returns {Promise<any>}
1149
+ */
1150
+ startMeta(info) {
1151
+ this.metaMode = true;
1152
+ this.lastMetaProbe = now();
1153
+ clearTimeout(this.regaPollTimeout);
1154
+ this.logger.info(
1155
+ 'openccu-lite detected (' +
1156
+ (info.implementation || 'meta api') +
1157
+ ', api version ' +
1158
+ info.version +
1159
+ ') - names, rooms and functions come from its metadata api',
1160
+ );
1161
+ this.logger.info(
1162
+ 'this box has no ReGaHSS: system variables, programs and HM-Script are not available (sysvar, program, poll and script nodes stay idle)',
1163
+ );
1164
+
1165
+ if (info.version > 1) {
1166
+ this.logger.warn(
1167
+ 'the box speaks metadata api version ' +
1168
+ info.version +
1169
+ ', this version of node-red-contrib-ccu implements version 1',
1170
+ );
1171
+ }
1172
+
1173
+ if (!this.metaToken) {
1174
+ this.logger.warn(
1175
+ 'no metadata api token: create one on the box (Users page) and enter it in the connection node, or run on the box where ' +
1176
+ metaProvider.LOCAL_TOKEN_FILE +
1177
+ ' is readable. Without it the nodes work with addresses only.',
1178
+ );
1179
+ }
1180
+
1181
+ return new Promise((resolve) => {
1182
+ const done = setTimeout(resolve, 5000);
1183
+ this.meta = new metaProvider.MetaProvider({
1184
+ host: this.host,
1185
+ port: this.metaPort,
1186
+ tls: Boolean(this.tlsEnabled),
1187
+ insecure: Boolean(this.inSecure),
1188
+ token: this.metaToken,
1189
+ logger: this.logger,
1190
+ onNames: (names) => {
1191
+ this.applyMetaNames(names);
1192
+ clearTimeout(done);
1193
+ resolve();
1194
+ },
1195
+ onStatus: (connected) => {
1196
+ this.setIfaceStatus('ReGaHSS', connected);
1197
+ },
1198
+ onGone: () => {
1199
+ this.metaMode = false;
1200
+ this.meta = null;
1201
+ this.lastMetaProbe = 0;
1202
+ this.logger.warn('this box no longer answers as openccu-lite - detecting again');
1203
+ this.detectMeta().then((again) => {
1204
+ if (again) {
1205
+ return this.startMeta(again);
1206
+ }
1207
+
1208
+ return this.getRegaData().then(() => this.regaPoll());
1209
+ });
1210
+ },
1211
+ });
1212
+ this.meta.start();
1213
+ });
1214
+ }
1215
+
1216
+ /**
1217
+ * The metadata api's answer, in the shape the rest of the code expects
1218
+ * from the ReGa: names by address, rooms and functions as arrays of
1219
+ * names. Called for the snapshot and again for every change event.
1220
+ * @param {object} names
1221
+ */
1222
+ applyMetaNames(names) {
1223
+ this.channelNames = names.channelNames;
1224
+ this.channelRooms = names.channelRooms;
1225
+ this.channelFunctions = names.channelFunctions;
1226
+ this.rooms = names.rooms;
1227
+ this.functions = names.functions;
1228
+ this.logger.debug(
1229
+ 'meta revision ' +
1230
+ names.revision +
1231
+ ': ' +
1232
+ Object.keys(names.channelNames).length +
1233
+ ' names, ' +
1234
+ names.rooms.length +
1235
+ ' rooms, ' +
1236
+ names.functions.length +
1237
+ ' functions',
1238
+ );
1239
+ this.saveRegadata();
1240
+ }
1241
+
1242
+ /**
1243
+ * The ReGa did not answer. Maybe this box became an openccu-lite (a
1244
+ * restored backup, a firmware swap) - probe again, at most every five
1245
+ * minutes, so a box that changes underneath a running Node-RED is
1246
+ * picked up without a redeploy.
1247
+ */
1248
+ recheckMeta() {
1249
+ if (this.metaMode || this.cancelRegaPoll || this.metaRecheckPending) {
1250
+ return;
1251
+ }
1252
+
1253
+ if (now() - (this.lastMetaProbe || 0) < 300000) {
1254
+ return;
1255
+ }
1256
+
1257
+ this.lastMetaProbe = now();
1258
+ this.metaRecheckPending = true;
1259
+ this.detectMeta()
1260
+ .then((info) => {
1261
+ this.metaRecheckPending = false;
1262
+ if (info && !this.metaMode) {
1263
+ return this.startMeta(info);
1264
+ }
1265
+ })
1266
+ .catch(() => {
1267
+ this.metaRecheckPending = false;
1268
+ });
1269
+ }
1270
+
1271
+ /**
1272
+ * The error a ReGa-only feature answers with on openccu-lite. The
1273
+ * nodes stay in the palette and in the flow, every message gets this.
1274
+ * @param {string} feature
1275
+ * @returns {Error}
1276
+ */
1277
+ regaMissingError(feature) {
1278
+ return new Error(feature + ' are not available on this box (openccu-lite has no ReGaHSS)');
1069
1279
  }
1070
1280
 
1071
1281
  /**
@@ -1084,7 +1294,14 @@ module.exports = function (RED) {
1084
1294
  const ts = dp.ts;
1085
1295
  const [iface, channel, datapoint] = dp.name.split('.');
1086
1296
  if (this.enabledIfaces.includes(iface) && datapoint) {
1087
- if (['RSSI_DEVICE', 'RSSI_PEER'].includes(datapoint)) {
1297
+ if (
1298
+ ['RSSI_DEVICE', 'RSSI_PEER'].includes(datapoint) &&
1299
+ typeof dp.value === 'number' &&
1300
+ dp.value > 127
1301
+ ) {
1302
+ // the ReGa reports the unsigned byte; without
1303
+ // the guard an already-signed value or the 0
1304
+ // for "never received" became -318 / -256 (#183)
1088
1305
  dp.value -= 256;
1089
1306
  }
1090
1307
 
@@ -1218,6 +1435,11 @@ module.exports = function (RED) {
1218
1435
  */
1219
1436
  programActive(name, active) {
1220
1437
  return new Promise((resolve, reject) => {
1438
+ if (this.metaMode) {
1439
+ reject(this.regaMissingError('programs'));
1440
+ return;
1441
+ }
1442
+
1221
1443
  const program = this.program[name];
1222
1444
  if (program) {
1223
1445
  const script = `dom.GetObject(${program.id}).Active(${active});`;
@@ -1245,6 +1467,11 @@ module.exports = function (RED) {
1245
1467
  */
1246
1468
  programExecute(name) {
1247
1469
  return new Promise((resolve, reject) => {
1470
+ if (this.metaMode) {
1471
+ reject(this.regaMissingError('programs'));
1472
+ return;
1473
+ }
1474
+
1248
1475
  const program = this.program[name];
1249
1476
  if (program) {
1250
1477
  const d = new Date();
@@ -1279,6 +1506,11 @@ module.exports = function (RED) {
1279
1506
  */
1280
1507
  setVariable(name, value) {
1281
1508
  return new Promise((resolve, reject) => {
1509
+ if (this.metaMode) {
1510
+ reject(this.regaMissingError('system variables'));
1511
+ return;
1512
+ }
1513
+
1282
1514
  if (!this.hasRegaVariables) {
1283
1515
  this.logger.debug('variables not yet known. defer setVariable ' + name);
1284
1516
  clearTimeout(this.setVariableQueueTimeout[name]);
@@ -1319,8 +1551,19 @@ module.exports = function (RED) {
1319
1551
  */
1320
1552
  regaPoll() {
1321
1553
  //this.logger.trace('regaPoll');
1554
+ if (this.metaMode) {
1555
+ // nothing to poll: no variables, no programs, and names arrive
1556
+ // over the metadata api's event stream
1557
+ return;
1558
+ }
1559
+
1322
1560
  if (this.regaPollPending) {
1323
- this.logger.warn('rega poll already pending');
1561
+ // #166: writing several variables at once used to lose all but
1562
+ // the first immediate re-poll, so their new values only showed
1563
+ // up at the next scheduled one (30 s by default). Remember the
1564
+ // request and run exactly one more poll afterwards.
1565
+ this.logger.debug('rega poll already pending, will repeat');
1566
+ this.regaPollAgain = true;
1324
1567
  } else {
1325
1568
  this.regaPollPending = true;
1326
1569
  clearTimeout(this.regaPollTimeout);
@@ -1342,6 +1585,14 @@ module.exports = function (RED) {
1342
1585
 
1343
1586
  this.firstRegaPollDone = true;
1344
1587
  this.regaPollPending = false;
1588
+
1589
+ if (this.regaPollAgain && !this.cancelRegaPoll) {
1590
+ this.regaPollAgain = false;
1591
+ this.regaPoll();
1592
+ } else {
1593
+ this.regaPollAgain = false;
1594
+ this.refreshRegaDataIfDue();
1595
+ }
1345
1596
  });
1346
1597
  }
1347
1598
  }
@@ -1481,6 +1732,38 @@ module.exports = function (RED) {
1481
1732
  * Poll ReGaHSS variables and call subscription callbacks
1482
1733
  * @returns {Promise}
1483
1734
  */
1735
+ /**
1736
+ * Re-read the ReGa metadata that only ever got fetched once, in the
1737
+ * constructor: channel names, rooms and functions. A room or function
1738
+ * added in the CCU WebUI was invisible until the flow was redeployed
1739
+ * (#167). Cheap enough for a slow schedule - three small scripts.
1740
+ * @returns {Promise<any>}
1741
+ */
1742
+ refreshRegaData() {
1743
+ this.lastRegaDataRefresh = now();
1744
+ this.logger.debug('refreshRegaData');
1745
+ return this.getRegaChannels()
1746
+ .then(() => this.getRegaRooms())
1747
+ .then(() => this.getRegaFunctions())
1748
+ .then(() => this.saveRegadata())
1749
+ .catch((error) => {
1750
+ this.logger.error('refreshRegaData', error);
1751
+ this.recheckMeta();
1752
+ });
1753
+ }
1754
+
1755
+ /** Runs refreshRegaData() when the configured interval has elapsed. */
1756
+ refreshRegaDataIfDue() {
1757
+ if (!this.regaMetaInterval || this.cancelRegaPoll) {
1758
+ return;
1759
+ }
1760
+
1761
+ const elapsed = now() - (this.lastRegaDataRefresh || 0);
1762
+ if (elapsed >= this.regaMetaInterval * 60000) {
1763
+ this.refreshRegaData();
1764
+ }
1765
+ }
1766
+
1484
1767
  getRegaVariables() {
1485
1768
  return new Promise((resolve, reject) => {
1486
1769
  this.logger.debug('getRegaVariables');
@@ -1766,6 +2049,10 @@ module.exports = function (RED) {
1766
2049
  * @param iface
1767
2050
  */
1768
2051
  rpcCheckInit(iface) {
2052
+ if (!this.rpcPingEnabled) {
2053
+ return;
2054
+ }
2055
+
1769
2056
  if (!this.metadata.devices[iface] || !Object.keys(this.metadata.devices[iface]).length > 0) {
1770
2057
  return;
1771
2058
  }
@@ -2509,6 +2796,7 @@ module.exports = function (RED) {
2509
2796
  'change',
2510
2797
  'cache',
2511
2798
  'stable',
2799
+ 'uncertain',
2512
2800
  'iface',
2513
2801
  'device',
2514
2802
  'deviceType',
@@ -2714,6 +3002,15 @@ module.exports = function (RED) {
2714
3002
  let matchChange;
2715
3003
  let matchStable;
2716
3004
 
3005
+ // Checked before the attribute loop below: that loop may break
3006
+ // early for a whitelisted datapoint, and this decision has to be
3007
+ // made for every message regardless of key order. Only present in
3008
+ // the filter when a node opted in, so existing subscriptions keep
3009
+ // receiving values the ReGa could not timestamp (#96).
3010
+ if (filter && filter.uncertain === false && message.uncertain) {
3011
+ return false;
3012
+ }
3013
+
2717
3014
  if (filter) {
2718
3015
  const arrayAttr = Object.keys(filter);
2719
3016
 
@@ -2753,6 +3050,11 @@ module.exports = function (RED) {
2753
3050
  continue;
2754
3051
  }
2755
3052
 
3053
+ if (attr === 'uncertain') {
3054
+ // already decided above, and not a value to compare
3055
+ continue;
3056
+ }
3057
+
2756
3058
  if (this.callbackWhitelists[message.datapointName].has(id)) {
2757
3059
  if (matchCache && matchChange && matchStable) {
2758
3060
  break;
@@ -3058,6 +3360,11 @@ module.exports = function (RED) {
3058
3360
  */
3059
3361
  script(script) {
3060
3362
  return new Promise((resolve, reject) => {
3363
+ if (this.metaMode) {
3364
+ reject(this.regaMissingError('HM-Script and ReGaHSS scripts'));
3365
+ return;
3366
+ }
3367
+
3061
3368
  execToCallback(this.rega.exec(script), (err, payload, objects) => {
3062
3369
  if (err) {
3063
3370
  reject(err);
@@ -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">