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/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
- hmip: {configPendingMode: 'hmip'},
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
- this.loadBehaviors(options.behaviorPath || path.join(__dirname, '..', 'behaviors'));
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
- return name === undefined ? undefined : this.paramsetDescriptions[name];
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
- if (configured) {
909
- return configured;
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
- await server.close();
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
- client.methodCall(method, [client.id, ...params]);
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) => this.putParamset(iface, params[0], params[1], params[2]),
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
- results.push([this.callMethod(iface, call.methodName, call.params || [])]);
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({faultCode: error.faultCode, faultString: error.faultString});
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
- let result;
1554
- try {
1555
- result = this.callMethod(iface, method, params);
1556
- } catch (error) {
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
- init(iface, params) {
1569
- const [url, id] = params;
1570
- let [protocol, host, port] = String(url).split(':');
1571
- host = host.replace(/^\/\//, '');
1572
- if (protocol === 'xmlrpc_bin') {
1573
- protocol = 'binrpc';
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
- const clientId = [host, port].join(':');
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 transport = protocol === 'binrpc' ? binrpc : xmlrpc;
1587
- const client = {
1588
- id,
1589
- url,
1590
- client: transport.createClient({host, port}),
1591
- methodCall: (methodName, methodParams, callback) => {
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 === 'hmip') {
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.event(iface, ['CENTRAL', 'PONG', params[0]]);
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
- client.methodCall('event', [client.id, ...params]);
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
- client.methodCall('system.multicall', [multicall]);
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) => this.dispatch(iface, 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) => this.dispatch(iface, 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) => binrpcServer('rfd', port, 'rfd binrpc'));
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) => binrpcServer('wired', port, 'wired binrpc'));
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.values(this.servers)) {
1885
- if (typeof server.close === 'function') {
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) {