hm-simulator 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/README.md +596 -223
- package/data/fixtures/lab-2026-09.json +45956 -0
- package/index.js +260 -47
- package/lib/control.js +132 -0
- package/lib/method-help.js +8 -0
- package/lib/paramset-index.js +107 -0
- package/lib/servers.js +262 -13
- package/lib/sim.js +1142 -59
- package/lib/validate.js +4 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,190 +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,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.**
|
|
29
|
-
|
|
30
|
-
|
|
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`
|
|
41
|
-
|
|
|
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.
|
|
42
135
|
|
|
43
136
|
**Outgoing RPC calls** to every registered logic layer: `listDevices`, `newDevices`,
|
|
44
|
-
`deleteDevices`, `
|
|
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**
|
|
45
230
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
```
|
|
51
237
|
|
|
52
|
-
**
|
|
53
|
-
parameter, wrong type, out of range, unknown link. Over xmlrpc as an XML-RPC fault, over binrpc as
|
|
54
|
-
a message of type `0xff` with a `faultCode`/`faultString` struct - see
|
|
55
|
-
[Fault codes](#fault-codes-measured) below.
|
|
238
|
+
**Values and events**
|
|
56
239
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
+
```
|
|
60
247
|
|
|
61
|
-
|
|
248
|
+
**Service messages, device and radio health**
|
|
62
249
|
|
|
63
250
|
```js
|
|
64
|
-
|
|
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
|
+
```
|
|
65
258
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
});
|
|
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**
|
|
73
271
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
sim.close();
|
|
272
|
+
```js
|
|
273
|
+
await sim.schedule([{at: 1000, call: 'setReachable', args: ['rfd', 'LAB0000002', false]}]);
|
|
77
274
|
```
|
|
78
275
|
|
|
79
|
-
|
|
80
|
-
(`sim.ports.rfd`, `sim.ports.hmip`, ...) once `whenReady()` resolved. That is the reliable way to
|
|
81
|
-
run tests in parallel.
|
|
82
|
-
|
|
83
|
-
### Options
|
|
84
|
-
|
|
85
|
-
| option | default | |
|
|
86
|
-
| -------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
|
87
|
-
| `log` | silent | object with `debug`, `info`, `warn`, `error` |
|
|
88
|
-
| `devices` | `{rfd: {devices: []}, hmip: {devices: []}}` | device descriptions per interface |
|
|
89
|
-
| `paramsetDescriptions` | the bundled 8.4 MB `data/paramset-descriptions.json` | replaces the bundled descriptions |
|
|
90
|
-
| `config.listenAddress` | all interfaces | |
|
|
91
|
-
| `config.binrpcListenPort` | – | rfd |
|
|
92
|
-
| `config.xmlrpcListenPort` | – | hmipserver |
|
|
93
|
-
| `config.wiredListenPort` | – | BidCos-Wired, started when `devices.wired` exists |
|
|
94
|
-
| `config.virtualListenPort` | off | VirtualDevices |
|
|
95
|
-
| `config.cuxdListenPort` | off | CUxD |
|
|
96
|
-
| `config.virtualPath` | `/groups` | path the VirtualDevices server answers on |
|
|
97
|
-
| `behaviorPath` | `behaviors/` of the package | directory with behaviour scripts |
|
|
98
|
-
| `rega` | off | ReGa mock, see below |
|
|
99
|
-
| `links` | `{}` | links per interface, `[{SENDER, RECEIVER, NAME, DESCRIPTION}]` |
|
|
100
|
-
| `serviceMessages` | `{}` | service messages per interface, `[[address, datapoint, value]]` |
|
|
101
|
-
| `newDevices` | `{}` | devices that appear when the install mode is switched on, `{<iface>: {devices, delay}}` |
|
|
102
|
-
| `bidcosInterfaces` | one per interface | what `listBidcosInterfaces` answers |
|
|
103
|
-
| `defaultRssi` | `-65` | what `rssiInfo` answers where a device has no RSSI datapoint |
|
|
104
|
-
| `interfaces` | see [CONFIG_PENDING](#config_pending) | per interface behaviour |
|
|
105
|
-
| `faults` | see [Fault codes](#fault-codes-measured) | overrides for the fault table |
|
|
106
|
-
| `tls` | off | `true` generates a self signed certificate, or pass `{key, cert}` |
|
|
107
|
-
| `auth` | off | `{username, password}`, HTTP basic auth for the xmlrpc servers and ReGa |
|
|
108
|
-
|
|
109
|
-
`rega` takes `{port, listenAddress, channels, variables, programs, rooms, functions, values, tls,
|
|
110
|
-
auth}`. `channels` is what `getChannels()` answers: `[{id, address, name}]`.
|
|
111
|
-
|
|
112
|
-
### Scenario API
|
|
276
|
+
**Introspection**
|
|
113
277
|
|
|
114
278
|
```js
|
|
115
|
-
sim.
|
|
116
|
-
sim.removeDevice('rfd', 'ABC0000001'); // sends deleteDevices
|
|
117
|
-
sim.fireEvent('rfd', 'ABC0000001:1', 'STATE', true); // one event, no validation
|
|
118
|
-
sim.setServiceMessage('rfd', 'ABC0000001:0', 'STICKY_UNREACH', true);
|
|
119
|
-
sim.scriptNewDevices('rfd', [devices], 500); // appear after setInstallMode
|
|
120
|
-
await sim.dropConnection('rfd'); // interface process restart
|
|
121
|
-
sim.getWriteLog(); // every accepted putParamset: {iface, address, paramset, values, rejected, ts}
|
|
279
|
+
sim.getWriteLog(); // every accepted putParamset: [{iface, address, paramset, values, rejected, ts}]
|
|
122
280
|
sim.getConfigPending('rfd'); // [{address, sticky}]
|
|
123
281
|
sim.getPoisonedChannels('hmip'); // channels whose stored MASTER has a parameter they do not have
|
|
124
|
-
sim.
|
|
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
|
|
125
290
|
```
|
|
126
291
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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:
|
|
131
315
|
|
|
132
|
-
|
|
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
|
+
```
|
|
133
322
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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.
|
|
140
336
|
|
|
141
|
-
|
|
142
|
-
configuration for the channel first; the fault comes afterwards, when the result cannot be
|
|
143
|
-
transferred to the device. A parameter the channel does not have is stored **for ever** - it
|
|
144
|
-
survives a restart, no RPC method removes it, and from then on every `putParamset` on that
|
|
145
|
-
channel faults, including one with an empty struct. A value of the wrong type raises a sticky
|
|
146
|
-
`CONFIG_PENDING`, which a valid full MASTER write clears. A number outside `MIN`..`MAX` is
|
|
147
|
-
accepted without a word: hmipserver does not range-check.
|
|
148
|
-
- **rfd never faults on a value.** It drops a parameter the device does not have, ignores what it
|
|
149
|
-
cannot use, clamps numbers into `MIN`..`MAX` and coerces strings - and answers `ok` to all of
|
|
150
|
-
it. `CONFIG_PENDING` there means "a configuration is queued for the device" and clears when the
|
|
151
|
-
device takes it, which on a battery device is when it next wakes up.
|
|
337
|
+
## Recipes
|
|
152
338
|
|
|
153
|
-
|
|
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.**
|
|
154
344
|
|
|
155
345
|
```js
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
162
460
|
```
|
|
163
461
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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`):
|
|
170
478
|
|
|
171
479
|
| `configPendingMode` | what a `putParamset MASTER` does |
|
|
172
480
|
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
173
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 |
|
|
174
482
|
| `'bidcos'` | measured rfd: no fault, unknown parameters dropped, values clamped or ignored, `CONFIG_PENDING` while the change is queued |
|
|
175
|
-
| `'strict'` |
|
|
176
|
-
| `'pending'` |
|
|
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 |
|
|
177
485
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
the
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
183
502
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
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.
|
|
187
505
|
|
|
188
|
-
|
|
506
|
+
### Fault codes
|
|
189
507
|
|
|
190
|
-
The
|
|
191
|
-
|
|
192
|
-
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):
|
|
193
510
|
|
|
194
511
|
| key | code | string | when |
|
|
195
512
|
| ------------------ | ---- | ------------------------------- | ---------------------------------------------------------------------------------- |
|
|
@@ -206,79 +523,168 @@ The default table is **hmipserver's**, because that is the one an application ha
|
|
|
206
523
|
| `notReachable` | -1 | Generic error (UNREACH) | a sleeping battery device |
|
|
207
524
|
| `invalidArguments` | -321 | Invalid arguments | wrong number of arguments; hmipserver really answers a Java exception message here |
|
|
208
525
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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:
|
|
212
530
|
|
|
213
531
|
```js
|
|
214
532
|
const {FAULT_TABLES} = require('hm-simulator/lib/faults.js');
|
|
215
533
|
new HmSim({faults: FAULT_TABLES.bidcos});
|
|
534
|
+
new HmSim({faults: {unknownParameter: {faultCode: -4, faultString: 'Unknown parameter'}}}); // one entry
|
|
216
535
|
```
|
|
217
536
|
|
|
218
|
-
|
|
537
|
+
### Service messages
|
|
219
538
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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.
|
|
223
545
|
|
|
224
|
-
|
|
546
|
+
### A callback server that hangs
|
|
225
547
|
|
|
226
|
-
`
|
|
227
|
-
|
|
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:
|
|
228
550
|
|
|
229
|
-
|
|
230
|
-
|
|
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. |
|
|
231
555
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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:
|
|
236
572
|
|
|
237
|
-
|
|
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
|
|
238
610
|
|
|
239
611
|
```
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
243
638
|
```
|
|
244
639
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
real layout of the hardware, read from a `listDevices` dump; the seven device types of the
|
|
248
|
-
Homematic Manager lab (HmIP-PDT, HmIP-WRC2, HmIPW-DRS8, HmIPW-DRI16, HmIPW-DRAP, HM-CC-TC,
|
|
249
|
-
HM-Sec-SC) have one since 2026-09-05. Everything else is synthesised from `order` and `counts`:
|
|
250
|
-
consistent within a fixture (`CHILDREN`, `PARENT` and `INDEX` always agree), not guaranteed to
|
|
251
|
-
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.
|
|
252
642
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
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:
|
|
256
645
|
|
|
257
|
-
```
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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}
|
|
261
651
|
```
|
|
262
652
|
|
|
263
|
-
|
|
264
|
-
|
|
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
|
+
```
|
|
265
669
|
|
|
266
|
-
|
|
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`.
|
|
267
682
|
|
|
268
683
|
```
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
--listen-address <ip> default 127.0.0.1
|
|
272
|
-
--binrpc-port <port> rfd, default 2001
|
|
273
|
-
--xmlrpc-port <port> hmipserver, default 2010
|
|
274
|
-
--virtual-port <port> VirtualDevices, off unless given
|
|
275
|
-
--cuxd-port <port> CUxD, off unless given
|
|
276
|
-
--rega-port <port> ReGa mock, default 8181
|
|
277
|
-
--no-rega do not start the ReGa mock
|
|
278
|
-
-v, --verbosity <level> error|warn|info|debug, default info
|
|
684
|
+
curl -s -X POST localhost:40126/scenario/setReachable -d '["rfd", "LAB0000002", false]'
|
|
279
685
|
```
|
|
280
686
|
|
|
281
|
-
##
|
|
687
|
+
## Upgrading from 0.x
|
|
282
688
|
|
|
283
689
|
- `require('hm-simulator/sim')` and `require('hm-simulator/sim.js')` keep returning the `HmSim`
|
|
284
690
|
class; `sim.mjs` is the ESM entry point.
|
|
@@ -298,47 +704,14 @@ hm-simulator [options]
|
|
|
298
704
|
and the rest of the table above. An unknown method now answers a fault instead of an empty string.
|
|
299
705
|
- Node >= 20.19. `binrpc` 4 and `homematic-xmlrpc` 2 are the only runtime dependencies.
|
|
300
706
|
|
|
301
|
-
##
|
|
707
|
+
## Contributing
|
|
302
708
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
- Node >= 20.19, `binrpc` ^4.2, `homematic-xmlrpc` ^2.0; `express`, `body-parser`, `request`,
|
|
306
|
-
`async` and `yalm` removed. The ReGa mock runs on `node:http`, the CLI logs through `console`.
|
|
307
|
-
- Paramset state per device and channel for MASTER, VALUES, SERVICE and the link paramsets, with
|
|
308
|
-
type, range, `VALUE_LIST` and `OPERATIONS` checks on every write.
|
|
309
|
-
- `CONFIG_PENDING` semantics, configurable per interface: `'hmip'` and `'bidcos'` are what the
|
|
310
|
-
two interface processes of a CCU on firmware 3.89.8 were measured to do (Homematic Manager task
|
|
311
|
-
6, 2026-09-05), `'strict'` and `'pending'` are the two hypotheses that measurement replaced.
|
|
312
|
-
`getPoisonedChannels()` for the channel an unknown parameter destroyed.
|
|
313
|
-
- Fault codes and strings measured on the same firmware, hmipserver's as the default table and
|
|
314
|
-
rfd's as `BIDCOS_FAULTS`.
|
|
315
|
-
- `data/fixtures/lab-devices.json`: an anonymised real `listDevices` of two CCUs, and the channel
|
|
316
|
-
layouts in `tools/device-layouts.json` corrected from it.
|
|
317
|
-
- Links: `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`,
|
|
318
|
-
`activateLinkParamset`, and link paramsets addressed by the peer's address.
|
|
319
|
-
- Interface and service methods: `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`,
|
|
320
|
-
`getServiceMessages`, `setInstallMode`/`getInstallMode` with scripted `newDevices`,
|
|
321
|
-
`deleteDevice`, `replaceDevice`, `reportValueUsage`, `updateFirmware`, `installFirmware`,
|
|
322
|
-
`clearConfigCache`, `restoreConfigToDevice`, `determineParameter`, `getDeviceDescription`,
|
|
323
|
-
`getValue`, `system.methodHelp`, `system.multicall`.
|
|
324
|
-
- Fault responses on both transports, with an overridable fault table.
|
|
325
|
-
- VirtualDevices and CUxD servers, BidCos-Wired, optional TLS with a generated certificate and
|
|
326
|
-
optional HTTP basic auth.
|
|
327
|
-
- ReGa mock with `getChannels`, `getValues`, rename/program scripts and a script log.
|
|
328
|
-
- Scenario API: `addDevice`, `removeDevice`, `fireEvent`, `setServiceMessage`, `scriptNewDevices`,
|
|
329
|
-
`dropConnection`, `getWriteLog`, `getConfigPending`.
|
|
330
|
-
- `whenReady()`, port `0` support and `sim.ports`.
|
|
331
|
-
- Fixture generator `tools/fixtures-from-paramsets.js` and generated real device fixtures.
|
|
332
|
-
- ESLint 9 + Prettier, `node --test`, GitHub Actions on Node 20/22/24.
|
|
333
|
-
|
|
334
|
-
### 0.1.1 and earlier
|
|
335
|
-
|
|
336
|
-
`init`, `ping`, `system.listMethods`, `getParamsetDescription`, `listDevices`, `setValue`, events
|
|
337
|
-
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.
|
|
338
711
|
|
|
339
|
-
##
|
|
712
|
+
## Changelog
|
|
340
713
|
|
|
341
|
-
|
|
714
|
+
See [CHANGELOG.md](CHANGELOG.md).
|
|
342
715
|
|
|
343
716
|
## License
|
|
344
717
|
|