hm-simulator 0.1.1 → 1.0.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/lib/tls.js ADDED
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+
5
+ /**
6
+ * Self signed certificate, generated at start.
7
+ *
8
+ * The CCU serves its XML-RPC interfaces over TLS with a certificate nobody can validate either, so
9
+ * a client that talks to the simulator has to be able to deal with exactly that. Node can generate
10
+ * key pairs but not certificates, and a test simulator should not need a dependency (or a private
11
+ * key checked into the repository) for it, so the certificate is DER encoded here by hand. It is a
12
+ * plain v3 certificate for localhost/127.0.0.1, valid for one year, and it is only ever meant for
13
+ * `rejectUnauthorized: false` clients.
14
+ */
15
+
16
+ function length(size) {
17
+ if (size < 0x80) {
18
+ return Buffer.from([size]);
19
+ }
20
+ const bytes = [];
21
+ let rest = size;
22
+ while (rest > 0) {
23
+ bytes.unshift(rest & 0xff);
24
+ rest >>= 8;
25
+ }
26
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
27
+ }
28
+
29
+ const tagged = (tag, content) => Buffer.concat([Buffer.from([tag]), length(content.length), content]);
30
+ const sequence = (...parts) => tagged(0x30, Buffer.concat(parts));
31
+ const set = (content) => tagged(0x31, content);
32
+ const octetString = (content) => tagged(0x04, content);
33
+ const bitString = (content) => tagged(0x03, Buffer.concat([Buffer.from([0x00]), content]));
34
+ const utf8String = (value) => tagged(0x0c, Buffer.from(value, 'utf8'));
35
+ const boolean = (value) => tagged(0x01, Buffer.from([value ? 0xff : 0x00]));
36
+ const nullValue = () => Buffer.from([0x05, 0x00]);
37
+
38
+ function integer(value) {
39
+ let bytes = [];
40
+ let rest = BigInt(value);
41
+ if (rest === 0n) {
42
+ bytes = [0];
43
+ }
44
+ while (rest > 0n) {
45
+ bytes.unshift(Number(rest & 0xffn));
46
+ rest >>= 8n;
47
+ }
48
+ if (bytes[0] & 0x80) {
49
+ bytes.unshift(0);
50
+ }
51
+ return tagged(0x02, Buffer.from(bytes));
52
+ }
53
+
54
+ function oid(dotted) {
55
+ const parts = dotted.split('.').map(Number);
56
+ const bytes = [parts[0] * 40 + parts[1]];
57
+ for (const part of parts.slice(2)) {
58
+ const chunk = [part & 0x7f];
59
+ let rest = part >> 7;
60
+ while (rest > 0) {
61
+ chunk.unshift((rest & 0x7f) | 0x80);
62
+ rest >>= 7;
63
+ }
64
+ bytes.push(...chunk);
65
+ }
66
+ return tagged(0x06, Buffer.from(bytes));
67
+ }
68
+
69
+ function utcTime(date) {
70
+ const pad = (value) => String(value).padStart(2, '0');
71
+ const text =
72
+ pad(date.getUTCFullYear() % 100) +
73
+ pad(date.getUTCMonth() + 1) +
74
+ pad(date.getUTCDate()) +
75
+ pad(date.getUTCHours()) +
76
+ pad(date.getUTCMinutes()) +
77
+ pad(date.getUTCSeconds()) +
78
+ 'Z';
79
+ return tagged(0x17, Buffer.from(text, 'ascii'));
80
+ }
81
+
82
+ /** Name with a single CN attribute. */
83
+ const name = (commonName) => sequence(set(sequence(oid('2.5.4.3'), utf8String(commonName))));
84
+
85
+ const extension = (id, critical, value) =>
86
+ critical ? sequence(oid(id), boolean(true), octetString(value)) : sequence(oid(id), octetString(value));
87
+
88
+ function subjectAltName(hostnames, ips) {
89
+ const parts = [
90
+ ...hostnames.map((host) => tagged(0x82, Buffer.from(host, 'ascii'))),
91
+ ...ips.map((ip) => tagged(0x87, Buffer.from(ip.split('.').map(Number)))),
92
+ ];
93
+ return sequence(...parts);
94
+ }
95
+
96
+ /**
97
+ * @param {object} [options]
98
+ * @param {string} [options.commonName='localhost']
99
+ * @param {Array<string>} [options.hostnames=['localhost']]
100
+ * @param {Array<string>} [options.ips=['127.0.0.1']]
101
+ * @param {number} [options.days=365]
102
+ * @returns {{key: string, cert: string}} both PEM encoded
103
+ */
104
+ function selfSignedCertificate({
105
+ commonName = 'localhost',
106
+ hostnames = ['localhost'],
107
+ ips = ['127.0.0.1'],
108
+ days = 365,
109
+ } = {}) {
110
+ const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', {modulusLength: 2048});
111
+ const spki = publicKey.export({type: 'spki', format: 'der'});
112
+
113
+ const sha256WithRsa = sequence(oid('1.2.840.113549.1.1.11'), nullValue());
114
+ const notBefore = new Date(Date.now() - 60 * 60 * 1000);
115
+ const notAfter = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
116
+
117
+ const tbs = sequence(
118
+ tagged(0xa0, integer(2)), // version v3
119
+ integer(Date.now()),
120
+ sha256WithRsa,
121
+ name(commonName),
122
+ sequence(utcTime(notBefore), utcTime(notAfter)),
123
+ name(commonName),
124
+ spki,
125
+ tagged(
126
+ 0xa3,
127
+ sequence(
128
+ extension('2.5.29.19', true, sequence(boolean(true))), // basicConstraints CA:TRUE
129
+ extension('2.5.29.17', false, subjectAltName(hostnames, ips)),
130
+ ),
131
+ ),
132
+ );
133
+
134
+ const signature = crypto.sign('sha256', tbs, privateKey);
135
+ const der = sequence(tbs, sha256WithRsa, bitString(signature));
136
+
137
+ const base64 = der.toString('base64').replace(/(.{64})/g, '$1\n');
138
+ const cert = `-----BEGIN CERTIFICATE-----\n${base64}${base64.endsWith('\n') ? '' : '\n'}-----END CERTIFICATE-----\n`;
139
+
140
+ return {key: privateKey.export({type: 'pkcs8', format: 'pem'}), cert};
141
+ }
142
+
143
+ /**
144
+ * Turns the `tls` option into {key, cert}: `true` generates a certificate, an object with key and
145
+ * cert is used as it is.
146
+ */
147
+ function resolveTls(option) {
148
+ if (!option) {
149
+ return null;
150
+ }
151
+ if (option === true) {
152
+ return selfSignedCertificate();
153
+ }
154
+ if (option.key && option.cert) {
155
+ return option;
156
+ }
157
+ return selfSignedCertificate(option);
158
+ }
159
+
160
+ module.exports = {selfSignedCertificate, resolveTls};
@@ -0,0 +1,233 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Type and range checking of parameter values against a paramset description.
5
+ *
6
+ * The real interface processes are stricter than the old simulator was: a value of the wrong type
7
+ * or outside MIN..MAX is answered with a fault (hmipserver) or silently dropped and left in
8
+ * CONFIG_PENDING (crRFD, see the CONFIG_PENDING modes in lib/sim.js). Both need the same check,
9
+ * so it lives here.
10
+ */
11
+
12
+ const WRITEABLE = 2;
13
+
14
+ /** ENUM values arrive as the index (BidCos) or as the name (HmIP); both are stored as the index. */
15
+ function castEnum(parameter, value, fault, name) {
16
+ const list = Array.isArray(parameter.VALUE_LIST) ? parameter.VALUE_LIST : [];
17
+ if (typeof value === 'string') {
18
+ const index = list.indexOf(value);
19
+ if (index === -1) {
20
+ throw fault('outOfRange', `${name}=${value}`);
21
+ }
22
+ return index;
23
+ }
24
+
25
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
26
+ throw fault('typeError', `${name} is ENUM`);
27
+ }
28
+ if (value < 0 || (list.length > 0 && value >= list.length)) {
29
+ throw fault('outOfRange', `${name}=${value}`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function castNumber(parameter, value, fault, name) {
35
+ if (value !== null && typeof value === 'object' && typeof value.explicitDouble === 'number') {
36
+ value = value.explicitDouble;
37
+ }
38
+ if (typeof value !== 'number' || Number.isNaN(value)) {
39
+ throw fault('typeError', `${name} is ${parameter.TYPE}`);
40
+ }
41
+ if (parameter.TYPE === 'INTEGER' && !Number.isInteger(value)) {
42
+ throw fault('typeError', `${name} is INTEGER`);
43
+ }
44
+ if (typeof parameter.MIN === 'number' && value < parameter.MIN) {
45
+ throw fault('outOfRange', `${name}=${value} < ${parameter.MIN}`);
46
+ }
47
+ if (typeof parameter.MAX === 'number' && value > parameter.MAX) {
48
+ throw fault('outOfRange', `${name}=${value} > ${parameter.MAX}`);
49
+ }
50
+ return value;
51
+ }
52
+
53
+ /**
54
+ * @param {object} description the paramset description
55
+ * @param {string} name parameter name
56
+ * @param {*} value
57
+ * @param {function} fault fault factory, see lib/faults.js
58
+ * @param {object} [options]
59
+ * @param {boolean} [options.write=true] check OPERATIONS for the write bit
60
+ * @returns {*} the value as it is stored
61
+ */
62
+ function castValue(description, name, value, fault, {write = true} = {}) {
63
+ const parameter = description && description[name];
64
+ if (!parameter) {
65
+ throw fault('unknownParameter', name);
66
+ }
67
+
68
+ if (write && typeof parameter.OPERATIONS === 'number' && !(parameter.OPERATIONS & WRITEABLE)) {
69
+ throw fault('readOnly', name);
70
+ }
71
+
72
+ switch (parameter.TYPE) {
73
+ case 'BOOL':
74
+ case 'ACTION':
75
+ if (typeof value === 'boolean') {
76
+ return value;
77
+ }
78
+ // the interface processes accept 0/1 for BOOL as well
79
+ if (value === 0 || value === 1) {
80
+ return Boolean(value);
81
+ }
82
+ throw fault('typeError', `${name} is ${parameter.TYPE}`);
83
+ case 'INTEGER':
84
+ case 'FLOAT':
85
+ return castNumber(parameter, value, fault, name);
86
+ case 'ENUM':
87
+ return castEnum(parameter, value, fault, name);
88
+ case 'STRING':
89
+ if (typeof value !== 'string') {
90
+ throw fault('typeError', `${name} is STRING`);
91
+ }
92
+ return value;
93
+ default:
94
+ return value;
95
+ }
96
+ }
97
+
98
+ /** The value a parameter has before anything was written, taken from its description. */
99
+ function defaultValue(parameter) {
100
+ if (parameter.TYPE === 'ENUM' && Array.isArray(parameter.VALUE_LIST)) {
101
+ const index = parameter.VALUE_LIST.indexOf(parameter.DEFAULT);
102
+ return index === -1 ? 0 : index;
103
+ }
104
+ return parameter.DEFAULT;
105
+ }
106
+
107
+ /** Every parameter of a description with its default value. */
108
+ function defaultParamset(description) {
109
+ const result = {};
110
+ for (const [name, parameter] of Object.entries(description || {})) {
111
+ result[name] = defaultValue(parameter);
112
+ }
113
+ return result;
114
+ }
115
+
116
+ /** Unwraps the `{explicitDouble}` wrapper both RPC encoders understand. */
117
+ function plain(value) {
118
+ return value !== null && typeof value === 'object' && typeof value.explicitDouble === 'number'
119
+ ? value.explicitDouble
120
+ : value;
121
+ }
122
+
123
+ function clamp(parameter, value) {
124
+ let result = value;
125
+ if (typeof parameter.MIN === 'number' && result < parameter.MIN) {
126
+ result = parameter.MIN;
127
+ }
128
+ if (typeof parameter.MAX === 'number' && result > parameter.MAX) {
129
+ result = parameter.MAX;
130
+ }
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * hmipserver's MASTER write, measured on firmware 3.89.8 (Homematic Manager task 6).
136
+ *
137
+ * It stores what it is given **before** it validates, and it validates the *type* only: a string
138
+ * in an INTEGER and an ENUM value that is not in the VALUE_LIST are stored as they came and make
139
+ * the channel's configuration invalid, while a number outside MIN..MAX is simply accepted - there
140
+ * is no range check anywhere in hmipserver.
141
+ *
142
+ * @param {object} parameter the parameter's description
143
+ * @param {*} value
144
+ * @returns {{value: *, valid: boolean}} the value as it is stored, and whether it is acceptable
145
+ */
146
+ function hmipStoredValue(parameter, value) {
147
+ const raw = plain(value);
148
+ switch (parameter.TYPE) {
149
+ case 'BOOL':
150
+ case 'ACTION':
151
+ if (typeof raw === 'boolean') {
152
+ return {value: raw, valid: true};
153
+ }
154
+ if (raw === 0 || raw === 1) {
155
+ return {value: Boolean(raw), valid: true};
156
+ }
157
+ return {value: raw, valid: false};
158
+ case 'INTEGER':
159
+ return {value: raw, valid: typeof raw === 'number' && Number.isInteger(raw)};
160
+ case 'FLOAT':
161
+ // an <int> is accepted where a <double> belongs and stored as the integer it is
162
+ return {value: raw, valid: typeof raw === 'number' && !Number.isNaN(raw)};
163
+ case 'ENUM': {
164
+ const list = Array.isArray(parameter.VALUE_LIST) ? parameter.VALUE_LIST : [];
165
+ if (typeof raw === 'string') {
166
+ const index = list.indexOf(raw);
167
+ return index === -1 ? {value: raw, valid: false} : {value: index, valid: true};
168
+ }
169
+ const inRange = typeof raw === 'number' && Number.isInteger(raw) && raw >= 0 && raw < list.length;
170
+ return {value: raw, valid: inRange};
171
+ }
172
+ case 'STRING':
173
+ return {value: raw, valid: typeof raw === 'string'};
174
+ default:
175
+ return {value: raw, valid: true};
176
+ }
177
+ }
178
+
179
+ /**
180
+ * rfd's MASTER write, measured on firmware 3.89.8 (Homematic Manager task 6).
181
+ *
182
+ * rfd never faults on a bad value. It silently clamps a number to MIN..MAX, coerces a string to a
183
+ * number, and ignores what it cannot use at all - an integer where a BOOL belongs, an ENUM name
184
+ * that is not in the VALUE_LIST. `undefined` means "ignored, nothing was stored".
185
+ *
186
+ * Not modelled: rfd also ignores a FLOAT that arrives as an XML-RPC `<int>`, which is why every
187
+ * FLOAT has to go out as an explicit double. Both decode to a JavaScript number, so the
188
+ * distinction is gone by the time the simulator sees the value.
189
+ *
190
+ * @param {object} parameter the parameter's description
191
+ * @param {*} value
192
+ * @returns {*} the value as it is stored, or `undefined` when rfd ignores it
193
+ */
194
+ function bidcosStoredValue(parameter, value) {
195
+ const raw = plain(value);
196
+ switch (parameter.TYPE) {
197
+ case 'BOOL':
198
+ case 'ACTION':
199
+ return typeof raw === 'boolean' ? raw : undefined;
200
+ case 'INTEGER': {
201
+ const number = typeof raw === 'number' ? raw : Number.parseInt(String(raw), 10);
202
+ return clamp(parameter, Number.isNaN(number) ? 0 : Math.round(number));
203
+ }
204
+ case 'FLOAT': {
205
+ const number = typeof raw === 'number' ? raw : Number.parseFloat(String(raw));
206
+ return clamp(parameter, Number.isNaN(number) ? 0 : number);
207
+ }
208
+ case 'ENUM': {
209
+ const list = Array.isArray(parameter.VALUE_LIST) ? parameter.VALUE_LIST : [];
210
+ if (typeof raw === 'string') {
211
+ const index = list.indexOf(raw);
212
+ return index === -1 ? undefined : index;
213
+ }
214
+ if (typeof raw !== 'number' || !Number.isInteger(raw)) {
215
+ return undefined;
216
+ }
217
+ return list.length > 0 ? Math.min(Math.max(raw, 0), list.length - 1) : raw;
218
+ }
219
+ case 'STRING':
220
+ return typeof raw === 'string' ? raw : undefined;
221
+ default:
222
+ return raw;
223
+ }
224
+ }
225
+
226
+ module.exports = {
227
+ WRITEABLE,
228
+ castValue,
229
+ defaultValue,
230
+ defaultParamset,
231
+ hmipStoredValue,
232
+ bidcosStoredValue,
233
+ };
package/package.json CHANGED
@@ -1,14 +1,21 @@
1
1
  {
2
2
  "name": "hm-simulator",
3
- "version": "0.1.1",
4
- "description": "Simulates a Homematic CCU",
5
- "main": "index.js",
3
+ "version": "1.0.0",
4
+ "description": "Simulates a Homematic CCU (rfd, hmipserver, VirtualDevices, CUxD, ReGa) for automated tests",
5
+ "main": "sim.js",
6
6
  "preferGlobal": true,
7
7
  "bin": {
8
8
  "hm-simulator": "./index.js"
9
9
  },
10
+ "engines": {
11
+ "node": ">=20.19"
12
+ },
10
13
  "scripts": {
11
- "test": "camo-purge ; xo --space 4"
14
+ "lint": "eslint . && prettier --check .",
15
+ "format": "prettier --write . && eslint --fix .",
16
+ "test": "node --test test/*.test.js",
17
+ "test:watch": "node --test --watch test/*.test.js",
18
+ "fixtures": "node tools/fixtures-from-paramsets.js"
12
19
  },
13
20
  "repository": {
14
21
  "type": "git",
@@ -26,30 +33,32 @@
26
33
  ],
27
34
  "author": "Sebastian Raff <hobbyquaker@gmail.com>",
28
35
  "license": "MIT",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
29
39
  "bugs": {
30
40
  "url": "https://github.com/hobbyquaker/hm-simulator/issues"
31
41
  },
32
42
  "homepage": "https://github.com/hobbyquaker/hm-simulator#readme",
43
+ "files": [
44
+ "index.js",
45
+ "sim.js",
46
+ "sim.mjs",
47
+ "lib/",
48
+ "data/",
49
+ "behaviors/",
50
+ "README.md",
51
+ "LICENSE"
52
+ ],
33
53
  "dependencies": {
34
- "async": "^3.1.0",
35
- "binrpc": "^3.0.1",
36
- "body-parser": "^1.18.3",
37
- "express": "^4.16.4",
38
- "homematic-xmlrpc": "^1.0.2",
39
- "request": "^2.88.0",
40
- "yalm": "^4.0.2"
54
+ "binrpc": "^4.2.0",
55
+ "homematic-xmlrpc": "^2.0.0"
41
56
  },
42
57
  "devDependencies": {
43
- "camo-purge": "^1.0.1",
44
- "xo": "^0.25.3"
45
- },
46
- "xo": {
47
- "space": 4,
48
- "rules": {
49
- "no-cond-assign": "warn",
50
- "no-case-declarations": "warn",
51
- "no-constant-condition": "warn",
52
- "no-unused-vars": "warn"
53
- }
58
+ "@eslint/js": "^9.39.5",
59
+ "eslint": "^9.39.5",
60
+ "eslint-config-prettier": "^10.1.8",
61
+ "globals": "^16.5.0",
62
+ "prettier": "^3.9.6"
54
63
  }
55
64
  }