hm-simulator 1.1.0 → 1.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.
- package/CHANGELOG.md +124 -0
- package/README.md +651 -242
- package/data/fixtures/lab-2026-09.json +45956 -0
- package/index.js +260 -47
- package/lib/control.js +133 -0
- package/lib/faults.d.ts +48 -0
- package/lib/method-help.js +18 -0
- package/lib/paramset-index.js +107 -0
- package/lib/servers.js +262 -13
- package/lib/sim.js +1307 -65
- package/lib/validate.js +4 -0
- package/package.json +11 -4
- package/sim.d.mts +5 -0
- package/sim.d.ts +443 -0
package/lib/sim.js
CHANGED
|
@@ -16,9 +16,11 @@ const {
|
|
|
16
16
|
hmipStoredValue,
|
|
17
17
|
bidcosStoredValue,
|
|
18
18
|
} = require('./validate.js');
|
|
19
|
-
const {createBinrpcServer, createXmlrpcServer} = require('./servers.js');
|
|
19
|
+
const {createBinrpcServer, createXmlrpcServer, createDualServer} = require('./servers.js');
|
|
20
20
|
const {resolveTls} = require('./tls.js');
|
|
21
21
|
const RegaSim = require('./rega.js');
|
|
22
|
+
const {ParamsetIndex} = require('./paramset-index.js');
|
|
23
|
+
const {SCENARIO_METHODS} = require('./control.js');
|
|
22
24
|
const METHOD_HELP = require('./method-help.js');
|
|
23
25
|
|
|
24
26
|
const NOOP_LOG = {
|
|
@@ -37,9 +39,18 @@ const PARAMSET_PREFIX = {
|
|
|
37
39
|
cuxd: 'CUxD',
|
|
38
40
|
};
|
|
39
41
|
|
|
42
|
+
/** how many calls to registered clients getCallbackLog() keeps */
|
|
43
|
+
const CALLBACK_LOG_SIZE = 10000;
|
|
44
|
+
|
|
40
45
|
/** the interfaces that speak the BidCos protocol, and therefore have setTempKey */
|
|
41
46
|
const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
|
|
42
47
|
|
|
48
|
+
/** device types of the HmIP radio modules, which carry DUTY_CYCLE_LEVEL and CARRIER_SENSE_LEVEL */
|
|
49
|
+
const RADIO_MODULE_TYPES = new Set(['RPI-RF-MOD', 'HmIP-RFUSB', 'HmIP-RFUSB-TK', 'HMIP-CCU3', 'HmIP-CCU3']);
|
|
50
|
+
|
|
51
|
+
/** the steps of a firmware update after updateFirmware/installFirmware, see offerFirmware() */
|
|
52
|
+
const FIRMWARE_UPDATE_STEPS = ['DO_UPDATE_PENDING', 'PERFORMING_UPDATE', 'UP_TO_DATE'];
|
|
53
|
+
|
|
43
54
|
/** paramset keys that are not the address of a linked peer channel */
|
|
44
55
|
const STANDARD_PARAMSETS = new Set(['MASTER', 'VALUES', 'SERVICE']);
|
|
45
56
|
|
|
@@ -63,18 +74,48 @@ const SERVICE_FLAG = 8;
|
|
|
63
74
|
* 'pending' - the write is accepted, the valid parameters are stored, and CONFIG_PENDING is
|
|
64
75
|
* raised on the device's :0 channel and stays until a valid full MASTER write or
|
|
65
76
|
* clearConfigCache. The other hypothesis of 1.0; kept so both can still be tested.
|
|
77
|
+
* listenerModel
|
|
78
|
+
* 'isolated' - every registered client on its own (the default).
|
|
79
|
+
* 'measured' - what the interface processes did with a callback server that accepts the
|
|
80
|
+
* connection and never answers (measured 2026-09-25 on firmware 3.89.8), see init().
|
|
81
|
+
* pong, pingDelay
|
|
82
|
+
* whether `ping` sends the CENTRAL/PONG event (not on hmip), and after how many milliseconds.
|
|
83
|
+
* startDelay
|
|
84
|
+
* milliseconds after whenReady() until the port accepts connections: a process that starts late.
|
|
85
|
+
* deliveryTimeout
|
|
86
|
+
* 'measured' BidCos only: milliseconds after which an unanswered callback drops the client.
|
|
87
|
+
* unreachWrites
|
|
88
|
+
* what a setValue/putParamset to an unreachable device answers: 'accept' (the default) or
|
|
89
|
+
* 'fault' (notReachable). The simulator's model; not measured yet.
|
|
90
|
+
* firmwareUpdateDelay
|
|
91
|
+
* milliseconds per step of a firmware update, see offerFirmware().
|
|
92
|
+
* protocols
|
|
93
|
+
* rfd and wired only: what their port answers, ['binrpc', 'xmlrpc'] (both, as on a CCU) unless
|
|
94
|
+
* set; ['binrpc'] is the BIN-RPC-only server of 1.1.
|
|
66
95
|
* configPendingOnWrite
|
|
67
96
|
* raise CONFIG_PENDING for every accepted MASTER write, the way a BidCos battery device queues
|
|
68
97
|
* the configuration until it wakes up. Clears after configPendingDelay milliseconds. Implied by
|
|
69
98
|
* 'bidcos', which raises it for every write that changes something.
|
|
70
99
|
*/
|
|
71
100
|
const INTERFACE_DEFAULTS = {
|
|
101
|
+
// interface process lifecycle, see restartInterface() and the listener models in init()
|
|
102
|
+
listenerModel: 'isolated',
|
|
103
|
+
pong: true,
|
|
104
|
+
pingDelay: 0,
|
|
105
|
+
startDelay: 0,
|
|
106
|
+
deliveryTimeout: 10000,
|
|
107
|
+
// device health, see setReachable() and offerFirmware()
|
|
108
|
+
unreachWrites: 'accept',
|
|
109
|
+
firmwareUpdateDelay: 0,
|
|
72
110
|
configPendingMode: 'strict',
|
|
73
111
|
configPendingOnWrite: false,
|
|
74
112
|
configPendingDelay: 0,
|
|
75
113
|
// rfd answers the empty string instead of an empty array when it has no service message; the
|
|
76
114
|
// real shape, off by default because it breaks every consumer that assumes an array
|
|
77
115
|
serviceMessagesEmptyAsString: false,
|
|
116
|
+
// answer getServiceMessages with the unknown-method fault, as VirtualDevices, CUxD and
|
|
117
|
+
// hmipserver were seen to on 3.89.x ("Invalid XML-RPC message"); off by default
|
|
118
|
+
getServiceMessagesFault: false,
|
|
78
119
|
};
|
|
79
120
|
|
|
80
121
|
/**
|
|
@@ -82,11 +123,16 @@ const INTERFACE_DEFAULTS = {
|
|
|
82
123
|
* the value from INTERFACE_DEFAULTS.
|
|
83
124
|
*/
|
|
84
125
|
const MEASURED_INTERFACE_DEFAULTS = {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
126
|
+
// hmipserver answers ping without a PONG event, see https://github.com/eq-3/occu/issues/42
|
|
127
|
+
hmip: {configPendingMode: 'hmip', pong: false, version: '3.89.11.20260919'},
|
|
128
|
+
rfd: {configPendingMode: 'bidcos', version: '2.6.0'},
|
|
129
|
+
// hs485d's getVersion was not measured; rfd's answer stands in
|
|
130
|
+
wired: {configPendingMode: 'bidcos', version: '2.6.0'},
|
|
88
131
|
};
|
|
89
132
|
|
|
133
|
+
/** the interfaces with the metadata methods: rfd and hs485d fault on a key that is not set, hmipserver answers '' */
|
|
134
|
+
const METADATA_INTERFACES = new Set(['rfd', 'wired', 'hmip']);
|
|
135
|
+
|
|
90
136
|
/** what listBidcosInterfaces answers when the `bidcosInterfaces` option does not say otherwise */
|
|
91
137
|
const DEFAULT_BIDCOS_INTERFACES = {
|
|
92
138
|
rfd: [
|
|
@@ -149,9 +195,11 @@ function shortenParams(params) {
|
|
|
149
195
|
* @param {object} [options.log] object with debug/info/warn/error methods
|
|
150
196
|
* @param {object} [options.devices] {<iface>: {devices: []}} - device descriptions per interface
|
|
151
197
|
* @param {object} [options.config] listenAddress, binrpcListenPort, xmlrpcListenPort
|
|
152
|
-
* @param {string} [options.behaviorPath] directory with behaviour scripts
|
|
198
|
+
* @param {string|false} [options.behaviorPath] directory with behaviour scripts, `false` for none
|
|
153
199
|
* @param {object} [options.rega] ReGa mock options, see lib/rega.js - omit to not start it
|
|
154
200
|
* @param {object} [options.paramsetDescriptions] replaces the bundled descriptions
|
|
201
|
+
* @param {boolean} [options.paramsetFallback=true] a device whose firmware has no description uses
|
|
202
|
+
* the nearest firmware of the same type and VERSION; `false` for exact keys only
|
|
155
203
|
* @param {object} [options.faults] overrides for the fault table, see lib/faults.js
|
|
156
204
|
*/
|
|
157
205
|
class HmSim {
|
|
@@ -165,6 +213,10 @@ class HmSim {
|
|
|
165
213
|
|
|
166
214
|
this.devices = options.devices || {rfd: {devices: []}, hmip: {devices: []}};
|
|
167
215
|
this.paramsetDescriptions = options.paramsetDescriptions || require('../data/paramset-descriptions.json');
|
|
216
|
+
this.paramsetFallback = options.paramsetFallback !== false;
|
|
217
|
+
/** requested key -> the key used for it (a fallback) or null, see resolveParamsetKey() */
|
|
218
|
+
this.paramsetResolved = new Map();
|
|
219
|
+
this.paramsetIndex = new ParamsetIndex(this.paramsetDescriptions);
|
|
168
220
|
|
|
169
221
|
const {table, fault} = createFaults(options.faults);
|
|
170
222
|
this.faults = table;
|
|
@@ -192,12 +244,44 @@ class HmSim {
|
|
|
192
244
|
this.valueUsage = [];
|
|
193
245
|
/** every updateFirmware/installFirmware call */
|
|
194
246
|
this.firmwareUpdates = [];
|
|
247
|
+
/** every refreshDeployedDeviceFirmwareList call: [{iface, ts}] */
|
|
248
|
+
this.firmwareListRefreshes = [];
|
|
249
|
+
/** every changeKey call: [{iface, key, ts}] */
|
|
250
|
+
this.keyChanges = [];
|
|
251
|
+
/** the log level of each interface process, see logLevel() */
|
|
252
|
+
this.logLevels = {};
|
|
195
253
|
/** pending timers, cleared by close() */
|
|
196
254
|
this.timers = new Set();
|
|
197
255
|
/** devices with a pending configuration: {<iface>: Map<deviceAddress, {sticky}>} */
|
|
198
256
|
this.configPending = {};
|
|
199
257
|
/** channels whose stored MASTER carries a parameter the device does not have */
|
|
200
258
|
this.poisonedChannels = {};
|
|
259
|
+
/** faults waiting for the next matching calls, see injectFault() */
|
|
260
|
+
this.injectedFaults = [];
|
|
261
|
+
/** every call the simulator made to a registered client, see getCallbackLog() */
|
|
262
|
+
this.callbackLog = [];
|
|
263
|
+
/** per interface: a promise every incoming call waits for while the process is busy */
|
|
264
|
+
this.gates = {};
|
|
265
|
+
/** per interface: deliveries held back while a registration hangs, see init() */
|
|
266
|
+
this.held = {};
|
|
267
|
+
/** interfaces whose port is closed, see stopInterface() */
|
|
268
|
+
this.stopped = new Set();
|
|
269
|
+
/** interfaces that open their port only after `startDelay` */
|
|
270
|
+
this.delayedStarts = {};
|
|
271
|
+
/** devices that are unreachable: {<iface>: Set<deviceAddress>}, see setReachable() */
|
|
272
|
+
this.unreachable = {};
|
|
273
|
+
/** what listBidcosInterfaces reports on top of the configured answer: {<iface>: {DUTY_CYCLE, ...}} */
|
|
274
|
+
this.radio = {};
|
|
275
|
+
/** a device that holds another system's key: {<iface>: {serial, key, devices, delay}}, see scriptKeyMismatch() */
|
|
276
|
+
this.keyMismatchScript = {};
|
|
277
|
+
/** the serial getKeyMismatchDevice answers: {<iface>: serial} */
|
|
278
|
+
this.keyMismatch = {};
|
|
279
|
+
/** suppressed service messages (hmipserver): {<iface>: Map<channelAddress, Set<parameter>>} */
|
|
280
|
+
this.suppressed = {};
|
|
281
|
+
/** metadata per interface and address: {<iface>: {<address>: {<key>: value}}} */
|
|
282
|
+
this.metadata = structuredClone(options.metadata || {});
|
|
283
|
+
/** every setInterfaceClock call: [{iface, utc, offset, ts}] */
|
|
284
|
+
this.interfaceClocks = [];
|
|
201
285
|
|
|
202
286
|
this.rpcMethods = this.buildRpcMethods();
|
|
203
287
|
|
|
@@ -231,7 +315,10 @@ class HmSim {
|
|
|
231
315
|
this.setValue(iface, address, datapoint, value, {internal: true}),
|
|
232
316
|
);
|
|
233
317
|
|
|
234
|
-
|
|
318
|
+
// `behaviorPath: false`: no behaviour scripts at all
|
|
319
|
+
if (options.behaviorPath !== false) {
|
|
320
|
+
this.loadBehaviors(options.behaviorPath || path.join(__dirname, '..', 'behaviors'));
|
|
321
|
+
}
|
|
235
322
|
|
|
236
323
|
if (options.rega) {
|
|
237
324
|
// the ReGa mock inherits TLS and basic auth unless its own options say otherwise
|
|
@@ -299,10 +386,7 @@ class HmSim {
|
|
|
299
386
|
if (this.values[iface][device.ADDRESS].VALUES[name] !== undefined) {
|
|
300
387
|
continue;
|
|
301
388
|
}
|
|
302
|
-
this.values[iface][device.ADDRESS].VALUES[name] =
|
|
303
|
-
parameter.TYPE === 'ENUM' && Array.isArray(parameter.VALUE_LIST)
|
|
304
|
-
? parameter.VALUE_LIST.indexOf(parameter.DEFAULT)
|
|
305
|
-
: parameter.DEFAULT;
|
|
389
|
+
this.values[iface][device.ADDRESS].VALUES[name] = defaultValue(parameter);
|
|
306
390
|
}
|
|
307
391
|
}
|
|
308
392
|
}
|
|
@@ -345,7 +429,76 @@ class HmSim {
|
|
|
345
429
|
}
|
|
346
430
|
|
|
347
431
|
const name = this.paramsetName(iface, device, paramset);
|
|
348
|
-
|
|
432
|
+
if (name === undefined) {
|
|
433
|
+
return undefined;
|
|
434
|
+
}
|
|
435
|
+
const key = this.resolveParamsetKey(name);
|
|
436
|
+
return key === null ? undefined : this.paramsetDescriptions[key];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The key whose description stands in for `name`: `name` itself when the set has it, otherwise
|
|
441
|
+
* the same type, VERSION, channel type and paramset of the nearest firmware (the highest one at
|
|
442
|
+
* or below the device's, else the lowest above it) unless `paramsetFallback` is off, else null.
|
|
443
|
+
*
|
|
444
|
+
* A device reports the firmware it runs, and a description set only has the firmwares somebody
|
|
445
|
+
* dumped: without this, the bundled `HM-RCV-50` (firmware 2.27.8, descriptions from 2.31.25 on)
|
|
446
|
+
* answers `Invalid device` for every channel `listDevices` reports (PR #1). Each substitution
|
|
447
|
+
* and each miss is logged once.
|
|
448
|
+
*
|
|
449
|
+
* @param {string} name `<interface>/<type>/<firmware>/<version>/<channelType>/<paramset>`
|
|
450
|
+
* @returns {string|null}
|
|
451
|
+
*/
|
|
452
|
+
resolveParamsetKey(name) {
|
|
453
|
+
if (this.paramsetDescriptions[name]) {
|
|
454
|
+
return name;
|
|
455
|
+
}
|
|
456
|
+
const index = this.currentParamsetIndex();
|
|
457
|
+
if (this.paramsetResolved.has(name)) {
|
|
458
|
+
return this.paramsetResolved.get(name);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const used = this.paramsetFallback ? index.nearest(name) : null;
|
|
462
|
+
this.paramsetResolved.set(name, used);
|
|
463
|
+
if (used) {
|
|
464
|
+
this.log.warn('paramset description', name, 'missing, using', used);
|
|
465
|
+
} else {
|
|
466
|
+
this.log.warn('paramset description', name, 'missing');
|
|
467
|
+
}
|
|
468
|
+
return used;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** The firmware index of the descriptions, rebuilt when somebody replaced sim.paramsetDescriptions. */
|
|
472
|
+
currentParamsetIndex() {
|
|
473
|
+
if (this.paramsetIndex.descriptions !== this.paramsetDescriptions) {
|
|
474
|
+
this.paramsetIndex = new ParamsetIndex(this.paramsetDescriptions);
|
|
475
|
+
this.paramsetResolved.clear();
|
|
476
|
+
}
|
|
477
|
+
return this.paramsetIndex;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Every paramset the devices announce in `PARAMSETS` whose description is not in the set under
|
|
482
|
+
* its exact key, with the key the simulator uses instead (`null`: none, the paramset faults).
|
|
483
|
+
* An empty list means the device data and the descriptions match exactly.
|
|
484
|
+
*
|
|
485
|
+
* @returns {Array<{iface: string, key: string, usedKey: string|null}>}
|
|
486
|
+
*/
|
|
487
|
+
getMissingParamsetDescriptions() {
|
|
488
|
+
const missing = new Map();
|
|
489
|
+
for (const iface of this.ifaces) {
|
|
490
|
+
for (const device of this.devices[iface].devices) {
|
|
491
|
+
for (const paramset of device.PARAMSETS || []) {
|
|
492
|
+
const key = this.paramsetName(iface, device, paramset);
|
|
493
|
+
if (key === undefined || this.paramsetDescriptions[key] || missing.has(`${iface} ${key}`)) {
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const usedKey = this.paramsetFallback ? this.currentParamsetIndex().nearest(key) : null;
|
|
497
|
+
missing.set(`${iface} ${key}`, {iface, key, usedKey});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return [...missing.values()];
|
|
349
502
|
}
|
|
350
503
|
|
|
351
504
|
/* ------------------------------------------------------------------ paramsets */
|
|
@@ -414,7 +567,8 @@ class HmSim {
|
|
|
414
567
|
if (!description[datapoint]) {
|
|
415
568
|
throw this.fault('unknownParameter', `${address}/${datapoint}`);
|
|
416
569
|
}
|
|
417
|
-
|
|
570
|
+
const value = values[datapoint] === undefined ? defaultValue(description[datapoint]) : values[datapoint];
|
|
571
|
+
return this.reportedValue(iface, address, datapoint, value);
|
|
418
572
|
}
|
|
419
573
|
|
|
420
574
|
/**
|
|
@@ -427,7 +581,14 @@ class HmSim {
|
|
|
427
581
|
throw this.fault('invalidArguments', 'paramset key');
|
|
428
582
|
}
|
|
429
583
|
const {values} = this.storedParamset(iface, address, key);
|
|
430
|
-
|
|
584
|
+
if (key !== 'VALUES' || !this.suppressed[iface] || !this.suppressed[iface].has(address)) {
|
|
585
|
+
return {...values};
|
|
586
|
+
}
|
|
587
|
+
const reported = {};
|
|
588
|
+
for (const [name, value] of Object.entries(values)) {
|
|
589
|
+
reported[name] = this.reportedValue(iface, address, name, value);
|
|
590
|
+
}
|
|
591
|
+
return reported;
|
|
431
592
|
}
|
|
432
593
|
|
|
433
594
|
/**
|
|
@@ -905,10 +1066,9 @@ class HmSim {
|
|
|
905
1066
|
|
|
906
1067
|
listBidcosInterfaces(iface) {
|
|
907
1068
|
const configured = this.options.bidcosInterfaces && this.options.bidcosInterfaces[iface];
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
return DEFAULT_BIDCOS_INTERFACES[iface] || DEFAULT_BIDCOS_INTERFACES.rfd;
|
|
1069
|
+
const list = configured || DEFAULT_BIDCOS_INTERFACES[iface] || DEFAULT_BIDCOS_INTERFACES.rfd;
|
|
1070
|
+
const radio = this.radio[iface];
|
|
1071
|
+
return radio ? list.map((entry) => ({...entry, ...radio})) : list;
|
|
912
1072
|
}
|
|
913
1073
|
|
|
914
1074
|
setBidcosInterface(iface, params) {
|
|
@@ -930,6 +1090,9 @@ class HmSim {
|
|
|
930
1090
|
* plus what was raised through the scenario API.
|
|
931
1091
|
*/
|
|
932
1092
|
getServiceMessages(iface) {
|
|
1093
|
+
if (this.interfaceOption(iface, 'getServiceMessagesFault')) {
|
|
1094
|
+
throw this.fault('unknownMethod', `${iface}/getServiceMessages`);
|
|
1095
|
+
}
|
|
933
1096
|
const messages = [];
|
|
934
1097
|
for (const address of Object.keys(this.values[iface])) {
|
|
935
1098
|
const description = this.getParamsetDescription(iface, address, 'VALUES');
|
|
@@ -938,13 +1101,16 @@ class HmSim {
|
|
|
938
1101
|
}
|
|
939
1102
|
for (const [name, value] of Object.entries(this.values[iface][address].VALUES || {})) {
|
|
940
1103
|
const parameter = description[name];
|
|
941
|
-
if (parameter && parameter.FLAGS & SERVICE_FLAG && value) {
|
|
1104
|
+
if (parameter && parameter.FLAGS & SERVICE_FLAG && value && !this.isSuppressed(iface, address, name)) {
|
|
942
1105
|
messages.push([address, name, value]);
|
|
943
1106
|
}
|
|
944
1107
|
}
|
|
945
1108
|
}
|
|
946
1109
|
|
|
947
1110
|
for (const message of this.serviceMessages[iface] || []) {
|
|
1111
|
+
if (this.isSuppressed(iface, message[0], message[1])) {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
948
1114
|
if (!messages.some((entry) => entry[0] === message[0] && entry[1] === message[1])) {
|
|
949
1115
|
messages.push([...message]);
|
|
950
1116
|
}
|
|
@@ -980,6 +1146,292 @@ class HmSim {
|
|
|
980
1146
|
this.timer(() => this.addDevices(iface, scripted.devices), scripted.delay);
|
|
981
1147
|
}
|
|
982
1148
|
|
|
1149
|
+
const mismatch = this.keyMismatchScript[iface];
|
|
1150
|
+
if (mismatch) {
|
|
1151
|
+
this.timer(() => this.hearKeyMismatchDevice(iface, mismatch), mismatch.delay);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return '';
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/* ------------------------------------------------------------------ key mismatch */
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* A device that holds another system's security key comes into reach of the next install mode
|
|
1161
|
+
* (or of an `addDevice` with its serial) - the case of Homematic Manager task 76.
|
|
1162
|
+
*
|
|
1163
|
+
* rfd hears the device but cannot pair it: nothing joins and nobody is told; the serial is only
|
|
1164
|
+
* what `getKeyMismatchDevice` answers. Once the temporary key (`setTempKey`) is the device's
|
|
1165
|
+
* `key`, the next install mode pairs it: `devices` (the description and its channels) join with
|
|
1166
|
+
* `newDevices`, and the script is used up. Without `devices` the device is never paired.
|
|
1167
|
+
*
|
|
1168
|
+
* @param {string} iface
|
|
1169
|
+
* @param {string} serial
|
|
1170
|
+
* @param {object} [script]
|
|
1171
|
+
* @param {string} [script.key=''] the passphrase the device was taught in with
|
|
1172
|
+
* @param {Array} [script.devices=[]] its description and channel descriptions
|
|
1173
|
+
* @param {number} [script.delay=0] milliseconds between the install mode and the device being heard
|
|
1174
|
+
*/
|
|
1175
|
+
scriptKeyMismatch(iface, serial, {key = '', devices = [], delay = 0} = {}) {
|
|
1176
|
+
if (typeof serial !== 'string' || serial === '') {
|
|
1177
|
+
throw new TypeError('scriptKeyMismatch wants a serial');
|
|
1178
|
+
}
|
|
1179
|
+
this.keyMismatchScript[iface] = {serial, key: String(key), devices: devices || [], delay};
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** The scripted device is heard: paired when the temporary key is its own, else a key mismatch. */
|
|
1183
|
+
hearKeyMismatchDevice(iface, script) {
|
|
1184
|
+
if (this.keyMismatchScript[iface] !== script) {
|
|
1185
|
+
return undefined;
|
|
1186
|
+
}
|
|
1187
|
+
if (script.key !== '' && this.getTempKey(iface) === script.key && script.devices.length > 0) {
|
|
1188
|
+
delete this.keyMismatchScript[iface];
|
|
1189
|
+
this.addDevices(iface, script.devices);
|
|
1190
|
+
this.log.info(iface, script.serial, 'paired with the temporary key');
|
|
1191
|
+
return this.index[iface].get(script.devices[0].ADDRESS) || script.devices[0];
|
|
1192
|
+
}
|
|
1193
|
+
this.keyMismatch[iface] = script.serial;
|
|
1194
|
+
this.log.info(iface, script.serial, 'heard, but it holds another key');
|
|
1195
|
+
return undefined;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* `getKeyMismatchDevice(reset)` - the serial of the last device that could not be paired
|
|
1200
|
+
* because it holds another key, `''` when there is none (measured on rfd and hmipserver,
|
|
1201
|
+
* firmware 3.89.11). `true` clears it after the answer.
|
|
1202
|
+
*/
|
|
1203
|
+
getKeyMismatchDevice(iface, params) {
|
|
1204
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1205
|
+
throw this.fault('unknownMethod', `${iface}/getKeyMismatchDevice`);
|
|
1206
|
+
}
|
|
1207
|
+
const [reset = false] = params || [];
|
|
1208
|
+
const serial = this.keyMismatch[iface] || '';
|
|
1209
|
+
if (reset) {
|
|
1210
|
+
delete this.keyMismatch[iface];
|
|
1211
|
+
}
|
|
1212
|
+
return serial;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* `addDevice(serial)` - rfd pairs one device by its serial. The simulator knows only the device
|
|
1217
|
+
* of scriptKeyMismatch(): with the right temporary key it joins and its description is the
|
|
1218
|
+
* answer; otherwise it is the key mismatch and the call faults. Any other serial is not in reach
|
|
1219
|
+
* (`unknownInstance`). BidCos only.
|
|
1220
|
+
*/
|
|
1221
|
+
addDeviceBySerial(iface, params) {
|
|
1222
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1223
|
+
throw this.fault('unknownMethod', `${iface}/addDevice`);
|
|
1224
|
+
}
|
|
1225
|
+
const [serial] = params || [];
|
|
1226
|
+
const script = this.keyMismatchScript[iface];
|
|
1227
|
+
if (!script || script.serial !== serial) {
|
|
1228
|
+
throw this.fault('unknownInstance', `${iface}/${serial}`);
|
|
1229
|
+
}
|
|
1230
|
+
const paired = this.hearKeyMismatchDevice(iface, script);
|
|
1231
|
+
if (!paired) {
|
|
1232
|
+
throw this.fault('notSupported', `${serial} holds another key`);
|
|
1233
|
+
}
|
|
1234
|
+
return paired;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/* ------------------------------------------------------------------ service message suppression */
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* `suppressServiceMessages(channelAddress, parameter, suppress)` - hmipserver only (eQ-3's HmIP
|
|
1241
|
+
* addendum; rfd does not have it). `parameter` is one of the channel's service parameters, or
|
|
1242
|
+
* `''` for all of them; a parameter without the service flag is ignored. Measured on firmware
|
|
1243
|
+
* 3.89.11: answered with an empty value, `''` suppressed every service parameter of the
|
|
1244
|
+
* channel, `LEVEL` nothing. A suppressed message is left out of `getServiceMessages`, and the
|
|
1245
|
+
* datapoint reports the value that raises no message (`false`, `0`) - the addendum's words; that
|
|
1246
|
+
* a change of the suppression sends that value as an event is the simulator's model.
|
|
1247
|
+
*/
|
|
1248
|
+
suppressServiceMessages(iface, params) {
|
|
1249
|
+
if (iface !== 'hmip') {
|
|
1250
|
+
throw this.fault('unknownMethod', `${iface}/suppressServiceMessages`);
|
|
1251
|
+
}
|
|
1252
|
+
const [address, parameter = '', suppress = true] = params || [];
|
|
1253
|
+
if (typeof address !== 'string' || typeof parameter !== 'string') {
|
|
1254
|
+
throw this.fault('invalidArguments', 'suppressServiceMessages wants a channel address and a parameter');
|
|
1255
|
+
}
|
|
1256
|
+
const description = this.getParamsetDescription(iface, address, 'VALUES') || {};
|
|
1257
|
+
const names = Object.keys(description).filter(
|
|
1258
|
+
(name) => description[name].FLAGS & SERVICE_FLAG && (parameter === '' || parameter === name),
|
|
1259
|
+
);
|
|
1260
|
+
const table = (this.suppressed[iface] = this.suppressed[iface] || new Map());
|
|
1261
|
+
const set = table.get(address) || new Set();
|
|
1262
|
+
for (const name of names) {
|
|
1263
|
+
const before = this.reportedValue(iface, address, name);
|
|
1264
|
+
if (suppress) {
|
|
1265
|
+
set.add(name);
|
|
1266
|
+
} else {
|
|
1267
|
+
set.delete(name);
|
|
1268
|
+
}
|
|
1269
|
+
table.set(address, set);
|
|
1270
|
+
const after = this.reportedValue(iface, address, name);
|
|
1271
|
+
if (before !== after && description[name].OPERATIONS & 4) {
|
|
1272
|
+
this.event(iface, [address, name, after]);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (set.size === 0) {
|
|
1276
|
+
table.delete(address);
|
|
1277
|
+
}
|
|
1278
|
+
return '';
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
/**
|
|
1282
|
+
* `getSuppressedServiceMessages(channelAddress)` - the suppressed parameters of a channel, sorted;
|
|
1283
|
+
* `[]` for an address hmipserver does not know (measured, firmware 3.89.11). hmipserver only.
|
|
1284
|
+
*/
|
|
1285
|
+
getSuppressedServiceMessages(iface, params) {
|
|
1286
|
+
if (iface !== 'hmip') {
|
|
1287
|
+
throw this.fault('unknownMethod', `${iface}/getSuppressedServiceMessages`);
|
|
1288
|
+
}
|
|
1289
|
+
const [address] = params || [];
|
|
1290
|
+
const set = this.suppressed[iface] && this.suppressed[iface].get(address);
|
|
1291
|
+
return set ? [...set].sort() : [];
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
isSuppressed(iface, address, name) {
|
|
1295
|
+
const set = this.suppressed[iface] && this.suppressed[iface].get(address);
|
|
1296
|
+
return Boolean(set && set.has(name));
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** The stored value, or for a suppressed service message the value that raises no message. */
|
|
1300
|
+
reportedValue(iface, address, name, value) {
|
|
1301
|
+
const stored =
|
|
1302
|
+
value === undefined
|
|
1303
|
+
? ((this.values[iface] && this.values[iface][address]) || {VALUES: {}}).VALUES[name]
|
|
1304
|
+
: value;
|
|
1305
|
+
if (!this.isSuppressed(iface, address, name)) {
|
|
1306
|
+
return stored;
|
|
1307
|
+
}
|
|
1308
|
+
return typeof stored === 'boolean' || stored === undefined ? false : 0;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
/* ------------------------------------------------------------------ metadata */
|
|
1312
|
+
|
|
1313
|
+
requireMetadataInterface(iface, method) {
|
|
1314
|
+
if (!METADATA_INTERFACES.has(iface)) {
|
|
1315
|
+
throw this.fault('unknownMethod', `${iface}/${method}`);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* `getMetadata(address, key)` - a value stored with `setMetadata` or the `metadata` option. A key
|
|
1321
|
+
* that is not set: hmipserver answers an empty value, rfd the fault `-1 Failure` - also for an
|
|
1322
|
+
* address it does not know (measured, firmware 3.89.11). rfd, hs485d and hmipserver.
|
|
1323
|
+
*/
|
|
1324
|
+
getMetadata(iface, params) {
|
|
1325
|
+
this.requireMetadataInterface(iface, 'getMetadata');
|
|
1326
|
+
const [address, key] = params || [];
|
|
1327
|
+
const stored = this.metadata[iface] && this.metadata[iface][address];
|
|
1328
|
+
if (stored && Object.hasOwn(stored, key)) {
|
|
1329
|
+
return stored[key];
|
|
1330
|
+
}
|
|
1331
|
+
if (iface === 'hmip') {
|
|
1332
|
+
return '';
|
|
1333
|
+
}
|
|
1334
|
+
throw this.fault('notSupported', `no metadata ${key} on ${address}`);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* `setMetadata(address, key, value)` - stores a value. rfd and hs485d know only their own
|
|
1339
|
+
* addresses (`unknownInstance`); hmipserver takes any (the simulator's model).
|
|
1340
|
+
*/
|
|
1341
|
+
setMetadata(iface, params) {
|
|
1342
|
+
this.requireMetadataInterface(iface, 'setMetadata');
|
|
1343
|
+
const [address, key, value] = params || [];
|
|
1344
|
+
if (typeof address !== 'string' || typeof key !== 'string' || key === '') {
|
|
1345
|
+
throw this.fault('invalidArguments', 'setMetadata wants an address and a key');
|
|
1346
|
+
}
|
|
1347
|
+
if (iface !== 'hmip') {
|
|
1348
|
+
this.requireDevice(iface, address);
|
|
1349
|
+
}
|
|
1350
|
+
this.metadata[iface] = this.metadata[iface] || {};
|
|
1351
|
+
this.metadata[iface][address] = this.metadata[iface][address] || {};
|
|
1352
|
+
this.metadata[iface][address][key] = value;
|
|
1353
|
+
return '';
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* `getAllMetadata(address)` - every key of an address. rfd and hs485d only (hmipserver does not
|
|
1358
|
+
* list it); nothing stored is the fault `-1 Failure`, as measured on rfd.
|
|
1359
|
+
*/
|
|
1360
|
+
getAllMetadata(iface, params) {
|
|
1361
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1362
|
+
throw this.fault('unknownMethod', `${iface}/getAllMetadata`);
|
|
1363
|
+
}
|
|
1364
|
+
const [address] = params || [];
|
|
1365
|
+
const stored = this.metadata[iface] && this.metadata[iface][address];
|
|
1366
|
+
if (!stored || Object.keys(stored).length === 0) {
|
|
1367
|
+
throw this.fault('notSupported', `no metadata on ${address}`);
|
|
1368
|
+
}
|
|
1369
|
+
return {...stored};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/* ------------------------------------------------------------------ interface information */
|
|
1373
|
+
|
|
1374
|
+
/**
|
|
1375
|
+
* `listReplaceableDevices(address)` - the devices a device can take the place of. rfd: the other
|
|
1376
|
+
* devices of the same `TYPE` (the simulator's model; measured: `[]` for rfd's own `BidCoS-RF`,
|
|
1377
|
+
* `-2 Unknown instance` for an address it does not know). hmipserver answers with an empty HTTP
|
|
1378
|
+
* body whatever it is asked (measured, firmware 3.89.11), which the simulator answers as `''`.
|
|
1379
|
+
*/
|
|
1380
|
+
listReplaceableDevices(iface, params) {
|
|
1381
|
+
const [address] = params || [];
|
|
1382
|
+
if (iface === 'hmip') {
|
|
1383
|
+
return '';
|
|
1384
|
+
}
|
|
1385
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1386
|
+
throw this.fault('unknownMethod', `${iface}/listReplaceableDevices`);
|
|
1387
|
+
}
|
|
1388
|
+
const device = this.requireDevice(iface, address);
|
|
1389
|
+
if (device.PARENT) {
|
|
1390
|
+
return [];
|
|
1391
|
+
}
|
|
1392
|
+
return this.deviceList(iface).filter((entry) => entry.TYPE === device.TYPE && entry.ADDRESS !== address);
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* `getVersion()` - the version string of the interface process: `interfaces.<iface>.version`,
|
|
1397
|
+
* by default what rfd (`2.6.0`) and hmipserver (the firmware, `3.89.11.20260919`) answered on
|
|
1398
|
+
* firmware 3.89.11. VirtualDevices and CUxD: unknown-method.
|
|
1399
|
+
*/
|
|
1400
|
+
getVersion(iface) {
|
|
1401
|
+
const version = this.interfaceOption(iface, 'version');
|
|
1402
|
+
if (version === undefined) {
|
|
1403
|
+
throw this.fault('unknownMethod', `${iface}/getVersion`);
|
|
1404
|
+
}
|
|
1405
|
+
return version;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/**
|
|
1409
|
+
* `getLGWStatus()` - rfd on firmware 3.89.11 answers `unknown method name`, measured on two
|
|
1410
|
+
* systems; `interfaces.rfd.lgwStatus` makes it answer that value instead, for a client that
|
|
1411
|
+
* shows it. BidCos only.
|
|
1412
|
+
*/
|
|
1413
|
+
getLGWStatus(iface) {
|
|
1414
|
+
const status = BIDCOS_INTERFACES.has(iface) ? this.interfaceOption(iface, 'lgwStatus') : undefined;
|
|
1415
|
+
if (status === undefined) {
|
|
1416
|
+
throw this.fault('unknownMethod', `${iface}/getLGWStatus`);
|
|
1417
|
+
}
|
|
1418
|
+
return status;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* `setInterfaceClock(utcSeconds, offsetMinutes)` - rfd and hmipserver set the clock they send the
|
|
1423
|
+
* devices. Recorded in `sim.interfaceClocks` (`{iface, utc, offset, ts}`) and answered with an
|
|
1424
|
+
* empty string (the simulator's model; not measured - it changes the system's clock).
|
|
1425
|
+
*/
|
|
1426
|
+
setInterfaceClock(iface, params) {
|
|
1427
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1428
|
+
throw this.fault('unknownMethod', `${iface}/setInterfaceClock`);
|
|
1429
|
+
}
|
|
1430
|
+
const [utc, offset = 0] = params || [];
|
|
1431
|
+
if (!Number.isFinite(Number(utc))) {
|
|
1432
|
+
throw this.fault('invalidArguments', 'setInterfaceClock wants the UTC seconds');
|
|
1433
|
+
}
|
|
1434
|
+
this.interfaceClocks.push({iface, utc: Number(utc), offset: Number(offset), ts: Date.now()});
|
|
983
1435
|
return '';
|
|
984
1436
|
}
|
|
985
1437
|
|
|
@@ -1014,6 +1466,67 @@ class HmSim {
|
|
|
1014
1466
|
return '';
|
|
1015
1467
|
}
|
|
1016
1468
|
|
|
1469
|
+
/**
|
|
1470
|
+
* `logLevel(level)` - rfd and hs485d set their log level and answer it; without a level they
|
|
1471
|
+
* answer the one in force (5 on the rfd measured 2026-09-28). hmipserver lists the
|
|
1472
|
+
* method but answers it with an empty HTTP body; the simulator answers an empty string there.
|
|
1473
|
+
* Elsewhere unknown-method.
|
|
1474
|
+
*
|
|
1475
|
+
* @param {string} iface
|
|
1476
|
+
* @param {Array} params [level]
|
|
1477
|
+
*/
|
|
1478
|
+
logLevel(iface, params) {
|
|
1479
|
+
if (iface === 'hmip') {
|
|
1480
|
+
return '';
|
|
1481
|
+
}
|
|
1482
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1483
|
+
throw this.fault('unknownMethod', `${iface}/logLevel`);
|
|
1484
|
+
}
|
|
1485
|
+
const [level] = params || [];
|
|
1486
|
+
if (level !== undefined) {
|
|
1487
|
+
if (!Number.isInteger(level)) {
|
|
1488
|
+
throw this.fault('invalidArguments', 'logLevel wants an integer');
|
|
1489
|
+
}
|
|
1490
|
+
this.logLevels[iface] = level;
|
|
1491
|
+
}
|
|
1492
|
+
return this.logLevels[iface] === undefined ? 5 : this.logLevels[iface];
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* `changeKey(passphrase)` - the interface's security key. rfd stores it and re-keys every
|
|
1497
|
+
* AES-capable device; hmipserver lists the method too. The simulator does no cryptography: the
|
|
1498
|
+
* call is recorded in `sim.keyChanges` (`{iface, key, ts}`) and answered with an empty string.
|
|
1499
|
+
* BidCos-Wired has no such method (unknown-method).
|
|
1500
|
+
*
|
|
1501
|
+
* @param {string} iface
|
|
1502
|
+
* @param {Array} params [passphrase]
|
|
1503
|
+
*/
|
|
1504
|
+
changeKey(iface, params) {
|
|
1505
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1506
|
+
throw this.fault('unknownMethod', `${iface}/changeKey`);
|
|
1507
|
+
}
|
|
1508
|
+
const [key] = params || [];
|
|
1509
|
+
if (typeof key !== 'string') {
|
|
1510
|
+
throw this.fault('invalidArguments', 'changeKey wants a string');
|
|
1511
|
+
}
|
|
1512
|
+
this.keyChanges.push({iface, key, ts: Date.now()});
|
|
1513
|
+
this.log.info(iface, 'security key changed');
|
|
1514
|
+
return '';
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
/**
|
|
1518
|
+
* `refreshDeployedDeviceFirmwareList()` - rfd and hmipserver read the directory of deployed
|
|
1519
|
+
* device firmware again and answer an empty value (measured 2026-09-28); hs485d and
|
|
1520
|
+
* VirtualDevices have no such method. Recorded in `sim.firmwareListRefreshes`.
|
|
1521
|
+
*/
|
|
1522
|
+
refreshDeployedDeviceFirmwareList(iface) {
|
|
1523
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1524
|
+
throw this.fault('unknownMethod', `${iface}/refreshDeployedDeviceFirmwareList`);
|
|
1525
|
+
}
|
|
1526
|
+
this.firmwareListRefreshes.push({iface, ts: Date.now()});
|
|
1527
|
+
return '';
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1017
1530
|
/* ------------------------------------------------------------------ teams */
|
|
1018
1531
|
|
|
1019
1532
|
/**
|
|
@@ -1169,6 +1682,12 @@ class HmSim {
|
|
|
1169
1682
|
if (this.poisonedChannels[iface]) {
|
|
1170
1683
|
this.poisonedChannels[iface].delete(entry);
|
|
1171
1684
|
}
|
|
1685
|
+
if (this.suppressed[iface]) {
|
|
1686
|
+
this.suppressed[iface].delete(entry);
|
|
1687
|
+
}
|
|
1688
|
+
if (this.metadata[iface]) {
|
|
1689
|
+
delete this.metadata[iface][entry];
|
|
1690
|
+
}
|
|
1172
1691
|
}
|
|
1173
1692
|
if (this.configPending[iface]) {
|
|
1174
1693
|
this.configPending[iface].delete(address);
|
|
@@ -1237,6 +1756,9 @@ class HmSim {
|
|
|
1237
1756
|
this.requireDevice(iface, address);
|
|
1238
1757
|
}
|
|
1239
1758
|
this.firmwareUpdates.push({iface, addresses, method: 'updateFirmware', ts: Date.now()});
|
|
1759
|
+
for (const address of addresses) {
|
|
1760
|
+
this.performFirmwareUpdate(iface, address);
|
|
1761
|
+
}
|
|
1240
1762
|
return true;
|
|
1241
1763
|
}
|
|
1242
1764
|
|
|
@@ -1244,6 +1766,7 @@ class HmSim {
|
|
|
1244
1766
|
const address = params[0];
|
|
1245
1767
|
this.requireDevice(iface, address);
|
|
1246
1768
|
this.firmwareUpdates.push({iface, addresses: [address], method: 'installFirmware', ts: Date.now()});
|
|
1769
|
+
this.performFirmwareUpdate(iface, address);
|
|
1247
1770
|
return true;
|
|
1248
1771
|
}
|
|
1249
1772
|
|
|
@@ -1322,6 +1845,212 @@ class HmSim {
|
|
|
1322
1845
|
return added;
|
|
1323
1846
|
}
|
|
1324
1847
|
|
|
1848
|
+
/* ------------------------------------------------------------------ device and radio health */
|
|
1849
|
+
|
|
1850
|
+
/** The device of an address, its maintenance channel `:0` and that channel's VALUES description. */
|
|
1851
|
+
maintenanceOf(iface, address) {
|
|
1852
|
+
const entry = this.requireDevice(iface, address);
|
|
1853
|
+
const device = entry.PARENT ? this.requireDevice(iface, entry.PARENT) : entry;
|
|
1854
|
+
const channel = `${device.ADDRESS}:0`;
|
|
1855
|
+
const description =
|
|
1856
|
+
(this.index[iface].has(channel) && this.getParamsetDescription(iface, channel, 'VALUES')) || {};
|
|
1857
|
+
return {device, channel, description};
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* Reports a datapoint of the maintenance channel: stored and sent the way the description says
|
|
1862
|
+
* (a service message by its FLAGS), or - when the description does not have it - as an event and
|
|
1863
|
+
* a service message raised through setServiceMessage().
|
|
1864
|
+
*/
|
|
1865
|
+
reportMaintenance(iface, channel, description, datapoint, value) {
|
|
1866
|
+
if (description[datapoint]) {
|
|
1867
|
+
this.setValue(iface, channel, datapoint, value, {internal: true});
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
if (typeof value === 'boolean') {
|
|
1871
|
+
this.setServiceMessage(iface, channel, datapoint, value);
|
|
1872
|
+
}
|
|
1873
|
+
this.fireEvent(iface, channel, datapoint, value);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* A device goes out of reach or comes back: `UNREACH` on its `:0` channel, and on the BidCos
|
|
1878
|
+
* interfaces `STICKY_UNREACH`, which stays after the device is back until a client writes it
|
|
1879
|
+
* `false` - as rfd does. What a write to the device answers meanwhile is the `unreachWrites`
|
|
1880
|
+
* option ('accept', or 'fault' with notReachable).
|
|
1881
|
+
*
|
|
1882
|
+
* @param {string} iface
|
|
1883
|
+
* @param {string} address the device or one of its channels
|
|
1884
|
+
* @param {boolean} reachable
|
|
1885
|
+
*/
|
|
1886
|
+
setReachable(iface, address, reachable) {
|
|
1887
|
+
const {device, channel, description} = this.maintenanceOf(iface, address);
|
|
1888
|
+
const unreachable = (this.unreachable[iface] = this.unreachable[iface] || new Set());
|
|
1889
|
+
if (reachable) {
|
|
1890
|
+
unreachable.delete(device.ADDRESS);
|
|
1891
|
+
} else {
|
|
1892
|
+
unreachable.add(device.ADDRESS);
|
|
1893
|
+
}
|
|
1894
|
+
this.reportMaintenance(iface, channel, description, 'UNREACH', !reachable);
|
|
1895
|
+
const sticky = description.STICKY_UNREACH || (BIDCOS_INTERFACES.has(iface) && !description.UNREACH);
|
|
1896
|
+
if (!reachable && sticky) {
|
|
1897
|
+
this.reportMaintenance(iface, channel, description, 'STICKY_UNREACH', true);
|
|
1898
|
+
}
|
|
1899
|
+
this.log.info(iface, device.ADDRESS, reachable ? 'reachable again' : 'unreachable');
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
/** Throws notReachable for a write to an unreachable device when `unreachWrites` is 'fault'. */
|
|
1903
|
+
requireReachable(iface, address) {
|
|
1904
|
+
if (this.interfaceOption(iface, 'unreachWrites') !== 'fault' || !this.unreachable[iface]) {
|
|
1905
|
+
return;
|
|
1906
|
+
}
|
|
1907
|
+
const entry = this.index[iface] && this.index[iface].get(address);
|
|
1908
|
+
const deviceAddress = entry ? entry.PARENT || entry.ADDRESS : address;
|
|
1909
|
+
if (this.unreachable[iface].has(deviceAddress)) {
|
|
1910
|
+
throw this.fault('notReachable', address);
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* Low battery: `LOW_BAT` or `LOWBAT` on the device's `:0` channel, whichever its description has
|
|
1916
|
+
* (`LOW_BAT` on hmip, `LOWBAT` on BidCos without one).
|
|
1917
|
+
*/
|
|
1918
|
+
setLowBattery(iface, address, low) {
|
|
1919
|
+
const {channel, description} = this.maintenanceOf(iface, address);
|
|
1920
|
+
let datapoint = iface === 'hmip' ? 'LOW_BAT' : 'LOWBAT';
|
|
1921
|
+
if (description.LOW_BAT) {
|
|
1922
|
+
datapoint = 'LOW_BAT';
|
|
1923
|
+
} else if (description.LOWBAT) {
|
|
1924
|
+
datapoint = 'LOWBAT';
|
|
1925
|
+
}
|
|
1926
|
+
this.reportMaintenance(iface, channel, description, datapoint, Boolean(low));
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* The radio module of an interface, if the device list has it: the device whose address is the
|
|
1931
|
+
* last 14 characters of the interface's address (an SGTIN), or one of a radio module's types.
|
|
1932
|
+
*/
|
|
1933
|
+
radioModule(iface) {
|
|
1934
|
+
const address = String((this.listBidcosInterfaces(iface)[0] || {}).ADDRESS || '');
|
|
1935
|
+
const devices = this.deviceList(iface);
|
|
1936
|
+
return (
|
|
1937
|
+
devices.find((device) => device.ADDRESS === address.slice(-14) || device.ADDRESS === address) ||
|
|
1938
|
+
devices.find((device) => RADIO_MODULE_TYPES.has(device.TYPE))
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Sets a radio level of an interface: in what `listBidcosInterfaces` answers, and on hmip as
|
|
1944
|
+
* the event of the radio module's `:0` channel, when the device list has a radio module.
|
|
1945
|
+
*/
|
|
1946
|
+
setRadioLevel(iface, field, datapoint, percent) {
|
|
1947
|
+
if (typeof percent !== 'number' || Number.isNaN(percent)) {
|
|
1948
|
+
throw new TypeError(`${field} wants a number`);
|
|
1949
|
+
}
|
|
1950
|
+
this.radio[iface] = {...this.radio[iface], [field]: percent};
|
|
1951
|
+
const module = iface === 'hmip' ? this.radioModule(iface) : undefined;
|
|
1952
|
+
if (module) {
|
|
1953
|
+
const {channel, description} = this.maintenanceOf(iface, module.ADDRESS);
|
|
1954
|
+
this.reportMaintenance(iface, channel, description, datapoint, percent);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/** The duty cycle of an interface in percent: `DUTY_CYCLE`, on hmip also `DUTY_CYCLE_LEVEL`. */
|
|
1959
|
+
setDutyCycle(iface, percent) {
|
|
1960
|
+
this.setRadioLevel(iface, 'DUTY_CYCLE', 'DUTY_CYCLE_LEVEL', percent);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** The carrier sense level of an interface in percent: `CARRIER_SENSE_LEVEL`. */
|
|
1964
|
+
setCarrierSense(iface, percent) {
|
|
1965
|
+
this.setRadioLevel(iface, 'CARRIER_SENSE_LEVEL', 'CARRIER_SENSE_LEVEL', percent);
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
/**
|
|
1969
|
+
* A firmware update becomes available for a device: `AVAILABLE_FIRMWARE`,
|
|
1970
|
+
* `FIRMWARE_UPDATE_STATE: 'NEW_FIRMWARE_AVAILABLE'` and `UPDATABLE` in its description,
|
|
1971
|
+
* `UPDATE_PENDING` on `:0` where the description has it, and `updateDevice` to the clients.
|
|
1972
|
+
* A following `updateFirmware`/`installFirmware` walks `FIRMWARE_UPDATE_STATE` through
|
|
1973
|
+
* DO_UPDATE_PENDING, PERFORMING_UPDATE and UP_TO_DATE, one step per `firmwareUpdateDelay`, and
|
|
1974
|
+
* sets `FIRMWARE` at the end. The state names are the RPC specification's; the timing is a model.
|
|
1975
|
+
*
|
|
1976
|
+
* @param {string} iface
|
|
1977
|
+
* @param {string} address the device
|
|
1978
|
+
* @param {string} version
|
|
1979
|
+
*/
|
|
1980
|
+
offerFirmware(iface, address, version) {
|
|
1981
|
+
const device = this.requireDevice(iface, address);
|
|
1982
|
+
if (device.PARENT) {
|
|
1983
|
+
throw this.fault('notSupported', 'firmware is offered to a device, not a channel');
|
|
1984
|
+
}
|
|
1985
|
+
device.AVAILABLE_FIRMWARE = String(version);
|
|
1986
|
+
device.FIRMWARE_UPDATE_STATE = 'NEW_FIRMWARE_AVAILABLE';
|
|
1987
|
+
device.UPDATABLE = device.UPDATABLE === undefined ? 1 : device.UPDATABLE;
|
|
1988
|
+
const {channel, description} = this.maintenanceOf(iface, address);
|
|
1989
|
+
if (description.UPDATE_PENDING) {
|
|
1990
|
+
this.setValue(iface, channel, 'UPDATE_PENDING', true, {internal: true});
|
|
1991
|
+
}
|
|
1992
|
+
this.tellClientsParams(iface, 'updateDevice', [address, 0]);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
performFirmwareUpdate(iface, address) {
|
|
1996
|
+
const device = this.index[iface].get(address);
|
|
1997
|
+
if (!device || device.PARENT || device.FIRMWARE_UPDATE_STATE !== 'NEW_FIRMWARE_AVAILABLE') {
|
|
1998
|
+
return;
|
|
1999
|
+
}
|
|
2000
|
+
const delay = this.interfaceOption(iface, 'firmwareUpdateDelay');
|
|
2001
|
+
const step = (index) => {
|
|
2002
|
+
// the device can be gone by now
|
|
2003
|
+
if (this.index[iface].get(address) !== device) {
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
device.FIRMWARE_UPDATE_STATE = FIRMWARE_UPDATE_STEPS[index];
|
|
2007
|
+
if (index === FIRMWARE_UPDATE_STEPS.length - 1) {
|
|
2008
|
+
device.FIRMWARE = device.AVAILABLE_FIRMWARE;
|
|
2009
|
+
const {channel, description} = this.maintenanceOf(iface, address);
|
|
2010
|
+
if (description.UPDATE_PENDING) {
|
|
2011
|
+
this.setValue(iface, channel, 'UPDATE_PENDING', false, {internal: true});
|
|
2012
|
+
}
|
|
2013
|
+
this.log.info(iface, address, 'firmware', device.FIRMWARE);
|
|
2014
|
+
} else {
|
|
2015
|
+
this.timer(() => step(index + 1), delay);
|
|
2016
|
+
}
|
|
2017
|
+
this.tellClientsParams(iface, 'updateDevice', [address, 0]);
|
|
2018
|
+
};
|
|
2019
|
+
this.timer(() => step(0), delay);
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Runs scenario calls on the simulator's timer: `[{at: ms, call: 'setReachable', args: [...]}]`,
|
|
2024
|
+
* `at` counted from now. Resolves with the results once every step ran; `close()` cancels what
|
|
2025
|
+
* did not run yet (and the promise then never resolves).
|
|
2026
|
+
*
|
|
2027
|
+
* @param {Array<{at: number, call: string, args: Array}>} steps
|
|
2028
|
+
* @returns {Promise<Array>}
|
|
2029
|
+
*/
|
|
2030
|
+
schedule(steps) {
|
|
2031
|
+
const list = steps || [];
|
|
2032
|
+
for (const step of list) {
|
|
2033
|
+
if (!SCENARIO_METHODS.has(step.call) || step.call === 'schedule' || typeof this[step.call] !== 'function') {
|
|
2034
|
+
throw new Error(`schedule: ${step.call} is no scenario call`);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
return Promise.all(
|
|
2038
|
+
list.map(
|
|
2039
|
+
(step) =>
|
|
2040
|
+
new Promise((resolve) => {
|
|
2041
|
+
this.timer(async () => {
|
|
2042
|
+
try {
|
|
2043
|
+
resolve(await this[step.call](...(step.args || [])));
|
|
2044
|
+
} catch (error) {
|
|
2045
|
+
this.log.error('schedule', step.call, error.message);
|
|
2046
|
+
resolve(error);
|
|
2047
|
+
}
|
|
2048
|
+
}, step.at || 0);
|
|
2049
|
+
}),
|
|
2050
|
+
),
|
|
2051
|
+
);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
1325
2054
|
/* ------------------------------------------------------------------ scenario api */
|
|
1326
2055
|
|
|
1327
2056
|
/**
|
|
@@ -1385,7 +2114,10 @@ class HmSim {
|
|
|
1385
2114
|
|
|
1386
2115
|
const port = this.ports[iface];
|
|
1387
2116
|
this.log.info(iface, 'dropping all connections');
|
|
1388
|
-
|
|
2117
|
+
if (!this.stopped.has(iface)) {
|
|
2118
|
+
await server.close();
|
|
2119
|
+
}
|
|
2120
|
+
this.stopped.delete(iface);
|
|
1389
2121
|
|
|
1390
2122
|
this.ready = [];
|
|
1391
2123
|
this.servers[iface] = factory(port);
|
|
@@ -1393,6 +2125,109 @@ class HmSim {
|
|
|
1393
2125
|
await this.whenReady();
|
|
1394
2126
|
}
|
|
1395
2127
|
|
|
2128
|
+
/**
|
|
2129
|
+
* Stops an interface process: its port refuses connections, open connections are reset. The
|
|
2130
|
+
* registered clients stay known until startInterface() decides what the process remembers.
|
|
2131
|
+
*
|
|
2132
|
+
* @param {string} iface
|
|
2133
|
+
* @returns {Promise<void>} once the port is closed
|
|
2134
|
+
*/
|
|
2135
|
+
async stopInterface(iface) {
|
|
2136
|
+
const server = this.servers[iface];
|
|
2137
|
+
if (!server || this.stopped.has(iface)) {
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
this.stopped.add(iface);
|
|
2141
|
+
this.log.info(iface, 'interface process stopped');
|
|
2142
|
+
await server.close();
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* Starts a stopped interface process again on the same port.
|
|
2147
|
+
*
|
|
2148
|
+
* With `forgetClients` (the default) the registered clients are forgotten, as by a process
|
|
2149
|
+
* that keeps no list of them: no event reaches a client until it calls `init` again, and
|
|
2150
|
+
* nothing tells it. Without, the process remembers them and calls each back the way rfd does
|
|
2151
|
+
* when it starts with its handlers file: `system.listMethods`, then `listDevices`, and
|
|
2152
|
+
* `newDevices`/`deleteDevices` as at an init - a client sees calls it did not ask for, which is
|
|
2153
|
+
* how it can tell that the process restarted.
|
|
2154
|
+
*
|
|
2155
|
+
* @param {string} iface
|
|
2156
|
+
* @param {object} [options]
|
|
2157
|
+
* @param {boolean} [options.forgetClients=true]
|
|
2158
|
+
* @returns {Promise<void>} once the port accepts connections again
|
|
2159
|
+
*/
|
|
2160
|
+
async startInterface(iface, {forgetClients = true} = {}) {
|
|
2161
|
+
const factory = this.serverFactories[iface];
|
|
2162
|
+
if (!factory || !this.stopped.has(iface)) {
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
if (forgetClients) {
|
|
2166
|
+
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
2167
|
+
this.forgetClient(iface, key);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
const before = this.ready.length;
|
|
2172
|
+
this.servers[iface] = factory(this.ports[iface]);
|
|
2173
|
+
this.exposeServer(iface);
|
|
2174
|
+
const ready = this.ready.slice(before);
|
|
2175
|
+
this.ready.splice(before);
|
|
2176
|
+
await Promise.all(ready);
|
|
2177
|
+
this.stopped.delete(iface);
|
|
2178
|
+
this.log.info(iface, 'interface process started');
|
|
2179
|
+
|
|
2180
|
+
for (const client of Object.values(this.clients[iface] || {})) {
|
|
2181
|
+
const listDevices = () => this.startInit(iface, client);
|
|
2182
|
+
if (BIDCOS_INTERFACES.has(iface)) {
|
|
2183
|
+
client.methodCall('system.listMethods', [client.id], listDevices);
|
|
2184
|
+
} else {
|
|
2185
|
+
listDevices();
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* An interface process restarts: stopInterface(), `downMs` with the port closed,
|
|
2192
|
+
* startInterface(). See startInterface() for `forgetClients`.
|
|
2193
|
+
*
|
|
2194
|
+
* @param {string} iface
|
|
2195
|
+
* @param {object} [options]
|
|
2196
|
+
* @param {number} [options.downMs=0]
|
|
2197
|
+
* @param {boolean} [options.forgetClients=true]
|
|
2198
|
+
* @returns {Promise<void>} once the port accepts connections again
|
|
2199
|
+
*/
|
|
2200
|
+
async restartInterface(iface, {downMs = 0, forgetClients = true} = {}) {
|
|
2201
|
+
await this.stopInterface(iface);
|
|
2202
|
+
if (downMs > 0) {
|
|
2203
|
+
await new Promise((resolve) => this.timer(resolve, downMs));
|
|
2204
|
+
}
|
|
2205
|
+
await this.startInterface(iface, {forgetClients});
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/**
|
|
2209
|
+
* Sends a burst of events the way the interface processes do, as `system.multicall` batches.
|
|
2210
|
+
*
|
|
2211
|
+
* @param {string} iface
|
|
2212
|
+
* @param {Array<Array>} events `[[address, datapoint, value], ...]`, not validated
|
|
2213
|
+
* @param {object} [options]
|
|
2214
|
+
* @param {number} [options.batch] events per multicall, all in one when omitted
|
|
2215
|
+
*/
|
|
2216
|
+
fireEvents(iface, events, {batch} = {}) {
|
|
2217
|
+
const list = events || [];
|
|
2218
|
+
for (const [address, datapoint, value] of list) {
|
|
2219
|
+
const state = this.values[iface] && this.values[iface][address];
|
|
2220
|
+
if (state) {
|
|
2221
|
+
state.VALUES[datapoint] = value;
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
const size = batch > 0 ? batch : list.length;
|
|
2225
|
+
for (let index = 0; index < list.length; index += size) {
|
|
2226
|
+
this.eventMulticall(iface, list.slice(index, index + size));
|
|
2227
|
+
}
|
|
2228
|
+
return list.length;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
1396
2231
|
/**
|
|
1397
2232
|
* Raises or clears a service message. If the channel has the datapoint in its VALUES
|
|
1398
2233
|
* description the value is written as well, so that the logic layer sees the event too.
|
|
@@ -1439,10 +2274,88 @@ class HmSim {
|
|
|
1439
2274
|
tellClientsParams(iface, method, params) {
|
|
1440
2275
|
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1441
2276
|
const client = this.clients[iface][key];
|
|
1442
|
-
|
|
2277
|
+
this.deliver(iface, client, method, [client.id, ...params]);
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
/**
|
|
2282
|
+
* One call to one registered client: held back while a registration of the interface hangs
|
|
2283
|
+
* (the 'measured' hmip model), and under the 'measured' BidCos model the client is dropped when
|
|
2284
|
+
* it does not answer within `deliveryTimeout`.
|
|
2285
|
+
*/
|
|
2286
|
+
deliver(iface, client, method, params) {
|
|
2287
|
+
const held = this.held[iface];
|
|
2288
|
+
if (held && held.count > 0) {
|
|
2289
|
+
held.queue.push(() => this.deliver(iface, client, method, params));
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
const measuredBidcos =
|
|
2293
|
+
BIDCOS_INTERFACES.has(iface) && this.interfaceOption(iface, 'listenerModel') === 'measured';
|
|
2294
|
+
if (!measuredBidcos) {
|
|
2295
|
+
client.methodCall(method, params);
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
client.methodCall(
|
|
2299
|
+
method,
|
|
2300
|
+
params,
|
|
2301
|
+
(error) => {
|
|
2302
|
+
if (error && this.clients[iface][client.key] === client) {
|
|
2303
|
+
this.log.warn(iface, 'client', client.url, 'did not take', method, '- dropped:', error.message);
|
|
2304
|
+
this.forgetClient(iface, client.key);
|
|
2305
|
+
}
|
|
2306
|
+
},
|
|
2307
|
+
{timeout: this.interfaceOption(iface, 'deliveryTimeout')},
|
|
2308
|
+
);
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
forgetClient(iface, key) {
|
|
2312
|
+
const client = this.clients[iface] && this.clients[iface][key];
|
|
2313
|
+
if (client) {
|
|
2314
|
+
closeClient(client.client);
|
|
2315
|
+
delete this.clients[iface][key];
|
|
1443
2316
|
}
|
|
1444
2317
|
}
|
|
1445
2318
|
|
|
2319
|
+
/** Holds every delivery of an interface back until the returned function is called. */
|
|
2320
|
+
hold(iface) {
|
|
2321
|
+
const held = (this.held[iface] = this.held[iface] || {count: 0, queue: []});
|
|
2322
|
+
held.count++;
|
|
2323
|
+
let released = false;
|
|
2324
|
+
return () => {
|
|
2325
|
+
if (released) {
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
released = true;
|
|
2329
|
+
held.count--;
|
|
2330
|
+
if (held.count === 0) {
|
|
2331
|
+
const queue = held.queue.splice(0);
|
|
2332
|
+
for (const send of queue) {
|
|
2333
|
+
send();
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
};
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Makes every incoming call of an interface wait until the returned function is called - the
|
|
2341
|
+
* interface process is busy with something that does not return.
|
|
2342
|
+
*/
|
|
2343
|
+
closeGate(iface) {
|
|
2344
|
+
let release;
|
|
2345
|
+
const gate = new Promise((resolve) => {
|
|
2346
|
+
release = resolve;
|
|
2347
|
+
});
|
|
2348
|
+
const previous = this.gates[iface];
|
|
2349
|
+
const combined = previous ? Promise.all([previous, gate]) : gate;
|
|
2350
|
+
this.gates[iface] = combined;
|
|
2351
|
+
return () => {
|
|
2352
|
+
release();
|
|
2353
|
+
if (this.gates[iface] === combined) {
|
|
2354
|
+
delete this.gates[iface];
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
|
|
1446
2359
|
/** A timer that never keeps the process alive and is cleared by close(). */
|
|
1447
2360
|
timer(callback, ms) {
|
|
1448
2361
|
const handle = setTimeout(() => {
|
|
@@ -1456,6 +2369,13 @@ class HmSim {
|
|
|
1456
2369
|
return handle;
|
|
1457
2370
|
}
|
|
1458
2371
|
|
|
2372
|
+
clearTimer(handle) {
|
|
2373
|
+
if (handle) {
|
|
2374
|
+
clearTimeout(handle);
|
|
2375
|
+
this.timers.delete(handle);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
1459
2379
|
/**
|
|
1460
2380
|
* Every putParamset the simulator accepted, oldest first.
|
|
1461
2381
|
* @returns {Array<{iface: string, address: string, paramset: string, values: object, ts: number}>}
|
|
@@ -1476,12 +2396,16 @@ class HmSim {
|
|
|
1476
2396
|
getDeviceDescription: (iface, params) => this.getDeviceDescription(iface, params[0]),
|
|
1477
2397
|
ping: (iface, params) => this.ping(iface, params),
|
|
1478
2398
|
setValue: (iface, params) => {
|
|
2399
|
+
this.requireReachable(iface, params[0]);
|
|
1479
2400
|
this.setValue(iface, params[0], params[1], params[2], {strict: true});
|
|
1480
2401
|
return '';
|
|
1481
2402
|
},
|
|
1482
2403
|
getValue: (iface, params) => this.getValue(iface, params[0], params[1]),
|
|
1483
2404
|
getParamset: (iface, params) => this.getParamset(iface, params[0], params[1]),
|
|
1484
|
-
putParamset: (iface, params) =>
|
|
2405
|
+
putParamset: (iface, params) => {
|
|
2406
|
+
this.requireReachable(iface, params[0]);
|
|
2407
|
+
return this.putParamset(iface, params[0], params[1], params[2]);
|
|
2408
|
+
},
|
|
1485
2409
|
'system.methodHelp': (iface, params) => this.methodHelp(params[0]),
|
|
1486
2410
|
getLinks: (iface, params) => this.getLinks(iface, params),
|
|
1487
2411
|
getLinkPeers: (iface, params) => this.getLinkPeers(iface, params),
|
|
@@ -1507,6 +2431,20 @@ class HmSim {
|
|
|
1507
2431
|
clearConfigCache: (iface, params) => this.clearConfigCache(iface, params[0]),
|
|
1508
2432
|
restoreConfigToDevice: (iface, params) => this.restoreConfigToDevice(iface, params[0]),
|
|
1509
2433
|
determineParameter: (iface, params) => this.determineParameter(iface, params),
|
|
2434
|
+
logLevel: (iface, params) => this.logLevel(iface, params),
|
|
2435
|
+
changeKey: (iface, params) => this.changeKey(iface, params),
|
|
2436
|
+
refreshDeployedDeviceFirmwareList: (iface) => this.refreshDeployedDeviceFirmwareList(iface),
|
|
2437
|
+
getKeyMismatchDevice: (iface, params) => this.getKeyMismatchDevice(iface, params),
|
|
2438
|
+
addDevice: (iface, params) => this.addDeviceBySerial(iface, params),
|
|
2439
|
+
suppressServiceMessages: (iface, params) => this.suppressServiceMessages(iface, params),
|
|
2440
|
+
getSuppressedServiceMessages: (iface, params) => this.getSuppressedServiceMessages(iface, params),
|
|
2441
|
+
getMetadata: (iface, params) => this.getMetadata(iface, params),
|
|
2442
|
+
setMetadata: (iface, params) => this.setMetadata(iface, params),
|
|
2443
|
+
getAllMetadata: (iface, params) => this.getAllMetadata(iface, params),
|
|
2444
|
+
listReplaceableDevices: (iface, params) => this.listReplaceableDevices(iface, params),
|
|
2445
|
+
getVersion: (iface) => this.getVersion(iface),
|
|
2446
|
+
getLGWStatus: (iface) => this.getLGWStatus(iface),
|
|
2447
|
+
setInterfaceClock: (iface, params) => this.setInterfaceClock(iface, params),
|
|
1510
2448
|
};
|
|
1511
2449
|
|
|
1512
2450
|
// the CCU accepts the lower case spelling as well
|
|
@@ -1521,15 +2459,17 @@ class HmSim {
|
|
|
1521
2459
|
|
|
1522
2460
|
multicall(iface, params) {
|
|
1523
2461
|
const calls = (params && params[0]) || [];
|
|
2462
|
+
const asFault = (error) => ({faultCode: error.faultCode, faultString: error.faultString});
|
|
1524
2463
|
const results = [];
|
|
1525
2464
|
for (const call of calls) {
|
|
1526
2465
|
try {
|
|
1527
|
-
|
|
2466
|
+
const result = this.callMethod(iface, call.methodName, call.params || []);
|
|
2467
|
+
results.push(isThenable(result) ? result.then((value) => [value], asFault) : [result]);
|
|
1528
2468
|
} catch (error) {
|
|
1529
|
-
results.push(
|
|
2469
|
+
results.push(asFault(error));
|
|
1530
2470
|
}
|
|
1531
2471
|
}
|
|
1532
|
-
return results;
|
|
2472
|
+
return results.some(isThenable) ? Promise.all(results) : results;
|
|
1533
2473
|
}
|
|
1534
2474
|
|
|
1535
2475
|
callMethod(iface, method, params) {
|
|
@@ -1548,32 +2488,158 @@ class HmSim {
|
|
|
1548
2488
|
* @param {Array} params
|
|
1549
2489
|
* @param {function} callback (fault, result)
|
|
1550
2490
|
*/
|
|
1551
|
-
dispatch(iface, method, params, callback) {
|
|
2491
|
+
dispatch(iface, method, params, callback, context = {}) {
|
|
1552
2492
|
this.log.debug('rpc', iface, '<', method, shortenParams(params));
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2493
|
+
const gate = this.gates[iface];
|
|
2494
|
+
if (gate) {
|
|
2495
|
+
gate.then(() => this.dispatchNow(iface, method, params, callback, context));
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
this.dispatchNow(iface, method, params, callback, context);
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
dispatchNow(iface, method, params, callback, context) {
|
|
2502
|
+
const injected = this.takeInjectedFault(iface, method);
|
|
2503
|
+
if (!injected) {
|
|
2504
|
+
this.execute(iface, method, params, callback);
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
const act = () => {
|
|
2509
|
+
if (injected.hang) {
|
|
2510
|
+
this.log.info('rpc', iface, method, 'injected: never answered');
|
|
2511
|
+
return;
|
|
2512
|
+
}
|
|
2513
|
+
if (injected.closeSocket) {
|
|
2514
|
+
this.log.info('rpc', iface, method, 'injected: connection closed');
|
|
2515
|
+
if (context.socket) {
|
|
2516
|
+
context.socket.destroy();
|
|
2517
|
+
}
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
if (injected.fault !== undefined) {
|
|
2521
|
+
callback(this.injectedFaultOf(injected.fault, method));
|
|
2522
|
+
return;
|
|
2523
|
+
}
|
|
2524
|
+
this.execute(iface, method, params, callback);
|
|
2525
|
+
};
|
|
2526
|
+
if (injected.delayMs > 0) {
|
|
2527
|
+
this.timer(act, injected.delayMs);
|
|
2528
|
+
} else {
|
|
2529
|
+
act();
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
execute(iface, method, params, callback) {
|
|
2534
|
+
const failed = (error) => {
|
|
1557
2535
|
if (error.faultCode === undefined) {
|
|
1558
2536
|
this.log.error('rpc', iface, method, error.stack || error.message);
|
|
1559
2537
|
callback(this.fault('invalidArguments', error.message));
|
|
1560
2538
|
return;
|
|
1561
2539
|
}
|
|
1562
2540
|
callback(error);
|
|
2541
|
+
};
|
|
2542
|
+
let result;
|
|
2543
|
+
try {
|
|
2544
|
+
result = this.callMethod(iface, method, params);
|
|
2545
|
+
} catch (error) {
|
|
2546
|
+
failed(error);
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
if (isThenable(result)) {
|
|
2550
|
+
result.then((value) => callback(null, value === undefined ? '' : value), failed);
|
|
1563
2551
|
return;
|
|
1564
2552
|
}
|
|
1565
2553
|
callback(null, result);
|
|
1566
2554
|
}
|
|
1567
2555
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
2556
|
+
/* ------------------------------------------------------------------ fault injection */
|
|
2557
|
+
|
|
2558
|
+
/**
|
|
2559
|
+
* Makes the next calls of a method misbehave.
|
|
2560
|
+
*
|
|
2561
|
+
* @param {object} rule
|
|
2562
|
+
* @param {string} [rule.iface] the interface, every one when omitted
|
|
2563
|
+
* @param {string} [rule.method='*'] the method, `'*'` for any
|
|
2564
|
+
* @param {number} [rule.times=1] how many calls, `Infinity` or `-1` for all until clearFaults()
|
|
2565
|
+
* @param {number} [rule.delayMs] answer (or misbehave) only after this many milliseconds
|
|
2566
|
+
* @param {boolean} [rule.hang] never answer; the connection stays open
|
|
2567
|
+
* @param {boolean} [rule.closeSocket] close the connection instead of answering
|
|
2568
|
+
* @param {object|string} [rule.fault] answer this fault: `{faultCode, faultString}` or the name
|
|
2569
|
+
* of an entry of the fault table (`'unknownInstance'`, `'notReachable'`, ...)
|
|
2570
|
+
*/
|
|
2571
|
+
injectFault(rule = {}) {
|
|
2572
|
+
const entry = {method: '*', times: 1, ...rule};
|
|
2573
|
+
if (entry.fault !== undefined) {
|
|
2574
|
+
// check now, not when the call arrives
|
|
2575
|
+
this.injectedFaultOf(entry.fault, entry.method);
|
|
2576
|
+
}
|
|
2577
|
+
this.injectedFaults.push(entry);
|
|
2578
|
+
return entry;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
/** Removes every injected fault that did not happen yet. */
|
|
2582
|
+
clearFaults() {
|
|
2583
|
+
this.injectedFaults = [];
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
takeInjectedFault(iface, method) {
|
|
2587
|
+
const index = this.injectedFaults.findIndex(
|
|
2588
|
+
(rule) =>
|
|
2589
|
+
(rule.iface === undefined || rule.iface === iface) && (rule.method === '*' || rule.method === method),
|
|
2590
|
+
);
|
|
2591
|
+
if (index === -1) {
|
|
2592
|
+
return undefined;
|
|
2593
|
+
}
|
|
2594
|
+
const rule = this.injectedFaults[index];
|
|
2595
|
+
// Infinity or a negative number: until clearFaults() (-1 is what JSON can carry)
|
|
2596
|
+
if (rule.times !== Infinity && rule.times >= 0) {
|
|
2597
|
+
rule.times--;
|
|
2598
|
+
if (rule.times <= 0) {
|
|
2599
|
+
this.injectedFaults.splice(index, 1);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return rule;
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
injectedFaultOf(fault, method) {
|
|
2606
|
+
if (typeof fault === 'string') {
|
|
2607
|
+
if (!this.faults[fault]) {
|
|
2608
|
+
throw new Error(`no fault ${fault} in the fault table`);
|
|
2609
|
+
}
|
|
2610
|
+
return this.fault(fault, `injected into ${method}`);
|
|
1574
2611
|
}
|
|
2612
|
+
if (!fault || typeof fault.faultCode !== 'number') {
|
|
2613
|
+
throw new Error('an injected fault is a name of the fault table or {faultCode, faultString}');
|
|
2614
|
+
}
|
|
2615
|
+
return {faultCode: fault.faultCode, faultString: fault.faultString || ''};
|
|
2616
|
+
}
|
|
1575
2617
|
|
|
1576
|
-
|
|
2618
|
+
/**
|
|
2619
|
+
* Every call the simulator made to a registered client, oldest first (the last 10000):
|
|
2620
|
+
* `{iface, client, method, sentAt, answeredAt, error}`. `answeredAt` is null while the client
|
|
2621
|
+
* has not answered, `error` the transport error or timeout.
|
|
2622
|
+
*/
|
|
2623
|
+
getCallbackLog() {
|
|
2624
|
+
return this.callbackLog.map((entry) => ({...entry}));
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
/**
|
|
2628
|
+
* `init(url, interfaceId)` - registers a logic layer, or with an empty id removes it.
|
|
2629
|
+
*
|
|
2630
|
+
* The url is `xmlrpc_bin://host:port` or `binrpc://…` for BIN-RPC callbacks, `http://` or
|
|
2631
|
+
* `https://host:port/path` for XML-RPC ones (the path is kept: a callback server may serve
|
|
2632
|
+
* several interfaces on one port). A registration is identified by the url exactly, scheme and
|
|
2633
|
+
* path included, as the interface processes do: `init('http://h:1', '')` does not remove a
|
|
2634
|
+
* `xmlrpc_bin://h:1` registration.
|
|
2635
|
+
*/
|
|
2636
|
+
init(iface, params) {
|
|
2637
|
+
const [url, id] = params || [];
|
|
2638
|
+
const parsed = parseCallbackUrl(url);
|
|
2639
|
+
if (!parsed) {
|
|
2640
|
+
throw this.fault('invalidArguments', `init url ${url}`);
|
|
2641
|
+
}
|
|
2642
|
+
const clientId = String(url);
|
|
1577
2643
|
|
|
1578
2644
|
if (id === '' || id === undefined) {
|
|
1579
2645
|
const client = this.clients[iface][clientId];
|
|
@@ -1583,21 +2649,12 @@ class HmSim {
|
|
|
1583
2649
|
delete this.clients[iface][clientId];
|
|
1584
2650
|
}
|
|
1585
2651
|
} else {
|
|
1586
|
-
const
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
this.log.debug('rpc >', url, methodName, shortenParams(methodParams));
|
|
1593
|
-
client.client.methodCall(methodName, methodParams, (error, result) => {
|
|
1594
|
-
this.log.debug('rpc <', url, shortenParams(result));
|
|
1595
|
-
if (typeof callback === 'function') {
|
|
1596
|
-
callback(error, result);
|
|
1597
|
-
}
|
|
1598
|
-
});
|
|
1599
|
-
},
|
|
1600
|
-
};
|
|
2652
|
+
const client = this.createClient(iface, parsed, url, id, clientId);
|
|
2653
|
+
if (this.interfaceOption(iface, 'listenerModel') === 'measured') {
|
|
2654
|
+
return BIDCOS_INTERFACES.has(iface)
|
|
2655
|
+
? this.initMeasuredBidcos(iface, client)
|
|
2656
|
+
: this.initMeasuredHmip(iface, client);
|
|
2657
|
+
}
|
|
1601
2658
|
this.clients[iface][clientId] = client;
|
|
1602
2659
|
this.startInit(iface, client);
|
|
1603
2660
|
}
|
|
@@ -1605,14 +2662,134 @@ class HmSim {
|
|
|
1605
2662
|
return '';
|
|
1606
2663
|
}
|
|
1607
2664
|
|
|
2665
|
+
/** A registered logic layer: its RPC client, and a methodCall that logs and can time out. */
|
|
2666
|
+
createClient(iface, {protocol, host, port, path}, url, id, key) {
|
|
2667
|
+
let rpcClient;
|
|
2668
|
+
if (protocol === 'binrpc') {
|
|
2669
|
+
rpcClient = binrpc.createClient({host, port});
|
|
2670
|
+
} else if (protocol === 'https') {
|
|
2671
|
+
rpcClient = xmlrpc.createSecureClient({host, port, path, rejectUnauthorized: false});
|
|
2672
|
+
} else {
|
|
2673
|
+
rpcClient = xmlrpc.createClient({host, port, path});
|
|
2674
|
+
}
|
|
2675
|
+
const client = {
|
|
2676
|
+
id,
|
|
2677
|
+
url,
|
|
2678
|
+
key,
|
|
2679
|
+
client: rpcClient,
|
|
2680
|
+
methodCall: (methodName, methodParams, callback, {timeout} = {}) => {
|
|
2681
|
+
this.log.debug('rpc >', url, methodName, shortenParams(methodParams));
|
|
2682
|
+
const entry = {
|
|
2683
|
+
iface,
|
|
2684
|
+
client: url,
|
|
2685
|
+
method: methodName,
|
|
2686
|
+
sentAt: Date.now(),
|
|
2687
|
+
answeredAt: null,
|
|
2688
|
+
error: null,
|
|
2689
|
+
};
|
|
2690
|
+
this.logCallback(entry);
|
|
2691
|
+
let done = false;
|
|
2692
|
+
let timer;
|
|
2693
|
+
const finish = (error, result) => {
|
|
2694
|
+
if (done) {
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
done = true;
|
|
2698
|
+
this.clearTimer(timer);
|
|
2699
|
+
entry.answeredAt = Date.now();
|
|
2700
|
+
entry.error = error ? String(error.message || error) : null;
|
|
2701
|
+
this.log.debug('rpc <', url, error ? error.message : shortenParams(result));
|
|
2702
|
+
if (typeof callback === 'function') {
|
|
2703
|
+
callback(error, result);
|
|
2704
|
+
}
|
|
2705
|
+
};
|
|
2706
|
+
if (timeout > 0) {
|
|
2707
|
+
timer = this.timer(() => finish(new Error(`no answer within ${timeout} ms`)), timeout);
|
|
2708
|
+
}
|
|
2709
|
+
client.client.methodCall(methodName, methodParams, finish);
|
|
2710
|
+
},
|
|
2711
|
+
};
|
|
2712
|
+
return client;
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
logCallback(entry) {
|
|
2716
|
+
this.callbackLog.push(entry);
|
|
2717
|
+
if (this.callbackLog.length > CALLBACK_LOG_SIZE) {
|
|
2718
|
+
this.callbackLog.splice(0, this.callbackLog.length - CALLBACK_LOG_SIZE);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
/**
|
|
2723
|
+
* rfd's `init`, as measured with a callback server that never answers (firmware 3.89.8,
|
|
2724
|
+
* 2026-09-25): rfd calls `system.listMethods` back *before* it answers the `init`, and until
|
|
2725
|
+
* that callback returns, rfd answers nothing else at all - every other client's calls wait.
|
|
2726
|
+
* When the callback fails (the connection ended), rfd tries it three more times at once and
|
|
2727
|
+
* registers the client anyway. A registered client that does not take a callback within
|
|
2728
|
+
* `deliveryTimeout` (10 s measured) is dropped. That `listDevices` also comes before the
|
|
2729
|
+
* answer is the model of the simulator, as the rest of the init sequence.
|
|
2730
|
+
*/
|
|
2731
|
+
initMeasuredBidcos(iface, client) {
|
|
2732
|
+
const release = this.closeGate(iface);
|
|
2733
|
+
return new Promise((resolve) => {
|
|
2734
|
+
const listMethods = (attempt) => {
|
|
2735
|
+
client.methodCall('system.listMethods', [client.id], (error) => {
|
|
2736
|
+
if (error && attempt < 4) {
|
|
2737
|
+
listMethods(attempt + 1);
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
this.clients[iface][client.key] = client;
|
|
2741
|
+
client.methodCall('listDevices', [client.id], (listError, clientDevices) => {
|
|
2742
|
+
release();
|
|
2743
|
+
resolve('');
|
|
2744
|
+
if (!listError) {
|
|
2745
|
+
this.checkDevices(iface, client, clientDevices);
|
|
2746
|
+
}
|
|
2747
|
+
});
|
|
2748
|
+
});
|
|
2749
|
+
};
|
|
2750
|
+
listMethods(1);
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* hmipserver's `init`, as measured (firmware 3.89.8, 2026-09-25): the call is answered at
|
|
2756
|
+
* once and `listDevices` is called back afterwards. While that callback hangs, hmipserver
|
|
2757
|
+
* delivers **no event to any client** of the interface - they are queued and follow when the
|
|
2758
|
+
* connection ends, and the client that hung is not called again. After a registration
|
|
2759
|
+
* finished, a client that stops answering holds up only itself.
|
|
2760
|
+
*/
|
|
2761
|
+
initMeasuredHmip(iface, client) {
|
|
2762
|
+
this.clients[iface][client.key] = client;
|
|
2763
|
+
const release = this.hold(iface);
|
|
2764
|
+
client.methodCall('listDevices', [client.id], (error, clientDevices) => {
|
|
2765
|
+
if (error) {
|
|
2766
|
+
this.log.warn(iface, 'client', client.url, 'failed at listDevices - not registered:', error.message);
|
|
2767
|
+
if (this.clients[iface][client.key] === client) {
|
|
2768
|
+
this.forgetClient(iface, client.key);
|
|
2769
|
+
}
|
|
2770
|
+
} else {
|
|
2771
|
+
this.checkDevices(iface, client, clientDevices);
|
|
2772
|
+
}
|
|
2773
|
+
release();
|
|
2774
|
+
});
|
|
2775
|
+
return '';
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
/**
|
|
2779
|
+
* `ping(callerId)` - the BidCos processes answer with a `CENTRAL`/`PONG` event to *every*
|
|
2780
|
+
* registered client, hmipserver sends none (`pong: false`). `pingDelay` delays the event.
|
|
2781
|
+
*/
|
|
1608
2782
|
ping(iface, params) {
|
|
1609
|
-
if (iface
|
|
1610
|
-
// hmipserver does not answer ping with a PONG event, see
|
|
1611
|
-
// https://github.com/eq-3/occu/issues/42
|
|
2783
|
+
if (!this.interfaceOption(iface, 'pong')) {
|
|
1612
2784
|
return '';
|
|
1613
2785
|
}
|
|
1614
|
-
|
|
1615
|
-
this.
|
|
2786
|
+
const pong = () => this.event(iface, ['CENTRAL', 'PONG', (params || [])[0]]);
|
|
2787
|
+
const delay = this.interfaceOption(iface, 'pingDelay');
|
|
2788
|
+
if (delay > 0) {
|
|
2789
|
+
this.timer(pong, delay);
|
|
2790
|
+
} else {
|
|
2791
|
+
pong();
|
|
2792
|
+
}
|
|
1616
2793
|
return '';
|
|
1617
2794
|
}
|
|
1618
2795
|
|
|
@@ -1626,7 +2803,7 @@ class HmSim {
|
|
|
1626
2803
|
event(iface, params) {
|
|
1627
2804
|
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1628
2805
|
const client = this.clients[iface][key];
|
|
1629
|
-
|
|
2806
|
+
this.deliver(iface, client, 'event', [client.id, ...params]);
|
|
1630
2807
|
}
|
|
1631
2808
|
}
|
|
1632
2809
|
|
|
@@ -1637,7 +2814,7 @@ class HmSim {
|
|
|
1637
2814
|
methodName: 'event',
|
|
1638
2815
|
params: [client.id, singleEvent[0], singleEvent[1], singleEvent[2]],
|
|
1639
2816
|
}));
|
|
1640
|
-
|
|
2817
|
+
this.deliver(iface, client, 'system.multicall', [multicall]);
|
|
1641
2818
|
}
|
|
1642
2819
|
}
|
|
1643
2820
|
|
|
@@ -1737,7 +2914,11 @@ class HmSim {
|
|
|
1737
2914
|
const events =
|
|
1738
2915
|
parameter.TYPE === 'ACTION'
|
|
1739
2916
|
? [[address, datapoint, casted]]
|
|
1740
|
-
: Object.keys(state.VALUES).map((name) => [
|
|
2917
|
+
: Object.keys(state.VALUES).map((name) => [
|
|
2918
|
+
address,
|
|
2919
|
+
name,
|
|
2920
|
+
this.reportedValue(iface, address, name, state.VALUES[name]),
|
|
2921
|
+
]);
|
|
1741
2922
|
this.eventMulticall(iface, events);
|
|
1742
2923
|
}
|
|
1743
2924
|
}
|
|
@@ -1764,7 +2945,8 @@ class HmSim {
|
|
|
1764
2945
|
return createBinrpcServer({
|
|
1765
2946
|
host: listenAddress,
|
|
1766
2947
|
port,
|
|
1767
|
-
dispatch: (method, params, callback) =>
|
|
2948
|
+
dispatch: (method, params, callback, context) =>
|
|
2949
|
+
this.dispatch(iface, method, params, callback, context),
|
|
1768
2950
|
onListening,
|
|
1769
2951
|
onError,
|
|
1770
2952
|
});
|
|
@@ -1778,7 +2960,28 @@ class HmSim {
|
|
|
1778
2960
|
path,
|
|
1779
2961
|
tls: this.tls,
|
|
1780
2962
|
auth,
|
|
1781
|
-
dispatch: (method, params, callback) =>
|
|
2963
|
+
dispatch: (method, params, callback, context) =>
|
|
2964
|
+
this.dispatch(iface, method, params, callback, context),
|
|
2965
|
+
onListening,
|
|
2966
|
+
onError,
|
|
2967
|
+
});
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
// rfd and hs485d answer BIN-RPC and XML-RPC on the same port; `protocols` narrows it
|
|
2971
|
+
const bidcosServer = (iface, port, label) => {
|
|
2972
|
+
const protocols = this.interfaceOption(iface, 'protocols') || ['binrpc', 'xmlrpc'];
|
|
2973
|
+
if (protocols.length === 1 && protocols[0] === 'binrpc') {
|
|
2974
|
+
return binrpcServer(iface, port, `${label} binrpc`);
|
|
2975
|
+
}
|
|
2976
|
+
const {onListening, onError} = this.listeningCallbacks(iface, label, listenAddress);
|
|
2977
|
+
return createDualServer({
|
|
2978
|
+
host: listenAddress,
|
|
2979
|
+
port,
|
|
2980
|
+
protocols,
|
|
2981
|
+
tls: this.tls,
|
|
2982
|
+
auth,
|
|
2983
|
+
dispatch: (method, params, callback, context) =>
|
|
2984
|
+
this.dispatch(iface, method, params, callback, context),
|
|
1782
2985
|
onListening,
|
|
1783
2986
|
onError,
|
|
1784
2987
|
});
|
|
@@ -1786,17 +2989,20 @@ class HmSim {
|
|
|
1786
2989
|
|
|
1787
2990
|
// the factory of an interface is kept so that dropConnection() can restart its server
|
|
1788
2991
|
const start = (iface, port, factory) => {
|
|
2992
|
+
if (this.interfaceOption(iface, 'startDelay') > 0) {
|
|
2993
|
+
this.delayedStarts[iface] = this.interfaceOption(iface, 'startDelay');
|
|
2994
|
+
}
|
|
1789
2995
|
this.serverFactories[iface] = factory;
|
|
1790
2996
|
this.servers[iface] = factory(port);
|
|
1791
2997
|
this.exposeServer(iface);
|
|
1792
2998
|
};
|
|
1793
2999
|
|
|
1794
3000
|
if (this.devices.rfd) {
|
|
1795
|
-
start('rfd', binrpcListenPort, (port) =>
|
|
3001
|
+
start('rfd', binrpcListenPort, (port) => bidcosServer('rfd', port, 'rfd'));
|
|
1796
3002
|
}
|
|
1797
3003
|
|
|
1798
3004
|
if (this.devices.wired) {
|
|
1799
|
-
start('wired', this.config.wiredListenPort, (port) =>
|
|
3005
|
+
start('wired', this.config.wiredListenPort, (port) => bidcosServer('wired', port, 'wired'));
|
|
1800
3006
|
}
|
|
1801
3007
|
|
|
1802
3008
|
if (this.devices.hmip) {
|
|
@@ -1839,6 +3045,15 @@ class HmSim {
|
|
|
1839
3045
|
const port = serverPort(server);
|
|
1840
3046
|
this.ports[iface] = port;
|
|
1841
3047
|
this.log.info(label, 'server listening on', host, port);
|
|
3048
|
+
const delay = this.delayedStarts[iface];
|
|
3049
|
+
if (delay !== undefined) {
|
|
3050
|
+
// the port is known now; it refuses connections until the delay is over
|
|
3051
|
+
delete this.delayedStarts[iface];
|
|
3052
|
+
this.stopped.add(iface);
|
|
3053
|
+
server.close();
|
|
3054
|
+
this.log.info(label, 'starts in', delay, 'ms');
|
|
3055
|
+
this.timer(() => this.startInterface(iface, {forgetClients: false}), delay);
|
|
3056
|
+
}
|
|
1842
3057
|
resolve();
|
|
1843
3058
|
},
|
|
1844
3059
|
onError: (error) => {
|
|
@@ -1881,8 +3096,9 @@ class HmSim {
|
|
|
1881
3096
|
clearTimeout(handle);
|
|
1882
3097
|
}
|
|
1883
3098
|
this.timers.clear();
|
|
1884
|
-
for (const server of Object.
|
|
1885
|
-
|
|
3099
|
+
for (const [iface, server] of Object.entries(this.servers)) {
|
|
3100
|
+
// a stopped server is closed already, and binrpc's close() rejects a second time
|
|
3101
|
+
if (typeof server.close === 'function' && !this.stopped.has(iface)) {
|
|
1886
3102
|
server.close();
|
|
1887
3103
|
}
|
|
1888
3104
|
}
|
|
@@ -1907,6 +3123,32 @@ function serverPort(server) {
|
|
|
1907
3123
|
return address ? address.port : undefined;
|
|
1908
3124
|
}
|
|
1909
3125
|
|
|
3126
|
+
/**
|
|
3127
|
+
* The parts of a callback url: `{protocol: 'binrpc'|'http'|'https', host, port, path}`, or
|
|
3128
|
+
* undefined when it is none.
|
|
3129
|
+
*/
|
|
3130
|
+
function parseCallbackUrl(url) {
|
|
3131
|
+
const match = /^([A-Za-z_]+):\/\/(\[[^\]]+\]|[^/:]+):(\d+)(\/.*)?$/.exec(String(url));
|
|
3132
|
+
if (!match) {
|
|
3133
|
+
return undefined;
|
|
3134
|
+
}
|
|
3135
|
+
const scheme = match[1].toLowerCase();
|
|
3136
|
+
const protocol = scheme === 'xmlrpc_bin' || scheme === 'binrpc' ? 'binrpc' : scheme;
|
|
3137
|
+
if (!['binrpc', 'http', 'https', 'xmlrpc'].includes(protocol)) {
|
|
3138
|
+
return undefined;
|
|
3139
|
+
}
|
|
3140
|
+
return {
|
|
3141
|
+
protocol: protocol === 'xmlrpc' ? 'http' : protocol,
|
|
3142
|
+
host: match[2].replace(/^\[|\]$/g, ''),
|
|
3143
|
+
port: Number(match[3]),
|
|
3144
|
+
path: match[4] || '/',
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
function isThenable(value) {
|
|
3149
|
+
return value !== null && typeof value === 'object' && typeof value.then === 'function';
|
|
3150
|
+
}
|
|
3151
|
+
|
|
1910
3152
|
/** Closes a binrpc or xmlrpc client so that it stops holding the event loop open. */
|
|
1911
3153
|
function closeClient(client) {
|
|
1912
3154
|
if (!client) {
|