hm-simulator 1.1.0 → 1.3.0

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