hm-simulator 1.1.0 → 1.3.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 +124 -0
- package/README.md +651 -242
- package/data/fixtures/lab-2026-09.json +45956 -0
- package/index.js +260 -47
- package/lib/control.js +133 -0
- package/lib/faults.d.ts +48 -0
- package/lib/method-help.js +18 -0
- package/lib/paramset-index.js +107 -0
- package/lib/servers.js +262 -13
- package/lib/sim.js +1307 -65
- package/lib/validate.js +4 -0
- package/package.json +11 -4
- package/sim.d.mts +5 -0
- package/sim.d.ts +443 -0
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,133 @@
|
|
|
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
|
+
'scriptKeyMismatch',
|
|
18
|
+
'dropConnection',
|
|
19
|
+
'stopInterface',
|
|
20
|
+
'startInterface',
|
|
21
|
+
'restartInterface',
|
|
22
|
+
'fireEvents',
|
|
23
|
+
'injectFault',
|
|
24
|
+
'clearFaults',
|
|
25
|
+
'getCallbackLog',
|
|
26
|
+
'setReachable',
|
|
27
|
+
'setLowBattery',
|
|
28
|
+
'setDutyCycle',
|
|
29
|
+
'setCarrierSense',
|
|
30
|
+
'offerFirmware',
|
|
31
|
+
'schedule',
|
|
32
|
+
'getDevice',
|
|
33
|
+
'getWriteLog',
|
|
34
|
+
'getConfigPending',
|
|
35
|
+
'getPoisonedChannels',
|
|
36
|
+
'getMissingParamsetDescriptions',
|
|
37
|
+
'getTempKey',
|
|
38
|
+
'getInstallMode',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function send(response, status, body) {
|
|
42
|
+
const json = JSON.stringify(body === undefined ? null : body);
|
|
43
|
+
response.writeHead(status, {'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(json)});
|
|
44
|
+
response.end(json);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readBody(request) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
51
|
+
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
52
|
+
request.on('error', reject);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The scenario API over HTTP, for a simulator that runs in a process of its own (the CLI's
|
|
58
|
+
* `--control-port`). Loopback only.
|
|
59
|
+
*
|
|
60
|
+
* - `POST /scenario/<method>` with a JSON array of arguments, `GET /scenario/<method>` without;
|
|
61
|
+
* answers `{"result": …}`, a fault as 400 `{"error", "faultCode", "faultString"}`, an unknown
|
|
62
|
+
* method as 404.
|
|
63
|
+
* - `GET /ports`: the ports the simulator listens on.
|
|
64
|
+
*
|
|
65
|
+
* `setValue` is the device reporting a value, as a behaviour script does (`sim.api`), not a client
|
|
66
|
+
* writing one.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} sim a HmSim
|
|
69
|
+
* @param {object} options
|
|
70
|
+
* @param {number} options.port
|
|
71
|
+
* @param {function} [options.log] (…args) for errors
|
|
72
|
+
* @returns {Promise<http.Server>} once listening
|
|
73
|
+
*/
|
|
74
|
+
function createControlServer(sim, {port, log = () => {}}) {
|
|
75
|
+
const server = http.createServer(async (request, response) => {
|
|
76
|
+
const url = new URL(request.url, 'http://localhost');
|
|
77
|
+
if (url.pathname === '/ports' && request.method === 'GET') {
|
|
78
|
+
send(response, 200, sim.ports);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const match = /^\/scenario\/([A-Za-z]+)$/.exec(url.pathname);
|
|
83
|
+
if (!match || !['GET', 'POST'].includes(request.method)) {
|
|
84
|
+
send(response, 404, {error: 'not found'});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const method = match[1];
|
|
88
|
+
if (!SCENARIO_METHODS.has(method) || typeof sim[method] !== 'function') {
|
|
89
|
+
send(response, 404, {error: `unknown scenario call ${method}`});
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let args = [];
|
|
94
|
+
if (request.method === 'POST') {
|
|
95
|
+
const body = await readBody(request);
|
|
96
|
+
try {
|
|
97
|
+
args = body.trim() === '' ? [] : JSON.parse(body);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
send(response, 400, {error: `body is no JSON: ${error.message}`});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(args)) {
|
|
103
|
+
send(response, 400, {error: 'body must be a JSON array of arguments'});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const result =
|
|
110
|
+
method === 'setValue'
|
|
111
|
+
? sim.setValue(args[0], args[1], args[2], args[3], {internal: true})
|
|
112
|
+
: await sim[method](...args);
|
|
113
|
+
send(response, 200, {result});
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error.faultCode !== undefined) {
|
|
116
|
+
send(response, 400, {error: error.message, faultCode: error.faultCode, faultString: error.faultString});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
log('control', method, error.stack || error.message);
|
|
120
|
+
send(response, 500, {error: error.message});
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
server.once('error', reject);
|
|
126
|
+
server.listen(port, '127.0.0.1', () => {
|
|
127
|
+
server.removeListener('error', reject);
|
|
128
|
+
resolve(server);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {createControlServer, SCENARIO_METHODS};
|
package/lib/faults.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Type declarations for lib/faults.js (`require('hm-simulator/lib/faults.js')`).
|
|
2
|
+
|
|
3
|
+
/** An XML-RPC fault: what the interface process answers. */
|
|
4
|
+
export interface FaultEntry {
|
|
5
|
+
faultCode: number;
|
|
6
|
+
faultString: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** The names of the fault table's entries. */
|
|
10
|
+
export type FaultName =
|
|
11
|
+
| 'unknownMethod'
|
|
12
|
+
| 'unknownInstance'
|
|
13
|
+
| 'unknownParamset'
|
|
14
|
+
| 'unknownParameter'
|
|
15
|
+
| 'unknownLink'
|
|
16
|
+
| 'readOnly'
|
|
17
|
+
| 'typeError'
|
|
18
|
+
| 'outOfRange'
|
|
19
|
+
| 'invalidValue'
|
|
20
|
+
| 'notSupported'
|
|
21
|
+
| 'notReachable'
|
|
22
|
+
| 'invalidArguments';
|
|
23
|
+
|
|
24
|
+
/** A fault table: every name above, plus whatever an override added. */
|
|
25
|
+
export type FaultTable = Record<FaultName, FaultEntry> & Record<string, FaultEntry>;
|
|
26
|
+
|
|
27
|
+
/** hmipserver 3.89.8, measured 2026-09-05 - the default. */
|
|
28
|
+
export declare const DEFAULT_FAULTS: FaultTable;
|
|
29
|
+
/** rfd 3.89.8, measured 2026-09-05. */
|
|
30
|
+
export declare const BIDCOS_FAULTS: FaultTable;
|
|
31
|
+
/** The measured tables by name, for `new HmSim({faults: FAULT_TABLES.bidcos})`. */
|
|
32
|
+
export declare const FAULT_TABLES: {hmip: FaultTable; bidcos: FaultTable};
|
|
33
|
+
|
|
34
|
+
/** Error carrying an XML-RPC fault. */
|
|
35
|
+
export declare class RpcFault extends Error {
|
|
36
|
+
constructor(faultCode: number, faultString: string, detail?: string);
|
|
37
|
+
faultCode: number;
|
|
38
|
+
faultString: string;
|
|
39
|
+
detail?: string;
|
|
40
|
+
/** The struct a BIN-RPC fault carries as its body. */
|
|
41
|
+
toStruct(): FaultEntry;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A fault factory bound to a (possibly overridden) table. */
|
|
45
|
+
export declare function createFaults(overrides?: Partial<Record<FaultName | (string & {}), Partial<FaultEntry>>>): {
|
|
46
|
+
table: FaultTable;
|
|
47
|
+
fault: (name: FaultName | (string & {}), detail?: string) => RpcFault;
|
|
48
|
+
};
|
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:
|
|
@@ -51,4 +55,18 @@ module.exports = {
|
|
|
51
55
|
restoreConfigToDevice: 'void restoreConfigToDevice(string address) - write the cached configuration to the device',
|
|
52
56
|
determineParameter:
|
|
53
57
|
'void determineParameter(string address, string paramsetKey, string parameterId) - read a parameter from the device',
|
|
58
|
+
getKeyMismatchDevice:
|
|
59
|
+
'string getKeyMismatchDevice(boolean reset) - the serial of a device that holds another key, reset clears it',
|
|
60
|
+
addDevice: 'struct addDevice(string serialNumber) - pair one device by its serial; BidCos only',
|
|
61
|
+
suppressServiceMessages:
|
|
62
|
+
'void suppressServiceMessages(string channelAddress, string parameter, boolean suppress) - suppress a service message, an empty parameter for all; HmIP only',
|
|
63
|
+
getSuppressedServiceMessages:
|
|
64
|
+
'array getSuppressedServiceMessages(string channelAddress) - the suppressed service parameters of a channel; HmIP only',
|
|
65
|
+
getMetadata: 'ValueType getMetadata(string address, string key) - a stored metadata value',
|
|
66
|
+
setMetadata: 'void setMetadata(string address, string key, ValueType value) - store a metadata value',
|
|
67
|
+
getAllMetadata: 'struct getAllMetadata(string address) - every metadata value of an address; BidCos only',
|
|
68
|
+
listReplaceableDevices: 'array listReplaceableDevices(string address) - the devices a device can replace',
|
|
69
|
+
getVersion: 'string getVersion() - the version of the interface process',
|
|
70
|
+
getLGWStatus: 'struct getLGWStatus() - the state of the LAN gateways',
|
|
71
|
+
setInterfaceClock: 'void setInterfaceClock(int utcSeconds, int offsetMinutes) - set the clock sent to the devices',
|
|
54
72
|
};
|
|
@@ -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};
|