hm-simulator 0.1.1 → 1.0.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 ADDED
@@ -0,0 +1,1751 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const EventEmitter = require('node:events');
6
+
7
+ const binrpc = require('binrpc');
8
+ const xmlrpc = require('homematic-xmlrpc');
9
+
10
+ const {createFaults} = require('./faults.js');
11
+ const {
12
+ WRITEABLE,
13
+ castValue,
14
+ defaultValue,
15
+ defaultParamset,
16
+ hmipStoredValue,
17
+ bidcosStoredValue,
18
+ } = require('./validate.js');
19
+ const {createBinrpcServer, createXmlrpcServer} = require('./servers.js');
20
+ const {resolveTls} = require('./tls.js');
21
+ const RegaSim = require('./rega.js');
22
+ const METHOD_HELP = require('./method-help.js');
23
+
24
+ const NOOP_LOG = {
25
+ debug() {},
26
+ info() {},
27
+ warn() {},
28
+ error() {},
29
+ };
30
+
31
+ /** interface key -> the name used in the keys of data/paramset-descriptions.json */
32
+ const PARAMSET_PREFIX = {
33
+ rfd: 'BidCos-RF',
34
+ wired: 'BidCos-Wired',
35
+ hmip: 'HmIP-RF',
36
+ virtual: 'VirtualDevices',
37
+ cuxd: 'CUxD',
38
+ };
39
+
40
+ /** paramset keys that are not the address of a linked peer channel */
41
+ const STANDARD_PARAMSETS = new Set(['MASTER', 'VALUES', 'SERVICE']);
42
+
43
+ /** FLAGS bit of a datapoint that is a service message (1 visible, 2 internal, 4 transform, 8 service) */
44
+ const SERVICE_FLAG = 8;
45
+
46
+ /**
47
+ * Per interface behaviour, overridable with the `interfaces` option.
48
+ *
49
+ * configPendingMode
50
+ * 'hmip' - what hmipserver 3.89.8 really does, measured for Homematic Manager task 6 (see
51
+ * putMasterHmip): everything is stored before it is checked, an unknown parameter
52
+ * poisons the channel for good, a wrong type raises a sticky CONFIG_PENDING, a
53
+ * number outside MIN..MAX is accepted without a word. The default for `hmip`.
54
+ * 'bidcos' - what rfd 3.89.8 really does (see putMasterBidcos): no fault ever, unknown
55
+ * parameters dropped, values clamped or ignored, CONFIG_PENDING while the
56
+ * configuration is queued for the device. The default for `rfd` and `wired`.
57
+ * 'strict' - an invalid putParamset MASTER is answered with a fault and nothing is written.
58
+ * The hypothesis of hm-simulator 1.0 before the measurement; still the default for
59
+ * the interfaces that were not measured, and the strictest thing to test against.
60
+ * 'pending' - the write is accepted, the valid parameters are stored, and CONFIG_PENDING is
61
+ * raised on the device's :0 channel and stays until a valid full MASTER write or
62
+ * clearConfigCache. The other hypothesis of 1.0; kept so both can still be tested.
63
+ * configPendingOnWrite
64
+ * raise CONFIG_PENDING for every accepted MASTER write, the way a BidCos battery device queues
65
+ * the configuration until it wakes up. Clears after configPendingDelay milliseconds. Implied by
66
+ * 'bidcos', which raises it for every write that changes something.
67
+ */
68
+ const INTERFACE_DEFAULTS = {
69
+ configPendingMode: 'strict',
70
+ configPendingOnWrite: false,
71
+ configPendingDelay: 0,
72
+ // rfd answers the empty string instead of an empty array when it has no service message; the
73
+ // real shape, off by default because it breaks every consumer that assumes an array
74
+ serviceMessagesEmptyAsString: false,
75
+ };
76
+
77
+ /**
78
+ * Per interface defaults where the lab measured a behaviour (task 6). Anything not listed keeps
79
+ * the value from INTERFACE_DEFAULTS.
80
+ */
81
+ const MEASURED_INTERFACE_DEFAULTS = {
82
+ hmip: {configPendingMode: 'hmip'},
83
+ rfd: {configPendingMode: 'bidcos'},
84
+ wired: {configPendingMode: 'bidcos'},
85
+ };
86
+
87
+ /** what listBidcosInterfaces answers when the `bidcosInterfaces` option does not say otherwise */
88
+ const DEFAULT_BIDCOS_INTERFACES = {
89
+ rfd: [
90
+ {
91
+ ADDRESS: 'OEQ0123456',
92
+ DESCRIPTION: 'HM-MOD-RPI-PCB',
93
+ CONNECTED: true,
94
+ DEFAULT: true,
95
+ FIRMWARE_VERSION: '2.8.6',
96
+ TYPE: 'CCU2',
97
+ DUTY_CYCLE: 0,
98
+ },
99
+ ],
100
+ wired: [
101
+ {
102
+ ADDRESS: 'HMW-LGW-1',
103
+ DESCRIPTION: 'HMW-LGW',
104
+ CONNECTED: true,
105
+ DEFAULT: true,
106
+ FIRMWARE_VERSION: '1.1.2',
107
+ TYPE: 'HMW_LGW',
108
+ },
109
+ ],
110
+ hmip: [
111
+ {
112
+ ADDRESS: 'XEQ0123456',
113
+ DESCRIPTION: 'eQ-3 HmIP-RFUSB',
114
+ CONNECTED: true,
115
+ DEFAULT: true,
116
+ FIRMWARE_VERSION: '1.4.6',
117
+ TYPE: 'HMIP_CCU',
118
+ DUTY_CYCLE: 0,
119
+ },
120
+ ],
121
+ };
122
+
123
+ /** True when a MASTER write covers every writeable parameter of the description. */
124
+ function isFullMasterWrite(description, set) {
125
+ for (const [name, parameter] of Object.entries(description)) {
126
+ if (parameter.OPERATIONS & 2 && set[name] === undefined) {
127
+ return false;
128
+ }
129
+ }
130
+ return true;
131
+ }
132
+
133
+ function shortenParams(params) {
134
+ if (!params) {
135
+ return;
136
+ }
137
+
138
+ const string = JSON.stringify(params);
139
+ return string.length > 77 ? string.slice(0, 77) + '...' : string;
140
+ }
141
+
142
+ /**
143
+ * Simulates the RPC interface processes and the ReGa of a Homematic CCU.
144
+ *
145
+ * @param {object} [options]
146
+ * @param {object} [options.log] object with debug/info/warn/error methods
147
+ * @param {object} [options.devices] {<iface>: {devices: []}} - device descriptions per interface
148
+ * @param {object} [options.config] listenAddress, binrpcListenPort, xmlrpcListenPort
149
+ * @param {string} [options.behaviorPath] directory with behaviour scripts
150
+ * @param {object} [options.rega] ReGa mock options, see lib/rega.js - omit to not start it
151
+ * @param {object} [options.paramsetDescriptions] replaces the bundled descriptions
152
+ * @param {object} [options.faults] overrides for the fault table, see lib/faults.js
153
+ */
154
+ class HmSim {
155
+ constructor(options = {}) {
156
+ const log = options.log || NOOP_LOG;
157
+ this.log = log;
158
+ this.options = options;
159
+
160
+ const config = options.config || {};
161
+ this.config = config;
162
+
163
+ this.devices = options.devices || {rfd: {devices: []}, hmip: {devices: []}};
164
+ this.paramsetDescriptions = options.paramsetDescriptions || require('../data/paramset-descriptions.json');
165
+
166
+ const {table, fault} = createFaults(options.faults);
167
+ this.faults = table;
168
+ this.fault = fault;
169
+
170
+ /** connected logic layers per interface: {<iface>: {<host:port>: client}} */
171
+ this.clients = {};
172
+ /** datapoint state per interface: {<iface>: {<address>: {VALUES: {}}}} */
173
+ this.values = {sysvars: {}, programs: {}};
174
+ /** device index per interface: {<iface>: Map<address, description>} */
175
+ this.index = {};
176
+ /** every putParamset the simulator accepted, see getWriteLog() */
177
+ this.writeLog = [];
178
+ /** links per interface: {<iface>: [{SENDER, RECEIVER, FLAGS, NAME, DESCRIPTION}]} */
179
+ this.links = {};
180
+ /** service messages raised through the scenario API: {<iface>: [[address, name, value]]} */
181
+ this.serviceMessages = {};
182
+ /** end of the install mode per interface, as an epoch timestamp */
183
+ this.installMode = {};
184
+ /** devices that appear when the install mode is switched on, see scriptNewDevices() */
185
+ this.newDevicesScript = {};
186
+ /** every reportValueUsage the simulator was told about */
187
+ this.valueUsage = [];
188
+ /** every updateFirmware/installFirmware call */
189
+ this.firmwareUpdates = [];
190
+ /** pending timers, cleared by close() */
191
+ this.timers = new Set();
192
+ /** devices with a pending configuration: {<iface>: Map<deviceAddress, {sticky}>} */
193
+ this.configPending = {};
194
+ /** channels whose stored MASTER carries a parameter the device does not have */
195
+ this.poisonedChannels = {};
196
+
197
+ this.rpcMethods = this.buildRpcMethods();
198
+
199
+ // interfaces with a port but without a device list still need their state
200
+ if (config.virtualListenPort !== undefined && !this.devices.virtual) {
201
+ this.devices.virtual = {devices: []};
202
+ }
203
+ if (config.cuxdListenPort !== undefined && !this.devices.cuxd) {
204
+ this.devices.cuxd = {devices: []};
205
+ }
206
+
207
+ this.servers = {};
208
+ /** how to (re)create the server of an interface, see dropConnection() */
209
+ this.serverFactories = {};
210
+ this.ifaces = [];
211
+ /** promises that resolve once each server is accepting connections, see whenReady() */
212
+ this.ready = [];
213
+ /** the port each server actually listens on, filled in once it is listening */
214
+ this.ports = {};
215
+
216
+ for (const iface of Object.keys(this.devices)) {
217
+ this.initInterface(iface);
218
+ }
219
+
220
+ this.startServers();
221
+
222
+ this.api = new EventEmitter();
223
+ // a behaviour script is the *device* acting, not a client: it may report datapoints a
224
+ // client is not allowed to write (PRESS_SHORT, UNREACH, ...), and a bad script only logs
225
+ this.api.on('setValue', (iface, address, datapoint, value) =>
226
+ this.setValue(iface, address, datapoint, value, {internal: true}),
227
+ );
228
+
229
+ this.loadBehaviors(options.behaviorPath || path.join(__dirname, '..', 'behaviors'));
230
+
231
+ if (options.rega) {
232
+ // the ReGa mock inherits TLS and basic auth unless its own options say otherwise
233
+ this.regaSim = new RegaSim(
234
+ {
235
+ ...(this.tls ? {tls: this.tls} : {}),
236
+ ...(this.auth ? {auth: this.auth} : {}),
237
+ ...options.rega,
238
+ },
239
+ this.log,
240
+ );
241
+ this.ready.push(this.regaSim.ready);
242
+ }
243
+ }
244
+
245
+ /* ------------------------------------------------------------------ state */
246
+
247
+ initInterface(iface) {
248
+ if (!this.devices[iface]) {
249
+ this.devices[iface] = {devices: []};
250
+ }
251
+ if (!Array.isArray(this.devices[iface].devices)) {
252
+ this.devices[iface].devices = [];
253
+ }
254
+
255
+ this.clients[iface] = this.clients[iface] || {};
256
+ this.values[iface] = this.values[iface] || {};
257
+ this.links[iface] = this.links[iface] || (this.options.links && this.options.links[iface]) || [];
258
+ this.serviceMessages[iface] =
259
+ this.serviceMessages[iface] || (this.options.serviceMessages && this.options.serviceMessages[iface]) || [];
260
+ if (this.options.newDevices && this.options.newDevices[iface] && !this.newDevicesScript[iface]) {
261
+ this.newDevicesScript[iface] = {delay: 0, ...this.options.newDevices[iface]};
262
+ }
263
+ this.reindex(iface);
264
+ this.setDefaultValues(iface);
265
+ if (!this.ifaces.includes(iface)) {
266
+ this.ifaces.push(iface);
267
+ }
268
+ }
269
+
270
+ reindex(iface) {
271
+ const index = new Map();
272
+ for (const device of this.devices[iface].devices) {
273
+ index.set(device.ADDRESS, device);
274
+ }
275
+ this.index[iface] = index;
276
+ }
277
+
278
+ setDefaultValues(iface) {
279
+ for (const device of this.devices[iface].devices) {
280
+ if (!device.PARENT_TYPE || !device.PARAMSETS || !device.PARAMSETS.includes('VALUES')) {
281
+ continue;
282
+ }
283
+
284
+ const description = this.getParamsetDescription(iface, device, 'VALUES');
285
+ if (!description) {
286
+ continue;
287
+ }
288
+
289
+ if (!this.values[iface][device.ADDRESS]) {
290
+ this.values[iface][device.ADDRESS] = {VALUES: {}};
291
+ }
292
+
293
+ for (const [name, parameter] of Object.entries(description)) {
294
+ if (this.values[iface][device.ADDRESS].VALUES[name] !== undefined) {
295
+ continue;
296
+ }
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;
301
+ }
302
+ }
303
+ }
304
+
305
+ getDevice(iface, address) {
306
+ const device = this.index[iface] && this.index[iface].get(address);
307
+ if (!device) {
308
+ this.log.error(iface, 'unknown device', address);
309
+ return false;
310
+ }
311
+ return device;
312
+ }
313
+
314
+ /**
315
+ * Builds the key of the paramset description cache:
316
+ * `<interface>/<deviceType>/<firmware>/<version>/<channelType>/<paramset>`
317
+ */
318
+ paramsetName(iface, device, paramset) {
319
+ if (!device) {
320
+ return;
321
+ }
322
+
323
+ let channelType = '';
324
+ let parent = device;
325
+ if (device.PARENT) {
326
+ channelType = device.TYPE;
327
+ parent = this.index[iface] ? this.index[iface].get(device.PARENT) : undefined;
328
+ if (!parent) {
329
+ return;
330
+ }
331
+ }
332
+
333
+ const prefix = PARAMSET_PREFIX[iface] || iface;
334
+ return [prefix, parent.TYPE, parent.FIRMWARE, parent.VERSION, channelType, paramset].join('/');
335
+ }
336
+
337
+ getParamsetDescription(iface, device, paramset) {
338
+ if (typeof device === 'string') {
339
+ device = this.index[iface] ? this.index[iface].get(device) : undefined;
340
+ }
341
+
342
+ const name = this.paramsetName(iface, device, paramset);
343
+ return name === undefined ? undefined : this.paramsetDescriptions[name];
344
+ }
345
+
346
+ /* ------------------------------------------------------------------ paramsets */
347
+
348
+ /** The device or channel, or a fault if this interface does not know the address. */
349
+ requireDevice(iface, address) {
350
+ const device = this.index[iface] && this.index[iface].get(address);
351
+ if (!device) {
352
+ throw this.fault('unknownInstance', `${iface}/${address}`);
353
+ }
354
+ return device;
355
+ }
356
+
357
+ requireParamsetDescription(iface, device, paramset) {
358
+ const description = this.getParamsetDescription(iface, device, paramset);
359
+ if (!description) {
360
+ throw this.fault('unknownParamset', `${device.ADDRESS}/${paramset}`);
361
+ }
362
+ return description;
363
+ }
364
+
365
+ /** Everything the simulator remembers about one device or channel. */
366
+ stateOf(iface, address) {
367
+ if (!this.values[iface][address]) {
368
+ this.values[iface][address] = {VALUES: {}};
369
+ }
370
+ return this.values[iface][address];
371
+ }
372
+
373
+ /**
374
+ * The stored values of one paramset, filled with the defaults of its description on first use.
375
+ * @param {string} iface
376
+ * @param {string} address
377
+ * @param {string} paramset MASTER, VALUES, SERVICE or the address of a linked peer channel
378
+ */
379
+ storedParamset(iface, address, paramset) {
380
+ const device = this.requireDevice(iface, address);
381
+ const state = this.stateOf(iface, address);
382
+
383
+ if (!STANDARD_PARAMSETS.has(paramset)) {
384
+ // link paramsets are addressed by the peer channel's address
385
+ if (!this.index[iface].has(paramset) || !this.findLink(iface, address, paramset)) {
386
+ throw this.fault('unknownParamset', `${address}/${paramset}`);
387
+ }
388
+ const description = this.requireParamsetDescription(iface, device, 'LINK');
389
+ state.LINKS = state.LINKS || {};
390
+ if (!state.LINKS[paramset]) {
391
+ state.LINKS[paramset] = defaultParamset(description);
392
+ }
393
+ return {device, description, values: state.LINKS[paramset]};
394
+ }
395
+
396
+ const description = this.requireParamsetDescription(iface, device, paramset);
397
+ if (!state[paramset]) {
398
+ state[paramset] = defaultParamset(description);
399
+ }
400
+ return {device, description, values: state[paramset]};
401
+ }
402
+
403
+ getDeviceDescription(iface, address) {
404
+ return this.requireDevice(iface, address);
405
+ }
406
+
407
+ getValue(iface, address, datapoint) {
408
+ const {description, values} = this.storedParamset(iface, address, 'VALUES');
409
+ if (!description[datapoint]) {
410
+ throw this.fault('unknownParameter', `${address}/${datapoint}`);
411
+ }
412
+ return values[datapoint] === undefined ? defaultValue(description[datapoint]) : values[datapoint];
413
+ }
414
+
415
+ /**
416
+ * @param {string} iface
417
+ * @param {string} address device or channel address
418
+ * @param {string} key MASTER, VALUES, SERVICE or the address of a linked peer channel
419
+ */
420
+ getParamset(iface, address, key) {
421
+ if (typeof key !== 'string' || key === '') {
422
+ throw this.fault('invalidArguments', 'paramset key');
423
+ }
424
+ const {values} = this.storedParamset(iface, address, key);
425
+ return {...values};
426
+ }
427
+
428
+ /**
429
+ * @param {string} iface
430
+ * @param {string} address
431
+ * @param {string} key MASTER, VALUES, SERVICE or the address of a linked peer channel
432
+ * @param {object} set parameter name -> value
433
+ */
434
+ putParamset(iface, address, key, set) {
435
+ if (typeof key !== 'string' || key === '') {
436
+ throw this.fault('invalidArguments', 'paramset key');
437
+ }
438
+ if (!set || typeof set !== 'object' || Array.isArray(set)) {
439
+ throw this.fault('invalidArguments', 'paramset');
440
+ }
441
+
442
+ if (key === 'VALUES') {
443
+ const {description} = this.storedParamset(iface, address, 'VALUES');
444
+ for (const name of Object.keys(set)) {
445
+ castValue(description, name, set[name], this.fault);
446
+ }
447
+ for (const name of Object.keys(set)) {
448
+ this.setValue(iface, address, name, set[name], {strict: true});
449
+ }
450
+ return '';
451
+ }
452
+
453
+ const {description, values} = this.storedParamset(iface, address, key);
454
+ if (key === 'MASTER') {
455
+ return this.putMaster(iface, address, description, values, set);
456
+ }
457
+
458
+ const casted = {};
459
+ for (const name of Object.keys(set)) {
460
+ casted[name] = castValue(description, name, set[name], this.fault);
461
+ }
462
+ Object.assign(values, casted);
463
+ this.writeLog.push({iface, address, paramset: key, values: {...set}, ts: Date.now()});
464
+ return '';
465
+ }
466
+
467
+ /** The MASTER write, with the CONFIG_PENDING semantics of the interface, see setConfigPending. */
468
+ putMaster(iface, address, description, values, set) {
469
+ const mode = this.interfaceOption(iface, 'configPendingMode');
470
+ if (mode === 'hmip') {
471
+ return this.putMasterHmip(iface, address, description, values, set);
472
+ }
473
+ if (mode === 'bidcos') {
474
+ return this.putMasterBidcos(iface, address, description, values, set);
475
+ }
476
+
477
+ const casted = {};
478
+ const rejected = [];
479
+
480
+ for (const name of Object.keys(set)) {
481
+ try {
482
+ casted[name] = castValue(description, name, set[name], this.fault);
483
+ } catch (error) {
484
+ if (mode === 'strict') {
485
+ throw error;
486
+ }
487
+ rejected.push({name, value: set[name], faultCode: error.faultCode, reason: error.message});
488
+ }
489
+ }
490
+
491
+ Object.assign(values, casted);
492
+ this.writeLog.push({iface, address, paramset: 'MASTER', values: {...set}, rejected, ts: Date.now()});
493
+
494
+ if (rejected.length > 0) {
495
+ this.log.warn(iface, address, 'MASTER write with', rejected.length, 'rejected parameters');
496
+ this.setConfigPending(iface, address, {sticky: true});
497
+ } else if (mode === 'pending' && isFullMasterWrite(description, set)) {
498
+ this.clearConfigPending(iface, address);
499
+ } else if (this.interfaceOption(iface, 'configPendingOnWrite')) {
500
+ this.setConfigPending(iface, address, {sticky: false});
501
+ }
502
+
503
+ return '';
504
+ }
505
+
506
+ /**
507
+ * hmipserver's MASTER write, as measured on firmware 3.89.8.
508
+ *
509
+ * Everything in the struct is stored first, whether it belongs to the channel or not and
510
+ * whether its type fits or not. Only then is the resulting configuration checked, and the call
511
+ * faults when it cannot be transferred to the device. Three consequences, all measured:
512
+ *
513
+ * - a parameter the channel does not have is kept **for ever**: it cannot be overwritten,
514
+ * there is no method that removes it, and from then on every putParamset on this channel
515
+ * faults - including one with an empty struct;
516
+ * - a value of the wrong type is kept too, and raises a sticky CONFIG_PENDING on the device,
517
+ * which a valid full MASTER write clears again;
518
+ * - a number outside MIN..MAX is simply accepted. hmipserver does not range-check.
519
+ */
520
+ putMasterHmip(iface, address, description, values, set) {
521
+ const poisoned = (this.poisonedChannels[iface] = this.poisonedChannels[iface] || new Set());
522
+ const rejected = [];
523
+ let changedKnown = false;
524
+
525
+ for (const name of Object.keys(set)) {
526
+ const parameter = description[name];
527
+ if (!parameter) {
528
+ values[name] = set[name];
529
+ poisoned.add(address);
530
+ rejected.push({name, value: set[name], reason: 'not in the paramset description'});
531
+ continue;
532
+ }
533
+ if (!(parameter.OPERATIONS & WRITEABLE)) {
534
+ rejected.push({name, value: set[name], reason: 'not writeable'});
535
+ continue;
536
+ }
537
+ const stored = hmipStoredValue(parameter, set[name]);
538
+ if (values[name] !== stored.value) {
539
+ changedKnown = true;
540
+ }
541
+ values[name] = stored.value;
542
+ if (!stored.valid) {
543
+ rejected.push({name, value: set[name], reason: 'not valid for this parameter'});
544
+ }
545
+ }
546
+
547
+ this.writeLog.push({iface, address, paramset: 'MASTER', values: {...set}, rejected, ts: Date.now()});
548
+
549
+ const invalid = Object.keys(values).filter(
550
+ (name) => description[name] && !hmipStoredValue(description[name], values[name]).valid,
551
+ );
552
+ if (invalid.length > 0 && changedKnown) {
553
+ this.setConfigPending(iface, address, {sticky: true});
554
+ } else if (invalid.length === 0) {
555
+ this.clearConfigPending(iface, address);
556
+ }
557
+
558
+ if (invalid.length > 0 || poisoned.has(address)) {
559
+ this.log.warn(iface, address, 'MASTER configuration cannot be transferred to the device');
560
+ throw this.fault('invalidValue', address);
561
+ }
562
+ return '';
563
+ }
564
+
565
+ /**
566
+ * rfd's MASTER write, as measured on firmware 3.89.8.
567
+ *
568
+ * rfd never faults on a value: it drops a parameter the device does not have, ignores what it
569
+ * cannot use, clamps a number into MIN..MAX and coerces a string to a number - and answers
570
+ * `ok` to all of it. CONFIG_PENDING means only "a configuration is queued for the device" and
571
+ * clears when the device takes it, which on a battery device is when it next wakes up
572
+ * (`configPendingDelay` stands in for that).
573
+ */
574
+ putMasterBidcos(iface, address, description, values, set) {
575
+ const rejected = [];
576
+ let changed = false;
577
+
578
+ for (const name of Object.keys(set)) {
579
+ const parameter = description[name];
580
+ if (!parameter) {
581
+ rejected.push({name, value: set[name], reason: 'not in the paramset description, dropped'});
582
+ continue;
583
+ }
584
+ if (!(parameter.OPERATIONS & WRITEABLE)) {
585
+ rejected.push({name, value: set[name], reason: 'not writeable, dropped'});
586
+ continue;
587
+ }
588
+ const stored = bidcosStoredValue(parameter, set[name]);
589
+ if (stored === undefined) {
590
+ rejected.push({name, value: set[name], reason: 'not usable for this parameter, ignored'});
591
+ continue;
592
+ }
593
+ if (values[name] !== stored) {
594
+ values[name] = stored;
595
+ changed = true;
596
+ }
597
+ }
598
+
599
+ this.writeLog.push({iface, address, paramset: 'MASTER', values: {...set}, rejected, ts: Date.now()});
600
+
601
+ if (changed) {
602
+ this.setConfigPending(iface, address, {sticky: false});
603
+ }
604
+ return '';
605
+ }
606
+
607
+ /* ------------------------------------------------------------------ CONFIG_PENDING */
608
+
609
+ interfaceOption(iface, name) {
610
+ const configured = this.options.interfaces && this.options.interfaces[iface];
611
+ if (configured && configured[name] !== undefined) {
612
+ return configured[name];
613
+ }
614
+ const measured = MEASURED_INTERFACE_DEFAULTS[iface];
615
+ if (measured && measured[name] !== undefined) {
616
+ return measured[name];
617
+ }
618
+ return INTERFACE_DEFAULTS[name];
619
+ }
620
+
621
+ /** The device an address belongs to. */
622
+ deviceAddressOf(iface, address) {
623
+ const device = this.requireDevice(iface, address);
624
+ return device.PARENT || device.ADDRESS;
625
+ }
626
+
627
+ /**
628
+ * Raises CONFIG_PENDING on the device's :0 channel.
629
+ *
630
+ * `sticky` is the case Homematic Manager issue #98 describes: the interface process kept a
631
+ * configuration the device never acknowledged, and the flag stays until a valid full MASTER
632
+ * write or clearConfigCache. Without `sticky` it is the ordinary BidCos case - the config is
633
+ * queued until the battery device wakes up - and it clears itself after `configPendingDelay`.
634
+ */
635
+ setConfigPending(iface, address, {sticky = false} = {}) {
636
+ const deviceAddress = this.deviceAddressOf(iface, address);
637
+ const pending = (this.configPending[iface] = this.configPending[iface] || new Map());
638
+ const wasSticky = (pending.get(deviceAddress) || {}).sticky === true;
639
+ pending.set(deviceAddress, {sticky: sticky || wasSticky});
640
+
641
+ this.writeConfigPending(iface, deviceAddress, true);
642
+
643
+ if (!sticky && !wasSticky) {
644
+ const delay = this.interfaceOption(iface, 'configPendingDelay');
645
+ this.timer(() => {
646
+ const entry = this.configPending[iface] && this.configPending[iface].get(deviceAddress);
647
+ if (entry && !entry.sticky) {
648
+ this.clearConfigPending(iface, deviceAddress);
649
+ }
650
+ }, delay);
651
+ }
652
+ }
653
+
654
+ clearConfigPending(iface, address) {
655
+ // the delayed clear of a BidCos write can arrive after the device was deleted
656
+ if (!this.index[iface] || !this.index[iface].has(address)) {
657
+ if (this.configPending[iface]) {
658
+ this.configPending[iface].delete(address);
659
+ }
660
+ return;
661
+ }
662
+ const deviceAddress = this.deviceAddressOf(iface, address);
663
+ if (this.configPending[iface]) {
664
+ this.configPending[iface].delete(deviceAddress);
665
+ }
666
+ this.writeConfigPending(iface, deviceAddress, false);
667
+ }
668
+
669
+ writeConfigPending(iface, deviceAddress, value) {
670
+ const maintenance = `${deviceAddress}:0`;
671
+ const description = this.getParamsetDescription(iface, maintenance, 'VALUES');
672
+ if (description && description.CONFIG_PENDING) {
673
+ this.setValue(iface, maintenance, 'CONFIG_PENDING', value, {internal: true});
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Device addresses with a pending configuration.
679
+ * @param {string} iface
680
+ * @returns {Array<{address: string, sticky: boolean}>}
681
+ */
682
+ getConfigPending(iface) {
683
+ return [...(this.configPending[iface] || new Map())].map(([address, entry]) => ({
684
+ address,
685
+ sticky: Boolean(entry.sticky),
686
+ }));
687
+ }
688
+
689
+ /**
690
+ * Channels whose stored MASTER carries a parameter their description does not have, in the
691
+ * 'hmip' CONFIG_PENDING mode. Every putParamset on such a channel faults, and nothing but
692
+ * deleting and pairing the device again clears it - which is what `removeDevice` does here.
693
+ * @param {string} iface
694
+ * @returns {string[]} channel addresses
695
+ */
696
+ getPoisonedChannels(iface) {
697
+ return [...(this.poisonedChannels[iface] || new Set())];
698
+ }
699
+
700
+ methodHelp(name) {
701
+ return METHOD_HELP[name] || '';
702
+ }
703
+
704
+ /* ------------------------------------------------------------------ links */
705
+
706
+ findLink(iface, address, peer) {
707
+ return (this.links[iface] || []).find(
708
+ (link) =>
709
+ (link.SENDER === address && link.RECEIVER === peer) ||
710
+ (link.SENDER === peer && link.RECEIVER === address),
711
+ );
712
+ }
713
+
714
+ /** Channel addresses of a device, or the address itself if it is already a channel. */
715
+ channelsOf(iface, address) {
716
+ const device = this.requireDevice(iface, address);
717
+ return device.PARENT ? [address] : device.CHILDREN || [];
718
+ }
719
+
720
+ /**
721
+ * @param {string} iface
722
+ * @param {Array} params [address, flags] - without an address all links of the interface
723
+ * @returns {Array} link structs
724
+ */
725
+ getLinks(iface, params) {
726
+ const [address, flags = 0] = params || [];
727
+ let links = this.links[iface] || [];
728
+
729
+ if (address) {
730
+ const addresses = new Set(this.channelsOf(iface, address));
731
+ links = links.filter((link) => addresses.has(link.SENDER) || addresses.has(link.RECEIVER));
732
+ }
733
+
734
+ return links.map((link) => this.linkStruct(iface, link, flags));
735
+ }
736
+
737
+ linkStruct(iface, link, flags) {
738
+ const result = {
739
+ SENDER: link.SENDER,
740
+ RECEIVER: link.RECEIVER,
741
+ FLAGS: link.FLAGS || 0,
742
+ NAME: link.NAME || '',
743
+ DESCRIPTION: link.DESCRIPTION || '',
744
+ };
745
+
746
+ // flags & 2: the link paramsets, flags & 4: the descriptions of both channels
747
+ if (flags & 2) {
748
+ result.SENDER_PARAMSET = this.linkParamsetOrEmpty(iface, link.SENDER, link.RECEIVER);
749
+ result.RECEIVER_PARAMSET = this.linkParamsetOrEmpty(iface, link.RECEIVER, link.SENDER);
750
+ }
751
+ if (flags & 4) {
752
+ result.SENDER_DESCRIPTION = this.index[iface].get(link.SENDER) || {};
753
+ result.RECEIVER_DESCRIPTION = this.index[iface].get(link.RECEIVER) || {};
754
+ }
755
+
756
+ return result;
757
+ }
758
+
759
+ linkParamsetOrEmpty(iface, address, peer) {
760
+ try {
761
+ return {...this.storedParamset(iface, address, peer).values};
762
+ } catch {
763
+ return {};
764
+ }
765
+ }
766
+
767
+ getLinkPeers(iface, params) {
768
+ const addresses = new Set(this.channelsOf(iface, params[0]));
769
+ const peers = [];
770
+ for (const link of this.links[iface] || []) {
771
+ if (addresses.has(link.SENDER)) {
772
+ peers.push(link.RECEIVER);
773
+ } else if (addresses.has(link.RECEIVER)) {
774
+ peers.push(link.SENDER);
775
+ }
776
+ }
777
+ return peers;
778
+ }
779
+
780
+ addLink(iface, params) {
781
+ const [sender, receiver, name = '', description = ''] = params || [];
782
+ for (const address of [sender, receiver]) {
783
+ const device = this.requireDevice(iface, address);
784
+ if (device.PARAMSETS && !device.PARAMSETS.includes('LINK')) {
785
+ throw this.fault('notSupported', `${address} has no LINK paramset`);
786
+ }
787
+ }
788
+
789
+ this.links[iface] = this.links[iface] || [];
790
+ const existing = this.links[iface].find((link) => link.SENDER === sender && link.RECEIVER === receiver);
791
+ if (existing) {
792
+ existing.NAME = name;
793
+ existing.DESCRIPTION = description;
794
+ return '';
795
+ }
796
+
797
+ this.links[iface].push({SENDER: sender, RECEIVER: receiver, FLAGS: 0, NAME: name, DESCRIPTION: description});
798
+ this.log.info(iface, 'link added', sender, '->', receiver);
799
+ return '';
800
+ }
801
+
802
+ requireLink(iface, sender, receiver) {
803
+ const link = (this.links[iface] || []).find((item) => item.SENDER === sender && item.RECEIVER === receiver);
804
+ if (!link) {
805
+ throw this.fault('unknownLink', `${sender} -> ${receiver}`);
806
+ }
807
+ return link;
808
+ }
809
+
810
+ removeLink(iface, params) {
811
+ const [sender, receiver] = params || [];
812
+ this.requireLink(iface, sender, receiver);
813
+ this.links[iface] = this.links[iface].filter((link) => !(link.SENDER === sender && link.RECEIVER === receiver));
814
+
815
+ for (const [address, peer] of [
816
+ [sender, receiver],
817
+ [receiver, sender],
818
+ ]) {
819
+ const state = this.values[iface][address];
820
+ if (state && state.LINKS) {
821
+ delete state.LINKS[peer];
822
+ }
823
+ }
824
+
825
+ this.log.info(iface, 'link removed', sender, '->', receiver);
826
+ return '';
827
+ }
828
+
829
+ getLinkInfo(iface, params) {
830
+ const link = this.requireLink(iface, params[0], params[1]);
831
+ return {NAME: link.NAME || '', DESCRIPTION: link.DESCRIPTION || ''};
832
+ }
833
+
834
+ setLinkInfo(iface, params) {
835
+ const [sender, receiver, name = '', description = ''] = params || [];
836
+ const link = this.requireLink(iface, sender, receiver);
837
+ link.NAME = name;
838
+ link.DESCRIPTION = description;
839
+ return '';
840
+ }
841
+
842
+ /**
843
+ * Applies a link paramset the way pressing the linked sender would.
844
+ *
845
+ * The real interface process makes the receiver run the action the link paramset describes;
846
+ * the simulator writes back every parameter of the link paramset that the receiver also has in
847
+ * VALUES (ON_TIME, STATE, LEVEL, ...). Everything else is only recorded in the write log.
848
+ */
849
+ activateLinkParamset(iface, params) {
850
+ const [address, peer, longPress = false] = params || [];
851
+ const {values} = this.storedParamset(iface, address, peer);
852
+ const {description: valuesDescription} = this.storedParamset(iface, address, 'VALUES');
853
+
854
+ for (const [name, value] of Object.entries(values)) {
855
+ if (valuesDescription[name] && valuesDescription[name].OPERATIONS & 2) {
856
+ this.setValue(iface, address, name, value);
857
+ }
858
+ }
859
+
860
+ this.writeLog.push({
861
+ iface,
862
+ address,
863
+ paramset: peer,
864
+ values: {...values},
865
+ method: 'activateLinkParamset',
866
+ longPress: Boolean(longPress),
867
+ ts: Date.now(),
868
+ });
869
+ return '';
870
+ }
871
+
872
+ /* ------------------------------------------------------------------ interface and service */
873
+
874
+ /** Devices (not channels) of an interface. */
875
+ deviceList(iface) {
876
+ return this.devices[iface].devices.filter((device) => !device.PARENT);
877
+ }
878
+
879
+ /**
880
+ * Receive levels: {<deviceAddress>: {<interfaceAddress>: [rssiPeer, rssiDevice]}}.
881
+ * The values come from RSSI_PEER/RSSI_DEVICE of the device's :0 channel when the description
882
+ * has them, otherwise from the `defaultRssi` option.
883
+ */
884
+ rssiInfo(iface) {
885
+ const interfaceAddress = this.listBidcosInterfaces(iface)[0].ADDRESS;
886
+ const result = {};
887
+ for (const device of this.deviceList(iface)) {
888
+ const maintenance = this.values[iface][`${device.ADDRESS}:0`];
889
+ const values = (maintenance && maintenance.VALUES) || {};
890
+ const fallback = this.options.defaultRssi === undefined ? -65 : this.options.defaultRssi;
891
+ result[device.ADDRESS] = {
892
+ [interfaceAddress]: [
893
+ values.RSSI_PEER === undefined ? fallback : values.RSSI_PEER,
894
+ values.RSSI_DEVICE === undefined ? fallback : values.RSSI_DEVICE,
895
+ ],
896
+ };
897
+ }
898
+ return result;
899
+ }
900
+
901
+ listBidcosInterfaces(iface) {
902
+ 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;
907
+ }
908
+
909
+ setBidcosInterface(iface, params) {
910
+ const [deviceId, interfaceId, roaming = false] = params || [];
911
+ const device = this.requireDevice(iface, deviceId);
912
+ const known = this.listBidcosInterfaces(iface).some((entry) => entry.ADDRESS === interfaceId);
913
+ if (!known) {
914
+ throw this.fault('unknownInstance', interfaceId);
915
+ }
916
+ device.INTERFACE = interfaceId;
917
+ device.ROAMING = roaming ? 1 : 0;
918
+ return '';
919
+ }
920
+
921
+ /**
922
+ * Pending service messages as [channelAddress, datapoint, value] triples.
923
+ *
924
+ * Everything that is set and has the SERVICE flag (FLAGS & 8) in its VALUES description counts,
925
+ * plus what was raised through the scenario API.
926
+ */
927
+ getServiceMessages(iface) {
928
+ const messages = [];
929
+ for (const address of Object.keys(this.values[iface])) {
930
+ const description = this.getParamsetDescription(iface, address, 'VALUES');
931
+ if (!description) {
932
+ continue;
933
+ }
934
+ for (const [name, value] of Object.entries(this.values[iface][address].VALUES || {})) {
935
+ const parameter = description[name];
936
+ if (parameter && parameter.FLAGS & SERVICE_FLAG && value) {
937
+ messages.push([address, name, value]);
938
+ }
939
+ }
940
+ }
941
+
942
+ for (const message of this.serviceMessages[iface] || []) {
943
+ if (!messages.some((entry) => entry[0] === message[0] && entry[1] === message[1])) {
944
+ messages.push([...message]);
945
+ }
946
+ }
947
+
948
+ // rfd answers the empty string, not an empty array, when there is nothing pending
949
+ // (measured for task 6). Off by default: it breaks everything that assumes an array,
950
+ // which is exactly why an application should be tested against it once.
951
+ if (messages.length === 0 && this.interfaceOption(iface, 'serviceMessagesEmptyAsString')) {
952
+ return '';
953
+ }
954
+
955
+ return messages;
956
+ }
957
+
958
+ /**
959
+ * @param {string} iface
960
+ * @param {Array} params [on, time, mode] - mode 1 is "install mode with a specific device"
961
+ */
962
+ setInstallMode(iface, params) {
963
+ const [on, time = 60, mode = 1] = params || [];
964
+ if (!on) {
965
+ this.installMode[iface] = 0;
966
+ return '';
967
+ }
968
+
969
+ this.installMode[iface] = Date.now() + Number(time) * 1000;
970
+ this.log.info(iface, 'install mode on for', time, 'seconds, mode', mode);
971
+
972
+ const scripted = this.newDevicesScript[iface];
973
+ if (scripted) {
974
+ delete this.newDevicesScript[iface];
975
+ this.timer(() => this.addDevices(iface, scripted.devices), scripted.delay);
976
+ }
977
+
978
+ return '';
979
+ }
980
+
981
+ /** Remaining seconds of the install mode, 0 when it is off. */
982
+ getInstallMode(iface) {
983
+ const until = this.installMode[iface] || 0;
984
+ return Math.max(0, Math.ceil((until - Date.now()) / 1000));
985
+ }
986
+
987
+ deleteDevice(iface, address, flags = 0) {
988
+ const device = this.requireDevice(iface, address);
989
+ if (device.PARENT) {
990
+ throw this.fault('notSupported', 'only devices can be deleted');
991
+ }
992
+
993
+ const addresses = [address, ...(device.CHILDREN || [])];
994
+ this.devices[iface].devices = this.devices[iface].devices.filter((entry) => !addresses.includes(entry.ADDRESS));
995
+ for (const entry of addresses) {
996
+ delete this.values[iface][entry];
997
+ // deleting the device is the only thing that removes a poisoned channel configuration
998
+ if (this.poisonedChannels[iface]) {
999
+ this.poisonedChannels[iface].delete(entry);
1000
+ }
1001
+ }
1002
+ if (this.configPending[iface]) {
1003
+ this.configPending[iface].delete(address);
1004
+ }
1005
+ this.links[iface] = (this.links[iface] || []).filter(
1006
+ (link) => !addresses.includes(link.SENDER) && !addresses.includes(link.RECEIVER),
1007
+ );
1008
+ this.reindex(iface);
1009
+ this.log.info(iface, 'device deleted', address, 'flags', flags);
1010
+ this.tellClients(iface, 'deleteDevices', addresses);
1011
+ return '';
1012
+ }
1013
+
1014
+ /**
1015
+ * Takes over the configuration of one device to another one. The CCU uses this after a device
1016
+ * was exchanged; the new device keeps the old one's paramsets and links, the old one is gone.
1017
+ */
1018
+ replaceDevice(iface, oldAddress, newAddress) {
1019
+ const oldDevice = this.requireDevice(iface, oldAddress);
1020
+ const newDevice = this.requireDevice(iface, newAddress);
1021
+ if (oldDevice.PARENT || newDevice.PARENT) {
1022
+ throw this.fault('notSupported', 'only devices can be replaced');
1023
+ }
1024
+ if (oldDevice.TYPE !== newDevice.TYPE) {
1025
+ throw this.fault('notSupported', `${oldDevice.TYPE} cannot be replaced by ${newDevice.TYPE}`);
1026
+ }
1027
+
1028
+ const oldChannels = [oldAddress, ...(oldDevice.CHILDREN || [])];
1029
+ const newChannels = [newAddress, ...(newDevice.CHILDREN || [])];
1030
+ for (const [index, from] of oldChannels.entries()) {
1031
+ const to = newChannels[index];
1032
+ if (to && this.values[iface][from]) {
1033
+ this.values[iface][to] = this.values[iface][from];
1034
+ }
1035
+ }
1036
+
1037
+ for (const link of this.links[iface] || []) {
1038
+ const senderIndex = oldChannels.indexOf(link.SENDER);
1039
+ const receiverIndex = oldChannels.indexOf(link.RECEIVER);
1040
+ if (senderIndex !== -1) {
1041
+ link.SENDER = newChannels[senderIndex];
1042
+ }
1043
+ if (receiverIndex !== -1) {
1044
+ link.RECEIVER = newChannels[receiverIndex];
1045
+ }
1046
+ }
1047
+
1048
+ this.deleteDevice(iface, oldAddress);
1049
+ return '';
1050
+ }
1051
+
1052
+ reportValueUsage(iface, params) {
1053
+ const [address, valueId, refCounter = 0] = params || [];
1054
+ this.requireDevice(iface, address);
1055
+ this.valueUsage.push({iface, address, valueId, refCounter, ts: Date.now()});
1056
+ return '';
1057
+ }
1058
+
1059
+ /**
1060
+ * Firmware update stub: the addresses are remembered (sim.firmwareUpdates) and the call
1061
+ * answers true. No device state changes - what a real update does to a device is out of scope.
1062
+ */
1063
+ updateFirmware(iface, params) {
1064
+ const addresses = Array.isArray(params[0]) ? params[0] : [params[0]];
1065
+ for (const address of addresses) {
1066
+ this.requireDevice(iface, address);
1067
+ }
1068
+ this.firmwareUpdates.push({iface, addresses, method: 'updateFirmware', ts: Date.now()});
1069
+ return true;
1070
+ }
1071
+
1072
+ installFirmware(iface, params) {
1073
+ const address = params[0];
1074
+ this.requireDevice(iface, address);
1075
+ this.firmwareUpdates.push({iface, addresses: [address], method: 'installFirmware', ts: Date.now()});
1076
+ return true;
1077
+ }
1078
+
1079
+ /**
1080
+ * `clearConfigCache`, `restoreConfigToDevice` and `determineParameter` are BidCos methods.
1081
+ * hmipserver lists them in `system.listMethods` and answers `-1 Generic error` to all three -
1082
+ * measured in the lab for task 6, and the reason a "repair configuration" action must not
1083
+ * offer them on an HmIP device.
1084
+ */
1085
+ requireBidcosMaintenance(iface, method) {
1086
+ if (this.interfaceOption(iface, 'configPendingMode') === 'hmip') {
1087
+ throw this.fault('notSupported', method);
1088
+ }
1089
+ }
1090
+
1091
+ /** Drops the interface process' cached configuration of a device. */
1092
+ clearConfigCache(iface, address) {
1093
+ this.requireBidcosMaintenance(iface, 'clearConfigCache');
1094
+ const device = this.requireDevice(iface, address);
1095
+ for (const channel of [address, ...(device.CHILDREN || [])]) {
1096
+ const state = this.values[iface][channel];
1097
+ if (state) {
1098
+ delete state.MASTER;
1099
+ }
1100
+ }
1101
+ this.clearConfigPending(iface, address);
1102
+ this.log.info(iface, 'config cache cleared', address);
1103
+ return '';
1104
+ }
1105
+
1106
+ /**
1107
+ * Writes the cached configuration back to the device. The stored MASTER values stay as they
1108
+ * are and a pending configuration is resolved - this is the BidCos recovery path.
1109
+ */
1110
+ restoreConfigToDevice(iface, address) {
1111
+ this.requireBidcosMaintenance(iface, 'restoreConfigToDevice');
1112
+ this.requireDevice(iface, address);
1113
+ this.clearConfigPending(iface, address);
1114
+ this.log.info(iface, 'config restored to device', address);
1115
+ return '';
1116
+ }
1117
+
1118
+ /** Re-reads one parameter from the device; the simulator has nothing to re-read. */
1119
+ determineParameter(iface, params) {
1120
+ this.requireBidcosMaintenance(iface, 'determineParameter');
1121
+ const [address, paramsetKey, parameterId] = params || [];
1122
+ const {description} = this.storedParamset(iface, address, paramsetKey);
1123
+ if (!description[parameterId]) {
1124
+ throw this.fault('unknownParameter', parameterId);
1125
+ }
1126
+ return '';
1127
+ }
1128
+
1129
+ /**
1130
+ * Adds devices at runtime and tells the connected logic layers about them.
1131
+ * @param {string} iface
1132
+ * @param {Array} list device and channel descriptions
1133
+ * @returns {Array} the descriptions that were not known yet
1134
+ */
1135
+ addDevices(iface, list) {
1136
+ const added = [];
1137
+ for (const device of list || []) {
1138
+ if (!this.index[iface].has(device.ADDRESS)) {
1139
+ this.devices[iface].devices.push(device);
1140
+ added.push(device);
1141
+ }
1142
+ }
1143
+
1144
+ if (added.length > 0) {
1145
+ this.reindex(iface);
1146
+ this.setDefaultValues(iface);
1147
+ this.log.info(iface, 'added', added.length, 'devices');
1148
+ this.tellClients(iface, 'newDevices', added);
1149
+ }
1150
+
1151
+ return added;
1152
+ }
1153
+
1154
+ /* ------------------------------------------------------------------ scenario api */
1155
+
1156
+ /**
1157
+ * Adds one device with its channels.
1158
+ * @param {string} iface
1159
+ * @param {...object} descriptions the device description and its channel descriptions
1160
+ * @returns {Array} the descriptions that were not known yet
1161
+ */
1162
+ addDevice(iface, ...descriptions) {
1163
+ return this.addDevices(iface, descriptions.flat());
1164
+ }
1165
+
1166
+ /**
1167
+ * Removes a device with its channels, values and links, like deleteDevice over RPC.
1168
+ * @param {string} iface
1169
+ * @param {string} address
1170
+ */
1171
+ removeDevice(iface, address) {
1172
+ return this.deleteDevice(iface, address);
1173
+ }
1174
+
1175
+ /**
1176
+ * Sends one event, the way a device reports a change.
1177
+ *
1178
+ * Unlike setValue this does not check the description: a test has to be able to send exactly
1179
+ * the event it wants, including values a client would never be allowed to write.
1180
+ *
1181
+ * @param {string} iface
1182
+ * @param {string} address channel address
1183
+ * @param {string} datapoint
1184
+ * @param {*} value
1185
+ */
1186
+ fireEvent(iface, address, datapoint, value) {
1187
+ const state = this.values[iface] && this.values[iface][address];
1188
+ if (state) {
1189
+ state.VALUES[datapoint] = value;
1190
+ }
1191
+ this.event(iface, [address, datapoint, value]);
1192
+ return value;
1193
+ }
1194
+
1195
+ /**
1196
+ * Drops everything that is connected to one interface: the registered logic layers are
1197
+ * forgotten and the server is restarted on the same port, so open sockets are reset. That is
1198
+ * what a client sees when an interface process restarts, and it has to re-init afterwards.
1199
+ *
1200
+ * @param {string} iface
1201
+ * @returns {Promise<void>} resolves once the server accepts connections again
1202
+ */
1203
+ async dropConnection(iface) {
1204
+ for (const key of Object.keys(this.clients[iface] || {})) {
1205
+ closeClient(this.clients[iface][key].client);
1206
+ delete this.clients[iface][key];
1207
+ }
1208
+
1209
+ const server = this.servers[iface];
1210
+ const factory = this.serverFactories[iface];
1211
+ if (!server || !factory) {
1212
+ return;
1213
+ }
1214
+
1215
+ const port = this.ports[iface];
1216
+ this.log.info(iface, 'dropping all connections');
1217
+ await server.close();
1218
+
1219
+ this.ready = [];
1220
+ this.servers[iface] = factory(port);
1221
+ this.exposeServer(iface);
1222
+ await this.whenReady();
1223
+ }
1224
+
1225
+ /**
1226
+ * Raises or clears a service message. If the channel has the datapoint in its VALUES
1227
+ * description the value is written as well, so that the logic layer sees the event too.
1228
+ * @param {string} iface
1229
+ * @param {string} address channel address
1230
+ * @param {string} datapoint e.g. UNREACH, STICKY_UNREACH, LOWBAT, ERROR_OVERHEAT
1231
+ * @param {*} [value=true] a falsy value clears the message
1232
+ */
1233
+ setServiceMessage(iface, address, datapoint, value = true) {
1234
+ const messages = (this.serviceMessages[iface] = this.serviceMessages[iface] || []);
1235
+ const index = messages.findIndex((entry) => entry[0] === address && entry[1] === datapoint);
1236
+ if (value) {
1237
+ if (index === -1) {
1238
+ messages.push([address, datapoint, value]);
1239
+ } else {
1240
+ messages[index][2] = value;
1241
+ }
1242
+ } else if (index !== -1) {
1243
+ messages.splice(index, 1);
1244
+ }
1245
+
1246
+ const description = this.getParamsetDescription(iface, address, 'VALUES');
1247
+ if (description && description[datapoint]) {
1248
+ this.setValue(iface, address, datapoint, value === true ? true : value || false, {internal: true});
1249
+ }
1250
+ }
1251
+
1252
+ /**
1253
+ * Devices that appear the next time the install mode is switched on.
1254
+ * @param {string} iface
1255
+ * @param {Array} devices
1256
+ * @param {number} [delay=0] milliseconds between setInstallMode and the newDevices call
1257
+ */
1258
+ scriptNewDevices(iface, devices, delay = 0) {
1259
+ this.newDevicesScript[iface] = {devices, delay};
1260
+ }
1261
+
1262
+ /** Calls one method on every connected logic layer. */
1263
+ tellClients(iface, method, payload) {
1264
+ for (const key of Object.keys(this.clients[iface] || {})) {
1265
+ const client = this.clients[iface][key];
1266
+ client.methodCall(method, [client.id, payload]);
1267
+ }
1268
+ }
1269
+
1270
+ /** A timer that never keeps the process alive and is cleared by close(). */
1271
+ timer(callback, ms) {
1272
+ const handle = setTimeout(() => {
1273
+ this.timers.delete(handle);
1274
+ callback();
1275
+ }, ms);
1276
+ if (typeof handle.unref === 'function') {
1277
+ handle.unref();
1278
+ }
1279
+ this.timers.add(handle);
1280
+ return handle;
1281
+ }
1282
+
1283
+ /**
1284
+ * Every putParamset the simulator accepted, oldest first.
1285
+ * @returns {Array<{iface: string, address: string, paramset: string, values: object, ts: number}>}
1286
+ */
1287
+ getWriteLog() {
1288
+ return this.writeLog.map((entry) => ({...entry, values: {...entry.values}}));
1289
+ }
1290
+
1291
+ /* ------------------------------------------------------------------ rpc */
1292
+
1293
+ buildRpcMethods() {
1294
+ const methods = {
1295
+ 'system.listMethods': (iface) => this.methodNames(iface),
1296
+ 'system.multicall': (iface, params) => this.multicall(iface, params),
1297
+ init: (iface, params) => this.init(iface, params),
1298
+ listDevices: (iface) => this.devices[iface].devices,
1299
+ getParamsetDescription: (iface, params) => this.getParamsetDescription(iface, params[0], params[1]),
1300
+ getDeviceDescription: (iface, params) => this.getDeviceDescription(iface, params[0]),
1301
+ ping: (iface, params) => this.ping(iface, params),
1302
+ setValue: (iface, params) => {
1303
+ this.setValue(iface, params[0], params[1], params[2], {strict: true});
1304
+ return '';
1305
+ },
1306
+ getValue: (iface, params) => this.getValue(iface, params[0], params[1]),
1307
+ getParamset: (iface, params) => this.getParamset(iface, params[0], params[1]),
1308
+ putParamset: (iface, params) => this.putParamset(iface, params[0], params[1], params[2]),
1309
+ 'system.methodHelp': (iface, params) => this.methodHelp(params[0]),
1310
+ getLinks: (iface, params) => this.getLinks(iface, params),
1311
+ getLinkPeers: (iface, params) => this.getLinkPeers(iface, params),
1312
+ getLinkInfo: (iface, params) => this.getLinkInfo(iface, params),
1313
+ setLinkInfo: (iface, params) => this.setLinkInfo(iface, params),
1314
+ addLink: (iface, params) => this.addLink(iface, params),
1315
+ removeLink: (iface, params) => this.removeLink(iface, params),
1316
+ activateLinkParamset: (iface, params) => this.activateLinkParamset(iface, params),
1317
+ rssiInfo: (iface) => this.rssiInfo(iface),
1318
+ listBidcosInterfaces: (iface) => this.listBidcosInterfaces(iface),
1319
+ setBidcosInterface: (iface, params) => this.setBidcosInterface(iface, params),
1320
+ getServiceMessages: (iface) => this.getServiceMessages(iface),
1321
+ setInstallMode: (iface, params) => this.setInstallMode(iface, params),
1322
+ getInstallMode: (iface) => this.getInstallMode(iface),
1323
+ deleteDevice: (iface, params) => this.deleteDevice(iface, params[0], params[1]),
1324
+ replaceDevice: (iface, params) => this.replaceDevice(iface, params[0], params[1]),
1325
+ reportValueUsage: (iface, params) => this.reportValueUsage(iface, params),
1326
+ updateFirmware: (iface, params) => this.updateFirmware(iface, params),
1327
+ installFirmware: (iface, params) => this.installFirmware(iface, params),
1328
+ clearConfigCache: (iface, params) => this.clearConfigCache(iface, params[0]),
1329
+ restoreConfigToDevice: (iface, params) => this.restoreConfigToDevice(iface, params[0]),
1330
+ determineParameter: (iface, params) => this.determineParameter(iface, params),
1331
+ };
1332
+
1333
+ // the CCU accepts the lower case spelling as well
1334
+ methods['system.listmethods'] = methods['system.listMethods'];
1335
+
1336
+ return methods;
1337
+ }
1338
+
1339
+ methodNames() {
1340
+ return Object.keys(this.rpcMethods);
1341
+ }
1342
+
1343
+ multicall(iface, params) {
1344
+ const calls = (params && params[0]) || [];
1345
+ const results = [];
1346
+ for (const call of calls) {
1347
+ try {
1348
+ results.push([this.callMethod(iface, call.methodName, call.params || [])]);
1349
+ } catch (error) {
1350
+ results.push({faultCode: error.faultCode, faultString: error.faultString});
1351
+ }
1352
+ }
1353
+ return results;
1354
+ }
1355
+
1356
+ callMethod(iface, method, params) {
1357
+ const handler = this.rpcMethods[method];
1358
+ if (!handler) {
1359
+ this.log.error('rpc', iface, '< unknown method', method, shortenParams(params));
1360
+ throw this.fault('unknownMethod', method);
1361
+ }
1362
+ const result = handler(iface, params || []);
1363
+ return result === undefined ? '' : result;
1364
+ }
1365
+
1366
+ /**
1367
+ * @param {string} iface
1368
+ * @param {string} method
1369
+ * @param {Array} params
1370
+ * @param {function} callback (fault, result)
1371
+ */
1372
+ dispatch(iface, method, params, callback) {
1373
+ this.log.debug('rpc', iface, '<', method, shortenParams(params));
1374
+ let result;
1375
+ try {
1376
+ result = this.callMethod(iface, method, params);
1377
+ } catch (error) {
1378
+ if (error.faultCode === undefined) {
1379
+ this.log.error('rpc', iface, method, error.stack || error.message);
1380
+ callback(this.fault('invalidArguments', error.message));
1381
+ return;
1382
+ }
1383
+ callback(error);
1384
+ return;
1385
+ }
1386
+ callback(null, result);
1387
+ }
1388
+
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';
1395
+ }
1396
+
1397
+ const clientId = [host, port].join(':');
1398
+
1399
+ if (id === '' || id === undefined) {
1400
+ const client = this.clients[iface][clientId];
1401
+ if (client) {
1402
+ this.log.debug('remove', iface, client.url);
1403
+ closeClient(client.client);
1404
+ delete this.clients[iface][clientId];
1405
+ }
1406
+ } 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
+ };
1422
+ this.clients[iface][clientId] = client;
1423
+ this.startInit(iface, client);
1424
+ }
1425
+
1426
+ return '';
1427
+ }
1428
+
1429
+ 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
1433
+ return '';
1434
+ }
1435
+
1436
+ this.event(iface, ['CENTRAL', 'PONG', params[0]]);
1437
+ return '';
1438
+ }
1439
+
1440
+ /* ------------------------------------------------------------------ events */
1441
+
1442
+ /**
1443
+ * Sends one `event` call to every connected logic layer.
1444
+ * @param {string} iface
1445
+ * @param {Array} params [address, datapoint, value] - the interface id is prepended per client
1446
+ */
1447
+ event(iface, params) {
1448
+ for (const key of Object.keys(this.clients[iface] || {})) {
1449
+ const client = this.clients[iface][key];
1450
+ client.methodCall('event', [client.id, ...params]);
1451
+ }
1452
+ }
1453
+
1454
+ eventMulticall(iface, events) {
1455
+ for (const key of Object.keys(this.clients[iface] || {})) {
1456
+ const client = this.clients[iface][key];
1457
+ const multicall = events.map((singleEvent) => ({
1458
+ methodName: 'event',
1459
+ params: [client.id, singleEvent[0], singleEvent[1], singleEvent[2]],
1460
+ }));
1461
+ client.methodCall('system.multicall', [multicall]);
1462
+ }
1463
+ }
1464
+
1465
+ startInit(iface, client) {
1466
+ client.methodCall('listDevices', [client.id], (error, clientDevices) => {
1467
+ if (error) {
1468
+ this.log.error(error.toString());
1469
+ } else {
1470
+ this.checkDevices(iface, client, clientDevices);
1471
+ }
1472
+ });
1473
+ }
1474
+
1475
+ checkDevices(iface, client, clientDevices) {
1476
+ clientDevices = clientDevices || [];
1477
+ this.log.info(iface, 'client', client.url, 'knows', clientDevices.length, 'devices');
1478
+
1479
+ const clientDeviceAddresses = clientDevices.map((device) => device.ADDRESS);
1480
+ const deviceAddresses = this.devices[iface].devices.map((device) => device.ADDRESS);
1481
+ const newDevices = [];
1482
+ const deleteDevices = [];
1483
+
1484
+ for (const device of this.devices[iface].devices) {
1485
+ const clientDeviceIndex = clientDeviceAddresses.indexOf(device.ADDRESS);
1486
+ if (clientDeviceIndex === -1) {
1487
+ this.log.debug('device unknown by client', device.ADDRESS);
1488
+ newDevices.push(device);
1489
+ } else if (iface === 'hmip') {
1490
+ // the CCU sends every HmIP device again on every init, see
1491
+ // https://github.com/eq-3/occu/issues/45
1492
+ deleteDevices.push(device.ADDRESS);
1493
+ newDevices.push(device);
1494
+ } else if (clientDevices[clientDeviceIndex].VERSION !== device.VERSION) {
1495
+ this.log.debug('device mismatch', device.ADDRESS);
1496
+ deleteDevices.push(device.ADDRESS);
1497
+ newDevices.push(device);
1498
+ }
1499
+ }
1500
+
1501
+ for (const clientDevice of clientDevices) {
1502
+ if (!deviceAddresses.includes(clientDevice.ADDRESS)) {
1503
+ this.log.debug('device unknown', clientDevice.ADDRESS);
1504
+ deleteDevices.push(clientDevice.ADDRESS);
1505
+ }
1506
+ }
1507
+
1508
+ const addNew = () => {
1509
+ if (newDevices.length > 0) {
1510
+ this.log.info(iface, 'client', client.url, 'should add', newDevices.length, 'devices');
1511
+ client.methodCall('newDevices', [client.id, newDevices]);
1512
+ }
1513
+ };
1514
+
1515
+ if (deleteDevices.length > 0) {
1516
+ this.log.info(iface, 'client', client.url, 'should delete', deleteDevices.length, 'devices');
1517
+ client.methodCall('deleteDevices', [client.id, deleteDevices], addNew);
1518
+ } else if (newDevices.length > 0) {
1519
+ addNew();
1520
+ } else {
1521
+ this.log.info(iface, 'client', client.url, 'all devices known');
1522
+ }
1523
+ }
1524
+
1525
+ /* ------------------------------------------------------------------ values */
1526
+
1527
+ /**
1528
+ * @param {string} iface
1529
+ * @param {string} address
1530
+ * @param {string} datapoint
1531
+ * @param {*} value
1532
+ * @param {object} [options]
1533
+ * @param {boolean} [options.strict=false] throw a fault instead of logging - used for incoming
1534
+ * RPC calls; the behaviour script API stays lenient
1535
+ */
1536
+ setValue(iface, address, datapoint, value, {strict = false, internal = false} = {}) {
1537
+ this.log.debug('setValue', iface, address, datapoint, value);
1538
+
1539
+ let description;
1540
+ let casted;
1541
+ try {
1542
+ const device = this.requireDevice(iface, address);
1543
+ description = this.requireParamsetDescription(iface, device, 'VALUES');
1544
+ casted = castValue(description, datapoint, value, this.fault, {write: !internal});
1545
+ } catch (error) {
1546
+ if (strict) {
1547
+ throw error;
1548
+ }
1549
+ this.log.error('setValue', iface, address, datapoint, error.message);
1550
+ return;
1551
+ }
1552
+
1553
+ const parameter = description[datapoint];
1554
+ const state = this.stateOf(iface, address);
1555
+ state.VALUES[datapoint] = casted;
1556
+
1557
+ if (parameter.OPERATIONS & 4) {
1558
+ const events =
1559
+ parameter.TYPE === 'ACTION'
1560
+ ? [[address, datapoint, casted]]
1561
+ : Object.keys(state.VALUES).map((name) => [address, name, state.VALUES[name]]);
1562
+ this.eventMulticall(iface, events);
1563
+ }
1564
+ }
1565
+
1566
+ /* ------------------------------------------------------------------ servers */
1567
+
1568
+ startServers() {
1569
+ const {
1570
+ listenAddress,
1571
+ binrpcListenPort,
1572
+ xmlrpcListenPort,
1573
+ virtualListenPort,
1574
+ cuxdListenPort,
1575
+ virtualPath = '/groups',
1576
+ } = this.config;
1577
+
1578
+ // TLS and basic auth are off unless asked for, so that existing setups see no change
1579
+ this.tls = resolveTls(this.options.tls);
1580
+ this.auth = this.options.auth || null;
1581
+ const auth = this.auth;
1582
+
1583
+ const binrpcServer = (iface, port, label) => {
1584
+ const {onListening, onError} = this.listeningCallbacks(iface, label, listenAddress);
1585
+ return createBinrpcServer({
1586
+ host: listenAddress,
1587
+ port,
1588
+ dispatch: (method, params, callback) => this.dispatch(iface, method, params, callback),
1589
+ onListening,
1590
+ onError,
1591
+ });
1592
+ };
1593
+
1594
+ const xmlrpcServer = (iface, port, label, path) => {
1595
+ const {onListening, onError} = this.listeningCallbacks(iface, label, listenAddress);
1596
+ return createXmlrpcServer({
1597
+ host: listenAddress,
1598
+ port,
1599
+ path,
1600
+ tls: this.tls,
1601
+ auth,
1602
+ dispatch: (method, params, callback) => this.dispatch(iface, method, params, callback),
1603
+ onListening,
1604
+ onError,
1605
+ });
1606
+ };
1607
+
1608
+ // the factory of an interface is kept so that dropConnection() can restart its server
1609
+ const start = (iface, port, factory) => {
1610
+ this.serverFactories[iface] = factory;
1611
+ this.servers[iface] = factory(port);
1612
+ this.exposeServer(iface);
1613
+ };
1614
+
1615
+ if (this.devices.rfd) {
1616
+ start('rfd', binrpcListenPort, (port) => binrpcServer('rfd', port, 'rfd binrpc'));
1617
+ }
1618
+
1619
+ if (this.devices.wired) {
1620
+ start('wired', this.config.wiredListenPort, (port) => binrpcServer('wired', port, 'wired binrpc'));
1621
+ }
1622
+
1623
+ if (this.devices.hmip) {
1624
+ start('hmip', xmlrpcListenPort, (port) => xmlrpcServer('hmip', port, 'hmip xmlrpc'));
1625
+ }
1626
+
1627
+ if (virtualListenPort !== undefined) {
1628
+ start('virtual', virtualListenPort, (port) =>
1629
+ xmlrpcServer('virtual', port, 'VirtualDevices xmlrpc', virtualPath),
1630
+ );
1631
+ }
1632
+
1633
+ if (cuxdListenPort !== undefined) {
1634
+ start('cuxd', cuxdListenPort, (port) => binrpcServer('cuxd', port, 'CUxD binrpc'));
1635
+ }
1636
+ }
1637
+
1638
+ /** Keeps the historical property names (sim.rfdServer, sim.hmipServer, ...) pointing at the server. */
1639
+ exposeServer(iface) {
1640
+ this[`${iface}Server`] = this.servers[iface];
1641
+ }
1642
+
1643
+ /**
1644
+ * Registers a `whenReady()` promise for one server and returns its listening/error callbacks.
1645
+ * The port a server actually got - which matters when it was configured as 0 - lands in
1646
+ * `sim.ports[iface]`.
1647
+ */
1648
+ listeningCallbacks(iface, label, host) {
1649
+ let resolve;
1650
+ let reject;
1651
+ this.ready.push(
1652
+ new Promise((resolveReady, rejectReady) => {
1653
+ resolve = resolveReady;
1654
+ reject = rejectReady;
1655
+ }),
1656
+ );
1657
+
1658
+ return {
1659
+ onListening: (server) => {
1660
+ const port = serverPort(server);
1661
+ this.ports[iface] = port;
1662
+ this.log.info(label, 'server listening on', host, port);
1663
+ resolve();
1664
+ },
1665
+ onError: (error) => {
1666
+ this.log.error(label, 'server error:', error.message);
1667
+ reject(error);
1668
+ },
1669
+ };
1670
+ }
1671
+
1672
+ /**
1673
+ * Resolves once every server the simulator started is accepting connections.
1674
+ * @returns {Promise<void>}
1675
+ */
1676
+ async whenReady() {
1677
+ await Promise.all(this.ready);
1678
+ }
1679
+
1680
+ /* ------------------------------------------------------------------ misc */
1681
+
1682
+ loadBehaviors(behaviorPath) {
1683
+ let files;
1684
+ try {
1685
+ files = fs.readdirSync(behaviorPath);
1686
+ } catch (error) {
1687
+ this.log.warn('behavior path not readable', behaviorPath, error.message);
1688
+ return;
1689
+ }
1690
+
1691
+ for (const file of files) {
1692
+ if (file.endsWith('.js')) {
1693
+ this.log.info('loading behavior', file);
1694
+ require(path.join(behaviorPath, file))(this.api);
1695
+ }
1696
+ }
1697
+ }
1698
+
1699
+ close() {
1700
+ this.api.removeAllListeners();
1701
+ for (const handle of this.timers) {
1702
+ clearTimeout(handle);
1703
+ }
1704
+ this.timers.clear();
1705
+ for (const server of Object.values(this.servers)) {
1706
+ if (typeof server.close === 'function') {
1707
+ server.close();
1708
+ }
1709
+ }
1710
+
1711
+ for (const iface of Object.keys(this.clients)) {
1712
+ for (const key of Object.keys(this.clients[iface])) {
1713
+ closeClient(this.clients[iface][key].client);
1714
+ delete this.clients[iface][key];
1715
+ }
1716
+ }
1717
+
1718
+ if (this.regaSim) {
1719
+ this.regaSim.close();
1720
+ }
1721
+ }
1722
+ }
1723
+
1724
+ /** The port a binrpc or xmlrpc server is actually listening on. */
1725
+ function serverPort(server) {
1726
+ const underlying = server.server || server.httpServer;
1727
+ const address = underlying && underlying.address();
1728
+ return address ? address.port : undefined;
1729
+ }
1730
+
1731
+ /** Closes a binrpc or xmlrpc client so that it stops holding the event loop open. */
1732
+ function closeClient(client) {
1733
+ if (!client) {
1734
+ return;
1735
+ }
1736
+
1737
+ // binrpc clients reconnect on their own until that is switched off
1738
+ if (typeof client.reconnectTimeout === 'number') {
1739
+ client.reconnectTimeout = 0;
1740
+ }
1741
+ if (client.socket && typeof client.socket.destroy === 'function') {
1742
+ client.socket.destroy();
1743
+ }
1744
+ for (const method of ['end', 'close', 'destroy']) {
1745
+ if (typeof client[method] === 'function') {
1746
+ client[method]();
1747
+ }
1748
+ }
1749
+ }
1750
+
1751
+ module.exports = HmSim;