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

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 (38) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.js +7 -0
  3. package/dist/options.d.ts +20 -0
  4. package/dist/options.js +32 -0
  5. package/dist/server.d.ts +18 -2
  6. package/dist/server.js +47 -22
  7. package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +15 -0
  8. package/node_modules/@homematic-manager/backend/dist/api/backend.js +150 -14
  9. package/node_modules/@homematic-manager/backend/dist/config/store.d.ts +28 -1
  10. package/node_modules/@homematic-manager/backend/dist/config/store.js +59 -3
  11. package/node_modules/@homematic-manager/backend/dist/devices/installMode.js +9 -0
  12. package/node_modules/@homematic-manager/backend/dist/errors.d.ts +6 -0
  13. package/node_modules/@homematic-manager/backend/dist/errors.js +16 -0
  14. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +26 -2
  15. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +124 -5
  16. package/node_modules/@homematic-manager/backend/dist/rpc/server.d.ts +12 -0
  17. package/node_modules/@homematic-manager/backend/dist/rpc/server.js +33 -3
  18. package/node_modules/@homematic-manager/backend/package.json +1 -1
  19. package/node_modules/@homematic-manager/core/dist/api/types.d.ts +81 -5
  20. package/node_modules/@homematic-manager/core/dist/api/types.js +15 -0
  21. package/node_modules/@homematic-manager/core/dist/index.d.ts +1 -0
  22. package/node_modules/@homematic-manager/core/dist/index.js +1 -0
  23. package/node_modules/@homematic-manager/core/dist/interfaces/table.d.ts +14 -0
  24. package/node_modules/@homematic-manager/core/dist/interfaces/table.js +12 -0
  25. package/node_modules/@homematic-manager/core/dist/meta/store.d.ts +1 -1
  26. package/node_modules/@homematic-manager/core/dist/meta/types.d.ts +6 -2
  27. package/node_modules/@homematic-manager/core/dist/meta/types.js +6 -4
  28. package/node_modules/@homematic-manager/core/dist/rpc/text.d.ts +22 -0
  29. package/node_modules/@homematic-manager/core/dist/rpc/text.js +46 -0
  30. package/node_modules/@homematic-manager/core/dist/serviceMessages/index.d.ts +20 -0
  31. package/node_modules/@homematic-manager/core/dist/serviceMessages/index.js +27 -0
  32. package/node_modules/@homematic-manager/core/package.json +1 -1
  33. package/package.json +3 -3
  34. package/ui/assets/index-CfMq3RQ-.css +1 -0
  35. package/ui/assets/index-Ykn5vZig.js +14 -0
  36. package/ui/index.html +2 -2
  37. package/ui/assets/index-C-Mop9IQ.css +0 -1
  38. 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,13 @@ 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.inContainer ? { inContainer: true } : {}),
173
+ ...(values.callbackXmlrpcDefaultPort === undefined
174
+ ? {}
175
+ : { callbackXmlrpcDefaultPort: values.callbackXmlrpcDefaultPort }),
176
+ ...(values.callbackBinrpcDefaultPort === undefined
177
+ ? {}
178
+ : { callbackBinrpcDefaultPort: values.callbackBinrpcDefaultPort }),
172
179
  idleUnsubscribeMs: values.idleUnsubscribeMs,
173
180
  });
174
181
  }
