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/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 {values: args} = parseArgs({
11
- options: {
12
- 'listen-address': {type: 'string', default: '127.0.0.1'},
13
- 'binrpc-port': {type: 'string', default: '2001'},
14
- 'xmlrpc-port': {type: 'string', default: '2010'},
15
- 'virtual-port': {type: 'string'},
16
- 'cuxd-port': {type: 'string'},
17
- 'rega-port': {type: 'string', default: '8181'},
18
- 'no-rega': {type: 'boolean', default: false},
19
- verbosity: {type: 'string', short: 'v', default: 'info'},
20
- version: {type: 'boolean', default: false},
21
- help: {type: 'boolean', short: 'h', default: false},
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(`hm-simulator ${pkg.version} - simulates a Homematic CCU
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
- const port = (value) => (value === undefined ? undefined : Number.parseInt(value, 10));
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 hmSim = new HmSim({
63
- log,
64
- devices: {
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
- config: {
69
- listenAddress: args['listen-address'],
70
- binrpcListenPort: port(args['binrpc-port']),
71
- xmlrpcListenPort: port(args['xmlrpc-port']),
72
- virtualListenPort: port(args['virtual-port']),
73
- cuxdListenPort: port(args['cuxd-port']),
74
- },
75
- rega: args['no-rega'] ? undefined : {port: port(args['rega-port']), listenAddress: args['listen-address']},
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
- hmSim.close();
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};
@@ -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};