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.
- package/.prettierignore +3 -0
- package/CHANGELOG.md +72 -0
- package/HANDOFF.md +54 -12
- package/README.md +42 -0
- package/ROADMAP.md +19 -15
- package/nodes/ccu-connection.html +25 -0
- package/nodes/ccu-connection.js +252 -11
- package/nodes/ccu-mqtt.js +12 -3
- package/nodes/ccu-poll.js +8 -1
- package/nodes/lib/metaprovider.js +716 -0
- package/package.json +1 -1
- package/roadmap-archive/B-17.md +106 -0
- package/roadmap-archive/README.md +3 -0
package/nodes/ccu-connection.js
CHANGED
|
@@ -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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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(
|
|
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) =>
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
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
|
|
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
|
|
417
|
+
this.ccu
|
|
418
|
+
.programActive(filter.name, payload)
|
|
419
|
+
.catch((error) => this.error('programActive ' + filter.name + ': ' + error.message));
|
|
413
420
|
} else {
|
|
414
|
-
this.ccu
|
|
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.
|
|
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
|
}
|