node-red-contrib-ccu 4.3.0 → 4.4.1

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.
@@ -14,6 +14,7 @@ const {bestMatch} = require('./lib/similarity.js');
14
14
  const nextport = require('./lib/nextport.js');
15
15
  const hmDiscover = require('./lib/discover.js');
16
16
  const {Rega} = require('homematic-rega'); // ES module - require(esm) needs Node >= 20.19 / >= 22.12
17
+ const metaProvider = require('./lib/metaprovider.js');
17
18
  const xmlrpc = require('homematic-xmlrpc');
18
19
  const binrpc = require('binrpc');
19
20
 
@@ -542,6 +543,15 @@ module.exports = function (RED) {
542
543
 
543
544
  this.regaEnabled = config.regaEnabled;
544
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;
545
555
  // #167: minutes between re-reads of channel names, rooms and
546
556
  // functions. 0 switches it off.
547
557
  this.regaMetaInterval =
@@ -640,10 +650,26 @@ module.exports = function (RED) {
640
650
 
641
651
  if (config.regaEnabled) {
642
652
  this.lastRegaDataRefresh = now();
643
- this.getRegaData().then(() => {
644
- this.regaPoll();
645
- this.initIfaces(config);
646
- });
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
+ });
647
673
  } else {
648
674
  this.initIfaces(config);
649
675
  }
