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.
Files changed (36) hide show
  1. package/node_modules/@homematic-manager/backend/dist/api/backend.d.ts +7 -0
  2. package/node_modules/@homematic-manager/backend/dist/api/backend.js +164 -45
  3. package/node_modules/@homematic-manager/backend/dist/groups/client.d.ts +9 -10
  4. package/node_modules/@homematic-manager/backend/dist/groups/client.js +41 -68
  5. package/node_modules/@homematic-manager/backend/dist/interfaces/lite.d.ts +105 -0
  6. package/node_modules/@homematic-manager/backend/dist/interfaces/lite.js +691 -0
  7. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.d.ts +35 -3
  8. package/node_modules/@homematic-manager/backend/dist/interfaces/manager.js +1 -1
  9. package/node_modules/@homematic-manager/backend/dist/meta/index.d.ts +3 -4
  10. package/node_modules/@homematic-manager/backend/dist/meta/index.js +3 -4
  11. package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.d.ts +9 -2
  12. package/node_modules/@homematic-manager/backend/dist/meta/occuliteProvider.js +28 -17
  13. package/node_modules/@homematic-manager/backend/dist/meta/service.d.ts +25 -6
  14. package/node_modules/@homematic-manager/backend/dist/meta/service.js +56 -18
  15. package/node_modules/@homematic-manager/backend/dist/meta/system.d.ts +200 -0
  16. package/node_modules/@homematic-manager/backend/dist/meta/system.js +593 -0
  17. package/node_modules/@homematic-manager/backend/dist/transport/wsServer.d.ts +10 -1
  18. package/node_modules/@homematic-manager/backend/dist/transport/wsServer.js +48 -6
  19. package/node_modules/@homematic-manager/backend/package.json +2 -2
  20. package/node_modules/@homematic-manager/core/dist/api/types.d.ts +86 -0
  21. package/node_modules/@homematic-manager/core/dist/api/types.js +1 -0
  22. package/node_modules/@homematic-manager/core/dist/i18n/lookup.d.ts +8 -0
  23. package/node_modules/@homematic-manager/core/dist/i18n/lookup.js +13 -0
  24. package/node_modules/@homematic-manager/core/package.json +1 -1
  25. package/package.json +5 -4
  26. package/ui/assets/index-C-5bKNPO.css +1 -0
  27. package/ui/assets/index-DH86pPK8.js +20 -0
  28. package/ui/index.html +2 -2
  29. package/node_modules/@homematic-manager/backend/dist/meta/client.d.ts +0 -148
  30. package/node_modules/@homematic-manager/backend/dist/meta/client.js +0 -381
  31. package/node_modules/@homematic-manager/backend/dist/meta/credentials.d.ts +0 -38
  32. package/node_modules/@homematic-manager/backend/dist/meta/credentials.js +0 -53
  33. package/node_modules/@homematic-manager/backend/dist/meta/systemFetch.d.ts +0 -45
  34. package/node_modules/@homematic-manager/backend/dist/meta/systemFetch.js +0 -256
  35. package/ui/assets/index-Co1znn_z.js +0 -20
  36. 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 { createSystemFetch } from '../meta/systemFetch.js';
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
- const manager = (this.#options.createInterfaceManager ?? ((options) => new InterfaceManager(options)))({
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
- ...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
616
- ...(this.#options.defaultCallbackPorts === undefined
617
- ? {}
618
- : { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
619
- ...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
620
- // B-69: read at every watchdog round - the cache is replaced when the host changes
621
- listsDevices: (interfaceName) => this.#caches.devices.size(interfaceName) > 0,
622
- ...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
623
- ...(this.#options.watchdogIntervalMs === undefined
624
- ? {}
625
- : { watchdogIntervalMs: this.#options.watchdogIntervalMs }),
626
- ...(this.#options.localAddresses === undefined ? {} : { localAddresses: this.#options.localAddresses }),
627
- ...(this.#options.network === undefined ? {} : { network: this.#options.network }),
628
- ...(this.#keepsConfiguredCallbackIp() ? { keepConfiguredCallbackIp: true } : {}),
629
- ...(this.#options.now === undefined ? {} : { now: this.#options.now }),
630
- ...this.#options.interfaceManagerOptions,
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
- // B-62: is the host an openccu-lite system? Asked alongside the interface start, so that a
634
- // host which swallows the packet holds up the names, never the interfaces (D-40's rule).
635
- const hostProbe = this.#probeHost(connection);
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 client.detect(this.#options.metaOptions?.detectTimeoutMs ?? DETECT_TIMEOUT_MS);
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
- const meta = this.#meta;
1346
- const baseUrl = meta?.boxUrl;
1347
- if (meta === undefined || baseUrl === undefined) {
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
- * 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
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 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.
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 credential: () => string | undefined;
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
- * 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
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.#json('GET', '/groups');
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.#json('GET', '/groups'));
94
- const groups = await Promise.all((answer.groups ?? []).map(async (wire) => {
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.devices_to_configure ?? [] };
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 answer = (await this.#json('GET', '/groups/types'));
109
- return (answer.types ?? []).map((type) => ({
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((await this.#json('GET', `/groups/${String(id)}`)));
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((await this.#json('POST', '/groups', { name, type, members: [...members] })));
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((await this.#json('PUT', `/groups/${String(id)}`, body)));
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.#json('DELETE', `/groups/${String(id)}`));
138
- return answer.former_members ?? [];
137
+ const answer = (await this.#call((groups, options) => groups.remove(id, options)));
138
+ return answer?.former_members ?? [];
139
139
  }
140
- #url(route) {
141
- return `${this.#options.baseUrl.replace(/\/$/, '')}/api/system/v1${route}`;
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
- response = await this.#fetch(this.#url(route), {
148
- method,
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
- // the box is off, or the change outlasted the timeout above
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
- /** The API's `{error, message}` body as the error, or the status when there is none. */
210
- async function refusal(response) {
211
- let code = response.status === 401 || response.status === 403 ? 'forbidden' : 'error';
212
- let message = `${String(response.status)} ${response.statusText}`.trim();
213
- try {
214
- const body = (await response.json());
215
- if (typeof body.error === 'string') {
216
- code = body.error;
217
- }
218
- if (typeof body.message === 'string') {
219
- message = body.message;
220
- }
221
- }
222
- catch {
223
- // not JSON: lighttpd's own error page, or a box that is not openccu-lite at all
224
- }
225
- if (response.status === 401 || response.status === 403) {
226
- message = `the system refused the credential for the heating groups (${String(response.status)}): ${message}`;
227
- }
228
- return new HeatingGroupsApiError(response.status, code, message);
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);