homematic-manager 3.0.0-beta.21 → 3.0.0-beta.22
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 +5 -3
- package/dist/cli.js +7 -1
- package/dist/options.d.ts +5 -0
- package/dist/options.js +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +8 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +175 -17
- package/node_modules/@homematic-manager/backend/dist/config/defaults.js +41 -0
- package/node_modules/@homematic-manager/backend/dist/groups/client.d.ts +76 -0
- package/node_modules/@homematic-manager/backend/dist/groups/client.js +233 -0
- package/node_modules/@homematic-manager/backend/dist/index.d.ts +1 -0
- package/node_modules/@homematic-manager/backend/dist/index.js +2 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +111 -5
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +289 -13
- package/node_modules/@homematic-manager/backend/dist/meta/client.d.ts +29 -1
- package/node_modules/@homematic-manager/backend/dist/meta/client.js +89 -15
- package/node_modules/@homematic-manager/backend/dist/meta/service.d.ts +50 -4
- package/node_modules/@homematic-manager/backend/dist/meta/service.js +89 -17
- package/node_modules/@homematic-manager/backend/dist/meta/systemFetch.d.ts +45 -0
- package/node_modules/@homematic-manager/backend/dist/meta/systemFetch.js +256 -0
- package/node_modules/@homematic-manager/backend/dist/rega/client.d.ts +6 -0
- package/node_modules/@homematic-manager/backend/dist/rega/client.js +33 -9
- package/node_modules/@homematic-manager/backend/dist/rpc/server.d.ts +3 -4
- package/node_modules/@homematic-manager/backend/dist/rpc/server.js +9 -15
- package/node_modules/@homematic-manager/backend/package.json +1 -1
- package/node_modules/@homematic-manager/core/dist/address/names.d.ts +28 -0
- package/node_modules/@homematic-manager/core/dist/address/names.js +46 -0
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +203 -2
- package/node_modules/@homematic-manager/core/dist/devices/smokeGroups.d.ts +85 -0
- package/node_modules/@homematic-manager/core/dist/devices/smokeGroups.js +156 -0
- package/node_modules/@homematic-manager/core/dist/index.d.ts +2 -0
- package/node_modules/@homematic-manager/core/dist/index.js +2 -0
- package/node_modules/@homematic-manager/core/dist/interfaces/table.d.ts +12 -0
- package/node_modules/@homematic-manager/core/dist/interfaces/table.js +6 -0
- package/node_modules/@homematic-manager/core/dist/meta/types.d.ts +15 -0
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +3 -3
- package/ui/assets/index-CNnzEOiY.css +1 -0
- package/ui/assets/index-rHTK97G5.js +20 -0
- package/ui/index.html +2 -2
- package/ui/assets/index-D-NsbBVS.css +0 -1
- package/ui/assets/index-DNUNMBbb.js +0 -16
package/README.md
CHANGED
|
@@ -85,9 +85,11 @@ subscribes again with one `init` per interface. That page sees the interfaces ma
|
|
|
85
85
|
until their first `listDevices` sweep is through, because `hmipserver` re-sends every device on
|
|
86
86
|
`init` (occu#45).
|
|
87
87
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
The time also runs from the start when no page is opened after it (B-70). The settings dialog
|
|
89
|
+
offers it (*Unsubscribe when idle*: the default, 15 minutes to 4 hours, or never), saved in the
|
|
90
|
+
profile. `--idle-unsubscribe <duration>` / `HMM_IDLE_UNSUBSCRIBE` sets it at start instead (`5m`,
|
|
91
|
+
`300s`, `90`, `0` for never) and then wins over the dialog, which shows it read-only. It is on by
|
|
92
|
+
default for every server install type - npm, Docker and the CCU addon all run unattended for days. The Electron app never does this: its in-process
|
|
91
93
|
transport reports no sessions at all, so nothing there can ever be counted as idle.
|
|
92
94
|
|
|
93
95
|
What is **not** replayed after a resubscribe are the events and service messages of the idle
|
package/dist/cli.js
CHANGED
|
@@ -118,8 +118,13 @@ export async function runCli(options = {}) {
|
|
|
118
118
|
log.info(`development mode: everything but the api is proxied to ${parsed.uiDevServer}`);
|
|
119
119
|
}
|
|
120
120
|
if (!parsed.demo) {
|
|
121
|
+
// B-70: a time given at start is the one in force; without it the settings dialog's time
|
|
122
|
+
// (saved in the profile) replaces the default, and the backend logs nothing more about it
|
|
121
123
|
log.info(parsed.idleUnsubscribeMs > 0
|
|
122
|
-
? `idle unsubscribe after ${String(Math.round(parsed.idleUnsubscribeMs / 1000))} s without an open page`
|
|
124
|
+
? `idle unsubscribe after ${String(Math.round(parsed.idleUnsubscribeMs / 1000))} s without an open page` +
|
|
125
|
+
(parsed.idleUnsubscribePinned
|
|
126
|
+
? ', set at start'
|
|
127
|
+
: ' unless the settings dialog chose another time')
|
|
123
128
|
: 'idle unsubscribe is off: the interfaces stay subscribed with no page open');
|
|
124
129
|
}
|
|
125
130
|
const exit = options.exit ?? ((code) => process.exit(code));
|
|
@@ -177,6 +182,7 @@ function startHost(values, log, version) {
|
|
|
177
182
|
? {}
|
|
178
183
|
: { callbackBinrpcDefaultPort: values.callbackBinrpcDefaultPort }),
|
|
179
184
|
idleUnsubscribeMs: values.idleUnsubscribeMs,
|
|
185
|
+
...(values.idleUnsubscribePinned ? { idleUnsubscribePinned: true } : {}),
|
|
180
186
|
});
|
|
181
187
|
}
|
|
182
188
|
/** Was this file started, or only imported? The tests and `dev.ts` import it. */
|
package/dist/options.d.ts
CHANGED
|
@@ -244,6 +244,11 @@ export interface WebOptions {
|
|
|
244
244
|
readonly demo: boolean;
|
|
245
245
|
/** D-31, in milliseconds; `0` disables the idle unsubscribe. */
|
|
246
246
|
readonly idleUnsubscribeMs: number;
|
|
247
|
+
/**
|
|
248
|
+
* B-70: `--idle-unsubscribe` or `HMM_IDLE_UNSUBSCRIBE` was given. Then it wins over the time the
|
|
249
|
+
* settings dialog saves; without it the five minutes are only the default the dialog changes.
|
|
250
|
+
*/
|
|
251
|
+
readonly idleUnsubscribePinned: boolean;
|
|
247
252
|
readonly logLevel: LogLevel;
|
|
248
253
|
readonly help: boolean;
|
|
249
254
|
readonly version: boolean;
|
package/dist/options.js
CHANGED
|
@@ -346,6 +346,7 @@ export function parseOptions(argv, env = process.env) {
|
|
|
346
346
|
inContainer: boolean('in-container') ?? false,
|
|
347
347
|
demo: boolean('demo'),
|
|
348
348
|
idleUnsubscribeMs: parseDuration(string('idle-unsubscribe'), '--idle-unsubscribe'),
|
|
349
|
+
idleUnsubscribePinned: raw['idle-unsubscribe'] !== undefined,
|
|
349
350
|
logLevel: isLogLevel(logLevel) ? logLevel : 'info',
|
|
350
351
|
help: boolean('help') ?? false,
|
|
351
352
|
version: boolean('version') ?? false,
|
package/dist/server.d.ts
CHANGED
|
@@ -92,6 +92,8 @@ export interface WebHostOptions {
|
|
|
92
92
|
* interface processes. `0` (the default here; the CLI's default is five minutes) is off.
|
|
93
93
|
*/
|
|
94
94
|
readonly idleUnsubscribeMs?: number;
|
|
95
|
+
/** B-70: `idleUnsubscribeMs` was given at start and wins over the settings dialog's time. */
|
|
96
|
+
readonly idleUnsubscribePinned?: boolean;
|
|
95
97
|
/** `http://127.0.0.1:5173` - proxy everything that is not the API to a vite dev server. */
|
|
96
98
|
readonly uiDevServer?: string | undefined;
|
|
97
99
|
/** The token clients have to present. Generated when auth is on and none is given. */
|
package/dist/server.js
CHANGED
|
@@ -128,6 +128,7 @@ export async function createWebHost(options = {}) {
|
|
|
128
128
|
// reports them, and Electron's in-process transport reports none - so an Electron
|
|
129
129
|
// window can never be idled out however the backend is configured.
|
|
130
130
|
...(options.idleUnsubscribeMs === undefined ? {} : { idleUnsubscribeMs: options.idleUnsubscribeMs }),
|
|
131
|
+
...(options.idleUnsubscribePinned === true ? { idleUnsubscribePinned: true } : {}),
|
|
131
132
|
...(defaultCallbackPorts === undefined ? {} : { defaultCallbackPorts }),
|
|
132
133
|
...(pinnedCallback === undefined ? {} : { pinnedCallback }),
|
|
133
134
|
...(options.inContainer === true ? { inContainer: true } : {}),
|
|
@@ -62,8 +62,16 @@ export interface BackendOptions extends Omit<ConfigStoreOptions, 'version'> {
|
|
|
62
62
|
* `0` or omitted turns it off, which is the Electron case: `InProcessTransport` never reports
|
|
63
63
|
* a session, so the count is always zero and a grace period would unsubscribe a running window.
|
|
64
64
|
* Only a transport that really counts sessions - `ApiWebSocketServer` - may switch this on.
|
|
65
|
+
*
|
|
66
|
+
* B-70: given at all, this is the host's default, and the profile's `idleUnsubscribeMs`
|
|
67
|
+
* replaces it unless {@link idleUnsubscribePinned} says the host was started with it.
|
|
65
68
|
*/
|
|
66
69
|
readonly idleUnsubscribeMs?: number;
|
|
70
|
+
/**
|
|
71
|
+
* B-70: `idleUnsubscribeMs` was set when the host was started (`HMM_IDLE_UNSUBSCRIBE`,
|
|
72
|
+
* `--idle-unsubscribe`, the addon's settings page) and wins over the profile.
|
|
73
|
+
*/
|
|
74
|
+
readonly idleUnsubscribePinned?: boolean;
|
|
67
75
|
readonly hmipSweepDelayMs?: number;
|
|
68
76
|
readonly cacheWriteDelayMs?: number;
|
|
69
77
|
readonly now?: () => number;
|
|
@@ -23,8 +23,11 @@ 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, LOOPBACK_IP, callbackBindHost, firstBidcosInterfaceAddress, } from '../interfaces/manager.js';
|
|
27
|
-
import {
|
|
26
|
+
import { DEFAULT_IDLE_UNSUBSCRIBE_MS, InterfaceManager, LOOPBACK_IP, callbackBindHost, firstBidcosInterfaceAddress, } from '../interfaces/manager.js';
|
|
27
|
+
import { HeatingGroupsClient } from '../groups/client.js';
|
|
28
|
+
import { DETECT_TIMEOUT_MS, MetaApiClient } from '../meta/client.js';
|
|
29
|
+
import { MetaService, hostBaseUrl } from '../meta/service.js';
|
|
30
|
+
import { createSystemFetch } from '../meta/systemFetch.js';
|
|
28
31
|
import { RegaService } from '../rega/client.js';
|
|
29
32
|
import { listDevicesAnswer } from '../rpc/server.js';
|
|
30
33
|
import { ApiEventEmitter } from '../util/emitter.js';
|
|
@@ -241,8 +244,35 @@ export class Backend {
|
|
|
241
244
|
}
|
|
242
245
|
return;
|
|
243
246
|
}
|
|
244
|
-
|
|
245
|
-
|
|
247
|
+
this.#armIdleTimer();
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* B-70 (D-31): the grace period in force - the host's pinned value, else the profile's, else the
|
|
251
|
+
* host's default. `0` when the host never goes idle (Electron) or the choice is "never".
|
|
252
|
+
*/
|
|
253
|
+
#idleGraceMs() {
|
|
254
|
+
const host = this.#options.idleUnsubscribeMs;
|
|
255
|
+
if (host === undefined) {
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
if (this.#options.idleUnsubscribePinned === true) {
|
|
259
|
+
return host;
|
|
260
|
+
}
|
|
261
|
+
return this.#config.connection.idleUnsubscribeMs ?? host;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Starts the grace period when there is no session. Called when the count drops to zero and -
|
|
265
|
+
* B-70 - when a connection has just been made with nobody looking: an addon nobody opens after
|
|
266
|
+
* its start used to stay subscribed for good, because only a count *dropping* to zero started
|
|
267
|
+
* the timer, and a count that never rose never dropped.
|
|
268
|
+
*/
|
|
269
|
+
#armIdleTimer() {
|
|
270
|
+
if (this.#idleTimer !== undefined) {
|
|
271
|
+
clearTimeout(this.#idleTimer);
|
|
272
|
+
this.#idleTimer = undefined;
|
|
273
|
+
}
|
|
274
|
+
const grace = this.#idleGraceMs();
|
|
275
|
+
if (grace <= 0 || this.#stopped || this.#sessions > 0) {
|
|
246
276
|
return;
|
|
247
277
|
}
|
|
248
278
|
const timer = setTimeout(() => {
|
|
@@ -389,11 +419,35 @@ export class Backend {
|
|
|
389
419
|
await meta.refresh();
|
|
390
420
|
return meta.state();
|
|
391
421
|
}
|
|
422
|
+
case 'meta.pairing': {
|
|
423
|
+
// task 66: no store yet, or a store that is not the system's, is "nothing to say"
|
|
424
|
+
await this.#metaReady?.catch(() => undefined);
|
|
425
|
+
return (await this.#meta?.hmipPairing()) ?? null;
|
|
426
|
+
}
|
|
392
427
|
case 'meta.export':
|
|
393
428
|
return (await this.#requireMeta()).document();
|
|
394
429
|
case 'meta.import':
|
|
395
430
|
await (await this.#requireMeta()).import(p[0], params[1] ?? 'replace');
|
|
396
431
|
return null;
|
|
432
|
+
case 'groups.state':
|
|
433
|
+
return this.#groupsState();
|
|
434
|
+
case 'groups.list':
|
|
435
|
+
return (await this.#requireGroups()).list();
|
|
436
|
+
case 'groups.types':
|
|
437
|
+
return (await this.#requireGroups()).types();
|
|
438
|
+
case 'groups.get':
|
|
439
|
+
return (await this.#requireGroups()).get(p[0]);
|
|
440
|
+
case 'groups.create':
|
|
441
|
+
return (await this.#requireGroups()).create(p[0], p[1], p[2]);
|
|
442
|
+
case 'groups.update':
|
|
443
|
+
// `undefined` keeps a field, so these two are read from `params`, like `meta.node.create`'s -
|
|
444
|
+
// and a `null`, which is what the WebSocket makes of an omitted argument, means the same
|
|
445
|
+
return (await this.#requireGroups()).update(p[0], {
|
|
446
|
+
name: params[1] ?? undefined,
|
|
447
|
+
members: params[2] ?? undefined,
|
|
448
|
+
});
|
|
449
|
+
case 'groups.delete':
|
|
450
|
+
return (await this.#requireGroups()).remove(p[0]);
|
|
397
451
|
case 'paramset.get':
|
|
398
452
|
return this.#getParamset(p[0], p[1], p[2]);
|
|
399
453
|
case 'paramset.description':
|
|
@@ -531,6 +585,8 @@ export class Backend {
|
|
|
531
585
|
? {}
|
|
532
586
|
: { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
|
|
533
587
|
...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
|
|
588
|
+
// B-69: read at every watchdog round - the cache is replaced when the host changes
|
|
589
|
+
listsDevices: (interfaceName) => this.#caches.devices.size(interfaceName) > 0,
|
|
534
590
|
...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
|
|
535
591
|
...(this.#options.watchdogIntervalMs === undefined
|
|
536
592
|
? {}
|
|
@@ -542,9 +598,30 @@ export class Backend {
|
|
|
542
598
|
...this.#options.interfaceManagerOptions,
|
|
543
599
|
});
|
|
544
600
|
this.#manager = manager;
|
|
601
|
+
// B-62: is the host an openccu-lite system? Asked alongside the interface start, so that a
|
|
602
|
+
// host which swallows the packet holds up the names, never the interfaces (D-40's rule).
|
|
603
|
+
const hostProbe = this.#probeHost(connection);
|
|
604
|
+
try {
|
|
605
|
+
await manager.start();
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
this.#manager = undefined;
|
|
609
|
+
this.#notice('error', errorMessage(error));
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const probe = await hostProbe;
|
|
613
|
+
const hostVersion = probe.answer;
|
|
614
|
+
if (this.#manager !== manager) {
|
|
615
|
+
// disconnected while the host was being asked
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
545
618
|
this.#rega = (this.#options.createRega ?? ((options) => new RegaService(options)))({
|
|
546
619
|
host: connection.host,
|
|
547
620
|
enabled: connection.rega,
|
|
621
|
+
// B-62: an openccu-lite system has no ReGaHSS. Nothing is called and nothing is logged;
|
|
622
|
+
// the state says why, and the profile's own switch is left as it is - the same profile
|
|
623
|
+
// moved to a CCU has ReGa again.
|
|
624
|
+
...(hostVersion === undefined ? {} : { reason: 'openccu-lite' }),
|
|
548
625
|
tls: connection.tls,
|
|
549
626
|
auth: connection.auth,
|
|
550
627
|
// ReGa's client takes a language for the WebUI placeholder translation, which is off
|
|
@@ -562,20 +639,42 @@ export class Backend {
|
|
|
562
639
|
},
|
|
563
640
|
...this.#options.regaOptions,
|
|
564
641
|
});
|
|
565
|
-
try {
|
|
566
|
-
await manager.start();
|
|
567
|
-
}
|
|
568
|
-
catch (error) {
|
|
569
|
-
this.#manager = undefined;
|
|
570
|
-
this.#notice('error', errorMessage(error));
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
642
|
if (await this.#rega.refreshNames()) {
|
|
574
643
|
this.#caches.saveNames();
|
|
575
644
|
this.events.emit('names.changed', this.#caches.names.all());
|
|
576
645
|
}
|
|
577
|
-
this.#metaReady = this.#startMeta(connection);
|
|
646
|
+
this.#metaReady = this.#startMeta(connection, probe);
|
|
578
647
|
this.#startServiceMessagePolling();
|
|
648
|
+
// B-70: counted from the start (or the reconnect of a save) when no page is open
|
|
649
|
+
this.#armIdleTimer();
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* B-62: whether the connection's own host is an openccu-lite system - decided from what exists,
|
|
653
|
+
* openccu-lite's own rule (D-40): `GET /api/meta/v1/version` on the host itself answers
|
|
654
|
+
* `{"api":"meta",…}` there and 404 or HTML on a CCU. Never `metaUrl`, which names the store and
|
|
655
|
+
* may be another system. Never throws; a host that is off is "not openccu-lite" for this
|
|
656
|
+
* connect, and its ReGa is asked as before.
|
|
657
|
+
*/
|
|
658
|
+
async #probeHost(connection) {
|
|
659
|
+
const baseUrl = hostBaseUrl(connection);
|
|
660
|
+
if (baseUrl === '') {
|
|
661
|
+
return { answer: undefined };
|
|
662
|
+
}
|
|
663
|
+
const client = new MetaApiClient({ baseUrl, fetch: this.#systemFetch(connection) });
|
|
664
|
+
// B-67: the redirect to https:// is followed, and a certificate nothing trusts is said
|
|
665
|
+
const found = await client.detect(this.#options.metaOptions?.detectTimeoutMs ?? DETECT_TIMEOUT_MS);
|
|
666
|
+
return {
|
|
667
|
+
answer: found.version,
|
|
668
|
+
baseUrl: found.baseUrl,
|
|
669
|
+
...(found.certificate === undefined ? {} : { certificate: found.certificate }),
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* B-67: the `fetch` for the system's own APIs - with the certificates the profile trusts. The
|
|
674
|
+
* tests inject theirs through `metaOptions.fetch`, which wins.
|
|
675
|
+
*/
|
|
676
|
+
#systemFetch(connection) {
|
|
677
|
+
return this.#options.metaOptions?.fetch ?? createSystemFetch(connection.systemTrust);
|
|
579
678
|
}
|
|
580
679
|
async #disconnect() {
|
|
581
680
|
this.#clearTimers();
|
|
@@ -599,7 +698,7 @@ export class Backend {
|
|
|
599
698
|
* connect. Before that the store's names are still applied - they are keyed by ref, and the
|
|
600
699
|
* address half of a ref never needs an interface to be readable.
|
|
601
700
|
*/
|
|
602
|
-
async #startMeta(connection) {
|
|
701
|
+
async #startMeta(connection, hostProbe) {
|
|
603
702
|
try {
|
|
604
703
|
const meta = await MetaService.create({
|
|
605
704
|
connection,
|
|
@@ -607,9 +706,11 @@ export class Backend {
|
|
|
607
706
|
cacheDir: this.#config.cacheDir,
|
|
608
707
|
names: this.#caches.names,
|
|
609
708
|
interfaceOf: (address) => this.#interfaceOf(address),
|
|
709
|
+
hostProbe,
|
|
610
710
|
// task 27: ReGa as the store of rooms and functions on a CCU. Read through the
|
|
611
|
-
// service that is current at call time - a reconnect replaces it.
|
|
612
|
-
|
|
711
|
+
// service that is current at call time - a reconnect replaces it. Off by the
|
|
712
|
+
// profile or by the host (B-62): no ReGa store either way.
|
|
713
|
+
rega: this.#rega === undefined || !this.#rega.state.enabled
|
|
613
714
|
? undefined
|
|
614
715
|
: {
|
|
615
716
|
available: this.#rega.available,
|
|
@@ -624,6 +725,7 @@ export class Backend {
|
|
|
624
725
|
onNotice: (level, message) => {
|
|
625
726
|
this.#notice(level, message);
|
|
626
727
|
},
|
|
728
|
+
fetch: this.#systemFetch(connection),
|
|
627
729
|
...this.#options.metaOptions,
|
|
628
730
|
});
|
|
629
731
|
if (this.#stopped) {
|
|
@@ -840,7 +942,8 @@ export class Backend {
|
|
|
840
942
|
return null;
|
|
841
943
|
}
|
|
842
944
|
#recordDeviceEvent(interfaceName, method, payload) {
|
|
843
|
-
|
|
945
|
+
// B-56: a device callback shows that the interface calls us, not that its events reach us
|
|
946
|
+
this.#manager?.noteEvent(interfaceName, 'device');
|
|
844
947
|
const record = { timestamp: this.#now(), interfaceName, method, payload };
|
|
845
948
|
this.#caches.events.push(record);
|
|
846
949
|
this.events.emit('rpc.event', record);
|
|
@@ -880,11 +983,21 @@ export class Backend {
|
|
|
880
983
|
// Task 38: a container whose callback servers listen on the loopback only has nothing to publish
|
|
881
984
|
const bindHost = this.#options.callbackHost ?? callbackBindHost(config.connection);
|
|
882
985
|
const publish = this.#options.inContainer === true && bindHost !== LOOPBACK_IP;
|
|
986
|
+
const idle = this.#options.idleUnsubscribeMs;
|
|
883
987
|
return {
|
|
884
988
|
...config,
|
|
885
989
|
...(ports === undefined ? {} : { callbackDefaultPorts: { xmlrpc: ports.xmlrpc, binrpc: ports.binrpc } }),
|
|
886
990
|
...(pins === undefined ? {} : { callbackPinned: { ...pins } }),
|
|
887
991
|
...(publish ? { publishCallbackPorts: true } : {}),
|
|
992
|
+
// B-70: only a host that goes idle offers the time; the default of an unpinned host is
|
|
993
|
+
// D-31's five minutes, whatever it was started with, so "the default" means one thing
|
|
994
|
+
...(idle === undefined
|
|
995
|
+
? {}
|
|
996
|
+
: {
|
|
997
|
+
idleUnsubscribe: this.#options.idleUnsubscribePinned === true
|
|
998
|
+
? { defaultMs: DEFAULT_IDLE_UNSUBSCRIBE_MS, pinnedMs: idle }
|
|
999
|
+
: { defaultMs: idle },
|
|
1000
|
+
}),
|
|
888
1001
|
};
|
|
889
1002
|
}
|
|
890
1003
|
async #setConfig(connection, options) {
|
|
@@ -1118,6 +1231,43 @@ export class Backend {
|
|
|
1118
1231
|
}
|
|
1119
1232
|
return this.#meta;
|
|
1120
1233
|
}
|
|
1234
|
+
/*
|
|
1235
|
+
* the heating groups of openccu-lite (task 57)
|
|
1236
|
+
*/
|
|
1237
|
+
/**
|
|
1238
|
+
* The client for the box's groups API, or `undefined` where there is no box. Built per call: it
|
|
1239
|
+
* is a handful of closures over the store's URL and credential, and the credential changes
|
|
1240
|
+
* when the person's session arrives (`noteMetaSession`).
|
|
1241
|
+
*/
|
|
1242
|
+
async #groupsClient() {
|
|
1243
|
+
await this.#metaReady?.catch(() => undefined);
|
|
1244
|
+
const meta = this.#meta;
|
|
1245
|
+
const baseUrl = meta?.boxUrl;
|
|
1246
|
+
if (meta === undefined || baseUrl === undefined) {
|
|
1247
|
+
return undefined;
|
|
1248
|
+
}
|
|
1249
|
+
return new HeatingGroupsClient({
|
|
1250
|
+
baseUrl,
|
|
1251
|
+
credential: () => meta.boxCredential(),
|
|
1252
|
+
// B-67: the same trust as the store's, on the https:// URL the detection found
|
|
1253
|
+
fetch: this.#systemFetch(this.#config.connection),
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/** `groups.state`: no box is an answer, not an error - it is what every CCU says. */
|
|
1257
|
+
async #groupsState() {
|
|
1258
|
+
const client = await this.#groupsClient();
|
|
1259
|
+
if (client === undefined) {
|
|
1260
|
+
return { available: false, reason: 'no-box' };
|
|
1261
|
+
}
|
|
1262
|
+
return client.probe();
|
|
1263
|
+
}
|
|
1264
|
+
async #requireGroups() {
|
|
1265
|
+
const client = await this.#groupsClient();
|
|
1266
|
+
if (client === undefined) {
|
|
1267
|
+
throw configError('heating groups are edited on openccu-lite only: this connection is not to such a system');
|
|
1268
|
+
}
|
|
1269
|
+
return client;
|
|
1270
|
+
}
|
|
1121
1271
|
/*
|
|
1122
1272
|
* paramsets
|
|
1123
1273
|
*/
|
|
@@ -1624,6 +1774,13 @@ export const API_METHOD_NAMES = [
|
|
|
1624
1774
|
'devices.replaceable',
|
|
1625
1775
|
'names.get',
|
|
1626
1776
|
'names.set',
|
|
1777
|
+
'groups.state',
|
|
1778
|
+
'groups.list',
|
|
1779
|
+
'groups.types',
|
|
1780
|
+
'groups.get',
|
|
1781
|
+
'groups.create',
|
|
1782
|
+
'groups.update',
|
|
1783
|
+
'groups.delete',
|
|
1627
1784
|
'meta.state',
|
|
1628
1785
|
'meta.get',
|
|
1629
1786
|
'meta.enums',
|
|
@@ -1637,6 +1794,7 @@ export const API_METHOD_NAMES = [
|
|
|
1637
1794
|
'meta.node.update',
|
|
1638
1795
|
'meta.node.delete',
|
|
1639
1796
|
'meta.refresh',
|
|
1797
|
+
'meta.pairing',
|
|
1640
1798
|
'meta.export',
|
|
1641
1799
|
'meta.import',
|
|
1642
1800
|
'paramset.get',
|
|
@@ -112,10 +112,51 @@ export function normaliseConnection(input) {
|
|
|
112
112
|
...(metaProvider === undefined ? {} : { metaProvider }),
|
|
113
113
|
...(metaToken === '' ? {} : { metaToken }),
|
|
114
114
|
...(metaUrl === '' ? {} : { metaUrl }),
|
|
115
|
+
// B-70: a time the profile chose, or nothing - the host's default then applies
|
|
116
|
+
...(typeof raw.idleUnsubscribeMs === 'number' &&
|
|
117
|
+
Number.isFinite(raw.idleUnsubscribeMs) &&
|
|
118
|
+
raw.idleUnsubscribeMs >= 0
|
|
119
|
+
? { idleUnsubscribeMs: Math.round(raw.idleUnsubscribeMs) }
|
|
120
|
+
: {}),
|
|
121
|
+
...systemTrustOf(raw.systemTrust),
|
|
115
122
|
};
|
|
116
123
|
const auth = normaliseAuth(raw.auth);
|
|
117
124
|
return auth === undefined ? connection : { ...connection, auth };
|
|
118
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* B-67: the certificates the profile trusts on the system. A fingerprint is 32 bytes of hex in
|
|
128
|
+
* any spelling, stored as `AB:CD:…`; a CA is kept only as a PEM certificate. Nothing else, and
|
|
129
|
+
* nothing twice - this is what decides whose certificate the app believes.
|
|
130
|
+
*/
|
|
131
|
+
function systemTrustOf(value) {
|
|
132
|
+
if (typeof value !== 'object' || value === null) {
|
|
133
|
+
return {};
|
|
134
|
+
}
|
|
135
|
+
const raw = value;
|
|
136
|
+
const certificates = [
|
|
137
|
+
...new Set((Array.isArray(raw['certificates']) ? raw['certificates'] : [])
|
|
138
|
+
.filter((entry) => typeof entry === 'string')
|
|
139
|
+
.map((entry) => entry.replace(/[^0-9a-f]/gi, '').toUpperCase())
|
|
140
|
+
.filter((hex) => hex.length === 64)
|
|
141
|
+
.map((hex) => hex.match(/.{2}/g)?.join(':') ?? '')),
|
|
142
|
+
];
|
|
143
|
+
const cas = [
|
|
144
|
+
...new Set((Array.isArray(raw['cas']) ? raw['cas'] : [])
|
|
145
|
+
.filter((entry) => typeof entry === 'string')
|
|
146
|
+
.map((entry) => entry.trim())
|
|
147
|
+
.filter((pem) => /^-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----$/.test(pem))
|
|
148
|
+
.map((pem) => `${pem}\n`)),
|
|
149
|
+
];
|
|
150
|
+
if (certificates.length === 0 && cas.length === 0) {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
systemTrust: {
|
|
155
|
+
...(certificates.length === 0 ? {} : { certificates }),
|
|
156
|
+
...(cas.length === 0 ? {} : { cas }),
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
119
160
|
function normaliseAuth(value) {
|
|
120
161
|
if (typeof value !== 'object' || value === null) {
|
|
121
162
|
return undefined;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task 57: the HTTP client for openccu-lite's heating groups - `/api/system/v1/groups`.
|
|
3
|
+
*
|
|
4
|
+
* The heating groups (the `VirtualDevices` group devices `INT000000N`) are the group process's,
|
|
5
|
+
* and that process has no RPC method to create one, change its members or delete it: the CCU's
|
|
6
|
+
* WebUI does all of that through the process's own HTTP pages, behind a WebUI session. On
|
|
7
|
+
* openccu-lite the box's `occulited` is the one client of those pages and offers them as a plain
|
|
8
|
+
* JSON API with the box's own login - the one this application already has for the metadata store
|
|
9
|
+
* (D-40). This client speaks that API and nothing else; on a CCU it is never built.
|
|
10
|
+
*
|
|
11
|
+
* Like the metadata client: `fetch` and `AbortSignal` only, a typed answer or a thrown
|
|
12
|
+
* {@link BackendError} whose `kind` says what the UI should make of it. The box's error body
|
|
13
|
+
* (`{error, message}`) is kept, because its message is the one worth showing - "there is no group
|
|
14
|
+
* 7", "members: hmipserver's device ids, as GET /groups/types lists them".
|
|
15
|
+
*/
|
|
16
|
+
import type { HeatingGroupChange, HeatingGroupDetail, HeatingGroupList, HeatingGroupMember, HeatingGroupType, HeatingGroupsState } from '@homematic-manager/core';
|
|
17
|
+
import { BackendError } from '../errors.js';
|
|
18
|
+
/**
|
|
19
|
+
* How long a call may take. A change makes the box's group process configure direct links, and the
|
|
20
|
+
* box itself waits up to thirty seconds for that process before it answers `502`; this has to
|
|
21
|
+
* outlast it, or the UI reports a timeout for a change that went through.
|
|
22
|
+
*/
|
|
23
|
+
export declare const GROUPS_TIMEOUT_MS = 45000;
|
|
24
|
+
export interface HeatingGroupsClientOptions {
|
|
25
|
+
/** `http://ccu` or `http://127.0.0.1` - scheme and authority, no path. */
|
|
26
|
+
readonly baseUrl: string;
|
|
27
|
+
/**
|
|
28
|
+
* The credential every call goes out with. Reads need the box's `system:read`, changes
|
|
29
|
+
* `system:write`: the person's session on the box, or the API token off it. Never the addon's
|
|
30
|
+
* local token by choice - it reads metadata and nothing else, and the box answers 403.
|
|
31
|
+
*/
|
|
32
|
+
readonly credential: () => string | undefined;
|
|
33
|
+
readonly timeoutMs?: number;
|
|
34
|
+
/** Injected by the tests. */
|
|
35
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
36
|
+
}
|
|
37
|
+
/** The box refused or could not answer; `status` and `code` are what it said. */
|
|
38
|
+
export declare class HeatingGroupsApiError extends BackendError {
|
|
39
|
+
readonly status: number;
|
|
40
|
+
/** The API's error word: `unknown-group`, `invalid`, `unsupported`, `hmipserver`, `forbidden`. */
|
|
41
|
+
readonly code: string;
|
|
42
|
+
constructor(status: number, code: string, message: string);
|
|
43
|
+
}
|
|
44
|
+
export declare class HeatingGroupsClient {
|
|
45
|
+
#private;
|
|
46
|
+
constructor(options: HeatingGroupsClientOptions);
|
|
47
|
+
get baseUrl(): string;
|
|
48
|
+
/**
|
|
49
|
+
* Does this box have the groups API, and may this credential read it? One `GET /groups`; the
|
|
50
|
+
* answer's status is the whole result. Never throws: the state is what the UI decides the tab by.
|
|
51
|
+
*/
|
|
52
|
+
probe(): Promise<HeatingGroupsState>;
|
|
53
|
+
/**
|
|
54
|
+
* `GET /groups`, then each group once for its members - the list itself names none, and a
|
|
55
|
+
* grid of groups without their members would say nothing. A group whose detail fails (deleted
|
|
56
|
+
* between the two calls) is listed without members rather than failing the list.
|
|
57
|
+
*/
|
|
58
|
+
list(): Promise<HeatingGroupList>;
|
|
59
|
+
/** `GET /groups/types` - what a new group can be, and what each type could take now. */
|
|
60
|
+
types(): Promise<HeatingGroupType[]>;
|
|
61
|
+
/** `GET /groups/{id}`. */
|
|
62
|
+
get(id: number): Promise<HeatingGroupDetail>;
|
|
63
|
+
/** `POST /groups` - the box's `create` then `save`, and the metadata side effects in one go. */
|
|
64
|
+
create(name: string, type: string, members: readonly string[]): Promise<HeatingGroupChange>;
|
|
65
|
+
/**
|
|
66
|
+
* `PUT /groups/{id}` - the name and the members as a whole; a field left out keeps its value.
|
|
67
|
+
* Adding and removing is this call with the new list, exactly as the WebUI saved it.
|
|
68
|
+
*/
|
|
69
|
+
update(id: number, change: {
|
|
70
|
+
readonly name?: string | undefined;
|
|
71
|
+
readonly members?: readonly string[] | undefined;
|
|
72
|
+
}): Promise<HeatingGroupChange>;
|
|
73
|
+
/** `DELETE /groups/{id}` - answers with the former members, whose group membership is gone. */
|
|
74
|
+
remove(id: number): Promise<HeatingGroupMember[]>;
|
|
75
|
+
}
|
|
76
|
+
//# sourceMappingURL=client.d.ts.map
|