hm-simulator 1.4.0 → 1.5.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
@@ -48,6 +48,32 @@ const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
48
48
  /** FLAGS bit of a link whose sender rfd counts as broken (bit 2 is the receiver) */
49
49
  const LINK_SENDER_BROKEN = 1;
50
50
 
51
+ /** data/paramset-ids.json, read on the first getParamsetId of a BidCos interface */
52
+ let bundledParamsetIds;
53
+
54
+ /**
55
+ * The paramset ids of a BidCos device type and version from the device files
56
+ * (`tools/paramset-ids.js`); a version the files lack takes the highest one below it, else the
57
+ * lowest. `undefined` for a type they do not describe.
58
+ */
59
+ function bidcosParamsetIds(prefix, type, version) {
60
+ if (!bundledParamsetIds) {
61
+ bundledParamsetIds = require('../data/paramset-ids.json');
62
+ }
63
+ const versions = (bundledParamsetIds[prefix] || {})[type];
64
+ if (!versions) {
65
+ return undefined;
66
+ }
67
+ if (versions[String(version)]) {
68
+ return versions[String(version)];
69
+ }
70
+ const known = Object.keys(versions)
71
+ .map(Number)
72
+ .sort((a, b) => a - b);
73
+ const below = known.filter((entry) => entry <= Number(version));
74
+ return versions[String(below.length > 0 ? below.at(-1) : known[0])];
75
+ }
76
+
51
77
  /** The device part of a channel address: `ABC0000001:1` -> `ABC0000001`. */
52
78
  /** An SGTIN or an HmIP device key as the comparisons take it: no dashes or blanks, upper case. */
