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