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 +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 +106 -11
- 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 +20 -2
- 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/dist/interfaces/table.d.ts +9 -2
- package/node_modules/@homematic-manager/core/dist/interfaces/table.js +6 -1
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/index-Bcghf2cO.js +14 -0
- package/ui/assets/index-BgAqZKFF.css +1 -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, 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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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)
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
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
|
-
|
|
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.#
|
|
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
|
|
@@ -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
|
-
|
|
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;
|