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/lib/servers.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const {EventEmitter} = require('node:events');
|
|
4
|
+
const net = require('node:net');
|
|
5
|
+
const {Duplex, PassThrough} = require('node:stream');
|
|
4
6
|
|
|
5
7
|
const binrpc = require('binrpc');
|
|
8
|
+
const binrpcProtocol = require('binrpc/lib/protocol.js');
|
|
6
9
|
const xmlrpc = require('homematic-xmlrpc');
|
|
7
10
|
|
|
8
11
|
const {encodeResponse, encodeFault} = require('./binrpc-codec.js');
|
|
@@ -35,9 +38,16 @@ function createBinrpcServer({host, port, dispatch, onListening, onError}) {
|
|
|
35
38
|
return;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
dispatch(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
dispatch(
|
|
42
|
+
method,
|
|
43
|
+
params,
|
|
44
|
+
(fault, result) => {
|
|
45
|
+
if (!socket.destroyed) {
|
|
46
|
+
socket.write(fault ? encodeFault(fault) : encodeResponse(result));
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{socket},
|
|
50
|
+
);
|
|
41
51
|
};
|
|
42
52
|
|
|
43
53
|
server.on('error', (error) => {
|
|
@@ -61,6 +71,22 @@ function unauthorized(response, realm) {
|
|
|
61
71
|
response.end('Unauthorized');
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
const LATIN1 = /^(iso-?8859-1|latin-?1|l1|us-ascii|windows-1252|cp1252)$/i;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* An XML-RPC request body as UTF-8: converted from ISO-8859-1 when the Content-Type header or the
|
|
78
|
+
* XML declaration says so, as it is.
|
|
79
|
+
* @param {Buffer} body
|
|
80
|
+
* @param {string} [contentType]
|
|
81
|
+
* @returns {Buffer}
|
|
82
|
+
*/
|
|
83
|
+
function toUtf8(body, contentType) {
|
|
84
|
+
const header = /charset=["']?([\w-]+)/i.exec(contentType || '');
|
|
85
|
+
const prolog = /^\s*<\?xml[^>]*encoding=["']([\w-]+)["']/i.exec(body.subarray(0, 200).toString('latin1'));
|
|
86
|
+
const charset = (prolog && prolog[1]) || (header && header[1]);
|
|
87
|
+
return charset && LATIN1.test(charset) ? Buffer.from(body.toString('latin1'), 'utf8') : body;
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
/**
|
|
65
91
|
* xmlrpc server, optionally with TLS and/or HTTP basic auth.
|
|
66
92
|
*
|
|
@@ -79,7 +105,7 @@ function unauthorized(response, realm) {
|
|
|
79
105
|
* @param {function} [options.onListening]
|
|
80
106
|
* @returns {object} the homematic-xmlrpc Server instance
|
|
81
107
|
*/
|
|
82
|
-
function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening, onError}) {
|
|
108
|
+
function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening, onError, listen = true}) {
|
|
83
109
|
const serverOptions = tls ? {host, port, ...tls} : {host, port};
|
|
84
110
|
const listening = () => {
|
|
85
111
|
if (typeof onListening === 'function') {
|
|
@@ -90,21 +116,64 @@ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening,
|
|
|
90
116
|
? xmlrpc.createSecureServer(serverOptions, listening)
|
|
91
117
|
: xmlrpc.createServer(serverOptions, listening);
|
|
92
118
|
|
|
119
|
+
if (!listen) {
|
|
120
|
+
// the connections come from createDualServer, see there; homematic-xmlrpc listens on the
|
|
121
|
+
// next tick, so this is in time
|
|
122
|
+
server.httpServer.listen = function () {
|
|
123
|
+
return this;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
93
127
|
server.listenerCount = function (eventName) {
|
|
94
128
|
return INTERNAL_EVENTS.has(eventName) ? EventEmitter.prototype.listenerCount.call(this, eventName) : 1;
|
|
95
129
|
};
|
|
96
130
|
|
|
131
|
+
// homematic-xmlrpc reads every request as UTF-8, but the interface processes speak ISO-8859-1
|
|
132
|
+
// and so do clients that talk to them (openccu-lite's lite-rpc proxy sends it declared in the
|
|
133
|
+
// prolog): the body is read here and handed on as UTF-8. It also emits the method call
|
|
134
|
+
// synchronously from the body's 'end'; a listener registered before its own runs first and
|
|
135
|
+
// tells the dispatch which connection the call came on.
|
|
136
|
+
let current = null;
|
|
137
|
+
const httpServer = server.httpServer;
|
|
138
|
+
const [handleRequest] = httpServer.listeners('request');
|
|
139
|
+
httpServer.removeAllListeners('request');
|
|
140
|
+
httpServer.on('request', (request, response) => {
|
|
141
|
+
const chunks = [];
|
|
142
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
143
|
+
request.on('end', () => {
|
|
144
|
+
const body = new PassThrough();
|
|
145
|
+
Object.assign(body, {
|
|
146
|
+
headers: request.headers,
|
|
147
|
+
url: request.url,
|
|
148
|
+
method: request.method,
|
|
149
|
+
socket: request.socket,
|
|
150
|
+
});
|
|
151
|
+
body.once('end', () => {
|
|
152
|
+
current = {socket: request.socket};
|
|
153
|
+
});
|
|
154
|
+
handleRequest(body, response);
|
|
155
|
+
body.end(toUtf8(Buffer.concat(chunks), request.headers['content-type']));
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
97
159
|
const originalEmit = server.emit;
|
|
98
160
|
server.emit = function (eventName, ...args) {
|
|
99
161
|
if (!INTERNAL_EVENTS.has(eventName) && args.length === 3 && typeof args[2] === 'function') {
|
|
100
162
|
const [, params, callback] = args;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
163
|
+
const context = current || {};
|
|
164
|
+
current = null;
|
|
165
|
+
dispatch(
|
|
166
|
+
eventName,
|
|
167
|
+
params || [],
|
|
168
|
+
(fault, result) => {
|
|
169
|
+
if (fault) {
|
|
170
|
+
callback({faultCode: fault.faultCode, faultString: fault.faultString});
|
|
171
|
+
} else {
|
|
172
|
+
callback(null, result === undefined ? '' : result);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
context,
|
|
176
|
+
);
|
|
108
177
|
return true;
|
|
109
178
|
}
|
|
110
179
|
|
|
@@ -119,7 +188,6 @@ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening,
|
|
|
119
188
|
});
|
|
120
189
|
|
|
121
190
|
if (auth || path) {
|
|
122
|
-
const httpServer = server.httpServer;
|
|
123
191
|
const [handleMethodCall] = httpServer.listeners('request');
|
|
124
192
|
httpServer.removeAllListeners('request');
|
|
125
193
|
const expected = auth
|
|
@@ -147,4 +215,185 @@ function createXmlrpcServer({host, port, dispatch, tls, auth, path, onListening,
|
|
|
147
215
|
return server;
|
|
148
216
|
}
|
|
149
217
|
|
|
150
|
-
|
|
218
|
+
/**
|
|
219
|
+
* Reads BIN-RPC requests from one connection and answers them, as binrpc's server does, but with
|
|
220
|
+
* the simulator's own encoder so that a fault goes out as message type 0xff.
|
|
221
|
+
*
|
|
222
|
+
* @param {net.Socket} socket
|
|
223
|
+
* @param {function} dispatch (method, params, callback) - callback(fault, result)
|
|
224
|
+
* @param {Buffer} [first] bytes already read from the socket
|
|
225
|
+
*/
|
|
226
|
+
function serveBinrpc(socket, dispatch, first) {
|
|
227
|
+
let receiver = Buffer.alloc(0);
|
|
228
|
+
|
|
229
|
+
const receive = (data) => {
|
|
230
|
+
receiver = Buffer.concat([receiver, data]);
|
|
231
|
+
while (receiver.length >= 8) {
|
|
232
|
+
const length = receiver.readUInt32BE(4);
|
|
233
|
+
if (receiver.length < length + 8) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const message = receiver.subarray(0, length + 8);
|
|
237
|
+
receiver = receiver.subarray(length + 8);
|
|
238
|
+
|
|
239
|
+
let request;
|
|
240
|
+
try {
|
|
241
|
+
request = binrpcProtocol.decodeRequest(message);
|
|
242
|
+
} catch {
|
|
243
|
+
request = undefined;
|
|
244
|
+
}
|
|
245
|
+
const method = request && request.method;
|
|
246
|
+
if (typeof method !== 'string' || method === '') {
|
|
247
|
+
socket.write(encodeResponse(''));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
dispatch(
|
|
251
|
+
method,
|
|
252
|
+
request.params || [],
|
|
253
|
+
(fault, result) => {
|
|
254
|
+
if (!socket.destroyed) {
|
|
255
|
+
socket.write(fault ? encodeFault(fault) : encodeResponse(result));
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
{socket},
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
socket.on('data', receive);
|
|
264
|
+
if (first) {
|
|
265
|
+
receive(first);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A duplex stream over a socket that first yields `head`, the bytes already read from it.
|
|
271
|
+
* @param {net.Socket} socket
|
|
272
|
+
* @param {Buffer} head
|
|
273
|
+
* @returns {Duplex}
|
|
274
|
+
*/
|
|
275
|
+
function replay(socket, head) {
|
|
276
|
+
const stream = new Duplex({
|
|
277
|
+
read() {
|
|
278
|
+
socket.resume();
|
|
279
|
+
},
|
|
280
|
+
write(chunk, encoding, callback) {
|
|
281
|
+
socket.write(chunk, encoding, callback);
|
|
282
|
+
},
|
|
283
|
+
final(callback) {
|
|
284
|
+
socket.end();
|
|
285
|
+
callback();
|
|
286
|
+
},
|
|
287
|
+
destroy(error, callback) {
|
|
288
|
+
socket.destroy();
|
|
289
|
+
callback(error);
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
stream.push(head);
|
|
293
|
+
socket.on('data', (data) => {
|
|
294
|
+
if (!stream.push(data)) {
|
|
295
|
+
socket.pause();
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
socket.on('end', () => stream.push(null));
|
|
299
|
+
socket.on('close', () => stream.destroy());
|
|
300
|
+
return stream;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** First bytes of a BIN-RPC message, and of a TLS handshake record. */
|
|
304
|
+
const BIN = Buffer.from('Bin', 'ascii');
|
|
305
|
+
const TLS_HANDSHAKE = 0x16;
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* rfd's and hs485d's port: BIN-RPC and XML-RPC on the same port, as on a CCU.
|
|
309
|
+
*
|
|
310
|
+
* A connection whose first bytes are `Bin` is BIN-RPC; anything else is handed to an HTTP server
|
|
311
|
+
* running the XML-RPC dispatch - with `tls` an HTTPS server, and a connection that does not start
|
|
312
|
+
* with a TLS handshake is closed. BIN-RPC stays plain, and basic auth applies to the XML-RPC half
|
|
313
|
+
* only: BIN-RPC has no such thing.
|
|
314
|
+
*
|
|
315
|
+
* @param {object} options as createXmlrpcServer, plus
|
|
316
|
+
* @param {string[]} [options.protocols=['binrpc', 'xmlrpc']] what this port answers
|
|
317
|
+
* @returns {{server: net.Server, close: function(): Promise<void>, on: function}} an emitter with
|
|
318
|
+
* `server` (the net server) and `close()`, like the other two server kinds
|
|
319
|
+
*/
|
|
320
|
+
function createDualServer({host, port, dispatch, tls, auth, onListening, onError, protocols}) {
|
|
321
|
+
const accepts = new Set(protocols || ['binrpc', 'xmlrpc']);
|
|
322
|
+
const emitter = new EventEmitter();
|
|
323
|
+
const connections = new Set();
|
|
324
|
+
|
|
325
|
+
const xmlrpcServer = (secure) =>
|
|
326
|
+
createXmlrpcServer({host, port: 0, dispatch, tls: secure ? tls : null, auth, listen: false});
|
|
327
|
+
// with tls the XML-RPC half is HTTPS only, as the simulator's xmlrpc servers are
|
|
328
|
+
const http = accepts.has('xmlrpc') && !tls ? xmlrpcServer(false) : null;
|
|
329
|
+
const https = accepts.has('xmlrpc') && tls ? xmlrpcServer(true) : null;
|
|
330
|
+
|
|
331
|
+
const server = net.createServer((socket) => {
|
|
332
|
+
connections.add(socket);
|
|
333
|
+
socket.on('close', () => connections.delete(socket));
|
|
334
|
+
socket.on('error', () => {});
|
|
335
|
+
|
|
336
|
+
let head = Buffer.alloc(0);
|
|
337
|
+
const sniff = (data) => {
|
|
338
|
+
head = Buffer.concat([head, data]);
|
|
339
|
+
if (head.length < BIN.length && BIN.subarray(0, head.length).equals(head)) {
|
|
340
|
+
// could still be "Bin": wait for more
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
socket.removeListener('data', sniff);
|
|
344
|
+
|
|
345
|
+
if (head.subarray(0, BIN.length).equals(BIN)) {
|
|
346
|
+
if (accepts.has('binrpc')) {
|
|
347
|
+
serveBinrpc(socket, dispatch, head);
|
|
348
|
+
} else {
|
|
349
|
+
socket.destroy();
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (head[0] === TLS_HANDSHAKE && https) {
|
|
355
|
+
// a TLS socket reads from the connection's handle, past anything unshifted: it
|
|
356
|
+
// gets a stream that starts with the bytes already read
|
|
357
|
+
https.httpServer.emit('connection', replay(socket, head));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (!http) {
|
|
361
|
+
socket.destroy();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
socket.pause();
|
|
365
|
+
socket.unshift(head);
|
|
366
|
+
http.httpServer.emit('connection', socket);
|
|
367
|
+
socket.resume();
|
|
368
|
+
};
|
|
369
|
+
socket.on('data', sniff);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
server.on('error', (error) => {
|
|
373
|
+
if (emitter.listenerCount('error') > 0) {
|
|
374
|
+
emitter.emit('error', error);
|
|
375
|
+
}
|
|
376
|
+
if (typeof onError === 'function') {
|
|
377
|
+
onError(error);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
server.listen(port, host, () => {
|
|
382
|
+
emitter.emit('listening');
|
|
383
|
+
if (typeof onListening === 'function') {
|
|
384
|
+
onListening(emitter);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
emitter.server = server;
|
|
389
|
+
emitter.close = () =>
|
|
390
|
+
new Promise((resolve) => {
|
|
391
|
+
server.close(() => resolve());
|
|
392
|
+
for (const socket of connections) {
|
|
393
|
+
socket.destroy();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return emitter;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
module.exports = {createBinrpcServer, createXmlrpcServer, createDualServer};
|