@@ -822,6 +848,8 @@ module.exports = function (RED) {
822
848
  regaChannels: this.regaChannels,
823
849
  channelRooms: this.channelRooms,
824
850
  channelFunctions: this.channelFunctions,
851
+ rooms: this.rooms,
852
+ functions: this.functions,
825
853
  groups: this.groups,
826
854
  sysvar: this.sysvar,
827
855
  program: this.program,
@@ -885,6 +913,16 @@ module.exports = function (RED) {
885
913
  this.channelRooms = regadata.channelRooms;
886
914
  this.channelFunctions = regadata.channelFunctions;
887
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
+
888
926
  /*
889
927
  this.sysvar = regadata.sysvar;
890
928
  Object.keys(this.sysvar).forEach(s => {
@@ -994,6 +1032,12 @@ module.exports = function (RED) {
994
1032
  this.cancelRegaPoll = true;
995
1033
  clearTimeout(this.regaPollTimeout);
996
1034
 
1035
+ if (this.meta) {
1036
+ this.logger.debug('stop metadata event stream');
1037
+ this.meta.stop();
1038
+ this.meta = null;
1039
+ }
1040
+
997
1041
  Object.keys(this.rpcPingTimer).forEach((iface) => {
998
1042
  this.logger.debug('clear rpcPingTimer', iface);
999
1043
  clearTimeout(this.rpcPingTimer[iface]);
@@ -1073,7 +1117,165 @@ module.exports = function (RED) {
1073
1117
  .then(() => this.getRegaFunctions())
1074
1118
  .then(() => this.getRegaValues())
1075
1119
  .then(() => this.getGroupsData())
1076
- .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)');
1077
1279
  }
1078
1280
 
1079
1281
  /**
@@ -1233,6 +1435,11 @@ module.exports = function (RED) {
1233
1435
  */
1234
1436
  programActive(name, active) {
1235
1437
  return new Promise((resolve, reject) => {
1438
+ if (this.metaMode) {
1439
+ reject(this.regaMissingError('programs'));
1440
+ return;
1441
+ }
1442
+
1236
1443
  const program = this.program[name];
1237
1444
  if (program) {
1238
1445
  const script = `dom.GetObject(${program.id}).Active(${active});`;
@@ -1260,6 +1467,11 @@ module.exports = function (RED) {
1260
1467
  */
1261
1468
  programExecute(name) {
1262
1469
  return new Promise((resolve, reject) => {
1470
+ if (this.metaMode) {
1471
+ reject(this.regaMissingError('programs'));
1472
+ return;
1473
+ }
1474
+
1263
1475
  const program = this.program[name];
1264
1476
  if (program) {
1265
1477
  const d = new Date();
@@ -1294,6 +1506,11 @@ module.exports = function (RED) {
1294
1506
  */
1295
1507
  setVariable(name, value) {
1296
1508
  return new Promise((resolve, reject) => {
1509
+ if (this.metaMode) {
1510
+ reject(this.regaMissingError('system variables'));
1511
+ return;
1512
+ }
1513
+
1297
1514
  if (!this.hasRegaVariables) {
1298
1515
  this.logger.debug('variables not yet known. defer setVariable ' + name);
1299
1516
  clearTimeout(this.setVariableQueueTimeout[name]);
@@ -1334,6 +1551,12 @@ module.exports = function (RED) {
1334
1551
  */
1335
1552
  regaPoll() {
1336
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
+
1337
1560
  if (this.regaPollPending) {
1338
1561
  // #166: writing several variables at once used to lose all but
1339
1562
  // the first immediate re-poll, so their new values only showed
@@ -1370,7 +1593,10 @@ module.exports = function (RED) {
1370
1593
  this.regaPollAgain = false;
1371
1594
  this.refreshRegaDataIfDue();
1372
1595
  }
1373
- });
1596
+ })
1597
+ // a throw inside the finally above would otherwise be an
1598
+ // unhandled rejection, see #601
1599
+ .catch((error) => this.logger.error('regaPoll', error));
1374
1600
  }
1375
1601
  }
1376
1602
 
@@ -1523,7 +1749,10 @@ module.exports = function (RED) {
1523
1749
  .then(() => this.getRegaRooms())
1524
1750
  .then(() => this.getRegaFunctions())
1525
1751
  .then(() => this.saveRegadata())
1526
- .catch((error) => this.logger.error('refreshRegaData', error));
1752
+ .catch((error) => {
1753
+ this.logger.error('refreshRegaData', error);
1754
+ this.recheckMeta();
1755
+ });
1527
1756
  }
1528
1757
 
1529
1758
  /** Runs refreshRegaData() when the configured interval has elapsed. */
@@ -1558,10 +1787,17 @@ module.exports = function (RED) {
1558
1787
  Object.keys(this.setVariableQueueTimeout).forEach((name) =>
1559
1788
  clearTimeout(this.setVariableQueueTimeout[name]),
1560
1789
  );
1561
- Object.keys(this.setVariableQueue).reduce(
1562
- (p, name) => p.then((_) => this.setVariable(name, this.setVariableQueue[name])),
1563
- Promise.resolve(),
1564
- );
1790
+ // #601: a rejected write here used to be an
1791
+ // unhandled rejection, which Node >= 15 turns into
1792
+ // an uncaught exception - it killed the whole
1793
+ // process seconds after start whenever ReGaHSS
1794
+ // dropped the connection (RedMatic #601).
1795
+ Object.keys(this.setVariableQueue)
1796
+ .reduce(
1797
+ (p, name) => p.then((_) => this.setVariable(name, this.setVariableQueue[name])),
1798
+ Promise.resolve(),
1799
+ )
1800
+ .catch((error) => this.logger.error('deferred setVariable', error));
1565
1801
  }
1566
1802
 
1567
1803
  resolve();
@@ -3134,6 +3370,11 @@ module.exports = function (RED) {
3134
3370
  */
3135
3371
  script(script) {
3136
3372
  return new Promise((resolve, reject) => {
3373
+ if (this.metaMode) {
3374
+ reject(this.regaMissingError('HM-Script and ReGaHSS scripts'));
3375
+ return;
3376
+ }
3377
+
3137
3378
  execToCallback(this.rega.exec(script), (err, payload, objects) => {
3138
3379
  if (err) {
3139
3380
  reject(err);
package/nodes/ccu-mqtt.js CHANGED
@@ -405,13 +405,22 @@ module.exports = function (RED) {
405
405
  return;
406
406
  }
407
407
 
408
+ // #601: these promises used to float - a rejection (e.g. ReGaHSS
409
+ // closing the connection) became an unhandled rejection and took
410
+ // the whole Node-RED process down.
408
411
  if (this.ccu.sysvar[filter.name]) {
409
- this.ccu.setVariable(filter.name, payload);
412
+ this.ccu
413
+ .setVariable(filter.name, payload)
414
+ .catch((error) => this.error('setVariable ' + filter.name + ': ' + error.message));
410
415
  } else if (this.ccu.program[filter.name]) {
411
416
  if (typeof payload === 'boolean') {
412
- this.ccu.programActive(filter.name, payload);
417
+ this.ccu
418
+ .programActive(filter.name, payload)
419
+ .catch((error) => this.error('programActive ' + filter.name + ': ' + error.message));
413
420
  } else {
414
- this.ccu.programExecute(filter.name);
421
+ this.ccu
422
+ .programExecute(filter.name)
423
+ .catch((error) => this.error('programExecute ' + filter.name + ': ' + error.message));
415
424
  }
416
425
  } else {
417
426
  this.error('no sysvar or program with name ' + filter.name + ' found');
package/nodes/ccu-poll.js CHANGED
@@ -17,7 +17,14 @@ module.exports = function (RED) {
17
17
  this.ccu.register(this);
18
18
 
19
19
  this.on('input', (message, send, done) => {
20
- this.ccu.regaPoll(); // TODO catch errors
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
+
26
+ // regaPoll() returns nothing and handles its own errors (#601)
27
+ this.ccu.regaPoll();
21
28
  done();
22
29
  });
23
30
  }