homematic-manager 3.0.0-beta.32 → 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.
@@ -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,15 +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
29
  import { MetaService, hostBaseUrl } from '../meta/service.js';
29
- import { DETECT_TIMEOUT_MS, detectSystem, transportFor } from '../meta/system.js';
30
+ import { DETECT_TIMEOUT_MS, detectSystem, pairWithSystem, testSystem, transportFor, } from '../meta/system.js';
30
31
  import { RegaService } from '../rega/client.js';
31
32
  import { listDevicesAnswer } from '../rpc/server.js';
32
33
  import { ApiEventEmitter } from '../util/emitter.js';
@@ -77,7 +78,16 @@ export class Backend {
77
78
  #now;
78
79
  #methodCache = new Map();
79
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
+ */
80
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;
81
91
  #rega;
82
92
  /** D-40: the metadata store of this connection - this profile's own, or an openccu-lite box. */
83
93
  #meta;
@@ -358,6 +368,12 @@ export class Backend {
358
368
  return this.#clearCaches();
359
369
  case 'config.callbackAddresses':
360
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();
361
377
  case 'interfaces.list':
362
378
  return this.#manager?.states() ?? [];
363
379
  case 'interfaces.reconnect':
@@ -592,12 +608,34 @@ export class Backend {
592
608
  }
593
609
  async #connectNow() {
594
610
  const connection = this.#config.connection;
611
+ const seq = ++this.#connectSeq;
595
612
  this.#paramsetIds.clear();
596
613
  this.#noServiceMessages.clear();
597
614
  this.#serviceMessageFailures.clear();
598
615
  this.#listedMethods.clear();
599
616
  this.#noListMethods.clear();
600
- 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 = {
601
639
  connection,
602
640
  handler: this.#callbackHandler(),
603
641
  onStateChanged: (states) => {
@@ -611,27 +649,45 @@ export class Backend {
611
649
  this.#onCall(record);
612
650
  },
613
651
  originOf: currentOrigin,
614
- ...(this.#options.callbackHost === undefined ? {} : { callbackHost: this.#options.callbackHost }),
615
- ...(this.#options.defaultCallbackPorts === undefined
616
- ? {}
617
- : { defaultCallbackPorts: this.#options.defaultCallbackPorts }),
618
- ...(this.#config.callbackPins === undefined ? {} : { callbackPins: this.#config.callbackPins }),
619
- // B-69: read at every watchdog round - the cache is replaced when the host changes
620
- listsDevices: (interfaceName) => this.#caches.devices.size(interfaceName) > 0,
621
- ...(this.#options.rpcTimeoutMs === undefined ? {} : { rpcTimeoutMs: this.#options.rpcTimeoutMs }),
622
- ...(this.#options.watchdogIntervalMs === undefined
623
- ? {}
624
- : { watchdogIntervalMs: this.#options.watchdogIntervalMs }),
625
- ...(this.#options.localAddresses === undefined ? {} : { localAddresses: this.#options.localAddresses }),
626
- ...(this.#options.network === undefined ? {} : { network: this.#options.network }),
627
- ...(this.#keepsConfiguredCallbackIp() ? { keepConfiguredCallbackIp: true } : {}),
628
- ...(this.#options.now === undefined ? {} : { now: this.#options.now }),
629
- ...this.#options.interfaceManagerOptions,
630
- });
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
+ });
631
687
  this.#manager = manager;
632
- // B-62: is the host an openccu-lite system? Asked alongside the interface start, so that a
633
- // host which swallows the packet holds up the names, never the interfaces (D-40's rule).
634
- 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
+ }
635
691
  try {
636
692
  await manager.start();
637
693
  }
@@ -717,6 +773,7 @@ export class Backend {
717
773
  };
718
774
  }
719
775
  async #disconnect() {
776
+ this.#connectSeq += 1;
720
777
  this.#clearTimers();
721
778
  this.#queue.cancel();
722
779
  await this.#manager?.stop();
@@ -1006,6 +1063,83 @@ export class Backend {
1006
1063
  const origin = currentOrigin();
1007
1064
  return this.#queue.enqueue(interfaceName, () => client.call(method, params, { origin }));
1008
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
+ }
1009
1143
  /*
1010
1144
  * configuration
1011
1145
  */
@@ -1951,6 +2085,9 @@ export const API_METHOD_NAMES = [
1951
2085
  'config.discover',
1952
2086
  'config.clearCaches',
1953
2087
  'config.callbackAddresses',
2088
+ 'connection.test',
2089
+ 'connection.pair',
2090
+ 'connection.pairCancel',
1954
2091
  'interfaces.list',
1955
2092
  'interfaces.reconnect',
1956
2093
  'rega.state',
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Task 72: the interfaces of an openccu-lite system, reached from off the system.
3
+ *
4
+ * A CCU calls its clients back, so `manager.ts` runs callback servers and subscribes to every
5
+ * interface process with `init`. openccu-lite does not call a remote client at all (its D-70): the
6
+ * requests go to the system's web port - lite-rpc's JSON-RPC path, one per interface, with the
7
+ * profile's API token - and the events come from a stream the client opens itself. occulite-client
8
+ * owns that stream and its reliability: reconnect with backoff, resume by the last event id, a
9
+ * `resync` after a gap or another boot of the system. What is left here is this application's:
10
+ *
11
+ * - the {@link Interfaces} surface the backend talks to, so the write queue, the RPC log and every
12
+ * `ApiMethods` handler are the same code on both paths;
13
+ * - the stream's events handed to the very same {@link CallbackHandler} the callback servers feed,
14
+ * so the grids, the service messages and the event log do not know where an event came from;
15
+ * - the state per interface as the popup shows it, from the system's interface list and the
16
+ * client's status;
17
+ * - the errors as the backend knows them: a fault of the interface process stays a fault, a
18
+ * scope the token lacks is said as such and never taken for a lost connection (task 78's tiers).
19
+ *
20
+ * The CCU path is untouched: on the system itself the addon keeps its loopback callbacks
21
+ * (maintainer, 2026-09-27: "remote only"), and a CCU never gets here at all.
22
+ */
23
+ import { OccuLite, type Options as OccuLiteOptions, type Transport } from 'occulite-client';
24
+ import { type ConnectionConfig, type InterfaceState, type ResolvedInterface, type RpcOrigin, type RpcValue } from '@homematic-manager/core';
25
+ import type { RpcCallRecord, RpcOutValue } from '../rpc/client.js';
26
+ import type { CallbackHandler } from '../rpc/server.js';
27
+ import type { InterfaceLink, Interfaces } from './manager.js';
28
+ /**
29
+ * The waits between two attempts to reach a system that does not answer: quick at first (it may be
30
+ * booting), then a minute. A remote system that is off for the night is not asked every two seconds.
31
+ */
32
+ export declare const LITE_RETRY_DELAYS_MS: readonly number[];
33
+ /** How long one request may take; the same as the CCU path's clients. */
34
+ export declare const LITE_RPC_TIMEOUT_MS = 20000;
35
+ /** How long the first picture - the version, the credential, the interfaces, the values - may take. */
36
+ export declare const LITE_CONNECT_TIMEOUT_MS = 30000;
37
+ export interface LiteInterfacesOptions {
38
+ readonly connection: ConnectionConfig;
39
+ /** Where the system answered the detection: `https://…` when it redirected there (B-67). */
40
+ readonly baseUrl: string;
41
+ /** The profile's API token (`metaToken`); paired, or made on the system's API tokens page. */
42
+ readonly token: string;
43
+ /** The transport with the profile's certificate trust (`systemTransport`). */
44
+ readonly transport: Transport;
45
+ /** What the stream's events become - the same handler the callback servers feed. */
46
+ readonly handler: CallbackHandler;
47
+ readonly onStateChanged: (states: InterfaceState[]) => void;
48
+ readonly onNotice: (level: 'debug' | 'info' | 'warn' | 'error', message: string, interfaceName?: string) => void;
49
+ /** Called once an interface is reachable - the backend fills its caches there. */
50
+ readonly onConnected?: (interfaceName: string) => void | Promise<void>;
51
+ readonly onCall?: (record: RpcCallRecord) => void;
52
+ /** Task 48: the origin of a call that names none (the backend's request context). */
53
+ readonly originOf?: () => RpcOrigin;
54
+ readonly now?: () => number;
55
+ readonly rpcTimeoutMs?: number;
56
+ readonly connectTimeoutMs?: number;
57
+ /** The retry schedule after a system that does not answer; {@link LITE_RETRY_DELAYS_MS}. */
58
+ readonly retryDelaysMs?: readonly number[];
59
+ /** Injected by the tests. */
60
+ readonly createClient?: (options: OccuLiteOptions) => OccuLite;
61
+ }
62
+ /**
63
+ * A value as lite-rpc's JSON path takes it. The core writes a `FLOAT` as `{explicitDouble: n}` for
64
+ * the XML-RPC and BIN-RPC encoders; the JSON path types a double as `{"double": n}` where the system
65
+ * says it understands that (`json_double`, openccu-lite task 196's S2), and takes the plain number
66
+ * everywhere else - a fraction is a double either way, only a whole number needs the hint.
67
+ */
68
+ export declare function toWire(value: RpcOutValue, typedDouble: boolean): unknown;
69
+ /**
70
+ * An answer of the JSON path as an `RpcValue`: JSON has `null` where XML-RPC has an empty string
71
+ * (a method without a result), and nothing else the contract does not know.
72
+ */
73
+ export declare function fromWire(value: unknown): RpcValue;
74
+ /** The interfaces of an openccu-lite system, over occulite-client. */
75
+ export declare class LiteInterfaces implements Interfaces {
76
+ #private;
77
+ constructor(options: LiteInterfacesOptions);
78
+ get idle(): boolean;
79
+ /** The client in use, for a test and for the backend's values (task 68). */
80
+ get box(): OccuLite | undefined;
81
+ states(): InterfaceState[];
82
+ client(interfaceName: string): InterfaceLink;
83
+ isConnected(interfaceName: string): boolean;
84
+ names(): string[];
85
+ resolved(interfaceName: string): ResolvedInterface | undefined;
86
+ /**
87
+ * Builds the entries and opens the client. Resolves once the first attempt is through - a
88
+ * system that does not answer leaves an error in every state and is tried again; it does not
89
+ * throw.
90
+ */
91
+ start(): Promise<void>;
92
+ /** D-31: closes the stream and forgets nothing; {@link subscribe} opens it again. */
93
+ unsubscribe(): Promise<void>;
94
+ subscribe(): Promise<void>;
95
+ /**
96
+ * `interfaces.reconnect`: the user saying "try now". A client that is not up is opened again at
97
+ * once (a token pasted after a refusal, a system that came back); one that is up re-reads the
98
+ * interface's devices, which is all a subscription-less path has to redo.
99
+ */
100
+ reconnect(interfaceName?: string): Promise<void>;
101
+ /** Closes the stream; nothing is de-registered, because nothing was registered. */
102
+ stop(): Promise<void>;
103
+ noteEvent(interfaceName: string, kind?: 'event' | 'device'): void;
104
+ }
105
+ //# sourceMappingURL=lite.d.ts.map