homematic-manager 3.0.0-beta.12 → 3.0.0-beta.13
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 +6 -0
- package/dist/options.d.ts +13 -0
- package/dist/options.js +26 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +11 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +9 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +125 -13
- package/node_modules/@homematic-manager/backend/dist/devices/installMode.js +9 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +20 -1
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +24 -1
- 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 +36 -5
- 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-C7XVzGWq.css +1 -0
- package/ui/assets/index-DiBHCDsg.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,12 @@ 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.callbackXmlrpcDefaultPort === undefined
|
|
173
|
+
? {}
|
|
174
|
+
: { callbackXmlrpcDefaultPort: values.callbackXmlrpcDefaultPort }),
|
|
175
|
+
...(values.callbackBinrpcDefaultPort === undefined
|
|
176
|
+
? {}
|
|
177
|
+
: { callbackBinrpcDefaultPort: values.callbackBinrpcDefaultPort }),
|
|
172
178
|
idleUnsubscribeMs: values.idleUnsubscribeMs,
|
|
173
179
|
});
|
|
174
180
|
}
|
package/dist/options.d.ts
CHANGED
|
@@ -144,6 +144,16 @@ 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
|
+
};
|
|
147
157
|
readonly 'idle-unsubscribe': {
|
|
148
158
|
readonly type: "string";
|
|
149
159
|
readonly describe: "drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)";
|
|
@@ -221,6 +231,9 @@ export interface WebOptions {
|
|
|
221
231
|
readonly callbackIp: string | undefined;
|
|
222
232
|
readonly callbackXmlrpcPort: number | undefined;
|
|
223
233
|
readonly callbackBinrpcPort: number | undefined;
|
|
234
|
+
/** Task 35 (D-43): the CCU addon's fixed callback ports, taken while the configured ones are `0`. */
|
|
235
|
+
readonly callbackXmlrpcDefaultPort: number | undefined;
|
|
236
|
+
readonly callbackBinrpcDefaultPort: number | undefined;
|
|
224
237
|
readonly demo: boolean;
|
|
225
238
|
/** D-31, in milliseconds; `0` disables the idle unsubscribe. */
|
|
226
239
|
readonly idleUnsubscribeMs: number;
|
package/dist/options.js
CHANGED
|
@@ -144,6 +144,16 @@ 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
|
+
},
|
|
147
157
|
'idle-unsubscribe': {
|
|
148
158
|
type: 'string',
|
|
149
159
|
describe: 'drop the event subscriptions after this long with no page open (5m, 300s, 0 to disable)',
|
|
@@ -292,6 +302,20 @@ export function parseOptions(argv, env = process.env) {
|
|
|
292
302
|
const value = raw[name] ?? OPTIONS[name].default;
|
|
293
303
|
return value === undefined ? undefined : Number(value);
|
|
294
304
|
};
|
|
305
|
+
const port = (name) => {
|
|
306
|
+
const value = number(name);
|
|
307
|
+
if (value !== undefined && !(Number.isInteger(value) && value >= 0 && value <= 65535)) {
|
|
308
|
+
throw new CliError(`--${name}: "${String(value)}" is not a port`);
|
|
309
|
+
}
|
|
310
|
+
return value;
|
|
311
|
+
};
|
|
312
|
+
const callbackXmlrpcDefaultPort = port('callback-xmlrpc-default-port');
|
|
313
|
+
const callbackBinrpcDefaultPort = port('callback-binrpc-default-port');
|
|
314
|
+
if (callbackXmlrpcDefaultPort !== undefined && callbackXmlrpcDefaultPort !== 0) {
|
|
315
|
+
if (callbackXmlrpcDefaultPort === callbackBinrpcDefaultPort) {
|
|
316
|
+
throw new CliError('--callback-xmlrpc-default-port and --callback-binrpc-default-port must differ');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
295
319
|
const logLevel = string('log-level');
|
|
296
320
|
return {
|
|
297
321
|
port: Number(raw['port'] ?? OPTIONS.port.default),
|
|
@@ -312,6 +336,8 @@ export function parseOptions(argv, env = process.env) {
|
|
|
312
336
|
callbackIp: string('callback-ip'),
|
|
313
337
|
callbackXmlrpcPort: number('callback-xmlrpc-port'),
|
|
314
338
|
callbackBinrpcPort: number('callback-binrpc-port'),
|
|
339
|
+
callbackXmlrpcDefaultPort,
|
|
340
|
+
callbackBinrpcDefaultPort,
|
|
315
341
|
demo: boolean('demo'),
|
|
316
342
|
idleUnsubscribeMs: parseDuration(string('idle-unsubscribe'), '--idle-unsubscribe'),
|
|
317
343
|
logLevel: isLogLevel(logLevel) ? logLevel : 'info',
|
package/dist/server.d.ts
CHANGED
|
@@ -118,6 +118,13 @@ export interface WebHostOptions {
|
|
|
118
118
|
/** Fixed callback ports, so a container can publish them. `0` picks a free one. */
|
|
119
119
|
readonly callbackXmlrpcPort?: number | undefined;
|
|
120
120
|
readonly callbackBinrpcPort?: number | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Task 35 (D-43): the callback ports taken while the configuration says `0` - the CCU addon's
|
|
123
|
+
* fixed pair. Handed to the backend and never written to `config.json`, so a port the user set
|
|
124
|
+
* in the settings dialog always wins and no update of the addon can overwrite it.
|
|
125
|
+
*/
|
|
126
|
+
readonly callbackXmlrpcDefaultPort?: number | undefined;
|
|
127
|
+
readonly callbackBinrpcDefaultPort?: number | undefined;
|
|
121
128
|
/** How long `close()` waits for `backend.stop()`. */
|
|
122
129
|
readonly shutdownTimeoutMs?: number;
|
|
123
130
|
/** How often an idle api socket is pinged; `0` turns the heartbeat off. */
|
package/dist/server.js
CHANGED
|
@@ -100,6 +100,10 @@ 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
|
+
}
|
|
103
107
|
backend = await Backend.open({
|
|
104
108
|
dataDir,
|
|
105
109
|
version: options.version ?? packageVersion(),
|
|
@@ -108,6 +112,7 @@ export async function createWebHost(options = {}) {
|
|
|
108
112
|
// reports them, and Electron's in-process transport reports none - so an Electron
|
|
109
113
|
// window can never be idled out however the backend is configured.
|
|
110
114
|
...(options.idleUnsubscribeMs === undefined ? {} : { idleUnsubscribeMs: options.idleUnsubscribeMs }),
|
|
115
|
+
...(defaultCallbackPorts === undefined ? {} : { defaultCallbackPorts }),
|
|
111
116
|
...options.backendOptions,
|
|
112
117
|
});
|
|
113
118
|
backend.on('notice', (notice) => {
|
|
@@ -545,6 +550,12 @@ function upstreamOf(connection) {
|
|
|
545
550
|
...(connection.auth ? { auth: connection.auth } : {}),
|
|
546
551
|
};
|
|
547
552
|
}
|
|
553
|
+
/** Task 35: the default pair, or nothing when neither port is set to anything but `0`. */
|
|
554
|
+
function callbackDefaults(options) {
|
|
555
|
+
const xmlrpc = options.callbackXmlrpcDefaultPort ?? 0;
|
|
556
|
+
const binrpc = options.callbackBinrpcDefaultPort ?? 0;
|
|
557
|
+
return xmlrpc === 0 && binrpc === 0 ? undefined : { xmlrpc, binrpc };
|
|
558
|
+
}
|
|
548
559
|
/** `--ccu`, `--local` and the callback options win over what `config.json` holds. */
|
|
549
560
|
async function applyConnectionOptions(backend, options, log) {
|
|
550
561
|
const wantsHost = options.ccu !== undefined && options.ccu !== '';
|
|
@@ -30,6 +30,15 @@ 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
|
+
};
|
|
33
42
|
readonly rpcTimeoutMs?: number;
|
|
34
43
|
readonly watchdogIntervalMs?: number;
|
|
35
44
|
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, 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';
|
|
@@ -37,6 +37,13 @@ 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
|
+
}
|
|
40
47
|
/** Implements the whole contract. `open()` loads, `start()` connects, `stop()` disconnects. */
|
|
41
48
|
export class Backend {
|
|
42
49
|
events;
|
|
@@ -263,7 +270,7 @@ export class Backend {
|
|
|
263
270
|
case 'config.get':
|
|
264
271
|
return this.#configWithDetected();
|
|
265
272
|
case 'config.set':
|
|
266
|
-
return this.#setConfig(p[0]);
|
|
273
|
+
return this.#setConfig(p[0], params[1]);
|
|
267
274
|
case 'config.discover':
|
|
268
275
|
return this.#discover();
|
|
269
276
|
case 'config.clearCaches':
|
|
@@ -473,6 +480,9 @@ export class Backend {
|
|
|
473
480
|
this.#onCall(record);
|
|
474
481
|
},
|
|
475
482
|
...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
|
|
483
|
+
...(this.#options.defaultCallbackPorts === undefined
|
|
484
|
+
? {}
|
|
485
|
+
: { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
|
|
476
486
|
...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
|
|
477
487
|
...(this.#options.watchdogIntervalMs === undefined
|
|
478
488
|
? {}
|
|
@@ -756,13 +766,16 @@ export class Backend {
|
|
|
756
766
|
* a device that is unreachable is not going to take a write either, which is the normal case
|
|
757
767
|
* here and not worth an error dialog.
|
|
758
768
|
*/
|
|
759
|
-
#noteUnreach(interfaceName, address, datapoint, value) {
|
|
769
|
+
#noteUnreach(interfaceName, address, datapoint, value, options = {}) {
|
|
760
770
|
if (!this.#caches.unreach.note(interfaceName, address, datapoint, value, this.#now())) {
|
|
761
771
|
return;
|
|
762
772
|
}
|
|
763
773
|
this.#caches.saveUnreach();
|
|
764
774
|
this.events.emit('unreach.changed', this.#caches.unreach.list());
|
|
765
|
-
if (
|
|
775
|
+
if (options.acknowledge !== false &&
|
|
776
|
+
datapoint === 'STICKY_UNREACH' &&
|
|
777
|
+
value === true &&
|
|
778
|
+
this.#config.connection.autoAckStickyUnreach === true) {
|
|
766
779
|
void this.#acknowledge(interfaceName, address, datapoint).catch((error) => {
|
|
767
780
|
this.#notice('info', `${address}: STICKY_UNREACH could not be acknowledged automatically: ${errorMessage(error)}`, interfaceName);
|
|
768
781
|
});
|
|
@@ -799,12 +812,28 @@ export class Backend {
|
|
|
799
812
|
* configuration
|
|
800
813
|
*/
|
|
801
814
|
#configWithDetected() {
|
|
802
|
-
return this.#config.config;
|
|
815
|
+
return this.#withHostFacts(this.#config.config);
|
|
803
816
|
}
|
|
804
|
-
|
|
817
|
+
/**
|
|
818
|
+
* What the host adds to the stored configuration before anybody sees it: task 35's callback
|
|
819
|
+
* ports a `0` stands for. Added to every `AppConfig` that leaves the backend - `config.get`,
|
|
820
|
+
* `config.set` and both `config.changed` events - so the dialog never loses the hint on a save.
|
|
821
|
+
*/
|
|
822
|
+
#withHostFacts(config) {
|
|
823
|
+
const ports = this.#options.defaultCallbackPorts;
|
|
824
|
+
return ports === undefined
|
|
825
|
+
? config
|
|
826
|
+
: { ...config, callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } };
|
|
827
|
+
}
|
|
828
|
+
async #setConfig(connection, options) {
|
|
805
829
|
const previousHost = this.#config.connection.host;
|
|
830
|
+
// Task 34 (#147, D-42): the messages the settings dialog asked about, taken before the
|
|
831
|
+
// reconnect, and only on the save that switches the auto-acknowledge from off to on.
|
|
832
|
+
const existing = options?.acknowledgeExisting === true && this.#config.connection.autoAckStickyUnreach !== true
|
|
833
|
+
? this.#stickyUnreachMessages()
|
|
834
|
+
: [];
|
|
806
835
|
await this.#disconnect();
|
|
807
|
-
const config = await this.#config.setConnection(connection);
|
|
836
|
+
const config = this.#withHostFacts(await this.#config.setConnection(connection));
|
|
808
837
|
this.#writeLog.setRpcLogFolder(config.connection.rpcLogFolder);
|
|
809
838
|
if (config.connection.host !== previousHost) {
|
|
810
839
|
await this.#caches.flush();
|
|
@@ -820,14 +849,61 @@ export class Backend {
|
|
|
820
849
|
this.events.emit('config.changed', config);
|
|
821
850
|
if (!this.#stopped && validateConnection(config.connection).length === 0) {
|
|
822
851
|
await this.#connect();
|
|
852
|
+
// after the reconnect, because a write needs the interface; not awaited, so the
|
|
853
|
+
// settings dialog closes as quickly as ever and the writes show up in the RPC log
|
|
854
|
+
if (existing.length > 0 &&
|
|
855
|
+
config.connection.autoAckStickyUnreach === true &&
|
|
856
|
+
config.connection.host === previousHost) {
|
|
857
|
+
void this.#acknowledgeExisting(existing);
|
|
858
|
+
}
|
|
823
859
|
}
|
|
824
860
|
return config;
|
|
825
861
|
}
|
|
862
|
+
/**
|
|
863
|
+
* Task 34: the `STICKY_UNREACH` messages in the list, without HmIP's.
|
|
864
|
+
*
|
|
865
|
+
* D-42 says nothing is acknowledged on HmIP: hmipserver has no such flag on real hardware, and
|
|
866
|
+
* where one appears anyway (a simulator, a future firmware) this is not the switch that should
|
|
867
|
+
* start writing to those devices.
|
|
868
|
+
*/
|
|
869
|
+
#stickyUnreachMessages() {
|
|
870
|
+
const states = this.#manager?.states() ?? [];
|
|
871
|
+
return this.#caches.listServiceMessages().filter((message) => {
|
|
872
|
+
if (message.datapoint !== 'STICKY_UNREACH' || message.value === false) {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
const type = states.find((state) => state.name === message.interfaceName)?.type ?? '';
|
|
876
|
+
return !isHmipInterface(message.interfaceName, type);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Task 34 (#147, D-42): acknowledges the messages that were in the list when the option was
|
|
881
|
+
* switched on, one after another, each with exactly the write of the acknowledge button
|
|
882
|
+
* (`#acknowledge`, the paced `setValue`). A message that is gone by now - the device came back
|
|
883
|
+
* and somebody acknowledged it, or the edge above got there first - is skipped rather than
|
|
884
|
+
* written twice. A failure is a notice and the write log's red line, as for the edge.
|
|
885
|
+
*/
|
|
886
|
+
async #acknowledgeExisting(messages) {
|
|
887
|
+
for (const message of messages) {
|
|
888
|
+
const listed = this.#caches
|
|
889
|
+
.listServiceMessages(message.interfaceName)
|
|
890
|
+
.some((entry) => entry.address === message.address && entry.datapoint === message.datapoint);
|
|
891
|
+
if (!listed || this.#stopped) {
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
try {
|
|
895
|
+
await this.#acknowledge(message.interfaceName, message.address, message.datapoint);
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
this.#notice('info', `${message.address}: STICKY_UNREACH could not be acknowledged: ${errorMessage(error)}`, message.interfaceName);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
826
902
|
async #discover() {
|
|
827
903
|
const discover = this.#options.discover ?? ((options) => discoverCcus(options));
|
|
828
904
|
const found = await discover({ tls: this.#config.connection.tls });
|
|
829
905
|
this.#config.setDiscovered(found);
|
|
830
|
-
this.events.emit('config.changed', this.#config.config);
|
|
906
|
+
this.events.emit('config.changed', this.#withHostFacts(this.#config.config));
|
|
831
907
|
return found;
|
|
832
908
|
}
|
|
833
909
|
async #clearCaches() {
|
|
@@ -879,7 +955,14 @@ export class Backend {
|
|
|
879
955
|
return null;
|
|
880
956
|
}
|
|
881
957
|
async #setInstallMode(interfaceName, on, options) {
|
|
882
|
-
|
|
958
|
+
const effective = { ...options };
|
|
959
|
+
if (interfaceName === 'HmIP-RF') {
|
|
960
|
+
// Task 28, measured in the lab: hmipserver's third `setInstallMode` parameter is a String,
|
|
961
|
+
// and `setInstallMode(true, 30, 1)` got an empty HTTP reply and no install mode at all.
|
|
962
|
+
// BidCos's mode integer never reaches HmIP, whoever asks for it.
|
|
963
|
+
delete effective.mode;
|
|
964
|
+
}
|
|
965
|
+
for (const call of installModeCalls(on, effective)) {
|
|
883
966
|
await this.#write(interfaceName, call.method, call.params);
|
|
884
967
|
}
|
|
885
968
|
return null;
|
|
@@ -1167,11 +1250,27 @@ export class Backend {
|
|
|
1167
1250
|
this.events.emit('serviceMessages.changed', this.#caches.listServiceMessages());
|
|
1168
1251
|
}
|
|
1169
1252
|
}
|
|
1170
|
-
/**
|
|
1253
|
+
/**
|
|
1254
|
+
* Files the RSSI, the service messages and the unreach state of a `getParamset(<device>:0,
|
|
1255
|
+
* VALUES)` answer.
|
|
1256
|
+
*
|
|
1257
|
+
* Task 34 (B-9): the unreach state goes through `#noteUnreach` like the BidCos poll's, so the
|
|
1258
|
+
* Funk tab counts the outages of HmIP devices that no event reported. `UNREACH` is what says
|
|
1259
|
+
* whether the device is away now; `STICKY_UNREACH` is only read where there is no `UNREACH`,
|
|
1260
|
+
* because a standing sticky flag next to `UNREACH: false` would count the same outage again on
|
|
1261
|
+
* every sweep. Nothing is acknowledged from here: a sweep is a read, and HmIP has nothing to
|
|
1262
|
+
* acknowledge (D-42).
|
|
1263
|
+
*/
|
|
1171
1264
|
#applyHmipMaintenance(interfaceName, address, values) {
|
|
1172
1265
|
const device = address.split(':')[0] ?? address;
|
|
1173
1266
|
this.#caches.rssi(interfaceName).applyHmipParamset(device, values);
|
|
1174
|
-
|
|
1267
|
+
const changed = this.#caches.serviceMessages.applyParamset(interfaceName, address, values);
|
|
1268
|
+
const datapoint = 'UNREACH' in values ? 'UNREACH' : 'STICKY_UNREACH';
|
|
1269
|
+
const value = values[datapoint];
|
|
1270
|
+
if (value !== undefined) {
|
|
1271
|
+
this.#noteUnreach(interfaceName, address, datapoint, value, { acknowledge: false });
|
|
1272
|
+
}
|
|
1273
|
+
return changed;
|
|
1175
1274
|
}
|
|
1176
1275
|
/**
|
|
1177
1276
|
* Acknowledging a service message writes its datapoint. An `ACTION` is confirmed with `true`,
|
|
@@ -1308,17 +1407,30 @@ function asLinks(value) {
|
|
|
1308
1407
|
const links = [];
|
|
1309
1408
|
for (const entry of value) {
|
|
1310
1409
|
if (isStruct(entry) && typeof entry['SENDER'] === 'string' && typeof entry['RECEIVER'] === 'string') {
|
|
1311
|
-
links.push(entry);
|
|
1410
|
+
links.push(withLinkText(entry));
|
|
1312
1411
|
}
|
|
1313
1412
|
}
|
|
1314
1413
|
return links;
|
|
1315
1414
|
}
|
|
1316
1415
|
function asLink(value, sender, receiver) {
|
|
1317
1416
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
1318
|
-
return { SENDER: sender, RECEIVER: receiver, ...value };
|
|
1417
|
+
return withLinkText({ SENDER: sender, RECEIVER: receiver, ...value });
|
|
1319
1418
|
}
|
|
1320
1419
|
return { SENDER: sender, RECEIVER: receiver };
|
|
1321
1420
|
}
|
|
1421
|
+
/**
|
|
1422
|
+
* B-23 (#156): a link's `NAME` and `DESCRIPTION` are UTF-8 in rfd - the WebUI's default description
|
|
1423
|
+
* "Standardverknüpfung" and our own `setLinkInfo` alike - and the XML-RPC client reads them as
|
|
1424
|
+
* ISO-8859-1. Repaired here, once, for both reads; a BIN-RPC answer (its library decodes UTF-8) and
|
|
1425
|
+
* a real ISO-8859-1 text pass through unchanged.
|
|
1426
|
+
*/
|
|
1427
|
+
function withLinkText(link) {
|
|
1428
|
+
return {
|
|
1429
|
+
...link,
|
|
1430
|
+
...(typeof link.NAME === 'string' ? { NAME: repairMisdecodedUtf8(link.NAME) } : {}),
|
|
1431
|
+
...(typeof link.DESCRIPTION === 'string' ? { DESCRIPTION: repairMisdecodedUtf8(link.DESCRIPTION) } : {}),
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1322
1434
|
function asBidcosInterfaces(value) {
|
|
1323
1435
|
if (!Array.isArray(value)) {
|
|
1324
1436
|
return [];
|
|
@@ -68,6 +68,15 @@ export function installModeCalls(on, options = {}) {
|
|
|
68
68
|
}
|
|
69
69
|
const seconds = installSeconds(options.seconds);
|
|
70
70
|
const calls = [];
|
|
71
|
+
if (options.hmipKeyMode === 'ANY') {
|
|
72
|
+
// Task 28: any HmIP device, no SGTIN. hmipserver's third parameter is a String - the address
|
|
73
|
+
// to admit - so BidCos's `mode` integer there is a type fault, which is why "setInstallMode
|
|
74
|
+
// does not work on HmIP" gets reported. Two arguments arm the interface for whatever asks to
|
|
75
|
+
// join next, as the CCU WebUI's key-server branch sends it (setinstallmodehmip.tcl); an SGTIN
|
|
76
|
+
// still in the dialog's field or a BidCos mode must not turn into a third one.
|
|
77
|
+
calls.push({ method: 'setInstallMode', params: [true, seconds] });
|
|
78
|
+
return calls;
|
|
79
|
+
}
|
|
71
80
|
if (options.tempKey !== undefined && options.tempKey !== '') {
|
|
72
81
|
// issue #20: the temporary key has to be set before the install mode opens
|
|
73
82
|
calls.push({ method: 'setTempKey', params: [options.tempKey] });
|
|
@@ -73,8 +73,16 @@ export interface InterfaceManagerOptions {
|
|
|
73
73
|
* interface that fails once and then works.
|
|
74
74
|
*/
|
|
75
75
|
readonly initBackoffMs?: number;
|
|
76
|
-
/** Address to bind the callback servers to;
|
|
76
|
+
/** Address to bind the callback servers to; left out, {@link callbackBindHost} decides. */
|
|
77
77
|
readonly callbackHost?: string;
|
|
78
|
+
/**
|
|
79
|
+
* Task 35 (D-43): the callback ports taken while the connection's are `0`. The CCU addon sets a
|
|
80
|
+
* fixed pair; the desktop app, npm and Docker leave this out and keep the kernel's free port.
|
|
81
|
+
*/
|
|
82
|
+
readonly defaultCallbackPorts?: {
|
|
83
|
+
readonly xmlrpc: number;
|
|
84
|
+
readonly binrpc: number;
|
|
85
|
+
};
|
|
78
86
|
/** Injected by the tests. */
|
|
79
87
|
readonly createClient?: (options: RpcClientOptions) => RpcClient;
|
|
80
88
|
readonly createCallbackServers?: (handler: CallbackHandler) => CallbackServerSet;
|
|
@@ -95,6 +103,17 @@ export interface InterfaceManagerOptions {
|
|
|
95
103
|
* takes the whole connection down on a CCU whose HmIP access point is not paired yet.
|
|
96
104
|
*/
|
|
97
105
|
export declare function firstBidcosInterfaceAddress(result: unknown): string | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Where the callback servers listen when the host names no address of its own (task 35).
|
|
108
|
+
*
|
|
109
|
+
* Where the interface processes are told to call back on the loopback - the CCU itself (`local`)
|
|
110
|
+
* with no address configured (#144), or the loopback configured by hand - nothing has to reach the
|
|
111
|
+
* servers from anywhere else, and they listen on `127.0.0.1` only. It matters more with the addon's
|
|
112
|
+
* fixed ports: a listener on every interface would be a known port on the LAN of every CCU whose
|
|
113
|
+
* firewall is open, and anything on that LAN could send it events. Everywhere else the CCU is
|
|
114
|
+
* across a network and `undefined` keeps `0.0.0.0`.
|
|
115
|
+
*/
|
|
116
|
+
export declare function callbackBindHost(connection: ConnectionConfig): string | undefined;
|
|
98
117
|
/** Connects, watches and disconnects every configured interface. */
|
|
99
118
|
export declare class InterfaceManager {
|
|
100
119
|
#private;
|
|
@@ -62,6 +62,20 @@ export function firstBidcosInterfaceAddress(result) {
|
|
|
62
62
|
const address = first['ADDRESS'];
|
|
63
63
|
return typeof address === 'string' && address !== '' ? address : undefined;
|
|
64
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Where the callback servers listen when the host names no address of its own (task 35).
|
|
67
|
+
*
|
|
68
|
+
* Where the interface processes are told to call back on the loopback - the CCU itself (`local`)
|
|
69
|
+
* with no address configured (#144), or the loopback configured by hand - nothing has to reach the
|
|
70
|
+
* servers from anywhere else, and they listen on `127.0.0.1` only. It matters more with the addon's
|
|
71
|
+
* fixed ports: a listener on every interface would be a known port on the LAN of every CCU whose
|
|
72
|
+
* firewall is open, and anything on that LAN could send it events. Everywhere else the CCU is
|
|
73
|
+
* across a network and `undefined` keeps `0.0.0.0`.
|
|
74
|
+
*/
|
|
75
|
+
export function callbackBindHost(connection) {
|
|
76
|
+
const { ip } = connection.callback;
|
|
77
|
+
return ip === LOOPBACK_IP || (ip === '' && connection.local === true) ? LOOPBACK_IP : undefined;
|
|
78
|
+
}
|
|
65
79
|
/** Connects, watches and disconnects every configured interface. */
|
|
66
80
|
export class InterfaceManager {
|
|
67
81
|
#options;
|
|
@@ -79,13 +93,22 @@ export class InterfaceManager {
|
|
|
79
93
|
}
|
|
80
94
|
#defaultServers(handler) {
|
|
81
95
|
const callback = this.#options.connection.callback;
|
|
96
|
+
const host = this.#options.callbackHost ?? callbackBindHost(this.#options.connection);
|
|
82
97
|
return new CallbackServers({
|
|
83
98
|
handler,
|
|
84
|
-
...(
|
|
99
|
+
...(host === undefined ? {} : { host }),
|
|
85
100
|
ports: { xmlrpc: callback.xmlrpcPort, binrpc: callback.binrpcPort },
|
|
101
|
+
...(this.#options.defaultCallbackPorts === undefined
|
|
102
|
+
? {}
|
|
103
|
+
: { defaultPorts: this.#options.defaultCallbackPorts }),
|
|
86
104
|
onError: (error) => {
|
|
87
105
|
this.#options.onNotice('error', `callback server: ${errorMessage(error)}`);
|
|
88
106
|
},
|
|
107
|
+
// a warning, not an error: the subscription works on the free port, only the fixed URL
|
|
108
|
+
// that keeps the handler lists short is gone until the next start
|
|
109
|
+
onFallback: (protocol, port, error) => {
|
|
110
|
+
this.#options.onNotice('warn', `callback server: the default ${protocol} port ${String(port)} is taken, a free port is used until the next start (${errorMessage(error)})`);
|
|
111
|
+
},
|
|
89
112
|
});
|
|
90
113
|
}
|
|
91
114
|
/**
|
|
@@ -88,11 +88,23 @@ export declare class CallbackServers implements CallbackServerSet {
|
|
|
88
88
|
constructor(options: {
|
|
89
89
|
handler: CallbackHandler;
|
|
90
90
|
host?: string;
|
|
91
|
+
/** The configured ports; `0` means none is configured. */
|
|
91
92
|
ports: {
|
|
92
93
|
xmlrpc: number;
|
|
93
94
|
binrpc: number;
|
|
94
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* Task 35 (D-43): the port a protocol takes while its configured port is `0` - the CCU
|
|
98
|
+
* addon's fixed pair, so that a restart registers the very URL it registered before.
|
|
99
|
+
* Unlike a configured port, a default that is taken is no error: the server falls back to
|
|
100
|
+
* a free port for this start, and `onFallback` hears about it once.
|
|
101
|
+
*/
|
|
102
|
+
defaultPorts?: {
|
|
103
|
+
xmlrpc: number;
|
|
104
|
+
binrpc: number;
|
|
105
|
+
};
|
|
95
106
|
onError?: (error: unknown) => void;
|
|
107
|
+
onFallback?: (protocol: RpcProtocol, port: number, error: unknown) => void;
|
|
96
108
|
});
|
|
97
109
|
/** Starts the server for a protocol if it is not running yet, and returns its port. */
|
|
98
110
|
ensure(protocol: RpcProtocol): Promise<number>;
|
|
@@ -199,11 +199,18 @@ export class CallbackServer {
|
|
|
199
199
|
return new Promise((resolve, reject) => {
|
|
200
200
|
const server = xmlrpc.createServer({ host: this.host, port: this.#requestedPort });
|
|
201
201
|
this.#xmlrpcServer = server;
|
|
202
|
+
let listening = false;
|
|
202
203
|
server.on('error', (error) => {
|
|
203
|
-
|
|
204
|
+
// a bind that fails is the rejection's to report, once; `onError` is for a server
|
|
205
|
+
// that is running
|
|
206
|
+
if (!listening) {
|
|
207
|
+
reject(connectionError(`xmlrpc callback server on port ${String(this.#requestedPort)}: ${error.message}`, error));
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
204
210
|
this.#onError(error);
|
|
205
211
|
});
|
|
206
212
|
server.on('listening', () => {
|
|
213
|
+
listening = true;
|
|
207
214
|
const address = server.httpServer.address();
|
|
208
215
|
resolve(typeof address === 'object' && address !== null ? address.port : this.#requestedPort);
|
|
209
216
|
});
|
|
@@ -221,11 +228,16 @@ export class CallbackServer {
|
|
|
221
228
|
return new Promise((resolve, reject) => {
|
|
222
229
|
const server = binrpc.createServer({ host: this.host, port: this.#requestedPort });
|
|
223
230
|
this.#binrpcServer = server;
|
|
231
|
+
let listening = false;
|
|
224
232
|
server.on('error', (error) => {
|
|
225
|
-
|
|
233
|
+
if (!listening) {
|
|
234
|
+
reject(connectionError(`binrpc callback server on port ${String(this.#requestedPort)}: ${error.message}`, error));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
226
237
|
this.#onError(error);
|
|
227
238
|
});
|
|
228
239
|
server.on('listening', () => {
|
|
240
|
+
listening = true;
|
|
229
241
|
const address = server.server.address();
|
|
230
242
|
resolve(typeof address === 'object' && address !== null ? address.port : this.#requestedPort);
|
|
231
243
|
});
|
|
@@ -284,12 +296,16 @@ export class CallbackServers {
|
|
|
284
296
|
#handler;
|
|
285
297
|
#host;
|
|
286
298
|
#ports;
|
|
299
|
+
#defaultPorts;
|
|
287
300
|
#onError;
|
|
301
|
+
#onFallback;
|
|
288
302
|
constructor(options) {
|
|
289
303
|
this.#handler = options.handler;
|
|
290
304
|
this.#host = options.host ?? '0.0.0.0';
|
|
291
305
|
this.#ports = options.ports;
|
|
306
|
+
this.#defaultPorts = options.defaultPorts ?? { xmlrpc: 0, binrpc: 0 };
|
|
292
307
|
this.#onError = options.onError ?? (() => undefined);
|
|
308
|
+
this.#onFallback = options.onFallback ?? (() => undefined);
|
|
293
309
|
}
|
|
294
310
|
/** Starts the server for a protocol if it is not running yet, and returns its port. */
|
|
295
311
|
async ensure(protocol) {
|
|
@@ -297,10 +313,24 @@ export class CallbackServers {
|
|
|
297
313
|
if (running) {
|
|
298
314
|
return running.port;
|
|
299
315
|
}
|
|
316
|
+
const configured = protocol === 'binrpc' ? this.#ports.binrpc : this.#ports.xmlrpc;
|
|
317
|
+
const preferred = protocol === 'binrpc' ? this.#defaultPorts.binrpc : this.#defaultPorts.xmlrpc;
|
|
318
|
+
if (configured !== 0 || preferred === 0) {
|
|
319
|
+
return this.#start(protocol, configured);
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
return await this.#start(protocol, preferred);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
this.#onFallback(protocol, preferred, error);
|
|
326
|
+
return this.#start(protocol, 0);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async #start(protocol, port) {
|
|
300
330
|
const server = new CallbackServer({
|
|
301
331
|
protocol,
|
|
302
332
|
host: this.#host,
|
|
303
|
-
port
|
|
333
|
+
port,
|
|
304
334
|
handler: this.#handler,
|
|
305
335
|
onError: this.#onError,
|
|
306
336
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homematic-manager/backend",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.13",
|
|
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",
|