hm-simulator 1.1.0 → 1.2.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 +106 -0
- package/README.md +596 -238
- package/data/fixtures/lab-2026-09.json +45956 -0
- package/index.js +260 -47
- package/lib/control.js +132 -0
- package/lib/method-help.js +4 -0
- package/lib/paramset-index.js +107 -0
- package/lib/servers.js +262 -13
- package/lib/sim.js +963 -59
- package/lib/validate.js +4 -0
- package/package.json +2 -1
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,12 +74,39 @@ 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,
|
|
@@ -82,7 +120,8 @@ const INTERFACE_DEFAULTS = {
|
|
|
82
120
|
* the value from INTERFACE_DEFAULTS.
|
|
83
121
|
*/
|
|
84
122
|
const MEASURED_INTERFACE_DEFAULTS = {
|
|
85
|
-
|
|
123
|
+
// hmipserver answers ping without a PONG event, see https://github.com/eq-3/occu/issues/42
|
|
124
|
+
hmip: {configPendingMode: 'hmip', pong: false},
|
|
86
125
|
rfd: {configPendingMode: 'bidcos'},
|
|
87
126
|
wired: {configPendingMode: 'bidcos'},
|
|
88
127
|
};
|
|
@@ -149,9 +188,11 @@ function shortenParams(params) {
|
|
|
149
188
|
* @param {object} [options.log] object with debug/info/warn/error methods
|
|
150
189
|
* @param {object} [options.devices] {<iface>: {devices: []}} - device descriptions per interface
|
|
151
190
|
* @param {object} [options.config] listenAddress, binrpcListenPort, xmlrpcListenPort
|
|
152
|
-
* @param {string} [options.behaviorPath] directory with behaviour scripts
|
|
191
|
+
* @param {string|false} [options.behaviorPath] directory with behaviour scripts, `false` for none
|
|
153
192
|
* @param {object} [options.rega] ReGa mock options, see lib/rega.js - omit to not start it
|
|
154
193
|
* @param {object} [options.paramsetDescriptions] replaces the bundled descriptions
|
|
194
|
+
* @param {boolean} [options.paramsetFallback=true] a device whose firmware has no description uses
|
|
195
|
+
* the nearest firmware of the same type and VERSION; `false` for exact keys only
|
|
155
196
|
* @param {object} [options.faults] overrides for the fault table, see lib/faults.js
|
|
156
197
|
*/
|
|
157
198
|
class HmSim {
|
|
@@ -165,6 +206,10 @@ class HmSim {
|
|
|
165
206
|
|
|
166
207
|
this.devices = options.devices || {rfd: {devices: []}, hmip: {devices: []}};
|
|
167
208
|
this.paramsetDescriptions = options.paramsetDescriptions || require('../data/paramset-descriptions.json');
|
|
209
|
+
this.paramsetFallback = options.paramsetFallback !== false;
|
|
210
|
+
/** requested key -> the key used for it (a fallback) or null, see resolveParamsetKey() */
|
|
211
|
+
this.paramsetResolved = new Map();
|
|
212
|
+
this.paramsetIndex = new ParamsetIndex(this.paramsetDescriptions);
|
|
168
213
|
|
|
169
214
|
const {table, fault} = createFaults(options.faults);
|
|
170
215
|
this.faults = table;
|
|
@@ -192,12 +237,34 @@ class HmSim {
|
|
|
192
237
|
this.valueUsage = [];
|
|
193
238
|
/** every updateFirmware/installFirmware call */
|
|
194
239
|
this.firmwareUpdates = [];
|
|
240
|
+
/** every refreshDeployedDeviceFirmwareList call: [{iface, ts}] */
|
|
241
|
+
this.firmwareListRefreshes = [];
|
|
242
|
+
/** every changeKey call: [{iface, key, ts}] */
|
|
243
|
+
this.keyChanges = [];
|
|
244
|
+
/** the log level of each interface process, see logLevel() */
|
|
245
|
+
this.logLevels = {};
|
|
195
246
|
/** pending timers, cleared by close() */
|
|
196
247
|
this.timers = new Set();
|
|
197
248
|
/** devices with a pending configuration: {<iface>: Map<deviceAddress, {sticky}>} */
|
|
198
249
|
this.configPending = {};
|
|
199
250
|
/** channels whose stored MASTER carries a parameter the device does not have */
|
|
200
251
|
this.poisonedChannels = {};
|
|
252
|
+
/** faults waiting for the next matching calls, see injectFault() */
|
|
253
|
+
this.injectedFaults = [];
|
|
254
|
+
/** every call the simulator made to a registered client, see getCallbackLog() */
|
|
255
|
+
this.callbackLog = [];
|
|
256
|
+
/** per interface: a promise every incoming call waits for while the process is busy */
|
|
257
|
+
this.gates = {};
|
|
258
|
+
/** per interface: deliveries held back while a registration hangs, see init() */
|
|
259
|
+
this.held = {};
|
|
260
|
+
/** interfaces whose port is closed, see stopInterface() */
|
|
261
|
+
this.stopped = new Set();
|
|
262
|
+
/** interfaces that open their port only after `startDelay` */
|
|
263
|
+
this.delayedStarts = {};
|
|
264
|
+
/** devices that are unreachable: {<iface>: Set<deviceAddress>}, see setReachable() */
|
|
265
|
+
this.unreachable = {};
|
|
266
|
+
/** what listBidcosInterfaces reports on top of the configured answer: {<iface>: {DUTY_CYCLE, ...}} */
|
|
267
|
+
this.radio = {};
|
|
201
268
|
|
|
202
269
|
this.rpcMethods = this.buildRpcMethods();
|
|
203
270
|
|
|
@@ -231,7 +298,10 @@ class HmSim {
|
|
|
231
298
|
this.setValue(iface, address, datapoint, value, {internal: true}),
|
|
232
299
|
);
|
|
233
300
|
|
|
234
|
-
|
|
301
|
+
// `behaviorPath: false`: no behaviour scripts at all
|
|
302
|
+
if (options.behaviorPath !== false) {
|
|
303
|
+
this.loadBehaviors(options.behaviorPath || path.join(__dirname, '..', 'behaviors'));
|
|
304
|
+
}
|
|
235
305
|
|
|
236
306
|
if (options.rega) {
|
|
237
307
|
// the ReGa mock inherits TLS and basic auth unless its own options say otherwise
|
|
@@ -299,10 +369,7 @@ class HmSim {
|
|
|
299
369
|
if (this.values[iface][device.ADDRESS].VALUES[name] !== undefined) {
|
|
300
370
|
continue;
|
|
301
371
|
}
|
|
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;
|
|
372
|
+
this.values[iface][device.ADDRESS].VALUES[name] = defaultValue(parameter);
|
|
306
373
|
}
|
|
307
374
|
}
|
|
308
375
|
}
|
|
@@ -345,7 +412,76 @@ class HmSim {
|
|
|
345
412
|
}
|
|
346
413
|
|
|
347
414
|
const name = this.paramsetName(iface, device, paramset);
|
|
348
|
-
|
|
415
|
+
if (name === undefined) {
|
|
416
|
+
return undefined;
|
|
417
|
+
}
|
|
418
|
+
const key = this.resolveParamsetKey(name);
|
|
419
|
+
return key === null ? undefined : this.paramsetDescriptions[key];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The key whose description stands in for `name`: `name` itself when the set has it, otherwise
|
|
424
|
+
* the same type, VERSION, channel type and paramset of the nearest firmware (the highest one at
|
|
425
|
+
* or below the device's, else the lowest above it) unless `paramsetFallback` is off, else null.
|
|
426
|
+
*
|
|
427
|
+
* A device reports the firmware it runs, and a description set only has the firmwares somebody
|
|
428
|
+
* dumped: without this, the bundled `HM-RCV-50` (firmware 2.27.8, descriptions from 2.31.25 on)
|
|
429
|
+
* answers `Invalid device` for every channel `listDevices` reports (PR #1). Each substitution
|
|
430
|
+
* and each miss is logged once.
|
|
431
|
+
*
|
|
432
|
+
* @param {string} name `<interface>/<type>/<firmware>/<version>/<channelType>/<paramset>`
|
|
433
|
+
* @returns {string|null}
|
|
434
|
+
*/
|
|
435
|
+
resolveParamsetKey(name) {
|
|
436
|
+
if (this.paramsetDescriptions[name]) {
|
|
437
|
+
return name;
|
|
438
|
+
}
|
|
439
|
+
const index = this.currentParamsetIndex();
|
|
440
|
+
if (this.paramsetResolved.has(name)) {
|
|
441
|
+
return this.paramsetResolved.get(name);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const used = this.paramsetFallback ? index.nearest(name) : null;
|
|
445
|
+
this.paramsetResolved.set(name, used);
|
|
446
|
+
if (used) {
|
|
447
|
+
this.log.warn('paramset description', name, 'missing, using', used);
|
|
448
|
+
} else {
|
|
449
|
+
this.log.warn('paramset description', name, 'missing');
|
|
450
|
+
}
|
|
451
|
+
return used;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The firmware index of the descriptions, rebuilt when somebody replaced sim.paramsetDescriptions. */
|
|
455
|
+
currentParamsetIndex() {
|
|
456
|
+
if (this.paramsetIndex.descriptions !== this.paramsetDescriptions) {
|
|
457
|
+
this.paramsetIndex = new ParamsetIndex(this.paramsetDescriptions);
|
|
458
|
+
this.paramsetResolved.clear();
|
|
459
|
+
}
|
|
460
|
+
return this.paramsetIndex;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Every paramset the devices announce in `PARAMSETS` whose description is not in the set under
|
|
465
|
+
* its exact key, with the key the simulator uses instead (`null`: none, the paramset faults).
|
|
466
|
+
* An empty list means the device data and the descriptions match exactly.
|
|
467
|
+
*
|
|
468
|
+
* @returns {Array<{iface: string, key: string, usedKey: string|null}>}
|
|
469
|
+
*/
|
|
470
|
+
getMissingParamsetDescriptions() {
|
|
471
|
+
const missing = new Map();
|
|
472
|
+
for (const iface of this.ifaces) {
|
|
473
|
+
for (const device of this.devices[iface].devices) {
|
|
474
|
+
for (const paramset of device.PARAMSETS || []) {
|
|
475
|
+
const key = this.paramsetName(iface, device, paramset);
|
|
476
|
+
if (key === undefined || this.paramsetDescriptions[key] || missing.has(`${iface} ${key}`)) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const usedKey = this.paramsetFallback ? this.currentParamsetIndex().nearest(key) : null;
|
|
480
|
+
missing.set(`${iface} ${key}`, {iface, key, usedKey});
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return [...missing.values()];
|
|
349
485
|
}
|
|
350
486
|
|
|
351
487
|
/* ------------------------------------------------------------------ paramsets */
|
|
@@ -905,10 +1041,9 @@ class HmSim {
|
|
|
905
1041
|
|
|
906
1042
|
listBidcosInterfaces(iface) {
|
|
907
1043
|
const configured = this.options.bidcosInterfaces && this.options.bidcosInterfaces[iface];
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}
|
|
911
|
-
return DEFAULT_BIDCOS_INTERFACES[iface] || DEFAULT_BIDCOS_INTERFACES.rfd;
|
|
1044
|
+
const list = configured || DEFAULT_BIDCOS_INTERFACES[iface] || DEFAULT_BIDCOS_INTERFACES.rfd;
|
|
1045
|
+
const radio = this.radio[iface];
|
|
1046
|
+
return radio ? list.map((entry) => ({...entry, ...radio})) : list;
|
|
912
1047
|
}
|
|
913
1048
|
|
|
914
1049
|
setBidcosInterface(iface, params) {
|
|
@@ -1014,6 +1149,67 @@ class HmSim {
|
|
|
1014
1149
|
return '';
|
|
1015
1150
|
}
|
|
1016
1151
|
|
|
1152
|
+
/**
|
|
1153
|
+
* `logLevel(level)` - rfd and hs485d set their log level and answer it; without a level they
|
|
1154
|
+
* answer the one in force (5 on the rfd measured 2026-09-28). hmipserver lists the
|
|
1155
|
+
* method but answers it with an empty HTTP body; the simulator answers an empty string there.
|
|
1156
|
+
* Elsewhere unknown-method.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {string} iface
|
|
1159
|
+
* @param {Array} params [level]
|
|
1160
|
+
*/
|
|
1161
|
+
logLevel(iface, params) {
|
|
1162
|
+
if (iface === 'hmip') {
|
|
1163
|
+
return '';
|
|
1164
|
+
}
|
|
1165
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1166
|
+
throw this.fault('unknownMethod', `${iface}/logLevel`);
|
|
1167
|
+
}
|
|
1168
|
+
const [level] = params || [];
|
|
1169
|
+
if (level !== undefined) {
|
|
1170
|
+
if (!Number.isInteger(level)) {
|
|
1171
|
+
throw this.fault('invalidArguments', 'logLevel wants an integer');
|
|
1172
|
+
}
|
|
1173
|
+
this.logLevels[iface] = level;
|
|
1174
|
+
}
|
|
1175
|
+
return this.logLevels[iface] === undefined ? 5 : this.logLevels[iface];
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* `changeKey(passphrase)` - the interface's security key. rfd stores it and re-keys every
|
|
1180
|
+
* AES-capable device; hmipserver lists the method too. The simulator does no cryptography: the
|
|
1181
|
+
* call is recorded in `sim.keyChanges` (`{iface, key, ts}`) and answered with an empty string.
|
|
1182
|
+
* BidCos-Wired has no such method (unknown-method).
|
|
1183
|
+
*
|
|
1184
|
+
* @param {string} iface
|
|
1185
|
+
* @param {Array} params [passphrase]
|
|
1186
|
+
*/
|
|
1187
|
+
changeKey(iface, params) {
|
|
1188
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1189
|
+
throw this.fault('unknownMethod', `${iface}/changeKey`);
|
|
1190
|
+
}
|
|
1191
|
+
const [key] = params || [];
|
|
1192
|
+
if (typeof key !== 'string') {
|
|
1193
|
+
throw this.fault('invalidArguments', 'changeKey wants a string');
|
|
1194
|
+
}
|
|
1195
|
+
this.keyChanges.push({iface, key, ts: Date.now()});
|
|
1196
|
+
this.log.info(iface, 'security key changed');
|
|
1197
|
+
return '';
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* `refreshDeployedDeviceFirmwareList()` - rfd and hmipserver read the directory of deployed
|
|
1202
|
+
* device firmware again and answer an empty value (measured 2026-09-28); hs485d and
|
|
1203
|
+
* VirtualDevices have no such method. Recorded in `sim.firmwareListRefreshes`.
|
|
1204
|
+
*/
|
|
1205
|
+
refreshDeployedDeviceFirmwareList(iface) {
|
|
1206
|
+
if (iface !== 'rfd' && iface !== 'hmip') {
|
|
1207
|
+
throw this.fault('unknownMethod', `${iface}/refreshDeployedDeviceFirmwareList`);
|
|
1208
|
+
}
|
|
1209
|
+
this.firmwareListRefreshes.push({iface, ts: Date.now()});
|
|
1210
|
+
return '';
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1017
1213
|
/* ------------------------------------------------------------------ teams */
|
|
1018
1214
|
|
|
1019
1215
|
/**
|
|
@@ -1237,6 +1433,9 @@ class HmSim {
|
|
|
1237
1433
|
this.requireDevice(iface, address);
|
|
1238
1434
|
}
|
|
1239
1435
|
this.firmwareUpdates.push({iface, addresses, method: 'updateFirmware', ts: Date.now()});
|
|
1436
|
+
for (const address of addresses) {
|
|
1437
|
+
this.performFirmwareUpdate(iface, address);
|
|
1438
|
+
}
|
|
1240
1439
|
return true;
|
|
1241
1440
|
}
|
|
1242
1441
|
|
|
@@ -1244,6 +1443,7 @@ class HmSim {
|
|
|
1244
1443
|
const address = params[0];
|
|
1245
1444
|
this.requireDevice(iface, address);
|
|
1246
1445
|
this.firmwareUpdates.push({iface, addresses: [address], method: 'installFirmware', ts: Date.now()});
|
|
1446
|
+
this.performFirmwareUpdate(iface, address);
|
|
1247
1447
|
return true;
|
|
1248
1448
|
}
|
|
1249
1449
|
|
|
@@ -1322,6 +1522,212 @@ class HmSim {
|
|
|
1322
1522
|
return added;
|
|
1323
1523
|
}
|
|
1324
1524
|
|
|
1525
|
+
/* ------------------------------------------------------------------ device and radio health */
|
|
1526
|
+
|
|
1527
|
+
/** The device of an address, its maintenance channel `:0` and that channel's VALUES description. */
|
|
1528
|
+
maintenanceOf(iface, address) {
|
|
1529
|
+
const entry = this.requireDevice(iface, address);
|
|
1530
|
+
const device = entry.PARENT ? this.requireDevice(iface, entry.PARENT) : entry;
|
|
1531
|
+
const channel = `${device.ADDRESS}:0`;
|
|
1532
|
+
const description =
|
|
1533
|
+
(this.index[iface].has(channel) && this.getParamsetDescription(iface, channel, 'VALUES')) || {};
|
|
1534
|
+
return {device, channel, description};
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* Reports a datapoint of the maintenance channel: stored and sent the way the description says
|
|
1539
|
+
* (a service message by its FLAGS), or - when the description does not have it - as an event and
|
|
1540
|
+
* a service message raised through setServiceMessage().
|
|
1541
|
+
*/
|
|
1542
|
+
reportMaintenance(iface, channel, description, datapoint, value) {
|
|
1543
|
+
if (description[datapoint]) {
|
|
1544
|
+
this.setValue(iface, channel, datapoint, value, {internal: true});
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
if (typeof value === 'boolean') {
|
|
1548
|
+
this.setServiceMessage(iface, channel, datapoint, value);
|
|
1549
|
+
}
|
|
1550
|
+
this.fireEvent(iface, channel, datapoint, value);
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
/**
|
|
1554
|
+
* A device goes out of reach or comes back: `UNREACH` on its `:0` channel, and on the BidCos
|
|
1555
|
+
* interfaces `STICKY_UNREACH`, which stays after the device is back until a client writes it
|
|
1556
|
+
* `false` - as rfd does. What a write to the device answers meanwhile is the `unreachWrites`
|
|
1557
|
+
* option ('accept', or 'fault' with notReachable).
|
|
1558
|
+
*
|
|
1559
|
+
* @param {string} iface
|
|
1560
|
+
* @param {string} address the device or one of its channels
|
|
1561
|
+
* @param {boolean} reachable
|
|
1562
|
+
*/
|
|
1563
|
+
setReachable(iface, address, reachable) {
|
|
1564
|
+
const {device, channel, description} = this.maintenanceOf(iface, address);
|
|
1565
|
+
const unreachable = (this.unreachable[iface] = this.unreachable[iface] || new Set());
|
|
1566
|
+
if (reachable) {
|
|
1567
|
+
unreachable.delete(device.ADDRESS);
|
|
1568
|
+
} else {
|
|
1569
|
+
unreachable.add(device.ADDRESS);
|
|
1570
|
+
}
|
|
1571
|
+
this.reportMaintenance(iface, channel, description, 'UNREACH', !reachable);
|
|
1572
|
+
const sticky = description.STICKY_UNREACH || (BIDCOS_INTERFACES.has(iface) && !description.UNREACH);
|
|
1573
|
+
if (!reachable && sticky) {
|
|
1574
|
+
this.reportMaintenance(iface, channel, description, 'STICKY_UNREACH', true);
|
|
1575
|
+
}
|
|
1576
|
+
this.log.info(iface, device.ADDRESS, reachable ? 'reachable again' : 'unreachable');
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/** Throws notReachable for a write to an unreachable device when `unreachWrites` is 'fault'. */
|
|
1580
|
+
requireReachable(iface, address) {
|
|
1581
|
+
if (this.interfaceOption(iface, 'unreachWrites') !== 'fault' || !this.unreachable[iface]) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
const entry = this.index[iface] && this.index[iface].get(address);
|
|
1585
|
+
const deviceAddress = entry ? entry.PARENT || entry.ADDRESS : address;
|
|
1586
|
+
if (this.unreachable[iface].has(deviceAddress)) {
|
|
1587
|
+
throw this.fault('notReachable', address);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
/**
|
|
1592
|
+
* Low battery: `LOW_BAT` or `LOWBAT` on the device's `:0` channel, whichever its description has
|
|
1593
|
+
* (`LOW_BAT` on hmip, `LOWBAT` on BidCos without one).
|
|
1594
|
+
*/
|
|
1595
|
+
setLowBattery(iface, address, low) {
|
|
1596
|
+
const {channel, description} = this.maintenanceOf(iface, address);
|
|
1597
|
+
let datapoint = iface === 'hmip' ? 'LOW_BAT' : 'LOWBAT';
|
|
1598
|
+
if (description.LOW_BAT) {
|
|
1599
|
+
datapoint = 'LOW_BAT';
|
|
1600
|
+
} else if (description.LOWBAT) {
|
|
1601
|
+
datapoint = 'LOWBAT';
|
|
1602
|
+
}
|
|
1603
|
+
this.reportMaintenance(iface, channel, description, datapoint, Boolean(low));
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/**
|
|
1607
|
+
* The radio module of an interface, if the device list has it: the device whose address is the
|
|
1608
|
+
* last 14 characters of the interface's address (an SGTIN), or one of a radio module's types.
|
|
1609
|
+
*/
|
|
1610
|
+
radioModule(iface) {
|
|
1611
|
+
const address = String((this.listBidcosInterfaces(iface)[0] || {}).ADDRESS || '');
|
|
1612
|
+
const devices = this.deviceList(iface);
|
|
1613
|
+
return (
|
|
1614
|
+
devices.find((device) => device.ADDRESS === address.slice(-14) || device.ADDRESS === address) ||
|
|
1615
|
+
devices.find((device) => RADIO_MODULE_TYPES.has(device.TYPE))
|
|
1616
|
+
);
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* Sets a radio level of an interface: in what `listBidcosInterfaces` answers, and on hmip as
|
|
1621
|
+
* the event of the radio module's `:0` channel, when the device list has a radio module.
|
|
1622
|
+
*/
|
|
1623
|
+
setRadioLevel(iface, field, datapoint, percent) {
|
|
1624
|
+
if (typeof percent !== 'number' || Number.isNaN(percent)) {
|
|
1625
|
+
throw new TypeError(`${field} wants a number`);
|
|
1626
|
+
}
|
|
1627
|
+
this.radio[iface] = {...this.radio[iface], [field]: percent};
|
|
1628
|
+
const module = iface === 'hmip' ? this.radioModule(iface) : undefined;
|
|
1629
|
+
if (module) {
|
|
1630
|
+
const {channel, description} = this.maintenanceOf(iface, module.ADDRESS);
|
|
1631
|
+
this.reportMaintenance(iface, channel, description, datapoint, percent);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/** The duty cycle of an interface in percent: `DUTY_CYCLE`, on hmip also `DUTY_CYCLE_LEVEL`. */
|
|
1636
|
+
setDutyCycle(iface, percent) {
|
|
1637
|
+
this.setRadioLevel(iface, 'DUTY_CYCLE', 'DUTY_CYCLE_LEVEL', percent);
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
/** The carrier sense level of an interface in percent: `CARRIER_SENSE_LEVEL`. */
|
|
1641
|
+
setCarrierSense(iface, percent) {
|
|
1642
|
+
this.setRadioLevel(iface, 'CARRIER_SENSE_LEVEL', 'CARRIER_SENSE_LEVEL', percent);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
/**
|
|
1646
|
+
* A firmware update becomes available for a device: `AVAILABLE_FIRMWARE`,
|
|
1647
|
+
* `FIRMWARE_UPDATE_STATE: 'NEW_FIRMWARE_AVAILABLE'` and `UPDATABLE` in its description,
|
|
1648
|
+
* `UPDATE_PENDING` on `:0` where the description has it, and `updateDevice` to the clients.
|
|
1649
|
+
* A following `updateFirmware`/`installFirmware` walks `FIRMWARE_UPDATE_STATE` through
|
|
1650
|
+
* DO_UPDATE_PENDING, PERFORMING_UPDATE and UP_TO_DATE, one step per `firmwareUpdateDelay`, and
|
|
1651
|
+
* sets `FIRMWARE` at the end. The state names are the RPC specification's; the timing is a model.
|
|
1652
|
+
*
|
|
1653
|
+
* @param {string} iface
|
|
1654
|
+
* @param {string} address the device
|
|
1655
|
+
* @param {string} version
|
|
1656
|
+
*/
|
|
1657
|
+
offerFirmware(iface, address, version) {
|
|
1658
|
+
const device = this.requireDevice(iface, address);
|
|
1659
|
+
if (device.PARENT) {
|
|
1660
|
+
throw this.fault('notSupported', 'firmware is offered to a device, not a channel');
|
|
1661
|
+
}
|
|
1662
|
+
device.AVAILABLE_FIRMWARE = String(version);
|
|
1663
|
+
device.FIRMWARE_UPDATE_STATE = 'NEW_FIRMWARE_AVAILABLE';
|
|
1664
|
+
device.UPDATABLE = device.UPDATABLE === undefined ? 1 : device.UPDATABLE;
|
|
1665
|
+
const {channel, description} = this.maintenanceOf(iface, address);
|
|
1666
|
+
if (description.UPDATE_PENDING) {
|
|
1667
|
+
this.setValue(iface, channel, 'UPDATE_PENDING', true, {internal: true});
|
|
1668
|
+
}
|
|
1669
|
+
this.tellClientsParams(iface, 'updateDevice', [address, 0]);
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
performFirmwareUpdate(iface, address) {
|
|
1673
|
+
const device = this.index[iface].get(address);
|
|
1674
|
+
if (!device || device.PARENT || device.FIRMWARE_UPDATE_STATE !== 'NEW_FIRMWARE_AVAILABLE') {
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
const delay = this.interfaceOption(iface, 'firmwareUpdateDelay');
|
|
1678
|
+
const step = (index) => {
|
|
1679
|
+
// the device can be gone by now
|
|
1680
|
+
if (this.index[iface].get(address) !== device) {
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
device.FIRMWARE_UPDATE_STATE = FIRMWARE_UPDATE_STEPS[index];
|
|
1684
|
+
if (index === FIRMWARE_UPDATE_STEPS.length - 1) {
|
|
1685
|
+
device.FIRMWARE = device.AVAILABLE_FIRMWARE;
|
|
1686
|
+
const {channel, description} = this.maintenanceOf(iface, address);
|
|
1687
|
+
if (description.UPDATE_PENDING) {
|
|
1688
|
+
this.setValue(iface, channel, 'UPDATE_PENDING', false, {internal: true});
|
|
1689
|
+
}
|
|
1690
|
+
this.log.info(iface, address, 'firmware', device.FIRMWARE);
|
|
1691
|
+
} else {
|
|
1692
|
+
this.timer(() => step(index + 1), delay);
|
|
1693
|
+
}
|
|
1694
|
+
this.tellClientsParams(iface, 'updateDevice', [address, 0]);
|
|
1695
|
+
};
|
|
1696
|
+
this.timer(() => step(0), delay);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/**
|
|
1700
|
+
* Runs scenario calls on the simulator's timer: `[{at: ms, call: 'setReachable', args: [...]}]`,
|
|
1701
|
+
* `at` counted from now. Resolves with the results once every step ran; `close()` cancels what
|
|
1702
|
+
* did not run yet (and the promise then never resolves).
|
|
1703
|
+
*
|
|
1704
|
+
* @param {Array<{at: number, call: string, args: Array}>} steps
|
|
1705
|
+
* @returns {Promise<Array>}
|
|
1706
|
+
*/
|
|
1707
|
+
schedule(steps) {
|
|
1708
|
+
const list = steps || [];
|
|
1709
|
+
for (const step of list) {
|
|
1710
|
+
if (!SCENARIO_METHODS.has(step.call) || step.call === 'schedule' || typeof this[step.call] !== 'function') {
|
|
1711
|
+
throw new Error(`schedule: ${step.call} is no scenario call`);
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
return Promise.all(
|
|
1715
|
+
list.map(
|
|
1716
|
+
(step) =>
|
|
1717
|
+
new Promise((resolve) => {
|
|
1718
|
+
this.timer(async () => {
|
|
1719
|
+
try {
|
|
1720
|
+
resolve(await this[step.call](...(step.args || [])));
|
|
1721
|
+
} catch (error) {
|
|
1722
|
+
this.log.error('schedule', step.call, error.message);
|
|
1723
|
+
resolve(error);
|
|
1724
|
+
}
|
|
1725
|
+
}, step.at || 0);
|
|
1726
|
+
}),
|
|
1727
|
+
),
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1325
1731
|
/* ------------------------------------------------------------------ scenario api */
|
|
1326
1732
|
|
|
1327
1733
|
/**
|
|
@@ -1385,7 +1791,10 @@ class HmSim {
|
|
|
1385
1791
|
|
|
1386
1792
|
const port = this.ports[iface];
|
|
1387
1793
|
this.log.info(iface, 'dropping all connections');
|
|
1388
|
-
|
|
1794
|
+
if (!this.stopped.has(iface)) {
|
|
1795
|
+
await server.close();
|
|
1796
|
+
}
|
|
1797
|
+
this.stopped.delete(iface);
|
|
1389
1798
|
|
|
1390
1799
|
this.ready = [];
|
|
1391
1800
|
this.servers[iface] = factory(port);
|
|
@@ -1393,6 +1802,109 @@ class HmSim {
|
|
|
1393
1802
|
await this.whenReady();
|
|
1394
1803
|
}
|
|
1395
1804
|
|
|
1805
|
+
/**
|
|
1806
|
+
* Stops an interface process: its port refuses connections, open connections are reset. The
|
|
1807
|
+
* registered clients stay known until startInterface() decides what the process remembers.
|
|
1808
|
+
*
|
|
1809
|
+
* @param {string} iface
|
|
1810
|
+
* @returns {Promise<void>} once the port is closed
|
|
1811
|
+
*/
|
|
1812
|
+
async stopInterface(iface) {
|
|
1813
|
+
const server = this.servers[iface];
|
|
1814
|
+
if (!server || this.stopped.has(iface)) {
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
this.stopped.add(iface);
|
|
1818
|
+
this.log.info(iface, 'interface process stopped');
|
|
1819
|
+
await server.close();
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/**
|
|
1823
|
+
* Starts a stopped interface process again on the same port.
|
|
1824
|
+
*
|
|
1825
|
+
* With `forgetClients` (the default) the registered clients are forgotten, as by a process
|
|
1826
|
+
* that keeps no list of them: no event reaches a client until it calls `init` again, and
|
|
1827
|
+
* nothing tells it. Without, the process remembers them and calls each back the way rfd does
|
|
1828
|
+
* when it starts with its handlers file: `system.listMethods`, then `listDevices`, and
|
|
1829
|
+
* `newDevices`/`deleteDevices` as at an init - a client sees calls it did not ask for, which is
|
|
1830
|
+
* how it can tell that the process restarted.
|
|
1831
|
+
*
|
|
1832
|
+
* @param {string} iface
|
|
1833
|
+
* @param {object} [options]
|
|
1834
|
+
* @param {boolean} [options.forgetClients=true]
|
|
1835
|
+
* @returns {Promise<void>} once the port accepts connections again
|
|
1836
|
+
*/
|
|
1837
|
+
async startInterface(iface, {forgetClients = true} = {}) {
|
|
1838
|
+
const factory = this.serverFactories[iface];
|
|
1839
|
+
if (!factory || !this.stopped.has(iface)) {
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
if (forgetClients) {
|
|
1843
|
+
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1844
|
+
this.forgetClient(iface, key);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
const before = this.ready.length;
|
|
1849
|
+
this.servers[iface] = factory(this.ports[iface]);
|
|
1850
|
+
this.exposeServer(iface);
|
|
1851
|
+
const ready = this.ready.slice(before);
|
|
1852
|
+
this.ready.splice(before);
|
|
1853
|
+
await Promise.all(ready);
|
|
1854
|
+
this.stopped.delete(iface);
|
|
1855
|
+
this.log.info(iface, 'interface process started');
|
|
1856
|
+
|
|
1857
|
+
for (const client of Object.values(this.clients[iface] || {})) {
|
|
1858
|
+
const listDevices = () => this.startInit(iface, client);
|
|
1859
|
+
if (BIDCOS_INTERFACES.has(iface)) {
|
|
1860
|
+
client.methodCall('system.listMethods', [client.id], listDevices);
|
|
1861
|
+
} else {
|
|
1862
|
+
listDevices();
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* An interface process restarts: stopInterface(), `downMs` with the port closed,
|
|
1869
|
+
* startInterface(). See startInterface() for `forgetClients`.
|
|
1870
|
+
*
|
|
1871
|
+
* @param {string} iface
|
|
1872
|
+
* @param {object} [options]
|
|
1873
|
+
* @param {number} [options.downMs=0]
|
|
1874
|
+
* @param {boolean} [options.forgetClients=true]
|
|
1875
|
+
* @returns {Promise<void>} once the port accepts connections again
|
|
1876
|
+
*/
|
|
1877
|
+
async restartInterface(iface, {downMs = 0, forgetClients = true} = {}) {
|
|
1878
|
+
await this.stopInterface(iface);
|
|
1879
|
+
if (downMs > 0) {
|
|
1880
|
+
await new Promise((resolve) => this.timer(resolve, downMs));
|
|
1881
|
+
}
|
|
1882
|
+
await this.startInterface(iface, {forgetClients});
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Sends a burst of events the way the interface processes do, as `system.multicall` batches.
|
|
1887
|
+
*
|
|
1888
|
+
* @param {string} iface
|
|
1889
|
+
* @param {Array<Array>} events `[[address, datapoint, value], ...]`, not validated
|
|
1890
|
+
* @param {object} [options]
|
|
1891
|
+
* @param {number} [options.batch] events per multicall, all in one when omitted
|
|
1892
|
+
*/
|
|
1893
|
+
fireEvents(iface, events, {batch} = {}) {
|
|
1894
|
+
const list = events || [];
|
|
1895
|
+
for (const [address, datapoint, value] of list) {
|
|
1896
|
+
const state = this.values[iface] && this.values[iface][address];
|
|
1897
|
+
if (state) {
|
|
1898
|
+
state.VALUES[datapoint] = value;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
const size = batch > 0 ? batch : list.length;
|
|
1902
|
+
for (let index = 0; index < list.length; index += size) {
|
|
1903
|
+
this.eventMulticall(iface, list.slice(index, index + size));
|
|
1904
|
+
}
|
|
1905
|
+
return list.length;
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1396
1908
|
/**
|
|
1397
1909
|
* Raises or clears a service message. If the channel has the datapoint in its VALUES
|
|
1398
1910
|
* description the value is written as well, so that the logic layer sees the event too.
|
|
@@ -1439,8 +1951,86 @@ class HmSim {
|
|
|
1439
1951
|
tellClientsParams(iface, method, params) {
|
|
1440
1952
|
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1441
1953
|
const client = this.clients[iface][key];
|
|
1442
|
-
|
|
1954
|
+
this.deliver(iface, client, method, [client.id, ...params]);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/**
|
|
1959
|
+
* One call to one registered client: held back while a registration of the interface hangs
|
|
1960
|
+
* (the 'measured' hmip model), and under the 'measured' BidCos model the client is dropped when
|
|
1961
|
+
* it does not answer within `deliveryTimeout`.
|
|
1962
|
+
*/
|
|
1963
|
+
deliver(iface, client, method, params) {
|
|
1964
|
+
const held = this.held[iface];
|
|
1965
|
+
if (held && held.count > 0) {
|
|
1966
|
+
held.queue.push(() => this.deliver(iface, client, method, params));
|
|
1967
|
+
return;
|
|
1443
1968
|
}
|
|
1969
|
+
const measuredBidcos =
|
|
1970
|
+
BIDCOS_INTERFACES.has(iface) && this.interfaceOption(iface, 'listenerModel') === 'measured';
|
|
1971
|
+
if (!measuredBidcos) {
|
|
1972
|
+
client.methodCall(method, params);
|
|
1973
|
+
return;
|
|
1974
|
+
}
|
|
1975
|
+
client.methodCall(
|
|
1976
|
+
method,
|
|
1977
|
+
params,
|
|
1978
|
+
(error) => {
|
|
1979
|
+
if (error && this.clients[iface][client.key] === client) {
|
|
1980
|
+
this.log.warn(iface, 'client', client.url, 'did not take', method, '- dropped:', error.message);
|
|
1981
|
+
this.forgetClient(iface, client.key);
|
|
1982
|
+
}
|
|
1983
|
+
},
|
|
1984
|
+
{timeout: this.interfaceOption(iface, 'deliveryTimeout')},
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
forgetClient(iface, key) {
|
|
1989
|
+
const client = this.clients[iface] && this.clients[iface][key];
|
|
1990
|
+
if (client) {
|
|
1991
|
+
closeClient(client.client);
|
|
1992
|
+
delete this.clients[iface][key];
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/** Holds every delivery of an interface back until the returned function is called. */
|
|
1997
|
+
hold(iface) {
|
|
1998
|
+
const held = (this.held[iface] = this.held[iface] || {count: 0, queue: []});
|
|
1999
|
+
held.count++;
|
|
2000
|
+
let released = false;
|
|
2001
|
+
return () => {
|
|
2002
|
+
if (released) {
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
released = true;
|
|
2006
|
+
held.count--;
|
|
2007
|
+
if (held.count === 0) {
|
|
2008
|
+
const queue = held.queue.splice(0);
|
|
2009
|
+
for (const send of queue) {
|
|
2010
|
+
send();
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
};
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/**
|
|
2017
|
+
* Makes every incoming call of an interface wait until the returned function is called - the
|
|
2018
|
+
* interface process is busy with something that does not return.
|
|
2019
|
+
*/
|
|
2020
|
+
closeGate(iface) {
|
|
2021
|
+
let release;
|
|
2022
|
+
const gate = new Promise((resolve) => {
|
|
2023
|
+
release = resolve;
|
|
2024
|
+
});
|
|
2025
|
+
const previous = this.gates[iface];
|
|
2026
|
+
const combined = previous ? Promise.all([previous, gate]) : gate;
|
|
2027
|
+
this.gates[iface] = combined;
|
|
2028
|
+
return () => {
|
|
2029
|
+
release();
|
|
2030
|
+
if (this.gates[iface] === combined) {
|
|
2031
|
+
delete this.gates[iface];
|
|
2032
|
+
}
|
|
2033
|
+
};
|
|
1444
2034
|
}
|
|
1445
2035
|
|
|
1446
2036
|
/** A timer that never keeps the process alive and is cleared by close(). */
|
|
@@ -1456,6 +2046,13 @@ class HmSim {
|
|
|
1456
2046
|
return handle;
|
|
1457
2047
|
}
|
|
1458
2048
|
|
|
2049
|
+
clearTimer(handle) {
|
|
2050
|
+
if (handle) {
|
|
2051
|
+
clearTimeout(handle);
|
|
2052
|
+
this.timers.delete(handle);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1459
2056
|
/**
|
|
1460
2057
|
* Every putParamset the simulator accepted, oldest first.
|
|
1461
2058
|
* @returns {Array<{iface: string, address: string, paramset: string, values: object, ts: number}>}
|
|
@@ -1476,12 +2073,16 @@ class HmSim {
|
|
|
1476
2073
|
getDeviceDescription: (iface, params) => this.getDeviceDescription(iface, params[0]),
|
|
1477
2074
|
ping: (iface, params) => this.ping(iface, params),
|
|
1478
2075
|
setValue: (iface, params) => {
|
|
2076
|
+
this.requireReachable(iface, params[0]);
|
|
1479
2077
|
this.setValue(iface, params[0], params[1], params[2], {strict: true});
|
|
1480
2078
|
return '';
|
|
1481
2079
|
},
|
|
1482
2080
|
getValue: (iface, params) => this.getValue(iface, params[0], params[1]),
|
|
1483
2081
|
getParamset: (iface, params) => this.getParamset(iface, params[0], params[1]),
|
|
1484
|
-
putParamset: (iface, params) =>
|
|
2082
|
+
putParamset: (iface, params) => {
|
|
2083
|
+
this.requireReachable(iface, params[0]);
|
|
2084
|
+
return this.putParamset(iface, params[0], params[1], params[2]);
|
|
2085
|
+
},
|
|
1485
2086
|
'system.methodHelp': (iface, params) => this.methodHelp(params[0]),
|
|
1486
2087
|
getLinks: (iface, params) => this.getLinks(iface, params),
|
|
1487
2088
|
getLinkPeers: (iface, params) => this.getLinkPeers(iface, params),
|
|
@@ -1507,6 +2108,9 @@ class HmSim {
|
|
|
1507
2108
|
clearConfigCache: (iface, params) => this.clearConfigCache(iface, params[0]),
|
|
1508
2109
|
restoreConfigToDevice: (iface, params) => this.restoreConfigToDevice(iface, params[0]),
|
|
1509
2110
|
determineParameter: (iface, params) => this.determineParameter(iface, params),
|
|
2111
|
+
logLevel: (iface, params) => this.logLevel(iface, params),
|
|
2112
|
+
changeKey: (iface, params) => this.changeKey(iface, params),
|
|
2113
|
+
refreshDeployedDeviceFirmwareList: (iface) => this.refreshDeployedDeviceFirmwareList(iface),
|
|
1510
2114
|
};
|
|
1511
2115
|
|
|
1512
2116
|
// the CCU accepts the lower case spelling as well
|
|
@@ -1521,15 +2125,17 @@ class HmSim {
|
|
|
1521
2125
|
|
|
1522
2126
|
multicall(iface, params) {
|
|
1523
2127
|
const calls = (params && params[0]) || [];
|
|
2128
|
+
const asFault = (error) => ({faultCode: error.faultCode, faultString: error.faultString});
|
|
1524
2129
|
const results = [];
|
|
1525
2130
|
for (const call of calls) {
|
|
1526
2131
|
try {
|
|
1527
|
-
|
|
2132
|
+
const result = this.callMethod(iface, call.methodName, call.params || []);
|
|
2133
|
+
results.push(isThenable(result) ? result.then((value) => [value], asFault) : [result]);
|
|
1528
2134
|
} catch (error) {
|
|
1529
|
-
results.push(
|
|
2135
|
+
results.push(asFault(error));
|
|
1530
2136
|
}
|
|
1531
2137
|
}
|
|
1532
|
-
return results;
|
|
2138
|
+
return results.some(isThenable) ? Promise.all(results) : results;
|
|
1533
2139
|
}
|
|
1534
2140
|
|
|
1535
2141
|
callMethod(iface, method, params) {
|
|
@@ -1548,32 +2154,158 @@ class HmSim {
|
|
|
1548
2154
|
* @param {Array} params
|
|
1549
2155
|
* @param {function} callback (fault, result)
|
|
1550
2156
|
*/
|
|
1551
|
-
dispatch(iface, method, params, callback) {
|
|
2157
|
+
dispatch(iface, method, params, callback, context = {}) {
|
|
1552
2158
|
this.log.debug('rpc', iface, '<', method, shortenParams(params));
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
2159
|
+
const gate = this.gates[iface];
|
|
2160
|
+
if (gate) {
|
|
2161
|
+
gate.then(() => this.dispatchNow(iface, method, params, callback, context));
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
this.dispatchNow(iface, method, params, callback, context);
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
dispatchNow(iface, method, params, callback, context) {
|
|
2168
|
+
const injected = this.takeInjectedFault(iface, method);
|
|
2169
|
+
if (!injected) {
|
|
2170
|
+
this.execute(iface, method, params, callback);
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
const act = () => {
|
|
2175
|
+
if (injected.hang) {
|
|
2176
|
+
this.log.info('rpc', iface, method, 'injected: never answered');
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
if (injected.closeSocket) {
|
|
2180
|
+
this.log.info('rpc', iface, method, 'injected: connection closed');
|
|
2181
|
+
if (context.socket) {
|
|
2182
|
+
context.socket.destroy();
|
|
2183
|
+
}
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (injected.fault !== undefined) {
|
|
2187
|
+
callback(this.injectedFaultOf(injected.fault, method));
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
this.execute(iface, method, params, callback);
|
|
2191
|
+
};
|
|
2192
|
+
if (injected.delayMs > 0) {
|
|
2193
|
+
this.timer(act, injected.delayMs);
|
|
2194
|
+
} else {
|
|
2195
|
+
act();
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
execute(iface, method, params, callback) {
|
|
2200
|
+
const failed = (error) => {
|
|
1557
2201
|
if (error.faultCode === undefined) {
|
|
1558
2202
|
this.log.error('rpc', iface, method, error.stack || error.message);
|
|
1559
2203
|
callback(this.fault('invalidArguments', error.message));
|
|
1560
2204
|
return;
|
|
1561
2205
|
}
|
|
1562
2206
|
callback(error);
|
|
2207
|
+
};
|
|
2208
|
+
let result;
|
|
2209
|
+
try {
|
|
2210
|
+
result = this.callMethod(iface, method, params);
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
failed(error);
|
|
2213
|
+
return;
|
|
2214
|
+
}
|
|
2215
|
+
if (isThenable(result)) {
|
|
2216
|
+
result.then((value) => callback(null, value === undefined ? '' : value), failed);
|
|
1563
2217
|
return;
|
|
1564
2218
|
}
|
|
1565
2219
|
callback(null, result);
|
|
1566
2220
|
}
|
|
1567
2221
|
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
2222
|
+
/* ------------------------------------------------------------------ fault injection */
|
|
2223
|
+
|
|
2224
|
+
/**
|
|
2225
|
+
* Makes the next calls of a method misbehave.
|
|
2226
|
+
*
|
|
2227
|
+
* @param {object} rule
|
|
2228
|
+
* @param {string} [rule.iface] the interface, every one when omitted
|
|
2229
|
+
* @param {string} [rule.method='*'] the method, `'*'` for any
|
|
2230
|
+
* @param {number} [rule.times=1] how many calls, `Infinity` or `-1` for all until clearFaults()
|
|
2231
|
+
* @param {number} [rule.delayMs] answer (or misbehave) only after this many milliseconds
|
|
2232
|
+
* @param {boolean} [rule.hang] never answer; the connection stays open
|
|
2233
|
+
* @param {boolean} [rule.closeSocket] close the connection instead of answering
|
|
2234
|
+
* @param {object|string} [rule.fault] answer this fault: `{faultCode, faultString}` or the name
|
|
2235
|
+
* of an entry of the fault table (`'unknownInstance'`, `'notReachable'`, ...)
|
|
2236
|
+
*/
|
|
2237
|
+
injectFault(rule = {}) {
|
|
2238
|
+
const entry = {method: '*', times: 1, ...rule};
|
|
2239
|
+
if (entry.fault !== undefined) {
|
|
2240
|
+
// check now, not when the call arrives
|
|
2241
|
+
this.injectedFaultOf(entry.fault, entry.method);
|
|
1574
2242
|
}
|
|
2243
|
+
this.injectedFaults.push(entry);
|
|
2244
|
+
return entry;
|
|
2245
|
+
}
|
|
1575
2246
|
|
|
1576
|
-
|
|
2247
|
+
/** Removes every injected fault that did not happen yet. */
|
|
2248
|
+
clearFaults() {
|
|
2249
|
+
this.injectedFaults = [];
|
|
2250
|
+
}
|
|
2251
|
+
|
|
2252
|
+
takeInjectedFault(iface, method) {
|
|
2253
|
+
const index = this.injectedFaults.findIndex(
|
|
2254
|
+
(rule) =>
|
|
2255
|
+
(rule.iface === undefined || rule.iface === iface) && (rule.method === '*' || rule.method === method),
|
|
2256
|
+
);
|
|
2257
|
+
if (index === -1) {
|
|
2258
|
+
return undefined;
|
|
2259
|
+
}
|
|
2260
|
+
const rule = this.injectedFaults[index];
|
|
2261
|
+
// Infinity or a negative number: until clearFaults() (-1 is what JSON can carry)
|
|
2262
|
+
if (rule.times !== Infinity && rule.times >= 0) {
|
|
2263
|
+
rule.times--;
|
|
2264
|
+
if (rule.times <= 0) {
|
|
2265
|
+
this.injectedFaults.splice(index, 1);
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return rule;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
injectedFaultOf(fault, method) {
|
|
2272
|
+
if (typeof fault === 'string') {
|
|
2273
|
+
if (!this.faults[fault]) {
|
|
2274
|
+
throw new Error(`no fault ${fault} in the fault table`);
|
|
2275
|
+
}
|
|
2276
|
+
return this.fault(fault, `injected into ${method}`);
|
|
2277
|
+
}
|
|
2278
|
+
if (!fault || typeof fault.faultCode !== 'number') {
|
|
2279
|
+
throw new Error('an injected fault is a name of the fault table or {faultCode, faultString}');
|
|
2280
|
+
}
|
|
2281
|
+
return {faultCode: fault.faultCode, faultString: fault.faultString || ''};
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Every call the simulator made to a registered client, oldest first (the last 10000):
|
|
2286
|
+
* `{iface, client, method, sentAt, answeredAt, error}`. `answeredAt` is null while the client
|
|
2287
|
+
* has not answered, `error` the transport error or timeout.
|
|
2288
|
+
*/
|
|
2289
|
+
getCallbackLog() {
|
|
2290
|
+
return this.callbackLog.map((entry) => ({...entry}));
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
/**
|
|
2294
|
+
* `init(url, interfaceId)` - registers a logic layer, or with an empty id removes it.
|
|
2295
|
+
*
|
|
2296
|
+
* The url is `xmlrpc_bin://host:port` or `binrpc://…` for BIN-RPC callbacks, `http://` or
|
|
2297
|
+
* `https://host:port/path` for XML-RPC ones (the path is kept: a callback server may serve
|
|
2298
|
+
* several interfaces on one port). A registration is identified by the url exactly, scheme and
|
|
2299
|
+
* path included, as the interface processes do: `init('http://h:1', '')` does not remove a
|
|
2300
|
+
* `xmlrpc_bin://h:1` registration.
|
|
2301
|
+
*/
|
|
2302
|
+
init(iface, params) {
|
|
2303
|
+
const [url, id] = params || [];
|
|
2304
|
+
const parsed = parseCallbackUrl(url);
|
|
2305
|
+
if (!parsed) {
|
|
2306
|
+
throw this.fault('invalidArguments', `init url ${url}`);
|
|
2307
|
+
}
|
|
2308
|
+
const clientId = String(url);
|
|
1577
2309
|
|
|
1578
2310
|
if (id === '' || id === undefined) {
|
|
1579
2311
|
const client = this.clients[iface][clientId];
|
|
@@ -1583,21 +2315,12 @@ class HmSim {
|
|
|
1583
2315
|
delete this.clients[iface][clientId];
|
|
1584
2316
|
}
|
|
1585
2317
|
} 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
|
-
};
|
|
2318
|
+
const client = this.createClient(iface, parsed, url, id, clientId);
|
|
2319
|
+
if (this.interfaceOption(iface, 'listenerModel') === 'measured') {
|
|
2320
|
+
return BIDCOS_INTERFACES.has(iface)
|
|
2321
|
+
? this.initMeasuredBidcos(iface, client)
|
|
2322
|
+
: this.initMeasuredHmip(iface, client);
|
|
2323
|
+
}
|
|
1601
2324
|
this.clients[iface][clientId] = client;
|
|
1602
2325
|
this.startInit(iface, client);
|
|
1603
2326
|
}
|
|
@@ -1605,14 +2328,134 @@ class HmSim {
|
|
|
1605
2328
|
return '';
|
|
1606
2329
|
}
|
|
1607
2330
|
|
|
2331
|
+
/** A registered logic layer: its RPC client, and a methodCall that logs and can time out. */
|
|
2332
|
+
createClient(iface, {protocol, host, port, path}, url, id, key) {
|
|
2333
|
+
let rpcClient;
|
|
2334
|
+
if (protocol === 'binrpc') {
|
|
2335
|
+
rpcClient = binrpc.createClient({host, port});
|
|
2336
|
+
} else if (protocol === 'https') {
|
|
2337
|
+
rpcClient = xmlrpc.createSecureClient({host, port, path, rejectUnauthorized: false});
|
|
2338
|
+
} else {
|
|
2339
|
+
rpcClient = xmlrpc.createClient({host, port, path});
|
|
2340
|
+
}
|
|
2341
|
+
const client = {
|
|
2342
|
+
id,
|
|
2343
|
+
url,
|
|
2344
|
+
key,
|
|
2345
|
+
client: rpcClient,
|
|
2346
|
+
methodCall: (methodName, methodParams, callback, {timeout} = {}) => {
|
|
2347
|
+
this.log.debug('rpc >', url, methodName, shortenParams(methodParams));
|
|
2348
|
+
const entry = {
|
|
2349
|
+
iface,
|
|
2350
|
+
client: url,
|
|
2351
|
+
method: methodName,
|
|
2352
|
+
sentAt: Date.now(),
|
|
2353
|
+
answeredAt: null,
|
|
2354
|
+
error: null,
|
|
2355
|
+
};
|
|
2356
|
+
this.logCallback(entry);
|
|
2357
|
+
let done = false;
|
|
2358
|
+
let timer;
|
|
2359
|
+
const finish = (error, result) => {
|
|
2360
|
+
if (done) {
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
done = true;
|
|
2364
|
+
this.clearTimer(timer);
|
|
2365
|
+
entry.answeredAt = Date.now();
|
|
2366
|
+
entry.error = error ? String(error.message || error) : null;
|
|
2367
|
+
this.log.debug('rpc <', url, error ? error.message : shortenParams(result));
|
|
2368
|
+
if (typeof callback === 'function') {
|
|
2369
|
+
callback(error, result);
|
|
2370
|
+
}
|
|
2371
|
+
};
|
|
2372
|
+
if (timeout > 0) {
|
|
2373
|
+
timer = this.timer(() => finish(new Error(`no answer within ${timeout} ms`)), timeout);
|
|
2374
|
+
}
|
|
2375
|
+
client.client.methodCall(methodName, methodParams, finish);
|
|
2376
|
+
},
|
|
2377
|
+
};
|
|
2378
|
+
return client;
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
logCallback(entry) {
|
|
2382
|
+
this.callbackLog.push(entry);
|
|
2383
|
+
if (this.callbackLog.length > CALLBACK_LOG_SIZE) {
|
|
2384
|
+
this.callbackLog.splice(0, this.callbackLog.length - CALLBACK_LOG_SIZE);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
/**
|
|
2389
|
+
* rfd's `init`, as measured with a callback server that never answers (firmware 3.89.8,
|
|
2390
|
+
* 2026-09-25): rfd calls `system.listMethods` back *before* it answers the `init`, and until
|
|
2391
|
+
* that callback returns, rfd answers nothing else at all - every other client's calls wait.
|
|
2392
|
+
* When the callback fails (the connection ended), rfd tries it three more times at once and
|
|
2393
|
+
* registers the client anyway. A registered client that does not take a callback within
|
|
2394
|
+
* `deliveryTimeout` (10 s measured) is dropped. That `listDevices` also comes before the
|
|
2395
|
+
* answer is the model of the simulator, as the rest of the init sequence.
|
|
2396
|
+
*/
|
|
2397
|
+
initMeasuredBidcos(iface, client) {
|
|
2398
|
+
const release = this.closeGate(iface);
|
|
2399
|
+
return new Promise((resolve) => {
|
|
2400
|
+
const listMethods = (attempt) => {
|
|
2401
|
+
client.methodCall('system.listMethods', [client.id], (error) => {
|
|
2402
|
+
if (error && attempt < 4) {
|
|
2403
|
+
listMethods(attempt + 1);
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
this.clients[iface][client.key] = client;
|
|
2407
|
+
client.methodCall('listDevices', [client.id], (listError, clientDevices) => {
|
|
2408
|
+
release();
|
|
2409
|
+
resolve('');
|
|
2410
|
+
if (!listError) {
|
|
2411
|
+
this.checkDevices(iface, client, clientDevices);
|
|
2412
|
+
}
|
|
2413
|
+
});
|
|
2414
|
+
});
|
|
2415
|
+
};
|
|
2416
|
+
listMethods(1);
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
/**
|
|
2421
|
+
* hmipserver's `init`, as measured (firmware 3.89.8, 2026-09-25): the call is answered at
|
|
2422
|
+
* once and `listDevices` is called back afterwards. While that callback hangs, hmipserver
|
|
2423
|
+
* delivers **no event to any client** of the interface - they are queued and follow when the
|
|
2424
|
+
* connection ends, and the client that hung is not called again. After a registration
|
|
2425
|
+
* finished, a client that stops answering holds up only itself.
|
|
2426
|
+
*/
|
|
2427
|
+
initMeasuredHmip(iface, client) {
|
|
2428
|
+
this.clients[iface][client.key] = client;
|
|
2429
|
+
const release = this.hold(iface);
|
|
2430
|
+
client.methodCall('listDevices', [client.id], (error, clientDevices) => {
|
|
2431
|
+
if (error) {
|
|
2432
|
+
this.log.warn(iface, 'client', client.url, 'failed at listDevices - not registered:', error.message);
|
|
2433
|
+
if (this.clients[iface][client.key] === client) {
|
|
2434
|
+
this.forgetClient(iface, client.key);
|
|
2435
|
+
}
|
|
2436
|
+
} else {
|
|
2437
|
+
this.checkDevices(iface, client, clientDevices);
|
|
2438
|
+
}
|
|
2439
|
+
release();
|
|
2440
|
+
});
|
|
2441
|
+
return '';
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/**
|
|
2445
|
+
* `ping(callerId)` - the BidCos processes answer with a `CENTRAL`/`PONG` event to *every*
|
|
2446
|
+
* registered client, hmipserver sends none (`pong: false`). `pingDelay` delays the event.
|
|
2447
|
+
*/
|
|
1608
2448
|
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
|
|
2449
|
+
if (!this.interfaceOption(iface, 'pong')) {
|
|
1612
2450
|
return '';
|
|
1613
2451
|
}
|
|
1614
|
-
|
|
1615
|
-
this.
|
|
2452
|
+
const pong = () => this.event(iface, ['CENTRAL', 'PONG', (params || [])[0]]);
|
|
2453
|
+
const delay = this.interfaceOption(iface, 'pingDelay');
|
|
2454
|
+
if (delay > 0) {
|
|
2455
|
+
this.timer(pong, delay);
|
|
2456
|
+
} else {
|
|
2457
|
+
pong();
|
|
2458
|
+
}
|
|
1616
2459
|
return '';
|
|
1617
2460
|
}
|
|
1618
2461
|
|
|
@@ -1626,7 +2469,7 @@ class HmSim {
|
|
|
1626
2469
|
event(iface, params) {
|
|
1627
2470
|
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1628
2471
|
const client = this.clients[iface][key];
|
|
1629
|
-
|
|
2472
|
+
this.deliver(iface, client, 'event', [client.id, ...params]);
|
|
1630
2473
|
}
|
|
1631
2474
|
}
|
|
1632
2475
|
|
|
@@ -1637,7 +2480,7 @@ class HmSim {
|
|
|
1637
2480
|
methodName: 'event',
|
|
1638
2481
|
params: [client.id, singleEvent[0], singleEvent[1], singleEvent[2]],
|
|
1639
2482
|
}));
|
|
1640
|
-
|
|
2483
|
+
this.deliver(iface, client, 'system.multicall', [multicall]);
|
|
1641
2484
|
}
|
|
1642
2485
|
}
|
|
1643
2486
|
|
|
@@ -1764,7 +2607,8 @@ class HmSim {
|
|
|
1764
2607
|
return createBinrpcServer({
|
|
1765
2608
|
host: listenAddress,
|
|
1766
2609
|
port,
|
|
1767
|
-
dispatch: (method, params, callback) =>
|
|
2610
|
+
dispatch: (method, params, callback, context) =>
|
|
2611
|
+
this.dispatch(iface, method, params, callback, context),
|
|
1768
2612
|
onListening,
|
|
1769
2613
|
onError,
|
|
1770
2614
|
});
|
|
@@ -1778,7 +2622,28 @@ class HmSim {
|
|
|
1778
2622
|
path,
|
|
1779
2623
|
tls: this.tls,
|
|
1780
2624
|
auth,
|
|
1781
|
-
dispatch: (method, params, callback) =>
|
|
2625
|
+
dispatch: (method, params, callback, context) =>
|
|
2626
|
+
this.dispatch(iface, method, params, callback, context),
|
|
2627
|
+
onListening,
|
|
2628
|
+
onError,
|
|
2629
|
+
});
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
// rfd and hs485d answer BIN-RPC and XML-RPC on the same port; `protocols` narrows it
|
|
2633
|
+
const bidcosServer = (iface, port, label) => {
|
|
2634
|
+
const protocols = this.interfaceOption(iface, 'protocols') || ['binrpc', 'xmlrpc'];
|
|
2635
|
+
if (protocols.length === 1 && protocols[0] === 'binrpc') {
|
|
2636
|
+
return binrpcServer(iface, port, `${label} binrpc`);
|
|
2637
|
+
}
|
|
2638
|
+
const {onListening, onError} = this.listeningCallbacks(iface, label, listenAddress);
|
|
2639
|
+
return createDualServer({
|
|
2640
|
+
host: listenAddress,
|
|
2641
|
+
port,
|
|
2642
|
+
protocols,
|
|
2643
|
+
tls: this.tls,
|
|
2644
|
+
auth,
|
|
2645
|
+
dispatch: (method, params, callback, context) =>
|
|
2646
|
+
this.dispatch(iface, method, params, callback, context),
|
|
1782
2647
|
onListening,
|
|
1783
2648
|
onError,
|
|
1784
2649
|
});
|
|
@@ -1786,17 +2651,20 @@ class HmSim {
|
|
|
1786
2651
|
|
|
1787
2652
|
// the factory of an interface is kept so that dropConnection() can restart its server
|
|
1788
2653
|
const start = (iface, port, factory) => {
|
|
2654
|
+
if (this.interfaceOption(iface, 'startDelay') > 0) {
|
|
2655
|
+
this.delayedStarts[iface] = this.interfaceOption(iface, 'startDelay');
|
|
2656
|
+
}
|
|
1789
2657
|
this.serverFactories[iface] = factory;
|
|
1790
2658
|
this.servers[iface] = factory(port);
|
|
1791
2659
|
this.exposeServer(iface);
|
|
1792
2660
|
};
|
|
1793
2661
|
|
|
1794
2662
|
if (this.devices.rfd) {
|
|
1795
|
-
start('rfd', binrpcListenPort, (port) =>
|
|
2663
|
+
start('rfd', binrpcListenPort, (port) => bidcosServer('rfd', port, 'rfd'));
|
|
1796
2664
|
}
|
|
1797
2665
|
|
|
1798
2666
|
if (this.devices.wired) {
|
|
1799
|
-
start('wired', this.config.wiredListenPort, (port) =>
|
|
2667
|
+
start('wired', this.config.wiredListenPort, (port) => bidcosServer('wired', port, 'wired'));
|
|
1800
2668
|
}
|
|
1801
2669
|
|
|
1802
2670
|
if (this.devices.hmip) {
|
|
@@ -1839,6 +2707,15 @@ class HmSim {
|
|
|
1839
2707
|
const port = serverPort(server);
|
|
1840
2708
|
this.ports[iface] = port;
|
|
1841
2709
|
this.log.info(label, 'server listening on', host, port);
|
|
2710
|
+
const delay = this.delayedStarts[iface];
|
|
2711
|
+
if (delay !== undefined) {
|
|
2712
|
+
// the port is known now; it refuses connections until the delay is over
|
|
2713
|
+
delete this.delayedStarts[iface];
|
|
2714
|
+
this.stopped.add(iface);
|
|
2715
|
+
server.close();
|
|
2716
|
+
this.log.info(label, 'starts in', delay, 'ms');
|
|
2717
|
+
this.timer(() => this.startInterface(iface, {forgetClients: false}), delay);
|
|
2718
|
+
}
|
|
1842
2719
|
resolve();
|
|
1843
2720
|
},
|
|
1844
2721
|
onError: (error) => {
|
|
@@ -1881,8 +2758,9 @@ class HmSim {
|
|
|
1881
2758
|
clearTimeout(handle);
|
|
1882
2759
|
}
|
|
1883
2760
|
this.timers.clear();
|
|
1884
|
-
for (const server of Object.
|
|
1885
|
-
|
|
2761
|
+
for (const [iface, server] of Object.entries(this.servers)) {
|
|
2762
|
+
// a stopped server is closed already, and binrpc's close() rejects a second time
|
|
2763
|
+
if (typeof server.close === 'function' && !this.stopped.has(iface)) {
|
|
1886
2764
|
server.close();
|
|
1887
2765
|
}
|
|
1888
2766
|
}
|
|
@@ -1907,6 +2785,32 @@ function serverPort(server) {
|
|
|
1907
2785
|
return address ? address.port : undefined;
|
|
1908
2786
|
}
|
|
1909
2787
|
|
|
2788
|
+
/**
|
|
2789
|
+
* The parts of a callback url: `{protocol: 'binrpc'|'http'|'https', host, port, path}`, or
|
|
2790
|
+
* undefined when it is none.
|
|
2791
|
+
*/
|
|
2792
|
+
function parseCallbackUrl(url) {
|
|
2793
|
+
const match = /^([A-Za-z_]+):\/\/(\[[^\]]+\]|[^/:]+):(\d+)(\/.*)?$/.exec(String(url));
|
|
2794
|
+
if (!match) {
|
|
2795
|
+
return undefined;
|
|
2796
|
+
}
|
|
2797
|
+
const scheme = match[1].toLowerCase();
|
|
2798
|
+
const protocol = scheme === 'xmlrpc_bin' || scheme === 'binrpc' ? 'binrpc' : scheme;
|
|
2799
|
+
if (!['binrpc', 'http', 'https', 'xmlrpc'].includes(protocol)) {
|
|
2800
|
+
return undefined;
|
|
2801
|
+
}
|
|
2802
|
+
return {
|
|
2803
|
+
protocol: protocol === 'xmlrpc' ? 'http' : protocol,
|
|
2804
|
+
host: match[2].replace(/^\[|\]$/g, ''),
|
|
2805
|
+
port: Number(match[3]),
|
|
2806
|
+
path: match[4] || '/',
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
function isThenable(value) {
|
|
2811
|
+
return value !== null && typeof value === 'object' && typeof value.then === 'function';
|
|
2812
|
+
}
|
|
2813
|
+
|
|
1910
2814
|
/** Closes a binrpc or xmlrpc client so that it stops holding the event loop open. */
|
|
1911
2815
|
function closeClient(client) {
|
|
1912
2816
|
if (!client) {
|