hm-simulator 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,16 @@
3
3
  All notable changes of hm-simulator. The release workflow takes each version's section from here for the
4
4
  GitHub release notes.
5
5
 
6
+ ## 1.4.0 — 2026-10-03
7
+
8
+ - `setInstallModeWithWhitelist(on, time, [{ADDRESS, KEY_MODE, KEY}])` on hmipserver, which answered
9
+ the unknown-method fault: it opens the install mode for the listed SGTINs (Homematic Manager's
10
+ pairing with the key from the sticker), `on: false` closes it. The scenario call `scriptInclusion`
11
+ puts an HmIP device in reach of the next install mode: it joins, or - with a whitelist `LOCAL` key
12
+ that is not its own - is declined, nothing joins and the device stays in reach; `getInclusions`
13
+ answers every request with its result (`paired`, `declined`, `ignored`), `getInstallWhitelist` the
14
+ open install mode's list. The simulator's model, not a measurement (Homematic Manager task 88).
15
+
6
16
  ## 1.3.2
7
17
 
8
18
  - `getParamsetDescription` of an address the interface does not know answers the `unknownInstance`
package/README.md CHANGED
@@ -122,17 +122,17 @@ that starts with the bytes `Bin` is BIN-RPC, anything else XML-RPC over HTTP.
122
122
 
123
123
  **Incoming RPC methods.**
124
124
 
125
- | | |
126
- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
- | session | `init` (register and de-register a logic layer), `ping`, `system.listMethods`, `system.methodHelp`, `system.multicall` |
128
- | devices | `listDevices`, `getDeviceDescription`, `deleteDevice`, `replaceDevice`, `listReplaceableDevices`, `setInstallMode`, `getInstallMode`, `addDevice` (BidCos only), `getKeyMismatchDevice` (rfd, hmipserver) |
129
- | paramsets | `getParamsetDescription`, `getParamset`, `putParamset`, `getValue`, `setValue`, `determineParameter`, `reportValueUsage` |
130
- | links | `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`, `activateLinkParamset` |
131
- | interface | `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`, `getServiceMessages`, `setTempKey` (BidCos only), `changeKey` (rfd, hmipserver), `logLevel`, `getVersion`, `setInterfaceClock` (rfd, hmipserver), `getLGWStatus` (only when configured) |
132
- | metadata | `getMetadata`, `setMetadata` (rfd, hs485d, hmipserver), `getAllMetadata` (BidCos only) |
133
- | service messages | `suppressServiceMessages`, `getSuppressedServiceMessages` (hmipserver only) |
134
- | teams | `listTeams`, `setTeam` (BidCos only): the smoke detector teams as rfd keeps them, pseudo devices `*<serial>` |
135
- | maintenance | `clearConfigCache`, `restoreConfigToDevice`, `updateFirmware`, `installFirmware`, `refreshDeployedDeviceFirmwareList` (rfd, hmipserver) |
125
+ | | |
126
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | session | `init` (register and de-register a logic layer), `ping`, `system.listMethods`, `system.methodHelp`, `system.multicall` |
128
+ | devices | `listDevices`, `getDeviceDescription`, `deleteDevice`, `replaceDevice`, `listReplaceableDevices`, `setInstallMode`, `setInstallModeWithWhitelist` (hmipserver), `getInstallMode`, `addDevice` (BidCos only), `getKeyMismatchDevice` (rfd, hmipserver) |
129
+ | paramsets | `getParamsetDescription`, `getParamset`, `putParamset`, `getValue`, `setValue`, `determineParameter`, `reportValueUsage` |
130
+ | links | `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`, `activateLinkParamset` |
131
+ | interface | `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`, `getServiceMessages`, `setTempKey` (BidCos only), `changeKey` (rfd, hmipserver), `logLevel`, `getVersion`, `setInterfaceClock` (rfd, hmipserver), `getLGWStatus` (only when configured) |
132
+ | metadata | `getMetadata`, `setMetadata` (rfd, hs485d, hmipserver), `getAllMetadata` (BidCos only) |
133
+ | service messages | `suppressServiceMessages`, `getSuppressedServiceMessages` (hmipserver only) |
134
+ | teams | `listTeams`, `setTeam` (BidCos only): the smoke detector teams as rfd keeps them, pseudo devices `*<serial>` |
135
+ | maintenance | `clearConfigCache`, `restoreConfigToDevice`, `updateFirmware`, `installFirmware`, `refreshDeployedDeviceFirmwareList` (rfd, hmipserver) |
136
136
 