53
79
  function normaliseHex(text) {
@@ -73,6 +99,9 @@ const STANDARD_PARAMSETS = new Set(['MASTER', 'VALUES', 'SERVICE']);
73
99
  /** FLAGS bit of a datapoint that is a service message (1 visible, 2 internal, 4 transform, 8 service) */
74
100
  const SERVICE_FLAG = 8;
75
101
 
102
+ /** FLAGS bit of a datapoint that keeps its value until a client acknowledges it (STICKY_UNREACH) */
103
+ const STICKY_FLAG = 16;
104
+
76
105
  /**
77
106
  * Per interface behaviour, overridable with the `interfaces` option.
78
107
  *
@@ -103,6 +132,10 @@ const SERVICE_FLAG = 8;
103
132
  * unreachWrites
104
133
  * what a setValue/putParamset to an unreachable device answers: 'accept' (the default) or
105
134
  * 'fault' (notReachable). The simulator's model; not measured yet.
135
+ * stickyWriteEvents
136
+ * whether a client's write to a sticky datapoint (FLAGS & 16: the acknowledgement of
137
+ * STICKY_UNREACH) is answered with the channel's events like every other write (true, the
138
+ * default), or with none (false): a registered client on rfd 3.89.11 got no event for it.
106
139
  * firmwareUpdateDelay
107
140
  * milliseconds per step of a firmware update, see offerFirmware().
108
141
  * protocols
@@ -122,6 +155,7 @@ const INTERFACE_DEFAULTS = {
122
155
  deliveryTimeout: 10000,
123
156
  // device health, see setReachable() and offerFirmware()
124
157
  unreachWrites: 'accept',
158
+ stickyWriteEvents: true,
125
159
  firmwareUpdateDelay: 0,
126
160
  configPendingMode: 'strict',
127
161
  configPendingOnWrite: false,
@@ -217,6 +251,10 @@ function shortenParams(params) {
217
251
  * @param {boolean} [options.paramsetFallback=true] a device whose firmware has no description uses
218
252
  * the nearest firmware of the same type and VERSION; `false` for exact keys only
219
253
  * @param {object} [options.faults] overrides for the fault table, see lib/faults.js
254
+ * @param {object} [options.paramsets] stored values to start with,
255
+ * `{<iface>: {<address>: {<MASTER|VALUES|SERVICE|peer address>: {}}}}`
256
+ * @param {object} [options.paramsetIds] what `getParamsetId` answers before the bundled ids,
257
+ * `{<iface>: {<address>: {<paramset>: id}}}`
220
258
  */
221
259
  class HmSim {
222
260
  constructor(options = {}) {
@@ -233,6 +271,10 @@ class HmSim {
233
271
  /** requested key -> the key used for it (a fallback) or null, see resolveParamsetKey() */
234
272
  this.paramsetResolved = new Map();
235
273
  this.paramsetIndex = new ParamsetIndex(this.paramsetDescriptions);
274
+ /** stored values a paramset starts with, on top of its defaults: {<iface>: {<address>: {<key>: {}}}} */
275
+ this.seededParamsets = structuredClone(options.paramsets || {});
276
+ /** getParamsetId answers given in the options: {<iface>: {<address>: {<paramset>: id}}} */
277
+ this.paramsetIdOverrides = options.paramsetIds || {};
236
278
 
237
279
  const {table, fault} = createFaults(options.faults);
238
280
  this.faults = table;
@@ -375,6 +417,7 @@ class HmSim {
375
417
  this.newDevicesScript[iface] = {delay: 0, ...this.options.newDevices[iface]};
376
418
  }
377
419
  this.reindex(iface);
420
+ this.seedValues(iface);
378
421
  this.setDefaultValues(iface);
379
422
  if (!this.ifaces.includes(iface)) {
380
423
  this.ifaces.push(iface);
@@ -389,6 +432,22 @@ class HmSim {
389
432
  this.index[iface] = index;
390
433
  }
391
434
 
435
+ /** The VALUES of `options.paramsets`, before setDefaultValues fills in the rest. */
436
+ seedValues(iface) {
437
+ for (const [address, paramsets] of Object.entries(this.seededParamsets[iface] || {})) {
438
+ if (paramsets.VALUES && this.index[iface].has(address)) {
439
+ const state = this.stateOf(iface, address);
440
+ state.VALUES = {...state.VALUES, ...paramsets.VALUES};
441
+ }
442
+ }
443
+ }
444
+
445
+ /** What `options.paramsets` gives a paramset to start with, or `{}`. */
446
+ seededParamset(iface, address, key) {
447
+ const paramsets = this.seededParamsets[iface] && this.seededParamsets[iface][address];
448
+ return (paramsets && paramsets[key]) || {};
449
+ }
450
+
392
451
  setDefaultValues(iface) {
393
452
  for (const device of this.devices[iface].devices) {
394
453
  if (!device.PARENT_TYPE || !device.PARAMSETS || !device.PARAMSETS.includes('VALUES')) {
@@ -568,14 +627,17 @@ class HmSim {
568
627
  const description = this.requireParamsetDescription(iface, device, 'LINK');
569
628
  state.LINKS = state.LINKS || {};
570
629
  if (!state.LINKS[paramset]) {
571
- state.LINKS[paramset] = defaultParamset(description);
630
+ state.LINKS[paramset] = {
631
+ ...defaultParamset(description),
632
+ ...this.seededParamset(iface, address, paramset),
633
+ };
572
634
  }
573
635
  return {device, description, values: state.LINKS[paramset]};
574
636
  }
575
637
 
576
638
  const description = this.requireParamsetDescription(iface, device, paramset);
577
639
  if (!state[paramset]) {
578
- state[paramset] = defaultParamset(description);
640
+ state[paramset] = {...defaultParamset(description), ...this.seededParamset(iface, address, paramset)};
579
641
  }
580
642
  return {device, description, values: state[paramset]};
581
643
  }
@@ -606,6 +668,56 @@ class HmSim {
606
668
  return this.getParamsetDescription(iface, device, paramset);
607
669
  }
608
670
 
671
+ /**
672
+ * The RPC `getParamsetId(address, paramset)`: the CCU's own name of a paramset, by which its WebUI
673
+ * picks a form (`easymodes/<id>.tcl`). Measured on firmware 3.89.11:
674
+ *
675
+ * - hmipserver builds it: the device type, the channel index and the key, lower case and joined
676
+ * by `_` - `hmipw-drbl4_2_link`, `hmipw-drbl4_master` for the device - for every key it is
677
+ * given, a peer address too (`hmipw-dri16_1_00000000000102:1`), `null` for none.
678
+ * - rfd and hs485d answer the `id` the device file gives the paramset (`cc_tc_ch_wsr_link`,
679
+ * `hmw_input_ch_master`; `data/paramset-ids.json`): MASTER for a device address and MASTER,
680
+ * VALUES, LINK for a channel; any other key on a channel is taken as a peer address and answers
681
+ * the LINK id. A paramset the address does not have is `-3 Unknown paramset`
682
+ * (`unknownParamsetDescription`). A device type the bundled ids lack answers `''`.
683
+ *
684
+ * An unknown address is `unknownInstance` on all of them. `options.paramsetIds` answers first.
685
+ * @param {string} iface
686
+ * @param {Array} params [address, paramset]
687
+ */
688
+ getParamsetId(iface, params) {
689
+ const [address, key] = params || [];
690
+ const device = this.requireDevice(iface, address);
691
+ const overrides = this.paramsetIdOverrides[iface] && this.paramsetIdOverrides[iface][address];
692
+ if (overrides && typeof key === 'string' && overrides[key] !== undefined) {
693
+ return overrides[key];
694
+ }
695
+ const parent = (device.PARENT ? this.index[iface].get(device.PARENT) : device) || {};
696
+
697
+ if (!BIDCOS_INTERFACES.has(iface)) {
698
+ const name = key === undefined || key === null ? 'null' : String(key).toLowerCase();
699
+ const type = String(parent.TYPE || '').toLowerCase();
700
+ return device.PARENT ? `${type}_${device.INDEX}_${name}` : `${type}_${name}`;
701
+ }
702
+
703
+ const name = !device.PARENT || ['MASTER', 'VALUES', 'LINK'].includes(key) ? key : 'LINK';
704
+ const has = Array.isArray(device.PARAMSETS) ? device.PARAMSETS.includes(name) : name === 'MASTER';
705
+ if (!has || (!device.PARENT && name !== 'MASTER')) {
706
+ throw this.fault('unknownParamsetDescription', `${address}/${key}`);
707
+ }
708
+ const ids = bidcosParamsetIds(PARAMSET_PREFIX[iface], parent.TYPE, parent.VERSION);
709
+ if (!ids) {
710
+ return '';
711
+ }
712
+ if (!device.PARENT) {
713
+ return ids.device.MASTER || '';
714
+ }
715
+ const channel = ids.channels.find(
716
+ (entry) => device.INDEX >= entry.index && device.INDEX < entry.index + entry.count,
717
+ );
718
+ return (channel && channel[name]) || '';
719
+ }
720
+
609
721
  getValue(iface, address, datapoint) {
610
722
  const {description, values} = this.storedParamset(iface, address, 'VALUES');
611
723
  if (!description[datapoint]) {
@@ -2606,6 +2718,7 @@ class HmSim {
2606
2718
  },
2607
2719
  getValue: (iface, params) => this.getValue(iface, params[0], params[1]),
2608
2720
  getParamset: (iface, params) => this.getParamset(iface, params[0], params[1]),
2721
+ getParamsetId: (iface, params) => this.getParamsetId(iface, params),
2609
2722
  putParamset: (iface, params) => {
2610
2723
  this.requireReachable(iface, params[0]);
2611
2724
  return this.putParamset(iface, params[0], params[1], params[2]);
@@ -3115,7 +3228,11 @@ class HmSim {
3115
3228
  const state = this.stateOf(iface, address);
3116
3229
  state.VALUES[datapoint] = casted;
3117
3230
 
3118
- if (parameter.OPERATIONS & 4) {
3231
+ // a client's acknowledgement of a sticky datapoint: the value goes, and with
3232
+ // stickyWriteEvents false nobody is told (a strict write is one that came over RPC)
3233
+ const silent = strict && parameter.FLAGS & STICKY_FLAG && !this.interfaceOption(iface, 'stickyWriteEvents');
3234
+
3235
+ if (parameter.OPERATIONS & 4 && !silent) {
3119
3236
  const events =
3120
3237
  parameter.TYPE === 'ACTION'
3121
3238
  ? [[address, datapoint, casted]]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm-simulator",
3
- "version": "1.4.0",
3
+ "version": "1.5.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
@@ -142,6 +142,8 @@ declare namespace HmSim {
142
142
  pingDelay?: number;
143
143
  startDelay?: number;
144
144
  unreachWrites?: 'accept' | 'fault';
145
+ /** `false`: a client's write to a sticky datapoint (FLAGS & 16) sends no event, as observed on rfd */
146
+ stickyWriteEvents?: boolean;
145
147
  firmwareUpdateDelay?: number;
146
148
  /** what `getVersion` answers */
147
149
  version?: string;
@@ -183,6 +185,13 @@ declare namespace HmSim {
183
185
  defaultRssi?: number;
184
186
  /** `{<iface>: {<address>: {<key>: value}}}`, what `getMetadata` answers before any `setMetadata` */
185
187
  metadata?: Record<string, Record<string, Record<string, unknown>>>;
188
+ /**
189
+ * `{<iface>: {<address>: {<MASTER|VALUES|SERVICE|peer address>: {<parameter>: value}}}}`: stored
190
+ * values a paramset starts with, on top of its description's defaults
191
+ */
192
+ paramsets?: Record<string, Record<string, Record<string, Record<string, unknown>>>>;
193
+ /** `{<iface>: {<address>: {<paramset>: id}}}`, what `getParamsetId` answers before the bundled ids */
194
+ paramsetIds?: Record<string, Record<string, Record<string, string>>>;
186
195
  tls?: TlsOption;
187
196
  auth?: BasicAuth;
188
197
  }
@@ -500,6 +509,8 @@ declare class HmSim {
500
509
  ): void;
501
510
  getLinks(iface: string, params?: unknown[]): Array<HmSim.Link & Record<string, unknown>>;
502
511
  getLinkPeers(iface: string, params: unknown[]): string[];
512
+ /** `getParamsetId(address, paramset)`: hmipserver's built id, or the BidCos device file's */
513
+ getParamsetId(iface: string, params: [address: string, paramset?: string]): string;
503
514
  deleteDevice(iface: string, address: string, flags?: number): '';
504
515
  replaceDevice(iface: string, oldAddress: string, newAddress: string): true;
505
516
  getKeyMismatchDevice(iface: string, params?: [reset?: boolean]): string;