hm-simulator 1.2.0 → 1.3.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/CHANGELOG.md +33 -0
- package/README.md +98 -34
- package/lib/control.js +1 -0
- package/lib/faults.d.ts +48 -0
- package/lib/method-help.js +14 -0
- package/lib/sim.js +373 -11
- package/package.json +10 -4
- package/sim.d.mts +5 -0
- package/sim.d.ts +481 -0
package/lib/sim.js
CHANGED
|
@@ -45,6 +45,15 @@ const CALLBACK_LOG_SIZE = 10000;
|
|
|
45
45
|
/** the interfaces that speak the BidCos protocol, and therefore have setTempKey */
|
|
46
46
|
const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
|
|
47
47
|
|
|
48
|
+
/** FLAGS bit of a link whose sender rfd counts as broken (bit 2 is the receiver) */
|
|
49
|
+
const LINK_SENDER_BROKEN = 1;
|
|
50
|
+
|
|
51
|
+
/** The device part of a channel address: `ABC0000001:1` -> `ABC0000001`. */
|
|
52
|
+
function deviceOf(address) {
|
|
53
|
+
const colon = String(address).indexOf(':');
|
|
54
|
+
return colon === -1 ? String(address) : String(address).slice(0, colon);
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
/** device types of the HmIP radio modules, which carry DUTY_CYCLE_LEVEL and CARRIER_SENSE_LEVEL */
|
|
49
58
|
const RADIO_MODULE_TYPES = new Set(['RPI-RF-MOD', 'HmIP-RFUSB', 'HmIP-RFUSB-TK', 'HMIP-CCU3', 'HmIP-CCU3']);
|
|
50
59
|
|
|
@@ -113,6 +122,9 @@ const INTERFACE_DEFAULTS = {
|
|
|
113
122
|
// rfd answers the empty string instead of an empty array when it has no service message; the
|
|
114
123
|
// real shape, off by default because it breaks every consumer that assumes an array
|
|
115
124
|
serviceMessagesEmptyAsString: false,
|
|
125
|
+
// answer getServiceMessages with the unknown-method fault, as VirtualDevices, CUxD and
|
|
126
|
+
// hmipserver were seen to on 3.89.x ("Invalid XML-RPC message"); off by default
|
|
127
|
+
getServiceMessagesFault: false,
|
|
116
128
|
};
|
|
117
129
|
|
|
118
130
|
/**
|
|
@@ -121,11 +133,15 @@ const INTERFACE_DEFAULTS = {
|
|
|
121
133
|
*/
|
|
122
134
|
const MEASURED_INTERFACE_DEFAULTS = {
|
|
123
135
|
// hmipserver answers ping without a PONG event, see https://github.com/eq-3/occu/issues/42
|
|
124
|
-
hmip: {configPendingMode: 'hmip', pong: false},
|
|
125
|
-
rfd: {configPendingMode: 'bidcos'},
|
|
126
|
-
|
|
136
|
+
hmip: {configPendingMode: 'hmip', pong: false, version: '3.89.11.20260919'},
|
|
137
|
+
rfd: {configPendingMode: 'bidcos', version: '2.6.0'},
|
|
138
|
+
// hs485d's getVersion was not measured; rfd's answer stands in
|
|
139
|
+
wired: {configPendingMode: 'bidcos', version: '2.6.0'},
|
|
127
140
|
};
|
|
128
141
|
|
|
142
|
+
/** the interfaces with the metadata methods: rfd and hs485d fault on a key that is not set, hmipserver answers '' */
|
|
143
|
+
const METADATA_INTERFACES = new Set(['rfd', 'wired', 'hmip']);
|
|
144
|
+
|
|
129
145
|
/** what listBidcosInterfaces answers when the `bidcosInterfaces` option does not say otherwise */
|
|
130
146
|
const DEFAULT_BIDCOS_INTERFACES = {
|
|
131
147
|
rfd: [
|
|
@@ -265,6 +281,16 @@ class HmSim {
|
|
|
265
281
|
this.unreachable = {};
|
|
266
282
|
/** what listBidcosInterfaces reports on top of the configured answer: {<iface>: {DUTY_CYCLE, ...}} */
|
|
267
283
|
this.radio = {};
|
|
284
|
+
/** a device that holds another system's key: {<iface>: {serial, key, devices, delay}}, see scriptKeyMismatch() */
|
|
285
|
+
this.keyMismatchScript = {};
|
|
286
|
+
/** the serial getKeyMismatchDevice answers: {<iface>: serial} */
|
|
287
|
+
this.keyMismatch = {};
|
|
288
|
+
/** suppressed service messages (hmipserver): {<iface>: Map<channelAddress, Set<parameter>>} */
|
|
289
|
+
this.suppressed = {};
|
|
290
|
+
/** metadata per interface and address: {<iface>: {<address>: {<key>: value}}} */
|
|
291
|
+
this.metadata = structuredClone(options.metadata || {});
|
|
292
|
+
/** every setInterfaceClock call: [{iface, utc, offset, ts}] */
|
|
293
|
+
this.interfaceClocks = [];
|
|
268
294
|
|
|
269
295
|
this.rpcMethods = this.buildRpcMethods();
|
|
270
296
|
|
|
@@ -550,7 +576,8 @@ class HmSim {
|
|
|
550
576
|
if (!description[datapoint]) {
|
|
551
577
|
throw this.fault('unknownParameter', `${address}/${datapoint}`);
|
|
552
578
|
}
|
|
553
|
-
|
|
579
|
+
const value = values[datapoint] === undefined ? defaultValue(description[datapoint]) : values[datapoint];
|
|
580
|
+
return this.reportedValue(iface, address, datapoint, value);
|
|
554
581
|
}
|
|
555
582
|
|
|
556
583
|
/**
|
|
@@ -563,7 +590,14 @@ class HmSim {
|
|
|
563
590
|
throw this.fault('invalidArguments', 'paramset key');
|
|
564
591
|
}
|
|
565
592
|
const {values} = this.storedParamset(iface, address, key);
|
|
566
|
-
|
|
593
|
+
if (key !== 'VALUES' || !this.suppressed[iface] || !this.suppressed[iface].has(address)) {
|
|
594
|
+
return {...values};
|
|
595
|
+
}
|
|
596
|
+
const reported = {};
|
|
597
|
+
for (const [name, value] of Object.entries(values)) {
|
|
598
|
+
reported[name] = this.reportedValue(iface, address, name, value);
|
|
599
|
+
}
|
|
600
|
+
return reported;
|
|
567
601
|
}
|
|
568
602
|
|
|
569
603
|
/**
|
|
@@ -872,14 +906,24 @@ class HmSim {
|
|
|
872
906
|
links = links.filter((link) => addresses.has(link.SENDER) || addresses.has(link.RECEIVER));
|
|
873
907
|
}
|
|
874
908
|
|
|
875
|
-
|
|
909
|
+
// rfd (measured, firmware 3.89.11): the list of *all* links carries FLAGS bit 1 (SENDER_BROKEN)
|
|
910
|
+
// on every device-internal link - a relay's own button on the relay, a dimmer's channel on its
|
|
911
|
+
// virtual channels - although the link works; asked for one address, the same link comes with
|
|
912
|
+
// its stored flags, 0. The simulator does the same on rfd, whatever `flags` asks for.
|
|
913
|
+
const internalBroken = iface === 'rfd' && !address;
|
|
914
|
+
return links.map((link) => this.linkStruct(iface, link, flags, internalBroken));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Sender and receiver on one device: `ABC0000001:1` and `ABC0000001:2`, or the same channel. */
|
|
918
|
+
isInternalLink(link) {
|
|
919
|
+
return deviceOf(link.SENDER) === deviceOf(link.RECEIVER);
|
|
876
920
|
}
|
|
877
921
|
|
|
878
|
-
linkStruct(iface, link, flags) {
|
|
922
|
+
linkStruct(iface, link, flags, internalBroken = false) {
|
|
879
923
|
const result = {
|
|
880
924
|
SENDER: link.SENDER,
|
|
881
925
|
RECEIVER: link.RECEIVER,
|
|
882
|
-
FLAGS: link.FLAGS || 0,
|
|
926
|
+
FLAGS: (link.FLAGS || 0) | (internalBroken && this.isInternalLink(link) ? LINK_SENDER_BROKEN : 0),
|
|
883
927
|
NAME: link.NAME || '',
|
|
884
928
|
DESCRIPTION: link.DESCRIPTION || '',
|
|
885
929
|
};
|
|
@@ -1065,6 +1109,9 @@ class HmSim {
|
|
|
1065
1109
|
* plus what was raised through the scenario API.
|
|
1066
1110
|
*/
|
|
1067
1111
|
getServiceMessages(iface) {
|
|
1112
|
+
if (this.interfaceOption(iface, 'getServiceMessagesFault')) {
|
|
1113
|
+
throw this.fault('unknownMethod', `${iface}/getServiceMessages`);
|
|
1114
|
+
}
|
|
1068
1115
|
const messages = [];
|
|
1069
1116
|
for (const address of Object.keys(this.values[iface])) {
|
|
1070
1117
|
const description = this.getParamsetDescription(iface, address, 'VALUES');
|
|
@@ -1073,13 +1120,16 @@ class HmSim {
|
|
|
1073
1120
|
}
|
|
1074
1121
|
for (const [name, value] of Object.entries(this.values[iface][address].VALUES || {})) {
|
|
1075
1122
|
const parameter = description[name];
|
|
1076
|
-
if (parameter && parameter.FLAGS & SERVICE_FLAG && value) {
|
|
1123
|
+
if (parameter && parameter.FLAGS & SERVICE_FLAG && value && !this.isSuppressed(iface, address, name)) {
|
|
1077
1124
|
messages.push([address, name, value]);
|
|
1078
1125
|
}
|
|
1079
1126
|
}
|
|
1080
1127
|
}
|
|
1081
1128
|
|
|
1082
1129
|
for (const message of this.serviceMessages[iface] || []) {
|
|
1130
|
+
if (this.isSuppressed(iface, message[0], message[1])) {
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1083
1133
|
if (!messages.some((entry) => entry[0] === message[0] && entry[1] === message[1])) {
|
|
1084
1134
|
messages.push([...message]);
|
|
1085
1135
|
}
|
|
@@ -1115,6 +1165,297 @@ class HmSim {
|
|
|
1115
1165
|
this.timer(() => this.addDevices(iface, scripted.devices), scripted.delay);
|
|
1116
1166
|
}
|
|
1117
1167
|
|
|
1168
|
+
const mismatch = this.keyMismatchScript[iface];
|
|
1169
|
+
if (mismatch) {
|
|
1170
|
+
this.timer(() => this.hearKeyMismatchDevice(iface, mismatch), mismatch.delay);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
return '';
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/* ------------------------------------------------------------------ key mismatch */
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* A device that holds another system's security key comes into reach of the next install mode
|
|
1180
|
+
* (or of an `addDevice` with its serial) - the case of Homematic Manager task 76.
|
|
1181
|
+
*
|
|
1182
|
+
* rfd hears the device but cannot pair it: nothing joins and nobody is told; the serial is only
|
|
1183
|
+
* what `getKeyMismatchDevice` answers. Once the temporary key (`setTempKey`) is the device's
|
|
1184
|
+
* `key`, the next install mode pairs it: `devices` (the description and its channels) join with
|
|
1185
|
+
* `newDevices`, and the script is used up. Without `devices` the device is never paired.
|
|
1186
|
+
*
|
|
1187
|
+
* @param {string} iface
|
|
1188
|
+
* @param {string} serial
|
|
1189
|
+
* @param {object} [script]
|
|
1190
|
+
* @param {string} [script.key=''] the passphrase the device was taught in with
|
|
1191
|
+
* @param {Array} [script.devices=[]] its description and channel descriptions
|
|
1192
|
+
* @param {number} [script.delay=0] milliseconds between the install mode and the device being heard
|
|
1193
|
+
*/
|
|
1194
|
+
scriptKeyMismatch(iface, serial, {key = '', devices = [], delay = 0} = {}) {
|
|
1195
|
+
if (typeof serial !== 'string' || serial === '') {
|
|
1196
|
+
throw new TypeError('scriptKeyMismatch wants a serial');
|
|
1197
|
+
}
|
|
1198
|
+
this.keyMismatchScript[iface] = {serial, key: String(key), devices: devices || [], delay};
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/** The scripted device is heard: paired when the temporary key is its own, else a key mismatch. */
|
|
1202
|
+
hearKeyMismatchDevice(iface, script) {
|
|
1203
|
+
if (this.keyMismatchScript[iface] !== script) {
|
|
1204
|
+
return undefined;
|
|
1205
|
+
}
|
|
1206
|
+
if (script.key !== '' && this.getTempKey(iface) === script.key && script.devices.length > 0) {
|
|
1207
|
+
delete this.keyMismatchScript[iface];
|
|
1208
|
+
this.addDevices(iface, script.devices);
|
|
1209
|
+
this.log.info(iface, script.serial, 'paired with the temporary key');
|
|
1210
|
+
return this.index[iface].get(script.devices[0].ADDRESS) || script.devices[0];
|
|
1211
|
+
}
|
|
1212
|
+
this.keyMismatch[iface] = script.serial;
|
|
1213
|
+
this.log.info(iface, script.serial, 'heard, but it holds another key');
|
|
1214
|
+
return undefined;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* `getKeyMismatchDevice(reset)` - the serial of the last device that could not be paired
|
|
1219
|
+
* because it holds another key, `''` when there is none (measured on rfd and hmipserver,
|
|
1220
|
+
* firmware 3.89.11). `true` clears it after the answer.
|
|
1221
|
+
*/
|
|
1222
|
+
getKeyMismatchDevice(iface, params) {
|
|
1223
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1224
|
+
throw this.fault('unknownMethod', `${iface}/getKeyMismatchDevice`);
|
|
1225
|
+
}
|
|
1226
|
+
const [reset = false] = params || [];
|
|
1227
|
+
const serial = this.keyMismatch[iface] || '';
|
|
1228
|
+
if (reset) {
|
|
1229
|
+
delete this.keyMismatch[iface];
|
|
1230
|
+
}
|
|
1231
|
+
return serial;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* `addDevice(serial)` - rfd pairs one device by its serial. The simulator knows only the device
|
|
1236
|
+
* of scriptKeyMismatch(): with the right temporary key it joins and its description is the
|
|
1237
|
+
* answer; otherwise it is the key mismatch and the call faults. Any other serial is not in reach
|
|
1238
|
+
* (`unknownInstance`). BidCos only.
|
|
1239
|
+
*/
|
|
1240
|
+
addDeviceBySerial(iface, params) {
|
|
1241
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1242
|
+
throw this.fault('unknownMethod', `${iface}/addDevice`);
|
|
1243
|
+
}
|
|
1244
|
+
const [serial] = params || [];
|
|
1245
|
+
const script = this.keyMismatchScript[iface];
|
|
1246
|
+
if (!script || script.serial !== serial) {
|
|
1247
|
+
throw this.fault('unknownInstance', `${iface}/${serial}`);
|
|
1248
|
+
}
|
|
1249
|
+
const paired = this.hearKeyMismatchDevice(iface, script);
|
|
1250
|
+
if (!paired) {
|
|
1251
|
+
throw this.fault('notSupported', `${serial} holds another key`);
|
|
1252
|
+
}
|
|
1253
|
+
return paired;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/* ------------------------------------------------------------------ service message suppression */
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* `suppressServiceMessages(channelAddress, parameter, suppress)` - hmipserver only (eQ-3's HmIP
|
|
1260
|
+
* addendum; rfd does not have it). `parameter` is one of the channel's service parameters, or
|
|
1261
|
+
* `''` for all of them; a parameter without the service flag is ignored. Measured on firmware
|
|
1262
|
+
* 3.89.11: answered with an empty value, `''` suppressed every service parameter of the
|
|
1263
|
+
* channel, `LEVEL` nothing. A suppressed message is left out of `getServiceMessages`, and the
|
|
1264
|
+
* datapoint reports the value that raises no message (`false`, `0`) - the addendum's words; that
|
|
1265
|
+
* a change of the suppression sends that value as an event is the simulator's model.
|
|
1266
|
+
*/
|
|
1267
|
+
suppressServiceMessages(iface, params) {
|
|
1268
|
+
if (iface !== 'hmip') {
|
|
1269
|
+
throw this.fault('unknownMethod', `${iface}/suppressServiceMessages`);
|
|
1270
|
+
}
|
|
1271
|
+
const [address, parameter = '', suppress = true] = params || [];
|
|
1272
|
+
if (typeof address !== 'string' || typeof parameter !== 'string') {
|
|
1273
|
+
throw this.fault('invalidArguments', 'suppressServiceMessages wants a channel address and a parameter');
|
|
1274
|
+
}
|
|
1275
|
+
const description = this.getParamsetDescription(iface, address, 'VALUES') || {};
|
|
1276
|
+
const names = Object.keys(description).filter(
|
|
1277
|
+
(name) => description[name].FLAGS & SERVICE_FLAG && (parameter === '' || parameter === name),
|
|
1278
|
+
);
|
|
1279
|
+
const table = (this.suppressed[iface] = this.suppressed[iface] || new Map());
|
|
1280
|
+
const set = table.get(address) || new Set();
|
|
1281
|
+
for (const name of names) {
|
|
1282
|
+
const before = this.reportedValue(iface, address, name);
|
|
1283
|
+
if (suppress) {
|
|
1284
|
+
set.add(name);
|
|
1285
|
+
} else {
|
|
1286
|
+
set.delete(name);
|
|
1287
|
+
}
|
|
1288
|
+
table.set(address, set);
|
|
1289
|
+
const after = this.reportedValue(iface, address, name);
|
|
1290
|
+
if (before !== after && description[name].OPERATIONS & 4) {
|
|
1291
|
+
this.event(iface, [address, name, after]);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (set.size === 0) {
|
|
1295
|
+
table.delete(address);
|
|
1296
|
+
}
|
|
1297
|
+
return '';
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* `getSuppressedServiceMessages(channelAddress)` - the suppressed parameters of a channel, sorted;
|
|
1302
|
+
* `[]` for an address hmipserver does not know (measured, firmware 3.89.11). hmipserver only.
|
|
1303
|
+
*/
|
|
1304
|
+
getSuppressedServiceMessages(iface, params) {
|
|
1305
|
+
if (iface !== 'hmip') {
|
|
1306
|
+
throw this.fault('unknownMethod', `${iface}/getSuppressedServiceMessages`);
|
|
1307
|
+
}
|
|
1308
|
+
const [address] = params || [];
|
|
1309
|
+
const set = this.suppressed[iface] && this.suppressed[iface].get(address);
|
|
1310
|
+
return set ? [...set].sort() : [];
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
isSuppressed(iface, address, name) {
|
|
1314
|
+
const set = this.suppressed[iface] && this.suppressed[iface].get(address);
|
|
1315
|
+
return Boolean(set && set.has(name));
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** The stored value, or for a suppressed service message the value that raises no message. */
|
|
1319
|
+
reportedValue(iface, address, name, value) {
|
|
1320
|
+
const stored =
|
|
1321
|
+
value === undefined
|
|
1322
|
+
? ((this.values[iface] && this.values[iface][address]) || {VALUES: {}}).VALUES[name]
|
|
1323
|
+
: value;
|
|
1324
|
+
if (!this.isSuppressed(iface, address, name)) {
|
|
1325
|
+
return stored;
|
|
1326
|
+
}
|
|
1327
|
+
return typeof stored === 'boolean' || stored === undefined ? false : 0;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/* ------------------------------------------------------------------ metadata */
|
|
1331
|
+
|
|
1332
|
+
requireMetadataInterface(iface, method) {
|
|
1333
|
+
if (!METADATA_INTERFACES.has(iface)) {
|
|
1334
|
+
throw this.fault('unknownMethod', `${iface}/${method}`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* `getMetadata(address, key)` - a value stored with `setMetadata` or the `metadata` option. A key
|
|
1340
|
+
* that is not set: hmipserver answers an empty value, rfd the fault `-1 Failure` - also for an
|
|
1341
|
+
* address it does not know (measured, firmware 3.89.11). rfd, hs485d and hmipserver.
|
|
1342
|
+
*/
|
|
1343
|
+
getMetadata(iface, params) {
|
|
1344
|
+
this.requireMetadataInterface(iface, 'getMetadata');
|
|
1345
|
+
const [address, key] = params || [];
|
|
1346
|
+
const stored = this.metadata[iface] && this.metadata[iface][address];
|
|
1347
|
+
if (stored && Object.hasOwn(stored, key)) {
|
|
1348
|
+
return stored[key];
|
|
1349
|
+
}
|
|
1350
|
+
if (iface === 'hmip') {
|
|
1351
|
+
return '';
|
|
1352
|
+
}
|
|
1353
|
+
throw this.fault('notSupported', `no metadata ${key} on ${address}`);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* `setMetadata(address, key, value)` - stores a value. rfd and hs485d know only their own
|
|
1358
|
+
* addresses (`unknownInstance`); hmipserver takes any (the simulator's model).
|
|
1359
|
+
*/
|
|
1360
|
+
setMetadata(iface, params) {
|
|
1361
|
+
this.requireMetadataInterface(iface, 'setMetadata');
|
|
1362
|
+
const [address, key, value] = params || [];
|
|
1363
|
+
if (typeof address !== 'string' || typeof key !== 'string' || key === '') {
|
|
1364
|
+
throw this.fault('invalidArguments', 'setMetadata wants an address and a key');
|
|
1365
|
+
}
|
|
1366
|
+
if (iface !== 'hmip') {
|
|
1367
|
+
this.requireDevice(iface, address);
|
|
1368
|
+
}
|
|
1369
|
+
this.metadata[iface] = this.metadata[iface] || {};
|
|
1370
|
+
this.metadata[iface][address] = this.metadata[iface][address] || {};
|
|
1371
|
+
this.metadata[iface][address][key] = value;
|
|
1372
|
+
return '';
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* `getAllMetadata(address)` - every key of an address. rfd and hs485d only (hmipserver does not
|
|
1377
|
+
* list it); nothing stored is the fault `-1 Failure`, as measured on rfd.
|
|
1378
|
+
*/
|
|
1379
|
+
getAllMetadata(iface, params) {
|
|
1380
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1381
|
+
throw this.fault('unknownMethod', `${iface}/getAllMetadata`);
|
|
1382
|
+
}
|
|
1383
|
+
const [address] = params || [];
|
|
1384
|
+
const stored = this.metadata[iface] && this.metadata[iface][address];
|
|
1385
|
+
if (!stored || Object.keys(stored).length === 0) {
|
|
1386
|
+
throw this.fault('notSupported', `no metadata on ${address}`);
|
|
1387
|
+
}
|
|
1388
|
+
return {...stored};
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/* ------------------------------------------------------------------ interface information */
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* `listReplaceableDevices(address)` - the devices a device can take the place of. rfd: the other
|
|
1395
|
+
* devices of the same `TYPE` that are unreachable (`setReachable(iface, address, false)`) - a
|
|
1396
|
+
* device that still answers is not replaced (the simulator's model, after what the CCU's WebUI
|
|
1397
|
+
* offers; measured: `[]` for rfd's own `BidCoS-RF`, `-2 Unknown instance` for an address it does
|
|
1398
|
+
* not know). hmipserver answers with an empty HTTP body whatever it is asked (measured, firmware
|
|
1399
|
+
* 3.89.11), which the simulator answers as `''`.
|
|
1400
|
+
*/
|
|
1401
|
+
listReplaceableDevices(iface, params) {
|
|
1402
|
+
const [address] = params || [];
|
|
1403
|
+
if (iface === 'hmip') {
|
|
1404
|
+
return '';
|
|
1405
|
+
}
|
|
1406
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1407
|
+
throw this.fault('unknownMethod', `${iface}/listReplaceableDevices`);
|
|
1408
|
+
}
|
|
1409
|
+
const device = this.requireDevice(iface, address);
|
|
1410
|
+
if (device.PARENT) {
|
|
1411
|
+
return [];
|
|
1412
|
+
}
|
|
1413
|
+
const unreachable = this.unreachable[iface] || new Set();
|
|
1414
|
+
return this.deviceList(iface).filter(
|
|
1415
|
+
(entry) => entry.TYPE === device.TYPE && entry.ADDRESS !== address && unreachable.has(entry.ADDRESS),
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* `getVersion()` - the version string of the interface process: `interfaces.<iface>.version`,
|
|
1421
|
+
* by default what rfd (`2.6.0`) and hmipserver (the firmware, `3.89.11.20260919`) answered on
|
|
1422
|
+
* firmware 3.89.11. VirtualDevices and CUxD: unknown-method.
|
|
1423
|
+
*/
|
|
1424
|
+
getVersion(iface) {
|
|
1425
|
+
const version = this.interfaceOption(iface, 'version');
|
|
1426
|
+
if (version === undefined) {
|
|
1427
|
+
throw this.fault('unknownMethod', `${iface}/getVersion`);
|
|
1428
|
+
}
|
|
1429
|
+
return version;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* `getLGWStatus()` - rfd on firmware 3.89.11 answers `unknown method name`, measured on two
|
|
1434
|
+
* systems; `interfaces.rfd.lgwStatus` makes it answer that value instead, for a client that
|
|
1435
|
+
* shows it. BidCos only.
|
|
1436
|
+
*/
|
|
1437
|
+
getLGWStatus(iface) {
|
|
1438
|
+
const status = BIDCOS_INTERFACES.has(iface) ? this.interfaceOption(iface, 'lgwStatus') : undefined;
|
|
1439
|
+
if (status === undefined) {
|
|
1440
|
+
throw this.fault('unknownMethod', `${iface}/getLGWStatus`);
|
|
1441
|
+
}
|
|
1442
|
+
return status;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* `setInterfaceClock(utcSeconds, offsetMinutes)` - rfd and hmipserver set the clock they send the
|
|
1447
|
+
* devices. Recorded in `sim.interfaceClocks` (`{iface, utc, offset, ts}`) and answered with an
|
|
1448
|
+
* empty string (the simulator's model; not measured - it changes the system's clock).
|
|
1449
|
+
*/
|
|
1450
|
+
setInterfaceClock(iface, params) {
|
|
1451
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1452
|
+
throw this.fault('unknownMethod', `${iface}/setInterfaceClock`);
|
|
1453
|
+
}
|
|
1454
|
+
const [utc, offset = 0] = params || [];
|
|
1455
|
+
if (!Number.isFinite(Number(utc))) {
|
|
1456
|
+
throw this.fault('invalidArguments', 'setInterfaceClock wants the UTC seconds');
|
|
1457
|
+
}
|
|
1458
|
+
this.interfaceClocks.push({iface, utc: Number(utc), offset: Number(offset), ts: Date.now()});
|
|
1118
1459
|
return '';
|
|
1119
1460
|
}
|
|
1120
1461
|
|
|
@@ -1365,6 +1706,12 @@ class HmSim {
|
|
|
1365
1706
|
if (this.poisonedChannels[iface]) {
|
|
1366
1707
|
this.poisonedChannels[iface].delete(entry);
|
|
1367
1708
|
}
|
|
1709
|
+
if (this.suppressed[iface]) {
|
|
1710
|
+
this.suppressed[iface].delete(entry);
|
|
1711
|
+
}
|
|
1712
|
+
if (this.metadata[iface]) {
|
|
1713
|
+
delete this.metadata[iface][entry];
|
|
1714
|
+
}
|
|
1368
1715
|
}
|
|
1369
1716
|
if (this.configPending[iface]) {
|
|
1370
1717
|
this.configPending[iface].delete(address);
|
|
@@ -1413,7 +1760,7 @@ class HmSim {
|
|
|
1413
1760
|
}
|
|
1414
1761
|
|
|
1415
1762
|
this.deleteDevice(iface, oldAddress);
|
|
1416
|
-
return
|
|
1763
|
+
return true;
|
|
1417
1764
|
}
|
|
1418
1765
|
|
|
1419
1766
|
reportValueUsage(iface, params) {
|
|
@@ -2111,6 +2458,17 @@ class HmSim {
|
|
|
2111
2458
|
logLevel: (iface, params) => this.logLevel(iface, params),
|
|
2112
2459
|
changeKey: (iface, params) => this.changeKey(iface, params),
|
|
2113
2460
|
refreshDeployedDeviceFirmwareList: (iface) => this.refreshDeployedDeviceFirmwareList(iface),
|
|
2461
|
+
getKeyMismatchDevice: (iface, params) => this.getKeyMismatchDevice(iface, params),
|
|
2462
|
+
addDevice: (iface, params) => this.addDeviceBySerial(iface, params),
|
|
2463
|
+
suppressServiceMessages: (iface, params) => this.suppressServiceMessages(iface, params),
|
|
2464
|
+
getSuppressedServiceMessages: (iface, params) => this.getSuppressedServiceMessages(iface, params),
|
|
2465
|
+
getMetadata: (iface, params) => this.getMetadata(iface, params),
|
|
2466
|
+
setMetadata: (iface, params) => this.setMetadata(iface, params),
|
|
2467
|
+
getAllMetadata: (iface, params) => this.getAllMetadata(iface, params),
|
|
2468
|
+
listReplaceableDevices: (iface, params) => this.listReplaceableDevices(iface, params),
|
|
2469
|
+
getVersion: (iface) => this.getVersion(iface),
|
|
2470
|
+
getLGWStatus: (iface) => this.getLGWStatus(iface),
|
|
2471
|
+
setInterfaceClock: (iface, params) => this.setInterfaceClock(iface, params),
|
|
2114
2472
|
};
|
|
2115
2473
|
|
|
2116
2474
|
// the CCU accepts the lower case spelling as well
|
|
@@ -2580,7 +2938,11 @@ class HmSim {
|
|
|
2580
2938
|
const events =
|
|
2581
2939
|
parameter.TYPE === 'ACTION'
|
|
2582
2940
|
? [[address, datapoint, casted]]
|
|
2583
|
-
: Object.keys(state.VALUES).map((name) => [
|
|
2941
|
+
: Object.keys(state.VALUES).map((name) => [
|
|
2942
|
+
address,
|
|
2943
|
+
name,
|
|
2944
|
+
this.reportedValue(iface, address, name, state.VALUES[name]),
|
|
2945
|
+
]);
|
|
2584
2946
|
this.eventMulticall(iface, events);
|
|
2585
2947
|
}
|
|
2586
2948
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hm-simulator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"description": "Simulates a Homematic CCU (rfd, hmipserver, VirtualDevices, CUxD, ReGa) for automated tests",
|
|
5
5
|
"main": "sim.js",
|
|
6
|
+
"types": "./sim.d.ts",
|
|
6
7
|
"preferGlobal": true,
|
|
7
8
|
"bin": {
|
|
8
9
|
"hm-simulator": "./index.js"
|
|
@@ -13,9 +14,10 @@
|
|
|
13
14
|
"scripts": {
|
|
14
15
|
"lint": "eslint . && prettier --check .",
|
|
15
16
|
"format": "prettier --write . && eslint --fix .",
|
|
16
|
-
"test": "node --test test/*.test.js",
|
|
17
|
+
"test": "node --test test/*.test.js && npm run test:types",
|
|
17
18
|
"test:watch": "node --test --watch test/*.test.js",
|
|
18
|
-
"fixtures": "node tools/fixtures-from-paramsets.js"
|
|
19
|
+
"fixtures": "node tools/fixtures-from-paramsets.js",
|
|
20
|
+
"test:types": "tsc -p test/types"
|
|
19
21
|
},
|
|
20
22
|
"repository": {
|
|
21
23
|
"type": "git",
|
|
@@ -44,6 +46,8 @@
|
|
|
44
46
|
"index.js",
|
|
45
47
|
"sim.js",
|
|
46
48
|
"sim.mjs",
|
|
49
|
+
"sim.d.ts",
|
|
50
|
+
"sim.d.mts",
|
|
47
51
|
"lib/",
|
|
48
52
|
"data/",
|
|
49
53
|
"behaviors/",
|
|
@@ -57,9 +61,11 @@
|
|
|
57
61
|
},
|
|
58
62
|
"devDependencies": {
|
|
59
63
|
"@eslint/js": "^9.39.5",
|
|
64
|
+
"@types/node": "^20.19.43",
|
|
60
65
|
"eslint": "^9.39.5",
|
|
61
66
|
"eslint-config-prettier": "^10.1.8",
|
|
62
67
|
"globals": "^16.5.0",
|
|
63
|
-
"prettier": "^3.9.6"
|
|
68
|
+
"prettier": "^3.9.6",
|
|
69
|
+
"typescript": "^5.9.3"
|
|
64
70
|
}
|
|
65
71
|
}
|