hm-simulator 0.1.0 → 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/LICENSE +21 -0
- package/README.md +330 -67
- package/behaviors/virtual-button-press.js +1 -1
- package/behaviors/window-open-close.js +1 -1
- package/data/fixtures/devices.json +31606 -0
- package/data/fixtures/lab-devices.json +4563 -0
- package/index.js +71 -7
- package/lib/binrpc-codec.js +100 -0
- package/lib/faults.js +126 -0
- package/lib/method-help.js +50 -0
- package/lib/rega.js +245 -0
- package/lib/servers.js +150 -0
- package/lib/sim.js +1751 -0
- package/lib/tls.js +160 -0
- package/lib/validate.js +233 -0
- package/package.json +31 -13
- package/sim.js +3 -530
- package/sim.mjs +5 -0
- package/.travis.yml +0 -3
- package/config.js +0 -3
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;
|