hm-simulator 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,11 +37,12 @@ messages. rfd and hmipserver start by default, the others when their port is con
37
37
  | devices | `listDevices`, `getDeviceDescription`, `deleteDevice`, `replaceDevice`, `setInstallMode`, `getInstallMode` |
38
38
  | paramsets | `getParamsetDescription`, `getParamset`, `putParamset`, `getValue`, `setValue`, `determineParameter`, `reportValueUsage` |
39
39
  | links | `getLinks`, `getLinkPeers`, `getLinkInfo`, `setLinkInfo`, `addLink`, `removeLink`, `activateLinkParamset` |
40
- | interface | `rssiInfo`, `listBidcosInterfaces`, `setBidcosInterface`, `getServiceMessages` |
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>` |
41
42
  | maintenance | `clearConfigCache`, `restoreConfigToDevice`, `updateFirmware`, `installFirmware` (the last two are stubs that record the call) |
42
43
 
43
44
  **Outgoing RPC calls** to every registered logic layer: `listDevices`, `newDevices`,
44
- `deleteDevices`, `event`, `system.multicall`.
45
+ `deleteDevices`, `updateDevice` (after `setTeam`), `event`, `system.multicall`.
45
46
 
46
47
  **ReGa** (`rega.exe` on port 8181): the scripts of the
47
48
  [homematic-rega](https://github.com/hobbyquaker/homematic-rega) client - `getChannels`,
@@ -300,6 +301,20 @@ hm-simulator [options]
300
301
 
301
302
  ## Changelog
302
303
 
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
+
303
318
  ### 1.0.0
304
319
 
305
320
  - Node >= 20.19, `binrpc` ^4.2, `homematic-xmlrpc` ^2.0; `express`, `body-parser`, `request`,
@@ -36,6 +36,10 @@ module.exports = {
36
36
  getServiceMessages: 'array getServiceMessages() - the pending service messages',
37
37
  setInstallMode: 'void setInstallMode(boolean on, int time, int mode) - switch the install mode',
38
38
  getInstallMode: 'int getInstallMode() - remaining seconds of the install mode',
39
+ setTempKey: 'void setTempKey(string passphrase) - the temporary key the next BidCos pairings use; BidCos only',
40
+ listTeams: 'array listTeams() - the team pseudo devices (smoke detector teams) with their channels; BidCos only',
41
+ setTeam:
42
+ 'void setTeam(string channelAddress, string teamAddress) - put a channel into a team, an empty team into its own; BidCos only',
39
43
  deleteDevice: 'void deleteDevice(string address, int flags) - remove a device',
40
44
  replaceDevice:
41
45
  'void replaceDevice(string oldDeviceAddress, string newDeviceAddress) - take over the configuration of a device',
package/lib/sim.js CHANGED
@@ -37,6 +37,9 @@ const PARAMSET_PREFIX = {
37
37
  cuxd: 'CUxD',
38
38
  };
39
39
 
40
+ /** the interfaces that speak the BidCos protocol, and therefore have setTempKey */
41
+ const BIDCOS_INTERFACES = new Set(['rfd', 'wired']);
42
+
40
43
  /** paramset keys that are not the address of a linked peer channel */
41
44
  const STANDARD_PARAMSETS = new Set(['MASTER', 'VALUES', 'SERVICE']);
42
45
 
@@ -181,6 +184,8 @@ class HmSim {
181
184
  this.serviceMessages = {};
182
185
  /** end of the install mode per interface, as an epoch timestamp */
183
186
  this.installMode = {};
187
+ /** the temporary AES key of the BidCos interfaces, see setTempKey */
188
+ this.tempKey = {};
184
189
  /** devices that appear when the install mode is switched on, see scriptNewDevices() */
185
190
  this.newDevicesScript = {};
186
191
  /** every reportValueUsage the simulator was told about */
@@ -978,6 +983,172 @@ class HmSim {
978
983
  return '';
979
984
  }
980
985
 
986
+ /**
987
+ * `setTempKey(passphrase)` - the temporary key a BidCos device is taught in with.
988
+ *
989
+ * A HomeMatic device can be paired with a passphrase instead of the interface's own key, and a
990
+ * device that was taught in that way can only be paired again when the same passphrase is
991
+ * offered. rfd takes it as one string argument and answers with the empty string; it applies to
992
+ * the pairings that follow, so an application sends it *before* `setInstallMode` or
993
+ * `addDevice`. An empty string clears it back to the interface's key.
994
+ *
995
+ * Only the BidCos processes have the method - hmipserver answers an unknown-method fault, which
996
+ * is what an application has to handle when a user types it into the wrong interface.
997
+ *
998
+ * The simulator does no cryptography: it remembers the key so a test can assert that the call
999
+ * happened, in which order, and with what. `sim.getTempKey(iface)` reads it back.
1000
+ *
1001
+ * @param {string} iface
1002
+ * @param {Array} params [passphrase]
1003
+ */
1004
+ setTempKey(iface, params) {
1005
+ if (!BIDCOS_INTERFACES.has(iface)) {
1006
+ throw this.fault('unknownMethod', `${iface}/setTempKey`);
1007
+ }
1008
+ const [key] = params || [];
1009
+ if (key !== undefined && typeof key !== 'string') {
1010
+ throw this.fault('invalidArguments', 'setTempKey wants a string');
1011
+ }
1012
+ this.tempKey[iface] = key === undefined ? '' : key;
1013
+ this.log.info(iface, this.tempKey[iface] === '' ? 'temporary key cleared' : 'temporary key set');
1014
+ return '';
1015
+ }
1016
+
1017
+ /* ------------------------------------------------------------------ teams */
1018
+
1019
+ /**
1020
+ * `listTeams()` - the team pseudo devices of a BidCos interface, with their channels.
1021
+ *
1022
+ * rfd keeps smoke detectors (and the other devices with a `TEAM_TAG`) in *teams*. A team is a
1023
+ * pseudo device whose address is `*` plus the serial of the device it was made from
1024
+ * (`*NEQ0448334`, type `HM-Sec-SD-2-Team`); its team channel carries the members in
1025
+ * `TEAM_CHANNELS`, and every member channel names the team channel in `TEAM`. The team devices
1026
+ * come with `listDevices` as well; `listTeams` is only them. hmipserver has no teams.
1027
+ *
1028
+ * @param {string} iface
1029
+ */
1030
+ listTeams(iface) {
1031
+ if (!BIDCOS_INTERFACES.has(iface)) {
1032
+ throw this.fault('unknownMethod', `${iface}/listTeams`);
1033
+ }
1034
+ return this.devices[iface].devices.filter((device) => device.ADDRESS.startsWith('*'));
1035
+ }
1036
+
1037
+ /**
1038
+ * `setTeam(channelAddress, teamAddress)` - puts a channel into a team.
1039
+ *
1040
+ * The shape is rfd's, as Homematic Manager task 58 read it from a CCU3: every detector starts in
1041
+ * a team of its own (`*<its serial>`); joining another team takes it out of the old one. That a
1042
+ * team nobody is left in is deleted (`deleteDevices`) is the simulator's model, not a measurement. An empty
1043
+ * `teamAddress` puts the channel back into a team of its own, which is created again
1044
+ * (`newDevices`) with the type of the team it leaves. Channel and team must carry the same
1045
+ * `TEAM_TAG`; there is no call that creates a team. The changed channels are announced with
1046
+ * `updateDevice`. BidCos only.
1047
+ *
1048
+ * @param {string} iface
1049
+ * @param {Array} params [channelAddress, teamAddress]
1050
+ */
1051
+ setTeam(iface, params) {
1052
+ if (!BIDCOS_INTERFACES.has(iface)) {
1053
+ throw this.fault('unknownMethod', `${iface}/setTeam`);
1054
+ }
1055
+ const [channelAddress, teamAddress = ''] = params || [];
1056
+ if (typeof channelAddress !== 'string' || typeof teamAddress !== 'string') {
1057
+ throw this.fault('invalidArguments', 'setTeam wants two strings');
1058
+ }
1059
+ const channel = this.requireDevice(iface, channelAddress);
1060
+ if (!channel.PARENT || !channel.TEAM_TAG || Array.isArray(channel.TEAM_CHANNELS)) {
1061
+ throw this.fault('invalidArguments', `${channelAddress} cannot be in a team`);
1062
+ }
1063
+ const oldTeam = channel.TEAM ? this.index[iface].get(channel.TEAM) : undefined;
1064
+ const ownDevice = `*${channel.PARENT}`;
1065
+ const ownTeam = `${ownDevice}:1`;
1066
+ const target = teamAddress === '' ? ownTeam : teamAddress;
1067
+ if (channel.TEAM === target) {
1068
+ return '';
1069
+ }
1070
+
1071
+ let team = this.index[iface].get(target);
1072
+ if (team === undefined && target === ownTeam) {
1073
+ team = this.createTeam(iface, channel, oldTeam);
1074
+ }
1075
+ if (team === undefined) {
1076
+ throw this.fault('unknownInstance', `${iface}/${target}`);
1077
+ }
1078
+ if (!Array.isArray(team.TEAM_CHANNELS) || team.TEAM_TAG !== channel.TEAM_TAG) {
1079
+ throw this.fault('invalidArguments', `${target} is no team for ${channelAddress}`);
1080
+ }
1081
+
1082
+ const changed = [channelAddress, target];
1083
+ if (oldTeam) {
1084
+ oldTeam.TEAM_CHANNELS = (oldTeam.TEAM_CHANNELS || []).filter((member) => member !== channelAddress);
1085
+ changed.push(oldTeam.ADDRESS);
1086
+ }
1087
+ team.TEAM_CHANNELS = [...team.TEAM_CHANNELS.filter((member) => member !== channelAddress), channelAddress];
1088
+ channel.TEAM = target;
1089
+ this.log.info(iface, 'team', channelAddress, '->', target);
1090
+
1091
+ for (const address of changed) {
1092
+ if (this.index[iface].has(address) && !(oldTeam && address === oldTeam.ADDRESS)) {
1093
+ this.tellClientsParams(iface, 'updateDevice', [address, 0]);
1094
+ }
1095
+ }
1096
+ // a team nobody is in any more is gone
1097
+ if (oldTeam && oldTeam.TEAM_CHANNELS.length === 0 && oldTeam.PARENT) {
1098
+ this.deleteDevice(iface, oldTeam.PARENT);
1099
+ } else if (oldTeam) {
1100
+ this.tellClientsParams(iface, 'updateDevice', [oldTeam.ADDRESS, 0]);
1101
+ }
1102
+ return '';
1103
+ }
1104
+
1105
+ /**
1106
+ * A team of a channel's own, `*<serial>`, shaped like the team it leaves (or, without one, as
1107
+ * `<PARENT_TYPE>-Team` with a `<TYPE>_TEAM` channel), and announced with `newDevices`.
1108
+ */
1109
+ createTeam(iface, channel, like) {
1110
+ const parent = this.index[iface].get(channel.PARENT) || {};
1111
+ const likeDevice = like && like.PARENT ? this.index[iface].get(like.PARENT) : undefined;
1112
+ const address = `*${channel.PARENT}`;
1113
+ const type = likeDevice ? likeDevice.TYPE : `${channel.PARENT_TYPE}-Team`;
1114
+ const device = {
1115
+ ADDRESS: address,
1116
+ TYPE: type,
1117
+ VERSION: likeDevice ? likeDevice.VERSION : parent.VERSION || 1,
1118
+ FIRMWARE: likeDevice ? likeDevice.FIRMWARE : parent.FIRMWARE || '1.0',
1119
+ CHILDREN: [`${address}:0`, `${address}:1`],
1120
+ PARAMSETS: ['MASTER'],
1121
+ FLAGS: 9,
1122
+ };
1123
+ const maintenance = {
1124
+ ADDRESS: `${address}:0`,
1125
+ TYPE: 'MAINTENANCE',
1126
+ VERSION: device.VERSION,
1127
+ PARENT: address,
1128
+ PARENT_TYPE: type,
1129
+ PARAMSETS: ['MASTER', 'VALUES'],
1130
+ INDEX: 0,
1131
+ };
1132
+ const teamChannel = {
1133
+ ADDRESS: `${address}:1`,
1134
+ TYPE: like ? like.TYPE : `${channel.TYPE}_TEAM`,
1135
+ VERSION: device.VERSION,
1136
+ PARENT: address,
1137
+ PARENT_TYPE: type,
1138
+ PARAMSETS: ['MASTER', 'VALUES'],
1139
+ INDEX: 1,
1140
+ TEAM_TAG: channel.TEAM_TAG,
1141
+ TEAM_CHANNELS: [],
1142
+ };
1143
+ this.addDevices(iface, [device, maintenance, teamChannel]);
1144
+ return this.index[iface].get(teamChannel.ADDRESS);
1145
+ }
1146
+
1147
+ /** The temporary key an interface was last given, `''` when there is none. */
1148
+ getTempKey(iface) {
1149
+ return this.tempKey[iface] || '';
1150
+ }
1151
+
981
1152
  /** Remaining seconds of the install mode, 0 when it is off. */
982
1153
  getInstallMode(iface) {
983
1154
  const until = this.installMode[iface] || 0;
@@ -1261,9 +1432,14 @@ class HmSim {
1261
1432
 
1262
1433
  /** Calls one method on every connected logic layer. */
1263
1434
  tellClients(iface, method, payload) {
1435
+ this.tellClientsParams(iface, method, [payload]);
1436
+ }
1437
+
1438
+ /** The same with several parameters after the interface id (`updateDevice(id, address, hint)`). */
1439
+ tellClientsParams(iface, method, params) {
1264
1440
  for (const key of Object.keys(this.clients[iface] || {})) {
1265
1441
  const client = this.clients[iface][key];
1266
- client.methodCall(method, [client.id, payload]);
1442
+ client.methodCall(method, [client.id, ...params]);
1267
1443
  }
1268
1444
  }
1269
1445
 
@@ -1320,6 +1496,9 @@ class HmSim {
1320
1496
  getServiceMessages: (iface) => this.getServiceMessages(iface),
1321
1497
  setInstallMode: (iface, params) => this.setInstallMode(iface, params),
1322
1498
  getInstallMode: (iface) => this.getInstallMode(iface),
1499
+ setTempKey: (iface, params) => this.setTempKey(iface, params),
1500
+ listTeams: (iface) => this.listTeams(iface),
1501
+ setTeam: (iface, params) => this.setTeam(iface, params),
1323
1502
  deleteDevice: (iface, params) => this.deleteDevice(iface, params[0], params[1]),
1324
1503
  replaceDevice: (iface, params) => this.replaceDevice(iface, params[0], params[1]),
1325
1504
  reportValueUsage: (iface, params) => this.reportValueUsage(iface, params),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hm-simulator",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Simulates a Homematic CCU (rfd, hmipserver, VirtualDevices, CUxD, ReGa) for automated tests",
5
5
  "main": "sim.js",
6
6
  "preferGlobal": true,