hm-simulator 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/sim.js CHANGED
@@ -16,9 +16,11 @@ const {
16
16
  hmipStoredValue,
17
17
  bidcosStoredValue,
18
18
  } = require('./validate.js');
19
- const {createBinrpcServer, createXmlrpcServer} = require('./servers.js');
19
+ const {createBinrpcServer, createXmlrpcServer, createDualServer} = require('./servers.js');
20
20
  const {resolveTls} = require('./tls.js');
21
21
  const RegaSim = require('./rega.js');
22
+ const {ParamsetIndex} = require('./paramset-index.js');
23
+ const {SCENARIO_METHODS} = require('./control.js');
22
24
  const METHOD_HELP = require('./method-help.js');
23
25
 
24
26
  const NOOP_LOG = {
@@ -37,6 +39,18 @@ const PARAMSET_PREFIX = {
37
39
  cuxd: 'CUxD',
38
40
  };
39
41
 
42
+ /** how many calls to registered clients getCallbackLog() keeps */
43
+ const CALLBACK_LOG_SIZE = 10000;
44
+
45
+ /** the interfaces that speak the BidCos protocol, and therefore have setTempKey */
46
+ const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
47
+
48
+ /** device types of the HmIP radio modules, which carry DUTY_CYCLE_LEVEL and CARRIER_SENSE_LEVEL */
49
+ const RADIO_MODULE_TYPES = new Set(['RPI-RF-MOD', 'HmIP-RFUSB', 'HmIP-RFUSB-TK', 'HMIP-CCU3', 'HmIP-CCU3']);
50
+
51
+ /** the steps of a firmware update after updateFirmware/installFirmware, see offerFirmware() */
52
+ const FIRMWARE_UPDATE_STEPS = ['DO_UPDATE_PENDING', 'PERFORMING_UPDATE', 'UP_TO_DATE'];
53
+
40
54
  /** paramset keys that are not the address of a linked peer channel */
41
55
  const STANDARD_PARAMSETS = new Set(['MASTER', 'VALUES', 'SERVICE']);
42
56
 
@@ -60,12 +74,39 @@ const SERVICE_FLAG = 8;
60
74
  * 'pending' - the write is accepted, the valid parameters are stored, and CONFIG_PENDING is
61
75
  * raised on the device's :0 channel and stays until a valid full MASTER write or
62
76
  * clearConfigCache. The other hypothesis of 1.0; kept so both can still be tested.
77
+ * listenerModel
78
+ * 'isolated' - every registered client on its own (the default).
79
+ * 'measured' - what the interface processes did with a callback server that accepts the
80
+ * connection and never answers (measured 2026-09-25 on firmware 3.89.8), see init().
81
+ * pong, pingDelay
82
+ * whether `ping` sends the CENTRAL/PONG event (not on hmip), and after how many milliseconds.
83
+ * startDelay
84
+ * milliseconds after whenReady() until the port accepts connections: a process that starts late.
85
+ * deliveryTimeout
86
+ * 'measured' BidCos only: milliseconds after which an unanswered callback drops the client.
87
+ * unreachWrites
88
+ * what a setValue/putParamset to an unreachable device answers: 'accept' (the default) or
89
+ * 'fault' (notReachable). The simulator's model; not measured yet.
90
+ * firmwareUpdateDelay
91
+ * milliseconds per step of a firmware update, see offerFirmware().
92
+ * protocols
93
+ * rfd and wired only: what their port answers, ['binrpc', 'xmlrpc'] (both, as on a CCU) unless
94
+ * set; ['binrpc'] is the BIN-RPC-only server of 1.1.
63
95
  * configPendingOnWrite
64
96
  * raise CONFIG_PENDING for every accepted MASTER write, the way a BidCos battery device queues
65
97
  * the configuration until it wakes up. Clears after configPendingDelay milliseconds. Implied by
66
98
  * 'bidcos', which raises it for every write that changes something.
67
99
  */
68
100
  const INTERFACE_DEFAULTS = {
101
+ // interface process lifecycle, see restartInterface() and the listener models in init()
102
+ listenerModel: 'isolated',
103
+ pong: true,
104
+ pingDelay: 0,
105
+ startDelay: 0,
106
+ deliveryTimeout: 10000,
107
+ // device health, see setReachable() and offerFirmware()
108
+ unreachWrites: 'accept',
109
+ firmwareUpdateDelay: 0,
69
110
  configPendingMode: 'strict',
70
111
  configPendingOnWrite: false,
71
112
  configPendingDelay: 0,
@@ -79,7 +120,8 @@ const INTERFACE_DEFAULTS = {
79
120
  * the value from INTERFACE_DEFAULTS.
80
121
  */
81
122
  const MEASURED_INTERFACE_DEFAULTS = {
82
- 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},
83
125
  rfd: {configPendingMode: 'bidcos'},
84
126
  wired: {configPendingMode: 'bidcos'},
85
127
  };
@@ -146,9 +188,11 @@ function shortenParams(params) {
146
188
  * @param {object} [options.log] object with debug/info/warn/error methods
147
189
  * @param {object} [options.devices] {<iface>: {devices: []}} - device descriptions per interface
148
190
  * @param {object} [options.config] listenAddress, binrpcListenPort, xmlrpcListenPort
149
- * @param {string} [options.behaviorPath] directory with behaviour scripts
191
+ * @param {string|false} [options.behaviorPath] directory with behaviour scripts, `false` for none
150
192
  * @param {object} [options.rega] ReGa mock options, see lib/rega.js - omit to not start it
151
193
  * @param {object} [options.paramsetDescriptions] replaces the bundled descriptions
194
+ * @param {boolean} [options.paramsetFallback=true] a device whose firmware has no description uses
195
+ * the nearest firmware of the same type and VERSION; `false` for exact keys only
152
196
  * @param {object} [options.faults] overrides for the fault table, see lib/faults.js
153
197
  */
154
198
  class HmSim {
@@ -162,6 +206,10 @@ class HmSim {
162
206
 
163
207
  this.devices = options.devices || {rfd: {devices: []}, hmip: {devices: []}};
164
208
  this.paramsetDescriptions = options.paramsetDescriptions || require('../data/paramset-descriptions.json');
209
+ this.paramsetFallback = options.paramsetFallback !== false;
210
+ /** requested key -> the key used for it (a fallback) or null, see resolveParamsetKey() */
211
+ this.paramsetResolved = new Map();
212
+ this.paramsetIndex = new ParamsetIndex(this.paramsetDescriptions);
165
213
 
166
214
  const {table, fault} = createFaults(options.faults);
167
215
  this.faults = table;
@@ -181,18 +229,42 @@ class HmSim {
181
229
  this.serviceMessages = {};
182
230
  /** end of the install mode per interface, as an epoch timestamp */
183
231
  this.installMode = {};
232
+ /** the temporary AES key of the BidCos interfaces, see setTempKey */
233
+ this.tempKey = {};
184
234
  /** devices that appear when the install mode is switched on, see scriptNewDevices() */
185
235
  this.newDevicesScript = {};
186
236
  /** every reportValueUsage the simulator was told about */
187
237
  this.valueUsage = [];
188
238
  /** every updateFirmware/installFirmware call */
189
239
  this.firmwareUpdates = [];
240
+ /** every refreshDeployedDeviceFirmwareList call: [{iface, ts}] */
241
+ this.firmwareListRefreshes = [];
242
+ /** every changeKey call: [{iface, key, ts}] */
243
+ this.keyChanges = [];
244
+ /** the log level of each interface process, see logLevel() */
245
+ this.logLevels = {};
190
246
  /** pending timers, cleared by close() */
191
247
  this.timers = new Set();
192
248
  /** devices with a pending configuration: {<iface>: Map<deviceAddress, {sticky}>} */
193
249
  this.configPending = {};
194
250
  /** channels whose stored MASTER carries a parameter the device does not have */
195
251
  this.poisonedChannels = {};
252
+ /** faults waiting for the next matching calls, see injectFault() */
253
+ this.injectedFaults = [];
254
+ /** every call the simulator made to a registered client, see getCallbackLog() */
255
+ this.callbackLog = [];
256
+ /** per interface: a promise every incoming call waits for while the process is busy */
257
+ this.gates = {};
258
+ /** per interface: deliveries held back while a registration hangs, see init() */
259
+ this.held = {};
260
+ /** interfaces whose port is closed, see stopInterface() */
261
+ this.stopped = new Set();
262
+ /** interfaces that open their port only after `startDelay` */
263
+ this.delayedStarts = {};
264
+ /** devices that are unreachable: {<iface>: Set<deviceAddress>}, see setReachable() */
265
+ this.unreachable = {};
266
+ /** what listBidcosInterfaces reports on top of the configured answer: {<iface>: {DUTY_CYCLE, ...}} */
267
+ this.radio = {};
196
268
 
197
269
  this.rpcMethods = this.buildRpcMethods();
198
270
 
@@ -226,7 +298,10 @@ class HmSim {
226
298
  this.setValue(iface, address, datapoint, value, {internal: true}),
227
299
  );
228
300
 
229
- 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
+ }
230
305
 
231
306
  if (options.rega) {
232
307
  // the ReGa mock inherits TLS and basic auth unless its own options say otherwise
@@ -294,10 +369,7 @@ class HmSim {
294
369
  if (this.values[iface][device.ADDRESS].VALUES[name] !== undefined) {
295
370
  continue;
296
371
  }
297
- this.values[iface][device.ADDRESS].VALUES[name] =
298
- parameter.TYPE === 'ENUM' && Array.isArray(parameter.VALUE_LIST)
299
- ? parameter.VALUE_LIST.indexOf(parameter.DEFAULT)
300
- : parameter.DEFAULT;
372
+ this.values[iface][device.ADDRESS].VALUES[name] = defaultValue(parameter);
301
373
  }
302
374
  }
303
375
  }
@@ -340,7 +412,76 @@ class HmSim {
340
412
  }
341
413
 
342
414
  const name = this.paramsetName(iface, device, paramset);
343
- 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()];
344
485
  }
345
486
 
346
487
  /* ------------------------------------------------------------------ paramsets */
@@ -900,10 +1041,9 @@ class HmSim {
900
1041
 
901
1042
  listBidcosInterfaces(iface) {
902
1043
  const configured = this.options.bidcosInterfaces && this.options.bidcosInterfaces[iface];
903
- if (configured) {
904
- return configured;
905
- }
906
- return DEFAULT_BIDCOS_INTERFACES[iface] || DEFAULT_BIDCOS_INTERFACES.rfd;
1044
+ const list = configured || DEFAULT_BIDCOS_INTERFACES[iface] || DEFAULT_BIDCOS_INTERFACES.rfd;
1045
+ const radio = this.radio[iface];
1046
+ return radio ? list.map((entry) => ({...entry, ...radio})) : list;
907
1047
  }
908
1048
 
909
1049
  setBidcosInterface(iface, params) {
@@ -978,6 +1118,233 @@ class HmSim {
978
1118
  return '';
979
1119
  }
980
1120
 
1121
+ /**
1122
+ * `setTempKey(passphrase)` - the temporary key a BidCos device is taught in with.
1123
+ *
1124
+ * A HomeMatic device can be paired with a passphrase instead of the interface's own key, and a
1125
+ * device that was taught in that way can only be paired again when the same passphrase is
1126
+ * offered. rfd takes it as one string argument and answers with the empty string; it applies to
1127
+ * the pairings that follow, so an application sends it *before* `setInstallMode` or
1128
+ * `addDevice`. An empty string clears it back to the interface's key.
1129
+ *
1130
+ * Only the BidCos processes have the method - hmipserver answers an unknown-method fault, which
1131
+ * is what an application has to handle when a user types it into the wrong interface.
1132
+ *
1133
+ * The simulator does no cryptography: it remembers the key so a test can assert that the call
1134
+ * happened, in which order, and with what. `sim.getTempKey(iface)` reads it back.
1135
+ *
1136
+ * @param {string} iface
1137
+ * @param {Array} params [passphrase]
1138
+ */
1139
+ setTempKey(iface, params) {
1140
+ if (!BIDCOS_INTERFACES.has(iface)) {
1141
+ throw this.fault('unknownMethod', `${iface}/setTempKey`);
1142
+ }
1143
+ const [key] = params || [];
1144
+ if (key !== undefined && typeof key !== 'string') {
1145
+ throw this.fault('invalidArguments', 'setTempKey wants a string');
1146
+ }
1147
+ this.tempKey[iface] = key === undefined ? '' : key;
1148
+ this.log.info(iface, this.tempKey[iface] === '' ? 'temporary key cleared' : 'temporary key set');
1149
+ return '';
1150
+ }
1151
+
1152
+ /**
1153
+ * `logLevel(level)` - rfd and hs485d set their log level and answer it; without a level they
1154
+ * answer the one in force (5 on the rfd measured 2026-09-28). hmipserver lists the
1155
+ * method but answers it with an empty HTTP body; the simulator answers an empty string there.
1156
+ * Elsewhere unknown-method.
1157
+ *
1158
+ * @param {string} iface
1159
+ * @param {Array} params [level]
1160
+ */
1161
+ logLevel(iface, params) {
1162
+ if (iface === 'hmip') {
1163
+ return '';
1164
+ }
1165
+ if (!BIDCOS_INTERFACES.has(iface)) {
1166
+ throw this.fault('unknownMethod', `${iface}/logLevel`);
1167
+ }
1168
+ const [level] = params || [];
1169
+ if (level !== undefined) {
1170
+ if (!Number.isInteger(level)) {
1171
+ throw this.fault('invalidArguments', 'logLevel wants an integer');
1172
+ }
1173
+ this.logLevels[iface] = level;
1174
+ }
1175
+ return this.logLevels[iface] === undefined ? 5 : this.logLevels[iface];
1176
+ }
1177
+
1178
+ /**
1179
+ * `changeKey(passphrase)` - the interface's security key. rfd stores it and re-keys every
1180
+ * AES-capable device; hmipserver lists the method too. The simulator does no cryptography: the
1181
+ * call is recorded in `sim.keyChanges` (`{iface, key, ts}`) and answered with an empty string.
1182
+ * BidCos-Wired has no such method (unknown-method).
1183
+ *
1184
+ * @param {string} iface
1185
+ * @param {Array} params [passphrase]
1186
+ */
1187
+ changeKey(iface, params) {
1188
+ if (iface !== 'rfd' && iface !== 'hmip') {
1189
+ throw this.fault('unknownMethod', `${iface}/changeKey`);
1190
+ }
1191
+ const [key] = params || [];
1192
+ if (typeof key !== 'string') {
1193
+ throw this.fault('invalidArguments', 'changeKey wants a string');
1194
+ }
1195
+ this.keyChanges.push({iface, key, ts: Date.now()});
1196
+ this.log.info(iface, 'security key changed');
1197
+ return '';
1198
+ }
1199
+
1200
+ /**
1201
+ * `refreshDeployedDeviceFirmwareList()` - rfd and hmipserver read the directory of deployed
1202
+ * device firmware again and answer an empty value (measured 2026-09-28); hs485d and
1203
+ * VirtualDevices have no such method. Recorded in `sim.firmwareListRefreshes`.
1204
+ */
1205
+ refreshDeployedDeviceFirmwareList(iface) {
1206
+ if (iface !== 'rfd' && iface !== 'hmip') {
1207
+ throw this.fault('unknownMethod', `${iface}/refreshDeployedDeviceFirmwareList`);
1208
+ }
1209
+ this.firmwareListRefreshes.push({iface, ts: Date.now()});
1210
+ return '';
1211
+ }
1212
+
1213
+ /* ------------------------------------------------------------------ teams */
1214
+
1215
+ /**
1216
+ * `listTeams()` - the team pseudo devices of a BidCos interface, with their channels.
1217
+ *
1218
+ * rfd keeps smoke detectors (and the other devices with a `TEAM_TAG`) in *teams*. A team is a
1219
+ * pseudo device whose address is `*` plus the serial of the device it was made from
1220
+ * (`*NEQ0448334`, type `HM-Sec-SD-2-Team`); its team channel carries the members in
1221
+ * `TEAM_CHANNELS`, and every member channel names the team channel in `TEAM`. The team devices
1222
+ * come with `listDevices` as well; `listTeams` is only them. hmipserver has no teams.
1223
+ *
1224
+ * @param {string} iface
1225
+ */
1226
+ listTeams(iface) {
1227
+ if (!BIDCOS_INTERFACES.has(iface)) {
1228
+ throw this.fault('unknownMethod', `${iface}/listTeams`);
1229
+ }
1230
+ return this.devices[iface].devices.filter((device) => device.ADDRESS.startsWith('*'));
1231
+ }
1232
+
1233
+ /**
1234
+ * `setTeam(channelAddress, teamAddress)` - puts a channel into a team.
1235
+ *
1236
+ * The shape is rfd's, as Homematic Manager task 58 read it from a CCU3: every detector starts in
1237
+ * a team of its own (`*<its serial>`); joining another team takes it out of the old one. That a
1238
+ * team nobody is left in is deleted (`deleteDevices`) is the simulator's model, not a measurement. An empty
1239
+ * `teamAddress` puts the channel back into a team of its own, which is created again
1240
+ * (`newDevices`) with the type of the team it leaves. Channel and team must carry the same
1241
+ * `TEAM_TAG`; there is no call that creates a team. The changed channels are announced with
1242
+ * `updateDevice`. BidCos only.
1243
+ *
1244
+ * @param {string} iface
1245
+ * @param {Array} params [channelAddress, teamAddress]
1246
+ */
1247
+ setTeam(iface, params) {
1248
+ if (!BIDCOS_INTERFACES.has(iface)) {
1249
+ throw this.fault('unknownMethod', `${iface}/setTeam`);
1250
+ }
1251
+ const [channelAddress, teamAddress = ''] = params || [];
1252
+ if (typeof channelAddress !== 'string' || typeof teamAddress !== 'string') {
1253
+ throw this.fault('invalidArguments', 'setTeam wants two strings');
1254
+ }
1255
+ const channel = this.requireDevice(iface, channelAddress);
1256
+ if (!channel.PARENT || !channel.TEAM_TAG || Array.isArray(channel.TEAM_CHANNELS)) {
1257
+ throw this.fault('invalidArguments', `${channelAddress} cannot be in a team`);
1258
+ }
1259
+ const oldTeam = channel.TEAM ? this.index[iface].get(channel.TEAM) : undefined;
1260
+ const ownDevice = `*${channel.PARENT}`;
1261
+ const ownTeam = `${ownDevice}:1`;
1262
+ const target = teamAddress === '' ? ownTeam : teamAddress;
1263
+ if (channel.TEAM === target) {
1264
+ return '';
1265
+ }
1266
+
1267
+ let team = this.index[iface].get(target);
1268
+ if (team === undefined && target === ownTeam) {
1269
+ team = this.createTeam(iface, channel, oldTeam);
1270
+ }
1271
+ if (team === undefined) {
1272
+ throw this.fault('unknownInstance', `${iface}/${target}`);
1273
+ }
1274
+ if (!Array.isArray(team.TEAM_CHANNELS) || team.TEAM_TAG !== channel.TEAM_TAG) {
1275
+ throw this.fault('invalidArguments', `${target} is no team for ${channelAddress}`);
1276
+ }
1277
+
1278
+ const changed = [channelAddress, target];
1279
+ if (oldTeam) {
1280
+ oldTeam.TEAM_CHANNELS = (oldTeam.TEAM_CHANNELS || []).filter((member) => member !== channelAddress);
1281
+ changed.push(oldTeam.ADDRESS);
1282
+ }
1283
+ team.TEAM_CHANNELS = [...team.TEAM_CHANNELS.filter((member) => member !== channelAddress), channelAddress];
1284
+ channel.TEAM = target;
1285
+ this.log.info(iface, 'team', channelAddress, '->', target);
1286
+
1287
+ for (const address of changed) {
1288
+ if (this.index[iface].has(address) && !(oldTeam && address === oldTeam.ADDRESS)) {
1289
+ this.tellClientsParams(iface, 'updateDevice', [address, 0]);
1290
+ }
1291
+ }
1292
+ // a team nobody is in any more is gone
1293
+ if (oldTeam && oldTeam.TEAM_CHANNELS.length === 0 && oldTeam.PARENT) {
1294
+ this.deleteDevice(iface, oldTeam.PARENT);
1295
+ } else if (oldTeam) {
1296
+ this.tellClientsParams(iface, 'updateDevice', [oldTeam.ADDRESS, 0]);
1297
+ }
1298
+ return '';
1299
+ }
1300
+
1301
+ /**
1302
+ * A team of a channel's own, `*<serial>`, shaped like the team it leaves (or, without one, as
1303
+ * `<PARENT_TYPE>-Team` with a `<TYPE>_TEAM` channel), and announced with `newDevices`.
1304
+ */
1305
+ createTeam(iface, channel, like) {
1306
+ const parent = this.index[iface].get(channel.PARENT) || {};
1307
+ const likeDevice = like && like.PARENT ? this.index[iface].get(like.PARENT) : undefined;
1308
+ const address = `*${channel.PARENT}`;
1309
+ const type = likeDevice ? likeDevice.TYPE : `${channel.PARENT_TYPE}-Team`;
1310
+ const device = {
1311
+ ADDRESS: address,
1312
+ TYPE: type,
1313
+ VERSION: likeDevice ? likeDevice.VERSION : parent.VERSION || 1,
1314
+ FIRMWARE: likeDevice ? likeDevice.FIRMWARE : parent.FIRMWARE || '1.0',
1315
+ CHILDREN: [`${address}:0`, `${address}:1`],
1316
+ PARAMSETS: ['MASTER'],
1317
+ FLAGS: 9,
1318
+ };
1319
+ const maintenance = {
1320
+ ADDRESS: `${address}:0`,
1321
+ TYPE: 'MAINTENANCE',
1322
+ VERSION: device.VERSION,
1323
+ PARENT: address,
1324
+ PARENT_TYPE: type,
1325
+ PARAMSETS: ['MASTER', 'VALUES'],
1326
+ INDEX: 0,
1327
+ };
1328
+ const teamChannel = {
1329
+ ADDRESS: `${address}:1`,
1330
+ TYPE: like ? like.TYPE : `${channel.TYPE}_TEAM`,
1331
+ VERSION: device.VERSION,
1332
+ PARENT: address,
1333
+ PARENT_TYPE: type,
1334
+ PARAMSETS: ['MASTER', 'VALUES'],
1335
+ INDEX: 1,
1336
+ TEAM_TAG: channel.TEAM_TAG,
1337
+ TEAM_CHANNELS: [],
1338
+ };
1339
+ this.addDevices(iface, [device, maintenance, teamChannel]);
1340
+ return this.index[iface].get(teamChannel.ADDRESS);
1341
+ }
1342
+
1343
+ /** The temporary key an interface was last given, `''` when there is none. */
1344
+ getTempKey(iface) {
1345
+ return this.tempKey[iface] || '';
1346
+ }
1347
+
981
1348
  /** Remaining seconds of the install mode, 0 when it is off. */
982
1349
  getInstallMode(iface) {
983
1350
  const until = this.installMode[iface] || 0;
@@ -1066,6 +1433,9 @@ class HmSim {
1066
1433
  this.requireDevice(iface, address);
1067
1434
  }
1068
1435
  this.firmwareUpdates.push({iface, addresses, method: 'updateFirmware', ts: Date.now()});
1436
+ for (const address of addresses) {
1437
+ this.performFirmwareUpdate(iface, address);
1438
+ }
1069
1439
  return true;
1070
1440
  }
1071
1441
 
@@ -1073,6 +1443,7 @@ class HmSim {
1073
1443
  const address = params[0];
1074
1444
  this.requireDevice(iface, address);
1075
1445
  this.firmwareUpdates.push({iface, addresses: [address], method: 'installFirmware', ts: Date.now()});
1446
+ this.performFirmwareUpdate(iface, address);
1076
1447
  return true;
1077
1448
  }
1078
1449
 
@@ -1151,6 +1522,212 @@ class HmSim {
1151
1522
  return added;
1152
1523
  }
1153
1524
 
1525
+ /* ------------------------------------------------------------------ device and radio health */
1526
+
1527
+ /** The device of an address, its maintenance channel `:0` and that channel's VALUES description. */
1528
+ maintenanceOf(iface, address) {
1529
+ const entry = this.requireDevice(iface, address);
1530
+ const device = entry.PARENT ? this.requireDevice(iface, entry.PARENT) : entry;
1531
+ const channel = `${device.ADDRESS}:0`;
1532
+ const description =
1533
+ (this.index[iface].has(channel) && this.getParamsetDescription(iface, channel, 'VALUES')) || {};
1534
+ return {device, channel, description};
1535
+ }
1536
+
1537
+ /**
1538
+ * Reports a datapoint of the maintenance channel: stored and sent the way the description says
1539
+ * (a service message by its FLAGS), or - when the description does not have it - as an event and
1540
+ * a service message raised through setServiceMessage().
1541
+ */
1542
+ reportMaintenance(iface, channel, description, datapoint, value) {
1543
+ if (description[datapoint]) {
1544
+ this.setValue(iface, channel, datapoint, value, {internal: true});
1545
+ return;
1546
+ }
1547
+ if (typeof value === 'boolean') {
1548
+ this.setServiceMessage(iface, channel, datapoint, value);
1549
+ }
1550
+ this.fireEvent(iface, channel, datapoint, value);
1551
+ }
1552
+
1553
+ /**
1554
+ * A device goes out of reach or comes back: `UNREACH` on its `:0` channel, and on the BidCos
1555
+ * interfaces `STICKY_UNREACH`, which stays after the device is back until a client writes it
1556
+ * `false` - as rfd does. What a write to the device answers meanwhile is the `unreachWrites`
1557
+ * option ('accept', or 'fault' with notReachable).
1558
+ *
1559
+ * @param {string} iface
1560
+ * @param {string} address the device or one of its channels
1561
+ * @param {boolean} reachable
1562
+ */
1563
+ setReachable(iface, address, reachable) {
1564
+ const {device, channel, description} = this.maintenanceOf(iface, address);
1565
+ const unreachable = (this.unreachable[iface] = this.unreachable[iface] || new Set());
1566
+ if (reachable) {
1567
+ unreachable.delete(device.ADDRESS);
1568
+ } else {
1569
+ unreachable.add(device.ADDRESS);
1570
+ }
1571
+ this.reportMaintenance(iface, channel, description, 'UNREACH', !reachable);
1572
+ const sticky = description.STICKY_UNREACH || (BIDCOS_INTERFACES.has(iface) && !description.UNREACH);
1573
+ if (!reachable && sticky) {
1574
+ this.reportMaintenance(iface, channel, description, 'STICKY_UNREACH', true);
1575
+ }
1576
+ this.log.info(iface, device.ADDRESS, reachable ? 'reachable again' : 'unreachable');
1577
+ }
1578
+
1579
+ /** Throws notReachable for a write to an unreachable device when `unreachWrites` is 'fault'. */
1580
+ requireReachable(iface, address) {
1581
+ if (this.interfaceOption(iface, 'unreachWrites') !== 'fault' || !this.unreachable[iface]) {
1582
+ return;
1583
+ }
1584
+ const entry = this.index[iface] && this.index[iface].get(address);
1585
+ const deviceAddress = entry ? entry.PARENT || entry.ADDRESS : address;
1586
+ if (this.unreachable[iface].has(deviceAddress)) {
1587
+ throw this.fault('notReachable', address);
1588
+ }
1589
+ }
1590
+
1591
+ /**
1592
+ * Low battery: `LOW_BAT` or `LOWBAT` on the device's `:0` channel, whichever its description has
1593
+ * (`LOW_BAT` on hmip, `LOWBAT` on BidCos without one).
1594
+ */
1595
+ setLowBattery(iface, address, low) {
1596
+ const {channel, description} = this.maintenanceOf(iface, address);
1597
+ let datapoint = iface === 'hmip' ? 'LOW_BAT' : 'LOWBAT';
1598
+ if (description.LOW_BAT) {
1599
+ datapoint = 'LOW_BAT';
1600
+ } else if (description.LOWBAT) {
1601
+ datapoint = 'LOWBAT';
1602
+ }
1603
+ this.reportMaintenance(iface, channel, description, datapoint, Boolean(low));
1604
+ }
1605
+
1606
+ /**
1607
+ * The radio module of an interface, if the device list has it: the device whose address is the
1608
+ * last 14 characters of the interface's address (an SGTIN), or one of a radio module's types.
1609
+ */
1610
+ radioModule(iface) {
1611
+ const address = String((this.listBidcosInterfaces(iface)[0] || {}).ADDRESS || '');
1612
+ const devices = this.deviceList(iface);
1613
+ return (
1614
+ devices.find((device) => device.ADDRESS === address.slice(-14) || device.ADDRESS === address) ||
1615
+ devices.find((device) => RADIO_MODULE_TYPES.has(device.TYPE))
1616
+ );
1617
+ }
1618
+
1619
+ /**
1620
+ * Sets a radio level of an interface: in what `listBidcosInterfaces` answers, and on hmip as
1621
+ * the event of the radio module's `:0` channel, when the device list has a radio module.
1622
+ */
1623
+ setRadioLevel(iface, field, datapoint, percent) {
1624
+ if (typeof percent !== 'number' || Number.isNaN(percent)) {
1625
+ throw new TypeError(`${field} wants a number`);
1626
+ }
1627
+ this.radio[iface] = {...this.radio[iface], [field]: percent};
1628
+ const module = iface === 'hmip' ? this.radioModule(iface) : undefined;
1629
+ if (module) {
1630
+ const {channel, description} = this.maintenanceOf(iface, module.ADDRESS);
1631
+ this.reportMaintenance(iface, channel, description, datapoint, percent);
1632
+ }
1633
+ }
1634
+
1635
+ /** The duty cycle of an interface in percent: `DUTY_CYCLE`, on hmip also `DUTY_CYCLE_LEVEL`. */
1636
+ setDutyCycle(iface, percent) {
1637
+ this.setRadioLevel(iface, 'DUTY_CYCLE', 'DUTY_CYCLE_LEVEL', percent);
1638
+ }
1639
+
1640
+ /** The carrier sense level of an interface in percent: `CARRIER_SENSE_LEVEL`. */
1641
+ setCarrierSense(iface, percent) {
1642
+ this.setRadioLevel(iface, 'CARRIER_SENSE_LEVEL', 'CARRIER_SENSE_LEVEL', percent);
1643
+ }
1644
+
1645
+ /**
1646
+ * A firmware update becomes available for a device: `AVAILABLE_FIRMWARE`,
1647
+ * `FIRMWARE_UPDATE_STATE: 'NEW_FIRMWARE_AVAILABLE'` and `UPDATABLE` in its description,
1648
+ * `UPDATE_PENDING` on `:0` where the description has it, and `updateDevice` to the clients.
1649
+ * A following `updateFirmware`/`installFirmware` walks `FIRMWARE_UPDATE_STATE` through
1650
+ * DO_UPDATE_PENDING, PERFORMING_UPDATE and UP_TO_DATE, one step per `firmwareUpdateDelay`, and
1651
+ * sets `FIRMWARE` at the end. The state names are the RPC specification's; the timing is a model.
1652
+ *
1653
+ * @param {string} iface
1654
+ * @param {string} address the device
1655
+ * @param {string} version
1656
+ */
1657
+ offerFirmware(iface, address, version) {
1658
+ const device = this.requireDevice(iface, address);
1659
+ if (device.PARENT) {
1660
+ throw this.fault('notSupported', 'firmware is offered to a device, not a channel');
1661
+ }
1662
+ device.AVAILABLE_FIRMWARE = String(version);
1663
+ device.FIRMWARE_UPDATE_STATE = 'NEW_FIRMWARE_AVAILABLE';
1664
+ device.UPDATABLE = device.UPDATABLE === undefined ? 1 : device.UPDATABLE;
1665
+ const {channel, description} = this.maintenanceOf(iface, address);
1666
+ if (description.UPDATE_PENDING) {
1667
+ this.setValue(iface, channel, 'UPDATE_PENDING', true, {internal: true});
1668
+ }
1669
+ this.tellClientsParams(iface, 'updateDevice', [address, 0]);
1670
+ }
1671
+
1672
+ performFirmwareUpdate(iface, address) {
1673
+ const device = this.index[iface].get(address);
1674
+ if (!device || device.PARENT || device.FIRMWARE_UPDATE_STATE !== 'NEW_FIRMWARE_AVAILABLE') {
1675
+ return;
1676
+ }
1677
+ const delay = this.interfaceOption(iface, 'firmwareUpdateDelay');
1678
+ const step = (index) => {
1679
+ // the device can be gone by now
1680
+ if (this.index[iface].get(address) !== device) {
1681
+ return;
1682
+ }
1683
+ device.FIRMWARE_UPDATE_STATE = FIRMWARE_UPDATE_STEPS[index];
1684
+ if (index === FIRMWARE_UPDATE_STEPS.length - 1) {
1685
+ device.FIRMWARE = device.AVAILABLE_FIRMWARE;
1686
+ const {channel, description} = this.maintenanceOf(iface, address);
1687
+ if (description.UPDATE_PENDING) {
1688
+ this.setValue(iface, channel, 'UPDATE_PENDING', false, {internal: true});
1689
+ }
1690
+ this.log.info(iface, address, 'firmware', device.FIRMWARE);
1691
+ } else {
1692
+ this.timer(() => step(index + 1), delay);
1693
+ }
1694
+ this.tellClientsParams(iface, 'updateDevice', [address, 0]);
1695
+ };
1696
+ this.timer(() => step(0), delay);
1697
+ }
1698
+
1699
+ /**
1700
+ * Runs scenario calls on the simulator's timer: `[{at: ms, call: 'setReachable', args: [...]}]`,
1701
+ * `at` counted from now. Resolves with the results once every step ran; `close()` cancels what
1702
+ * did not run yet (and the promise then never resolves).
1703
+ *
1704
+ * @param {Array<{at: number, call: string, args: Array}>} steps
1705
+ * @returns {Promise<Array>}
1706
+ */
1707
+ schedule(steps) {
1708
+ const list = steps || [];
1709
+ for (const step of list) {
1710
+ if (!SCENARIO_METHODS.has(step.call) || step.call === 'schedule' || typeof this[step.call] !== 'function') {
1711
+ throw new Error(`schedule: ${step.call} is no scenario call`);
1712
+ }
1713
+ }
1714
+ return Promise.all(
1715
+ list.map(
1716
+ (step) =>
1717
+ new Promise((resolve) => {
1718
+ this.timer(async () => {
1719
+ try {
1720
+ resolve(await this[step.call](...(step.args || [])));
1721
+ } catch (error) {
1722
+ this.log.error('schedule', step.call, error.message);
1723
+ resolve(error);
1724
+ }
1725
+ }, step.at || 0);
1726
+ }),
1727
+ ),
1728
+ );
1729
+ }
1730
+
1154
1731
  /* ------------------------------------------------------------------ scenario api */
1155
1732
 
1156
1733
  /**
@@ -1214,7 +1791,10 @@ class HmSim {
1214
1791
 
1215
1792
  const port = this.ports[iface];
1216
1793
  this.log.info(iface, 'dropping all connections');
1217
- await server.close();
1794
+ if (!this.stopped.has(iface)) {
1795
+ await server.close();
1796
+ }
1797
+ this.stopped.delete(iface);
1218
1798
 
1219
1799
  this.ready = [];
1220
1800
  this.servers[iface] = factory(port);
@@ -1222,6 +1802,109 @@ class HmSim {
1222
1802
  await this.whenReady();
1223
1803
  }
1224
1804
 
1805
+ /**
1806
+ * Stops an interface process: its port refuses connections, open connections are reset. The
1807
+ * registered clients stay known until startInterface() decides what the process remembers.
1808
+ *
1809
+ * @param {string} iface
1810
+ * @returns {Promise<void>} once the port is closed
1811
+ */
1812
+ async stopInterface(iface) {
1813
+ const server = this.servers[iface];
1814
+ if (!server || this.stopped.has(iface)) {
1815
+ return;
1816
+ }
1817
+ this.stopped.add(iface);
1818
+ this.log.info(iface, 'interface process stopped');
1819
+ await server.close();
1820
+ }
1821
+
1822
+ /**
1823
+ * Starts a stopped interface process again on the same port.
1824
+ *
1825
+ * With `forgetClients` (the default) the registered clients are forgotten, as by a process
1826
+ * that keeps no list of them: no event reaches a client until it calls `init` again, and
1827
+ * nothing tells it. Without, the process remembers them and calls each back the way rfd does
1828
+ * when it starts with its handlers file: `system.listMethods`, then `listDevices`, and
1829
+ * `newDevices`/`deleteDevices` as at an init - a client sees calls it did not ask for, which is
1830
+ * how it can tell that the process restarted.
1831
+ *
1832
+ * @param {string} iface
1833
+ * @param {object} [options]
1834
+ * @param {boolean} [options.forgetClients=true]
1835
+ * @returns {Promise<void>} once the port accepts connections again
1836
+ */
1837
+ async startInterface(iface, {forgetClients = true} = {}) {
1838
+ const factory = this.serverFactories[iface];
1839
+ if (!factory || !this.stopped.has(iface)) {
1840
+ return;
1841
+ }
1842
+ if (forgetClients) {
1843
+ for (const key of Object.keys(this.clients[iface] || {})) {
1844
+ this.forgetClient(iface, key);
1845
+ }
1846
+ }
1847
+
1848
+ const before = this.ready.length;
1849
+ this.servers[iface] = factory(this.ports[iface]);
1850
+ this.exposeServer(iface);
1851
+ const ready = this.ready.slice(before);
1852
+ this.ready.splice(before);
1853
+ await Promise.all(ready);
1854
+ this.stopped.delete(iface);
1855
+ this.log.info(iface, 'interface process started');
1856
+
1857
+ for (const client of Object.values(this.clients[iface] || {})) {
1858
+ const listDevices = () => this.startInit(iface, client);
1859
+ if (BIDCOS_INTERFACES.has(iface)) {
1860
+ client.methodCall('system.listMethods', [client.id], listDevices);
1861
+ } else {
1862
+ listDevices();
1863
+ }
1864
+ }
1865
+ }
1866
+
1867
+ /**
1868
+ * An interface process restarts: stopInterface(), `downMs` with the port closed,
1869
+ * startInterface(). See startInterface() for `forgetClients`.
1870
+ *
1871
+ * @param {string} iface
1872
+ * @param {object} [options]
1873
+ * @param {number} [options.downMs=0]
1874
+ * @param {boolean} [options.forgetClients=true]
1875
+ * @returns {Promise<void>} once the port accepts connections again
1876
+ */
1877
+ async restartInterface(iface, {downMs = 0, forgetClients = true} = {}) {
1878
+ await this.stopInterface(iface);
1879
+ if (downMs > 0) {
1880
+ await new Promise((resolve) => this.timer(resolve, downMs));
1881
+ }
1882
+ await this.startInterface(iface, {forgetClients});
1883
+ }
1884
+
1885
+ /**
1886
+ * Sends a burst of events the way the interface processes do, as `system.multicall` batches.
1887
+ *
1888
+ * @param {string} iface
1889
+ * @param {Array<Array>} events `[[address, datapoint, value], ...]`, not validated
1890
+ * @param {object} [options]
1891
+ * @param {number} [options.batch] events per multicall, all in one when omitted
1892
+ */
1893
+ fireEvents(iface, events, {batch} = {}) {
1894
+ const list = events || [];
1895
+ for (const [address, datapoint, value] of list) {
1896
+ const state = this.values[iface] && this.values[iface][address];
1897
+ if (state) {
1898
+ state.VALUES[datapoint] = value;
1899
+ }
1900
+ }
1901
+ const size = batch > 0 ? batch : list.length;
1902
+ for (let index = 0; index < list.length; index += size) {
1903
+ this.eventMulticall(iface, list.slice(index, index + size));
1904
+ }
1905
+ return list.length;
1906
+ }
1907
+
1225
1908
  /**
1226
1909
  * Raises or clears a service message. If the channel has the datapoint in its VALUES
1227
1910
  * description the value is written as well, so that the logic layer sees the event too.
@@ -1261,10 +1944,93 @@ class HmSim {
1261
1944
 
1262
1945
  /** Calls one method on every connected logic layer. */
1263
1946
  tellClients(iface, method, payload) {
1947
+ this.tellClientsParams(iface, method, [payload]);
1948
+ }
1949
+
1950
+ /** The same with several parameters after the interface id (`updateDevice(id, address, hint)`). */
1951
+ tellClientsParams(iface, method, params) {
1264
1952
  for (const key of Object.keys(this.clients[iface] || {})) {
1265
1953
  const client = this.clients[iface][key];
1266
- client.methodCall(method, [client.id, payload]);
1954
+ this.deliver(iface, client, method, [client.id, ...params]);
1955
+ }
1956
+ }
1957
+
1958
+ /**
1959
+ * One call to one registered client: held back while a registration of the interface hangs
1960
+ * (the 'measured' hmip model), and under the 'measured' BidCos model the client is dropped when
1961
+ * it does not answer within `deliveryTimeout`.
1962
+ */
1963
+ deliver(iface, client, method, params) {
1964
+ const held = this.held[iface];
1965
+ if (held && held.count > 0) {
1966
+ held.queue.push(() => this.deliver(iface, client, method, params));
1967
+ return;
1968
+ }
1969
+ const measuredBidcos =
1970
+ BIDCOS_INTERFACES.has(iface) && this.interfaceOption(iface, 'listenerModel') === 'measured';
1971
+ if (!measuredBidcos) {
1972
+ client.methodCall(method, params);
1973
+ return;
1267
1974
  }
1975
+ client.methodCall(
1976
+ method,
1977
+ params,
1978
+ (error) => {
1979
+ if (error && this.clients[iface][client.key] === client) {
1980
+ this.log.warn(iface, 'client', client.url, 'did not take', method, '- dropped:', error.message);
1981
+ this.forgetClient(iface, client.key);
1982
+ }
1983
+ },
1984
+ {timeout: this.interfaceOption(iface, 'deliveryTimeout')},
1985
+ );
1986
+ }
1987
+
1988
+ forgetClient(iface, key) {
1989
+ const client = this.clients[iface] && this.clients[iface][key];
1990
+ if (client) {
1991
+ closeClient(client.client);
1992
+ delete this.clients[iface][key];
1993
+ }
1994
+ }
1995
+
1996
+ /** Holds every delivery of an interface back until the returned function is called. */
1997
+ hold(iface) {
1998
+ const held = (this.held[iface] = this.held[iface] || {count: 0, queue: []});
1999
+ held.count++;
2000
+ let released = false;
2001
+ return () => {
2002
+ if (released) {
2003
+ return;
2004
+ }
2005
+ released = true;
2006
+ held.count--;
2007
+ if (held.count === 0) {
2008
+ const queue = held.queue.splice(0);
2009
+ for (const send of queue) {
2010
+ send();
2011
+ }
2012
+ }
2013
+ };
2014
+ }
2015
+
2016
+ /**
2017
+ * Makes every incoming call of an interface wait until the returned function is called - the
2018
+ * interface process is busy with something that does not return.
2019
+ */
2020
+ closeGate(iface) {
2021
+ let release;
2022
+ const gate = new Promise((resolve) => {
2023
+ release = resolve;
2024
+ });
2025
+ const previous = this.gates[iface];
2026
+ const combined = previous ? Promise.all([previous, gate]) : gate;
2027
+ this.gates[iface] = combined;
2028
+ return () => {
2029
+ release();
2030
+ if (this.gates[iface] === combined) {
2031
+ delete this.gates[iface];
2032
+ }
2033
+ };
1268
2034
  }
1269
2035
 
1270
2036
  /** A timer that never keeps the process alive and is cleared by close(). */
@@ -1280,6 +2046,13 @@ class HmSim {
1280
2046
  return handle;
1281
2047
  }
1282
2048
 
2049
+ clearTimer(handle) {
2050
+ if (handle) {
2051
+ clearTimeout(handle);
2052
+ this.timers.delete(handle);
2053
+ }
2054
+ }
2055
+
1283
2056
  /**
1284
2057
  * Every putParamset the simulator accepted, oldest first.
1285
2058
  * @returns {Array<{iface: string, address: string, paramset: string, values: object, ts: number}>}
@@ -1300,12 +2073,16 @@ class HmSim {
1300
2073
  getDeviceDescription: (iface, params) => this.getDeviceDescription(iface, params[0]),
1301
2074
  ping: (iface, params) => this.ping(iface, params),
1302
2075
  setValue: (iface, params) => {
2076
+ this.requireReachable(iface, params[0]);
1303
2077
  this.setValue(iface, params[0], params[1], params[2], {strict: true});
1304
2078
  return '';
1305
2079
  },
1306
2080
  getValue: (iface, params) => this.getValue(iface, params[0], params[1]),
1307
2081
  getParamset: (iface, params) => this.getParamset(iface, params[0], params[1]),
1308
- putParamset: (iface, params) => 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
+ },
1309
2086
  'system.methodHelp': (iface, params) => this.methodHelp(params[0]),
1310
2087
  getLinks: (iface, params) => this.getLinks(iface, params),
1311
2088
  getLinkPeers: (iface, params) => this.getLinkPeers(iface, params),
@@ -1320,6 +2097,9 @@ class HmSim {
1320
2097
  getServiceMessages: (iface) => this.getServiceMessages(iface),
1321
2098
  setInstallMode: (iface, params) => this.setInstallMode(iface, params),
1322
2099
  getInstallMode: (iface) => this.getInstallMode(iface),
2100
+ setTempKey: (iface, params) => this.setTempKey(iface, params),
2101
+ listTeams: (iface) => this.listTeams(iface),
2102
+ setTeam: (iface, params) => this.setTeam(iface, params),
1323
2103
  deleteDevice: (iface, params) => this.deleteDevice(iface, params[0], params[1]),
1324
2104
  replaceDevice: (iface, params) => this.replaceDevice(iface, params[0], params[1]),
1325
2105
  reportValueUsage: (iface, params) => this.reportValueUsage(iface, params),
@@ -1328,6 +2108,9 @@ class HmSim {
1328
2108
  clearConfigCache: (iface, params) => this.clearConfigCache(iface, params[0]),
1329
2109
  restoreConfigToDevice: (iface, params) => this.restoreConfigToDevice(iface, params[0]),
1330
2110
  determineParameter: (iface, params) => this.determineParameter(iface, params),
2111
+ logLevel: (iface, params) => this.logLevel(iface, params),
2112
+ changeKey: (iface, params) => this.changeKey(iface, params),
2113
+ refreshDeployedDeviceFirmwareList: (iface) => this.refreshDeployedDeviceFirmwareList(iface),
1331
2114
  };
1332
2115
 
1333
2116
  // the CCU accepts the lower case spelling as well
@@ -1342,15 +2125,17 @@ class HmSim {
1342
2125
 
1343
2126
  multicall(iface, params) {
1344
2127
  const calls = (params && params[0]) || [];
2128
+ const asFault = (error) => ({faultCode: error.faultCode, faultString: error.faultString});
1345
2129
  const results = [];
1346
2130
  for (const call of calls) {
1347
2131
  try {
1348
- 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]);
1349
2134
  } catch (error) {
1350
- results.push({faultCode: error.faultCode, faultString: error.faultString});
2135
+ results.push(asFault(error));
1351
2136
  }
1352
2137
  }
1353
- return results;
2138
+ return results.some(isThenable) ? Promise.all(results) : results;
1354
2139
  }
1355
2140
 
1356
2141
  callMethod(iface, method, params) {
@@ -1369,32 +2154,158 @@ class HmSim {
1369
2154
  * @param {Array} params
1370
2155
  * @param {function} callback (fault, result)
1371
2156
  */
1372
- dispatch(iface, method, params, callback) {
2157
+ dispatch(iface, method, params, callback, context = {}) {
1373
2158
  this.log.debug('rpc', iface, '<', method, shortenParams(params));
1374
- let result;
1375
- try {
1376
- result = this.callMethod(iface, method, params);
1377
- } 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) => {
1378
2201
  if (error.faultCode === undefined) {
1379
2202
  this.log.error('rpc', iface, method, error.stack || error.message);
1380
2203
  callback(this.fault('invalidArguments', error.message));
1381
2204
  return;
1382
2205
  }
1383
2206
  callback(error);
2207
+ };
2208
+ let result;
2209
+ try {
2210
+ result = this.callMethod(iface, method, params);
2211
+ } catch (error) {
2212
+ failed(error);
2213
+ return;
2214
+ }
2215
+ if (isThenable(result)) {
2216
+ result.then((value) => callback(null, value === undefined ? '' : value), failed);
1384
2217
  return;
1385
2218
  }
1386
2219
  callback(null, result);
1387
2220
  }
1388
2221
 
1389
- init(iface, params) {
1390
- const [url, id] = params;
1391
- let [protocol, host, port] = String(url).split(':');
1392
- host = host.replace(/^\/\//, '');
1393
- if (protocol === 'xmlrpc_bin') {
1394
- 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);
2242
+ }
2243
+ this.injectedFaults.push(entry);
2244
+ return entry;
2245
+ }
2246
+
2247
+ /** Removes every injected fault that did not happen yet. */
2248
+ clearFaults() {
2249
+ this.injectedFaults = [];
2250
+ }
2251
+
2252
+ takeInjectedFault(iface, method) {
2253
+ const index = this.injectedFaults.findIndex(
2254
+ (rule) =>
2255
+ (rule.iface === undefined || rule.iface === iface) && (rule.method === '*' || rule.method === method),
2256
+ );
2257
+ if (index === -1) {
2258
+ return undefined;
2259
+ }
2260
+ const rule = this.injectedFaults[index];
2261
+ // Infinity or a negative number: until clearFaults() (-1 is what JSON can carry)
2262
+ if (rule.times !== Infinity && rule.times >= 0) {
2263
+ rule.times--;
2264
+ if (rule.times <= 0) {
2265
+ this.injectedFaults.splice(index, 1);
2266
+ }
2267
+ }
2268
+ return rule;
2269
+ }
2270
+
2271
+ injectedFaultOf(fault, method) {
2272
+ if (typeof fault === 'string') {
2273
+ if (!this.faults[fault]) {
2274
+ throw new Error(`no fault ${fault} in the fault table`);
2275
+ }
2276
+ return this.fault(fault, `injected into ${method}`);
2277
+ }
2278
+ if (!fault || typeof fault.faultCode !== 'number') {
2279
+ throw new Error('an injected fault is a name of the fault table or {faultCode, faultString}');
1395
2280
  }
2281
+ return {faultCode: fault.faultCode, faultString: fault.faultString || ''};
2282
+ }
2283
+
2284
+ /**
2285
+ * Every call the simulator made to a registered client, oldest first (the last 10000):
2286
+ * `{iface, client, method, sentAt, answeredAt, error}`. `answeredAt` is null while the client
2287
+ * has not answered, `error` the transport error or timeout.
2288
+ */
2289
+ getCallbackLog() {
2290
+ return this.callbackLog.map((entry) => ({...entry}));
2291
+ }
1396
2292
 
1397
- const clientId = [host, port].join(':');
2293
+ /**
2294
+ * `init(url, interfaceId)` - registers a logic layer, or with an empty id removes it.
2295
+ *
2296
+ * The url is `xmlrpc_bin://host:port` or `binrpc://…` for BIN-RPC callbacks, `http://` or
2297
+ * `https://host:port/path` for XML-RPC ones (the path is kept: a callback server may serve
2298
+ * several interfaces on one port). A registration is identified by the url exactly, scheme and
2299
+ * path included, as the interface processes do: `init('http://h:1', '')` does not remove a
2300
+ * `xmlrpc_bin://h:1` registration.
2301
+ */
2302
+ init(iface, params) {
2303
+ const [url, id] = params || [];
2304
+ const parsed = parseCallbackUrl(url);
2305
+ if (!parsed) {
2306
+ throw this.fault('invalidArguments', `init url ${url}`);
2307
+ }
2308
+ const clientId = String(url);
1398
2309
 
1399
2310
  if (id === '' || id === undefined) {
1400
2311
  const client = this.clients[iface][clientId];
@@ -1404,21 +2315,12 @@ class HmSim {
1404
2315
  delete this.clients[iface][clientId];
1405
2316
  }
1406
2317
  } else {
1407
- const transport = protocol === 'binrpc' ? binrpc : xmlrpc;
1408
- const client = {
1409
- id,
1410
- url,
1411
- client: transport.createClient({host, port}),
1412
- methodCall: (methodName, methodParams, callback) => {
1413
- this.log.debug('rpc >', url, methodName, shortenParams(methodParams));
1414
- client.client.methodCall(methodName, methodParams, (error, result) => {
1415
- this.log.debug('rpc <', url, shortenParams(result));
1416
- if (typeof callback === 'function') {
1417
- callback(error, result);
1418
- }
1419
- });
1420
- },
1421
- };
2318
+ const client = this.createClient(iface, parsed, url, id, clientId);
2319
+ if (this.interfaceOption(iface, 'listenerModel') === 'measured') {
2320
+ return BIDCOS_INTERFACES.has(iface)
2321
+ ? this.initMeasuredBidcos(iface, client)
2322
+ : this.initMeasuredHmip(iface, client);
2323
+ }
1422
2324
  this.clients[iface][clientId] = client;
1423
2325
  this.startInit(iface, client);
1424
2326
  }
@@ -1426,14 +2328,134 @@ class HmSim {
1426
2328
  return '';
1427
2329
  }
1428
2330
 
2331
+ /** A registered logic layer: its RPC client, and a methodCall that logs and can time out. */
2332
+ createClient(iface, {protocol, host, port, path}, url, id, key) {
2333
+ let rpcClient;
2334
+ if (protocol === 'binrpc') {
2335
+ rpcClient = binrpc.createClient({host, port});
2336
+ } else if (protocol === 'https') {
2337
+ rpcClient = xmlrpc.createSecureClient({host, port, path, rejectUnauthorized: false});
2338
+ } else {
2339
+ rpcClient = xmlrpc.createClient({host, port, path});
2340
+ }
2341
+ const client = {
2342
+ id,
2343
+ url,
2344
+ key,
2345
+ client: rpcClient,
2346
+ methodCall: (methodName, methodParams, callback, {timeout} = {}) => {
2347
+ this.log.debug('rpc >', url, methodName, shortenParams(methodParams));
2348
+ const entry = {
2349
+ iface,
2350
+ client: url,
2351
+ method: methodName,
2352
+ sentAt: Date.now(),
2353
+ answeredAt: null,
2354
+ error: null,
2355
+ };
2356
+ this.logCallback(entry);
2357
+ let done = false;
2358
+ let timer;
2359
+ const finish = (error, result) => {
2360
+ if (done) {
2361
+ return;
2362
+ }
2363
+ done = true;
2364
+ this.clearTimer(timer);
2365
+ entry.answeredAt = Date.now();
2366
+ entry.error = error ? String(error.message || error) : null;
2367
+ this.log.debug('rpc <', url, error ? error.message : shortenParams(result));
2368
+ if (typeof callback === 'function') {
2369
+ callback(error, result);
2370
+ }
2371
+ };
2372
+ if (timeout > 0) {
2373
+ timer = this.timer(() => finish(new Error(`no answer within ${timeout} ms`)), timeout);
2374
+ }
2375
+ client.client.methodCall(methodName, methodParams, finish);
2376
+ },
2377
+ };
2378
+ return client;
2379
+ }
2380
+
2381
+ logCallback(entry) {
2382
+ this.callbackLog.push(entry);
2383
+ if (this.callbackLog.length > CALLBACK_LOG_SIZE) {
2384
+ this.callbackLog.splice(0, this.callbackLog.length - CALLBACK_LOG_SIZE);
2385
+ }
2386
+ }
2387
+
2388
+ /**
2389
+ * rfd's `init`, as measured with a callback server that never answers (firmware 3.89.8,
2390
+ * 2026-09-25): rfd calls `system.listMethods` back *before* it answers the `init`, and until
2391
+ * that callback returns, rfd answers nothing else at all - every other client's calls wait.
2392
+ * When the callback fails (the connection ended), rfd tries it three more times at once and
2393
+ * registers the client anyway. A registered client that does not take a callback within
2394
+ * `deliveryTimeout` (10 s measured) is dropped. That `listDevices` also comes before the
2395
+ * answer is the model of the simulator, as the rest of the init sequence.
2396
+ */
2397
+ initMeasuredBidcos(iface, client) {
2398
+ const release = this.closeGate(iface);
2399
+ return new Promise((resolve) => {
2400
+ const listMethods = (attempt) => {
2401
+ client.methodCall('system.listMethods', [client.id], (error) => {
2402
+ if (error && attempt < 4) {
2403
+ listMethods(attempt + 1);
2404
+ return;
2405
+ }
2406
+ this.clients[iface][client.key] = client;
2407
+ client.methodCall('listDevices', [client.id], (listError, clientDevices) => {
2408
+ release();
2409
+ resolve('');
2410
+ if (!listError) {
2411
+ this.checkDevices(iface, client, clientDevices);
2412
+ }
2413
+ });
2414
+ });
2415
+ };
2416
+ listMethods(1);
2417
+ });
2418
+ }
2419
+
2420
+ /**
2421
+ * hmipserver's `init`, as measured (firmware 3.89.8, 2026-09-25): the call is answered at
2422
+ * once and `listDevices` is called back afterwards. While that callback hangs, hmipserver
2423
+ * delivers **no event to any client** of the interface - they are queued and follow when the
2424
+ * connection ends, and the client that hung is not called again. After a registration
2425
+ * finished, a client that stops answering holds up only itself.
2426
+ */
2427
+ initMeasuredHmip(iface, client) {
2428
+ this.clients[iface][client.key] = client;
2429
+ const release = this.hold(iface);
2430
+ client.methodCall('listDevices', [client.id], (error, clientDevices) => {
2431
+ if (error) {
2432
+ this.log.warn(iface, 'client', client.url, 'failed at listDevices - not registered:', error.message);
2433
+ if (this.clients[iface][client.key] === client) {
2434
+ this.forgetClient(iface, client.key);
2435
+ }
2436
+ } else {
2437
+ this.checkDevices(iface, client, clientDevices);
2438
+ }
2439
+ release();
2440
+ });
2441
+ return '';
2442
+ }
2443
+
2444
+ /**
2445
+ * `ping(callerId)` - the BidCos processes answer with a `CENTRAL`/`PONG` event to *every*
2446
+ * registered client, hmipserver sends none (`pong: false`). `pingDelay` delays the event.
2447
+ */
1429
2448
  ping(iface, params) {
1430
- if (iface === 'hmip') {
1431
- // hmipserver does not answer ping with a PONG event, see
1432
- // https://github.com/eq-3/occu/issues/42
2449
+ if (!this.interfaceOption(iface, 'pong')) {
1433
2450
  return '';
1434
2451
  }
1435
-
1436
- this.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
+ }
1437
2459
  return '';
1438
2460
  }
1439
2461
 
@@ -1447,7 +2469,7 @@ class HmSim {
1447
2469
  event(iface, params) {
1448
2470
  for (const key of Object.keys(this.clients[iface] || {})) {
1449
2471
  const client = this.clients[iface][key];
1450
- client.methodCall('event', [client.id, ...params]);
2472
+ this.deliver(iface, client, 'event', [client.id, ...params]);
1451
2473
  }
1452
2474
  }
1453
2475
 
@@ -1458,7 +2480,7 @@ class HmSim {
1458
2480
  methodName: 'event',
1459
2481
  params: [client.id, singleEvent[0], singleEvent[1], singleEvent[2]],
1460
2482
  }));
1461
- client.methodCall('system.multicall', [multicall]);
2483
+ this.deliver(iface, client, 'system.multicall', [multicall]);
1462
2484
  }
1463
2485
  }
1464
2486
 
@@ -1585,7 +2607,8 @@ class HmSim {
1585
2607
  return createBinrpcServer({
1586
2608
  host: listenAddress,
1587
2609
  port,
1588
- dispatch: (method, params, callback) => this.dispatch(iface, method, params, callback),
2610
+ dispatch: (method, params, callback, context) =>
2611
+ this.dispatch(iface, method, params, callback, context),
1589
2612
  onListening,
1590
2613
  onError,
1591
2614
  });
@@ -1599,7 +2622,28 @@ class HmSim {
1599
2622
  path,
1600
2623
  tls: this.tls,
1601
2624
  auth,
1602
- dispatch: (method, params, callback) => 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),
1603
2647
  onListening,
1604
2648
  onError,
1605
2649
  });
@@ -1607,17 +2651,20 @@ class HmSim {
1607
2651
 
1608
2652
  // the factory of an interface is kept so that dropConnection() can restart its server
1609
2653
  const start = (iface, port, factory) => {
2654
+ if (this.interfaceOption(iface, 'startDelay') > 0) {
2655
+ this.delayedStarts[iface] = this.interfaceOption(iface, 'startDelay');
2656
+ }
1610
2657
  this.serverFactories[iface] = factory;
1611
2658
  this.servers[iface] = factory(port);
1612
2659
  this.exposeServer(iface);
1613
2660
  };
1614
2661
 
1615
2662
  if (this.devices.rfd) {
1616
- start('rfd', binrpcListenPort, (port) => binrpcServer('rfd', port, 'rfd binrpc'));
2663
+ start('rfd', binrpcListenPort, (port) => bidcosServer('rfd', port, 'rfd'));
1617
2664
  }
1618
2665
 
1619
2666
  if (this.devices.wired) {
1620
- start('wired', this.config.wiredListenPort, (port) => binrpcServer('wired', port, 'wired binrpc'));
2667
+ start('wired', this.config.wiredListenPort, (port) => bidcosServer('wired', port, 'wired'));
1621
2668
  }
1622
2669
 
1623
2670
  if (this.devices.hmip) {
@@ -1660,6 +2707,15 @@ class HmSim {
1660
2707
  const port = serverPort(server);
1661
2708
  this.ports[iface] = port;
1662
2709
  this.log.info(label, 'server listening on', host, port);
2710
+ const delay = this.delayedStarts[iface];
2711
+ if (delay !== undefined) {
2712
+ // the port is known now; it refuses connections until the delay is over
2713
+ delete this.delayedStarts[iface];
2714
+ this.stopped.add(iface);
2715
+ server.close();
2716
+ this.log.info(label, 'starts in', delay, 'ms');
2717
+ this.timer(() => this.startInterface(iface, {forgetClients: false}), delay);
2718
+ }
1663
2719
  resolve();
1664
2720
  },
1665
2721
  onError: (error) => {
@@ -1702,8 +2758,9 @@ class HmSim {
1702
2758
  clearTimeout(handle);
1703
2759
  }
1704
2760
  this.timers.clear();
1705
- for (const server of Object.values(this.servers)) {
1706
- 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)) {
1707
2764
  server.close();
1708
2765
  }
1709
2766
  }
@@ -1728,6 +2785,32 @@ function serverPort(server) {
1728
2785
  return address ? address.port : undefined;
1729
2786
  }
1730
2787
 
2788
+ /**
2789
+ * The parts of a callback url: `{protocol: 'binrpc'|'http'|'https', host, port, path}`, or
2790
+ * undefined when it is none.
2791
+ */
2792
+ function parseCallbackUrl(url) {
2793
+ const match = /^([A-Za-z_]+):\/\/(\[[^\]]+\]|[^/:]+):(\d+)(\/.*)?$/.exec(String(url));
2794
+ if (!match) {
2795
+ return undefined;
2796
+ }
2797
+ const scheme = match[1].toLowerCase();
2798
+ const protocol = scheme === 'xmlrpc_bin' || scheme === 'binrpc' ? 'binrpc' : scheme;
2799
+ if (!['binrpc', 'http', 'https', 'xmlrpc'].includes(protocol)) {
2800
+ return undefined;
2801
+ }
2802
+ return {
2803
+ protocol: protocol === 'xmlrpc' ? 'http' : protocol,
2804
+ host: match[2].replace(/^\[|\]$/g, ''),
2805
+ port: Number(match[3]),
2806
+ path: match[4] || '/',
2807
+ };
2808
+ }
2809
+
2810
+ function isThenable(value) {
2811
+ return value !== null && typeof value === 'object' && typeof value.then === 'function';
2812
+ }
2813
+
1731
2814
  /** Closes a binrpc or xmlrpc client so that it stops holding the event loop open. */
1732
2815
  function closeClient(client) {
1733
2816
  if (!client) {