hm-simulator 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/README.md +596 -238
- package/data/fixtures/lab-2026-09.json +45956 -0
- package/index.js +260 -47
- package/lib/control.js +132 -0
- package/lib/method-help.js +4 -0
- package/lib/paramset-index.js +107 -0
- package/lib/servers.js +262 -13
- package/lib/sim.js +963 -59
- package/lib/validate.js +4 -0
- package/package.json +2 -1
package/index.js
CHANGED
|
@@ -2,25 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
'use strict';
|
|
4
4
|
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
5
7
|
const {parseArgs} = require('node:util');
|
|
6
8
|
|
|
7
9
|
const HmSim = require('./sim.js');
|
|
10
|
+
const {createControlServer} = require('./lib/control.js');
|
|
8
11
|
const pkg = require('./package.json');
|
|
9
12
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
const HELP = `hm-simulator ${pkg.version} - simulates a Homematic CCU
|
|
14
|
+
|
|
15
|
+
Usage: hm-simulator [options]
|
|
16
|
+
|
|
17
|
+
--listen-address <ip> default 127.0.0.1
|
|
18
|
+
--binrpc-port <port> rfd (BIN-RPC and XML-RPC), default 2001
|
|
19
|
+
--xmlrpc-port <port> hmipserver, default 2010
|
|
20
|
+
--wired-port <port> BidCos-Wired, off unless given or configured
|
|
21
|
+
--virtual-port <port> VirtualDevices, off unless given
|
|
22
|
+
--cuxd-port <port> CUxD, off unless given
|
|
23
|
+
--rega-port <port> ReGa mock, default 8181
|
|
24
|
+
--no-rega do not start the ReGa mock
|
|
25
|
+
every port may be 0: the system picks a free one
|
|
26
|
+
--ports-json <file|-> once every server listens, write the ports as JSON to the file, or
|
|
27
|
+
as one line to stdout with "-" (the log then goes to stderr)
|
|
28
|
+
--config <file> constructor options as a .json or .js file; the flags override it
|
|
29
|
+
--devices <file> a device file: {devices: {rfd, hmip, ...}, paramsetDescriptions, links,
|
|
30
|
+
bidcosInterfaces}, such as data/fixtures/lab-2026-09.json; default:
|
|
31
|
+
the bundled rfd and hmip lists
|
|
32
|
+
--tls serve the XML-RPC servers over TLS with a generated certificate
|
|
33
|
+
--tls-cert-out <file> write that certificate (PEM) to the file
|
|
34
|
+
--auth <user:password> HTTP basic auth for the XML-RPC servers and ReGa
|
|
35
|
+
--behavior-path <dir> behaviour scripts, default the bundled examples
|
|
36
|
+
--no-behaviors no behaviour scripts
|
|
37
|
+
--control-port <port> the scenario API over HTTP on 127.0.0.1, off unless given
|
|
38
|
+
-v, --verbosity <level> error|warn|info|debug, default info
|
|
39
|
+
--version
|
|
40
|
+
-h, --help`;
|
|
41
|
+
|
|
42
|
+
function fail(message) {
|
|
43
|
+
console.error(`hm-simulator: ${message}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let args;
|
|
48
|
+
try {
|
|
49
|
+
({values: args} = parseArgs({
|
|
50
|
+
options: {
|
|
51
|
+
'listen-address': {type: 'string'},
|
|
52
|
+
'binrpc-port': {type: 'string'},
|
|
53
|
+
'xmlrpc-port': {type: 'string'},
|
|
54
|
+
'wired-port': {type: 'string'},
|
|
55
|
+
'virtual-port': {type: 'string'},
|
|
56
|
+
'cuxd-port': {type: 'string'},
|
|
57
|
+
'rega-port': {type: 'string'},
|
|
58
|
+
'no-rega': {type: 'boolean', default: false},
|
|
59
|
+
'ports-json': {type: 'string'},
|
|
60
|
+
config: {type: 'string'},
|
|
61
|
+
devices: {type: 'string'},
|
|
62
|
+
tls: {type: 'boolean', default: false},
|
|
63
|
+
'tls-cert-out': {type: 'string'},
|
|
64
|
+
auth: {type: 'string'},
|
|
65
|
+
'behavior-path': {type: 'string'},
|
|
66
|
+
'no-behaviors': {type: 'boolean', default: false},
|
|
67
|
+
'control-port': {type: 'string'},
|
|
68
|
+
verbosity: {type: 'string', short: 'v', default: 'info'},
|
|
69
|
+
version: {type: 'boolean', default: false},
|
|
70
|
+
help: {type: 'boolean', short: 'h', default: false},
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
fail(error.message);
|
|
75
|
+
}
|
|
24
76
|
|
|
25
77
|
if (args.version) {
|
|
26
78
|
console.log(pkg.version);
|
|
@@ -28,23 +80,12 @@ if (args.version) {
|
|
|
28
80
|
}
|
|
29
81
|
|
|
30
82
|
if (args.help) {
|
|
31
|
-
console.log(
|
|
32
|
-
|
|
33
|
-
Usage: hm-simulator [options]
|
|
34
|
-
|
|
35
|
-
--listen-address <ip> default 127.0.0.1
|
|
36
|
-
--binrpc-port <port> rfd, default 2001
|
|
37
|
-
--xmlrpc-port <port> hmipserver, default 2010
|
|
38
|
-
--virtual-port <port> VirtualDevices, off unless given
|
|
39
|
-
--cuxd-port <port> CUxD, off unless given
|
|
40
|
-
--rega-port <port> ReGa mock, default 8181
|
|
41
|
-
--no-rega do not start the ReGa mock
|
|
42
|
-
-v, --verbosity <level> error|warn|info|debug, default info
|
|
43
|
-
--version
|
|
44
|
-
-h, --help`);
|
|
83
|
+
console.log(HELP);
|
|
45
84
|
process.exit(0);
|
|
46
85
|
}
|
|
47
86
|
|
|
87
|
+
// with the ports on stdout, stdout carries nothing else
|
|
88
|
+
const toStderr = args['ports-json'] === '-';
|
|
48
89
|
const LEVELS = ['error', 'warn', 'info', 'debug'];
|
|
49
90
|
const level = LEVELS.indexOf(args.verbosity) === -1 ? 2 : LEVELS.indexOf(args.verbosity);
|
|
50
91
|
const logAt = (index, method) =>
|
|
@@ -52,33 +93,205 @@ const logAt = (index, method) =>
|
|
|
52
93
|
|
|
53
94
|
const log = {
|
|
54
95
|
error: logAt(0, 'error'),
|
|
55
|
-
warn: logAt(1, 'warn'),
|
|
56
|
-
info: logAt(2, 'log'),
|
|
57
|
-
debug: logAt(3, 'log'),
|
|
96
|
+
warn: logAt(1, toStderr ? 'error' : 'warn'),
|
|
97
|
+
info: logAt(2, toStderr ? 'error' : 'log'),
|
|
98
|
+
debug: logAt(3, toStderr ? 'error' : 'log'),
|
|
58
99
|
};
|
|
59
100
|
|
|
60
|
-
|
|
101
|
+
function port(value, name) {
|
|
102
|
+
if (value === undefined) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const number = Number(value);
|
|
106
|
+
if (value === '' || !Number.isInteger(number) || number < 0 || number > 65535) {
|
|
107
|
+
fail(`--${name} wants a port number, got ${value}`);
|
|
108
|
+
}
|
|
109
|
+
return number;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readJson(file) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
115
|
+
} catch (error) {
|
|
116
|
+
fail(`cannot read ${file}: ${error.message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A device file: `{devices: {rfd, hmip, ...}, paramsetDescriptions}` (a fixture), or only the
|
|
122
|
+
* `{rfd, hmip, ...}` part.
|
|
123
|
+
*/
|
|
124
|
+
/**
|
|
125
|
+
* A device file: either `{<iface>: {devices}}` or a fixture `{devices, paramsetDescriptions, links,
|
|
126
|
+
* bidcosInterfaces}` as data/fixtures/ and tools/dump-ccu.js have it.
|
|
127
|
+
*/
|
|
128
|
+
function loadDevices(file) {
|
|
129
|
+
const content = readJson(file);
|
|
130
|
+
return content.devices && !Array.isArray(content.devices)
|
|
131
|
+
? {
|
|
132
|
+
devices: content.devices,
|
|
133
|
+
paramsetDescriptions: content.paramsetDescriptions,
|
|
134
|
+
links: content.links,
|
|
135
|
+
bidcosInterfaces: content.bidcosInterfaces,
|
|
136
|
+
}
|
|
137
|
+
: {devices: content};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Takes what a device file brings along, unless the options already say it. */
|
|
141
|
+
function applyDeviceFile(options, loaded, override) {
|
|
142
|
+
options.devices = loaded.devices;
|
|
143
|
+
for (const key of ['paramsetDescriptions', 'links', 'bidcosInterfaces']) {
|
|
144
|
+
if (loaded[key] && (override || options[key] === undefined)) {
|
|
145
|
+
options[key] = loaded[key];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadConfig(file) {
|
|
151
|
+
const absolute = path.resolve(file);
|
|
152
|
+
let options;
|
|
153
|
+
try {
|
|
154
|
+
options = /\.c?js$/.test(absolute) ? require(absolute) : readJson(absolute);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
fail(`cannot load ${file}: ${error.message}`);
|
|
157
|
+
}
|
|
158
|
+
options = {...options};
|
|
159
|
+
const base = path.dirname(absolute);
|
|
160
|
+
// devices and paramsetDescriptions may be paths, relative to the file
|
|
161
|
+
if (typeof options.devices === 'string') {
|
|
162
|
+
applyDeviceFile(options, loadDevices(path.resolve(base, options.devices)), false);
|
|
163
|
+
}
|
|
164
|
+
if (typeof options.paramsetDescriptions === 'string') {
|
|
165
|
+
options.paramsetDescriptions = readJson(path.resolve(base, options.paramsetDescriptions));
|
|
166
|
+
}
|
|
167
|
+
if (typeof options.behaviorPath === 'string') {
|
|
168
|
+
options.behaviorPath = path.resolve(base, options.behaviorPath);
|
|
169
|
+
}
|
|
170
|
+
return options;
|
|
171
|
+
}
|
|
61
172
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
173
|
+
const options = args.config ? loadConfig(args.config) : {};
|
|
174
|
+
|
|
175
|
+
if (args.devices) {
|
|
176
|
+
applyDeviceFile(options, loadDevices(path.resolve(args.devices)), true);
|
|
177
|
+
}
|
|
178
|
+
if (!options.devices) {
|
|
179
|
+
options.devices = {
|
|
65
180
|
rfd: require('./data/devices-rfd.json'),
|
|
66
181
|
hmip: require('./data/devices-hmip.json'),
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
// a copy: the simulator changes the lists it is given
|
|
185
|
+
options.devices = JSON.parse(JSON.stringify(options.devices));
|
|
186
|
+
|
|
187
|
+
const config = {...options.config};
|
|
188
|
+
const listenAddress = args['listen-address'] || config.listenAddress || '127.0.0.1';
|
|
189
|
+
config.listenAddress = listenAddress;
|
|
190
|
+
config.binrpcListenPort = port(args['binrpc-port'], 'binrpc-port') ?? config.binrpcListenPort ?? 2001;
|
|
191
|
+
config.xmlrpcListenPort = port(args['xmlrpc-port'], 'xmlrpc-port') ?? config.xmlrpcListenPort ?? 2010;
|
|
192
|
+
config.virtualListenPort = port(args['virtual-port'], 'virtual-port') ?? config.virtualListenPort;
|
|
193
|
+
config.cuxdListenPort = port(args['cuxd-port'], 'cuxd-port') ?? config.cuxdListenPort;
|
|
194
|
+
config.wiredListenPort = port(args['wired-port'], 'wired-port') ?? config.wiredListenPort;
|
|
195
|
+
if (config.wiredListenPort !== undefined && !options.devices.wired) {
|
|
196
|
+
options.devices.wired = {devices: []};
|
|
197
|
+
}
|
|
198
|
+
if (options.devices.wired && config.wiredListenPort === undefined) {
|
|
199
|
+
config.wiredListenPort = 2000;
|
|
200
|
+
}
|
|
201
|
+
options.config = config;
|
|
202
|
+
|
|
203
|
+
if (args['no-rega']) {
|
|
204
|
+
delete options.rega;
|
|
205
|
+
} else {
|
|
206
|
+
options.rega = {port: 8181, listenAddress, ...options.rega};
|
|
207
|
+
if (args['rega-port'] !== undefined) {
|
|
208
|
+
options.rega.port = port(args['rega-port'], 'rega-port');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (args.tls) {
|
|
213
|
+
options.tls = true;
|
|
214
|
+
}
|
|
215
|
+
if (args.auth !== undefined) {
|
|
216
|
+
const separator = args.auth.indexOf(':');
|
|
217
|
+
if (separator < 1) {
|
|
218
|
+
fail('--auth wants user:password');
|
|
219
|
+
}
|
|
220
|
+
options.auth = {username: args.auth.slice(0, separator), password: args.auth.slice(separator + 1)};
|
|
221
|
+
}
|
|
222
|
+
if (args['no-behaviors']) {
|
|
223
|
+
options.behaviorPath = false;
|
|
224
|
+
} else if (args['behavior-path'] !== undefined) {
|
|
225
|
+
options.behaviorPath = path.resolve(args['behavior-path']);
|
|
226
|
+
}
|
|
227
|
+
options.log = log;
|
|
228
|
+
|
|
229
|
+
const controlPort = port(args['control-port'], 'control-port');
|
|
230
|
+
|
|
231
|
+
/** Writes a file so that a reader never sees half of it. */
|
|
232
|
+
function writeAtomically(file, content) {
|
|
233
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
234
|
+
fs.writeFileSync(temporary, content);
|
|
235
|
+
fs.renameSync(temporary, file);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let hmSim;
|
|
239
|
+
let control;
|
|
240
|
+
|
|
241
|
+
function shutdown(code) {
|
|
242
|
+
if (control) {
|
|
243
|
+
control.close();
|
|
244
|
+
}
|
|
245
|
+
if (hmSim) {
|
|
246
|
+
hmSim.close();
|
|
247
|
+
}
|
|
248
|
+
process.exit(code);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function main() {
|
|
252
|
+
hmSim = new HmSim(options);
|
|
253
|
+
try {
|
|
254
|
+
await hmSim.whenReady();
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// EADDRINUSE and friends name the address and the port
|
|
257
|
+
console.error(`hm-simulator: ${error.message}`);
|
|
258
|
+
shutdown(1);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (controlPort !== undefined) {
|
|
262
|
+
try {
|
|
263
|
+
control = await createControlServer(hmSim, {port: controlPort, log: log.error});
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error(`hm-simulator: control port: ${error.message}`);
|
|
266
|
+
shutdown(1);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (args['tls-cert-out'] && hmSim.tls) {
|
|
271
|
+
writeAtomically(path.resolve(args['tls-cert-out']), hmSim.tls.cert);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const ports = {...hmSim.ports};
|
|
275
|
+
if (hmSim.regaSim) {
|
|
276
|
+
ports.rega = hmSim.regaSim.port;
|
|
277
|
+
}
|
|
278
|
+
if (control) {
|
|
279
|
+
ports.control = control.address().port;
|
|
280
|
+
}
|
|
281
|
+
log.info('listening', JSON.stringify(ports));
|
|
282
|
+
|
|
283
|
+
if (args['ports-json'] === '-') {
|
|
284
|
+
process.stdout.write(JSON.stringify(ports) + '\n');
|
|
285
|
+
} else if (args['ports-json']) {
|
|
286
|
+
writeAtomically(path.resolve(args['ports-json']), JSON.stringify(ports) + '\n');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
77
289
|
|
|
78
290
|
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
79
291
|
process.on(signal, () => {
|
|
80
292
|
log.info('got', signal, '- shutting down');
|
|
81
|
-
|
|
82
|
-
process.exit(0);
|
|
293
|
+
shutdown(0);
|
|
83
294
|
});
|
|
84
295
|
}
|
|
296
|
+
|
|
297
|
+
main();
|
package/lib/control.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The scenario calls the control endpoint lets through. Everything a test does to a simulator it
|
|
7
|
+
* does not run in its own process: devices, events, service messages, interface processes, and
|
|
8
|
+
* reading back what the simulator saw.
|
|
9
|
+
*/
|
|
10
|
+
const SCENARIO_METHODS = new Set([
|
|
11
|
+
'addDevice',
|
|
12
|
+
'removeDevice',
|
|
13
|
+
'fireEvent',
|
|
14
|
+
'setValue',
|
|
15
|
+
'setServiceMessage',
|
|
16
|
+
'scriptNewDevices',
|
|
17
|
+
'dropConnection',
|
|
18
|
+
'stopInterface',
|
|
19
|
+
'startInterface',
|
|
20
|
+
'restartInterface',
|
|
21
|
+
'fireEvents',
|
|
22
|
+
'injectFault',
|
|
23
|
+
'clearFaults',
|
|
24
|
+
'getCallbackLog',
|
|
25
|
+
'setReachable',
|
|
26
|
+
'setLowBattery',
|
|
27
|
+
'setDutyCycle',
|
|
28
|
+
'setCarrierSense',
|
|
29
|
+
'offerFirmware',
|
|
30
|
+
'schedule',
|
|
31
|
+
'getDevice',
|
|
32
|
+
'getWriteLog',
|
|
33
|
+
'getConfigPending',
|
|
34
|
+
'getPoisonedChannels',
|
|
35
|
+
'getMissingParamsetDescriptions',
|
|
36
|
+
'getTempKey',
|
|
37
|
+
'getInstallMode',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function send(response, status, body) {
|
|
41
|
+
const json = JSON.stringify(body === undefined ? null : body);
|
|
42
|
+
response.writeHead(status, {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json)});
|
|
43
|
+
response.end(json);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readBody(request) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const chunks = [];
|
|
49
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
50
|
+
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
51
|
+
request.on('error', reject);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The scenario API over HTTP, for a simulator that runs in a process of its own (the CLI's
|
|
57
|
+
* `--control-port`). Loopback only.
|
|
58
|
+
*
|
|
59
|
+
* - `POST /scenario/<method>` with a JSON array of arguments, `GET /scenario/<method>` without;
|
|
60
|
+
* answers `{"result": …}`, a fault as 400 `{"error", "faultCode", "faultString"}`, an unknown
|
|
61
|
+
* method as 404.
|
|
62
|
+
* - `GET /ports`: the ports the simulator listens on.
|
|
63
|
+
*
|
|
64
|
+
* `setValue` is the device reporting a value, as a behaviour script does (`sim.api`), not a client
|
|
65
|
+
* writing one.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} sim a HmSim
|
|
68
|
+
* @param {object} options
|
|
69
|
+
* @param {number} options.port
|
|
70
|
+
* @param {function} [options.log] (…args) for errors
|
|
71
|
+
* @returns {Promise<http.Server>} once listening
|
|
72
|
+
*/
|
|
73
|
+
function createControlServer(sim, {port, log = () => {}}) {
|
|
74
|
+
const server = http.createServer(async (request, response) => {
|
|
75
|
+
const url = new URL(request.url, 'http://localhost');
|
|
76
|
+
if (url.pathname === '/ports' && request.method === 'GET') {
|
|
77
|
+
send(response, 200, sim.ports);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const match = /^\/scenario\/([A-Za-z]+)$/.exec(url.pathname);
|
|
82
|
+
if (!match || !['GET', 'POST'].includes(request.method)) {
|
|
83
|
+
send(response, 404, {error: 'not found'});
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const method = match[1];
|
|
87
|
+
if (!SCENARIO_METHODS.has(method) || typeof sim[method] !== 'function') {
|
|
88
|
+
send(response, 404, {error: `unknown scenario call ${method}`});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let args = [];
|
|
93
|
+
if (request.method === 'POST') {
|
|
94
|
+
const body = await readBody(request);
|
|
95
|
+
try {
|
|
96
|
+
args = body.trim() === '' ? [] : JSON.parse(body);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
send(response, 400, {error: `body is no JSON: ${error.message}`});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(args)) {
|
|
102
|
+
send(response, 400, {error: 'body must be a JSON array of arguments'});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const result =
|
|
109
|
+
method === 'setValue'
|
|
110
|
+
? sim.setValue(args[0], args[1], args[2], args[3], {internal: true})
|
|
111
|
+
: await sim[method](...args);
|
|
112
|
+
send(response, 200, {result});
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error.faultCode !== undefined) {
|
|
115
|
+
send(response, 400, {error: error.message, faultCode: error.faultCode, faultString: error.faultString});
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
log('control', method, error.stack || error.message);
|
|
119
|
+
send(response, 500, {error: error.message});
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
server.once('error', reject);
|
|
125
|
+
server.listen(port, '127.0.0.1', () => {
|
|
126
|
+
server.removeListener('error', reject);
|
|
127
|
+
resolve(server);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {createControlServer, SCENARIO_METHODS};
|
package/lib/method-help.js
CHANGED
|
@@ -19,6 +19,10 @@ module.exports = {
|
|
|
19
19
|
getValue: 'ValueType getValue(string address, string valueKey) - read one value',
|
|
20
20
|
setValue: 'void setValue(string address, string valueKey, ValueType value) - write one value',
|
|
21
21
|
ping: 'boolean ping(string callerId) - triggers a PONG event',
|
|
22
|
+
logLevel: 'int logLevel(int level) - set the log level of the interface process; without a level read it',
|
|
23
|
+
changeKey: 'void changeKey(string passphrase) - change the security key of the interface',
|
|
24
|
+
refreshDeployedDeviceFirmwareList:
|
|
25
|
+
'void refreshDeployedDeviceFirmwareList() - read the directory of deployed device firmware again',
|
|
22
26
|
getLinks: 'array getLinks(string address, int flags) - links of a channel or of the whole interface',
|
|
23
27
|
getLinkInfo: 'struct getLinkInfo(string senderAddress, string receiverAddress) - name and description of a link',
|
|
24
28
|
setLinkInfo:
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Finds a paramset description for a device whose firmware has none.
|
|
5
|
+
*
|
|
6
|
+
* The descriptions are keyed `<interface>/<type>/<firmware>/<version>/<channelType>/<paramset>`,
|
|
7
|
+
* so a device whose firmware is older or newer than every dump in the set has no description at
|
|
8
|
+
* all, although the channel layout of the same type and VERSION is the same. The index groups the
|
|
9
|
+
* keys by everything but the firmware and picks the nearest firmware: the highest one at or below
|
|
10
|
+
* the device's, else the lowest one above it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Firmware as numbers, part by part; `2.31.25.20180526` is `[2, 31, 25, 20180526]`, so a dated
|
|
15
|
+
* build sorts after its undated version. A part that is no number counts as -1.
|
|
16
|
+
* @param {string} firmware
|
|
17
|
+
* @returns {number[]}
|
|
18
|
+
*/
|
|
19
|
+
function firmwareParts(firmware) {
|
|
20
|
+
return String(firmware)
|
|
21
|
+
.split('.')
|
|
22
|
+
.map((part) => {
|
|
23
|
+
const number = Number.parseInt(part, 10);
|
|
24
|
+
return Number.isNaN(number) ? -1 : number;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {number[]} a
|
|
30
|
+
* @param {number[]} b
|
|
31
|
+
* @returns {number} < 0, 0 or > 0
|
|
32
|
+
*/
|
|
33
|
+
function compareFirmware(a, b) {
|
|
34
|
+
const length = Math.max(a.length, b.length);
|
|
35
|
+
for (let index = 0; index < length; index++) {
|
|
36
|
+
const left = a[index] === undefined ? -1 : a[index];
|
|
37
|
+
const right = b[index] === undefined ? -1 : b[index];
|
|
38
|
+
if (left !== right) {
|
|
39
|
+
return left - right;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class ParamsetIndex {
|
|
46
|
+
/**
|
|
47
|
+
* @param {object} descriptions key -> paramset description
|
|
48
|
+
*/
|
|
49
|
+
constructor(descriptions) {
|
|
50
|
+
this.descriptions = descriptions;
|
|
51
|
+
/** `<interface>/<type>/<version>/<channelType>/<paramset>` -> [{parts, key}] sorted by firmware */
|
|
52
|
+
this.groups = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
build() {
|
|
56
|
+
const groups = new Map();
|
|
57
|
+
for (const key of Object.keys(this.descriptions)) {
|
|
58
|
+
const fields = key.split('/');
|
|
59
|
+
if (fields.length !== 6) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const [prefix, type, firmware, version, channelType, paramset] = fields;
|
|
63
|
+
const group = [prefix, type, version, channelType, paramset].join('/');
|
|
64
|
+
if (!groups.has(group)) {
|
|
65
|
+
groups.set(group, []);
|
|
66
|
+
}
|
|
67
|
+
groups.get(group).push({parts: firmwareParts(firmware), key});
|
|
68
|
+
}
|
|
69
|
+
for (const entries of groups.values()) {
|
|
70
|
+
entries.sort((a, b) => compareFirmware(a.parts, b.parts));
|
|
71
|
+
}
|
|
72
|
+
this.groups = groups;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The key of the nearest firmware for a key that has no description.
|
|
77
|
+
* @param {string} key `<interface>/<type>/<firmware>/<version>/<channelType>/<paramset>`
|
|
78
|
+
* @returns {string|null}
|
|
79
|
+
*/
|
|
80
|
+
nearest(key) {
|
|
81
|
+
const fields = key.split('/');
|
|
82
|
+
if (fields.length !== 6) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!this.groups) {
|
|
86
|
+
this.build();
|
|
87
|
+
}
|
|
88
|
+
const [prefix, type, firmware, version, channelType, paramset] = fields;
|
|
89
|
+
const entries = this.groups.get([prefix, type, version, channelType, paramset].join('/'));
|
|
90
|
+
if (!entries || entries.length === 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const wanted = firmwareParts(firmware);
|
|
95
|
+
let below = null;
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
if (compareFirmware(entry.parts, wanted) <= 0) {
|
|
98
|
+
below = entry;
|
|
99
|
+
} else {
|
|
100
|
+
return (below || entry).key;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return below.key;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = {ParamsetIndex, firmwareParts, compareFirmware};
|