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

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, isKnownInterface, 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;
@@ -86,7 +92,23 @@ export class Backend {
86
92
  * table - and it is emptied whenever the connection is rebuilt.
87
93
  */
88
94
  #noServiceMessages = new Set();
95
+ /**
96
+ * B-26 (#158): interfaces whose last `getServiceMessages` failed for a reason that may pass - a
97
+ * timeout, a process that is restarting. The failure is logged when the interface lands in
98
+ * here and not again until a call succeeds; before, the five-minute poll logged it every round.
99
+ */
100
+ #serviceMessageFailures = new Set();
101
+ /**
102
+ * B-26 (#158): the `system.listMethods` answer of a user-defined interface, asked once and
103
+ * dropped when the interface subscribes again - a re-`init` may be a restarted, updated process.
104
+ * `undefined` inside means "no usable list", and the method is then tried once, as before.
105
+ */
106
+ #listedMethods = new Map();
107
+ /** B-26: interfaces that do not have `system.listMethods` itself; kept for the connection. */
108
+ #noListMethods = new Set();
89
109
  #idleTimer;
110
+ /** Task 38: the saved callback values a pinned one replaces are logged by the first `start()` only. */
111
+ #pinsReported = false;
90
112
  constructor(options, config, caches) {
91
113
  this.#options = options;
92
114
  this.#config = config;
@@ -158,6 +180,14 @@ export class Backend {
158
180
  if (this.#config.importedFromLegacy) {
159
181
  this.#notice('info', 'the configuration of Homematic Manager 2.x was imported');
160
182
  }
183
+ if (!this.#pinsReported) {
184
+ // Task 38: once per process - the value in config.json stays, it only has no effect
185
+ this.#pinsReported = true;
186
+ for (const { field, saved, pinned } of this.#config.ignoredCallback) {
187
+ this.#notice('info', `callback: the saved ${CALLBACK_FIELD_WORDS[field]} ${String(saved)} is ignored, ` +
188
+ `${callbackPinOption(field)} sets ${String(pinned)}`);
189
+ }
190
+ }
161
191
  const problems = validateConnection(this.#config.connection);
162
192
  if (problems.length > 0) {
163
193
  this.#notice('info', `not connecting: ${problems.join(', ')}`);
@@ -466,6 +496,9 @@ export class Backend {
466
496
  async #connect() {
467
497
  const connection = this.#config.connection;
468
498
  this.#noServiceMessages.clear();
499
+ this.#serviceMessageFailures.clear();
500
+ this.#listedMethods.clear();
501
+ this.#noListMethods.clear();
469
502
  const manager = (this.#options.createInterfaceManager ?? ((options) => new InterfaceManager(options)))({
470
503
  connection,
471
504
  handler: this.#callbackHandler(),
@@ -483,6 +516,7 @@ export class Backend {
483
516
  ...(this.#options.defaultCallbackPorts === undefined
484
517
  ? {}
485
518
  : { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
519
+ ...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
486
520
  ...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
487
521
  ...(this.#options.watchdogIntervalMs === undefined
488
522
  ? {}
@@ -647,6 +681,8 @@ export class Backend {
647
681
  }
648
682
  /** Fills the caches of an interface that has just subscribed. */
649
683
  async #onInterfaceConnected(interfaceName) {
684
+ // B-26: a (re-)`init` may be a restarted process, so its method list is asked again
685
+ this.#listedMethods.delete(interfaceName);
650
686
  try {
651
687
  await this.#refreshDevices(interfaceName);
652
688
  }
@@ -821,9 +857,16 @@ export class Backend {
821
857
  */
822
858
  #withHostFacts(config) {
823
859
  const ports = this.#options.defaultCallbackPorts;
824
- return ports === undefined
825
- ? config
826
- : { ...config, callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } };
860
+ const pins = this.#config.callbackPins;
861
+ // Task 38: a container whose callback servers listen on the loopback only has nothing to publish
862
+ const bindHost = this.#options.callbackHost ?? callbackBindHost(config.connection);
863
+ const publish = this.#options.inContainer === true && bindHost !== LOOPBACK_IP;
864
+ return {
865
+ ...config,
866
+ ...(ports === undefined ? {} : { callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } }),
867
+ ...(pins === undefined ? {} : { callbackPinned: { ...pins } }),
868
+ ...(publish ? { publishCallbackPorts: true } : {}),
869
+ };
827
870
  }
828
871
  async #setConfig(connection, options) {
829
872
  const previousHost = this.#config.connection.host;
@@ -1133,23 +1176,69 @@ export class Backend {
1133
1176
  * Does it make sense to ask this interface for its service messages?
1134
1177
  *
1135
1178
  * The built-in answer comes from the core's table: hmipserver has no `getServiceMessages` (the
1136
- * HmIP sweep below reads the `:0` channels instead) and the group process behind
1137
- * `VirtualDevices` answers it with invalid XML-RPC. A user-defined interface is asked once and
1138
- * then remembered for this session - see {@link isMethodUnsupported}.
1179
+ * HmIP sweep below reads the `:0` channels instead), the group process behind `VirtualDevices`
1180
+ * answers it with invalid XML-RPC, and CUxD with a fault and a warning in the CCU's syslog
1181
+ * (B-26, #158). A user-defined interface is judged by its own `system.listMethods` where that
1182
+ * gives a list, and is otherwise asked once and then remembered for this session - see
1183
+ * {@link isMethodUnsupported}.
1139
1184
  */
1140
- #hasServiceMessages(interfaceName) {
1185
+ async #hasServiceMessages(interfaceName) {
1141
1186
  if (this.#noServiceMessages.has(interfaceName)) {
1142
1187
  return false;
1143
1188
  }
1144
- return this.#manager?.resolved(interfaceName)?.serviceMessages !== false;
1189
+ if (this.#manager?.resolved(interfaceName)?.serviceMessages === false) {
1190
+ return false;
1191
+ }
1192
+ // the table is measured for the built-in processes; asking rfd for its method list after
1193
+ // every `init` would buy nothing
1194
+ if (isKnownInterface(interfaceName)) {
1195
+ return true;
1196
+ }
1197
+ const listed = await this.#methodList(interfaceName);
1198
+ return listed === undefined || listed.has('getServiceMessages');
1199
+ }
1200
+ /**
1201
+ * B-26 (#158): the methods a user-defined interface lists in `system.listMethods`, or
1202
+ * `undefined` when it gave no usable list.
1203
+ *
1204
+ * Asked once and shared by concurrent callers; the answer is dropped when the interface
1205
+ * subscribes again. A process that does not have `system.listMethods` either is remembered for
1206
+ * the connection, so it is not asked after every re-`init`; any other failure is forgotten, and
1207
+ * the next caller asks again.
1208
+ */
1209
+ #methodList(interfaceName) {
1210
+ if (this.#noListMethods.has(interfaceName)) {
1211
+ return Promise.resolve(undefined);
1212
+ }
1213
+ const cached = this.#listedMethods.get(interfaceName);
1214
+ if (cached) {
1215
+ return cached;
1216
+ }
1217
+ const pending = this.#read(interfaceName, 'system.listMethods', []).then((answer) => {
1218
+ const names = asStrings(answer);
1219
+ return names.length === 0 ? undefined : new Set(names);
1220
+ }, (error) => {
1221
+ if (isMethodUnsupported(error)) {
1222
+ this.#noListMethods.add(interfaceName);
1223
+ }
1224
+ if (this.#listedMethods.get(interfaceName) === pending) {
1225
+ this.#listedMethods.delete(interfaceName);
1226
+ }
1227
+ return undefined;
1228
+ });
1229
+ this.#listedMethods.set(interfaceName, pending);
1230
+ return pending;
1145
1231
  }
1146
1232
  /** Reads the BidCos service messages of one interface into the store. */
1147
1233
  async #refreshServiceMessages(interfaceName) {
1148
- if (!this.#hasServiceMessages(interfaceName)) {
1234
+ if (!(await this.#hasServiceMessages(interfaceName))) {
1149
1235
  return;
1150
1236
  }
1151
1237
  try {
1152
1238
  const answer = await this.#read(interfaceName, 'getServiceMessages', []);
1239
+ if (this.#serviceMessageFailures.delete(interfaceName)) {
1240
+ this.#notice('info', `${interfaceName}: getServiceMessages answers again`, interfaceName);
1241
+ }
1153
1242
  if (!Array.isArray(answer)) {
1154
1243
  return;
1155
1244
  }
@@ -1176,8 +1265,14 @@ export class Backend {
1176
1265
  // of it on hardware (task 17). Remembered, and never asked again this session.
1177
1266
  if (isMethodUnsupported(error)) {
1178
1267
  this.#noServiceMessages.add(interfaceName);
1268
+ this.#serviceMessageFailures.delete(interfaceName);
1269
+ return;
1270
+ }
1271
+ // B-26 (#158): once per interface until a call succeeds again, not once per poll
1272
+ if (this.#serviceMessageFailures.has(interfaceName)) {
1179
1273
  return;
1180
1274
  }
1275
+ this.#serviceMessageFailures.add(interfaceName);
1181
1276
  this.#notice('info', `${interfaceName}: getServiceMessages failed: ${errorMessage(error)}`, interfaceName);
1182
1277
  }
1183
1278
  }
@@ -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
@@ -133,8 +133,10 @@ const UNSUPPORTED_METHOD_PATTERNS = [
133
133
  /unknown xml-rpc tag/i,
134
134
  // binrpc's own parser
135
135
  /malformed response/i,
136
- // an interface that answers XML-RPC properly but has no handler for the method
137
- /unknown method/i,
136
+ // an interface that answers XML-RPC properly but has no handler for the method. rfd words it
137
+ // `unknown method name`, CUxD `unknown.method name` - both with faultCode -1, which is also
138
+ // eq-3's "generic error", so the code alone says nothing (B-26, #158)
139
+ /unknown[ ._]method/i,
138
140
  /method not found/i,
139
141
  ];
140
142
  /** The standard XML-RPC fault code for a method the server does not have. */
@@ -182,4 +184,20 @@ export function isConnectionRefused(value) {
182
184
  }
183
185
  return /\bECONNREFUSED\b/.test(errorMessage(value));
184
186
  }
187
+ /**
188
+ * Task 38: a callback server's bind failed because another process holds the port - hm2mqtt.js on
189
+ * the same pair, or a second container on the host network. Looked for along the `cause` chain and
190
+ * in the text, for the same reason as {@link isConnectionRefused}.
191
+ */
192
+ export function isAddressInUse(value) {
193
+ let current = value;
194
+ for (let depth = 0; current !== undefined && current !== null && depth < 10; depth += 1) {
195
+ const candidate = current;
196
+ if (candidate.code === 'EADDRINUSE') {
197
+ return true;
198
+ }
199
+ current = candidate.cause;
200
+ }
201
+ return /\bEADDRINUSE\b/.test(errorMessage(value));
202
+ }
185
203
  //# 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;