hm-simulator 1.0.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 -223
- 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 +8 -0
- package/lib/paramset-index.js +107 -0
- package/lib/servers.js +262 -13
- package/lib/sim.js +1142 -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,6 +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
|
+
|
|
45
|
+
/** the interfaces that speak the BidCos protocol, and therefore have setTempKey */
|
|
46
|
+
const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
|
|
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
|
+
|
|
40
54
|
/** paramset keys that are not the address of a linked peer channel */
|
|
41
55
|
const STANDARD_PARAMSETS = new Set(['MASTER', 'VALUES', 'SERVICE']);
|
|
42
56
|
|
|
@@ -60,12 +74,39 @@ const SERVICE_FLAG = 8;
|
|
|
60
74
|
* 'pending' - the write is accepted, the valid parameters are stored, and CONFIG_PENDING is
|
|
61
75
|
* raised on the device's :0 channel and stays until a valid full MASTER write or
|
|
62
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.
|
|
63
95
|
* configPendingOnWrite
|
|
64
96
|
* raise CONFIG_PENDING for every accepted MASTER write, the way a BidCos battery device queues
|
|
65
97
|
* the configuration until it wakes up. Clears after configPendingDelay milliseconds. Implied by
|
|
66
98
|
* 'bidcos', which raises it for every write that changes something.
|
|
67
99
|
*/
|
|
68
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,
|
|
69
110
|
configPendingMode: 'strict',
|
|
70
111
|
configPendingOnWrite: false,
|
|
71
112
|
configPendingDelay: 0,
|
|
@@ -79,7 +120,8 @@ const INTERFACE_DEFAULTS = {
|
|
|
79
120
|
* the value from INTERFACE_DEFAULTS.
|
|
80
121
|
*/
|
|
81
122
|
const MEASURED_INTERFACE_DEFAULTS = {
|
|
82
|
-
|
|
123
|
+
// hmipserver answers ping without a PONG event, see https://github.com/eq-3/occu/issues/42
|
|
124
|
+
hmip: {configPendingMode: 'hmip', pong: false},
|
|
83
125
|
rfd: {configPendingMode: 'bidcos'},
|
|
84
126
|
wired: {configPendingMode: 'bidcos'},
|
|
85
127
|
};
|
|
@@ -146,9 +188,11 @@ function shortenParams(params) {
|
|
|
146
188
|
* @param {object} [options.log] object with debug/info/warn/error methods
|
|
147
189
|
* @param {object} [options.devices] {<iface>: {devices: []}} - device descriptions per interface
|
|
148
190
|
* @param {object} [options.config] listenAddress, binrpcListenPort, xmlrpcListenPort
|
|
149
|
-
* @param {string} [options.behaviorPath] directory with behaviour scripts
|
|
191
|
+
* @param {string|false} [options.behaviorPath] directory with behaviour scripts, `false` for none
|
|
150
192
|
* @param {object} [options.rega] ReGa mock options, see lib/rega.js - omit to not start it
|
|
151
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
|
|
152
196
|
* @param {object} [options.faults] overrides for the fault table, see lib/faults.js
|
|
153
197
|
*/
|
|
154
198
|
class HmSim {
|
|
@@ -162,6 +206,10 @@ class HmSim {
|
|
|
162
206
|
|
|
163
207
|
this.devices = options.devices || {rfd: {devices: []}, hmip: {devices: []}};
|
|
164
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);
|
|
165
213
|
|
|
166
214
|
const {table, fault} = createFaults(options.faults);
|
|
167
215
|
this.faults = table;
|
|
@@ -181,18 +229,42 @@ class HmSim {
|
|
|
181
229
|
this.serviceMessages = {};
|
|
182
230
|
/** end of the install mode per interface, as an epoch timestamp */
|
|
183
231
|
this.installMode = {};
|
|
232
|
+
/** the temporary AES key of the BidCos interfaces, see setTempKey */
|
|
233
|
+
this.tempKey = {};
|
|
184
234
|
/** devices that appear when the install mode is switched on, see scriptNewDevices() */
|
|
185
235
|
this.newDevicesScript = {};
|
|
186
236
|
/** every reportValueUsage the simulator was told about */
|
|
187
237
|
this.valueUsage = [];
|
|
188
238
|
/** every updateFirmware/installFirmware call */
|
|
189
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 = {};
|
|
190
246
|
/** pending timers, cleared by close() */
|
|
191
247
|
this.timers = new Set();
|
|
192
248
|
/** devices with a pending configuration: {<iface>: Map<deviceAddress, {sticky}>} */
|
|
193
249
|
this.configPending = {};
|
|
194
250
|
/** channels whose stored MASTER carries a parameter the device does not have */
|
|
195
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 = {};
|
|
196
268
|
|
|
197
269
|
this.rpcMethods = this.buildRpcMethods();
|
|
198
270
|
|
|
@@ -226,7 +298,10 @@ class HmSim {
|
|
|
226
298
|
this.setValue(iface, address, datapoint, value, {internal: true}),
|
|
227
299
|
);
|
|
228
300
|
|
|
229
|
-
|
|
301
|
+
// `behaviorPath: false`: no behaviour scripts at all
|
|
302
|
+
if (options.behaviorPath !== false) {
|
|
303
|
+
this.loadBehaviors(options.behaviorPath || path.join(__dirname, '..', 'behaviors'));
|
|
304
|
+
}
|
|
230
305
|
|
|
231
306
|
if (options.rega) {
|
|
232
307
|
// the ReGa mock inherits TLS and basic auth unless its own options say otherwise
|
|
@@ -294,10 +369,7 @@ class HmSim {
|
|
|
294
369
|
if (this.values[iface][device.ADDRESS].VALUES[name] !== undefined) {
|
|
295
370
|
continue;
|
|
296
371
|
}
|
|
297
|
-
this.values[iface][device.ADDRESS].VALUES[name] =
|
|
298
|
-
parameter.TYPE === 'ENUM' && Array.isArray(parameter.VALUE_LIST)
|
|
299
|
-
? parameter.VALUE_LIST.indexOf(parameter.DEFAULT)
|
|
300
|
-
: parameter.DEFAULT;
|
|
372
|
+
this.values[iface][device.ADDRESS].VALUES[name] = defaultValue(parameter);
|
|
301
373
|
}
|
|
302
374
|
}
|
|
303
375
|
}
|
|
@@ -340,7 +412,76 @@ class HmSim {
|
|
|
340
412
|
}
|
|
341
413
|
|
|
342
414
|
const name = this.paramsetName(iface, device, paramset);
|
|
343
|
-
|
|
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()];
|
|
344
485
|
}
|
|
345
486
|
|
|
346
487
|
/* ------------------------------------------------------------------ paramsets */
|
|
@@ -900,10 +1041,9 @@ class HmSim {
|
|
|
900
1041
|
|
|
901
1042
|
listBidcosInterfaces(iface) {
|
|
902
1043
|
const configured = this.options.bidcosInterfaces && this.options.bidcosInterfaces[iface];
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
}
|
|
906
|
-
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;
|
|
907
1047
|
}
|
|
908
1048
|
|
|
909
1049
|
setBidcosInterface(iface, params) {
|
|
@@ -978,6 +1118,233 @@ class HmSim {
|
|
|
978
1118
|
return '';
|
|
979
1119
|
}
|
|
980
1120
|
|
|
1121
|
+
/**
|
|
1122
|
+
* `setTempKey(passphrase)` - the temporary key a BidCos device is taught in with.
|
|
1123
|
+
*
|
|
1124
|
+
* A HomeMatic device can be paired with a passphrase instead of the interface's own key, and a
|
|
1125
|
+
* device that was taught in that way can only be paired again when the same passphrase is
|
|
1126
|
+
* offered. rfd takes it as one string argument and answers with the empty string; it applies to
|
|
1127
|
+
* the pairings that follow, so an application sends it *before* `setInstallMode` or
|
|
1128
|
+
* `addDevice`. An empty string clears it back to the interface's key.
|
|
1129
|
+
*
|
|
1130
|
+
* Only the BidCos processes have the method - hmipserver answers an unknown-method fault, which
|
|
1131
|
+
* is what an application has to handle when a user types it into the wrong interface.
|
|
1132
|
+
*
|
|
1133
|
+
* The simulator does no cryptography: it remembers the key so a test can assert that the call
|
|
1134
|
+
* happened, in which order, and with what. `sim.getTempKey(iface)` reads it back.
|
|
1135
|
+
*
|
|
1136
|
+
* @param {string} iface
|
|
1137
|
+
* @param {Array} params [passphrase]
|
|
1138
|
+
*/
|
|
1139
|
+
setTempKey(iface, params) {
|
|
1140
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1141
|
+
throw this.fault('unknownMethod', `${iface}/setTempKey`);
|
|
1142
|
+
}
|
|
1143
|
+
const [key] = params || [];
|
|
1144
|
+
if (key !== undefined && typeof key !== 'string') {
|
|
1145
|
+
throw this.fault('invalidArguments', 'setTempKey wants a string');
|
|
1146
|
+
}
|
|
1147
|
+
this.tempKey[iface] = key === undefined ? '' : key;
|
|
1148
|
+
this.log.info(iface, this.tempKey[iface] === '' ? 'temporary key cleared' : 'temporary key set');
|
|
1149
|
+
return '';
|
|
1150
|
+
}
|
|
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
|
+
|
|
1213
|
+
/* ------------------------------------------------------------------ teams */
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* `listTeams()` - the team pseudo devices of a BidCos interface, with their channels.
|
|
1217
|
+
*
|
|
1218
|
+
* rfd keeps smoke detectors (and the other devices with a `TEAM_TAG`) in *teams*. A team is a
|
|
1219
|
+
* pseudo device whose address is `*` plus the serial of the device it was made from
|
|
1220
|
+
* (`*NEQ0448334`, type `HM-Sec-SD-2-Team`); its team channel carries the members in
|
|
1221
|
+
* `TEAM_CHANNELS`, and every member channel names the team channel in `TEAM`. The team devices
|
|
1222
|
+
* come with `listDevices` as well; `listTeams` is only them. hmipserver has no teams.
|
|
1223
|
+
*
|
|
1224
|
+
* @param {string} iface
|
|
1225
|
+
*/
|
|
1226
|
+
listTeams(iface) {
|
|
1227
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1228
|
+
throw this.fault('unknownMethod', `${iface}/listTeams`);
|
|
1229
|
+
}
|
|
1230
|
+
return this.devices[iface].devices.filter((device) => device.ADDRESS.startsWith('*'));
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* `setTeam(channelAddress, teamAddress)` - puts a channel into a team.
|
|
1235
|
+
*
|
|
1236
|
+
* The shape is rfd's, as Homematic Manager task 58 read it from a CCU3: every detector starts in
|
|
1237
|
+
* a team of its own (`*<its serial>`); joining another team takes it out of the old one. That a
|
|
1238
|
+
* team nobody is left in is deleted (`deleteDevices`) is the simulator's model, not a measurement. An empty
|
|
1239
|
+
* `teamAddress` puts the channel back into a team of its own, which is created again
|
|
1240
|
+
* (`newDevices`) with the type of the team it leaves. Channel and team must carry the same
|
|
1241
|
+
* `TEAM_TAG`; there is no call that creates a team. The changed channels are announced with
|
|
1242
|
+
* `updateDevice`. BidCos only.
|
|
1243
|
+
*
|
|
1244
|
+
* @param {string} iface
|
|
1245
|
+
* @param {Array} params [channelAddress, teamAddress]
|
|
1246
|
+
*/
|
|
1247
|
+
setTeam(iface, params) {
|
|
1248
|
+
if (!BIDCOS_INTERFACES.has(iface)) {
|
|
1249
|
+
throw this.fault('unknownMethod', `${iface}/setTeam`);
|
|
1250
|
+
}
|
|
1251
|
+
const [channelAddress, teamAddress = ''] = params || [];
|
|
1252
|
+
if (typeof channelAddress !== 'string' || typeof teamAddress !== 'string') {
|
|
1253
|
+
throw this.fault('invalidArguments', 'setTeam wants two strings');
|
|
1254
|
+
}
|
|
1255
|
+
const channel = this.requireDevice(iface, channelAddress);
|
|
1256
|
+
if (!channel.PARENT || !channel.TEAM_TAG || Array.isArray(channel.TEAM_CHANNELS)) {
|
|
1257
|
+
throw this.fault('invalidArguments', `${channelAddress} cannot be in a team`);
|
|
1258
|
+
}
|
|
1259
|
+
const oldTeam = channel.TEAM ? this.index[iface].get(channel.TEAM) : undefined;
|
|
1260
|
+
const ownDevice = `*${channel.PARENT}`;
|
|
1261
|
+
const ownTeam = `${ownDevice}:1`;
|
|
1262
|
+
const target = teamAddress === '' ? ownTeam : teamAddress;
|
|
1263
|
+
if (channel.TEAM === target) {
|
|
1264
|
+
return '';
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
let team = this.index[iface].get(target);
|
|
1268
|
+
if (team === undefined && target === ownTeam) {
|
|
1269
|
+
team = this.createTeam(iface, channel, oldTeam);
|
|
1270
|
+
}
|
|
1271
|
+
if (team === undefined) {
|
|
1272
|
+
throw this.fault('unknownInstance', `${iface}/${target}`);
|
|
1273
|
+
}
|
|
1274
|
+
if (!Array.isArray(team.TEAM_CHANNELS) || team.TEAM_TAG !== channel.TEAM_TAG) {
|
|
1275
|
+
throw this.fault('invalidArguments', `${target} is no team for ${channelAddress}`);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const changed = [channelAddress, target];
|
|
1279
|
+
if (oldTeam) {
|
|
1280
|
+
oldTeam.TEAM_CHANNELS = (oldTeam.TEAM_CHANNELS || []).filter((member) => member !== channelAddress);
|
|
1281
|
+
changed.push(oldTeam.ADDRESS);
|
|
1282
|
+
}
|
|
1283
|
+
team.TEAM_CHANNELS = [...team.TEAM_CHANNELS.filter((member) => member !== channelAddress), channelAddress];
|
|
1284
|
+
channel.TEAM = target;
|
|
1285
|
+
this.log.info(iface, 'team', channelAddress, '->', target);
|
|
1286
|
+
|
|
1287
|
+
for (const address of changed) {
|
|
1288
|
+
if (this.index[iface].has(address) && !(oldTeam && address === oldTeam.ADDRESS)) {
|
|
1289
|
+
this.tellClientsParams(iface, 'updateDevice', [address, 0]);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// a team nobody is in any more is gone
|
|
1293
|
+
if (oldTeam && oldTeam.TEAM_CHANNELS.length === 0 && oldTeam.PARENT) {
|
|
1294
|
+
this.deleteDevice(iface, oldTeam.PARENT);
|
|
1295
|
+
} else if (oldTeam) {
|
|
1296
|
+
this.tellClientsParams(iface, 'updateDevice', [oldTeam.ADDRESS, 0]);
|
|
1297
|
+
}
|
|
1298
|
+
return '';
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* A team of a channel's own, `*<serial>`, shaped like the team it leaves (or, without one, as
|
|
1303
|
+
* `<PARENT_TYPE>-Team` with a `<TYPE>_TEAM` channel), and announced with `newDevices`.
|
|
1304
|
+
*/
|
|
1305
|
+
createTeam(iface, channel, like) {
|
|
1306
|
+
const parent = this.index[iface].get(channel.PARENT) || {};
|
|
1307
|
+
const likeDevice = like && like.PARENT ? this.index[iface].get(like.PARENT) : undefined;
|
|
1308
|
+
const address = `*${channel.PARENT}`;
|
|
1309
|
+
const type = likeDevice ? likeDevice.TYPE : `${channel.PARENT_TYPE}-Team`;
|
|
1310
|
+
const device = {
|
|
1311
|
+
ADDRESS: address,
|
|
1312
|
+
TYPE: type,
|
|
1313
|
+
VERSION: likeDevice ? likeDevice.VERSION : parent.VERSION || 1,
|
|
1314
|
+
FIRMWARE: likeDevice ? likeDevice.FIRMWARE : parent.FIRMWARE || '1.0',
|
|
1315
|
+
CHILDREN: [`${address}:0`, `${address}:1`],
|
|
1316
|
+
PARAMSETS: ['MASTER'],
|
|
1317
|
+
FLAGS: 9,
|
|
1318
|
+
};
|
|
1319
|
+
const maintenance = {
|
|
1320
|
+
ADDRESS: `${address}:0`,
|
|
1321
|
+
TYPE: 'MAINTENANCE',
|
|
1322
|
+
VERSION: device.VERSION,
|
|
1323
|
+
PARENT: address,
|
|
1324
|
+
PARENT_TYPE: type,
|
|
1325
|
+
PARAMSETS: ['MASTER', 'VALUES'],
|
|
1326
|
+
INDEX: 0,
|
|
1327
|
+
};
|
|
1328
|
+
const teamChannel = {
|
|
1329
|
+
ADDRESS: `${address}:1`,
|
|
1330
|
+
TYPE: like ? like.TYPE : `${channel.TYPE}_TEAM`,
|
|
1331
|
+
VERSION: device.VERSION,
|
|
1332
|
+
PARENT: address,
|
|
1333
|
+
PARENT_TYPE: type,
|
|
1334
|
+
PARAMSETS: ['MASTER', 'VALUES'],
|
|
1335
|
+
INDEX: 1,
|
|
1336
|
+
TEAM_TAG: channel.TEAM_TAG,
|
|
1337
|
+
TEAM_CHANNELS: [],
|
|
1338
|
+
};
|
|
1339
|
+
this.addDevices(iface, [device, maintenance, teamChannel]);
|
|
1340
|
+
return this.index[iface].get(teamChannel.ADDRESS);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/** The temporary key an interface was last given, `''` when there is none. */
|
|
1344
|
+
getTempKey(iface) {
|
|
1345
|
+
return this.tempKey[iface] || '';
|
|
1346
|
+
}
|
|
1347
|
+
|
|
981
1348
|
/** Remaining seconds of the install mode, 0 when it is off. */
|
|
982
1349
|
getInstallMode(iface) {
|
|
983
1350
|
const until = this.installMode[iface] || 0;
|
|
@@ -1066,6 +1433,9 @@ class HmSim {
|
|
|
1066
1433
|
this.requireDevice(iface, address);
|
|
1067
1434
|
}
|
|
1068
1435
|
this.firmwareUpdates.push({iface, addresses, method: 'updateFirmware', ts: Date.now()});
|
|
1436
|
+
for (const address of addresses) {
|
|
1437
|
+
this.performFirmwareUpdate(iface, address);
|
|
1438
|
+
}
|
|
1069
1439
|
return true;
|
|
1070
1440
|
}
|
|
1071
1441
|
|
|
@@ -1073,6 +1443,7 @@ class HmSim {
|
|
|
1073
1443
|
const address = params[0];
|
|
1074
1444
|
this.requireDevice(iface, address);
|
|
1075
1445
|
this.firmwareUpdates.push({iface, addresses: [address], method: 'installFirmware', ts: Date.now()});
|
|
1446
|
+
this.performFirmwareUpdate(iface, address);
|
|
1076
1447
|
return true;
|
|
1077
1448
|
}
|
|
1078
1449
|
|
|
@@ -1151,6 +1522,212 @@ class HmSim {
|
|
|
1151
1522
|
return added;
|
|
1152
1523
|
}
|
|
1153
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
|
+
|
|
1154
1731
|
/* ------------------------------------------------------------------ scenario api */
|
|
1155
1732
|
|
|
1156
1733
|
/**
|
|
@@ -1214,7 +1791,10 @@ class HmSim {
|
|
|
1214
1791
|
|
|
1215
1792
|
const port = this.ports[iface];
|
|
1216
1793
|
this.log.info(iface, 'dropping all connections');
|
|
1217
|
-
|
|
1794
|
+
if (!this.stopped.has(iface)) {
|
|
1795
|
+
await server.close();
|
|
1796
|
+
}
|
|
1797
|
+
this.stopped.delete(iface);
|
|
1218
1798
|
|
|
1219
1799
|
this.ready = [];
|
|
1220
1800
|
this.servers[iface] = factory(port);
|
|
@@ -1222,6 +1802,109 @@ class HmSim {
|
|
|
1222
1802
|
await this.whenReady();
|
|
1223
1803
|
}
|
|
1224
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
|
+
|
|
1225
1908
|
/**
|
|
1226
1909
|
* Raises or clears a service message. If the channel has the datapoint in its VALUES
|
|
1227
1910
|
* description the value is written as well, so that the logic layer sees the event too.
|
|
@@ -1261,10 +1944,93 @@ class HmSim {
|
|
|
1261
1944
|
|
|
1262
1945
|
/** Calls one method on every connected logic layer. */
|
|
1263
1946
|
tellClients(iface, method, payload) {
|
|
1947
|
+
this.tellClientsParams(iface, method, [payload]);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/** The same with several parameters after the interface id (`updateDevice(id, address, hint)`). */
|
|
1951
|
+
tellClientsParams(iface, method, params) {
|
|
1264
1952
|
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1265
1953
|
const client = this.clients[iface][key];
|
|
1266
|
-
|
|
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;
|
|
1968
|
+
}
|
|
1969
|
+
const measuredBidcos =
|
|
1970
|
+
BIDCOS_INTERFACES.has(iface) && this.interfaceOption(iface, 'listenerModel') === 'measured';
|
|
1971
|
+
if (!measuredBidcos) {
|
|
1972
|
+
client.methodCall(method, params);
|
|
1973
|
+
return;
|
|
1267
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
|
+
};
|
|
1268
2034
|
}
|
|
1269
2035
|
|
|
1270
2036
|
/** A timer that never keeps the process alive and is cleared by close(). */
|
|
@@ -1280,6 +2046,13 @@ class HmSim {
|
|
|
1280
2046
|
return handle;
|
|
1281
2047
|
}
|
|
1282
2048
|
|
|
2049
|
+
clearTimer(handle) {
|
|
2050
|
+
if (handle) {
|
|
2051
|
+
clearTimeout(handle);
|
|
2052
|
+
this.timers.delete(handle);
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
1283
2056
|
/**
|
|
1284
2057
|
* Every putParamset the simulator accepted, oldest first.
|
|
1285
2058
|
* @returns {Array<{iface: string, address: string, paramset: string, values: object, ts: number}>}
|
|
@@ -1300,12 +2073,16 @@ class HmSim {
|
|
|
1300
2073
|
getDeviceDescription: (iface, params) => this.getDeviceDescription(iface, params[0]),
|
|
1301
2074
|
ping: (iface, params) => this.ping(iface, params),
|
|
1302
2075
|
setValue: (iface, params) => {
|
|
2076
|
+
this.requireReachable(iface, params[0]);
|
|
1303
2077
|
this.setValue(iface, params[0], params[1], params[2], {strict: true});
|
|
1304
2078
|
return '';
|
|
1305
2079
|
},
|
|
1306
2080
|
getValue: (iface, params) => this.getValue(iface, params[0], params[1]),
|
|
1307
2081
|
getParamset: (iface, params) => this.getParamset(iface, params[0], params[1]),
|
|
1308
|
-
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
|
+
},
|
|
1309
2086
|
'system.methodHelp': (iface, params) => this.methodHelp(params[0]),
|
|
1310
2087
|
getLinks: (iface, params) => this.getLinks(iface, params),
|
|
1311
2088
|
getLinkPeers: (iface, params) => this.getLinkPeers(iface, params),
|
|
@@ -1320,6 +2097,9 @@ class HmSim {
|
|
|
1320
2097
|
getServiceMessages: (iface) => this.getServiceMessages(iface),
|
|
1321
2098
|
setInstallMode: (iface, params) => this.setInstallMode(iface, params),
|
|
1322
2099
|
getInstallMode: (iface) => this.getInstallMode(iface),
|
|
2100
|
+
setTempKey: (iface, params) => this.setTempKey(iface, params),
|
|
2101
|
+
listTeams: (iface) => this.listTeams(iface),
|
|
2102
|
+
setTeam: (iface, params) => this.setTeam(iface, params),
|
|
1323
2103
|
deleteDevice: (iface, params) => this.deleteDevice(iface, params[0], params[1]),
|
|
1324
2104
|
replaceDevice: (iface, params) => this.replaceDevice(iface, params[0], params[1]),
|
|
1325
2105
|
reportValueUsage: (iface, params) => this.reportValueUsage(iface, params),
|
|
@@ -1328,6 +2108,9 @@ class HmSim {
|
|
|
1328
2108
|
clearConfigCache: (iface, params) => this.clearConfigCache(iface, params[0]),
|
|
1329
2109
|
restoreConfigToDevice: (iface, params) => this.restoreConfigToDevice(iface, params[0]),
|
|
1330
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),
|
|
1331
2114
|
};
|
|
1332
2115
|
|
|
1333
2116
|
// the CCU accepts the lower case spelling as well
|
|
@@ -1342,15 +2125,17 @@ class HmSim {
|
|
|
1342
2125
|
|
|
1343
2126
|
multicall(iface, params) {
|
|
1344
2127
|
const calls = (params && params[0]) || [];
|
|
2128
|
+
const asFault = (error) => ({faultCode: error.faultCode, faultString: error.faultString});
|
|
1345
2129
|
const results = [];
|
|
1346
2130
|
for (const call of calls) {
|
|
1347
2131
|
try {
|
|
1348
|
-
|
|
2132
|
+
const result = this.callMethod(iface, call.methodName, call.params || []);
|
|
2133
|
+
results.push(isThenable(result) ? result.then((value) => [value], asFault) : [result]);
|
|
1349
2134
|
} catch (error) {
|
|
1350
|
-
results.push(
|
|
2135
|
+
results.push(asFault(error));
|
|
1351
2136
|
}
|
|
1352
2137
|
}
|
|
1353
|
-
return results;
|
|
2138
|
+
return results.some(isThenable) ? Promise.all(results) : results;
|
|
1354
2139
|
}
|
|
1355
2140
|
|
|
1356
2141
|
callMethod(iface, method, params) {
|
|
@@ -1369,32 +2154,158 @@ class HmSim {
|
|
|
1369
2154
|
* @param {Array} params
|
|
1370
2155
|
* @param {function} callback (fault, result)
|
|
1371
2156
|
*/
|
|
1372
|
-
dispatch(iface, method, params, callback) {
|
|
2157
|
+
dispatch(iface, method, params, callback, context = {}) {
|
|
1373
2158
|
this.log.debug('rpc', iface, '<', method, shortenParams(params));
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
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) => {
|
|
1378
2201
|
if (error.faultCode === undefined) {
|
|
1379
2202
|
this.log.error('rpc', iface, method, error.stack || error.message);
|
|
1380
2203
|
callback(this.fault('invalidArguments', error.message));
|
|
1381
2204
|
return;
|
|
1382
2205
|
}
|
|
1383
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);
|
|
1384
2217
|
return;
|
|
1385
2218
|
}
|
|
1386
2219
|
callback(null, result);
|
|
1387
2220
|
}
|
|
1388
2221
|
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
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);
|
|
2242
|
+
}
|
|
2243
|
+
this.injectedFaults.push(entry);
|
|
2244
|
+
return entry;
|
|
2245
|
+
}
|
|
2246
|
+
|
|
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}');
|
|
1395
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
|
+
}
|
|
1396
2292
|
|
|
1397
|
-
|
|
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);
|
|
1398
2309
|
|
|
1399
2310
|
if (id === '' || id === undefined) {
|
|
1400
2311
|
const client = this.clients[iface][clientId];
|
|
@@ -1404,21 +2315,12 @@ class HmSim {
|
|
|
1404
2315
|
delete this.clients[iface][clientId];
|
|
1405
2316
|
}
|
|
1406
2317
|
} else {
|
|
1407
|
-
const
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
this.log.debug('rpc >', url, methodName, shortenParams(methodParams));
|
|
1414
|
-
client.client.methodCall(methodName, methodParams, (error, result) => {
|
|
1415
|
-
this.log.debug('rpc <', url, shortenParams(result));
|
|
1416
|
-
if (typeof callback === 'function') {
|
|
1417
|
-
callback(error, result);
|
|
1418
|
-
}
|
|
1419
|
-
});
|
|
1420
|
-
},
|
|
1421
|
-
};
|
|
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
|
+
}
|
|
1422
2324
|
this.clients[iface][clientId] = client;
|
|
1423
2325
|
this.startInit(iface, client);
|
|
1424
2326
|
}
|
|
@@ -1426,14 +2328,134 @@ class HmSim {
|
|
|
1426
2328
|
return '';
|
|
1427
2329
|
}
|
|
1428
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
|
+
*/
|
|
1429
2448
|
ping(iface, params) {
|
|
1430
|
-
if (iface
|
|
1431
|
-
// hmipserver does not answer ping with a PONG event, see
|
|
1432
|
-
// https://github.com/eq-3/occu/issues/42
|
|
2449
|
+
if (!this.interfaceOption(iface, 'pong')) {
|
|
1433
2450
|
return '';
|
|
1434
2451
|
}
|
|
1435
|
-
|
|
1436
|
-
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
|
+
}
|
|
1437
2459
|
return '';
|
|
1438
2460
|
}
|
|
1439
2461
|
|
|
@@ -1447,7 +2469,7 @@ class HmSim {
|
|
|
1447
2469
|
event(iface, params) {
|
|
1448
2470
|
for (const key of Object.keys(this.clients[iface] || {})) {
|
|
1449
2471
|
const client = this.clients[iface][key];
|
|
1450
|
-
|
|
2472
|
+
this.deliver(iface, client, 'event', [client.id, ...params]);
|
|
1451
2473
|
}
|
|
1452
2474
|
}
|
|
1453
2475
|
|
|
@@ -1458,7 +2480,7 @@ class HmSim {
|
|
|
1458
2480
|
methodName: 'event',
|
|
1459
2481
|
params: [client.id, singleEvent[0], singleEvent[1], singleEvent[2]],
|
|
1460
2482
|
}));
|
|
1461
|
-
|
|
2483
|
+
this.deliver(iface, client, 'system.multicall', [multicall]);
|
|
1462
2484
|
}
|
|
1463
2485
|
}
|
|
1464
2486
|
|
|
@@ -1585,7 +2607,8 @@ class HmSim {
|
|
|
1585
2607
|
return createBinrpcServer({
|
|
1586
2608
|
host: listenAddress,
|
|
1587
2609
|
port,
|
|
1588
|
-
dispatch: (method, params, callback) =>
|
|
2610
|
+
dispatch: (method, params, callback, context) =>
|
|
2611
|
+
this.dispatch(iface, method, params, callback, context),
|
|
1589
2612
|
onListening,
|
|
1590
2613
|
onError,
|
|
1591
2614
|
});
|
|
@@ -1599,7 +2622,28 @@ class HmSim {
|
|
|
1599
2622
|
path,
|
|
1600
2623
|
tls: this.tls,
|
|
1601
2624
|
auth,
|
|
1602
|
-
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),
|
|
1603
2647
|
onListening,
|
|
1604
2648
|
onError,
|
|
1605
2649
|
});
|
|
@@ -1607,17 +2651,20 @@ class HmSim {
|
|
|
1607
2651
|
|
|
1608
2652
|
// the factory of an interface is kept so that dropConnection() can restart its server
|
|
1609
2653
|
const start = (iface, port, factory) => {
|
|
2654
|
+
if (this.interfaceOption(iface, 'startDelay') > 0) {
|
|
2655
|
+
this.delayedStarts[iface] = this.interfaceOption(iface, 'startDelay');
|
|
2656
|
+
}
|
|
1610
2657
|
this.serverFactories[iface] = factory;
|
|
1611
2658
|
this.servers[iface] = factory(port);
|
|
1612
2659
|
this.exposeServer(iface);
|
|
1613
2660
|
};
|
|
1614
2661
|
|
|
1615
2662
|
if (this.devices.rfd) {
|
|
1616
|
-
start('rfd', binrpcListenPort, (port) =>
|
|
2663
|
+
start('rfd', binrpcListenPort, (port) => bidcosServer('rfd', port, 'rfd'));
|
|
1617
2664
|
}
|
|
1618
2665
|
|
|
1619
2666
|
if (this.devices.wired) {
|
|
1620
|
-
start('wired', this.config.wiredListenPort, (port) =>
|
|
2667
|
+
start('wired', this.config.wiredListenPort, (port) => bidcosServer('wired', port, 'wired'));
|
|
1621
2668
|
}
|
|
1622
2669
|
|
|
1623
2670
|
if (this.devices.hmip) {
|
|
@@ -1660,6 +2707,15 @@ class HmSim {
|
|
|
1660
2707
|
const port = serverPort(server);
|
|
1661
2708
|
this.ports[iface] = port;
|
|
1662
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
|
+
}
|
|
1663
2719
|
resolve();
|
|
1664
2720
|
},
|
|
1665
2721
|
onError: (error) => {
|
|
@@ -1702,8 +2758,9 @@ class HmSim {
|
|
|
1702
2758
|
clearTimeout(handle);
|
|
1703
2759
|
}
|
|
1704
2760
|
this.timers.clear();
|
|
1705
|
-
for (const server of Object.
|
|
1706
|
-
|
|
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)) {
|
|
1707
2764
|
server.close();
|
|
1708
2765
|
}
|
|
1709
2766
|
}
|
|
@@ -1728,6 +2785,32 @@ function serverPort(server) {
|
|
|
1728
2785
|
return address ? address.port : undefined;
|
|
1729
2786
|
}
|
|
1730
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
|
+
|
|
1731
2814
|
/** Closes a binrpc or xmlrpc client so that it stops holding the event loop open. */
|
|
1732
2815
|
function closeClient(client) {
|
|
1733
2816
|
if (!client) {
|