homematic-manager 3.0.0-beta.12 → 3.0.0-beta.13

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.
Files changed (33) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/options.d.ts +13 -0
  4. package/dist/options.js +26 -0
  5. package/dist/server.d.ts +7 -0
  6. package/dist/server.js +11 -0
  7. package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +9 -0
  8. package/node_modules/@homematic-manager/backend/dist/api/backend.js +125 -13
  9. package/node_modules/@homematic-manager/backend/dist/devices/installMode.js +9 -0
  10. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +20 -1
  11. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +24 -1
  12. package/node_modules/@homematic-manager/backend/dist/rpc/server.d.ts +12 -0
  13. package/node_modules/@homematic-manager/backend/dist/rpc/server.js +33 -3
  14. package/node_modules/@homematic-manager/backend/package.json +1 -1
  15. package/node_modules/@homematic-manager/core/dist/api/types.d.ts +36 -5
  16. package/node_modules/@homematic-manager/core/dist/index.d.ts +1 -0
  17. package/node_modules/@homematic-manager/core/dist/index.js +1 -0
  18. package/node_modules/@homematic-manager/core/dist/interfaces/table.d.ts +14 -0
  19. package/node_modules/@homematic-manager/core/dist/interfaces/table.js +12 -0
  20. package/node_modules/@homematic-manager/core/dist/meta/store.d.ts +1 -1
  21. package/node_modules/@homematic-manager/core/dist/meta/types.d.ts +6 -2
  22. package/node_modules/@homematic-manager/core/dist/meta/types.js +6 -4
  23. package/node_modules/@homematic-manager/core/dist/rpc/text.d.ts +22 -0
  24. package/node_modules/@homematic-manager/core/dist/rpc/text.js +46 -0
  25. package/node_modules/@homematic-manager/core/dist/serviceMessages/index.d.ts +20 -0
  26. package/node_modules/@homematic-manager/core/dist/serviceMessages/index.js +27 -0
  27. package/node_modules/@homematic-manager/core/package.json +1 -1
  28. package/package.json +3 -3
  29. package/ui/assets/index-C7XVzGWq.css +1 -0
  30. package/ui/assets/index-DiBHCDsg.js +14 -0
  31. package/ui/index.html +2 -2
  32. package/ui/assets/index-C-Mop9IQ.css +0 -1
  33. package/ui/assets/index-iWwfWYMS.js +0 -14
package/README.md CHANGED
@@ -65,6 +65,7 @@ hard-coding it.
65
65
  | `--local` | - | we run on the CCU itself: talk to the interface processes directly |
66
66
  | `--callback-ip` | auto | address the interfaces call back to; needed where this host cannot see it (Docker) |
67
67
  | `--callback-xmlrpc-port`, `--callback-binrpc-port` | free ports | fixed callback ports, so a container can publish them |
68
+ | `--callback-xmlrpc-default-port`, `--callback-binrpc-default-port` | - | callback ports while the configured ones are 0, a free port when one is taken (the CCU addon: 2031, 2032; never written to the configuration) |
68
69
  | `--demo` | off | serve the UI on its demo fixture and start no backend |
69
70
  | `--log-level` | `info` | `error`, `warn`, `info`, `debug` |
70
71
  | `--install` / `--uninstall` | - | systemd service; `--purge` also deletes the state |
package/dist/cli.js CHANGED
@@ -169,6 +169,12 @@ function startHost(values, log, version) {
169
169
  ...(values.callbackIp === undefined ? {} : { callbackIp: values.callbackIp }),
170
170
  ...(values.callbackXmlrpcPort === undefined ? {} : { callbackXmlrpcPort: values.callbackXmlrpcPort }),
171
171
  ...(values.callbackBinrpcPort === undefined ? {} : { callbackBinrpcPort: values.callbackBinrpcPort }),
172
+ ...(values.callbackXmlrpcDefaultPort === undefined
173
+ ? {}
174
+ : { callbackXmlrpcDefaultPort: values.callbackXmlrpcDefaultPort }),
175
+ ...(values.callbackBinrpcDefaultPort === undefined
176
+ ? {}
177
+ : { callbackBinrpcDefaultPort: values.callbackBinrpcDefaultPort }),
172
178
  idleUnsubscribeMs: values.idleUnsubscribeMs,
173
179
  });
174
180
  }
package/dist/options.d.ts CHANGED
@@ -144,6 +144,16 @@ export declare const OPTIONS: {
144
144
  readonly describe: "fixed port for the binrpc callback server; must differ from the xmlrpc one";
145
145
  readonly defaultDescription: "0, a free port";
146
146
  };
