hm-simulator 0.1.1 → 1.1.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 +345 -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 +72 -7
- package/lib/binrpc-codec.js +100 -0
- package/lib/faults.js +126 -0
- package/lib/method-help.js +54 -0
- package/lib/rega.js +245 -0
- package/lib/servers.js +150 -0
- package/lib/sim.js +1930 -0
- package/lib/tls.js +160 -0
- package/lib/validate.js +233 -0
- package/package.json +31 -22
- package/sim.js +3 -541
- package/sim.mjs +5 -0
- package/.travis.yml +0 -3
- package/config.js +0 -3
package/index.js
CHANGED
|
@@ -1,19 +1,84 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const {parseArgs} = require('node:util');
|
|
6
|
+
|
|
4
7
|
const HmSim = require('./sim.js');
|
|
8
|
+
const pkg = require('./package.json');
|
|
9
|
+
|
|
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
|
+
});
|
|
24
|
+
|
|
25
|
+
if (args.version) {
|
|
26
|
+
console.log(pkg.version);
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (args.help) {
|
|
31
|
+
console.log(`hm-simulator ${pkg.version} - simulates a Homematic CCU
|
|
5
32
|
|
|
6
|
-
|
|
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`);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const LEVELS = ['error', 'warn', 'info', 'debug'];
|
|
49
|
+
const level = LEVELS.indexOf(args.verbosity) === -1 ? 2 : LEVELS.indexOf(args.verbosity);
|
|
50
|
+
const logAt = (index, method) =>
|
|
51
|
+
level >= index ? (...arguments_) => console[method](new Date().toISOString(), ...arguments_) : () => {};
|
|
52
|
+
|
|
53
|
+
const log = {
|
|
54
|
+
error: logAt(0, 'error'),
|
|
55
|
+
warn: logAt(1, 'warn'),
|
|
56
|
+
info: logAt(2, 'log'),
|
|
57
|
+
debug: logAt(3, 'log'),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const port = (value) => (value === undefined ? undefined : Number.parseInt(value, 10));
|
|
7
61
|
|
|
8
62
|
const hmSim = new HmSim({
|
|
9
63
|
log,
|
|
10
64
|
devices: {
|
|
11
65
|
rfd: require('./data/devices-rfd.json'),
|
|
12
|
-
hmip: require('./data/devices-hmip.json')
|
|
66
|
+
hmip: require('./data/devices-hmip.json'),
|
|
13
67
|
},
|
|
14
68
|
config: {
|
|
15
|
-
listenAddress: '
|
|
16
|
-
binrpcListenPort:
|
|
17
|
-
xmlrpcListenPort:
|
|
18
|
-
|
|
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']},
|
|
19
76
|
});
|
|
77
|
+
|
|
78
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
79
|
+
process.on(signal, () => {
|
|
80
|
+
log.info('got', signal, '- shutting down');
|
|
81
|
+
hmSim.close();
|
|
82
|
+
process.exit(0);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal binrpc response encoder.
|
|
5
|
+
*
|
|
6
|
+
* binrpc's own server only ever writes message type 0x01 (response); the real rfd/crRFD answers an
|
|
7
|
+
* invalid call with message type 0xff and a struct body holding faultCode and faultString. To be
|
|
8
|
+
* able to send both, the simulator encodes responses itself instead of reaching into binrpc's
|
|
9
|
+
* internals. The encoding mirrors binrpc's protocol module byte for byte (every 32 bit word
|
|
10
|
+
* big-endian, strings ascii, doubles as mantissa/exponent pair); `test/binrpc-codec.test.js`
|
|
11
|
+
* verifies that against a real binrpc client.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function word32(value) {
|
|
15
|
+
const buf = Buffer.alloc(4);
|
|
16
|
+
buf.writeUInt32BE(value >>> 0, 0);
|
|
17
|
+
return buf;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function encodeInteger(value) {
|
|
21
|
+
return Buffer.concat([word32(0x01), word32(value)]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function encodeDouble(value) {
|
|
25
|
+
const exponent = Math.floor(Math.log(Math.abs(value)) / Math.LN2) + 1;
|
|
26
|
+
const mantissa = Math.floor(value * Math.pow(2, -exponent) * (1 << 30));
|
|
27
|
+
return Buffer.concat([word32(0x04), word32(mantissa), word32(exponent)]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function encodeString(value) {
|
|
31
|
+
const str = String(value);
|
|
32
|
+
return Buffer.concat([word32(0x03), word32(str.length), Buffer.from(str, 'ascii')]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function encodeBool(value) {
|
|
36
|
+
return Buffer.concat([word32(0x02), Buffer.from([value ? 1 : 0])]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encodeKey(key) {
|
|
40
|
+
return Buffer.concat([word32(key.length), Buffer.from(key, 'ascii')]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encodeData(data) {
|
|
44
|
+
if (data === undefined || data === null) {
|
|
45
|
+
return encodeString('');
|
|
46
|
+
}
|
|
47
|
+
switch (typeof data) {
|
|
48
|
+
case 'number':
|
|
49
|
+
return data % 1 === 0 ? encodeInteger(data) : encodeDouble(data);
|
|
50
|
+
case 'boolean':
|
|
51
|
+
return encodeBool(data);
|
|
52
|
+
case 'string':
|
|
53
|
+
return encodeString(data);
|
|
54
|
+
case 'object':
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
return encodeString(String(data));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Array.isArray(data)) {
|
|
61
|
+
return Buffer.concat([word32(0x100), word32(data.length), ...data.map((item) => encodeData(item))]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof data.explicitDouble === 'number') {
|
|
65
|
+
return encodeDouble(data.explicitDouble);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const keys = Object.keys(data).filter((key) => data[key] !== undefined);
|
|
69
|
+
const members = [];
|
|
70
|
+
for (const key of keys) {
|
|
71
|
+
members.push(encodeKey(key), encodeData(data[key]));
|
|
72
|
+
}
|
|
73
|
+
return Buffer.concat([word32(0x101), word32(keys.length), ...members]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function message(type, body) {
|
|
77
|
+
return Buffer.concat([Buffer.from('Bin', 'ascii'), Buffer.from([type]), word32(body.length), body]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {*} data
|
|
82
|
+
* @returns {Buffer} message type 0x01
|
|
83
|
+
*/
|
|
84
|
+
function encodeResponse(data) {
|
|
85
|
+
return message(0x01, encodeData(data === undefined ? '' : data));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param {{faultCode: number, faultString: string}} fault
|
|
90
|
+
* @returns {Buffer} message type 0xff
|
|
91
|
+
*/
|
|
92
|
+
function encodeFault(fault) {
|
|
93
|
+
const body = encodeData({
|
|
94
|
+
faultCode: Number(fault.faultCode) || 0,
|
|
95
|
+
faultString: fault.faultString === undefined ? '' : String(fault.faultString),
|
|
96
|
+
});
|
|
97
|
+
return message(0xff, body);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {encodeData, encodeResponse, encodeFault};
|
package/lib/faults.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fault responses.
|
|
5
|
+
*
|
|
6
|
+
* The real interface processes (rfd/crRFD, hmipserver, CUxD) answer invalid calls with an XML-RPC
|
|
7
|
+
* fault (binrpc message type 0xff with a struct body). The exact codes and strings are not
|
|
8
|
+
* publicly specified, so until 2026-09-05 the table below was an educated guess following the
|
|
9
|
+
* negative-code convention of the eq3 XML-RPC API.
|
|
10
|
+
*
|
|
11
|
+
* It is a measurement now. Homematic Manager's roadmap task 6 probed both interface processes of
|
|
12
|
+
* two CCUs on firmware 3.89.8 with calls that cannot change anything - an address that does not
|
|
13
|
+
* exist, a paramset that does not exist, a read-only parameter, a method that does not exist - and
|
|
14
|
+
* recorded what came back; `docs/config-pending.md` in that repository has the full tables and the
|
|
15
|
+
* script that produced them.
|
|
16
|
+
*
|
|
17
|
+
* The two processes disagree, and not only in wording:
|
|
18
|
+
*
|
|
19
|
+
* - hmipserver answers a fault for nearly everything and uses **-5 `Invalid parameter or value`**
|
|
20
|
+
* for every kind of bad `putParamset`, whatever went wrong.
|
|
21
|
+
* - rfd answers **no fault at all** for a whole class of mistakes: an unknown paramset name is
|
|
22
|
+
* taken as a peer address, a missing argument defaults, a write to a read-only datapoint is
|
|
23
|
+
* accepted. What it does fault on, it names differently.
|
|
24
|
+
*
|
|
25
|
+
* {@link DEFAULT_FAULTS} is hmipserver's table, because that is the one an application has to
|
|
26
|
+
* survive; {@link BIDCOS_FAULTS} is rfd's, for a simulator that models a BidCos interface. Every
|
|
27
|
+
* entry can still be overridden through the `faults` constructor option, and `sim.faults` exposes
|
|
28
|
+
* the effective table.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** hmipserver 3.89.8, measured 2026-09-05. */
|
|
32
|
+
const DEFAULT_FAULTS = {
|
|
33
|
+
// no handler for the requested method name. hmipserver answers this at the HTTP level and
|
|
34
|
+
// sends no faultCode at all, so -1 is the simulator's stand-in
|
|
35
|
+
unknownMethod: {faultCode: -1, faultString: 'Invalid XML-RPC message'},
|
|
36
|
+
// device or channel address not known to this interface process
|
|
37
|
+
unknownInstance: {faultCode: -2, faultString: 'Invalid device'},
|
|
38
|
+
// the device/channel exists but does not have the requested paramset
|
|
39
|
+
unknownParamset: {faultCode: -2, faultString: 'Invalid device'},
|
|
40
|
+
// the paramset exists but does not contain the requested parameter (getValue/setValue)
|
|
41
|
+
unknownParameter: {faultCode: -5, faultString: 'Unknown Parameter for value key'},
|
|
42
|
+
// the two channels are not linked with each other
|
|
43
|
+
unknownLink: {faultCode: -2, faultString: 'Invalid device'},
|
|
44
|
+
// parameter exists but OPERATIONS does not include write (2)
|
|
45
|
+
readOnly: {faultCode: -5, faultString: 'Invalid parameter or value'},
|
|
46
|
+
// value has a type the parameter's TYPE does not accept
|
|
47
|
+
typeError: {faultCode: -5, faultString: 'Invalid parameter or value'},
|
|
48
|
+
// ENUM index/name not in VALUE_LIST. hmipserver does *not* check MIN..MAX at all - see the
|
|
49
|
+
// 'hmip' configPendingMode in lib/sim.js
|
|
50
|
+
outOfRange: {faultCode: -5, faultString: 'Invalid parameter or value'},
|
|
51
|
+
// the stored configuration of a channel cannot be transferred to the device: the fault a
|
|
52
|
+
// poisoned channel answers to every putParamset, including one with an empty struct
|
|
53
|
+
invalidValue: {faultCode: -5, faultString: 'Invalid parameter or value'},
|
|
54
|
+
// method understood but not implemented for this interface: clearConfigCache,
|
|
55
|
+
// restoreConfigToDevice and determineParameter all answer this on hmipserver
|
|
56
|
+
notSupported: {faultCode: -1, faultString: 'Generic error'},
|
|
57
|
+
// device is known but currently not reachable (a sleeping battery device)
|
|
58
|
+
notReachable: {faultCode: -1, faultString: 'Generic error (UNREACH)'},
|
|
59
|
+
// malformed parameters. hmipserver lets a Java NullPointerException through here and answers
|
|
60
|
+
// -321 with the exception message, which is not something an application can rely on
|
|
61
|
+
invalidArguments: {faultCode: -321, faultString: 'Invalid arguments'},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** rfd (BidCos-RF) 3.89.8, measured 2026-09-05. */
|
|
65
|
+
const BIDCOS_FAULTS = {
|
|
66
|
+
unknownMethod: {faultCode: -1, faultString: 'unknown method name'},
|
|
67
|
+
unknownInstance: {faultCode: -2, faultString: 'Unknown instance'},
|
|
68
|
+
// rfd does not fault here at all: an unknown paramset name is taken as a peer channel address
|
|
69
|
+
// and the link defaults come back. The entry covers what the simulator cannot model.
|
|
70
|
+
unknownParamset: {faultCode: -2, faultString: 'Unknown instance'},
|
|
71
|
+
unknownParameter: {faultCode: -5, faultString: 'Unknown parameter'},
|
|
72
|
+
unknownLink: {faultCode: -1, faultString: 'Failure'},
|
|
73
|
+
// rfd accepts a setValue on a read-only datapoint, so this is never raised against it
|
|
74
|
+
readOnly: {faultCode: -5, faultString: 'Unknown parameter'},
|
|
75
|
+
typeError: {faultCode: -5, faultString: 'Unknown parameter'},
|
|
76
|
+
outOfRange: {faultCode: -5, faultString: 'Unknown parameter'},
|
|
77
|
+
invalidValue: {faultCode: -5, faultString: 'Unknown parameter'},
|
|
78
|
+
notSupported: {faultCode: -1, faultString: 'Failure'},
|
|
79
|
+
notReachable: {faultCode: -1, faultString: 'Failure'},
|
|
80
|
+
invalidArguments: {faultCode: -1, faultString: 'Failure'},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The measured tables by name, for `new HmSim({faults: FAULT_TABLES.bidcos})`. */
|
|
84
|
+
const FAULT_TABLES = {hmip: DEFAULT_FAULTS, bidcos: BIDCOS_FAULTS};
|
|
85
|
+
|
|
86
|
+
/** Error carrying an XML-RPC fault. */
|
|
87
|
+
class RpcFault extends Error {
|
|
88
|
+
constructor(faultCode, faultString, detail) {
|
|
89
|
+
super(detail ? `${faultString}: ${detail}` : faultString);
|
|
90
|
+
this.name = 'RpcFault';
|
|
91
|
+
this.faultCode = faultCode;
|
|
92
|
+
this.faultString = faultString;
|
|
93
|
+
this.detail = detail;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The struct a binrpc fault carries as its body. */
|
|
97
|
+
toStruct() {
|
|
98
|
+
return {faultCode: this.faultCode, faultString: this.faultString};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds a fault factory bound to a (possibly overridden) fault table.
|
|
104
|
+
* @param {object} [overrides] partial table, same shape as DEFAULT_FAULTS
|
|
105
|
+
* @returns {{table: object, fault: function(string, string=): RpcFault}}
|
|
106
|
+
*/
|
|
107
|
+
function createFaults(overrides = {}) {
|
|
108
|
+
const table = {};
|
|
109
|
+
for (const [key, value] of Object.entries(DEFAULT_FAULTS)) {
|
|
110
|
+
table[key] = {...value, ...overrides[key]};
|
|
111
|
+
}
|
|
112
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
113
|
+
if (!table[key]) {
|
|
114
|
+
table[key] = {...value};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const fault = (key, detail) => {
|
|
119
|
+
const entry = table[key] || table.unknownMethod;
|
|
120
|
+
return new RpcFault(entry.faultCode, entry.faultString, detail);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {table, fault};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = {DEFAULT_FAULTS, BIDCOS_FAULTS, FAULT_TABLES, RpcFault, createFaults};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Answers to `system.methodHelp`. The real interface processes answer with a one line signature;
|
|
5
|
+
* the wording here follows the eq3 XML-RPC API documentation.
|
|
6
|
+
*/
|
|
7
|
+
module.exports = {
|
|
8
|
+
'system.listMethods': 'array system.listMethods() - names of all methods this interface knows',
|
|
9
|
+
'system.methodHelp': 'string system.methodHelp(string method) - the documentation of a method',
|
|
10
|
+
'system.multicall': 'array system.multicall(array calls) - execute several method calls at once',
|
|
11
|
+
init: 'string init(string url, string interfaceId) - register or (with an empty id) remove a logic layer',
|
|
12
|
+
listDevices: 'array listDevices() - descriptions of all devices and channels',
|
|
13
|
+
getDeviceDescription: 'struct getDeviceDescription(string address) - description of one device or channel',
|
|
14
|
+
getParamsetDescription:
|
|
15
|
+
'struct getParamsetDescription(string address, string paramsetType) - description of a paramset',
|
|
16
|
+
getParamsetId: 'string getParamsetId(string address, string paramsetType) - identifier of a paramset',
|
|
17
|
+
getParamset: 'struct getParamset(string address, string paramsetKey) - values of a paramset',
|
|
18
|
+
putParamset: 'void putParamset(string address, string paramsetKey, struct set) - write values of a paramset',
|
|
19
|
+
getValue: 'ValueType getValue(string address, string valueKey) - read one value',
|
|
20
|
+
setValue: 'void setValue(string address, string valueKey, ValueType value) - write one value',
|
|
21
|
+
ping: 'boolean ping(string callerId) - triggers a PONG event',
|
|
22
|
+
getLinks: 'array getLinks(string address, int flags) - links of a channel or of the whole interface',
|
|
23
|
+
getLinkInfo: 'struct getLinkInfo(string senderAddress, string receiverAddress) - name and description of a link',
|
|
24
|
+
setLinkInfo:
|
|
25
|
+
'void setLinkInfo(string senderAddress, string receiverAddress, string name, string description) - rename a link',
|
|
26
|
+
getLinkPeers: 'array getLinkPeers(string address) - the peers a channel is linked with',
|
|
27
|
+
addLink:
|
|
28
|
+
'void addLink(string senderAddress, string receiverAddress, string name, string description) - link two channels',
|
|
29
|
+
removeLink: 'void removeLink(string senderAddress, string receiverAddress) - remove a link',
|
|
30
|
+
activateLinkParamset:
|
|
31
|
+
'void activateLinkParamset(string address, string peerAddress, boolean longPress) - apply a link paramset',
|
|
32
|
+
rssiInfo: 'struct rssiInfo() - receive levels of all devices',
|
|
33
|
+
listBidcosInterfaces: 'array listBidcosInterfaces() - the BidCos interfaces (antennas) of this process',
|
|
34
|
+
setBidcosInterface:
|
|
35
|
+
'void setBidcosInterface(string deviceId, string interfaceId, boolean roaming) - assign a device to an interface',
|
|
36
|
+
getServiceMessages: 'array getServiceMessages() - the pending service messages',
|
|
37
|
+
setInstallMode: 'void setInstallMode(boolean on, int time, int mode) - switch the install mode',
|
|
38
|
+
getInstallMode: 'int getInstallMode() - remaining seconds of the install mode',
|
|
39
|
+
setTempKey: 'void setTempKey(string passphrase) - the temporary key the next BidCos pairings use; BidCos only',
|
|
40
|
+
listTeams: 'array listTeams() - the team pseudo devices (smoke detector teams) with their channels; BidCos only',
|
|
41
|
+
setTeam:
|
|
42
|
+
'void setTeam(string channelAddress, string teamAddress) - put a channel into a team, an empty team into its own; BidCos only',
|
|
43
|
+
deleteDevice: 'void deleteDevice(string address, int flags) - remove a device',
|
|
44
|
+
replaceDevice:
|
|
45
|
+
'void replaceDevice(string oldDeviceAddress, string newDeviceAddress) - take over the configuration of a device',
|
|
46
|
+
reportValueUsage:
|
|
47
|
+
'void reportValueUsage(string address, string valueId, int refCounter) - announce datapoint usage',
|
|
48
|
+
updateFirmware: 'boolean updateFirmware(array deviceAddresses) - start a firmware update',
|
|
49
|
+
installFirmware: 'boolean installFirmware(string deviceAddress) - install a pending firmware',
|
|
50
|
+
clearConfigCache: 'void clearConfigCache(string address) - drop the cached configuration of a device',
|
|
51
|
+
restoreConfigToDevice: 'void restoreConfigToDevice(string address) - write the cached configuration to the device',
|
|
52
|
+
determineParameter:
|
|
53
|
+
'void determineParameter(string address, string paramsetKey, string parameterId) - read a parameter from the device',
|
|
54
|
+
};
|
package/lib/rega.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ReGa (HomeMatic logic layer) mock.
|
|
7
|
+
*
|
|
8
|
+
* Answers the `rega.exe` endpoint the way the CCU does: the script output followed by an `<xml>`
|
|
9
|
+
* block. The scripts of the `homematic-rega` client are recognised by their first line
|
|
10
|
+
* (`!# devices.rega`, `!# variables.rega`, `!# programs.rega`, `!# rooms.rega`,
|
|
11
|
+
* `!# functions.rega`, `!# values.rega`); `getChannels()` uses `channels.rega`, whose first line is
|
|
12
|
+
* the historical `!# devices.rega`.
|
|
13
|
+
*
|
|
14
|
+
* Every script that is not one of those is echoed into `regaSim.scripts` (and, for the ones the
|
|
15
|
+
* mock understands, applied to the mock's state), so tests can assert on what was executed -
|
|
16
|
+
* renames (`dom.GetObject(id).Name("...")`) in particular.
|
|
17
|
+
*/
|
|
18
|
+
class RegaSim {
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {number} [options.port=8181]
|
|
22
|
+
* @param {string} [options.listenAddress]
|
|
23
|
+
* @param {Array} [options.variables]
|
|
24
|
+
* @param {Array} [options.channels] devices and channels, the answer to `getChannels()`
|
|
25
|
+
* @param {Array} [options.programs]
|
|
26
|
+
* @param {Array} [options.rooms]
|
|
27
|
+
* @param {Array} [options.functions]
|
|
28
|
+
* @param {Array} [options.values] the answer to `getValues()`
|
|
29
|
+
* @param {object} [options.auth] {username, password} - enables HTTP basic auth
|
|
30
|
+
* @param {object} [options.tls] {key, cert} - serve https instead of http
|
|
31
|
+
* @param {object} [log]
|
|
32
|
+
*/
|
|
33
|
+
constructor(options, log) {
|
|
34
|
+
this.log = log || {debug() {}, info() {}, warn() {}, error() {}};
|
|
35
|
+
this.variables = options.variables || [];
|
|
36
|
+
this.devices = options.channels || [];
|
|
37
|
+
this.programs = options.programs || [];
|
|
38
|
+
this.rooms = options.rooms || [];
|
|
39
|
+
this.functions = options.functions || [];
|
|
40
|
+
this.values = options.values || [];
|
|
41
|
+
/** every script the mock received, newest last */
|
|
42
|
+
this.scripts = [];
|
|
43
|
+
/** every rename script, as {id, name, script} */
|
|
44
|
+
this.renames = [];
|
|
45
|
+
this.auth = options.auth || null;
|
|
46
|
+
|
|
47
|
+
const handler = (request, response) => this.handleRequest(request, response);
|
|
48
|
+
this.server = options.tls
|
|
49
|
+
? require('node:https').createServer(options.tls, handler)
|
|
50
|
+
: http.createServer(handler);
|
|
51
|
+
/** the port the mock listens on - known once `ready` resolved, which matters for port 0 */
|
|
52
|
+
this.port = options.port === undefined ? 8181 : options.port;
|
|
53
|
+
/** resolves once the mock accepts connections, rejects when the port is taken */
|
|
54
|
+
this.ready = new Promise((resolve, reject) => {
|
|
55
|
+
this.server.on('error', reject);
|
|
56
|
+
this.server.listen(this.port, options.listenAddress, () => {
|
|
57
|
+
const address = this.server.address();
|
|
58
|
+
if (address) {
|
|
59
|
+
this.port = address.port;
|
|
60
|
+
}
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
handleRequest(request, response) {
|
|
67
|
+
if (this.auth) {
|
|
68
|
+
const expected =
|
|
69
|
+
'Basic ' + Buffer.from(`${this.auth.username}:${this.auth.password || ''}`, 'utf8').toString('base64');
|
|
70
|
+
if (request.headers.authorization !== expected) {
|
|
71
|
+
response.writeHead(401, {
|
|
72
|
+
'WWW-Authenticate': 'Basic realm="hm-simulator"',
|
|
73
|
+
'Content-Type': 'text/plain',
|
|
74
|
+
});
|
|
75
|
+
response.end('Unauthorized');
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const path = (request.url || '/').split('?')[0];
|
|
81
|
+
if (!/^\/[\w-]+\.exe$/.test(path)) {
|
|
82
|
+
response.writeHead(404, {'Content-Type': 'text/plain'});
|
|
83
|
+
response.end('Not Found');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (request.method !== 'POST') {
|
|
88
|
+
this.send(response, this.response(''));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const chunks = [];
|
|
93
|
+
request.on('data', (chunk) => chunks.push(chunk));
|
|
94
|
+
request.on('end', () => {
|
|
95
|
+
const script = Buffer.concat(chunks).toString('latin1');
|
|
96
|
+
this.send(response, this.response(this.exec(script)));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Runs a script against the mock's state.
|
|
102
|
+
* @param {string} script
|
|
103
|
+
* @returns {string|Array|object} the script's output
|
|
104
|
+
*/
|
|
105
|
+
exec(script) {
|
|
106
|
+
this.scripts.push(script);
|
|
107
|
+
const line = script.split('\n')[0].trim();
|
|
108
|
+
|
|
109
|
+
switch (line) {
|
|
110
|
+
case '!# devices.rega':
|
|
111
|
+
return this.devices;
|
|
112
|
+
case '!# variables.rega':
|
|
113
|
+
return this.variables;
|
|
114
|
+
case '!# programs.rega':
|
|
115
|
+
return this.programs;
|
|
116
|
+
case '!# rooms.rega':
|
|
117
|
+
return this.rooms;
|
|
118
|
+
case '!# functions.rega':
|
|
119
|
+
return this.functions;
|
|
120
|
+
case '!# values.rega':
|
|
121
|
+
return this.values;
|
|
122
|
+
default:
|
|
123
|
+
return this.execStatement(script, line);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
execStatement(script, line) {
|
|
128
|
+
let match = line.match(/dom\.GetObject\((\d+)\)\.State\(([^)]*)\)/);
|
|
129
|
+
if (match) {
|
|
130
|
+
const id = Number.parseInt(match[1], 10);
|
|
131
|
+
let value;
|
|
132
|
+
try {
|
|
133
|
+
value = JSON.parse(match[2]);
|
|
134
|
+
} catch {
|
|
135
|
+
value = match[2];
|
|
136
|
+
}
|
|
137
|
+
for (const [index, variable] of this.variables.entries()) {
|
|
138
|
+
if (variable.id === id) {
|
|
139
|
+
this.variables[index].val = value;
|
|
140
|
+
this.variables[index].ts = this.ts();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
match = line.match(/dom\.GetObject\((\d+)\)\.Name\("([^"]*)"\)/);
|
|
147
|
+
if (match) {
|
|
148
|
+
const id = Number.parseInt(match[1], 10);
|
|
149
|
+
const name = match[2];
|
|
150
|
+
this.renames.push({id, name, script});
|
|
151
|
+
for (const list of [this.devices, this.variables, this.programs, this.rooms, this.functions]) {
|
|
152
|
+
for (const item of list) {
|
|
153
|
+
if (item.id === id) {
|
|
154
|
+
item.name = name;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
match = line.match(/dom\.GetObject\((\d+)\)\.Active\((true|false)\)/);
|
|
162
|
+
if (match) {
|
|
163
|
+
const id = Number.parseInt(match[1], 10);
|
|
164
|
+
for (const program of this.programs) {
|
|
165
|
+
if (program.id === id) {
|
|
166
|
+
program.active = match[2] === 'true';
|
|
167
|
+
program.ts = this.ts();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
match = line.match(/dom\.GetObject\((\d+)\)\.ProgramExecute\(\)/);
|
|
174
|
+
if (match) {
|
|
175
|
+
const id = Number.parseInt(match[1], 10);
|
|
176
|
+
for (const program of this.programs) {
|
|
177
|
+
if (program.id === id) {
|
|
178
|
+
program.ts = this.ts();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
this.log.warn('unknown script', line);
|
|
185
|
+
return '';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
send(response, body) {
|
|
189
|
+
const buffer = Buffer.from(body, 'latin1');
|
|
190
|
+
response.writeHead(200, {
|
|
191
|
+
server: 'ise GmbH HTTP-Server v2.0',
|
|
192
|
+
'accept-ranges': 'bytes',
|
|
193
|
+
'cache-control': 'no-store, no-cache',
|
|
194
|
+
'content-type': 'text/xml; charset=iso-8859-1',
|
|
195
|
+
'content-length': buffer.length,
|
|
196
|
+
});
|
|
197
|
+
response.end(buffer);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
close() {
|
|
201
|
+
this.server.close();
|
|
202
|
+
if (typeof this.server.closeAllConnections === 'function') {
|
|
203
|
+
this.server.closeAllConnections();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
response(stdout, vars = []) {
|
|
208
|
+
if (typeof stdout !== 'string') {
|
|
209
|
+
stdout = JSON.stringify(stdout);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const xml = [
|
|
213
|
+
['exec', 'rega.exe'],
|
|
214
|
+
['sessionId', ''],
|
|
215
|
+
['httpUserAgent', ''],
|
|
216
|
+
].concat(vars);
|
|
217
|
+
|
|
218
|
+
stdout += '<xml>';
|
|
219
|
+
for (const [key, value] of xml) {
|
|
220
|
+
stdout += `<${key}>${value}</${key}>`;
|
|
221
|
+
}
|
|
222
|
+
stdout += '</xml>';
|
|
223
|
+
|
|
224
|
+
return stdout;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
ts() {
|
|
228
|
+
const d = new Date();
|
|
229
|
+
return (
|
|
230
|
+
d.getFullYear() +
|
|
231
|
+
'-' +
|
|
232
|
+
('0' + (d.getMonth() + 1)).slice(-2) +
|
|
233
|
+
'-' +
|
|
234
|
+
('0' + d.getDate()).slice(-2) +
|
|
235
|
+
' ' +
|
|
236
|
+
('0' + d.getHours()).slice(-2) +
|
|
237
|
+
':' +
|
|
238
|
+
('0' + d.getMinutes()).slice(-2) +
|
|
239
|
+
':' +
|
|
240
|
+
('0' + d.getSeconds()).slice(-2)
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = RegaSim;
|