137
137
  Every write is checked against the paramset description (type, range, `VALUE_LIST`,
138
138
  `OPERATIONS`) and answered the way the interface process answers it - see
@@ -246,6 +246,14 @@ sim.getDevice('rfd', 'LAB0000002:1'); // the description, false when unknown
246
246
  // a device that holds another system's key: the next install mode hears it (getKeyMismatchDevice
247
247
  // names it) until setTempKey('their-key') lets it pair; addDevice('LAB0000009') does the same
248
248
  sim.scriptKeyMismatch('rfd', 'LAB0000009', {key: 'their-key', devices: [device, channel0, channel1]});
249
+ // an HmIP device in factory state asks the next install mode to join; with a whitelist whose LOCAL
250
+ // key is not its key it is declined, and stays in reach for the next one
251
+ sim.scriptInclusion('hmip', '3014F711A000000000000088', {
252
+ key: '00112233445566778899AABBCCDDEEFF',
253
+ devices: [hmipDevice],
254
+ });
255
+ sim.getInclusions('hmip'); // [{sgtin, result: 'paired' | 'declined' | 'ignored', ts}]
256
+ sim.getInstallWhitelist('hmip'); // the open install mode's [{ADDRESS, KEY_MODE, KEY}], [] without one
249
257
  ```
250
258
 
251
259
  **Values and events**
@@ -649,6 +657,17 @@ knows only its own addresses; `setInterfaceClock` is recorded, not measured (it
649
657
  devices get). `addDevice(serial)` pairs only the device of `scriptKeyMismatch`, with the right
650
658
  temporary key; any other serial is `unknownInstance`, the mismatch a `notSupported` fault.
651
659
 
660
+ `setInstallModeWithWhitelist(on, time, [{ADDRESS, KEY_MODE, KEY}])` (hmipserver only; the others
661
+ answer the unknown-method fault) opens the install mode for the listed SGTINs, as Homematic Manager
662
+ sends it for a device paired with the key from its sticker (`KEY_MODE: 'LOCAL'`, `KEY` the 32 hex
663
+ digits of the QR code), and `on: false` closes it; a list that is not an array of structs with an
664
+ `ADDRESS` is `invalidArguments`. Which devices it admits is the simulator's model, not a
665
+ measurement: a device of `scriptInclusion` joins when it is on the list and the entry's `LOCAL` key
666
+ is its own (or the entry names another key mode, the key server's), is **declined** when the `LOCAL`
667
+ key is another - nothing joins, no RPC client is told, `getInclusions` records it, the device stays
668
+ in reach - and is not admitted (`ignored`) when it is not on the list. Opened with `setInstallMode`,
669
+ the device joins.
670
+
652
671
  ## ReGa mock
653
672
 
654
673
  `rega: {port: 8181, ...}` starts an HTTP server that answers `POST /<name>.exe` as the CCU's
@@ -760,7 +779,8 @@ there. The flags win over the file:
760
779
  arguments. The answer is `{"result": ...}`; a fault is `400` with `faultCode` and `faultString`, an
761
780
  unknown call `404`. `GET /ports` answers the ports. The calls: `addDevice`, `removeDevice`,
762
781
  `fireEvent`, `fireEvents`, `setValue` (the device reporting a value, as a behaviour script does),
763
- `setServiceMessage`, `scriptNewDevices`, `scriptKeyMismatch`, `dropConnection`, `stopInterface`, `startInterface`,
782
+ `setServiceMessage`, `scriptNewDevices`, `scriptKeyMismatch`, `scriptInclusion`, `getInclusions`,
783
+ `getInstallWhitelist`, `dropConnection`, `stopInterface`, `startInterface`,
764
784
  `restartInterface`, `injectFault`, `clearFaults`, `setReachable`, `setLowBattery`, `setDutyCycle`,
765
785
  `setCarrierSense`, `offerFirmware`, `schedule`, `getDevice`, `getWriteLog`, `getConfigPending`,
766
786
  `getPoisonedChannels`, `getMissingParamsetDescriptions`, `getCallbackLog`, `getTempKey`,
package/lib/control.js CHANGED
@@ -15,6 +15,9 @@ const SCENARIO_METHODS = new Set([
15
15
  'setServiceMessage',
16
16
  'scriptNewDevices',
17
17
  'scriptKeyMismatch',
18
+ 'scriptInclusion',
19
+ 'getInclusions',
20
+ 'getInstallWhitelist',
18
21
  'dropConnection',
19
22
  'stopInterface',
20
23
  'startInterface',
@@ -40,6 +40,8 @@ module.exports = {
40
40
  getServiceMessages: 'array getServiceMessages() - the pending service messages',
41
41
  setInstallMode: 'void setInstallMode(boolean on, int time, int mode) - switch the install mode',
42
42
  getInstallMode: 'int getInstallMode() - remaining seconds of the install mode',
43
+ setInstallModeWithWhitelist:
44
+ 'void setInstallModeWithWhitelist(boolean on, int time, array whitelist) - the install mode for [{ADDRESS, KEY_MODE, KEY}] only; HmIP only',
43
45
  setTempKey: 'void setTempKey(string passphrase) - the temporary key the next BidCos pairings use; BidCos only',
44
46
  listTeams: 'array listTeams() - the team pseudo devices (smoke detector teams) with their channels; BidCos only',
45
47
  setTeam:
package/lib/sim.js CHANGED
@@ -49,6 +49,13 @@ const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
49
49
  const LINK_SENDER_BROKEN = 1;
50
50
 
51
51
  /** The device part of a channel address: `ABC0000001:1` -> `ABC0000001`. */
52
+ /** An SGTIN or an HmIP device key as the comparisons take it: no dashes or blanks, upper case. */
53
+ function normaliseHex(text) {
54
+ return String(text || '')
55
+ .replace(/[\s-]/g, '')
56
+ .toUpperCase();
57
+ }
58
+
52
59
  function deviceOf(address) {
53
60
  const colon = String(address).indexOf(':');
54
61
  return colon === -1 ? String(address) : String(address).slice(0, colon);
@@ -285,6 +292,12 @@ class HmSim {
285
292
  this.keyMismatchScript = {};
286
293
  /** the serial getKeyMismatchDevice answers: {<iface>: serial} */
287
294
  this.keyMismatch = {};
295
+ /** the whitelist of the open install mode (setInstallModeWithWhitelist): {<iface>: [{ADDRESS, KEY_MODE, KEY}]} */
296
+ this.installWhitelist = {};
297
+ /** HmIP devices in reach of the next install mode: {<iface>: Map<sgtin, {sgtin, key, devices, delay}>}, see scriptInclusion() */
298
+ this.inclusionScripts = {};
299
+ /** every inclusion request a scripted device made: {<iface>: [{sgtin, result, ts}]}, see getInclusions() */
300
+ this.inclusions = {};
288
301
  /** suppressed service messages (hmipserver): {<iface>: Map<channelAddress, Set<parameter>>} */
289
302
  this.suppressed = {};
290
303
  /** metadata per interface and address: {<iface>: {<address>: {<key>: value}}} */
@@ -1190,13 +1203,55 @@ class HmSim {
1190
1203
  */
1191
1204
  setInstallMode(iface, params) {
1192
1205
  const [on, time = 60, mode = 1] = params || [];
1206
+ delete this.installWhitelist[iface];
1193
1207
  if (!on) {
1194
1208
  this.installMode[iface] = 0;
1195
1209
  return '';
1196
1210
  }
1197
1211
 
1198
- this.installMode[iface] = Date.now() + Number(time) * 1000;
1199
1212
  this.log.info(iface, 'install mode on for', time, 'seconds, mode', mode);
1213
+ return this.openInstallMode(iface, time);
1214
+ }
1215
+
1216
+ /**
1217
+ * `setInstallModeWithWhitelist(on, time, whitelist)` - hmipserver only. Opens the install mode
1218
+ * for the devices of the whitelist, `[{ADDRESS: <SGTIN>, KEY_MODE, KEY}]`: `KEY_MODE: 'LOCAL'`
1219
+ * with the device key as `KEY` (32 hex digits, as the QR code holds it), any other mode without
1220
+ * one (the key server's key). `on: false` closes the install mode. The simulator's model: a
1221
+ * device of scriptInclusion() that is not on the list is not admitted, one with a `LOCAL` key
1222
+ * that is not its own is declined (nothing joins, getInclusions() records it), any other joins.
1223
+ */
1224
+ setInstallModeWithWhitelist(iface, params) {
1225
+ if (iface !== 'hmip') {
1226
+ throw this.fault('unknownMethod', `${iface}/setInstallModeWithWhitelist`);
1227
+ }
1228
+ const [on, time = 60, whitelist = []] = params || [];
1229
+ if (!on) {
1230
+ delete this.installWhitelist[iface];
1231
+ this.installMode[iface] = 0;
1232
+ return '';
1233
+ }
1234
+ if (
1235
+ !Array.isArray(whitelist) ||
1236
+ !whitelist.every((entry) => entry && typeof entry === 'object' && typeof entry.ADDRESS === 'string')
1237
+ ) {
1238
+ throw this.fault(
1239
+ 'invalidArguments',
1240
+ `${iface}/setInstallModeWithWhitelist wants [{ADDRESS, KEY_MODE, KEY}]`,
1241
+ );
1242
+ }
1243
+ this.installWhitelist[iface] = whitelist.map((entry) => ({
1244
+ ADDRESS: normaliseHex(entry.ADDRESS),
1245
+ KEY_MODE: typeof entry.KEY_MODE === 'string' ? entry.KEY_MODE : '',
1246
+ KEY: typeof entry.KEY === 'string' ? entry.KEY : '',
1247
+ }));
1248
+ this.log.info(iface, 'install mode on for', time, 'seconds, whitelist of', whitelist.length);
1249
+ return this.openInstallMode(iface, time);
1250
+ }
1251
+
1252
+ /** The install mode opens: the scripted devices come into reach. */
1253
+ openInstallMode(iface, time) {
1254
+ this.installMode[iface] = Date.now() + Number(time) * 1000;
1200
1255
 
1201
1256
  const scripted = this.newDevicesScript[iface];
1202
1257
  if (scripted) {
@@ -1209,9 +1264,95 @@ class HmSim {
1209
1264
  this.timer(() => this.hearKeyMismatchDevice(iface, mismatch), mismatch.delay);
1210
1265
  }
1211
1266
 
1267
+ for (const script of (this.inclusionScripts[iface] || new Map()).values()) {
1268
+ this.timer(() => this.hearInclusion(iface, script), script.delay);
1269
+ }
1270
+
1212
1271
  return '';
1213
1272
  }
1214
1273
 
1274
+ /* ------------------------------------------------------------------ HmIP inclusion */
1275
+
1276
+ /**
1277
+ * An HmIP device in factory state comes into reach of the next install mode and asks to join
1278
+ * (Homematic Manager task 88). `key` is its device key as the QR code holds it (32 hex digits;
1279
+ * case and dashes do not matter). Opened with setInstallMode, it joins (the key server knows its
1280
+ * key); opened with setInstallModeWithWhitelist, it joins when it is on the list and its entry's
1281
+ * `LOCAL` key is `key` or the entry asks the key server, is declined with another `LOCAL` key,
1282
+ * and is not admitted when it is not on the list. Declined and not admitted, it stays in reach
1283
+ * for the next install mode; joined, `devices` go out with `newDevices` and the script is used
1284
+ * up. Each request is recorded (getInclusions()).
1285
+ *
1286
+ * @param {string} iface
1287
+ * @param {string} sgtin
1288
+ * @param {object} [script]
1289
+ * @param {string} [script.key=''] its device key
1290
+ * @param {Array} [script.devices=[]] its description and channel descriptions
1291
+ * @param {number} [script.delay=0] milliseconds between the install mode and the request
1292
+ */
1293
+ scriptInclusion(iface, sgtin, {key = '', devices = [], delay = 0} = {}) {
1294
+ if (typeof sgtin !== 'string' || sgtin === '') {
1295
+ throw new TypeError('scriptInclusion wants an SGTIN');
1296
+ }
1297
+ if (!this.inclusionScripts[iface]) {
1298
+ this.inclusionScripts[iface] = new Map();
1299
+ }
1300
+ const normalised = normaliseHex(sgtin);
1301
+ this.inclusionScripts[iface].set(normalised, {
1302
+ sgtin: normalised,
1303
+ key: normaliseHex(key),
1304
+ devices: devices || [],
1305
+ delay,
1306
+ });
1307
+ }
1308
+
1309
+ /** The scripted device asks to join: joined, declined for its key, or not admitted. */
1310
+ hearInclusion(iface, script) {
1311
+ const scripts = this.inclusionScripts[iface];
1312
+ if (!scripts || scripts.get(script.sgtin) !== script || this.getInstallMode(iface) === 0) {
1313
+ return;
1314
+ }
1315
+ let result = 'paired';
1316
+ const whitelist = this.installWhitelist[iface];
1317
+ if (whitelist) {
1318
+ const entry = whitelist.find((candidate) => candidate.ADDRESS === script.sgtin);
1319
+ if (!entry) {
1320
+ result = 'ignored';
1321
+ } else if (entry.KEY_MODE === 'LOCAL' && normaliseHex(entry.KEY) !== script.key) {
1322
+ result = 'declined';
1323
+ }
1324
+ }
1325
+ if (!this.inclusions[iface]) {
1326
+ this.inclusions[iface] = [];
1327
+ }
1328
+ this.inclusions[iface].push({sgtin: script.sgtin, result, ts: Date.now()});
1329
+ this.log.info(
1330
+ iface,
1331
+ script.sgtin,
1332
+ result === 'paired'
1333
+ ? 'joined'
1334
+ : result === 'declined'
1335
+ ? 'declined, the key does not match'
1336
+ : 'not on the whitelist',
1337
+ );
1338
+ if (result === 'paired') {
1339
+ scripts.delete(script.sgtin);
1340
+ if (script.devices.length > 0) {
1341
+ this.addDevices(iface, script.devices);
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ /** Every inclusion request of a scripted device, oldest first: `[{sgtin, result, ts}]`, result `paired`, `declined` or `ignored`. */
1347
+ getInclusions(iface) {
1348
+ return [...(this.inclusions[iface] || [])];
1349
+ }
1350
+
1351
+ /** The whitelist of the open install mode, `[]` when it was opened without one or is closed. */
1352
+ getInstallWhitelist(iface) {
1353
+ return this.getInstallMode(iface) === 0 ? [] : structuredClone(this.installWhitelist[iface] || []);
1354
+ }
1355
+
1215
1356
  /* ------------------------------------------------------------------ key mismatch */
1216
1357
 
1217
1358
  /**
@@ -2482,6 +2623,7 @@ class HmSim {
2482
2623
  setBidcosInterface: (iface, params) => this.setBidcosInterface(iface, params),
2483
2624
  getServiceMessages: (iface) => this.getServiceMessages(iface),
2484
2625
  setInstallMode: (iface, params) => this.setInstallMode(iface, params),
2626
+ setInstallModeWithWhitelist: (iface, params) => this.setInstallModeWithWhitelist(iface, params),
2485
2627
  getInstallMode: (iface) => this.getInstallMode(iface),
2486
2628
  setTempKey: (iface, params) => this.setTempKey(iface, params),
2487
2629
  listTeams: (iface) => this.listTeams(iface),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm-simulator",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "Simulates a Homematic CCU (rfd, hmipserver, VirtualDevices, CUxD, ReGa) for automated tests",
5
5
  "main": "sim.js",
6
6
  "types": "./sim.d.ts",
package/sim.d.ts CHANGED
@@ -238,6 +238,31 @@ declare namespace HmSim {
238
238
  args?: unknown[];
239
239
  }
240
240
 
241
+ /** scriptInclusion(): an HmIP device in reach of the next install mode */
242
+ interface InclusionScript {
243
+ /** its device key as the QR code holds it (32 hex digits) */
244
+ key?: string;
245
+ /** its description and channel descriptions, paired when it joins */
246
+ devices?: DeviceDescription[];
247
+ /** milliseconds between the install mode and its request */
248
+ delay?: number;
249
+ }
250
+
251
+ /** One inclusion request of a scripted HmIP device. */
252
+ interface Inclusion {
253
+ sgtin: string;
254
+ /** joined; declined because the whitelist's LOCAL key is not its key; not on the whitelist */
255
+ result: 'paired' | 'declined' | 'ignored';
256
+ ts: number;
257
+ }
258
+
259
+ /** One entry of setInstallModeWithWhitelist's list, as the simulator keeps it. */
260
+ interface WhitelistEntry {
261
+ ADDRESS: string;
262
+ KEY_MODE: string;
263
+ KEY: string;
264
+ }
265
+
241
266
  interface KeyMismatchScript {
242
267
  /** the passphrase the device was taught in with */
243
268
  key?: string;
@@ -349,6 +374,12 @@ declare class HmSim {
349
374
  keyMismatch: Record<string, string>;
350
375
  /** what scriptKeyMismatch() set up and did not pair yet */
351
376
  keyMismatchScript: Record<string, Required<HmSim.KeyMismatchScript> & {serial: string}>;
377
+ /** the whitelist the install mode was last opened with (setInstallModeWithWhitelist), per interface */
378
+ installWhitelist: Record<string, HmSim.WhitelistEntry[]>;
379
+ /** the HmIP devices scriptInclusion() put in reach and that did not join yet, per interface and SGTIN */
380
+ inclusionScripts: Record<string, Map<string, Required<HmSim.InclusionScript> & {sgtin: string}>>;
381
+ /** every inclusion request of a scripted device, per interface */
382
+ inclusions: Record<string, HmSim.Inclusion[]>;
352
383
  /** the temporary key per interface, as setTempKey left it */
353
384
  tempKey: Record<string, string>;
354
385
  /** the log level per interface, as logLevel set it */
@@ -381,6 +412,12 @@ declare class HmSim {
381
412
  scriptNewDevices(iface: string, devices: HmSim.DeviceDescription[], delay?: number): void;
382
413
  /** A device that holds another system's key: heard at the next install mode, paired with its key. */
383
414
  scriptKeyMismatch(iface: string, serial: string, script?: HmSim.KeyMismatchScript): void;
415
+ /** An HmIP device in reach of the next install mode: joins, or is declined for a whitelist key that is not its own. */
416
+ scriptInclusion(iface: string, sgtin: string, script?: HmSim.InclusionScript): void;
417
+ /** Every inclusion request of a scripted device, oldest first. */
418
+ getInclusions(iface: string): HmSim.Inclusion[];
419
+ /** The whitelist of the open install mode, `[]` without one. */
420
+ getInstallWhitelist(iface: string): HmSim.WhitelistEntry[];
384
421
 
385
422
  /* ---------------------------------------------------------------- scenario api: values and events */
386
423