homematic-manager 3.0.0-beta.13 → 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.
package/dist/cli.js CHANGED
@@ -169,6 +169,7 @@ 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 } : {}),
172
173
  ...(values.callbackXmlrpcDefaultPort === undefined
173
174
  ? {}
174
175
  : { callbackXmlrpcDefaultPort: values.callbackXmlrpcDefaultPort }),
package/dist/options.d.ts CHANGED
@@ -154,6 +154,11 @@ export declare const OPTIONS: {
154
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
155
  readonly defaultDescription: "none, a free port";
156
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
+ };
157
162
  readonly 'idle-unsubscribe': {
158
163
  readonly type: "string";
159
164
  readonly describe: "drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)";
@@ -234,6 +239,8 @@ export interface WebOptions {
234
239
  /** Task 35 (D-43): the CCU addon's fixed callback ports, taken while the configured ones are `0`. */
235
240
  readonly callbackXmlrpcDefaultPort: number | undefined;
236
241
  readonly callbackBinrpcDefaultPort: number | undefined;
242
+ /** Task 38: set by the image's `HMM_IN_CONTAINER`. */
243
+ readonly inContainer: boolean;
237
244
  readonly demo: boolean;
238
245
  /** D-31, in milliseconds; `0` disables the idle unsubscribe. */
239
246
  readonly idleUnsubscribeMs: number;
package/dist/options.js CHANGED
@@ -154,6 +154,11 @@ export const OPTIONS = {
154
154
  describe: 'binrpc callback port while the configured one is 0; a free port when it is taken (the CCU addon sets 2032)',
155
155
  defaultDescription: 'none, a free port',
156
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
+ },
157
162
  'idle-unsubscribe': {
158
163
  type: 'string',
159
164
  describe: 'drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)',
@@ -338,6 +343,7 @@ export function parseOptions(argv, env = process.env) {
338
343
  callbackBinrpcPort: number('callback-binrpc-port'),
339
344
  callbackXmlrpcDefaultPort,
340
345
  callbackBinrpcDefaultPort,
346
+ inContainer: boolean('in-container') ?? false,
341
347
  demo: boolean('demo'),
342
348
  idleUnsubscribeMs: parseDuration(string('idle-unsubscribe'), '--idle-unsubscribe'),
343
349
  logLevel: isLogLevel(logLevel) ? logLevel : 'info',
package/dist/server.d.ts CHANGED
@@ -110,14 +110,23 @@ 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;
121
130
  /**
122
131
  * Task 35 (D-43): the callback ports taken while the configuration says `0` - the CCU addon's
123
132
  * fixed pair. Handed to the backend and never written to `config.json`, so a port the user set
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';
@@ -104,6 +104,16 @@ export async function createWebHost(options = {}) {
104
104
  if (defaultCallbackPorts !== undefined) {
105
105
  log.info(`callback: default ports xmlrpc=${String(defaultCallbackPorts.xmlrpc)} binrpc=${String(defaultCallbackPorts.binrpc)} while the configuration says 0`);
106
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
+ }
107
117
  backend = await Backend.open({
108
118
  dataDir,
109
119
  version: options.version ?? packageVersion(),
@@ -113,6 +123,8 @@ export async function createWebHost(options = {}) {
113
123
  // window can never be idled out however the backend is configured.
114
124
  ...(options.idleUnsubscribeMs === undefined ? {} : { idleUnsubscribeMs: options.idleUnsubscribeMs }),
115
125
  ...(defaultCallbackPorts === undefined ? {} : { defaultCallbackPorts }),
126
+ ...(pinnedCallback === undefined ? {} : { pinnedCallback }),
127
+ ...(options.inContainer === true ? { inContainer: true } : {}),
116
128
  ...options.backendOptions,
117
129
  });
118
130
  backend.on('notice', (notice) => {
@@ -556,17 +568,30 @@ function callbackDefaults(options) {
556
568
  const binrpc = options.callbackBinrpcDefaultPort ?? 0;
557
569
  return xmlrpc === 0 && binrpc === 0 ? undefined : { xmlrpc, binrpc };
558
570
  }
559
- /** `--ccu`, `--local` and the callback options win over what `config.json` holds. */
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
+ */
560
591
  async function applyConnectionOptions(backend, options, log) {
561
592
  const wantsHost = options.ccu !== undefined && options.ccu !== '';
562
593
  const wantsLocal = options.local !== undefined;
563
- const callback = {
564
- ...(options.callbackIp === undefined ? {} : { ip: options.callbackIp }),
565
- ...(options.callbackXmlrpcPort === undefined ? {} : { xmlrpcPort: options.callbackXmlrpcPort }),
566
- ...(options.callbackBinrpcPort === undefined ? {} : { binrpcPort: options.callbackBinrpcPort }),
567
- };
568
- const wantsCallback = Object.keys(callback).length > 0;
569
- if (!wantsHost && !wantsLocal && !wantsCallback) {
594
+ if (!wantsHost && !wantsLocal) {
570
595
  return;
571
596
  }
572
597
  const config = await backend.request('config.get');
@@ -574,22 +599,11 @@ async function applyConnectionOptions(backend, options, log) {
574
599
  ...config.connection,
575
600
  ...(wantsHost ? { host: options.ccu } : {}),
576
601
  ...(wantsLocal ? { local: options.local } : {}),
577
- callback: { ...config.connection.callback, ...callback },
578
602
  };
579
- if (connection.host === config.connection.host &&
580
- connection.local === config.connection.local &&
581
- connection.callback.ip === config.connection.callback.ip &&
582
- connection.callback.xmlrpcPort === config.connection.callback.xmlrpcPort &&
583
- connection.callback.binrpcPort === config.connection.callback.binrpcPort) {
603
+ if (connection.host === config.connection.host && connection.local === config.connection.local) {
584
604
  return;
585
605
  }
586
- if (wantsHost || wantsLocal) {
587
- log.info(`connection: host=${connection.host}${connection.local === true ? ' (local)' : ''}`);
588
- }
589
- if (wantsCallback) {
590
- const { ip, xmlrpcPort, binrpcPort } = connection.callback;
591
- log.info(`callback: ${ip === '' ? 'auto' : ip} xmlrpc=${String(xmlrpcPort)} binrpc=${String(binrpcPort)}`);
592
- }
606
+ log.info(`connection: host=${connection.host}${connection.local === true ? ' (local)' : ''}`);
593
607
  await backend.request('config.set', connection);
594
608
  }
595
609
  async function withTimeout(work, ms, onTimeout) {
@@ -39,6 +39,12 @@ export interface BackendOptions extends Omit<ConfigStoreOptions, 'version'> {
39
39
  readonly xmlrpc: number;
40
40
  readonly binrpc: number;
41
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;
42
48
  readonly rpcTimeoutMs?: number;
43
49
  readonly watchdogIntervalMs?: number;
44
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, repairMisdecodedUtf8, } 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';
@@ -44,6 +44,12 @@ export const HMIP_SWEEP_DELAY_MS = 1000;
44
44
  function isHmipInterface(name, type) {
45
45
  return /hmip/i.test(name) || /hmip/i.test(type);
46
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
+ };
47
53
  /** Implements the whole contract. `open()` loads, `start()` connects, `stop()` disconnects. */
48
54
  export class Backend {
49
55
  events;
@@ -87,6 +93,8 @@ export class Backend {
87
93
  */
88
94
  #noServiceMessages = new Set();
89
95
  #idleTimer;
96
+ /** Task 38: the saved callback values a pinned one replaces are logged by the first `start()` only. */
97
+ #pinsReported = false;
90
98
  constructor(options, config, caches) {
91
99
  this.#options = options;
92
100
  this.#config = config;
@@ -158,6 +166,14 @@ export class Backend {
158
166
  if (this.#config.importedFromLegacy) {
159
167
  this.#notice('info', 'the configuration of Homematic Manager 2.x was imported');
160
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
+ }
161
177
  const problems = validateConnection(this.#config.connection);
162
178
  if (problems.length > 0) {
163
179
  this.#notice('info', `not connecting: ${problems.join(', ')}`);
@@ -483,6 +499,7 @@ export class Backend {
483
499
  ...(this.#options.defaultCallbackPorts === undefined
484
500
  ? {}
485
501
  : { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
502
+ ...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
486
503
  ...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
487
504
  ...(this.#options.watchdogIntervalMs === undefined
488
505
  ? {}
@@ -821,9 +838,16 @@ export class Backend {
821
838
  */
822
839
  #withHostFacts(config) {
823
840
  const ports = this.#options.defaultCallbackPorts;
824
- return ports === undefined
825
- ? config
826
- : { ...config, callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } };
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
+ };
827
851
  }
828
852
  async #setConfig(connection, options) {
829
853
  const previousHost = this.#config.connection.host;
@@ -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
@@ -30,13 +30,23 @@ export function hostKey(host) {
30
30
  .replace(/[^a-z0-9._-]/g, '_');
31
31
  return key === '' ? 'unconfigured' : key;
32
32
  }
33
+ const CALLBACK_FIELDS = ['ip', 'xmlrpcPort', 'binrpcPort'];
34
+ function pinnedValue(pinned, field) {
35
+ const value = pinned[field];
36
+ return value === undefined || value === '' || value === 0 ? undefined : value;
37
+ }
33
38
  /** The persisted `AppConfig` plus the derived fields the UI wants with it. */
34
39
  export class ConfigStore {
35
40
  dataDir;
36
41
  file;
37
42
  #version;
38
43
  #localAddresses;
44
+ #pinned;
45
+ #ignored = [];
46
+ /** The effective connection: the saved one with the pinned callback fields over it. */
39
47
  #connection;
48
+ /** Task 38: the callback as the user saved it, which is what `config.json` keeps. */
49
+ #savedCallback;
40
50
  #discovered = [];
41
51
  /** True when the connection came from the 2.x configuration on this start (D-17). */
42
52
  #importedFromLegacy = false;
@@ -45,8 +55,42 @@ export class ConfigStore {
45
55
  this.file = path.join(options.dataDir, 'config.json');
46
56
  this.#version = options.version;
47
57
  this.#localAddresses = options.localAddresses ?? (() => localIPv4Addresses());
48
- this.#connection = connection;
58
+ this.#pinned = options.pinnedCallback ?? {};
59
+ this.#savedCallback = connection.callback;
60
+ this.#connection = this.#withPins(connection);
49
61
  this.#importedFromLegacy = imported;
62
+ for (const field of CALLBACK_FIELDS) {
63
+ const pinned = pinnedValue(this.#pinned, field);
64
+ const saved = connection.callback[field];
65
+ // an empty address or a 0 saved nothing, so nothing is being ignored
66
+ if (pinned !== undefined && saved !== '' && saved !== 0 && saved !== pinned) {
67
+ this.#ignored.push({ field, saved, pinned });
68
+ }
69
+ }
70
+ }
71
+ #withPins(connection) {
72
+ const callback = { ...connection.callback };
73
+ for (const field of CALLBACK_FIELDS) {
74
+ const pinned = pinnedValue(this.#pinned, field);
75
+ if (pinned !== undefined) {
76
+ callback[field] = pinned;
77
+ }
78
+ }
79
+ return { ...connection, callback };
80
+ }
81
+ /** Task 38: which callback fields the host pinned; `undefined` when it pinned none. */
82
+ get callbackPins() {
83
+ const pins = {};
84
+ for (const field of CALLBACK_FIELDS) {
85
+ if (pinnedValue(this.#pinned, field) !== undefined) {
86
+ pins[field] = true;
87
+ }
88
+ }
89
+ return Object.keys(pins).length === 0 ? undefined : pins;
90
+ }
91
+ /** Task 38: the saved callback values a pinned one replaced when the store was opened. */
92
+ get ignoredCallback() {
93
+ return this.#ignored;
50
94
  }
51
95
  /** Loads the configuration, importing the 2.x one when this is the first start (D-17). */
52
96
  static async open(options) {
@@ -80,7 +124,16 @@ export class ConfigStore {
80
124
  }
81
125
  /** Replaces the connection and persists it. */
82
126
  async setConnection(connection) {
83
- this.#connection = normaliseConnection(connection);
127
+ const normalised = normaliseConnection(connection);
128
+ // Task 38: a pinned field keeps what the file had; whatever came in for it is not saved
129
+ const savedCallback = { ...normalised.callback };
130
+ for (const field of CALLBACK_FIELDS) {
131
+ if (pinnedValue(this.#pinned, field) !== undefined) {
132
+ savedCallback[field] = this.#savedCallback[field];
133
+ }
134
+ }
135
+ this.#savedCallback = savedCallback;
136
+ this.#connection = this.#withPins(normalised);
84
137
  this.#importedFromLegacy = false;
85
138
  await this.save();
86
139
  return this.config;
@@ -98,7 +151,10 @@ export class ConfigStore {
98
151
  return path.join(this.cacheDir, name);
99
152
  }
100
153
  async save() {
101
- await writeJsonFile(this.file, { version: this.#version, connection: this.#connection });
154
+ await writeJsonFile(this.file, {
155
+ version: this.#version,
156
+ connection: { ...this.#connection, callback: this.#savedCallback },
157
+ });
102
158
  }
103
159
  }
104
160
  //# sourceMappingURL=store.js.map
@@ -73,4 +73,10 @@ export declare function isMethodUnsupported(value: unknown): boolean;
73
73
  * the message text as well, because `connectionError()` composes its text before the cause is set.
74
74
  */
75
75
  export declare function isConnectionRefused(value: unknown): boolean;
76
+ /**
77
+ * Task 38: a callback server's bind failed because another process holds the port - hm2mqtt.js on
78
+ * the same pair, or a second container on the host network. Looked for along the `cause` chain and
79
+ * in the text, for the same reason as {@link isConnectionRefused}.
80
+ */
81
+ export declare function isAddressInUse(value: unknown): boolean;
76
82
  //# sourceMappingURL=errors.d.ts.map
@@ -182,4 +182,20 @@ export function isConnectionRefused(value) {
182
182
  }
183
183
  return /\bECONNREFUSED\b/.test(errorMessage(value));
184
184
  }
185
+ /**
186
+ * Task 38: a callback server's bind failed because another process holds the port - hm2mqtt.js on
187
+ * the same pair, or a second container on the host network. Looked for along the `cause` chain and
188
+ * in the text, for the same reason as {@link isConnectionRefused}.
189
+ */
190
+ export function isAddressInUse(value) {
191
+ let current = value;
192
+ for (let depth = 0; current !== undefined && current !== null && depth < 10; depth += 1) {
193
+ const candidate = current;
194
+ if (candidate.code === 'EADDRINUSE') {
195
+ return true;
196
+ }
197
+ current = candidate.cause;
198
+ }
199
+ return /\bEADDRINUSE\b/.test(errorMessage(value));
200
+ }
185
201
  //# sourceMappingURL=errors.js.map
@@ -19,7 +19,7 @@
19
19
  * dialog. 2.x probed six ports with a 5 s timeout each before the window became usable, which is
20
20
  * the root of the "endless loading" issues #121, #126, #128 and #134.
21
21
  */
22
- import type { ConnectionConfig, InterfaceState, ResolvedInterface } from '@homematic-manager/core';
22
+ import type { CallbackPins, ConnectionConfig, InterfaceState, ResolvedInterface } from '@homematic-manager/core';
23
23
  import { type InterfaceTarget } from '../config/defaults.js';
24
24
  import { RpcClient, type RpcCallRecord, type RpcClientOptions } from '../rpc/client.js';
25
25
  import { type CallbackHandler, type CallbackServerSet } from '../rpc/server.js';
@@ -83,6 +83,11 @@ export interface InterfaceManagerOptions {
83
83
  readonly xmlrpc: number;
84
84
  readonly binrpc: number;
85
85
  };
86
+ /**
87
+ * Task 38: the callback fields the host set at start. Only used to name the option in the log
88
+ * line of a fixed port that cannot be opened.
89
+ */
90
+ readonly callbackPins?: CallbackPins;
86
91
  /** Injected by the tests. */
87
92
  readonly createClient?: (options: RpcClientOptions) => RpcClient;
88
93
  readonly createCallbackServers?: (handler: CallbackHandler) => CallbackServerSet;
@@ -19,8 +19,8 @@
19
19
  * dialog. 2.x probed six ports with a 5 s timeout each before the window became usable, which is
20
20
  * the root of the "endless loading" issues #121, #126, #128 and #134.
21
21
  */
22
- import { INTERFACE_NAMES, interfaceDefinition, interfacePort, isKnownInterface } from '@homematic-manager/core';
23
- import { configError, connectionError, errorMessage, isConnectionRefused } from '../errors.js';
22
+ import { INTERFACE_NAMES, callbackPinOption, interfaceDefinition, interfacePort, isKnownInterface, } from '@homematic-manager/core';
23
+ import { configError, connectionError, errorMessage, isAddressInUse, isConnectionRefused } from '../errors.js';
24
24
  import { interfaceTargets } from '../config/defaults.js';
25
25
  import { RpcClient } from '../rpc/client.js';
26
26
  import { CallbackServers } from '../rpc/server.js';
@@ -82,6 +82,13 @@ export class InterfaceManager {
82
82
  #now;
83
83
  #interfaces = new Map();
84
84
  #servers;
85
+ /**
86
+ * Task 38: protocols whose fixed callback port could not be opened. Their interfaces are not
87
+ * subscribed - an `init` with a URL nobody listens on would only look like a working one - and
88
+ * a later `init` attempt tries the port again.
89
+ */
90
+ #callbackFailures = new Map();
91
+ #reopening = new Map();
85
92
  #watchdog;
86
93
  #detected = [];
87
94
  #stopping = false;
@@ -180,7 +187,17 @@ export class InterfaceManager {
180
187
  throw configError('no interface selected');
181
188
  }
182
189
  for (const protocol of new Set(targets.map((target) => target.resolved.protocol))) {
183
- await this.#servers.ensure(protocol);
190
+ try {
191
+ await this.#servers.ensure(protocol);
192
+ }
193
+ catch (error) {
194
+ // a free port (0, or task 35's fallback failing too) that cannot be had is what it was
195
+ const port = this.#fixedCallbackPort(protocol);
196
+ if (port === 0) {
197
+ throw error;
198
+ }
199
+ this.#noteCallbackFailure(protocol, port, error);
200
+ }
184
201
  }
185
202
  for (const target of targets) {
186
203
  this.#interfaces.set(target.resolved.name, this.#create(target));
@@ -344,7 +361,7 @@ export class InterfaceManager {
344
361
  }
345
362
  /** `init(url, '')` with a hard timeout; a CCU that is gone must not hold anything up. */
346
363
  async #deregister(entry) {
347
- if (!entry.target.resolved.init) {
364
+ if (!entry.target.resolved.init || this.#callbackFailures.has(entry.target.resolved.protocol)) {
348
365
  return;
349
366
  }
350
367
  const url = this.#callbackUrl(entry.target.resolved.protocol);
@@ -392,6 +409,62 @@ export class InterfaceManager {
392
409
  #callbackUrl(protocol) {
393
410
  return this.#servers.callbackUrl(protocol, this.callbackIp);
394
411
  }
412
+ /** The port the connection fixes for a protocol; `0` when it leaves the choice to the host. */
413
+ #fixedCallbackPort(protocol) {
414
+ const { callback } = this.#options.connection;
415
+ return protocol === 'binrpc' ? callback.binrpcPort : callback.xmlrpcPort;
416
+ }
417
+ /**
418
+ * Task 38: a fixed callback port that cannot be opened is loud, once, and never replaced by a
419
+ * free port. In a container a free port is exactly the one nobody published, and behind a
420
+ * firewall the one nobody opened: the interfaces would be subscribed and silent.
421
+ */
422
+ #noteCallbackFailure(protocol, port, error) {
423
+ const inUse = isAddressInUse(error);
424
+ const field = protocol === 'binrpc' ? 'binrpcPort' : 'xmlrpcPort';
425
+ const source = this.#options.callbackPins?.[field] === true
426
+ ? callbackPinOption(field)
427
+ : `connection.callback.${field} in the settings`;
428
+ const what = inUse ? 'is in use' : 'cannot be opened';
429
+ this.#callbackFailures.set(protocol, { port, inUse, message: `callback port ${String(port)} ${what}` });
430
+ this.#options.onNotice('error', `callback server: the ${protocol} port ${String(port)} set by ${source} ${what} - the ${protocol} ` +
431
+ `interfaces are not subscribed and get no events, and no free port is taken instead ` +
432
+ `(${errorMessage(error)})`);
433
+ }
434
+ /** Task 38: one more try at a failed fixed port, shared by every interface of the protocol. */
435
+ #reopenCallbackServer(protocol) {
436
+ const pending = this.#reopening.get(protocol);
437
+ if (pending) {
438
+ return pending;
439
+ }
440
+ const attempt = this.#servers
441
+ .ensure(protocol)
442
+ .then((port) => {
443
+ this.#callbackFailures.delete(protocol);
444
+ this.#options.onNotice('info', `callback server: the ${protocol} port ${String(port)} is open now`);
445
+ return true;
446
+ }, () => false)
447
+ .finally(() => {
448
+ this.#reopening.delete(protocol);
449
+ });
450
+ this.#reopening.set(protocol, attempt);
451
+ return attempt;
452
+ }
453
+ /** Task 38: an interface whose callback server is not there; backs off like a failed `init`, silently. */
454
+ #noteNoCallbackServer(entry, failure) {
455
+ entry.failures += 1;
456
+ const base = this.#options.initBackoffMs ?? WATCHDOG_INTERVAL_MS;
457
+ entry.retryAt = this.#now() + Math.min(base * 2 ** (entry.failures - 1), MAX_INIT_BACKOFF_MS);
458
+ this.#update(entry, {
459
+ connected: false,
460
+ error: failure.message,
461
+ absent: false,
462
+ subscribing: false,
463
+ callbackUrl: undefined,
464
+ callbackFailure: { port: failure.port, inUse: failure.inUse },
465
+ });
466
+ this.#options.onStateChanged(this.states());
467
+ }
395
468
  async #init(interfaceName) {
396
469
  const entry = this.#interfaces.get(interfaceName);
397
470
  if (!entry || this.#stopping) {
@@ -404,7 +477,14 @@ export class InterfaceManager {
404
477
  entry.lastEvent = this.#now();
405
478
  return;
406
479
  }
480
+ const failure = this.#callbackFailures.get(resolved.protocol);
481
+ // the first attempt comes straight after the failed bind in `start()`; only a retry binds again
482
+ if (failure !== undefined && (entry.failures === 0 || !(await this.#reopenCallbackServer(resolved.protocol)))) {
483
+ this.#noteNoCallbackServer(entry, this.#callbackFailures.get(resolved.protocol) ?? failure);
484
+ return;
485
+ }
407
486
  const url = this.#callbackUrl(resolved.protocol);
487
+ this.#update(entry, { callbackUrl: url, callbackFailure: undefined });
408
488
  try {
409
489
  await entry.client.call('init', [url, resolved.ident]);
410
490
  entry.lastEvent = this.#now();
@@ -483,6 +563,22 @@ export class InterfaceManager {
483
563
  state.error = changes.error;
484
564
  }
485
565
  }
566
+ if ('callbackUrl' in changes) {
567
+ if (changes.callbackUrl === undefined) {
568
+ delete state.callbackUrl;
569
+ }
570
+ else {
571
+ state.callbackUrl = changes.callbackUrl;
572
+ }
573
+ }
574
+ if ('callbackFailure' in changes) {
575
+ if (changes.callbackFailure === undefined) {
576
+ delete state.callbackFailure;
577
+ }
578
+ else {
579
+ state.callbackFailure = changes.callbackFailure;
580
+ }
581
+ }
486
582
  entry.state = state;
487
583
  }
488
584
  #startWatchdog() {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homematic-manager/backend",
3
- "version": "3.0.0-beta.13",
3
+ "version": "3.0.0-beta.14",
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",