homematic-manager 3.0.0-beta.31 → 3.0.0-beta.33
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/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +7 -0
- package/node_modules/@homematic-manager/backend/dist/api/backend.js +164 -45
- package/node_modules/@homematic-manager/backend/dist/groups/client.d.ts +9 -10
- package/node_modules/@homematic-manager/backend/dist/groups/client.js +41 -68
- package/node_modules/@homematic-manager/backend/dist/interfaces/lite.d.ts +105 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/lite.js +691 -0
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +35 -3
- package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +1 -1
- package/node_modules/@homematic-manager/backend/dist/meta/index.d.ts +3 -4
- package/node_modules/@homematic-manager/backend/dist/meta/index.js +3 -4
- package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.d.ts +9 -2
- package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.js +28 -17
- package/node_modules/@homematic-manager/backend/dist/meta/service.d.ts +25 -6
- package/node_modules/@homematic-manager/backend/dist/meta/service.js +56 -18
- package/node_modules/@homematic-manager/backend/dist/meta/system.d.ts +200 -0
- package/node_modules/@homematic-manager/backend/dist/meta/system.js +593 -0
- package/node_modules/@homematic-manager/backend/dist/transport/wsServer.d.ts +10 -1
- package/node_modules/@homematic-manager/backend/dist/transport/wsServer.js +48 -6
- package/node_modules/@homematic-manager/backend/package.json +2 -2
- package/node_modules/@homematic-manager/core/dist/api/types.d.ts +86 -0
- package/node_modules/@homematic-manager/core/dist/api/types.js +1 -0
- package/node_modules/@homematic-manager/core/dist/i18n/lookup.d.ts +8 -0
- package/node_modules/@homematic-manager/core/dist/i18n/lookup.js +13 -0
- package/node_modules/@homematic-manager/core/package.json +1 -1
- package/package.json +5 -4
- package/ui/assets/index-C-5bKNPO.css +1 -0
- package/ui/assets/index-DH86pPK8.js +20 -0
- package/ui/index.html +2 -2
- package/node_modules/@homematic-manager/backend/dist/meta/client.d.ts +0 -148
- package/node_modules/@homematic-manager/backend/dist/meta/client.js +0 -381
- package/node_modules/@homematic-manager/backend/dist/meta/credentials.d.ts +0 -38
- package/node_modules/@homematic-manager/backend/dist/meta/credentials.js +0 -53
- package/node_modules/@homematic-manager/backend/dist/meta/systemFetch.d.ts +0 -45
- package/node_modules/@homematic-manager/backend/dist/meta/systemFetch.js +0 -256
- package/ui/assets/index-Co1znn_z.js +0 -20
- package/ui/assets/index-Xv-mu1eX.css +0 -1
|
@@ -17,7 +17,9 @@ import { maintenanceAddress, type AppConfig, type ApiEventName, type ApiEvents,
|
|
|
17
17
|
import { type ConfigStoreOptions } from '../config/store.js';
|
|
18
18
|
import { type DiscoverOptions } from '../discovery/discover.js';
|
|
19
19
|
import { InterfaceManager, type InterfaceManagerOptions } from '../interfaces/manager.js';
|
|
20
|
+
import { LiteInterfaces, type LiteInterfacesOptions } from '../interfaces/lite.js';
|
|
20
21
|
import { type MetaServiceOptions } from '../meta/service.js';
|
|
22
|
+
import { type PairWithSystemOptions } from '../meta/system.js';
|
|
21
23
|
import { RegaService, type RegaServiceOptions } from '../rega/client.js';
|
|
22
24
|
import { ApiEventEmitter } from '../util/emitter.js';
|
|
23
25
|
import { type CallbackNetwork } from '../util/net.js';
|
|
@@ -87,6 +89,11 @@ export interface BackendOptions extends Omit<ConfigStoreOptions, 'version'> {
|
|
|
87
89
|
readonly now?: () => number;
|
|
88
90
|
/** Injected by the tests in place of the real world. */
|
|
89
91
|
readonly createInterfaceManager?: (options: InterfaceManagerOptions) => InterfaceManager;
|
|
92
|
+
/** Task 72: the same for the openccu-lite path of a remote connection. */
|
|
93
|
+
readonly createLiteInterfaces?: (options: LiteInterfacesOptions) => LiteInterfaces;
|
|
94
|
+
readonly liteOptions?: Partial<LiteInterfacesOptions>;
|
|
95
|
+
/** Task 72: occulite-client's `pair()`, replaced by the tests. */
|
|
96
|
+
readonly pair?: PairWithSystemOptions['pair'];
|
|
90
97
|
readonly createRega?: (options: RegaServiceOptions) => RegaService;
|
|
91
98
|
/** D-40: injected by the tests, and by the integration test that runs a real occulited. */
|
|
92
99
|
readonly metaOptions?: Partial<MetaServiceOptions>;
|
|
@@ -18,16 +18,16 @@ import { RPC_METHOD_NAMES, RSSI_UNKNOWN, countsAsServiceMessage, isAcknowledgeab
|
|
|
18
18
|
import { CacheStore } from '../cache/store.js';
|
|
19
19
|
import { ConfigStore } from '../config/store.js';
|
|
20
20
|
import { LinkTemplateStore } from '../config/linkTemplates.js';
|
|
21
|
-
import { validateConnection, writePaceFor } from '../config/defaults.js';
|
|
21
|
+
import { normaliseConnection, validateConnection, writePaceFor } from '../config/defaults.js';
|
|
22
22
|
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
26
|
import { DEFAULT_IDLE_UNSUBSCRIBE_MS, InterfaceManager, LOOPBACK_IP, callbackBindHost, firstBidcosInterfaceAddress, } from '../interfaces/manager.js';
|
|
27
|
+
import { LiteInterfaces } from '../interfaces/lite.js';
|
|
27
28
|
import { HeatingGroupsClient } from '../groups/client.js';
|
|
28
|
-
import { DETECT_TIMEOUT_MS, MetaApiClient } from '../meta/client.js';
|
|
29
29
|
import { MetaService, hostBaseUrl } from '../meta/service.js';
|
|
30
|
-
import {
|
|
30
|
+
import { DETECT_TIMEOUT_MS, detectSystem, pairWithSystem, testSystem, transportFor, } from '../meta/system.js';
|
|
31
31
|
import { RegaService } from '../rega/client.js';
|
|
32
32
|
import { listDevicesAnswer } from '../rpc/server.js';
|
|
33
33
|
import { ApiEventEmitter } from '../util/emitter.js';
|
|
@@ -78,7 +78,16 @@ export class Backend {
|
|
|
78
78
|
#now;
|
|
79
79
|
#methodCache = new Map();
|
|
80
80
|
#caches;
|
|
81
|
+
/**
|
|
82
|
+
* Task 72: the interface processes - through callback servers and `init` on a CCU (and on the
|
|
83
|
+
* system itself, where the addon runs), through lite-rpc on an openccu-lite system reached from
|
|
84
|
+
* off it. Everything below the API talks to the {@link Interfaces} surface and nothing else.
|
|
85
|
+
*/
|
|
81
86
|
#manager;
|
|
87
|
+
/** Task 72: counts the connects, so a connect that waited for the probe knows it is still the current one. */
|
|
88
|
+
#connectSeq = 0;
|
|
89
|
+
/** Task 72: the pairing with an openccu-lite system that is running, if any. */
|
|
90
|
+
#pairing;
|
|
82
91
|
#rega;
|
|
83
92
|
/** D-40: the metadata store of this connection - this profile's own, or an openccu-lite box. */
|
|
84
93
|
#meta;
|
|
@@ -359,6 +368,12 @@ export class Backend {
|
|
|
359
368
|
return this.#clearCaches();
|
|
360
369
|
case 'config.callbackAddresses':
|
|
361
370
|
return this.#callbackAddresses(p[0]);
|
|
371
|
+
case 'connection.test':
|
|
372
|
+
return this.#testConnection(p[0]);
|
|
373
|
+
case 'connection.pair':
|
|
374
|
+
return this.#startPairing(p[0]);
|
|
375
|
+
case 'connection.pairCancel':
|
|
376
|
+
return this.#cancelPairing();
|
|
362
377
|
case 'interfaces.list':
|
|
363
378
|
return this.#manager?.states() ?? [];
|
|
364
379
|
case 'interfaces.reconnect':
|
|
@@ -593,12 +608,34 @@ export class Backend {
|
|
|
593
608
|
}
|
|
594
609
|
async #connectNow() {
|
|
595
610
|
const connection = this.#config.connection;
|
|
611
|
+
const seq = ++this.#connectSeq;
|
|
596
612
|
this.#paramsetIds.clear();
|
|
597
613
|
this.#noServiceMessages.clear();
|
|
598
614
|
this.#serviceMessageFailures.clear();
|
|
599
615
|
this.#listedMethods.clear();
|
|
600
616
|
this.#noListMethods.clear();
|
|
601
|
-
|
|
617
|
+
// Task 72: a connection from off the system with an API token may be one to an openccu-lite
|
|
618
|
+
// system, which never calls a remote client back (its D-70): the probe decides *before* the
|
|
619
|
+
// interfaces are started, and its answer picks lite-rpc over callback servers. Only then is
|
|
620
|
+
// the probe waited for - for everybody else (the addon on the system, a profile without a
|
|
621
|
+
// token, every CCU) it runs alongside the interface start as before (D-40's rule: a host
|
|
622
|
+
// that swallows the packet holds up the names, never the interfaces).
|
|
623
|
+
const token = (connection.metaToken ?? '').trim();
|
|
624
|
+
const mayBeRemoteLite = connection.local !== true && token !== '';
|
|
625
|
+
let hostProbe;
|
|
626
|
+
let liteProbe;
|
|
627
|
+
if (mayBeRemoteLite) {
|
|
628
|
+
liteProbe = await this.#probeHost(connection);
|
|
629
|
+
if (this.#stopped || seq !== this.#connectSeq) {
|
|
630
|
+
// disconnected while the host was being asked
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
hostProbe = Promise.resolve(liteProbe);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
hostProbe = this.#probeHost(connection);
|
|
637
|
+
}
|
|
638
|
+
const shared = {
|
|
602
639
|
connection,
|
|
603
640
|
handler: this.#callbackHandler(),
|
|
604
641
|
onStateChanged: (states) => {
|
|
@@ -612,27 +649,45 @@ export class Backend {
|
|
|
612
649
|
this.#onCall(record);
|
|
613
650
|
},
|
|
614
651
|
originOf: currentOrigin,
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
: {
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
652
|
+
};
|
|
653
|
+
const manager = liteProbe?.answer !== undefined
|
|
654
|
+
? (this.#options.createLiteInterfaces ?? ((options) => new LiteInterfaces(options)))({
|
|
655
|
+
...shared,
|
|
656
|
+
baseUrl: liteProbe.baseUrl ?? hostBaseUrl(connection),
|
|
657
|
+
token,
|
|
658
|
+
transport: transportFor({
|
|
659
|
+
fetch: this.#options.metaOptions?.fetch,
|
|
660
|
+
trust: connection.systemTrust,
|
|
661
|
+
}),
|
|
662
|
+
...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
|
|
663
|
+
...(this.#options.now === undefined ? {} : { now: this.#options.now }),
|
|
664
|
+
...this.#options.liteOptions,
|
|
665
|
+
})
|
|
666
|
+
: (this.#options.createInterfaceManager ?? ((options) => new InterfaceManager(options)))({
|
|
667
|
+
...shared,
|
|
668
|
+
...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
|
|
669
|
+
...(this.#options.defaultCallbackPorts === undefined
|
|
670
|
+
? {}
|
|
671
|
+
: { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
|
|
672
|
+
...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
|
|
673
|
+
// B-69: read at every watchdog round - the cache is replaced when the host changes
|
|
674
|
+
listsDevices: (interfaceName) => this.#caches.devices.size(interfaceName) > 0,
|
|
675
|
+
...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
|
|
676
|
+
...(this.#options.watchdogIntervalMs === undefined
|
|
677
|
+
? {}
|
|
678
|
+
: { watchdogIntervalMs: this.#options.watchdogIntervalMs }),
|
|
679
|
+
...(this.#options.localAddresses === undefined
|
|
680
|
+
? {}
|
|
681
|
+
: { localAddresses: this.#options.localAddresses }),
|
|
682
|
+
...(this.#options.network === undefined ? {} : { network: this.#options.network }),
|
|
683
|
+
...(this.#keepsConfiguredCallbackIp() ? { keepConfiguredCallbackIp: true } : {}),
|
|
684
|
+
...(this.#options.now === undefined ? {} : { now: this.#options.now }),
|
|
685
|
+
...this.#options.interfaceManagerOptions,
|
|
686
|
+
});
|
|
632
687
|
this.#manager = manager;
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
688
|
+
if (liteProbe?.answer !== undefined) {
|
|
689
|
+
this.#notice('info', `${connection.host}: an openccu-lite system - the interfaces are reached through its lite-rpc with the API token, without callbacks`);
|
|
690
|
+
}
|
|
636
691
|
try {
|
|
637
692
|
await manager.start();
|
|
638
693
|
}
|
|
@@ -709,23 +764,16 @@ export class Backend {
|
|
|
709
764
|
if (baseUrl === '') {
|
|
710
765
|
return { answer: undefined };
|
|
711
766
|
}
|
|
712
|
-
const client = new MetaApiClient({ baseUrl, fetch: this.#systemFetch(connection) });
|
|
713
767
|
// B-67: the redirect to https:// is followed, and a certificate nothing trusts is said
|
|
714
|
-
const found = await
|
|
768
|
+
const found = await detectSystem(transportFor({ fetch: this.#options.metaOptions?.fetch, trust: connection.systemTrust }), baseUrl, this.#options.metaOptions?.detectTimeoutMs ?? DETECT_TIMEOUT_MS);
|
|
715
769
|
return {
|
|
716
770
|
answer: found.version,
|
|
717
771
|
baseUrl: found.baseUrl,
|
|
718
772
|
...(found.certificate === undefined ? {} : { certificate: found.certificate }),
|
|
719
773
|
};
|
|
720
774
|
}
|
|
721
|
-
/**
|
|
722
|
-
* B-67: the `fetch` for the system's own APIs - with the certificates the profile trusts. The
|
|
723
|
-
* tests inject theirs through `metaOptions.fetch`, which wins.
|
|
724
|
-
*/
|
|
725
|
-
#systemFetch(connection) {
|
|
726
|
-
return this.#options.metaOptions?.fetch ?? createSystemFetch(connection.systemTrust);
|
|
727
|
-
}
|
|
728
775
|
async #disconnect() {
|
|
776
|
+
this.#connectSeq += 1;
|
|
729
777
|
this.#clearTimers();
|
|
730
778
|
this.#queue.cancel();
|
|
731
779
|
await this.#manager?.stop();
|
|
@@ -775,7 +823,6 @@ export class Backend {
|
|
|
775
823
|
onNotice: (level, message) => {
|
|
776
824
|
this.#notice(level, message);
|
|
777
825
|
},
|
|
778
|
-
fetch: this.#systemFetch(connection),
|
|
779
826
|
...this.#options.metaOptions,
|
|
780
827
|
});
|
|
781
828
|
if (this.#stopped) {
|
|
@@ -1016,6 +1063,83 @@ export class Backend {
|
|
|
1016
1063
|
const origin = currentOrigin();
|
|
1017
1064
|
return this.#queue.enqueue(interfaceName, () => client.call(method, params, { origin }));
|
|
1018
1065
|
}
|
|
1066
|
+
/*
|
|
1067
|
+
* task 72: the connection test and the pairing with an openccu-lite system
|
|
1068
|
+
*/
|
|
1069
|
+
/** `connection.test`: what is at the draft's host, and what its token is worth there. */
|
|
1070
|
+
async #testConnection(input) {
|
|
1071
|
+
const connection = normaliseConnection(input);
|
|
1072
|
+
const baseUrl = hostBaseUrl(connection);
|
|
1073
|
+
if (baseUrl === '') {
|
|
1074
|
+
throw validationError('no CCU address configured');
|
|
1075
|
+
}
|
|
1076
|
+
return testSystem(transportFor({ fetch: this.#options.metaOptions?.fetch, trust: connection.systemTrust }), baseUrl, (connection.metaToken ?? '').trim(), this.#options.metaOptions?.detectTimeoutMs ?? DETECT_TIMEOUT_MS);
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* `connection.pair`: asks the system for a token and answers at once; the steps come as
|
|
1080
|
+
* `pairing.changed` (openccu-lite task 219: the code is shown here and on the system's Status
|
|
1081
|
+
* page, an administrator approves it there). One pairing at a time - a new one cancels the old.
|
|
1082
|
+
*/
|
|
1083
|
+
#startPairing(input) {
|
|
1084
|
+
const connection = normaliseConnection(input);
|
|
1085
|
+
const baseUrl = hostBaseUrl(connection);
|
|
1086
|
+
if (baseUrl === '') {
|
|
1087
|
+
throw validationError('no CCU address configured');
|
|
1088
|
+
}
|
|
1089
|
+
this.#cancelPairing();
|
|
1090
|
+
const controller = new AbortController();
|
|
1091
|
+
this.#pairing = controller;
|
|
1092
|
+
this.events.emit('pairing.changed', { state: 'requesting' });
|
|
1093
|
+
void this.#runPairing(connection, baseUrl, controller);
|
|
1094
|
+
return null;
|
|
1095
|
+
}
|
|
1096
|
+
async #runPairing(connection, baseUrl, controller) {
|
|
1097
|
+
const current = () => this.#pairing === controller;
|
|
1098
|
+
try {
|
|
1099
|
+
const paired = await pairWithSystem({
|
|
1100
|
+
transport: transportFor({ fetch: this.#options.metaOptions?.fetch, trust: connection.systemTrust }),
|
|
1101
|
+
baseUrl,
|
|
1102
|
+
appVersion: this.#config.config.version,
|
|
1103
|
+
detectTimeoutMs: this.#options.metaOptions?.detectTimeoutMs ?? DETECT_TIMEOUT_MS,
|
|
1104
|
+
signal: controller.signal,
|
|
1105
|
+
onCode: (code) => {
|
|
1106
|
+
if (current()) {
|
|
1107
|
+
this.events.emit('pairing.changed', { state: 'code', code });
|
|
1108
|
+
}
|
|
1109
|
+
},
|
|
1110
|
+
...(this.#options.pair === undefined ? {} : { pair: this.#options.pair }),
|
|
1111
|
+
});
|
|
1112
|
+
if (!current()) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
this.#pairing = undefined;
|
|
1116
|
+
this.events.emit('pairing.changed', {
|
|
1117
|
+
state: 'approved',
|
|
1118
|
+
token: paired.token,
|
|
1119
|
+
fingerprint256: paired.fingerprint256,
|
|
1120
|
+
scopes: paired.scopes,
|
|
1121
|
+
name: paired.name,
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
if (!current()) {
|
|
1126
|
+
// cancelled, or replaced by a newer one: that one said so
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
this.#pairing = undefined;
|
|
1130
|
+
this.events.emit('pairing.changed', { state: 'failed', message: errorMessage(error) });
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/** `connection.pairCancel`: withdraws the request, on the system too (the client does that). */
|
|
1134
|
+
#cancelPairing() {
|
|
1135
|
+
const controller = this.#pairing;
|
|
1136
|
+
if (controller !== undefined) {
|
|
1137
|
+
this.#pairing = undefined;
|
|
1138
|
+
controller.abort();
|
|
1139
|
+
this.events.emit('pairing.changed', { state: 'cancelled' });
|
|
1140
|
+
}
|
|
1141
|
+
return null;
|
|
1142
|
+
}
|
|
1019
1143
|
/*
|
|
1020
1144
|
* configuration
|
|
1021
1145
|
*/
|
|
@@ -1342,17 +1466,9 @@ export class Backend {
|
|
|
1342
1466
|
*/
|
|
1343
1467
|
async #groupsClient() {
|
|
1344
1468
|
await this.#metaReady?.catch(() => undefined);
|
|
1345
|
-
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
return undefined;
|
|
1349
|
-
}
|
|
1350
|
-
return new HeatingGroupsClient({
|
|
1351
|
-
baseUrl,
|
|
1352
|
-
credential: () => meta.boxCredential(),
|
|
1353
|
-
// B-67: the same trust as the store's, on the https:// URL the detection found
|
|
1354
|
-
fetch: this.#systemFetch(this.#config.connection),
|
|
1355
|
-
});
|
|
1469
|
+
// B-67: the same transport and trust as the store's, on the https:// URL the detection found
|
|
1470
|
+
const system = this.#meta?.boxLink();
|
|
1471
|
+
return system === undefined ? undefined : new HeatingGroupsClient({ system });
|
|
1356
1472
|
}
|
|
1357
1473
|
/** `groups.state`: no box is an answer, not an error - it is what every CCU says. */
|
|
1358
1474
|
async #groupsState() {
|
|
@@ -1969,6 +2085,9 @@ export const API_METHOD_NAMES = [
|
|
|
1969
2085
|
'config.discover',
|
|
1970
2086
|
'config.clearCaches',
|
|
1971
2087
|
'config.callbackAddresses',
|
|
2088
|
+
'connection.test',
|
|
2089
|
+
'connection.pair',
|
|
2090
|
+
'connection.pairCancel',
|
|
1972
2091
|
'interfaces.list',
|
|
1973
2092
|
'interfaces.reconnect',
|
|
1974
2093
|
'rega.state',
|
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
* JSON API with the box's own login - the one this application already has for the metadata store
|
|
9
9
|
* (D-40). This client speaks that API and nothing else; on a CCU it is never built.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The requests are occulite-client's `system.groups` (task 72), on the same transport and with the
|
|
12
|
+
* same certificate trust as the metadata store; what stays here is the contract's shape and a
|
|
13
|
+
* thrown {@link BackendError} whose `kind` says what the UI should make of it. The box's error body
|
|
13
14
|
* (`{error, message}`) is kept, because its message is the one worth showing - "there is no group
|
|
14
15
|
* 7", "members: hmipserver's device ids, as GET /groups/types lists them".
|
|
15
16
|
*/
|
|
16
17
|
import type { HeatingGroupChange, HeatingGroupDetail, HeatingGroupList, HeatingGroupMember, HeatingGroupType, HeatingGroupsState } from '@homematic-manager/core';
|
|
17
18
|
import { BackendError } from '../errors.js';
|
|
19
|
+
import type { SystemLink } from '../meta/system.js';
|
|
18
20
|
/**
|
|
19
21
|
* How long a call may take. A change makes the box's group process configure direct links, and the
|
|
20
22
|
* box itself waits up to thirty seconds for that process before it answers `502`; this has to
|
|
@@ -22,17 +24,14 @@ import { BackendError } from '../errors.js';
|
|
|
22
24
|
*/
|
|
23
25
|
export declare const GROUPS_TIMEOUT_MS = 45000;
|
|
24
26
|
export interface HeatingGroupsClientOptions {
|
|
25
|
-
/** `http://ccu` or `http://127.0.0.1` - scheme and authority, no path. */
|
|
26
|
-
readonly baseUrl: string;
|
|
27
27
|
/**
|
|
28
|
-
* The credential every call goes out with. Reads need the
|
|
29
|
-
* `system:write`: the person's session on the box, or the API token
|
|
30
|
-
* local token by choice - it reads metadata and nothing else, and the
|
|
28
|
+
* The box, with the credential every call goes out with (its write credential). Reads need the
|
|
29
|
+
* box's `system:read`, changes `system:write`: the person's session on the box, or the API token
|
|
30
|
+
* off it. Never the addon's local token by choice - it reads metadata and nothing else, and the
|
|
31
|
+
* box answers 403.
|
|
31
32
|
*/
|
|
32
|
-
readonly
|
|
33
|
+
readonly system: SystemLink;
|
|
33
34
|
readonly timeoutMs?: number;
|
|
34
|
-
/** Injected by the tests. */
|
|
35
|
-
readonly fetch?: typeof globalThis.fetch;
|
|
36
35
|
}
|
|
37
36
|
/** The box refused or could not answer; `status` and `code` are what it said. */
|
|
38
37
|
export declare class HeatingGroupsApiError extends BackendError {
|
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
* JSON API with the box's own login - the one this application already has for the metadata store
|
|
9
9
|
* (D-40). This client speaks that API and nothing else; on a CCU it is never built.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* The requests are occulite-client's `system.groups` (task 72), on the same transport and with the
|
|
12
|
+
* same certificate trust as the metadata store; what stays here is the contract's shape and a
|
|
13
|
+
* thrown {@link BackendError} whose `kind` says what the UI should make of it. The box's error body
|
|
13
14
|
* (`{error, message}`) is kept, because its message is the one worth showing - "there is no group
|
|
14
15
|
* 7", "members: hmipserver's device ids, as GET /groups/types lists them".
|
|
15
16
|
*/
|
|
17
|
+
import { OccuLiteError } from 'occulite-client';
|
|
16
18
|
import { BackendError } from '../errors.js';
|
|
17
19
|
/**
|
|
18
20
|
* How long a call may take. A change makes the box's group process configure direct links, and the
|
|
@@ -51,13 +53,11 @@ function kindOf(status, code) {
|
|
|
51
53
|
}
|
|
52
54
|
export class HeatingGroupsClient {
|
|
53
55
|
#options;
|
|
54
|
-
#fetch;
|
|
55
56
|
constructor(options) {
|
|
56
57
|
this.#options = options;
|
|
57
|
-
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
58
58
|
}
|
|
59
59
|
get baseUrl() {
|
|
60
|
-
return this.#options.baseUrl;
|
|
60
|
+
return this.#options.system.baseUrl;
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
63
|
* Does this box have the groups API, and may this credential read it? One `GET /groups`; the
|
|
@@ -65,7 +65,7 @@ export class HeatingGroupsClient {
|
|
|
65
65
|
*/
|
|
66
66
|
async probe() {
|
|
67
67
|
try {
|
|
68
|
-
await this.#
|
|
68
|
+
await this.#call((groups, options) => groups.list(options));
|
|
69
69
|
return { available: true };
|
|
70
70
|
}
|
|
71
71
|
catch (error) {
|
|
@@ -90,8 +90,8 @@ export class HeatingGroupsClient {
|
|
|
90
90
|
* between the two calls) is listed without members rather than failing the list.
|
|
91
91
|
*/
|
|
92
92
|
async list() {
|
|
93
|
-
const answer = (await this.#
|
|
94
|
-
const groups = await Promise.all((answer
|
|
93
|
+
const answer = (await this.#call((groups, options) => groups.list(options)));
|
|
94
|
+
const groups = await Promise.all((answer?.groups ?? []).map(async (wire) => {
|
|
95
95
|
let members = [];
|
|
96
96
|
try {
|
|
97
97
|
members = (await this.get(wire.id)).members;
|
|
@@ -101,12 +101,12 @@ export class HeatingGroupsClient {
|
|
|
101
101
|
}
|
|
102
102
|
return { ...toGroup(wire), members };
|
|
103
103
|
}));
|
|
104
|
-
return { groups, devicesToConfigure: answer
|
|
104
|
+
return { groups, devicesToConfigure: answer?.devices_to_configure ?? [] };
|
|
105
105
|
}
|
|
106
106
|
/** `GET /groups/types` - what a new group can be, and what each type could take now. */
|
|
107
107
|
async types() {
|
|
108
|
-
const
|
|
109
|
-
return (
|
|
108
|
+
const types = (await this.#call((groups, options) => groups.types(options)));
|
|
109
|
+
return (types ?? []).map((type) => ({
|
|
110
110
|
id: type.id ?? '',
|
|
111
111
|
label: type.label ?? type.id ?? '',
|
|
112
112
|
assignable: type.assignable ?? [],
|
|
@@ -115,11 +115,11 @@ export class HeatingGroupsClient {
|
|
|
115
115
|
}
|
|
116
116
|
/** `GET /groups/{id}`. */
|
|
117
117
|
async get(id) {
|
|
118
|
-
return toDetail(
|
|
118
|
+
return toDetail(await this.#call((groups, options) => groups.get(id, options)));
|
|
119
119
|
}
|
|
120
120
|
/** `POST /groups` - the box's `create` then `save`, and the metadata side effects in one go. */
|
|
121
121
|
async create(name, type, members) {
|
|
122
|
-
return toChange(
|
|
122
|
+
return toChange(await this.#call((groups, options) => groups.create({ name, type, members: [...members] }, options)));
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
125
|
* `PUT /groups/{id}` - the name and the members as a whole; a field left out keeps its value.
|
|
@@ -130,49 +130,22 @@ export class HeatingGroupsClient {
|
|
|
130
130
|
...(change.name === undefined ? {} : { name: change.name }),
|
|
131
131
|
...(change.members === undefined ? {} : { members: [...change.members] }),
|
|
132
132
|
};
|
|
133
|
-
return toChange(
|
|
133
|
+
return toChange(await this.#call((groups, options) => groups.update(id, body, options)));
|
|
134
134
|
}
|
|
135
135
|
/** `DELETE /groups/{id}` - answers with the former members, whose group membership is gone. */
|
|
136
136
|
async remove(id) {
|
|
137
|
-
const answer = (await this.#
|
|
138
|
-
return answer
|
|
137
|
+
const answer = (await this.#call((groups, options) => groups.remove(id, options)));
|
|
138
|
+
return answer?.former_members ?? [];
|
|
139
139
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
async #json(method, route, body) {
|
|
144
|
-
const credential = this.#options.credential();
|
|
145
|
-
let response;
|
|
140
|
+
/** One call of the package's `system.groups`, its errors turned into the contract's. */
|
|
141
|
+
async #call(run) {
|
|
146
142
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
signal: AbortSignal.timeout(this.#options.timeoutMs ?? GROUPS_TIMEOUT_MS),
|
|
150
|
-
headers: {
|
|
151
|
-
Accept: 'application/json',
|
|
152
|
-
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
153
|
-
...(credential === undefined ? {} : { Authorization: `Bearer ${credential}` }),
|
|
154
|
-
},
|
|
155
|
-
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
143
|
+
return await run(this.#options.system.client('write').system.groups, {
|
|
144
|
+
timeout: this.#options.timeoutMs ?? GROUPS_TIMEOUT_MS,
|
|
156
145
|
});
|
|
157
146
|
}
|
|
158
147
|
catch (error) {
|
|
159
|
-
|
|
160
|
-
throw new BackendError({
|
|
161
|
-
message: `the groups API at ${this.#options.baseUrl} did not answer: ${errorText(error)}`,
|
|
162
|
-
kind: 'connection',
|
|
163
|
-
}, { cause: error });
|
|
164
|
-
}
|
|
165
|
-
if (!response.ok) {
|
|
166
|
-
throw await refusal(response);
|
|
167
|
-
}
|
|
168
|
-
if (response.status === 204) {
|
|
169
|
-
return {};
|
|
170
|
-
}
|
|
171
|
-
try {
|
|
172
|
-
return await response.json();
|
|
173
|
-
}
|
|
174
|
-
catch (error) {
|
|
175
|
-
throw new BackendError({ message: `the groups API answered ${String(response.status)} without JSON`, kind: 'internal' }, { cause: error });
|
|
148
|
+
throw refusal(error, this.baseUrl);
|
|
176
149
|
}
|
|
177
150
|
}
|
|
178
151
|
}
|
|
@@ -206,26 +179,26 @@ function toDetail(wire) {
|
|
|
206
179
|
function toChange(wire) {
|
|
207
180
|
return { ...toDetail(wire), devicesToConfigure: wire.devices_to_configure ?? [] };
|
|
208
181
|
}
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
message = `the system refused the credential for the heating groups (${String(
|
|
227
|
-
}
|
|
228
|
-
return new HeatingGroupsApiError(
|
|
182
|
+
/**
|
|
183
|
+
* The API's `{error, message}` answer as the error; a box that did not answer (off, the change
|
|
184
|
+
* outlasted the timeout above, a certificate nobody trusts) as a connection error.
|
|
185
|
+
*/
|
|
186
|
+
function refusal(error, baseUrl) {
|
|
187
|
+
if (!(error instanceof OccuLiteError) || error.status === undefined) {
|
|
188
|
+
return new BackendError({ message: `the groups API at ${baseUrl} did not answer: ${errorText(error)}`, kind: 'connection' }, { cause: error });
|
|
189
|
+
}
|
|
190
|
+
const status = error.status;
|
|
191
|
+
const detail = error.detail;
|
|
192
|
+
let message = typeof detail?.message === 'string' ? detail.message : error.message;
|
|
193
|
+
const code = error.code.startsWith('http-')
|
|
194
|
+
? status === 401 || status === 403
|
|
195
|
+
? 'forbidden'
|
|
196
|
+
: 'error'
|
|
197
|
+
: error.code;
|
|
198
|
+
if (status === 401 || status === 403) {
|
|
199
|
+
message = `the system refused the credential for the heating groups (${String(status)}): ${message}`;
|
|
200
|
+
}
|
|
201
|
+
return new HeatingGroupsApiError(status, code, message);
|
|
229
202
|
}
|
|
230
203
|
function errorText(error) {
|
|
231
204
|
return error instanceof Error ? error.message : String(error);
|