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/README.md CHANGED
@@ -6,191 +6,522 @@
6
6
 
7
7
  > Simulates a Homematic CCU for automated tests
8
8
 
9
- hm-simulator answers the RPC calls a CCU's interface processes answer, keeps the state behind them
10
- and calls back into the logic layer that registered itself, so that an application that talks to a
11
- CCU can be tested without one. It is used by
12
- [node-red-contrib-ccu](https://github.com/rdmtc/node-red-contrib-ccu),
13
- [hm2mqtt.js](https://github.com/hobbyquaker/hm2mqtt.js) and
14
- [Homematic Manager](https://github.com/hobbyquaker/homematic-manager).
9
+ hm-simulator answers the RPC calls a CCU's interface processes answer (rfd, hmipserver,
10
+ BidCos-Wired, VirtualDevices, CUxD), keeps the devices and paramsets behind them, calls back into
11
+ the logic layers that registered, and imitates what the real processes were measured to do -
12
+ faults, `CONFIG_PENDING`, service messages, restarts, a callback server that hangs. An application
13
+ that talks to a CCU can be tested against it without one, in-process or as a separate process. It
14
+ is used by the tests of [node-red-contrib-ccu](https://github.com/rdmtc/node-red-contrib-ccu),
15
+ [hm2mqtt.js](https://github.com/hobbyquaker/hm2mqtt.js),
16
+ [Homematic Manager](https://github.com/hobbyquaker/homematic-manager) and
17
+ [matterbridge-homematic](https://github.com/hobbyquaker/matterbridge-homematic).
18
+
19
+ - [Quick start](#quick-start)
20
+ - [What is simulated](#what-is-simulated)
21
+ - [Connecting a client](#connecting-a-client)
22
+ - [Options](#options)
23
+ - [Scenario API](#scenario-api)
24
+ - [Recipes](#recipes)
25
+ - [Device data and fixtures](#device-data-and-fixtures)
26
+ - [Interface behaviour, measured](#interface-behaviour-measured)
27
+ - [ReGa mock](#rega-mock)
28
+ - [TLS and basic auth](#tls-and-basic-auth)
29
+ - [Behaviour scripts](#behaviour-scripts)
30
+ - [Command line](#command-line)
31
+ - [Upgrading from 0.x](#upgrading-from-0x)
32
+
33
+ ## Quick start
34
+
35
+ Prerequisites: [Node.js](https://nodejs.org) >= 20.19.
15
36
 
16
- ## Installation
37
+ ```
38
+ npm install --save-dev hm-simulator
39
+ ```
40
+
41
+ TypeScript declarations ship with the package (`sim.d.ts`, `sim.d.mts`, `lib/faults.d.ts`): the
42
+ options, the scenario API, the introspection calls and the description types are under the `HmSim`
43
+ namespace (`HmSim.Options`, `HmSim.DeviceDescription`, `HmSim.ParamsetDescription`, ...).
17
44
 
18
- Prerequisites: [Node.js](https://nodejs.org) >= 20.19
45
+ A test file with `node:test` - the same shape works with mocha (`before`/`after`) and vitest
46
+ (`beforeAll`/`afterAll`):
19
47
 
48
+ ```js
49
+ const {before, after, test} = require('node:test');
50
+ const assert = require('node:assert/strict');
51
+ const HmSim = require('hm-simulator/sim.js'); // ESM: import HmSim from 'hm-simulator/sim.mjs'
52
+ const lab = require('hm-simulator/data/fixtures/lab-2026-09.json');
53
+
54
+ let sim;
55
+
56
+ before(async () => {
57
+ sim = new HmSim({
58
+ devices: structuredClone(lab.devices), // the simulator changes the lists it is given
59
+ paramsetDescriptions: lab.paramsetDescriptions,
60
+ links: structuredClone(lab.links),
61
+ bidcosInterfaces: lab.bidcosInterfaces,
62
+ behaviorPath: false, // no example behaviour scripts firing events of their own
63
+ config: {listenAddress: '127.0.0.1', binrpcListenPort: 0, xmlrpcListenPort: 0, wiredListenPort: 0},
64
+ });
65
+ await sim.whenReady(); // every server listens; rejects when a port is taken
66
+ });
67
+
68
+ after(() => sim.close());
69
+
70
+ test('the code under test sees the blind actuator', async () => {
71
+ // point the code under test at xmlrpc_bin://127.0.0.1:${sim.ports.rfd} (BidCos-RF)
72
+ // and http://127.0.0.1:${sim.ports.hmip} (HmIP-RF)
73
+ assert.equal(sim.getDevice('hmip', '00000000000004').TYPE, 'HmIP-BBL');
74
+ });
20
75
  ```
21
- npm install --save-dev hm-simulator
76
+
77
+ Port `0` lets the operating system pick a free port, so test files can run in parallel;
78
+ `sim.ports` (`rfd`, `hmip`, `wired`, `virtual`, `cuxd`) holds what it picked once `whenReady()`
79
+ resolved.
80
+
81
+ With Playwright, start it in a `globalSetup` and hand the ports to the application under test,
82
+ for example through the environment:
83
+
84
+ ```js
85
+ // playwright.global-setup.js
86
+ const HmSim = require('hm-simulator/sim.js');
87
+ const lab = require('hm-simulator/data/fixtures/lab-2026-09.json');
88
+
89
+ module.exports = async () => {
90
+ const sim = new HmSim({
91
+ devices: structuredClone(lab.devices),
92
+ paramsetDescriptions: lab.paramsetDescriptions,
93
+ behaviorPath: false,
94
+ config: {listenAddress: '127.0.0.1', binrpcListenPort: 0, xmlrpcListenPort: 0},
95
+ });
96
+ await sim.whenReady();
97
+ process.env.CCU_RFD_PORT = String(sim.ports.rfd);
98
+ process.env.CCU_HMIP_PORT = String(sim.ports.hmip);
99
+ return () => sim.close(); // Playwright runs the returned function as the teardown
100
+ };
22
101
  ```
23
102
 
24
- Or, as a standalone process, `npm install -g hm-simulator` and then `hm-simulator --help`.
103
+ For code that cannot `require()` it - a daemon in another language, a spawned service - run the
104
+ [command line](#command-line) with `--ports-json -` and drive it through the control port.
25
105
 
26
106
  ## What is simulated
27
107
 
28
- **Interfaces.** rfd (binrpc), hmipserver (xmlrpc), BidCos-Wired (binrpc), VirtualDevices (xmlrpc,
29
- path `/groups`) and CUxD (binrpc). Each has its own devices, values, paramsets, links and service
30
- messages. rfd and hmipserver start by default, the others when their port is configured.
108
+ **Interfaces.** Each has its own devices, values, paramsets, links and service messages.
109
+
110
+ | Interface | Protocol | Port on a CCU | Option (the CLI flag) |
111
+ | -------------- | ------------------------------- | ------------- | -------------------------------------------------------------------------- |
112
+ | rfd | BIN-RPC and XML-RPC on one port | 2001 | `config.binrpcListenPort` (`--binrpc-port`), on when `devices.rfd` exists |
113
+ | hmipserver | XML-RPC | 2010 | `config.xmlrpcListenPort` (`--xmlrpc-port`), on when `devices.hmip` exists |
114
+ | BidCos-Wired | BIN-RPC and XML-RPC on one port | 2000 | `config.wiredListenPort` (`--wired-port`), on when `devices.wired` exists |
115
+ | VirtualDevices | XML-RPC, path `/groups` | 9292 | `config.virtualListenPort` (`--virtual-port`) |
116
+ | CUxD | BIN-RPC | 8701 | `config.cuxdListenPort` (`--cuxd-port`) |
117
+ | ReGa | HTTP, `rega.exe` | 8181 | `rega` (`--rega-port`), see [ReGa mock](#rega-mock) |
118
+
119
+ rfd and BidCos-Wired answer **BIN-RPC and XML-RPC on the same port**, as on a CCU: a connection
120
+ that starts with the bytes `Bin` is BIN-RPC, anything else XML-RPC over HTTP.
121
+ `interfaces: {rfd: {protocols: ['binrpc']}}` narrows a port to one of them.
31
122
 
32
123
  **Incoming RPC methods.**
33
124
 
34
- | | |
35
- | ----------- | ------------------------------------------------------------------------------------------------------------------------------ |
36
- | session | `init` (register and de-register a logic layer), `ping`, `system.listMethods`, `system.methodHelp`, `system.multicall` |
37
- | devices | `listDevices`, `getDeviceDescription`, `deleteDevice`, `replaceDevice`, `setInstallMode`, `getInstallMode` |
38
- | paramsets | `getParamsetDescription`, `getParamset`, `putParamset`, `getValue`, `setValue`, `determineParameter`, `reportValueUsage` |
39
- | links | `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`, `activateLinkParamset` |
40
- | interface | `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`, `getServiceMessages`, `setTempKey` (BidCos only) |
41
- | teams | `listTeams`, `setTeam` (BidCos only): the smoke detector teams as rfd keeps them, pseudo devices `*<serial>` |
42
- | maintenance | `clearConfigCache`, `restoreConfigToDevice`, `updateFirmware`, `installFirmware` (the last two are stubs that record the call) |
125
+ | | |
126
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | session | `init` (register and de-register a logic layer), `ping`, `system.listMethods`, `system.methodHelp`, `system.multicall` |
128
+ | devices | `listDevices`, `getDeviceDescription`, `deleteDevice`, `replaceDevice`, `listReplaceableDevices`, `setInstallMode`, `getInstallMode`, `addDevice` (BidCos only), `getKeyMismatchDevice` (rfd, hmipserver) |
129
+ | paramsets | `getParamsetDescription`, `getParamset`, `putParamset`, `getValue`, `setValue`, `determineParameter`, `reportValueUsage` |
130
+ | links | `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`, `activateLinkParamset` |
131
+ | interface | `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`, `getServiceMessages`, `setTempKey` (BidCos only), `changeKey` (rfd, hmipserver), `logLevel`, `getVersion`, `setInterfaceClock` (rfd, hmipserver), `getLGWStatus` (only when configured) |
132
+ | metadata | `getMetadata`, `setMetadata` (rfd, hs485d, hmipserver), `getAllMetadata` (BidCos only) |
133
+ | service messages | `suppressServiceMessages`, `getSuppressedServiceMessages` (hmipserver only) |
134
+ | teams | `listTeams`, `setTeam` (BidCos only): the smoke detector teams as rfd keeps them, pseudo devices `*<serial>` |
135
+ | maintenance | `clearConfigCache`, `restoreConfigToDevice`, `updateFirmware`, `installFirmware`, `refreshDeployedDeviceFirmwareList` (rfd, hmipserver) |
136
+
137
+ Every write is checked against the paramset description (type, range, `VALUE_LIST`,
138
+ `OPERATIONS`) and answered the way the interface process answers it - see
139
+ [Interface behaviour, measured](#interface-behaviour-measured). `sim.methodNames()` lists the
140
+ methods.
43
141
 
44
142
  **Outgoing RPC calls** to every registered logic layer: `listDevices`, `newDevices`,
45
- `deleteDevices`, `updateDevice` (after `setTeam`), `event`, `system.multicall`.
143
+ `deleteDevices`, `updateDevice`, `event` and `system.multicall`; after a restart with remembered
144
+ clients also `system.listMethods`.
145
+
146
+ **What is not simulated:** the radio protocol and anything a device does on its own beyond what a
147
+ behaviour script or the scenario API makes it do; firmware behaviour beyond the update states of
148
+ [Device and radio health](#device-and-radio-health); duty cycle and carrier sense beyond the
149
+ levels a test sets; HmIP groups and heating groups as hmipserver forms them (VirtualDevices serves
150
+ whatever devices it is given); the CCU's web UI and JSON-API (`/api/homematic.cgi`); openccu-lite's
151
+ own APIs; ReGa beyond the scripts listed under [ReGa mock](#rega-mock).
152
+
153
+ ## Connecting a client
154
+
155
+ A logic layer registers with `init(url, interfaceId)` and removes itself with `init(url, '')`. The
156
+ url is `xmlrpc_bin://host:port` (or `binrpc://`) for BIN-RPC callbacks and `http://host:port/path`
157
+ or `https://…` for XML-RPC ones, whichever protocol the `init` itself came over. The path is kept,
158
+ so one callback server can serve several interfaces (`http://127.0.0.1:8184/cb/BidCos-RF`). As on
159
+ a CCU a registration is identified by its url exactly, scheme and path included:
160
+ `init('http://h:1', '')` does not remove `xmlrpc_bin://h:1`. A url that is none is a fault.
161
+
162
+ After the `init` the simulator calls the client back as the interface processes do:
163
+
164
+ 1. `listDevices(interfaceId)` - what the client already knows;
165
+ 2. `deleteDevices` for devices the client knows and the interface does not (or whose `VERSION`
166
+ differs), then `newDevices` with the ones the client does not know. hmipserver sends **every**
167
+ device again on every `init`, and so does the simulator on `hmip`;
168
+ 3. from then on `event(interfaceId, address, datapoint, value)` for every change - several at once
169
+ as `system.multicall` (`fireEvents`).
170
+
171
+ `ping(callerId)` answers with a `CENTRAL`/`PONG` event to every registered client on the BidCos
172
+ interfaces, and with nothing on hmipserver, as the real one (`interfaces.<iface>.pong` changes
173
+ that). A client that watches for events to arrive therefore has to treat HmIP differently.
174
+
175
+ `sim.dropConnection(iface)` is an interface process that restarts in an instant: every registered
176
+ client is forgotten and every open connection reset; nothing tells the client, which receives no
177
+ more events until it calls `init` again. `restartInterface()` and its siblings model slower and
178
+ more talkative restarts, see [Interface processes misbehaving](#interface-processes-misbehaving).
179
+
180
+ Strings: an XML-RPC request declared ISO-8859-1 (in the XML declaration or the Content-Type
181
+ header), as the interface processes speak it, is read as such, anything else as UTF-8; answers are
182
+ UTF-8. BIN-RPC strings go out as ISO-8859-1, as rfd sends them.
183
+
184
+ ## Options
185
+
186
+ `new HmSim(options)`; everything is optional.
187
+
188
+ | option | default | |
189
+ | -------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- |
190
+ | `devices` | `{rfd: {devices: []}, hmip: {devices: []}}` | device descriptions per interface, `{<iface>: {devices: [...]}}` |
191
+ | `paramsetDescriptions` | the bundled 8.4 MB `data/paramset-descriptions.json` | replaces the bundled descriptions, see [Device data and fixtures](#device-data-and-fixtures) |
192
+ | `paramsetFallback` | `true` | a firmware without a description uses the nearest one; `false` for exact keys only |
193
+ | `links` | `{}` | links per interface, `[{SENDER, RECEIVER, FLAGS, NAME, DESCRIPTION}]` |
194
+ | `bidcosInterfaces` | one per interface | what `listBidcosInterfaces` answers, per interface |
195
+ | `serviceMessages` | `{}` | extra service messages per interface, `[[address, datapoint, value]]` |
196
+ | `newDevices` | `{}` | devices that appear when the install mode is switched on, `{<iface>: {devices, delay}}` |
197
+ | `defaultRssi` | `-65` | what `rssiInfo` answers where a device has no RSSI datapoint |
198
+ | `metadata` | `{}` | what `getMetadata` answers before any `setMetadata`, `{<iface>: {<address>: {<key>: value}}}` |
199
+ | `config.listenAddress` | all interfaces | `'127.0.0.1'` in tests |
200
+ | `config.binrpcListenPort` | – | rfd |
201
+ | `config.xmlrpcListenPort` | – | hmipserver |
202
+ | `config.wiredListenPort` | – | BidCos-Wired, started when `devices.wired` exists |
203
+ | `config.virtualListenPort` | off | VirtualDevices |
204
+ | `config.virtualPath` | `/groups` | the path VirtualDevices answers on |
205
+ | `config.cuxdListenPort` | off | CUxD |
206
+ | `interfaces` | see below | behaviour per interface, `{<iface>: {...}}` |
207
+ | `faults` | hmipserver's table | overrides for the fault table, see [Fault codes](#fault-codes) |
208
+ | `behaviorPath` | `behaviors/` of the package | directory with [behaviour scripts](#behaviour-scripts), `false` for none |
209
+ | `rega` | off | the [ReGa mock](#rega-mock) |
210
+ | `tls` | off | `true` or `{key, cert}`, see [TLS and basic auth](#tls-and-basic-auth) |
211
+ | `auth` | off | `{username, password}`, HTTP basic auth |
212
+ | `log` | silent | an object with `debug`, `info`, `warn`, `error` (`console` will do) |
213
+
214
+ **Per interface** (`interfaces: {hmip: {...}, rfd: {...}}`):
215
+
216
+ | option | default | |
217
+ | ------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
218
+ | `configPendingMode` | `'hmip'` for `hmip`, `'bidcos'` for `rfd` and `wired`, `'strict'` for the rest | what a MASTER write does, see [CONFIG_PENDING](#config_pending) |
219
+ | `configPendingOnWrite` | `false` | raise `CONFIG_PENDING` for _every_ accepted MASTER write (implied by `'bidcos'` for every write that changes something) |
220
+ | `configPendingDelay` | `0` | milliseconds after which a non-sticky `CONFIG_PENDING` clears itself: the device taking the configuration (measured: 160-180 s on a thermostat that transmits regularly, "until the door opens" on a contact) |
221
+ | `serviceMessagesEmptyAsString` | `false` | answer `getServiceMessages` with `''` instead of `[]` when nothing is pending, which is what rfd really does |
222
+ | `protocols` | `['binrpc', 'xmlrpc']` for `rfd` and `wired` | what the port of a BidCos interface answers |
223
+ | `listenerModel` | `'isolated'` | `'measured'`: what the interface processes do with a callback server that hangs, see [below](#a-callback-server-that-hangs) |
224
+ | `deliveryTimeout` | `10000` | `'measured'` BidCos only: a client that does not take a callback within this time is dropped |
225
+ | `pong` | `false` for `hmip`, `true` for the rest | whether `ping` sends the `CENTRAL`/`PONG` event |
226
+ | `pingDelay` | `0` | milliseconds between `ping` and its `PONG` |
227
+ | `startDelay` | `0` | milliseconds after `whenReady()` until the port accepts connections: a process that starts late (`sim.ports` knows the port from the start) |
228
+ | `unreachWrites` | `'accept'` | what `setValue`/`putParamset` to an unreachable device answer: `'accept'`, or `'fault'` (`notReachable`). The simulator's model, not a measurement |
229
+ | `firmwareUpdateDelay` | `0` | milliseconds per step of a firmware update |
230
+ | `getServiceMessagesFault` | `false` | answer `getServiceMessages` with the unknown-method fault (`Invalid XML-RPC message`), as VirtualDevices, CUxD and hmipserver were seen to on 3.89.x |
231
+ | `version` | `'2.6.0'` for `rfd` and `wired`, `'3.89.11.20260919'` for `hmip` | what `getVersion` answers; the rest has no `getVersion` |
232
+ | `lgwStatus` | – | BidCos only: what `getLGWStatus` answers; without it the method is unknown, as on rfd 3.89.11 |
233
+
234
+ ## Scenario API
235
+
236
+ What a test calls on the simulator to make things happen and to look at what happened. The same
237
+ calls are available over HTTP on the [control port](#the-control-port).
238
+
239
+ **Devices**
46
240
 
47
- **ReGa** (`rega.exe` on port 8181): the scripts of the
48
- [homematic-rega](https://github.com/hobbyquaker/homematic-rega) client - `getChannels`,
49
- `getVariables`, `getPrograms`, `getRooms`, `getFunctions`, `getValues` - plus setting a variable,
50
- renaming an object, activating and executing a program. Every script the mock receives is recorded
51
- (`sim.regaSim.scripts`), renames additionally in `sim.regaSim.renames`.
241
+ ```js
242
+ sim.addDevice('rfd', device, channel0, channel1); // pairs a device: newDevices to the clients
243
+ sim.removeDevice('rfd', 'LAB0000002'); // deleteDevices, with its values and links
244
+ sim.scriptNewDevices('rfd', [device, channel0, channel1], 500); // appear 500 ms after setInstallMode
245
+ sim.getDevice('rfd', 'LAB0000002:1'); // the description, false when unknown
246
+ // a device that holds another system's key: the next install mode hears it (getKeyMismatchDevice
247
+ // names it) until setTempKey('their-key') lets it pair; addDevice('LAB0000009') does the same
248
+ sim.scriptKeyMismatch('rfd', 'LAB0000009', {key: 'their-key', devices: [device, channel0, channel1]});
249
+ ```
52
250
 
53
- **Faults.** Unknown method, unknown address, unknown paramset, unknown parameter, read-only
54
- parameter, wrong type, out of range, unknown link. Over xmlrpc as an XML-RPC fault, over binrpc as
55
- a message of type `0xff` with a `faultCode`/`faultString` struct - see
56
- [Fault codes](#fault-codes-measured) below.
251
+ **Values and events**
57
252
 
58
- What is **not** simulated: the actual radio protocol, firmware behaviour, duty cycle, the CCU's web
59
- UI and JSON-API, and anything a device does on its own beyond what a behaviour script or the
60
- scenario API makes it do.
253
+ ```js
254
+ sim.fireEvent('rfd', 'LAB0000002:1', 'STATE', true); // one event, not checked against the description
255
+ sim.fireEvents('hmip', [[address, 'LEVEL', 0.5], ...], {batch: 10}); // a burst as system.multicall
256
+ sim.setValue('rfd', 'LAB0000002:1', 'STATE', true, {internal: true}); // the device reports a value
257
+ sim.values.rfd['LAB0000002:1'].VALUES.STATE; // the stored state: VALUES, MASTER, LINKS per channel
258
+ sim.api.emit('setValue', 'rfd', 'LAB0000002:1', 'STATE', true); // as a behaviour script does
259
+ ```
61
260
 
62
- ## Usage
261
+ **Service messages, device and radio health**
63
262
 
64
263
  ```js
65
- const HmSim = require('hm-simulator/sim.js'); // ESM: import HmSim from 'hm-simulator/sim.mjs'
264
+ sim.setServiceMessage('rfd', 'LAB0000002:0', 'STICKY_UNREACH', true);
265
+ sim.setReachable('hmip', '00000000000004', false); // UNREACH (+ STICKY_UNREACH on BidCos)
266
+ sim.setLowBattery('rfd', 'LAB0000003', true); // LOWBAT or LOW_BAT, whichever :0 has
267
+ sim.setDutyCycle('rfd', 42); // listBidcosInterfaces' DUTY_CYCLE
268
+ sim.setCarrierSense('hmip', 8); // and CARRIER_SENSE_LEVEL on the radio module's :0
269
+ sim.offerFirmware('hmip', '00000000000004', '1.2.0'); // a firmware update becomes available
270
+ ```
66
271
 
67
- const sim = new HmSim({
68
- devices: {
69
- rfd: require('hm-simulator/data/devices-rfd.json'),
70
- hmip: require('hm-simulator/data/devices-hmip.json'),
71
- },
72
- config: {listenAddress: '127.0.0.1', binrpcListenPort: 2001, xmlrpcListenPort: 2010},
73
- });
272
+ **Interface processes**
74
273
 
75
- await sim.whenReady(); // every server is accepting connections
76
- // ... run the code under test ...
77
- sim.close();
274
+ ```js
275
+ await sim.dropConnection('rfd'); // restart in an instant, clients forgotten
276
+ await sim.stopInterface('hmip'); // the port refuses connections
277
+ await sim.startInterface('hmip', {forgetClients: true});
278
+ await sim.restartInterface('rfd', {downMs: 2000, forgetClients: false});
279
+ sim.injectFault({iface: 'rfd', method: 'setValue', fault: 'notReachable', times: 1});
280
+ sim.clearFaults();
78
281
  ```
79
282
 
80
- With a port of `0` the operating system picks one and `sim.ports` holds what it picked
81
- (`sim.ports.rfd`, `sim.ports.hmip`, ...) once `whenReady()` resolved. That is the reliable way to
82
- run tests in parallel.
83
-
84
- ### Options
85
-
86
- | option | default | |
87
- | -------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------- |
88
- | `log` | silent | object with `debug`, `info`, `warn`, `error` |
89
- | `devices` | `{rfd: {devices: []}, hmip: {devices: []}}` | device descriptions per interface |
90
- | `paramsetDescriptions` | the bundled 8.4 MB `data/paramset-descriptions.json` | replaces the bundled descriptions |
91
- | `config.listenAddress` | all interfaces | |
92
- | `config.binrpcListenPort` | – | rfd |
93
- | `config.xmlrpcListenPort` | – | hmipserver |
94
- | `config.wiredListenPort` | – | BidCos-Wired, started when `devices.wired` exists |
95
- | `config.virtualListenPort` | off | VirtualDevices |
96
- | `config.cuxdListenPort` | off | CUxD |
97
- | `config.virtualPath` | `/groups` | path the VirtualDevices server answers on |
98
- | `behaviorPath` | `behaviors/` of the package | directory with behaviour scripts |
99
- | `rega` | off | ReGa mock, see below |
100
- | `links` | `{}` | links per interface, `[{SENDER, RECEIVER, NAME, DESCRIPTION}]` |
101
- | `serviceMessages` | `{}` | service messages per interface, `[[address, datapoint, value]]` |
102
- | `newDevices` | `{}` | devices that appear when the install mode is switched on, `{<iface>: {devices, delay}}` |
103
- | `bidcosInterfaces` | one per interface | what `listBidcosInterfaces` answers |
104
- | `defaultRssi` | `-65` | what `rssiInfo` answers where a device has no RSSI datapoint |
105
- | `interfaces` | see [CONFIG_PENDING](#config_pending) | per interface behaviour |
106
- | `faults` | see [Fault codes](#fault-codes-measured) | overrides for the fault table |
107
- | `tls` | off | `true` generates a self signed certificate, or pass `{key, cert}` |
108
- | `auth` | off | `{username, password}`, HTTP basic auth for the xmlrpc servers and ReGa |
109
-
110
- `rega` takes `{port, listenAddress, channels, variables, programs, rooms, functions, values, tls,
111
- auth}`. `channels` is what `getChannels()` answers: `[{id, address, name}]`.
112
-
113
- ### Scenario API
283
+ **Time**
114
284
 
115
285
  ```js
116
- sim.addDevice('rfd', deviceDescription, channel0, channel1); // sends newDevices
117
- sim.removeDevice('rfd', 'ABC0000001'); // sends deleteDevices
118
- sim.fireEvent('rfd', 'ABC0000001:1', 'STATE', true); // one event, no validation
119
- sim.setServiceMessage('rfd', 'ABC0000001:0', 'STICKY_UNREACH', true);
120
- sim.scriptNewDevices('rfd', [devices], 500); // appear after setInstallMode
121
- await sim.dropConnection('rfd'); // interface process restart
122
- sim.getWriteLog(); // every accepted putParamset: {iface, address, paramset, values, rejected, ts}
286
+ await sim.schedule([{at: 1000, call: 'setReachable', args: ['rfd', 'LAB0000002', false]}]);
287
+ ```
288
+
289
+ **Introspection**
290
+
291
+ ```js
292
+ sim.getWriteLog(); // every accepted putParamset: [{iface, address, paramset, values, rejected, ts}]
123
293
  sim.getConfigPending('rfd'); // [{address, sticky}]
124
294
  sim.getPoisonedChannels('hmip'); // channels whose stored MASTER has a parameter they do not have
125
- sim.api.emit('setValue', 'rfd', 'ABC0000001:1', 'STATE', true); // as a behaviour script would
295
+ sim.getMissingParamsetDescriptions(); // [{iface, key, usedKey}]; empty: the fixture is complete
296
+ sim.getCallbackLog(); // every call to a client: [{iface, client, method, sentAt, answeredAt, error}]
297
+ sim.getTempKey('rfd'); // what setTempKey set
298
+ sim.getInstallMode('rfd'); // seconds of install mode left
299
+ sim.keyChanges; // every changeKey: [{iface, key, ts}]
300
+ sim.firmwareListRefreshes; // every refreshDeployedDeviceFirmwareList: [{iface, ts}]
301
+ sim.interfaceClocks; // every setInterfaceClock: [{iface, utc, offset, ts}]
302
+ sim.metadata; // what setMetadata stored: {<iface>: {<address>: {<key>: value}}}
303
+ sim.ports; // {rfd, hmip, wired, virtual, cuxd, ...}: the ports once whenReady() resolved
304
+ sim.regaSim.scripts; // every script the ReGa mock received
126
305
  ```
127
306
 
128
- Behaviour scripts are plain modules in `behaviorPath` that export `api => { ... }` and use
129
- `api.emit('setValue', iface, address, datapoint, value)`; the two examples in `behaviors/` press a
130
- virtual button and open a window periodically. Point `behaviorPath` at an empty directory to have
131
- none.
307
+ ### Device and radio health
308
+
309
+ - A datapoint the `:0` channel's description has is stored and sent as the description says, and
310
+ counts as a service message by its `FLAGS`; one it does not have is sent as an event and raised
311
+ with `setServiceMessage`. `STICKY_UNREACH` stays after the device is back until a client writes
312
+ it `false`, as rfd does. A write to an unreachable device is accepted unless `unreachWrites` is
313
+ `'fault'`.
314
+ - The radio module is the HmIP device whose address is the last 14 characters of the interface's
315
+ address in `listBidcosInterfaces` (its SGTIN), or a device of a radio module's type
316
+ (`RPI-RF-MOD`, `HmIP-RFUSB`); without one only `listBidcosInterfaces` changes.
317
+ - `offerFirmware` sets `AVAILABLE_FIRMWARE`, `FIRMWARE_UPDATE_STATE: 'NEW_FIRMWARE_AVAILABLE'` and
318
+ `UPDATABLE` in the device description and `UPDATE_PENDING` on `:0`, and sends `updateDevice`.
319
+ `updateFirmware`/`installFirmware` then walk `FIRMWARE_UPDATE_STATE` through
320
+ `DO_UPDATE_PENDING`, `PERFORMING_UPDATE` and `UP_TO_DATE` (an `updateDevice` each, one step per
321
+ `firmwareUpdateDelay`) and set `FIRMWARE` at the end. Without an offer they only record the call.
322
+ The state names are the RPC specification's; the timing is the simulator's model.
323
+ - `schedule` runs any scenario call on the simulator's timer (`at` in milliseconds from now) and
324
+ resolves with the results; `close()` cancels what did not run yet.
325
+
326
+ ### Interface processes misbehaving
327
+
328
+ For the code that has to survive an interface process that restarts, starts late, answers slowly or
329
+ not at all - reconnects, re-inits, watchdogs, init retries:
132
330
 
133
- ## CONFIG_PENDING
331
+ ```js
332
+ sim.injectFault({iface: 'rfd', method: 'init', times: 3, fault: 'unknownInstance'});
333
+ sim.injectFault({iface: 'hmip', method: '*', delayMs: 1500}); // answer late
334
+ sim.injectFault({method: 'getValue', hang: true}); // never answer
335
+ sim.injectFault({method: 'listDevices', closeSocket: true}); // close the connection instead
336
+ ```
134
337
 
135
- Devices that end up stuck in `CONFIG_PENDING` after a paramset write are the reason this part
136
- exists (Homematic Manager issue #98). Until 2026-09-05 there were two competing explanations and
137
- nobody had measured which one the interface processes actually implement. Homematic Manager's
138
- roadmap task 6 measured it, on two lab CCUs on firmware 3.89.8; the write-up with the raw answers
139
- is `docs/config-pending.md` in that repository, and the answer is that neither hypothesis was
140
- right:
338
+ - **Restarts.** With `forgetClients: true` (the default) a restarted process has forgotten every
339
+ registered client: no events until the client calls `init` again. With `forgetClients: false`
340
+ it remembers them and calls each back as rfd does when it starts with its handlers file:
341
+ `system.listMethods` (BidCos), `listDevices`, `newDevices`/`deleteDevices` - calls the client did
342
+ not ask for, which is how it can tell that the process restarted.
343
+ - **Late start.** `interfaces: {hmip: {startDelay: 5000}}` binds the port and refuses connections
344
+ until the delay is over.
345
+ - **Injected faults** apply to the next `times` calls (default 1; `Infinity` or `-1` until
346
+ `clearFaults()`) of `method` (`'*'` for any) on `iface` (every interface when omitted), over both
347
+ transports. `fault` is a key of the fault table or `{faultCode, faultString}`; `delayMs` can be
348
+ combined with the others.
349
+ - **The callback log** lists every call the simulator made to a client with when it was sent and
350
+ answered, so a test can assert that its callback server answers fast.
141
351
 
142
- - **hmipserver stores what it rejects.** Everything in the struct is written into its own
143
- configuration for the channel first; the fault comes afterwards, when the result cannot be
144
- transferred to the device. A parameter the channel does not have is stored **for ever** - it
145
- survives a restart, no RPC method removes it, and from then on every `putParamset` on that
146
- channel faults, including one with an empty struct. A value of the wrong type raises a sticky
147
- `CONFIG_PENDING`, which a valid full MASTER write clears. A number outside `MIN`..`MAX` is
148
- accepted without a word: hmipserver does not range-check.
149
- - **rfd never faults on a value.** It drops a parameter the device does not have, ignores what it
150
- cannot use, clamps numbers into `MIN`..`MAX` and coerces strings - and answers `ok` to all of
151
- it. `CONFIG_PENDING` there means "a configuration is queued for the device" and clears when the
152
- device takes it, which on a battery device is when it next wakes up.
352
+ ## Recipes
153
353
 
154
- The simulator implements both, plus the two original hypotheses, per interface:
354
+ Each assumes the quick start's `sim` with the lab fixture (`LAB0000002` is its HM-LC-Sw1-Pl-2,
355
+ `LAB0000003` the HM-Sec-SC, `00000000000004` the HmIP-BBL) and a client of the code under test
356
+ registered on the interface.
357
+
358
+ **A device goes unreachable and comes back.**
155
359
 
156
360
  ```js
157
- new HmSim({
158
- interfaces: {
159
- hmip: {configPendingMode: 'hmip'}, // the default for hmip
160
- rfd: {configPendingMode: 'bidcos', configPendingDelay: 3000}, // the default for rfd and wired
161
- },
162
- });
361
+ sim.setReachable('rfd', 'LAB0000002', false); // events UNREACH true, STICKY_UNREACH true on :0
362
+ sim.setReachable('rfd', 'LAB0000002', true); // UNREACH false; STICKY_UNREACH stays
363
+ // the client acknowledges: setValue('LAB0000002:0', 'STICKY_UNREACH', false) over RPC
364
+ ```
365
+
366
+ **A sticky service message.** `sim.setServiceMessage('hmip', '00000000000004:0', 'CONFIG_PENDING',
367
+ true)` sends the event, and `getServiceMessages` lists it until it is set `false` the same way.
368
+
369
+ **A device is paired.**
370
+
371
+ ```js
372
+ sim.scriptNewDevices('rfd', [device, channel0, channel1], 500);
373
+ // the code under test calls setInstallMode(true, 60) - 500 ms later the clients get newDevices
163
374
  ```
164
375
 
165
- | option | default | |
166
- | ------------------------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
167
- | `configPendingMode` | `'hmip'` for `hmip`, `'bidcos'` for `rfd` and `wired`, `'strict'` for the rest | see the table below |
168
- | `configPendingOnWrite` | `false` | raise `CONFIG_PENDING` for _every_ accepted MASTER write. Implied by `'bidcos'`, which raises it for every write that changes something |
169
- | `configPendingDelay` | `0` | milliseconds after which a non-sticky `CONFIG_PENDING` clears itself - the stand-in for the device taking the configuration. The lab measured 160-180 s on a thermostat that transmits regularly, and "until someone opens the door" on a contact |
170
- | `serviceMessagesEmptyAsString` | `false` | answer `getServiceMessages` with `''` instead of `[]` when nothing is pending, which is what rfd really does |
376
+ or without the install mode, `sim.addDevice('rfd', device, channel0, channel1)`.
377
+
378
+ **The interface process restarts.** `await sim.dropConnection('hmip')`, then assert that the code
379
+ under test calls `init` again; `await sim.restartInterface('rfd', {downMs: 3000, forgetClients:
380
+ false})` for a restart it has to notice from the callbacks.
381
+
382
+ **CONFIG_PENDING after a MASTER write.** With `interfaces: {rfd: {configPendingDelay: 2000}}`, a
383
+ `putParamset('LAB0000003', 'MASTER', {CYCLIC_INFO_MSG: true})` of the code under test raises
384
+ `CONFIG_PENDING` on `LAB0000003:0` (an event, and `sim.getConfigPending('rfd')`), and 2 s later it
385
+ clears, as when the device took the configuration.
386
+
387
+ **What was written.**
388
+
389
+ ```js
390
+ const writes = sim.getWriteLog().filter((entry) => entry.address === 'LAB0000003');
391
+ assert.deepEqual(writes.at(-1).values, {CYCLIC_INFO_MSG: true});
392
+ ```
393
+
394
+ **A smoke detector team.** Give the smoke channels of HM-Sec-SD-2 devices a `TEAM`
395
+ (`'*<serial of the team>:1'`) and `TEAM_TAG: 'smoke_detector'`, and the team as a device of its own
396
+ (`ADDRESS: '*<serial>'`, `TYPE: 'HM-Sec-SD-2-Team'`) whose team channel lists the members in
397
+ `TEAM_CHANNELS`. `listTeams` answers the team devices, `setTeam(channel, team)` moves a detector
398
+ and deletes a team nobody is left in, `setTeam(channel, '')` gives it a team of its own - with
399
+ `newDevices`, `deleteDevices` and `updateDevice` to the clients. The shape is rfd's as read from a
400
+ CCU3; when rfd deletes and creates the team devices is the simulator's model.
401
+
402
+ ## Device data and fixtures
403
+
404
+ **What ships.** Without `devices` the library starts with no devices; the [command line](#command-line)
405
+ loads the historical lists `data/devices-rfd.json` (only the CCU's own `HM-RCV-50`, firmware
406
+ 2.27.8, `BidCoS-RF:0..50`) and `data/devices-hmip.json` (six HmIP devices of 2017: HMIP-eTRV,
407
+ HMIP-PS, HMIP-SWDO, HMIP-WTH, HmIP-SMI and a radio module). Without `paramsetDescriptions` both use
408
+ `data/paramset-descriptions.json` (1855 descriptions). These stay as they are for compatibility;
409
+ **tests should use a fixture**:
410
+
411
+ | File | What |
412
+ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
413
+ | `data/fixtures/lab-2026-09.json` | real devices of four test systems with the descriptions of their exact firmware, links and radio modules - the recommended one |
414
+ | `data/fixtures/devices.json` | generated from node-red-contrib-ccu's `paramsets.json`: real descriptions, synthesised channel indexes - HmIP-PDT, HmIPW-DRS8, HmIPW-DRI16, HmIPW-DRAP, HM-LC-Sw1-Pl, HM-CC-RT-DN |
415
+ | `data/fixtures/lab-devices.json` | a real `listDevices` of two CCUs on firmware 3.89.8 (2026-09-05), no descriptions of its own - pass the ones of `devices.json` or the bundled set alongside |
416
+
417
+ **`data/fixtures/lab-2026-09.json`**: four test systems (openccu-lite, the interface processes of
418
+ OpenCCU 3.89.11) dumped with `tools/dump-ccu.js` on 2026-09-29 and merged - `listDevices`, the
419
+ paramset descriptions of exactly the firmware every device runs (MASTER, VALUES, SERVICE, LINK;
420
+ `getMissingParamsetDescriptions()` is empty without the fallback), the direct links and
421
+ `listBidcosInterfaces`:
422
+
423
+ | Interface | Device types |
424
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
425
+ | BidCos-RF | HM-RCV-50 (the central, `BidCoS-RF:0..50`), HM-CC-TC, HM-Sec-SC, HM-LC-Sw1-Pl-2; five radio interfaces (types `CCU2`, `USB Interface`, `HMLGW2`) |
426
+ | BidCos-Wired | HMW-RCV-50 (the central only) |
427
+ | HmIP-RF | HmIP-RCV-50 (the central), the radio modules RPI-RF-MOD and HmIP-RFUSB (twice), HmIP-HAP, HmIP-BBL, HMIP-WRC2 (three firmwares), HmIP-PDT, HmIPW-DRAP, HmIPW-DRI16, HmIPW-DRS8 |
428
+
429
+ Links: WRC2s on the PDT, the BBL and a DRS8 channel, the PDT's and the BBL's internal links, and
430
+ the Sw1's one on BidCos-RF. A real hmipserver lists one radio module; the merged set lists three,
431
+ and `listBidcosInterfaces` names the first as the HmIP radio (its `ADDRESS` is `3014F711A0` + that
432
+ module's device address, as on a CCU). Serials are anonymised consistently in their own shape -
433
+ BidCos `LAB0000001`, HmIP `00000000000004` - link names are empty, and nothing else is changed.
434
+ Real hardware's oddities stay: the CCU's own `HmIP-RCV-50` sends a trailing **empty string** in
435
+ `CHILDREN`, for instance. The file is also a device file for the command line (`--devices`; add
436
+ `--wired-port 0` or a port for BidCos-Wired).
437
+
438
+ **How descriptions are found.** A description is looked up by
439
+ `<interface>/<type>/<firmware>/<version>/<channel type>/<paramset>`, and a device reports the
440
+ firmware it runs - which a description set often does not have. Such a device uses the description
441
+ of the **nearest firmware** of the same type and `VERSION`: the highest one at or below its own,
442
+ else the lowest one above it; firmware compared numerically part by part, a trailing build date
443
+ (`3.41.11.20181222`) last. Each substitution is logged once at `warn`. A type with no description
444
+ at all answers `-2` for its paramsets. `paramsetFallback: false` keeps the exact keys only, and
445
+ `sim.getMissingParamsetDescriptions()` lists every paramset a device announces in `PARAMSETS`
446
+ without an exact description, with the key used instead (`usedKey`, `null` when there is none):
447
+
448
+ ```js
449
+ assert.deepEqual(sim.getMissingParamsetDescriptions(), []); // the fixture is complete
450
+ ```
451
+
452
+ **Making a fixture from a CCU.** `tools/dump-ccu.js` (in the repository, not in the package):
453
+
454
+ ```
455
+ node tools/dump-ccu.js --rfd xmlrpc_bin://127.0.0.1:2001 --hmip xmlrpc://127.0.0.1:2010 \
456
+ --virtual xmlrpc://127.0.0.1:9292/groups --out my-ccu.json
457
+ ```
458
+
459
+ Every interface may be given more than once to merge several systems; `--types` keeps only the
460
+ device types listed, `--keep-serials` switches the anonymisation off, `--help` lists the rest. It
461
+ only reads (`listDevices`, `getParamsetDescription`, `getLinks`, `listBidcosInterfaces`), and it
462
+ refuses to write a file in which anything shaped like a serial, an HmIP address or an SGTIN is left
463
+ that it did not put there itself. The rest of a CCU's identity - host names, addresses, keys - is
464
+ never read.
465
+
466
+ **Making a fixture from descriptions.** `tools/fixtures-from-paramsets.js` builds devices from a
467
+ paramset description dump: node-red-contrib-ccu writes every description it ever read into
468
+ `paramsets.json` in its Node-RED user directory (and its repository carries one), keyed the same
469
+ way.
470
+
471
+ ```
472
+ node tools/fixtures-from-paramsets.js --source ../node-red-contrib-ccu/paramsets.json \
473
+ --types HmIP-PDT,HM-LC-Sw1-Pl --out data/fixtures/devices.json
474
+ node tools/fixtures-from-paramsets.js --source ../node-red-contrib-ccu/paramsets.json --list
475
+ ```
476
+
477
+ Such a dump knows the channel _types_ of a device but never their _indexes_, so the channel layout
478
+ comes from `tools/device-layouts.json`. An entry with a `channels` list is the real layout of the
479
+ hardware, read from a `listDevices` dump (HmIP-PDT, HmIP-WRC2, HmIPW-DRS8, HmIPW-DRI16, HmIPW-DRAP,
480
+ HM-CC-TC, HM-Sec-SC); everything else is synthesised from `order` and `counts` - consistent within
481
+ a fixture, not guaranteed to match the hardware. **A device type** is added with an entry there and
482
+ a run of the generator, or, better, by dumping a CCU that has one.
483
+
484
+ ## Interface behaviour, measured
485
+
486
+ Where the simulator imitates an interface process, this says whether the shape was measured on a
487
+ CCU or is the simulator's model. The measurements were made for Homematic Manager (tasks 6 and 58)
488
+ on CCUs with firmware 3.89.8 and 3.89.x.
489
+
490
+ ### CONFIG_PENDING
491
+
492
+ What a `putParamset MASTER` does, per interface (`interfaces.<iface>.configPendingMode`):
171
493
 
172
494
  | `configPendingMode` | what a `putParamset MASTER` does |
173
495
  | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174
496
  | `'hmip'` | measured hmipserver: stores everything, faults when the result is not transferable, poisons the channel on an unknown parameter, sticky `CONFIG_PENDING` on a wrong type, no range check |
175
497
  | `'bidcos'` | measured rfd: no fault, unknown parameters dropped, values clamped or ignored, `CONFIG_PENDING` while the change is queued |
176
- | `'strict'` | the first hypothesis: an invalid write is answered with a fault and nothing is written |
177
- | `'pending'` | the second hypothesis: the write is accepted, the valid parameters are stored, the rejected ones are recorded, and a sticky `CONFIG_PENDING` is raised |
498
+ | `'strict'` | a model: an invalid write is answered with a fault and nothing is written |
499
+ | `'pending'` | a model: the write is accepted, the valid parameters are stored, the rejected ones are recorded, and a sticky `CONFIG_PENDING` is raised |
178
500
 
179
- In `'strict'` and `'pending'` a sticky `CONFIG_PENDING` clears on a valid full MASTER write, on
180
- `clearConfigCache(deviceAddress)` or on `restoreConfigToDevice(deviceAddress)`. In `'hmip'` only
181
- the valid full MASTER write does: `clearConfigCache`, `restoreConfigToDevice` and
182
- `determineParameter` answer `-1 Generic error` there, exactly as hmipserver does - they are BidCos
183
- methods that hmipserver lists but does not implement.
501
+ - **hmipserver stores what it rejects.** Everything in the struct is written into its own
502
+ configuration for the channel first; the fault comes afterwards, when the result cannot be
503
+ transferred to the device. A parameter the channel does not have is stored **for ever** - it
504
+ survives a restart, no RPC method removes it, and from then on every `putParamset` on that
505
+ channel faults, including one with an empty struct (`sim.getPoisonedChannels(iface)`; deleting
506
+ the device is the only cure, on the hardware too). A value of the wrong type raises a sticky
507
+ `CONFIG_PENDING`, which a valid full MASTER write clears. A number outside `MIN`..`MAX` is
508
+ accepted without a word.
509
+ - **rfd never faults on a value.** It drops a parameter the device does not have, ignores what it
510
+ cannot use, clamps numbers into `MIN`..`MAX` and coerces strings - and answers `ok` to all of
511
+ it. `CONFIG_PENDING` there means "a configuration is queued for the device" and clears when the
512
+ device takes it (`configPendingDelay`), which on a battery device is when it next wakes up.
513
+ - In `'strict'` and `'pending'` a sticky `CONFIG_PENDING` clears on a valid full MASTER write, on
514
+ `clearConfigCache(deviceAddress)` or on `restoreConfigToDevice(deviceAddress)`. In `'hmip'` only
515
+ the valid full MASTER write does: `clearConfigCache`, `restoreConfigToDevice` and
516
+ `determineParameter` answer `-1 Generic error` there, exactly as hmipserver does.
184
517
 
185
- `sim.getPoisonedChannels(iface)` lists the channels whose stored MASTER carries a parameter their
186
- description does not have. Deleting the device (`sim.removeDevice`) is the only thing that clears
187
- one, which is also true of the hardware: there, it means pairing the device again.
518
+ The two models are the explanations that competed before the measurement (Homematic Manager issue
519
+ #98); they stay for applications that want to test against them.
188
520
 
189
- ## Fault codes, measured
521
+ ### Fault codes
190
522
 
191
- The interface processes' fault codes are not publicly specified, and the table in `lib/faults.js`
192
- used to be an educated guess. It is a measurement now (Homematic Manager task 6, firmware 3.89.8).
193
- The default table is **hmipserver's**, because that is the one an application has to survive:
523
+ The default table is **hmipserver's**, because that is the one an application has to survive
524
+ (measured):
194
525
 
195
526
  | key | code | string | when |
196
527
  | ------------------ | ---- | ------------------------------- | ---------------------------------------------------------------------------------- |
@@ -207,79 +538,204 @@ The default table is **hmipserver's**, because that is the one an application ha
207
538
  | `notReachable` | -1 | Generic error (UNREACH) | a sleeping battery device |
208
539
  | `invalidArguments` | -321 | Invalid arguments | wrong number of arguments; hmipserver really answers a Java exception message here |
209
540
 
210
- rfd's table is exported as `BIDCOS_FAULTS` and differs in more than wording - it answers **no
211
- fault at all** for an unknown paramset name (it takes the name as a peer address), for a missing
212
- argument, and for a `setValue` on a read-only datapoint:
541
+ Over XML-RPC a fault is an XML-RPC fault, over BIN-RPC a message of type `0xff` with a
542
+ `faultCode`/`faultString` struct. rfd's table is exported as `BIDCOS_FAULTS` and differs in more
543
+ than wording - it answers **no fault at all** for an unknown paramset name (it takes the name as a
544
+ peer address), for a missing argument, and for a `setValue` on a read-only datapoint:
213
545
 
214
546
  ```js
215
547
  const {FAULT_TABLES} = require('hm-simulator/lib/faults.js');
216
548
  new HmSim({faults: FAULT_TABLES.bidcos});
549
+ new HmSim({faults: {unknownParameter: {faultCode: -4, faultString: 'Unknown parameter'}}}); // one entry
217
550
  ```
218
551
 
219
- Every entry can still be replaced individually:
552
+ ### Service messages
553
+
554
+ `getServiceMessages` answers `[[address, datapoint, value]]`: every datapoint whose description
555
+ flags it as a service message and whose value is set, plus what `setServiceMessage` and the
556
+ `serviceMessages` option add. rfd answers an empty **string** rather than an empty array when
557
+ nothing is pending (measured); `serviceMessagesEmptyAsString: true` does the same - off by default,
558
+ because it breaks every client that assumes an array, which is why a client should be tested with it
559
+ once. Teams (`listTeams`) have the shape rfd was read to have on a CCU3.
560
+
561
+ **Suppression** (hmipserver only, eQ-3's HmIP addendum): `suppressServiceMessages(channelAddress,
562
+ parameter, suppress)` takes one of the channel's service parameters or `''` for all of them, and
563
+ ignores a parameter without the service flag; `getSuppressedServiceMessages(channelAddress)`
564
+ answers the suppressed ones (`[]` for an address it does not know). Measured on 3.89.11. A
565
+ suppressed message is left out of `getServiceMessages` and its datapoint reports the value that
566
+ raises no message (`false`), in `getValue`, `getParamset` and events, while the stored value stays;
567
+ a new occurrence stays suppressed until the suppression is lifted. That a change of the suppression
568
+ sends that value as an event is the simulator's model.
569
+
570
+ VirtualDevices, CUxD and hmipserver were seen to answer `getServiceMessages` with the fault
571
+ `Invalid XML-RPC message` on 3.89.x (hmipserver answered it on 3.89.11);
572
+ `interfaces.<iface>.getServiceMessagesFault: true` does the same, for a client that has to survive
573
+ it.
574
+
575
+ ### A callback server that hangs
576
+
577
+ `interfaces.<iface>.listenerModel: 'measured'`. Measured on firmware 3.89.8 (2026-09-25) with a
578
+ callback server that accepts the connection and never answers:
579
+
580
+ | interface | what happens |
581
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
582
+ | `hmip` | `init` is answered at once, `listDevices` is called back afterwards. While that callback hangs, **no event reaches any client** of the interface; they are queued and follow when the connection ends, and the client that hung is not called again. A client that hangs _after_ its registration holds up only itself. |
583
+ | `rfd`, `wired` | `system.listMethods` is called back **before** `init` answers, and until it returns **the interface answers nothing at all**, to anybody. When the connection ends, it is tried three more times at once and the client is registered anyway. A registered client that does not take a callback within `deliveryTimeout` (10 s) is dropped. |
584
+
585
+ That `listDevices` also comes before rfd answers the `init` is the simulator's model, not a
586
+ measurement; so is everything with the default `'isolated'`, where every client is on its own.
587
+
588
+ ### The calls of openccu-lite's daemon
589
+
590
+ `logLevel` (rfd and hs485d answer the level, 5 until one is set; hmipserver an empty string),
591
+ `changeKey` (rfd, hmipserver; recorded in `sim.keyChanges`) and `refreshDeployedDeviceFirmwareList`
592
+ (rfd, hmipserver; recorded in `sim.firmwareListRefreshes`) answer as the interfaces were measured to
593
+ on firmware 3.89.x; the simulator does no cryptography and loads no firmware.
594
+
595
+ ### The calls of Homematic Manager
596
+
597
+ Measured on rfd and hmipserver of firmware 3.89.11 (read-only calls, and one suppression set and
598
+ lifted again):
599
+
600
+ | call | rfd | hmipserver |
601
+ | ---------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------- |
602
+ | `getKeyMismatchDevice(reset)` | `''` when no device holds another key | the same |
603
+ | `getMetadata(address, key)`, key not set | fault `-1 Failure`, for an unknown address too | an empty value |
604
+ | `getAllMetadata(address)`, nothing set | fault `-1 Failure` | not in `system.listMethods` |
605
+ | `listReplaceableDevices(address)` | `[]` for its own `BidCoS-RF`, `-2 Unknown instance` for an unknown one | an empty HTTP body, always |
606
+ | `getVersion()` | `2.6.0` | the firmware, `3.89.11.…` |
607
+ | `getLGWStatus()` | `unknown method name`, with and without a LAN gateway in the system | not in `system.listMethods` |
608
+ | `suppressServiceMessages(…)` | not in `system.listMethods` | see [Service messages](#service-messages) |
609
+
610
+ The simulator answers so, with the fault table in force (`-1 Generic error` under hmipserver's
611
+ default table where rfd says `-1 Failure`) and `''` for hmipserver's empty body. Its models:
612
+ `listReplaceableDevices` on rfd lists the other devices of the same `TYPE`; `setMetadata` on rfd
613
+ knows only its own addresses; `setInterfaceClock` is recorded, not measured (it sets the clock the
614
+ devices get). `addDevice(serial)` pairs only the device of `scriptKeyMismatch`, with the right
615
+ temporary key; any other serial is `unknownInstance`, the mismatch a `notSupported` fault.
616
+
617
+ ## ReGa mock
618
+
619
+ `rega: {port: 8181, ...}` starts an HTTP server that answers `POST /<name>.exe` as the CCU's
620
+ `rega.exe` does - the script output followed by an `<xml>` block - for the scripts of the
621
+ [homematic-rega](https://github.com/hobbyquaker/homematic-rega) client, recognised by their first
622
+ line. Each answers the option of the same name:
623
+
624
+ | first line | client call | option | one element |
625
+ | ------------------- | ---------------- | ----------- | --------------------------------------------------------------------------------- |
626
+ | `!# devices.rega` | `getChannels()` | `channels` | `{id: 1001, address: 'LAB0000002:1', name: 'Plug'}` |
627
+ | `!# variables.rega` | `getVariables()` | `variables` | `{id: 950, name: 'Presence', val: true, ts: '2026-01-01 12:00:00'}` |
628
+ | `!# programs.rega` | `getPrograms()` | `programs` | `{id: 2000, name: 'Lights off', active: true}` |
629
+ | `!# rooms.rega` | `getRooms()` | `rooms` | `{id: 20, name: 'Hall', channels: [1001]}` |
630
+ | `!# functions.rega` | `getFunctions()` | `functions` | `{id: 30, name: 'Light', channels: [1001]}` |
631
+ | `!# values.rega` | `getValues()` | `values` | `{name: 'BidCos-RF.LAB0000002:1.STATE', value: false, ts: '2026-01-01 12:00:00'}` |
632
+
633
+ Of the other scripts, these are applied to the mock's state: `dom.GetObject(id).State(value)` (a
634
+ variable), `.Name("...")` (any object; also recorded in `sim.regaSim.renames` as `{id, name,
635
+ script}`), `.Active(true|false)` and `.ProgramExecute()` (a program). Every script, applied or not,
636
+ is recorded in `sim.regaSim.scripts` and anything else is answered with an empty output. `rega`
637
+ also takes `listenAddress`, `tls` and `auth` (inherited from the simulator's when not given);
638
+ `sim.regaSim.port` is the port once `whenReady()` resolved.
639
+
640
+ ## TLS and basic auth
641
+
642
+ `tls: true` serves every XML-RPC server - hmipserver, VirtualDevices, the XML-RPC half of rfd's and
643
+ BidCos-Wired's ports - and the ReGa mock over HTTPS; BIN-RPC has no TLS and stays plain, as on a
644
+ CCU. The certificate is self-signed, for `localhost`/`127.0.0.1`, generated per run and available
645
+ as `sim.tls.cert` (PEM; `--tls-cert-out <file>` on the command line), so a client either trusts
646
+ that or connects with `rejectUnauthorized: false`. `tls: {key, cert}` uses your own pair instead.
647
+
648
+ `auth: {username, password}` requires HTTP basic auth on the same servers (the XML-RPC ones and
649
+ ReGa); BIN-RPC has none. Callbacks to `https://` urls are made with `rejectUnauthorized: false`.
650
+
651
+ ## Behaviour scripts
652
+
653
+ A behaviour script is a module in `behaviorPath` that exports `api => {...}` and makes devices act
654
+ on their own with `api.emit('setValue', iface, address, datapoint, value)` - a device report, so it
655
+ may set datapoints a client could not write (`PRESS_SHORT`, `UNREACH`). **They are on by default:**
656
+ without `behaviorPath` the two examples in the package's `behaviors/` load and press `BidCoS-RF:1`
657
+ every 5 s and toggle `0000D3C98C9233:1` every 60 s - events a test did not ask for, and timers that
658
+ keep the process alive. Pass `behaviorPath: false` (or `--no-behaviors`) in tests.
220
659
 
221
- ```js
222
- new HmSim({faults: {unknownParameter: {faultCode: -4, faultString: 'Unknown parameter'}}});
223
- ```
224
-
225
- ## Fixtures
226
-
227
- `data/devices-rfd.json` and `data/devices-hmip.json` (the historical device lists) and
228
- `data/paramset-descriptions.json` (1855 descriptions) ship with the package and are unchanged.
229
-
230
- `data/fixtures/devices.json` is generated from node-red-contrib-ccu's `paramsets.json` and contains
231
- real descriptions for HmIP-PDT, HmIPW-DRS8, HmIPW-DRI16, HmIPW-DRAP, HM-LC-Sw1-Pl and HM-CC-RT-DN:
660
+ ## Command line
232
661
 
233
- ```js
234
- const fixture = require('hm-simulator/data/fixtures/devices.json');
235
- const sim = new HmSim({devices: fixture.devices, paramsetDescriptions: fixture.paramsetDescriptions});
662
+ ```
663
+ Usage: hm-simulator [options]
664
+
665
+ --listen-address <ip> default 127.0.0.1
666
+ --binrpc-port <port> rfd (BIN-RPC and XML-RPC), default 2001
667
+ --xmlrpc-port <port> hmipserver, default 2010
668
+ --wired-port <port> BidCos-Wired, off unless given or configured
669
+ --virtual-port <port> VirtualDevices, off unless given
670
+ --cuxd-port <port> CUxD, off unless given
671
+ --rega-port <port> ReGa mock, default 8181
672
+ --no-rega do not start the ReGa mock
673
+ every port may be 0: the system picks a free one
674
+ --ports-json <file|-> once every server listens, write the ports as JSON to the file, or
675
+ as one line to stdout with "-" (the log then goes to stderr)
676
+ --config <file> constructor options as a .json or .js file; the flags override it
677
+ --devices <file> a device file: {devices: {rfd, hmip, ...}, paramsetDescriptions, links,
678
+ bidcosInterfaces}, such as data/fixtures/lab-2026-09.json; default:
679
+ the bundled rfd and hmip lists
680
+ --tls serve the XML-RPC servers over TLS with a generated certificate
681
+ --tls-cert-out <file> write that certificate (PEM) to the file
682
+ --auth <user:password> HTTP basic auth for the XML-RPC servers and ReGa
683
+ --behavior-path <dir> behaviour scripts, default the bundled examples
684
+ --no-behaviors no behaviour scripts
685
+ --control-port <port> the scenario API over HTTP on 127.0.0.1, off unless given
686
+ -v, --verbosity <level> error|warn|info|debug, default info
687
+ --version
688
+ -h, --help
236
689
  ```
237
690
 
238
- Regenerate or extend it with
691
+ Unlike the library, the CLI listens on `127.0.0.1`, starts the ReGa mock on 8181 and loads the
692
+ bundled device lists unless `--devices` or `--config` says otherwise.
693
+
694
+ **Out of process** - start it with every port `0` and `--ports-json -`, wait for the first line on
695
+ stdout, and read the ports from it:
239
696
 
240
697
  ```
241
- node tools/fixtures-from-paramsets.js --source ../node-red-contrib-ccu/paramsets.json \
242
- --types HmIP-PDT,HM-LC-Sw1-Pl --out data/fixtures/devices.json
243
- node tools/fixtures-from-paramsets.js --source ../node-red-contrib-ccu/paramsets.json --list
698
+ $ hm-simulator --binrpc-port 0 --xmlrpc-port 0 --no-rega --no-behaviors \
699
+ --devices node_modules/hm-simulator/data/fixtures/lab-2026-09.json --wired-port 0 \
700
+ --control-port 0 --ports-json -
701
+ {"rfd":40123,"wired":40124,"hmip":40125,"control":40126}
244
702
  ```
245
703
 
246
- A paramset description dump knows the channel _types_ of a device but never their _indexes_, so
247
- the channel layout comes from `tools/device-layouts.json`. An entry with a `channels` list is the
248
- real layout of the hardware, read from a `listDevices` dump; the seven device types of the
249
- Homematic Manager lab (HmIP-PDT, HmIP-WRC2, HmIPW-DRS8, HmIPW-DRI16, HmIPW-DRAP, HM-CC-TC,
250
- HM-Sec-SC) have one since 2026-09-05. Everything else is synthesised from `order` and `counts`:
251
- consistent within a fixture (`CHILDREN`, `PARENT` and `INDEX` always agree), not guaranteed to
252
- match the hardware. Correcting an entry from a `listDevices` dump is a one line change there.
253
-
254
- `data/fixtures/lab-devices.json` is such a dump: `listDevices` as two CCUs on firmware 3.89.8
255
- answered it, with the serials anonymised and nothing else touched. It has no paramset
256
- descriptions - pass the ones from `devices.json` or the bundled set alongside:
257
-
258
- ```js
259
- const lab = require('hm-simulator/data/fixtures/lab-devices.json');
260
- const fixture = require('hm-simulator/data/fixtures/devices.json');
261
- const sim = new HmSim({devices: lab.devices, paramsetDescriptions: fixture.paramsetDescriptions});
704
+ `--ports-json <file>` writes the same object to a file instead (atomically: a reader never sees
705
+ half of it). A port that is taken ends the process with exit code 1 and the address in the
706
+ message. `SIGTERM` and `SIGINT` close every server and exit 0.
707
+
708
+ `--config` takes the constructor options as a file; `devices` and `paramsetDescriptions` in it may
709
+ be paths (relative to the file), `devices` a device file as for `--devices`. Everything without a
710
+ flag of its own - `interfaces`, `faults`, `serviceMessages`, `newDevices`, `rega`'s data - goes
711
+ there. The flags win over the file:
712
+
713
+ ```json
714
+ {
715
+ "devices": "node_modules/hm-simulator/data/fixtures/lab-2026-09.json",
716
+ "interfaces": {"rfd": {"serviceMessagesEmptyAsString": true}},
717
+ "rega": {"variables": [{"id": 950, "name": "Presence", "val": true}]}
718
+ }
262
719
  ```
263
720
 
264
- It is also where the oddities live that only real hardware produces - the CCU's own `HmIP-RCV-50`
265
- sends a trailing **empty string** in `CHILDREN`, for instance.
721
+ ### The control port
266
722
 
267
- ## Command line
723
+ `--control-port` (loopback only) is the [scenario API](#scenario-api) over HTTP:
724
+ `POST /scenario/<call>` with a JSON array of the arguments, `GET /scenario/<call>` for one without
725
+ arguments. The answer is `{"result": ...}`; a fault is `400` with `faultCode` and `faultString`, an
726
+ unknown call `404`. `GET /ports` answers the ports. The calls: `addDevice`, `removeDevice`,
727
+ `fireEvent`, `fireEvents`, `setValue` (the device reporting a value, as a behaviour script does),
728
+ `setServiceMessage`, `scriptNewDevices`, `scriptKeyMismatch`, `dropConnection`, `stopInterface`, `startInterface`,
729
+ `restartInterface`, `injectFault`, `clearFaults`, `setReachable`, `setLowBattery`, `setDutyCycle`,
730
+ `setCarrierSense`, `offerFirmware`, `schedule`, `getDevice`, `getWriteLog`, `getConfigPending`,
731
+ `getPoisonedChannels`, `getMissingParamsetDescriptions`, `getCallbackLog`, `getTempKey`,
732
+ `getInstallMode`.
268
733
 
269
734
  ```
270
- hm-simulator [options]
271
-
272
- --listen-address <ip> default 127.0.0.1
273
- --binrpc-port <port> rfd, default 2001
274
- --xmlrpc-port <port> hmipserver, default 2010
275
- --virtual-port <port> VirtualDevices, off unless given
276
- --cuxd-port <port> CUxD, off unless given
277
- --rega-port <port> ReGa mock, default 8181
278
- --no-rega do not start the ReGa mock
279
- -v, --verbosity <level> error|warn|info|debug, default info
735
+ curl -s -X POST localhost:40126/scenario/setReachable -d '["rfd", "LAB0000002", false]'
280
736
  ```
281
737
 
282
- ## Compatibility
738
+ ## Upgrading from 0.x
283
739
 
284
740
  - `require('hm-simulator/sim')` and `require('hm-simulator/sim.js')` keep returning the `HmSim`
285
741
  class; `sim.mjs` is the ESM entry point.
@@ -299,61 +755,14 @@ hm-simulator [options]
299
755
  and the rest of the table above. An unknown method now answers a fault instead of an empty string.
300
756
  - Node >= 20.19. `binrpc` 4 and `homematic-xmlrpc` 2 are the only runtime dependencies.
301
757
 
302
- ## Changelog
758
+ ## Contributing
303
759
 
304
- ### 1.1.0
305
-
306
- - `setTempKey` on the BidCos interfaces: the passphrase a device is taught in with, applied to
307
- the pairings that follow; `sim.getTempKey(iface)` reads back what was set, an empty string
308
- clears it. No cryptography. hmipserver has no such method, so it faults with unknown-method
309
- there (Homematic Manager issue #20).
310
- - Smoke detector teams on the BidCos interfaces: `listTeams` and `setTeam`. A channel with a
311
- `TEAM_TAG` is in the team its `TEAM` names, a pseudo device `*<serial>` whose team channel lists
312
- the members in `TEAM_CHANNELS`; `setTeam(channel, team)` moves it and deletes a team nobody is
313
- left in, `setTeam(channel, '')` puts it back into a team of its own, created like the one it
314
- left (`newDevices`, `deleteDevices`, `updateDevice` to the logic layers). The shape is rfd's
315
- as Homematic Manager task 58 read it from a CCU3; when rfd deletes and creates the team devices
316
- is the simulator's model, not a measurement.
317
-
318
- ### 1.0.0
319
-
320
- - Node >= 20.19, `binrpc` ^4.2, `homematic-xmlrpc` ^2.0; `express`, `body-parser`, `request`,
321
- `async` and `yalm` removed. The ReGa mock runs on `node:http`, the CLI logs through `console`.
322
- - Paramset state per device and channel for MASTER, VALUES, SERVICE and the link paramsets, with
323
- type, range, `VALUE_LIST` and `OPERATIONS` checks on every write.
324
- - `CONFIG_PENDING` semantics, configurable per interface: `'hmip'` and `'bidcos'` are what the
325
- two interface processes of a CCU on firmware 3.89.8 were measured to do (Homematic Manager task
326
- 6, 2026-09-05), `'strict'` and `'pending'` are the two hypotheses that measurement replaced.
327
- `getPoisonedChannels()` for the channel an unknown parameter destroyed.
328
- - Fault codes and strings measured on the same firmware, hmipserver's as the default table and
329
- rfd's as `BIDCOS_FAULTS`.
330
- - `data/fixtures/lab-devices.json`: an anonymised real `listDevices` of two CCUs, and the channel
331
- layouts in `tools/device-layouts.json` corrected from it.
332
- - Links: `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`,
333
- `activateLinkParamset`, and link paramsets addressed by the peer's address.
334
- - Interface and service methods: `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`,
335
- `getServiceMessages`, `setInstallMode`/`getInstallMode` with scripted `newDevices`,
336
- `deleteDevice`, `replaceDevice`, `reportValueUsage`, `updateFirmware`, `installFirmware`,
337
- `clearConfigCache`, `restoreConfigToDevice`, `determineParameter`, `getDeviceDescription`,
338
- `getValue`, `system.methodHelp`, `system.multicall`.
339
- - Fault responses on both transports, with an overridable fault table.
340
- - VirtualDevices and CUxD servers, BidCos-Wired, optional TLS with a generated certificate and
341
- optional HTTP basic auth.
342
- - ReGa mock with `getChannels`, `getValues`, rename/program scripts and a script log.
343
- - Scenario API: `addDevice`, `removeDevice`, `fireEvent`, `setServiceMessage`, `scriptNewDevices`,
344
- `dropConnection`, `getWriteLog`, `getConfigPending`.
345
- - `whenReady()`, port `0` support and `sim.ports`.
346
- - Fixture generator `tools/fixtures-from-paramsets.js` and generated real device fixtures.
347
- - ESLint 9 + Prettier, `node --test`, GitHub Actions on Node 20/22/24.
348
-
349
- ### 0.1.1 and earlier
350
-
351
- `init`, `ping`, `system.listMethods`, `getParamsetDescription`, `listDevices`, `setValue`, events
352
- through behaviour scripts, ReGa mock.
760
+ Pull requests welcome. `npm run lint` and `npm test` have to pass; a new RPC method or scenario call
761
+ comes with a test over both transports where it applies.
353
762
 
354
- ## Contributing
763
+ ## Changelog
355
764
 
356
- Pull requests welcome. `npm run lint` and `npm test` have to pass.
765
+ See [CHANGELOG.md](CHANGELOG.md).
357
766
 
358
767
  ## License
359
768