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 +1 -0
- package/dist/options.d.ts +7 -0
- package/dist/options.js +6 -0
- package/dist/server.d.ts +11 -2
- package/dist/server.js +36 -22
- package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +6 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +29 -5
- package/node_modules/@homematic-manager/backend/dist/config/store.d.ts +28 -1
- package/node_modules/@homematic-manager/backend/dist/config/store.js +59 -3
- package/node_modules/@homematic-manager/backend/dist/errors.d.ts +6 -0
- package/node_modules/@homematic-manager/backend/dist/errors.js +16 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +6 -1
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +100 -4
- package/node_modules/@homematic-manager/backend/package.json +1 -1
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +45 -0
- package/node_modules/@homematic-manager/core/dist/api/types.js +15 -0
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/index-CfMq3RQ-.css +1 -0
- package/ui/assets/index-Ykn5vZig.js +14 -0
- package/ui/index.html +2 -2
- package/ui/assets/index-C7XVzGWq.css +0 -1
- package/ui/assets/index-DiBHCDsg.js +0 -14
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
|
-
*
|
|
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`
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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.#
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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.
|
|
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",
|