package/dist/options.d.ts CHANGED
@@ -144,6 +144,21 @@ 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
+ };
157
+ readonly 'in-container': {
158
+ readonly type: "boolean";
159
+ readonly describe: "we run in a container (the image sets it): the interface popup says to publish the callback ports unchanged";
160
+ readonly default: false;
161
+ };
147
162
  readonly 'idle-unsubscribe': {
148
163
  readonly type: "string";
149
164
  readonly describe: "drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)";
@@ -221,6 +236,11 @@ export interface WebOptions {
221
236
  readonly callbackIp: string | undefined;
222
237
  readonly callbackXmlrpcPort: number | undefined;
223
238
  readonly callbackBinrpcPort: number | undefined;
239
+ /** Task 35 (D-43): the CCU addon's fixed callback ports, taken while the configured ones are `0`. */
240
+ readonly callbackXmlrpcDefaultPort: number | undefined;
241
+ readonly callbackBinrpcDefaultPort: number | undefined;
242
+ /** Task 38: set by the image's `HMM_IN_CONTAINER`. */
243
+ readonly inContainer: boolean;
224
244
  readonly demo: boolean;
225
245
  /** D-31, in milliseconds; `0` disables the idle unsubscribe. */
226
246
  readonly idleUnsubscribeMs: number;
package/dist/options.js CHANGED
@@ -144,6 +144,21 @@ 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
+ },
157
+ 'in-container': {
158
+ type: 'boolean',
159
+ describe: 'we run in a container (the image sets it): the interface popup says to publish the callback ports unchanged',
160
+ default: false,
161
+ },
147
162
  'idle-unsubscribe': {
148
163
  type: 'string',
149
164
  describe: 'drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)',
@@ -292,6 +307,20 @@ export function parseOptions(argv, env = process.env) {
292
307
  const value = raw[name] ?? OPTIONS[name].default;
293
308
  return value === undefined ? undefined : Number(value);
294
309
  };
310
+ const port = (name) => {
311
+ const value = number(name);
312
+ if (value !== undefined && !(Number.isInteger(value) && value >= 0 && value <= 65535)) {
313
+ throw new CliError(`--${name}: "${String(value)}" is not a port`);
314
+ }
315
+ return value;
316
+ };
317
+ const callbackXmlrpcDefaultPort = port('callback-xmlrpc-default-port');
318
+ const callbackBinrpcDefaultPort = port('callback-binrpc-default-port');
319
+ if (callbackXmlrpcDefaultPort !== undefined && callbackXmlrpcDefaultPort !== 0) {
320
+ if (callbackXmlrpcDefaultPort === callbackBinrpcDefaultPort) {
321
+ throw new CliError('--callback-xmlrpc-default-port and --callback-binrpc-default-port must differ');
322
+ }
323
+ }
295
324
  const logLevel = string('log-level');
296
325
  return {
297
326
  port: Number(raw['port'] ?? OPTIONS.port.default),
@@ -312,6 +341,9 @@ export function parseOptions(argv, env = process.env) {
312
341
  callbackIp: string('callback-ip'),
313
342
  callbackXmlrpcPort: number('callback-xmlrpc-port'),
314
343
  callbackBinrpcPort: number('callback-binrpc-port'),
344
+ callbackXmlrpcDefaultPort,
345
+ callbackBinrpcDefaultPort,
346
+ inContainer: boolean('in-container') ?? false,
315
347
  demo: boolean('demo'),
316
348
  idleUnsubscribeMs: parseDuration(string('idle-unsubscribe'), '--idle-unsubscribe'),
317
349
  logLevel: isLogLevel(logLevel) ? logLevel : 'info',
package/dist/server.d.ts CHANGED
@@ -110,14 +110,30 @@ export interface WebHostOptions {
110
110
  /** Written to `ConnectionConfig.local` - the addon's "we run on the CCU" mode. */
111
111
  readonly local?: boolean | undefined;
112
112
  /**
113
- * Written to `ConnectionConfig.callback`. A host that cannot see the address the CCU reaches it
113
+ * Laid over `ConnectionConfig.callback`. A host that cannot see the address the CCU reaches it
114
114
  * on has to be told: a container on a bridge network sees `172.17.0.x`, and an `init` that
115
115
  * announces that address gets no events at all. With `--network host` none of this is needed.
116
+ *
117
+ * Task 38: these three are pinned, not written. They win over `config.json` for as long as the
118
+ * host runs with them, the settings dialog shows them read-only, and the file keeps what the
119
+ * user saved. An empty address and a port of `0` pin nothing.
116
120
  */
117
121
  readonly callbackIp?: string | undefined;
118
- /** Fixed callback ports, so a container can publish them. `0` picks a free one. */
122
+ /** Fixed callback ports, so a container can publish them. `0` pins nothing. */
119
123
  readonly callbackXmlrpcPort?: number | undefined;
120
124
  readonly callbackBinrpcPort?: number | undefined;
125
+ /**
126
+ * Task 38: the host runs in a container (`HMM_IN_CONTAINER`, set by the image). The interface
127
+ * popup then says beside each callback URL that its port must be published unchanged.
128
+ */
129
+ readonly inContainer?: boolean | undefined;
130
+ /**
131
+ * Task 35 (D-43): the callback ports taken while the configuration says `0` - the CCU addon's
132
+ * fixed pair. Handed to the backend and never written to `config.json`, so a port the user set
133
+ * in the settings dialog always wins and no update of the addon can overwrite it.
134
+ */
135
+ readonly callbackXmlrpcDefaultPort?: number | undefined;
136
+ readonly callbackBinrpcDefaultPort?: number | undefined;
121
137
  /** How long `close()` waits for `backend.stop()`. */
122
138
  readonly shutdownTimeoutMs?: number;
123
139
  /** How often an idle api socket is pinged; `0` turns the heartbeat off. */
package/dist/server.js CHANGED
@@ -24,7 +24,7 @@
24
24
  import fs from 'node:fs/promises';
25
25
  import http from 'node:http';
26
26
  import path from 'node:path';
27
- import { ApiWebSocketServer, Backend, RegaAuthenticator } from '@homematic-manager/backend';
27
+ import { ApiWebSocketServer, Backend, RegaAuthenticator, } from '@homematic-manager/backend';
28
28
  import { applyCookieToken, applySessionToken, clearedSessionCookie, createToken, isLoopbackHost, readCookie, SESSION_COOKIE, sessionCookie, TOKEN_COOKIE, tokenCookie, } from './auth.js';
29
29
  import { DeviceImageService, readIconMapFile } from './images.js';
30
30
  import { clientAddress, parseLoginForm, pickLanguage, readBody, renderLoginPage, } from './login.js';
@@ -100,6 +100,20 @@ 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
+ }
107
+ const pinnedCallback = callbackPins(options);
108
+ if (pinnedCallback !== undefined) {
109
+ const { ip, xmlrpcPort, binrpcPort } = pinnedCallback;
110
+ const parts = [
111
+ ...(ip === undefined ? [] : [`ip=${ip}`]),
112
+ ...(xmlrpcPort === undefined ? [] : [`xmlrpc=${String(xmlrpcPort)}`]),
113
+ ...(binrpcPort === undefined ? [] : [`binrpc=${String(binrpcPort)}`]),
114
+ ];
115
+ log.info(`callback: ${parts.join(' ')} set at start, read-only in the settings dialog`);
116
+ }
103
117
  backend = await Backend.open({
104
118
  dataDir,
105
119
  version: options.version ?? packageVersion(),
@@ -108,6 +122,9 @@ export async function createWebHost(options = {}) {
108
122
  // reports them, and Electron's in-process transport reports none - so an Electron
109
123
  // window can never be idled out however the backend is configured.
110
124
  ...(options.idleUnsubscribeMs === undefined ? {} : { idleUnsubscribeMs: options.idleUnsubscribeMs }),
125
+ ...(defaultCallbackPorts === undefined ? {} : { defaultCallbackPorts }),
126
+ ...(pinnedCallback === undefined ? {} : { pinnedCallback }),
127
+ ...(options.inContainer === true ? { inContainer: true } : {}),
111
128
  ...options.backendOptions,
112
129
  });
113
130
  backend.on('notice', (notice) => {
@@ -545,17 +562,36 @@ function upstreamOf(connection) {
545
562
  ...(connection.auth ? { auth: connection.auth } : {}),
546
563
  };
547
564
  }
548
- /** `--ccu`, `--local` and the callback options win over what `config.json` holds. */
565
+ /** Task 35: the default pair, or nothing when neither port is set to anything but `0`. */
566
+ function callbackDefaults(options) {
567
+ const xmlrpc = options.callbackXmlrpcDefaultPort ?? 0;
568
+ const binrpc = options.callbackBinrpcDefaultPort ?? 0;
569
+ return xmlrpc === 0 && binrpc === 0 ? undefined : { xmlrpc, binrpc };
570
+ }
571
+ /**
572
+ * Task 38: the callback fields given at start, or nothing. An empty address and a port of `0` are
573
+ * "not configured" in the connection model and pin nothing.
574
+ */
575
+ function callbackPins(options) {
576
+ const pins = {
577
+ ...(options.callbackIp === undefined || options.callbackIp === '' ? {} : { ip: options.callbackIp }),
578
+ ...(options.callbackXmlrpcPort === undefined || options.callbackXmlrpcPort === 0
579
+ ? {}
580
+ : { xmlrpcPort: options.callbackXmlrpcPort }),
581
+ ...(options.callbackBinrpcPort === undefined || options.callbackBinrpcPort === 0
582
+ ? {}
583
+ : { binrpcPort: options.callbackBinrpcPort }),
584
+ };
585
+ return Object.keys(pins).length === 0 ? undefined : pins;
586
+ }
587
+ /**
588
+ * `--ccu` and `--local` win over what `config.json` holds, and are written to it. The callback
589
+ * options are not: since task 38 the backend lays them over the profile (`pinnedCallback`).
590
+ */
549
591
  async function applyConnectionOptions(backend, options, log) {
550
592
  const wantsHost = options.ccu !== undefined && options.ccu !== '';
551
593
  const wantsLocal = options.local !== undefined;
552
- const callback = {
553
- ...(options.callbackIp === undefined ? {} : { ip: options.callbackIp }),
554
- ...(options.callbackXmlrpcPort === undefined ? {} : { xmlrpcPort: options.callbackXmlrpcPort }),
555
- ...(options.callbackBinrpcPort === undefined ? {} : { binrpcPort: options.callbackBinrpcPort }),
556
- };
557
- const wantsCallback = Object.keys(callback).length > 0;
558
- if (!wantsHost && !wantsLocal && !wantsCallback) {
594
+ if (!wantsHost && !wantsLocal) {
559
595
  return;
560
596
  }
561
597
  const config = await backend.request('config.get');
@@ -563,22 +599,11 @@ async function applyConnectionOptions(backend, options, log) {
563
599
  ...config.connection,
564
600
  ...(wantsHost ? { host: options.ccu } : {}),
565
601
  ...(wantsLocal ? { local: options.local } : {}),
566
- callback: { ...config.connection.callback, ...callback },
567
602
  };
568
- if (connection.host === config.connection.host &&
569
- connection.local === config.connection.local &&
570
- connection.callback.ip === config.connection.callback.ip &&
571
- connection.callback.xmlrpcPort === config.connection.callback.xmlrpcPort &&
572
- connection.callback.binrpcPort === config.connection.callback.binrpcPort) {
603
+ if (connection.host === config.connection.host && connection.local === config.connection.local) {
573
604
  return;
574
605
  }
575
- if (wantsHost || wantsLocal) {
576
- log.info(`connection: host=${connection.host}${connection.local === true ? ' (local)' : ''}`);
577
- }
578
- if (wantsCallback) {
579
- const { ip, xmlrpcPort, binrpcPort } = connection.callback;
580
- log.info(`callback: ${ip === '' ? 'auto' : ip} xmlrpc=${String(xmlrpcPort)} binrpc=${String(binrpcPort)}`);
581
- }
606
+ log.info(`connection: host=${connection.host}${connection.local === true ? ' (local)' : ''}`);
582
607
  await backend.request('config.set', connection);
583
608
  }
584
609
  async function withTimeout(work, ms, onTimeout) {
@@ -30,6 +30,21 @@ 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
+ };
42
+ /**
43
+ * Task 38: the host runs in a container (the image sets `HMM_IN_CONTAINER`). Where its callback
44
+ * servers listen beyond the loopback, `AppConfig.publishCallbackPorts` tells the interface
45
+ * popup to say that the callback ports must be published unchanged.
46
+ */
47
+ readonly inContainer?: boolean;
33
48
  readonly rpcTimeoutMs?: number;
34
49
  readonly watchdogIntervalMs?: number;
35
50
  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, callbackPinOption, 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';
@@ -23,7 +23,7 @@ import { DataFileServer } from '../data/files.js';
23
23
  import { installModeCalls } from '../devices/installMode.js';
24
24
  import { discoverCcus } from '../discovery/discover.js';
25
25
  import { BackendError, configError, connectionError, errorMessage, internalError, isMethodUnsupported, validationError, } from '../errors.js';
26
- import { InterfaceManager, firstBidcosInterfaceAddress } from '../interfaces/manager.js';
26
+ import { InterfaceManager, LOOPBACK_IP, callbackBindHost, firstBidcosInterfaceAddress, } from '../interfaces/manager.js';
27
27
  import { MetaService } from '../meta/service.js';
28
28
  import { RegaService } from '../rega/client.js';
29
29
  import { listDevicesAnswer } from '../rpc/server.js';
@@ -37,6 +37,19 @@ 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
+ }
47
+ /** Task 38: how a log line names a callback field. */
48
+ const CALLBACK_FIELD_WORDS = {
49
+ ip: 'callback address',
50
+ xmlrpcPort: 'XML-RPC callback port',
51
+ binrpcPort: 'BIN-RPC callback port',
52
+ };
40
53
  /** Implements the whole contract. `open()` loads, `start()` connects, `stop()` disconnects. */
41
54
  export class Backend {
42
55
  events;
@@ -80,6 +93,8 @@ export class Backend {
80
93
  */
81
94
  #noServiceMessages = new Set();
82
95
  #idleTimer;
96
+ /** Task 38: the saved callback values a pinned one replaces are logged by the first `start()` only. */
97
+ #pinsReported = false;
83
98
  constructor(options, config, caches) {
84
99
  this.#options = options;
85
100
  this.#config = config;
@@ -151,6 +166,14 @@ export class Backend {
151
166
  if (this.#config.importedFromLegacy) {
152
167
  this.#notice('info', 'the configuration of Homematic Manager 2.x was imported');
153
168
  }
169
+ if (!this.#pinsReported) {
170
+ // Task 38: once per process - the value in config.json stays, it only has no effect
171
+ this.#pinsReported = true;
172
+ for (const { field, saved, pinned } of this.#config.ignoredCallback) {
173
+ this.#notice('info', `callback: the saved ${CALLBACK_FIELD_WORDS[field]} ${String(saved)} is ignored, ` +
174
+ `${callbackPinOption(field)} sets ${String(pinned)}`);
175
+ }
176
+ }
154
177
  const problems = validateConnection(this.#config.connection);
155
178
  if (problems.length > 0) {
156
179
  this.#notice('info', `not connecting: ${problems.join(', ')}`);
@@ -263,7 +286,7 @@ export class Backend {
263
286
  case 'config.get':
264
287
  return this.#configWithDetected();
265
288
  case 'config.set':
266
- return this.#setConfig(p[0]);
289
+ return this.#setConfig(p[0], params[1]);
267
290
  case 'config.discover':
268
291
  return this.#discover();
269
292
  case 'config.clearCaches':
@@ -473,6 +496,10 @@ export class Backend {
473
496
  this.#onCall(record);
474
497
  },
475
498
  ...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
499
+ ...(this.#options.defaultCallbackPorts === undefined
500
+ ? {}
501
+ : { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
502
+ ...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
476
503
  ...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
477
504
  ...(this.#options.watchdogIntervalMs === undefined
478
505
  ? {}
@@ -756,13 +783,16 @@ export class Backend {
756
783
  * a device that is unreachable is not going to take a write either, which is the normal case
757
784
  * here and not worth an error dialog.
758
785
  */
759
- #noteUnreach(interfaceName, address, datapoint, value) {
786
+ #noteUnreach(interfaceName, address, datapoint, value, options = {}) {
760
787
  if (!this.#caches.unreach.note(interfaceName, address, datapoint, value, this.#now())) {
761
788
  return;
762
789
  }
763
790
  this.#caches.saveUnreach();
764
791
  this.events.emit('unreach.changed', this.#caches.unreach.list());
765
- if (datapoint === 'STICKY_UNREACH' && value === true && this.#config.connection.autoAckStickyUnreach === true) {
792
+ if (options.acknowledge !== false &&
793
+ datapoint === 'STICKY_UNREACH' &&
794
+ value === true &&
795
+ this.#config.connection.autoAckStickyUnreach === true) {
766
796
  void this.#acknowledge(interfaceName, address, datapoint).catch((error) => {
767
797
  this.#notice('info', `${address}: STICKY_UNREACH could not be acknowledged automatically: ${errorMessage(error)}`, interfaceName);
768
798
  });
@@ -799,12 +829,35 @@ export class Backend {
799
829
  * configuration
800
830
  */
801
831
  #configWithDetected() {
802
- return this.#config.config;
832
+ return this.#withHostFacts(this.#config.config);
833
+ }
834
+ /**
835
+ * What the host adds to the stored configuration before anybody sees it: task 35's callback
836
+ * ports a `0` stands for. Added to every `AppConfig` that leaves the backend - `config.get`,
837
+ * `config.set` and both `config.changed` events - so the dialog never loses the hint on a save.
838
+ */
839
+ #withHostFacts(config) {
840
+ const ports = this.#options.defaultCallbackPorts;
841
+ const pins = this.#config.callbackPins;
842
+ // Task 38: a container whose callback servers listen on the loopback only has nothing to publish
843
+ const bindHost = this.#options.callbackHost ?? callbackBindHost(config.connection);
844
+ const publish = this.#options.inContainer === true && bindHost !== LOOPBACK_IP;
845
+ return {
846
+ ...config,
847
+ ...(ports === undefined ? {} : { callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } }),
848
+ ...(pins === undefined ? {} : { callbackPinned: { ...pins } }),
849
+ ...(publish ? { publishCallbackPorts: true } : {}),
850
+ };
803
851
  }
804
- async #setConfig(connection) {
852
+ async #setConfig(connection, options) {
805
853
  const previousHost = this.#config.connection.host;
854
+ // Task 34 (#147, D-42): the messages the settings dialog asked about, taken before the
855
+ // reconnect, and only on the save that switches the auto-acknowledge from off to on.
856
+ const existing = options?.acknowledgeExisting === true && this.#config.connection.autoAckStickyUnreach !== true
857
+ ? this.#stickyUnreachMessages()
858
+ : [];
806
859
  await this.#disconnect();
807
- const config = await this.#config.setConnection(connection);
860
+ const config = this.#withHostFacts(await this.#config.setConnection(connection));
808
861
  this.#writeLog.setRpcLogFolder(config.connection.rpcLogFolder);
809
862
  if (config.connection.host !== previousHost) {
810
863
  await this.#caches.flush();
@@ -820,14 +873,61 @@ export class Backend {
820
873
  this.events.emit('config.changed', config);
821
874
  if (!this.#stopped && validateConnection(config.connection).length === 0) {
822
875
  await this.#connect();
876
+ // after the reconnect, because a write needs the interface; not awaited, so the
877
+ // settings dialog closes as quickly as ever and the writes show up in the RPC log
878
+ if (existing.length > 0 &&
879
+ config.connection.autoAckStickyUnreach === true &&
880
+ config.connection.host === previousHost) {
881
+ void this.#acknowledgeExisting(existing);
882
+ }
823
883
  }
824
884
  return config;
825
885
  }
886
+ /**
887
+ * Task 34: the `STICKY_UNREACH` messages in the list, without HmIP's.
888
+ *
889
+ * D-42 says nothing is acknowledged on HmIP: hmipserver has no such flag on real hardware, and
890
+ * where one appears anyway (a simulator, a future firmware) this is not the switch that should
891
+ * start writing to those devices.
892
+ */
893
+ #stickyUnreachMessages() {
894
+ const states = this.#manager?.states() ?? [];
895
+ return this.#caches.listServiceMessages().filter((message) => {
896
+ if (message.datapoint !== 'STICKY_UNREACH' || message.value === false) {
897
+ return false;
898
+ }
899
+ const type = states.find((state) => state.name === message.interfaceName)?.type ?? '';
900
+ return !isHmipInterface(message.interfaceName, type);
901
+ });
902
+ }
903
+ /**
904
+ * Task 34 (#147, D-42): acknowledges the messages that were in the list when the option was
905
+ * switched on, one after another, each with exactly the write of the acknowledge button
906
+ * (`#acknowledge`, the paced `setValue`). A message that is gone by now - the device came back
907
+ * and somebody acknowledged it, or the edge above got there first - is skipped rather than
908
+ * written twice. A failure is a notice and the write log's red line, as for the edge.
909
+ */
910
+ async #acknowledgeExisting(messages) {
911
+ for (const message of messages) {
912
+ const listed = this.#caches
913
+ .listServiceMessages(message.interfaceName)
914
+ .some((entry) => entry.address === message.address && entry.datapoint === message.datapoint);
915
+ if (!listed || this.#stopped) {
916
+ continue;
917
+ }
918
+ try {
919
+ await this.#acknowledge(message.interfaceName, message.address, message.datapoint);
920
+ }
921
+ catch (error) {
922
+ this.#notice('info', `${message.address}: STICKY_UNREACH could not be acknowledged: ${errorMessage(error)}`, message.interfaceName);
923
+ }
924
+ }
925
+ }
826
926
  async #discover() {
827
927
  const discover = this.#options.discover ?? ((options) => discoverCcus(options));
828
928
  const found = await discover({ tls: this.#config.connection.tls });
829
929
  this.#config.setDiscovered(found);
830
- this.events.emit('config.changed', this.#config.config);
930
+ this.events.emit('config.changed', this.#withHostFacts(this.#config.config));
831
931
  return found;
832
932
  }
833
933
  async #clearCaches() {
@@ -879,7 +979,14 @@ export class Backend {
879
979
  return null;
880
980
  }
881
981
  async #setInstallMode(interfaceName, on, options) {
882
- for (const call of installModeCalls(on, options ?? {})) {
982
+ const effective = { ...options };
983
+ if (interfaceName === 'HmIP-RF') {
984
+ // Task 28, measured in the lab: hmipserver's third `setInstallMode` parameter is a String,
985
+ // and `setInstallMode(true, 30, 1)` got an empty HTTP reply and no install mode at all.
986
+ // BidCos's mode integer never reaches HmIP, whoever asks for it.
987
+ delete effective.mode;
988
+ }
989
+ for (const call of installModeCalls(on, effective)) {
883
990
  await this.#write(interfaceName, call.method, call.params);
884
991
  }
885
992
  return null;
@@ -1167,11 +1274,27 @@ export class Backend {
1167
1274
  this.events.emit('serviceMessages.changed', this.#caches.listServiceMessages());
1168
1275
  }
1169
1276
  }
1170
- /** Files the RSSI and the service messages of a `getParamset(<device>:0, VALUES)` answer. */
1277
+ /**
1278
+ * Files the RSSI, the service messages and the unreach state of a `getParamset(<device>:0,
1279
+ * VALUES)` answer.
1280
+ *
1281
+ * Task 34 (B-9): the unreach state goes through `#noteUnreach` like the BidCos poll's, so the
1282
+ * Funk tab counts the outages of HmIP devices that no event reported. `UNREACH` is what says
1283
+ * whether the device is away now; `STICKY_UNREACH` is only read where there is no `UNREACH`,
1284
+ * because a standing sticky flag next to `UNREACH: false` would count the same outage again on
1285
+ * every sweep. Nothing is acknowledged from here: a sweep is a read, and HmIP has nothing to
1286
+ * acknowledge (D-42).
1287
+ */
1171
1288
  #applyHmipMaintenance(interfaceName, address, values) {
1172
1289
  const device = address.split(':')[0] ?? address;
1173
1290
  this.#caches.rssi(interfaceName).applyHmipParamset(device, values);
1174
- return this.#caches.serviceMessages.applyParamset(interfaceName, address, values);
1291
+ const changed = this.#caches.serviceMessages.applyParamset(interfaceName, address, values);
1292
+ const datapoint = 'UNREACH' in values ? 'UNREACH' : 'STICKY_UNREACH';
1293
+ const value = values[datapoint];
1294
+ if (value !== undefined) {
1295
+ this.#noteUnreach(interfaceName, address, datapoint, value, { acknowledge: false });
1296
+ }
1297
+ return changed;
1175
1298
  }
1176
1299
  /**
1177
1300
  * Acknowledging a service message writes its datapoint. An `ACTION` is confirmed with `true`,
@@ -1308,17 +1431,30 @@ function asLinks(value) {
1308
1431
  const links = [];
1309
1432
  for (const entry of value) {
1310
1433
  if (isStruct(entry) && typeof entry['SENDER'] === 'string' && typeof entry['RECEIVER'] === 'string') {
1311
- links.push(entry);
1434
+ links.push(withLinkText(entry));
1312
1435
  }
1313
1436
  }
1314
1437
  return links;
1315
1438
  }
1316
1439
  function asLink(value, sender, receiver) {
1317
1440
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
1318
- return { SENDER: sender, RECEIVER: receiver, ...value };
1441
+ return withLinkText({ SENDER: sender, RECEIVER: receiver, ...value });
1319
1442
  }
1320
1443
  return { SENDER: sender, RECEIVER: receiver };
1321
1444
  }
1445
+ /**
1446
+ * B-23 (#156): a link's `NAME` and `DESCRIPTION` are UTF-8 in rfd - the WebUI's default description
1447
+ * "Standardverknüpfung" and our own `setLinkInfo` alike - and the XML-RPC client reads them as
1448
+ * ISO-8859-1. Repaired here, once, for both reads; a BIN-RPC answer (its library decodes UTF-8) and
1449
+ * a real ISO-8859-1 text pass through unchanged.
1450
+ */
1451
+ function withLinkText(link) {
1452
+ return {
1453
+ ...link,
1454
+ ...(typeof link.NAME === 'string' ? { NAME: repairMisdecodedUtf8(link.NAME) } : {}),
1455
+ ...(typeof link.DESCRIPTION === 'string' ? { DESCRIPTION: repairMisdecodedUtf8(link.DESCRIPTION) } : {}),
1456
+ };
1457
+ }
1322
1458
  function asBidcosInterfaces(value) {
1323
1459
  if (!Array.isArray(value)) {
1324
1460
  return [];
@@ -17,10 +17,26 @@
17
17
  * D-17's one-time import of the 2.x configuration happens exactly when there is no `config.json`
18
18
  * yet, which is the only definition of "first start" that survives a reinstall.
19
19
  */
20
- import type { AppConfig, ConnectionConfig, DiscoveredCcu } from '@homematic-manager/core';
20
+ import type { AppConfig, CallbackPins, ConnectionConfig, DiscoveredCcu } from '@homematic-manager/core';
21
21
  import { type LegacyEnvironment } from './legacyImport.js';
22
22
  /** A host as a directory name: everything unusual becomes `_`. */
23
23
  export declare function hostKey(host: string): string;
24
+ /**
25
+ * Task 38: the callback fields the host was started with (`HMM_CALLBACK_*`, `--callback-*`). An
26
+ * empty address and a port of `0` are "nothing set" in the connection model, so they pin nothing.
27
+ */
28
+ export interface PinnedCallback {
29
+ readonly ip?: string | undefined;
30
+ readonly xmlrpcPort?: number | undefined;
31
+ readonly binrpcPort?: number | undefined;
32
+ }
33
+ type CallbackField = keyof CallbackPins;
34
+ /** A saved callback value the host's own one replaced on this start. */
35
+ export interface IgnoredCallbackValue {
36
+ readonly field: CallbackField;
37
+ readonly saved: string | number;
38
+ readonly pinned: string | number;
39
+ }
24
40
  export interface ConfigStoreOptions {
25
41
  /** The profile directory; created on the first write. */
26
42
  readonly dataDir: string;
@@ -32,6 +48,12 @@ export interface ConfigStoreOptions {
32
48
  readonly importLegacy?: boolean;
33
49
  /** Where the 2.x configuration is looked for; injected by the tests. */
34
50
  readonly legacyEnvironment?: LegacyEnvironment;
51
+ /**
52
+ * Task 38: callback fields set at start. They win over `config.json` in everything the store
53
+ * hands out, a `setConnection` cannot change them, and they are never written to the file - the
54
+ * file keeps what the user saved, so a start without the option goes back to that.
55
+ */
56
+ readonly pinnedCallback?: PinnedCallback;
35
57
  }
36
58
  /** The persisted `AppConfig` plus the derived fields the UI wants with it. */
37
59
  export declare class ConfigStore {
@@ -39,6 +61,10 @@ export declare class ConfigStore {
39
61
  readonly dataDir: string;
40
62
  readonly file: string;
41
63
  private constructor();
64
+ /** Task 38: which callback fields the host pinned; `undefined` when it pinned none. */
65
+ get callbackPins(): CallbackPins | undefined;
66
+ /** Task 38: the saved callback values a pinned one replaced when the store was opened. */
67
+ get ignoredCallback(): readonly IgnoredCallbackValue[];
42
68
  /** Loads the configuration, importing the 2.x one when this is the first start (D-17). */
43
69
  static open(options: ConfigStoreOptions): Promise<ConfigStore>;
44
70
  get importedFromLegacy(): boolean;
@@ -55,4 +81,5 @@ export declare class ConfigStore {
55
81
  cacheFile(name: string): string;
56
82
  save(): Promise<void>;
57
83
  }
84
+ export {};
58
85
  //# sourceMappingURL=store.d.ts.map