147
+ readonly 'callback-xmlrpc-default-port': {
148
+ readonly type: "number";
149
+ readonly describe: "xmlrpc callback port while the configured one is 0; a free port when it is taken (the CCU addon sets 2031)";
150
+ readonly defaultDescription: "none, a free port";
151
+ };
152
+ readonly 'callback-binrpc-default-port': {
153
+ readonly type: "number";
154
+ readonly describe: "binrpc callback port while the configured one is 0; a free port when it is taken (the CCU addon sets 2032)";
155
+ readonly defaultDescription: "none, a free port";
156
+ };
147
157
  readonly 'idle-unsubscribe': {
148
158
  readonly type: "string";
149
159
  readonly describe: "drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)";
@@ -221,6 +231,9 @@ export interface WebOptions {
221
231
  readonly callbackIp: string | undefined;
222
232
  readonly callbackXmlrpcPort: number | undefined;
223
233
  readonly callbackBinrpcPort: number | undefined;
234
+ /** Task 35 (D-43): the CCU addon's fixed callback ports, taken while the configured ones are `0`. */
235
+ readonly callbackXmlrpcDefaultPort: number | undefined;
236
+ readonly callbackBinrpcDefaultPort: number | undefined;
224
237
  readonly demo: boolean;
225
238
  /** D-31, in milliseconds; `0` disables the idle unsubscribe. */
226
239
  readonly idleUnsubscribeMs: number;
package/dist/options.js CHANGED
@@ -144,6 +144,16 @@ export const OPTIONS = {
144
144
  describe: 'fixed port for the binrpc callback server; must differ from the xmlrpc one',
145
145
  defaultDescription: '0, a free port',
146
146
  },
147
+ 'callback-xmlrpc-default-port': {
148
+ type: 'number',
149
+ describe: 'xmlrpc callback port while the configured one is 0; a free port when it is taken (the CCU addon sets 2031)',
150
+ defaultDescription: 'none, a free port',
151
+ },
152
+ 'callback-binrpc-default-port': {
153
+ type: 'number',
154
+ describe: 'binrpc callback port while the configured one is 0; a free port when it is taken (the CCU addon sets 2032)',
155
+ defaultDescription: 'none, a free port',
156
+ },
147
157
  'idle-unsubscribe': {
148
158
  type: 'string',
149
159
  describe: 'drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)',
@@ -292,6 +302,20 @@ export function parseOptions(argv, env = process.env) {
292
302
  const value = raw[name] ?? OPTIONS[name].default;
293
303
  return value === undefined ? undefined : Number(value);
294
304
  };
305
+ const port = (name) => {
306
+ const value = number(name);
307
+ if (value !== undefined && !(Number.isInteger(value) && value >= 0 && value <= 65535)) {
308
+ throw new CliError(`--${name}: "${String(value)}" is not a port`);
309
+ }
310
+ return value;
311
+ };
312
+ const callbackXmlrpcDefaultPort = port('callback-xmlrpc-default-port');
313
+ const callbackBinrpcDefaultPort = port('callback-binrpc-default-port');
314
+ if (callbackXmlrpcDefaultPort !== undefined && callbackXmlrpcDefaultPort !== 0) {
315
+ if (callbackXmlrpcDefaultPort === callbackBinrpcDefaultPort) {
316
+ throw new CliError('--callback-xmlrpc-default-port and --callback-binrpc-default-port must differ');
317
+ }
318
+ }
295
319
  const logLevel = string('log-level');
296
320
  return {
297
321
  port: Number(raw['port'] ?? OPTIONS.port.default),
@@ -312,6 +336,8 @@ export function parseOptions(argv, env = process.env) {
312
336
  callbackIp: string('callback-ip'),
313
337
  callbackXmlrpcPort: number('callback-xmlrpc-port'),
314
338
  callbackBinrpcPort: number('callback-binrpc-port'),
339
+ callbackXmlrpcDefaultPort,
340
+ callbackBinrpcDefaultPort,
315
341
  demo: boolean('demo'),
316
342
  idleUnsubscribeMs: parseDuration(string('idle-unsubscribe'), '--idle-unsubscribe'),
317
343
  logLevel: isLogLevel(logLevel) ? logLevel : 'info',
package/dist/server.d.ts CHANGED
@@ -118,6 +118,13 @@ export interface WebHostOptions {
118
118
  /** Fixed callback ports, so a container can publish them. `0` picks a free one. */
119
119
  readonly callbackXmlrpcPort?: number | undefined;
120
120
  readonly callbackBinrpcPort?: number | undefined;
121
+ /**
122
+ * Task 35 (D-43): the callback ports taken while the configuration says `0` - the CCU addon's
123
+ * fixed pair. Handed to the backend and never written to `config.json`, so a port the user set
124
+ * in the settings dialog always wins and no update of the addon can overwrite it.
125
+ */
126
+ readonly callbackXmlrpcDefaultPort?: number | undefined;
127
+ readonly callbackBinrpcDefaultPort?: number | undefined;
121
128
  /** How long `close()` waits for `backend.stop()`. */
122
129
  readonly shutdownTimeoutMs?: number;
123
130
  /** How often an idle api socket is pinged; `0` turns the heartbeat off. */
package/dist/server.js CHANGED
@@ -100,6 +100,10 @@ export async function createWebHost(options = {}) {
100
100
  let backend;
101
101
  if (!demo) {
102
102
  await ensureDataDir(dataDir);
103
+ const defaultCallbackPorts = callbackDefaults(options);
104
+ if (defaultCallbackPorts !== undefined) {
105
+ log.info(`callback: default ports xmlrpc=${String(defaultCallbackPorts.xmlrpc)} binrpc=${String(defaultCallbackPorts.binrpc)} while the configuration says 0`);
106
+ }
103
107
  backend = await Backend.open({
104
108
  dataDir,
105
109
  version: options.version ?? packageVersion(),
@@ -108,6 +112,7 @@ export async function createWebHost(options = {}) {
108
112
  // reports them, and Electron's in-process transport reports none - so an Electron
109
113
  // window can never be idled out however the backend is configured.
110
114
  ...(options.idleUnsubscribeMs === undefined ? {} : { idleUnsubscribeMs: options.idleUnsubscribeMs }),
115
+ ...(defaultCallbackPorts === undefined ? {} : { defaultCallbackPorts }),
111
116
  ...options.backendOptions,
112
117
  });
113
118
  backend.on('notice', (notice) => {
@@ -545,6 +550,12 @@ function upstreamOf(connection) {
545
550
  ...(connection.auth ? { auth: connection.auth } : {}),
546
551
  };
547
552
  }
553
+ /** Task 35: the default pair, or nothing when neither port is set to anything but `0`. */
554
+ function callbackDefaults(options) {
555
+ const xmlrpc = options.callbackXmlrpcDefaultPort ?? 0;
556
+ const binrpc = options.callbackBinrpcDefaultPort ?? 0;
557
+ return xmlrpc === 0 && binrpc === 0 ? undefined : { xmlrpc, binrpc };
558
+ }
548
559
  /** `--ccu`, `--local` and the callback options win over what `config.json` holds. */
549
560
  async function applyConnectionOptions(backend, options, log) {
550
561
  const wantsHost = options.ccu !== undefined && options.ccu !== '';
@@ -30,6 +30,15 @@ export interface BackendOptions extends Omit<ConfigStoreOptions, 'version'> {
30
30
  /** Roots `data.file` may read from, keyed by the prefix the UI uses. */
31
31
  readonly fileRoots?: Readonly<Record<string, string>>;
32
32
  readonly callbackHost?: string;
33
+ /**
34
+ * Task 35 (D-43): the callback ports taken while the connection's are `0` - the CCU addon's fixed
35
+ * pair. A property of the host, not of the profile: it is never written to `config.json`, and
36
+ * `AppConfig.callbackDefaultPorts` reports it so the settings dialog can say what `0` means.
37
+ */
38
+ readonly defaultCallbackPorts?: {
39
+ readonly xmlrpc: number;
40
+ readonly binrpc: number;
41
+ };
33
42
  readonly rpcTimeoutMs?: number;
34
43
  readonly watchdogIntervalMs?: number;
35
44
  readonly serviceMessagePollMs?: number;
@@ -14,7 +14,7 @@
14
14
  * main process with it, issue #127).
15
15
  */
16
16
  import path from 'node:path';
17
- import { RPC_METHOD_NAMES, RSSI_UNKNOWN, countsAsServiceMessage, isAcknowledgeable, maintenanceAddress, mergeMethodHelp, methodsFor, normaliseDescription, } from '@homematic-manager/core';
17
+ import { RPC_METHOD_NAMES, RSSI_UNKNOWN, countsAsServiceMessage, isAcknowledgeable, maintenanceAddress, mergeMethodHelp, methodsFor, normaliseDescription, repairMisdecodedUtf8, } from '@homematic-manager/core';
18
18
  import { CacheStore } from '../cache/store.js';
19
19
  import { ConfigStore } from '../config/store.js';
20
20
  import { LinkTemplateStore } from '../config/linkTemplates.js';
@@ -37,6 +37,13 @@ import { WriteQueue } from '../write/queue.js';
37
37
  export const SERVICE_MESSAGE_POLL_MS = 300_000;
38
38
  /** How long the HmIP `getParamset(:0, VALUES)` sweep waits for the device list to settle. */
39
39
  export const HMIP_SWEEP_DELAY_MS = 1000;
40
+ /**
41
+ * An HmIP interface, by name or by interface type - the same rule the UI's settings dialog uses to
42
+ * count the messages it asks about (task 34), so the question and the writes agree.
43
+ */
44
+ function isHmipInterface(name, type) {
45
+ return /hmip/i.test(name) || /hmip/i.test(type);
46
+ }
40
47
  /** Implements the whole contract. `open()` loads, `start()` connects, `stop()` disconnects. */
41
48
  export class Backend {
42
49
  events;
@@ -263,7 +270,7 @@ export class Backend {
263
270
  case 'config.get':
264
271
  return this.#configWithDetected();
265
272
  case 'config.set':
266
- return this.#setConfig(p[0]);
273
+ return this.#setConfig(p[0], params[1]);
267
274
  case 'config.discover':
268
275
  return this.#discover();
269
276
  case 'config.clearCaches':
@@ -473,6 +480,9 @@ export class Backend {
473
480
  this.#onCall(record);
474
481
  },
475
482
  ...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
483
+ ...(this.#options.defaultCallbackPorts === undefined
484
+ ? {}
485
+ : { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
476
486
  ...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
477
487
  ...(this.#options.watchdogIntervalMs === undefined
478
488
  ? {}
@@ -756,13 +766,16 @@ export class Backend {
756
766
  * a device that is unreachable is not going to take a write either, which is the normal case
757
767
  * here and not worth an error dialog.
758
768
  */
759
- #noteUnreach(interfaceName, address, datapoint, value) {
769
+ #noteUnreach(interfaceName, address, datapoint, value, options = {}) {
760
770
  if (!this.#caches.unreach.note(interfaceName, address, datapoint, value, this.#now())) {
761
771
  return;
762
772
  }
763
773
  this.#caches.saveUnreach();
764
774
  this.events.emit('unreach.changed', this.#caches.unreach.list());
765
- if (datapoint === 'STICKY_UNREACH' && value === true && this.#config.connection.autoAckStickyUnreach === true) {
775
+ if (options.acknowledge !== false &&
776
+ datapoint === 'STICKY_UNREACH' &&
777
+ value === true &&
778
+ this.#config.connection.autoAckStickyUnreach === true) {
766
779
  void this.#acknowledge(interfaceName, address, datapoint).catch((error) => {
767
780
  this.#notice('info', `${address}: STICKY_UNREACH could not be acknowledged automatically: ${errorMessage(error)}`, interfaceName);
768
781
  });
@@ -799,12 +812,28 @@ export class Backend {
799
812
  * configuration
800
813
  */
801
814
  #configWithDetected() {
802
- return this.#config.config;
815
+ return this.#withHostFacts(this.#config.config);
803
816
  }
804
- async #setConfig(connection) {
817
+ /**
818
+ * What the host adds to the stored configuration before anybody sees it: task 35's callback
819
+ * ports a `0` stands for. Added to every `AppConfig` that leaves the backend - `config.get`,
820
+ * `config.set` and both `config.changed` events - so the dialog never loses the hint on a save.
821
+ */
822
+ #withHostFacts(config) {
823
+ const ports = this.#options.defaultCallbackPorts;
824
+ return ports === undefined
825
+ ? config
826
+ : { ...config, callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } };
827
+ }
828
+ async #setConfig(connection, options) {
805
829
  const previousHost = this.#config.connection.host;
830
+ // Task 34 (#147, D-42): the messages the settings dialog asked about, taken before the
831
+ // reconnect, and only on the save that switches the auto-acknowledge from off to on.
832
+ const existing = options?.acknowledgeExisting === true && this.#config.connection.autoAckStickyUnreach !== true
833
+ ? this.#stickyUnreachMessages()
834
+ : [];
806
835
  await this.#disconnect();
807
- const config = await this.#config.setConnection(connection);
836
+ const config = this.#withHostFacts(await this.#config.setConnection(connection));
808
837
  this.#writeLog.setRpcLogFolder(config.connection.rpcLogFolder);
809
838
  if (config.connection.host !== previousHost) {
810
839
  await this.#caches.flush();
@@ -820,14 +849,61 @@ export class Backend {
820
849
  this.events.emit('config.changed', config);
821
850
  if (!this.#stopped && validateConnection(config.connection).length === 0) {
822
851
  await this.#connect();
852
+ // after the reconnect, because a write needs the interface; not awaited, so the
853
+ // settings dialog closes as quickly as ever and the writes show up in the RPC log
854
+ if (existing.length > 0 &&
855
+ config.connection.autoAckStickyUnreach === true &&
856
+ config.connection.host === previousHost) {
857
+ void this.#acknowledgeExisting(existing);
858
+ }
823
859
  }
824
860
  return config;
825
861
  }
862
+ /**
863
+ * Task 34: the `STICKY_UNREACH` messages in the list, without HmIP's.
864
+ *
865
+ * D-42 says nothing is acknowledged on HmIP: hmipserver has no such flag on real hardware, and
866
+ * where one appears anyway (a simulator, a future firmware) this is not the switch that should
867
+ * start writing to those devices.
868
+ */
869
+ #stickyUnreachMessages() {
870
+ const states = this.#manager?.states() ?? [];
871
+ return this.#caches.listServiceMessages().filter((message) => {
872
+ if (message.datapoint !== 'STICKY_UNREACH' || message.value === false) {
873
+ return false;
874
+ }
875
+ const type = states.find((state) => state.name === message.interfaceName)?.type ?? '';
876
+ return !isHmipInterface(message.interfaceName, type);
877
+ });
878
+ }
879
+ /**
880
+ * Task 34 (#147, D-42): acknowledges the messages that were in the list when the option was
881
+ * switched on, one after another, each with exactly the write of the acknowledge button
882
+ * (`#acknowledge`, the paced `setValue`). A message that is gone by now - the device came back
883
+ * and somebody acknowledged it, or the edge above got there first - is skipped rather than
884
+ * written twice. A failure is a notice and the write log's red line, as for the edge.
885
+ */
886
+ async #acknowledgeExisting(messages) {
887
+ for (const message of messages) {
888
+ const listed = this.#caches
889
+ .listServiceMessages(message.interfaceName)
890
+ .some((entry) => entry.address === message.address && entry.datapoint === message.datapoint);
891
+ if (!listed || this.#stopped) {
892
+ continue;
893
+ }
894
+ try {
895
+ await this.#acknowledge(message.interfaceName, message.address, message.datapoint);
896
+ }
897
+ catch (error) {
898
+ this.#notice('info', `${message.address}: STICKY_UNREACH could not be acknowledged: ${errorMessage(error)}`, message.interfaceName);
899
+ }
900
+ }
901
+ }
826
902
  async #discover() {
827
903
  const discover = this.#options.discover ?? ((options) => discoverCcus(options));
828
904
  const found = await discover({ tls: this.#config.connection.tls });
829
905
  this.#config.setDiscovered(found);
830
- this.events.emit('config.changed', this.#config.config);
906
+ this.events.emit('config.changed', this.#withHostFacts(this.#config.config));
831
907
  return found;
832
908
  }
833
909
  async #clearCaches() {
@@ -879,7 +955,14 @@ export class Backend {
879
955
  return null;
880
956
  }
881
957
  async #setInstallMode(interfaceName, on, options) {
882
- for (const call of installModeCalls(on, options ?? {})) {
958
+ const effective = { ...options };
959
+ if (interfaceName === 'HmIP-RF') {
960
+ // Task 28, measured in the lab: hmipserver's third `setInstallMode` parameter is a String,
961
+ // and `setInstallMode(true, 30, 1)` got an empty HTTP reply and no install mode at all.
962
+ // BidCos's mode integer never reaches HmIP, whoever asks for it.
963
+ delete effective.mode;
964
+ }
965
+ for (const call of installModeCalls(on, effective)) {
883
966
  await this.#write(interfaceName, call.method, call.params);
884
967
  }
885
968
  return null;
@@ -1167,11 +1250,27 @@ export class Backend {
1167
1250
  this.events.emit('serviceMessages.changed', this.#caches.listServiceMessages());
1168
1251
  }
1169
1252
  }
1170
- /** Files the RSSI and the service messages of a `getParamset(<device>:0, VALUES)` answer. */
1253
+ /**
1254
+ * Files the RSSI, the service messages and the unreach state of a `getParamset(<device>:0,
1255
+ * VALUES)` answer.
1256
+ *
1257
+ * Task 34 (B-9): the unreach state goes through `#noteUnreach` like the BidCos poll's, so the
1258
+ * Funk tab counts the outages of HmIP devices that no event reported. `UNREACH` is what says
1259
+ * whether the device is away now; `STICKY_UNREACH` is only read where there is no `UNREACH`,
1260
+ * because a standing sticky flag next to `UNREACH: false` would count the same outage again on
1261
+ * every sweep. Nothing is acknowledged from here: a sweep is a read, and HmIP has nothing to
1262
+ * acknowledge (D-42).
1263
+ */
1171
1264
  #applyHmipMaintenance(interfaceName, address, values) {
1172
1265
  const device = address.split(':')[0] ?? address;
1173
1266
  this.#caches.rssi(interfaceName).applyHmipParamset(device, values);
1174
- return this.#caches.serviceMessages.applyParamset(interfaceName, address, values);
1267
+ const changed = this.#caches.serviceMessages.applyParamset(interfaceName, address, values);
1268
+ const datapoint = 'UNREACH' in values ? 'UNREACH' : 'STICKY_UNREACH';
1269
+ const value = values[datapoint];
1270
+ if (value !== undefined) {
1271
+ this.#noteUnreach(interfaceName, address, datapoint, value, { acknowledge: false });
1272
+ }
1273
+ return changed;
1175
1274
  }
1176
1275
  /**
1177
1276
  * Acknowledging a service message writes its datapoint. An `ACTION` is confirmed with `true`,
@@ -1308,17 +1407,30 @@ function asLinks(value) {
1308
1407
  const links = [];
1309
1408
  for (const entry of value) {
1310
1409
  if (isStruct(entry) && typeof entry['SENDER'] === 'string' && typeof entry['RECEIVER'] === 'string') {
1311
- links.push(entry);
1410
+ links.push(withLinkText(entry));
1312
1411
  }
1313
1412
  }
1314
1413
  return links;
1315
1414
  }
1316
1415
  function asLink(value, sender, receiver) {
1317
1416
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1318
- return { SENDER: sender, RECEIVER: receiver, ...value };
1417
+ return withLinkText({ SENDER: sender, RECEIVER: receiver, ...value });
1319
1418
  }
1320
1419
  return { SENDER: sender, RECEIVER: receiver };
1321
1420
  }
1421
+ /**
1422
+ * B-23 (#156): a link's `NAME` and `DESCRIPTION` are UTF-8 in rfd - the WebUI's default description
1423
+ * "Standardverknüpfung" and our own `setLinkInfo` alike - and the XML-RPC client reads them as
1424
+ * ISO-8859-1. Repaired here, once, for both reads; a BIN-RPC answer (its library decodes UTF-8) and
1425
+ * a real ISO-8859-1 text pass through unchanged.
1426
+ */
1427
+ function withLinkText(link) {
1428
+ return {
1429
+ ...link,
1430
+ ...(typeof link.NAME === 'string' ? { NAME: repairMisdecodedUtf8(link.NAME) } : {}),
1431
+ ...(typeof link.DESCRIPTION === 'string' ? { DESCRIPTION: repairMisdecodedUtf8(link.DESCRIPTION) } : {}),
1432
+ };
1433
+ }
1322
1434
  function asBidcosInterfaces(value) {
1323
1435
  if (!Array.isArray(value)) {
1324
1436
  return [];
@@ -68,6 +68,15 @@ export function installModeCalls(on, options = {}) {
68
68
  }
69
69
  const seconds = installSeconds(options.seconds);
70
70
  const calls = [];
71
+ if (options.hmipKeyMode === 'ANY') {
72
+ // Task 28: any HmIP device, no SGTIN. hmipserver's third parameter is a String - the address
73
+ // to admit - so BidCos's `mode` integer there is a type fault, which is why "setInstallMode
74
+ // does not work on HmIP" gets reported. Two arguments arm the interface for whatever asks to
75
+ // join next, as the CCU WebUI's key-server branch sends it (setinstallmodehmip.tcl); an SGTIN
76
+ // still in the dialog's field or a BidCos mode must not turn into a third one.
77
+ calls.push({ method: 'setInstallMode', params: [true, seconds] });
78
+ return calls;
79
+ }
71
80
  if (options.tempKey !== undefined && options.tempKey !== '') {
72
81
  // issue #20: the temporary key has to be set before the install mode opens
73
82
  calls.push({ method: 'setTempKey', params: [options.tempKey] });
@@ -73,8 +73,16 @@ export interface InterfaceManagerOptions {
73
73
  * interface that fails once and then works.
74
74
  */
75
75
  readonly initBackoffMs?: number;
76
- /** Address to bind the callback servers to; `0.0.0.0` unless the addon says loopback. */
76
+ /** Address to bind the callback servers to; left out, {@link callbackBindHost} decides. */
77
77
  readonly callbackHost?: string;
78
+ /**
79
+ * Task 35 (D-43): the callback ports taken while the connection's are `0`. The CCU addon sets a
80
+ * fixed pair; the desktop app, npm and Docker leave this out and keep the kernel's free port.
81
+ */
82
+ readonly defaultCallbackPorts?: {
83
+ readonly xmlrpc: number;
84
+ readonly binrpc: number;
85
+ };
78
86
  /** Injected by the tests. */
79
87
  readonly createClient?: (options: RpcClientOptions) => RpcClient;
80
88
  readonly createCallbackServers?: (handler: CallbackHandler) => CallbackServerSet;
@@ -95,6 +103,17 @@ export interface InterfaceManagerOptions {
95
103
  * takes the whole connection down on a CCU whose HmIP access point is not paired yet.
96
104
  */
97
105
  export declare function firstBidcosInterfaceAddress(result: unknown): string | undefined;
106
+ /**
107
+ * Where the callback servers listen when the host names no address of its own (task 35).
108
+ *
109
+ * Where the interface processes are told to call back on the loopback - the CCU itself (`local`)
110
+ * with no address configured (#144), or the loopback configured by hand - nothing has to reach the
111
+ * servers from anywhere else, and they listen on `127.0.0.1` only. It matters more with the addon's
112
+ * fixed ports: a listener on every interface would be a known port on the LAN of every CCU whose
113
+ * firewall is open, and anything on that LAN could send it events. Everywhere else the CCU is
114
+ * across a network and `undefined` keeps `0.0.0.0`.
115
+ */
116
+ export declare function callbackBindHost(connection: ConnectionConfig): string | undefined;
98
117
  /** Connects, watches and disconnects every configured interface. */
99
118
  export declare class InterfaceManager {
100
119
  #private;
@@ -62,6 +62,20 @@ export function firstBidcosInterfaceAddress(result) {
62
62
  const address = first['ADDRESS'];
63
63
  return typeof address === 'string' && address !== '' ? address : undefined;
64
64
  }
65
+ /**
66
+ * Where the callback servers listen when the host names no address of its own (task 35).
67
+ *
68
+ * Where the interface processes are told to call back on the loopback - the CCU itself (`local`)
69
+ * with no address configured (#144), or the loopback configured by hand - nothing has to reach the
70
+ * servers from anywhere else, and they listen on `127.0.0.1` only. It matters more with the addon's
71
+ * fixed ports: a listener on every interface would be a known port on the LAN of every CCU whose
72
+ * firewall is open, and anything on that LAN could send it events. Everywhere else the CCU is
73
+ * across a network and `undefined` keeps `0.0.0.0`.
74
+ */
75
+ export function callbackBindHost(connection) {
76
+ const { ip } = connection.callback;
77
+ return ip === LOOPBACK_IP || (ip === '' && connection.local === true) ? LOOPBACK_IP : undefined;
78
+ }
65
79
  /** Connects, watches and disconnects every configured interface. */
66
80
  export class InterfaceManager {
67
81
  #options;
@@ -79,13 +93,22 @@ export class InterfaceManager {
79
93
  }
80
94
  #defaultServers(handler) {
81
95
  const callback = this.#options.connection.callback;
96
+ const host = this.#options.callbackHost ?? callbackBindHost(this.#options.connection);
82
97
  return new CallbackServers({
83
98
  handler,
84
- ...(this.#options.callbackHost === undefined ? {} : { host: this.#options.callbackHost }),
99
+ ...(host === undefined ? {} : { host }),
85
100
  ports: { xmlrpc: callback.xmlrpcPort, binrpc: callback.binrpcPort },
101
+ ...(this.#options.defaultCallbackPorts === undefined
102
+ ? {}
103
+ : { defaultPorts: this.#options.defaultCallbackPorts }),
86
104
  onError: (error) => {
87
105
  this.#options.onNotice('error', `callback server: ${errorMessage(error)}`);
88
106
  },
107
+ // a warning, not an error: the subscription works on the free port, only the fixed URL
108
+ // that keeps the handler lists short is gone until the next start
109
+ onFallback: (protocol, port, error) => {
110
+ this.#options.onNotice('warn', `callback server: the default ${protocol} port ${String(port)} is taken, a free port is used until the next start (${errorMessage(error)})`);
111
+ },
89
112
  });
90
113
  }
91
114
  /**
@@ -88,11 +88,23 @@ export declare class CallbackServers implements CallbackServerSet {
88
88
  constructor(options: {
89
89
  handler: CallbackHandler;
90
90
  host?: string;
91
+ /** The configured ports; `0` means none is configured. */
91
92
  ports: {
92
93
  xmlrpc: number;
93
94
  binrpc: number;
94
95
  };
96
+ /**
97
+ * Task 35 (D-43): the port a protocol takes while its configured port is `0` - the CCU
98
+ * addon's fixed pair, so that a restart registers the very URL it registered before.
99
+ * Unlike a configured port, a default that is taken is no error: the server falls back to
100
+ * a free port for this start, and `onFallback` hears about it once.
101
+ */
102
+ defaultPorts?: {
103
+ xmlrpc: number;
104
+ binrpc: number;
105
+ };
95
106
  onError?: (error: unknown) => void;
107
+ onFallback?: (protocol: RpcProtocol, port: number, error: unknown) => void;
96
108
  });
97
109
  /** Starts the server for a protocol if it is not running yet, and returns its port. */
98
110
  ensure(protocol: RpcProtocol): Promise<number>;
@@ -199,11 +199,18 @@ export class CallbackServer {
199
199
  return new Promise((resolve, reject) => {
200
200
  const server = xmlrpc.createServer({ host: this.host, port: this.#requestedPort });
201
201
  this.#xmlrpcServer = server;
202
+ let listening = false;
202
203
  server.on('error', (error) => {
203
- reject(connectionError(`xmlrpc callback server on port ${String(this.#requestedPort)}: ${error.message}`, error));
204
+ // a bind that fails is the rejection's to report, once; `onError` is for a server
205
+ // that is running
206
+ if (!listening) {
207
+ reject(connectionError(`xmlrpc callback server on port ${String(this.#requestedPort)}: ${error.message}`, error));
208
+ return;
209
+ }
204
210
  this.#onError(error);
205
211
  });
206
212
  server.on('listening', () => {
213
+ listening = true;
207
214
  const address = server.httpServer.address();
208
215
  resolve(typeof address === 'object' && address !== null ? address.port : this.#requestedPort);
209
216
  });
@@ -221,11 +228,16 @@ export class CallbackServer {
221
228
  return new Promise((resolve, reject) => {
222
229
  const server = binrpc.createServer({ host: this.host, port: this.#requestedPort });
223
230
  this.#binrpcServer = server;
231
+ let listening = false;
224
232
  server.on('error', (error) => {
225
- reject(connectionError(`binrpc callback server on port ${String(this.#requestedPort)}: ${error.message}`, error));
233
+ if (!listening) {
234
+ reject(connectionError(`binrpc callback server on port ${String(this.#requestedPort)}: ${error.message}`, error));
235
+ return;
236
+ }
226
237
  this.#onError(error);
227
238
  });
228
239
  server.on('listening', () => {
240
+ listening = true;
229
241
  const address = server.server.address();
230
242
  resolve(typeof address === 'object' && address !== null ? address.port : this.#requestedPort);
231
243
  });
@@ -284,12 +296,16 @@ export class CallbackServers {
284
296
  #handler;
285
297
  #host;
286
298
  #ports;
299
+ #defaultPorts;
287
300
  #onError;
301
+ #onFallback;
288
302
  constructor(options) {
289
303
  this.#handler = options.handler;
290
304
  this.#host = options.host ?? '0.0.0.0';
291
305
  this.#ports = options.ports;
306
+ this.#defaultPorts = options.defaultPorts ?? { xmlrpc: 0, binrpc: 0 };
292
307
  this.#onError = options.onError ?? (() => undefined);
308
+ this.#onFallback = options.onFallback ?? (() => undefined);
293
309
  }
294
310
  /** Starts the server for a protocol if it is not running yet, and returns its port. */
295
311
  async ensure(protocol) {
@@ -297,10 +313,24 @@ export class CallbackServers {
297
313
  if (running) {
298
314
  return running.port;
299
315
  }
316
+ const configured = protocol === 'binrpc' ? this.#ports.binrpc : this.#ports.xmlrpc;
317
+ const preferred = protocol === 'binrpc' ? this.#defaultPorts.binrpc : this.#defaultPorts.xmlrpc;
318
+ if (configured !== 0 || preferred === 0) {
319
+ return this.#start(protocol, configured);
320
+ }
321
+ try {
322
+ return await this.#start(protocol, preferred);
323
+ }
324
+ catch (error) {
325
+ this.#onFallback(protocol, preferred, error);
326
+ return this.#start(protocol, 0);
327
+ }
328
+ }
329
+ async #start(protocol, port) {
300
330
  const server = new CallbackServer({
301
331
  protocol,
302
332
  host: this.#host,
303
- port: protocol === 'binrpc' ? this.#ports.binrpc : this.#ports.xmlrpc,
333
+ port,
304
334
  handler: this.#handler,
305
335
  onError: this.#onError,
306
336
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homematic-manager/backend",
3
- "version": "3.0.0-beta.12",
3
+ "version": "3.0.0-beta.13",
4
4
  "type": "module",
5
5
  "description": "Homematic Manager backend: XML-RPC/BIN-RPC clients and callback servers, caches, optional ReGa",
6
6
  "license": "AGPL-3.0-or-later",