hm-simulator 1.1.0 → 1.2.0

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