@camstack/addon-provider-bosch 0.1.3 → 0.1.4

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.
@@ -1,5 +1,387 @@
1
- import { A as object, E as _enum, F as EventCategory, M as string, O as literal, _ as waterHeaterCapability, b as errMsg, c as deviceAdoptionCapability, n as BaseDeviceProvider, o as climateControlCapability, s as connectionTestCapability, t as BaseDevice, y as DeviceType } from "../dist-BiZi19Lw.mjs";
2
- import { n as cascadeRemoveDevicesForIntegration, t as setIntegrationDevicesOnline } from "../account-integration-offline-BfoLYzxz.mjs";
1
+ import { A as object, C as defineCustomActions, E as _enum, F as EventCategory, M as string, O as literal, S as customAction, T as isSecretMarkerLike, _ as waterHeaterCapability, b as errMsg, c as deviceAdoptionCapability, n as BaseDeviceProvider, o as climateControlCapability, s as connectionTestCapability, t as BaseDevice, w as isSecretMarker, y as DeviceType } from "../dist-LJMt_ZXW.mjs";
2
+ import { n as cascadeRemoveDevicesForIntegration, r as parseOAuthCallback, t as setIntegrationDevicesOnline } from "../account-integration-offline-DKdrL0V9.mjs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ //#region src/homecom/custom-actions.ts
5
+ var homeComActions = defineCustomActions({
6
+ beginSignIn: customAction(object({}), object({
7
+ authorizeUrl: string().min(1),
8
+ verifier: string().min(1),
9
+ state: string().min(1),
10
+ summary: string()
11
+ }), {
12
+ kind: "mutation",
13
+ auth: "admin"
14
+ }),
15
+ finishSignIn: customAction(object({
16
+ verifier: string().min(1),
17
+ callback: string().min(1),
18
+ state: string().min(1)
19
+ }), object({
20
+ token: string().min(1).meta({ secret: true }),
21
+ refresh: string().min(1).meta({ secret: true }),
22
+ username: string().meta({ secret: true }),
23
+ summary: string()
24
+ }), {
25
+ kind: "mutation",
26
+ auth: "admin"
27
+ })
28
+ });
29
+ //#endregion
30
+ //#region src/homecom/homecom-oauth.ts
31
+ /**
32
+ * Public OAuth client of the HomeCom Easy / PointT mobile app, signed in through SingleKey ID.
33
+ *
34
+ * These are the PUBLIC values the app itself ships (a PKCE client has no secret). They are not
35
+ * derivable from this repository; they come from the community PointT client (`homecom_alt`) and
36
+ * have to be confirmed against a live account — see docs/addons/addon-provider-bosch-homecom.md.
37
+ */
38
+ var HOMECOM_CLIENT_ID = "762162C0-FA2D-4540-AE66-6489F189FADC";
39
+ var HOMECOM_REDIRECT_URI = "com.bosch.tt.dashtt.pointt://app/login";
40
+ var HOMECOM_AUTHORIZE_URL = "https://singlekey-id.com/auth/connect/authorize";
41
+ var HOMECOM_TOKEN_URL = "https://singlekey-id.com/auth/connect/token";
42
+ var HOMECOM_SCOPE = [
43
+ "openid",
44
+ "email",
45
+ "profile",
46
+ "offline_access",
47
+ "pointt.gateway.claiming",
48
+ "pointt.gateway.removal",
49
+ "pointt.gateway.list",
50
+ "pointt.gateway.users",
51
+ "pointt.gateway.resource.dopair",
52
+ "pointt.castt.flow.token-exchange",
53
+ "bacon",
54
+ "hcc.tariff.read"
55
+ ].join(" ");
56
+ //#endregion
57
+ //#region src/homecom/homecom-login.ts
58
+ var MESSAGES = {
59
+ empty: "Nothing was pasted. Paste the address the browser tried to open after you signed in.",
60
+ "no-code": "No sign-in code was found in what you pasted. Copy the whole address that starts with com.bosch.tt.dashtt.pointt://, or just the code.",
61
+ "state-missing": "The pasted address has no state. Copy the complete address, or create the login link again.",
62
+ "state-mismatch": "This address belongs to a different sign-in attempt. Create the login link again and use the address that follows it.",
63
+ denied: "Bosch refused the sign-in. Create the login link again and approve the access.",
64
+ "code-rejected": "The code was already used or has expired. Create the login link again; a code works once and only for a few minutes.",
65
+ rejected: "Bosch did not accept the sign-in. Create the login link again.",
66
+ "no-refresh": "Bosch signed you in but returned no long-lived token. Create the login link again."
67
+ };
68
+ /** A failure the operator can act on; `reason` is stable, the message is for the form. */
69
+ var HomeComLoginError = class extends Error {
70
+ name = "HomeComLoginError";
71
+ reason;
72
+ constructor(reason) {
73
+ super(MESSAGES[reason]);
74
+ this.reason = reason;
75
+ }
76
+ };
77
+ function b64url(bytes) {
78
+ return bytes.toString("base64url");
79
+ }
80
+ function beginHomeComSignIn() {
81
+ const verifier = b64url(randomBytes(32));
82
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
83
+ const state = b64url(randomBytes(16));
84
+ const url = new URL(HOMECOM_AUTHORIZE_URL);
85
+ url.searchParams.set("client_id", HOMECOM_CLIENT_ID);
86
+ url.searchParams.set("redirect_uri", HOMECOM_REDIRECT_URI);
87
+ url.searchParams.set("response_type", "code");
88
+ url.searchParams.set("scope", HOMECOM_SCOPE);
89
+ url.searchParams.set("code_challenge", challenge);
90
+ url.searchParams.set("code_challenge_method", "S256");
91
+ url.searchParams.set("state", state);
92
+ url.searchParams.set("nonce", b64url(randomBytes(16)));
93
+ url.searchParams.set("prompt", "login");
94
+ return {
95
+ authorizeUrl: url.toString(),
96
+ verifier,
97
+ state
98
+ };
99
+ }
100
+ /**
101
+ * The sign-in code from whatever the operator pasted: the full app address, an address copied
102
+ * from the browser's developer tools, a bare `code=…&state=…` query, or the bare code.
103
+ */
104
+ function parseHomeComCallback(input, expectedState) {
105
+ const parsed = parseOAuthCallback(input, expectedState);
106
+ if (!parsed.ok) throw new HomeComLoginError(parsed.reason);
107
+ return parsed.code;
108
+ }
109
+ function isRecord$1(value) {
110
+ return typeof value === "object" && value !== null && !Array.isArray(value);
111
+ }
112
+ function emailFromIdToken(idToken) {
113
+ if (typeof idToken !== "string") return "";
114
+ const payload = idToken.split(".")[1];
115
+ if (!payload) return "";
116
+ try {
117
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
118
+ return isRecord$1(parsed) && typeof parsed["email"] === "string" ? parsed["email"] : "";
119
+ } catch {
120
+ return "";
121
+ }
122
+ }
123
+ async function completeHomeComSignIn(input, fetchFn = globalThis.fetch.bind(globalThis)) {
124
+ const code = parseHomeComCallback(input.callback, input.state);
125
+ const res = await fetchFn(HOMECOM_TOKEN_URL, {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
128
+ body: new URLSearchParams({
129
+ grant_type: "authorization_code",
130
+ client_id: HOMECOM_CLIENT_ID,
131
+ code,
132
+ code_verifier: input.verifier,
133
+ redirect_uri: HOMECOM_REDIRECT_URI
134
+ }).toString()
135
+ });
136
+ const body = await res.json().catch(() => null);
137
+ if (!res.ok) throw new HomeComLoginError((isRecord$1(body) ? body["error"] : void 0) === "invalid_grant" ? "code-rejected" : "rejected");
138
+ if (!isRecord$1(body) || typeof body["access_token"] !== "string" || body["access_token"] === "") throw new HomeComLoginError("rejected");
139
+ const refresh = body["refresh_token"];
140
+ if (typeof refresh !== "string" || refresh === "") throw new HomeComLoginError("no-refresh");
141
+ return {
142
+ token: body["access_token"],
143
+ refresh,
144
+ username: emailFromIdToken(body["id_token"])
145
+ };
146
+ }
147
+ //#endregion
148
+ //#region src/homecom/config.ts
149
+ var HOMECOM_BRANDS = ["bosch", "buderus"];
150
+ var HomeComBrandSchema = _enum(HOMECOM_BRANDS);
151
+ var HOMECOM_REGIONS = ["eu", "us"];
152
+ var HomeComRegionSchema = _enum(HOMECOM_REGIONS);
153
+ var homeComConfigSchema = object({
154
+ /** Account email; filled by the sign-in, informational only. */
155
+ username: string().optional().describe("HomeCom / PointT account email").meta({ secret: true }),
156
+ token: string().min(1).describe("OAuth access token").meta({ secret: true }),
157
+ refresh: string().min(1).describe("OAuth refresh token").meta({ secret: true }),
158
+ brand: HomeComBrandSchema.default("bosch"),
159
+ region: HomeComRegionSchema.default("eu"),
160
+ /** The gateway this integration controls; chosen once from the account's gateways. */
161
+ gatewayId: string().min(1).optional()
162
+ });
163
+ function connectionFromSettings(settings) {
164
+ const parsed = homeComConfigSchema.safeParse(settings);
165
+ return parsed.success ? parsed.data : null;
166
+ }
167
+ function sameHomeComConnection(a, b) {
168
+ if (a === null || b === null) return false;
169
+ return a.username === b.username && a.token === b.token && a.refresh === b.refresh && a.brand === b.brand && a.region === b.region && a.gatewayId === b.gatewayId;
170
+ }
171
+ /**
172
+ * PointT REST origin (matches homecom_alt / HomeCom Easy). US and EU accounts use
173
+ * the same host; `region` is retained for Bacon MQTT (`euc1` / `use1`) when wired.
174
+ */
175
+ var HOMECOM_POINTT_API_ORIGIN = "https://pointt-api.bosch-thermotechnology.com";
176
+ function homeComApiBase(_region) {
177
+ return HOMECOM_POINTT_API_ORIGIN;
178
+ }
179
+ var HOMECOM_SECRET_KEYS = [
180
+ "username",
181
+ "token",
182
+ "refresh"
183
+ ];
184
+ function buildHomeComFormSchema() {
185
+ return { sections: [{
186
+ id: "account",
187
+ title: "HomeCom account",
188
+ titleKey: "provider-bosch:homecom.s_account.title",
189
+ description: "Create the login link and open it in a browser, then sign in with your SingleKey ID. The browser cannot open the app and stops on an address starting with com.bosch.tt.dashtt.pointt://app/login?code=. Paste that whole address below (or only the code) and press Complete sign-in. The address is shown in Chrome and Edge under DevTools (F12) > Network, with Preserve log ticked before you sign in: select the last redirect and copy its Location header. In Firefox use Network > Headers > Location; in Safari use Develop > Show Web Inspector > Network. A code works once and expires within minutes. Tokens can also be pasted by hand in the fields below.",
190
+ descriptionKey: "provider-bosch:homecom.s_account.description",
191
+ columns: 1,
192
+ fields: [
193
+ {
194
+ type: "addon-action-button",
195
+ addonId: "bosch-homecom",
196
+ key: "__homecom-begin",
197
+ label: "HomeCom",
198
+ labelKey: "provider-bosch:homecom.s_account.__homecom-begin.label",
199
+ buttonLabel: "Create login link",
200
+ action: "beginSignIn",
201
+ variant: "primary",
202
+ assignResult: {
203
+ authorizeUrl: "authorizeUrl",
204
+ verifier: "verifier",
205
+ state: "state"
206
+ },
207
+ successMessage: "Open the link in a browser and sign in."
208
+ },
209
+ {
210
+ type: "textarea",
211
+ key: "authorizeUrl",
212
+ label: "Login link",
213
+ labelKey: "provider-bosch:homecom.s_account.authorizeUrl.label",
214
+ required: false,
215
+ omitFromSettings: true
216
+ },
217
+ {
218
+ type: "textarea",
219
+ key: "callback",
220
+ label: "Redirect address or code",
221
+ labelKey: "provider-bosch:homecom.s_account.callback.label",
222
+ required: false,
223
+ placeholder: "com.bosch.tt.dashtt.pointt://app/login?code=…&state=…",
224
+ omitFromSettings: true
225
+ },
226
+ {
227
+ type: "password",
228
+ key: "state",
229
+ label: "Login state",
230
+ labelKey: "provider-bosch:homecom.s_account.state.label",
231
+ required: false,
232
+ omitFromSettings: true
233
+ },
234
+ {
235
+ type: "password",
236
+ key: "verifier",
237
+ label: "Login verifier",
238
+ labelKey: "provider-bosch:homecom.s_account.verifier.label",
239
+ required: false,
240
+ omitFromSettings: true
241
+ },
242
+ {
243
+ type: "addon-action-button",
244
+ addonId: "bosch-homecom",
245
+ key: "__homecom-finish",
246
+ label: "Finish",
247
+ labelKey: "provider-bosch:homecom.s_account.__homecom-finish.label",
248
+ buttonLabel: "Complete sign-in",
249
+ action: "finishSignIn",
250
+ variant: "primary",
251
+ paramsFromForm: {
252
+ verifier: "verifier",
253
+ callback: "callback",
254
+ state: "state"
255
+ },
256
+ assignResult: {
257
+ token: "token",
258
+ refresh: "refresh",
259
+ username: "username"
260
+ },
261
+ successMessage: "{summary}"
262
+ },
263
+ {
264
+ type: "text",
265
+ key: "username",
266
+ label: "Account",
267
+ labelKey: "provider-bosch:homecom.s_account.username.label",
268
+ required: false
269
+ },
270
+ {
271
+ type: "password",
272
+ key: "token",
273
+ label: "Access token (filled by sign-in, or paste one)",
274
+ labelKey: "provider-bosch:homecom.s_account.token.label",
275
+ required: true,
276
+ showToggle: true
277
+ },
278
+ {
279
+ type: "password",
280
+ key: "refresh",
281
+ label: "Refresh token (filled by sign-in, or paste one)",
282
+ labelKey: "provider-bosch:homecom.s_account.refresh.label",
283
+ required: true,
284
+ showToggle: true
285
+ },
286
+ {
287
+ type: "text",
288
+ key: "gatewayId",
289
+ label: "Gateway id",
290
+ labelKey: "provider-bosch:homecom.s_account.gatewayId.label",
291
+ description: "Leave empty to use the first gateway of the account; the chosen one is stored here. For a second gateway add another HomeCom account entry and enter its id.",
292
+ descriptionKey: "provider-bosch:homecom.s_account.gatewayId.description",
293
+ required: false
294
+ },
295
+ {
296
+ type: "select",
297
+ key: "brand",
298
+ label: "Brand",
299
+ labelKey: "provider-bosch:homecom.s_account.brand.label",
300
+ default: "bosch",
301
+ options: HOMECOM_BRANDS.map((b) => ({
302
+ value: b,
303
+ label: b
304
+ }))
305
+ },
306
+ {
307
+ type: "select",
308
+ key: "region",
309
+ label: "Region",
310
+ labelKey: "provider-bosch:homecom.s_account.region.label",
311
+ default: "eu",
312
+ options: HOMECOM_REGIONS.map((r) => ({
313
+ value: r,
314
+ label: r.toUpperCase()
315
+ }))
316
+ }
317
+ ]
318
+ }] };
319
+ }
320
+ //#endregion
321
+ //#region src/homecom/homecom-token-persist.ts
322
+ function unreadable(value) {
323
+ return isSecretMarker(value) || isSecretMarkerLike(value);
324
+ }
325
+ /**
326
+ * Merge `patch` into the integration's stored settings in ONE `setSettings` write, one at a
327
+ * time per integration. Refuses (and says so, without values) when a secret the write
328
+ * touches cannot be read back from the keychain: the hub refuses an overwrite of a secret the
329
+ * addon could not read, so the write would only fail.
330
+ */
331
+ function buildSerialPatcher(deps) {
332
+ const tails = /* @__PURE__ */ new Map();
333
+ return (integrationId, patch) => {
334
+ const run = async () => {
335
+ try {
336
+ const stored = await deps.getSettings(integrationId);
337
+ const locked = HOMECOM_SECRET_KEYS.filter((key) => unreadable(stored[key]));
338
+ if (locked.length > 0) {
339
+ deps.logger.warn(`HomeCom not persisting ${deps.what} — stored secrets are locked`, {
340
+ tags: { integrationId },
341
+ meta: { locked }
342
+ });
343
+ return;
344
+ }
345
+ await deps.setSettings({
346
+ id: integrationId,
347
+ settings: {
348
+ ...stored,
349
+ ...patch
350
+ },
351
+ secretKeys: [...HOMECOM_SECRET_KEYS]
352
+ });
353
+ } catch (err) {
354
+ deps.logger.warn(`HomeCom failed to persist ${deps.what}`, {
355
+ tags: { integrationId },
356
+ meta: { error: errMsg(err) }
357
+ });
358
+ }
359
+ };
360
+ const next = (tails.get(integrationId) ?? Promise.resolve()).then(run);
361
+ tails.set(integrationId, next);
362
+ return next;
363
+ };
364
+ }
365
+ /** Persist a rotated token pair; never throws, never logs a value. */
366
+ function buildHomeComTokenPersister(deps) {
367
+ const patch = buildSerialPatcher({
368
+ ...deps,
369
+ what: "rotated tokens"
370
+ });
371
+ return (integrationId, tokens) => patch(integrationId, {
372
+ token: tokens.token,
373
+ refresh: tokens.refresh
374
+ });
375
+ }
376
+ /** Persist the gateway this integration settled on. */
377
+ function buildHomeComGatewayPersister(deps) {
378
+ const patch = buildSerialPatcher({
379
+ ...deps,
380
+ what: "chosen gateway"
381
+ });
382
+ return (integrationId, gatewayId) => patch(integrationId, { gatewayId });
383
+ }
384
+ //#endregion
3
385
  //#region src/homecom/homecom-discovery.ts
4
386
  function buildHomeComCandidates(input) {
5
387
  const out = [];
@@ -153,84 +535,6 @@ function buildHomeComAdoptionProvider(deps) {
153
535
  };
154
536
  }
155
537
  //#endregion
156
- //#region src/homecom/config.ts
157
- var HOMECOM_BRANDS = ["bosch", "buderus"];
158
- var HomeComBrandSchema = _enum(HOMECOM_BRANDS);
159
- var HOMECOM_REGIONS = ["eu", "us"];
160
- var HomeComRegionSchema = _enum(HOMECOM_REGIONS);
161
- var homeComConfigSchema = object({
162
- username: string().min(1).describe("HomeCom / PointT account email").meta({ secret: true }),
163
- token: string().min(1).describe("OAuth access token").meta({ secret: true }),
164
- refresh: string().min(1).describe("OAuth refresh token").meta({ secret: true }),
165
- brand: HomeComBrandSchema.default("bosch"),
166
- region: HomeComRegionSchema.default("eu")
167
- });
168
- function connectionFromSettings(settings) {
169
- const parsed = homeComConfigSchema.safeParse(settings);
170
- return parsed.success ? parsed.data : null;
171
- }
172
- function sameHomeComConnection(a, b) {
173
- if (a === null || b === null) return false;
174
- return a.username === b.username && a.token === b.token && a.refresh === b.refresh && a.brand === b.brand && a.region === b.region;
175
- }
176
- /**
177
- * PointT REST origin (matches homecom_alt / HomeCom Easy). US and EU accounts use
178
- * the same host; `region` is retained for Bacon MQTT (`euc1` / `use1`) when wired.
179
- */
180
- var HOMECOM_POINTT_API_ORIGIN = "https://pointt-api.bosch-thermotechnology.com";
181
- function homeComApiBase(_region) {
182
- return HOMECOM_POINTT_API_ORIGIN;
183
- }
184
- function buildHomeComFormSchema() {
185
- return { sections: [{
186
- id: "account",
187
- title: "HomeCom account",
188
- columns: 1,
189
- fields: [
190
- {
191
- type: "text",
192
- key: "username",
193
- label: "Username",
194
- required: true
195
- },
196
- {
197
- type: "password",
198
- key: "token",
199
- label: "Access token",
200
- required: true,
201
- showToggle: true
202
- },
203
- {
204
- type: "password",
205
- key: "refresh",
206
- label: "Refresh token",
207
- required: true,
208
- showToggle: true
209
- },
210
- {
211
- type: "select",
212
- key: "brand",
213
- label: "Brand",
214
- default: "bosch",
215
- options: HOMECOM_BRANDS.map((b) => ({
216
- value: b,
217
- label: b
218
- }))
219
- },
220
- {
221
- type: "select",
222
- key: "region",
223
- label: "Region",
224
- default: "eu",
225
- options: HOMECOM_REGIONS.map((r) => ({
226
- value: r,
227
- label: r.toUpperCase()
228
- }))
229
- }
230
- ]
231
- }] };
232
- }
233
- //#endregion
234
538
  //#region src/homecom/homecom-gateway.ts
235
539
  var HomeComConnectionResolver = class {
236
540
  #surfaces = /* @__PURE__ */ new Map();
@@ -397,6 +701,38 @@ function readNum(raw) {
397
701
  var HomeComAuthError = class extends Error {
398
702
  name = "HomeComAuthError";
399
703
  };
704
+ function isRecord(value) {
705
+ return typeof value === "object" && value !== null && !Array.isArray(value);
706
+ }
707
+ function rowId(row) {
708
+ for (const key of [
709
+ "deviceId",
710
+ "gatewayId",
711
+ "id"
712
+ ]) {
713
+ const value = row[key];
714
+ if (typeof value === "string" && value.length > 0) return value;
715
+ if (typeof value === "number") return String(value);
716
+ }
717
+ return null;
718
+ }
719
+ /** Gateways from the account listing: a bare array or `{ gateways: [...] }`. */
720
+ function parseHomeComGatewayList(payload) {
721
+ const rows = isRecord(payload) ? payload["gateways"] : payload;
722
+ if (!Array.isArray(rows)) return [];
723
+ const out = [];
724
+ for (const row of rows) {
725
+ if (!isRecord(row)) continue;
726
+ const gatewayId = rowId(row);
727
+ if (gatewayId === null) continue;
728
+ const name = row["name"];
729
+ out.push({
730
+ gatewayId,
731
+ name: typeof name === "string" && name.length > 0 ? name : gatewayId
732
+ });
733
+ }
734
+ return out;
735
+ }
400
736
  function createHomeComApiClient(config, gatewayId, deps = {}) {
401
737
  const fetchFn = deps.fetchFn ?? globalThis.fetch.bind(globalThis);
402
738
  let token = config.token;
@@ -421,10 +757,14 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
421
757
  }
422
758
  return {
423
759
  async refreshTokens() {
424
- const res = await fetchFn(`${base}/token/oauth2`, {
760
+ const res = await fetchFn(HOMECOM_TOKEN_URL, {
425
761
  method: "POST",
426
762
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
427
- body: `grant_type=refresh_token&refresh_token=${encodeURIComponent(refresh)}&client_id=homecom`
763
+ body: new URLSearchParams({
764
+ grant_type: "refresh_token",
765
+ refresh_token: refresh,
766
+ client_id: HOMECOM_CLIENT_ID
767
+ }).toString()
428
768
  });
429
769
  if (!res.ok) {
430
770
  if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
@@ -438,11 +778,18 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
438
778
  if (typeof newToken !== "string" || typeof newRefresh !== "string") throw new Error("HomeCom token refresh: missing tokens");
439
779
  token = newToken;
440
780
  refresh = newRefresh;
781
+ await deps.onTokensRotated?.({
782
+ token,
783
+ refresh
784
+ });
441
785
  return {
442
786
  token,
443
787
  refresh
444
788
  };
445
789
  },
790
+ async listGateways() {
791
+ return parseHomeComGatewayList(await request("/v2/gateways"));
792
+ },
446
793
  async fetchSystem() {
447
794
  const parsed = parseHomeComSystemPayload(await request(`/v2/gateways/${gatewayId}/system`));
448
795
  return {
@@ -493,6 +840,8 @@ var HomeComIntegrationManager = class {
493
840
  #surfaceSink;
494
841
  #makeClient;
495
842
  #pollIntervalMs;
843
+ #onGatewayChosen;
844
+ #gateways = [];
496
845
  #client = null;
497
846
  #gatewayId = null;
498
847
  #pollTimer = null;
@@ -508,7 +857,26 @@ var HomeComIntegrationManager = class {
508
857
  this.#onDisconnected = options.onDisconnected;
509
858
  this.#surfaceSink = options.surfaceSink;
510
859
  const fetchFn = options.fetchFn;
511
- this.#makeClient = options.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, { fetchFn }));
860
+ this.#onGatewayChosen = options.onGatewayChosen ?? (() => void 0);
861
+ const onTokensRotated = async (tokens) => {
862
+ this.#connection = {
863
+ ...this.#connection,
864
+ token: tokens.token,
865
+ refresh: tokens.refresh
866
+ };
867
+ try {
868
+ await options.onTokensRotated?.(tokens);
869
+ } catch (err) {
870
+ this.#logger.warn("HomeCom could not store the rotated tokens", {
871
+ tags: { integrationId: this.#id },
872
+ meta: { error: errMsg(err) }
873
+ });
874
+ }
875
+ };
876
+ this.#makeClient = options.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId, {
877
+ fetchFn,
878
+ onTokensRotated
879
+ }));
512
880
  this.#pollIntervalMs = options.pollIntervalMs ?? 6e4;
513
881
  }
514
882
  getConnection() {
@@ -521,8 +889,9 @@ var HomeComIntegrationManager = class {
521
889
  kind: "homecom",
522
890
  status: this.#status,
523
891
  info: {
524
- username: this.#connection.username,
525
- gatewayId: this.#gatewayId
892
+ username: this.#connection.username ?? "",
893
+ gatewayId: this.#gatewayId,
894
+ gateways: this.#gateways
526
895
  },
527
896
  lastCheckedAt: this.#lastCheckedAt,
528
897
  error: this.#error
@@ -532,14 +901,15 @@ var HomeComIntegrationManager = class {
532
901
  if (this.#client !== null) return;
533
902
  this.#status = "connecting";
534
903
  this.#error = null;
535
- const gatewayId = await this.resolveGatewayId(this.#connection);
536
- this.#gatewayId = gatewayId;
537
- const client = this.#makeClient(this.#connection, gatewayId);
538
- this.#client = client;
904
+ let client = null;
539
905
  try {
906
+ const gatewayId = await this.resolveGatewayId();
907
+ this.#gatewayId = gatewayId;
908
+ client = this.#makeClient(this.#connection, gatewayId);
909
+ this.#client = client;
540
910
  const snapshot = await this.fetchWithTokenRefresh(client);
541
911
  const surface = {
542
- gatewayId,
912
+ gatewayId: this.#gatewayId ?? "",
543
913
  client,
544
914
  snapshot,
545
915
  discoverables: buildDiscoverables(snapshot)
@@ -553,7 +923,8 @@ var HomeComIntegrationManager = class {
553
923
  }, this.#pollIntervalMs);
554
924
  } catch (err) {
555
925
  this.#status = "error";
556
- this.#error = err instanceof HomeComAuthError ? "authentication failed — check tokens" : errMsg(err);
926
+ this.#error = err instanceof HomeComAuthError ? "authentication failed — sign in again" : errMsg(err);
927
+ this.#client = null;
557
928
  this.#surfaceSink.set(this.#id, null);
558
929
  this.#onDisconnected(this.#id);
559
930
  throw err;
@@ -566,6 +937,7 @@ var HomeComIntegrationManager = class {
566
937
  }
567
938
  this.#client = null;
568
939
  this.#gatewayId = null;
940
+ this.#gateways = [];
569
941
  this.#status = "disconnected";
570
942
  this.#surfaceSink.set(this.#id, null);
571
943
  this.#onDisconnected(this.#id);
@@ -590,17 +962,34 @@ var HomeComIntegrationManager = class {
590
962
  }
591
963
  }
592
964
  async fetchWithTokenRefresh(client) {
965
+ return this.withTokenRefresh(client, () => client.fetchSystem());
966
+ }
967
+ async withTokenRefresh(client, call) {
593
968
  try {
594
- return await client.fetchSystem();
969
+ return await call();
595
970
  } catch (err) {
596
971
  if (!(err instanceof HomeComAuthError)) throw err;
597
972
  await client.refreshTokens();
598
- return client.fetchSystem();
973
+ return call();
599
974
  }
600
975
  }
601
- /** Discover K40 gateway id via account listing (simplified: username hash stub). */
602
- async resolveGatewayId(config) {
603
- return "gateway-k40";
976
+ /**
977
+ * The gateway to poll: the stored choice when the account still has it, otherwise the
978
+ * account's first gateway, which is then stored. A stored choice that vanished is an error —
979
+ * silently switching to another gateway would control a different house.
980
+ */
981
+ async resolveGatewayId() {
982
+ const lister = this.#makeClient(this.#connection, "");
983
+ this.#gateways = await this.withTokenRefresh(lister, () => lister.listGateways());
984
+ const stored = this.#connection.gatewayId;
985
+ if (stored !== void 0) {
986
+ if (!this.#gateways.some((g) => g.gatewayId === stored)) throw new Error(`HomeCom gateway ${stored} is no longer on this account`);
987
+ return stored;
988
+ }
989
+ const first = this.#gateways[0];
990
+ if (first === void 0) throw new Error("HomeCom account has no gateway");
991
+ this.#onGatewayChosen(first.gatewayId);
992
+ return first.gatewayId;
604
993
  }
605
994
  };
606
995
  //#endregion
@@ -610,11 +999,15 @@ var HomeComClientRegistry = class {
610
999
  #onConnected;
611
1000
  #onDisconnected;
612
1001
  #makeManager;
1002
+ #onTokensRotated;
1003
+ #onGatewayChosen;
613
1004
  #entries = /* @__PURE__ */ new Map();
614
1005
  constructor(logger, deps = {}) {
615
1006
  this.#logger = logger;
616
1007
  this.#onConnected = deps.onConnected ?? (() => void 0);
617
1008
  this.#onDisconnected = deps.onDisconnected ?? (() => void 0);
1009
+ this.#onTokensRotated = deps.onTokensRotated ?? (async () => void 0);
1010
+ this.#onGatewayChosen = deps.onGatewayChosen ?? (() => void 0);
618
1011
  const fetchFn = deps.fetchFn;
619
1012
  const makeClient = deps.makeClient;
620
1013
  this.#makeManager = deps.makeManager ?? ((opts) => new HomeComIntegrationManager({
@@ -624,6 +1017,8 @@ var HomeComClientRegistry = class {
624
1017
  logger: opts.logger,
625
1018
  onConnected: opts.onConnected,
626
1019
  onDisconnected: opts.onDisconnected,
1020
+ onTokensRotated: opts.onTokensRotated,
1021
+ onGatewayChosen: opts.onGatewayChosen,
627
1022
  surfaceSink: homeComConnections,
628
1023
  fetchFn,
629
1024
  makeClient,
@@ -639,7 +1034,7 @@ var HomeComClientRegistry = class {
639
1034
  async upsert(integrationId, name, connection) {
640
1035
  const existing = this.#entries.get(integrationId);
641
1036
  if (existing) {
642
- if (sameHomeComConnection(existing.connection, connection)) return;
1037
+ if (sameHomeComConnection(existing.connection, connection) || sameHomeComConnection(existing.manager.getConnection(), connection)) return;
643
1038
  try {
644
1039
  await existing.manager.applyConnection(connection);
645
1040
  this.#entries.set(integrationId, {
@@ -660,7 +1055,9 @@ var HomeComClientRegistry = class {
660
1055
  connection,
661
1056
  logger: this.#logger,
662
1057
  onConnected: (id) => this.#onConnected(id),
663
- onDisconnected: (id) => this.#onDisconnected(id)
1058
+ onDisconnected: (id) => this.#onDisconnected(id),
1059
+ onTokensRotated: (tokens) => this.#onTokensRotated(integrationId, tokens),
1060
+ onGatewayChosen: (gatewayId) => this.#onGatewayChosen(integrationId, gatewayId)
664
1061
  });
665
1062
  this.#entries.set(integrationId, {
666
1063
  manager,
@@ -718,14 +1115,10 @@ var HomeComClientRegistry = class {
718
1115
  };
719
1116
  //#endregion
720
1117
  //#region src/homecom/homecom-connection-test.ts
721
- async function defaultResolveGatewayId(_config) {
722
- return "gateway-k40";
723
- }
724
- var TEST_LABEL = "Refreshes HomeCom OAuth tokens and reads the heat-pump system snapshot";
1118
+ var TEST_LABEL = "Lists the account gateways and reads the heat-pump system snapshot";
725
1119
  function buildHomeComConnectionTestProvider(deps) {
726
1120
  const { logger } = deps;
727
1121
  const makeClient = deps.makeClient ?? ((config, gatewayId) => createHomeComApiClient(config, gatewayId));
728
- const resolveGatewayId = deps.resolveGatewayId ?? defaultResolveGatewayId;
729
1122
  return {
730
1123
  describeTest: async () => ({ label: TEST_LABEL }),
731
1124
  testSettings: async ({ settings }) => {
@@ -735,11 +1128,18 @@ function buildHomeComConnectionTestProvider(deps) {
735
1128
  error: `HomeCom settings incomplete: ${parsed.error.issues.map((i) => i.path.join(".") || "(root)").join(", ")}`
736
1129
  };
737
1130
  const startedAt = Date.now();
738
- const gatewayId = await resolveGatewayId(parsed.data);
739
- const client = makeClient(parsed.data, gatewayId);
740
1131
  try {
741
- await client.refreshTokens();
742
- await client.fetchSystem();
1132
+ const gateways = await makeClient(parsed.data, "").listGateways();
1133
+ const gatewayId = parsed.data.gatewayId ?? gateways[0]?.gatewayId;
1134
+ if (gatewayId === void 0) return {
1135
+ outcome: "rejected",
1136
+ error: "This HomeCom account has no gateway."
1137
+ };
1138
+ if (!gateways.some((g) => g.gatewayId === gatewayId)) return {
1139
+ outcome: "rejected",
1140
+ error: `Gateway ${gatewayId} is not on this account.`
1141
+ };
1142
+ await makeClient(parsed.data, gatewayId).fetchSystem();
743
1143
  return {
744
1144
  outcome: "validated",
745
1145
  latencyMs: Date.now() - startedAt
@@ -747,12 +1147,9 @@ function buildHomeComConnectionTestProvider(deps) {
747
1147
  } catch (err) {
748
1148
  if (err instanceof HomeComAuthError) return {
749
1149
  outcome: "rejected",
750
- error: "HomeCom refused these tokens — re-authenticate the PointT account."
1150
+ error: "HomeCom refused the access token — sign in again to get a fresh one."
751
1151
  };
752
- logger.warn("HomeCom connection test inconclusive", { meta: {
753
- username: parsed.data.username,
754
- error: errMsg(err)
755
- } });
1152
+ logger.warn("HomeCom connection test inconclusive", { meta: { error: errMsg(err) } });
756
1153
  return {
757
1154
  outcome: "inconclusive",
758
1155
  error: "Could not reach the HomeCom cloud API."
@@ -958,7 +1355,17 @@ var BoschHomeComProviderAddon = class extends BaseDeviceProvider {
958
1355
  }
959
1356
  async onInitialize() {
960
1357
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
961
- this.clients = new HomeComClientRegistry(this.ctx.logger);
1358
+ const persistDeps = {
1359
+ getSettings: (id) => this.ctx.api.integrations.getSettings.query({ id }),
1360
+ setSettings: (input) => this.ctx.api.integrations.setSettings.mutate(input),
1361
+ logger: this.ctx.logger
1362
+ };
1363
+ const persistTokens = buildHomeComTokenPersister(persistDeps);
1364
+ const persistGateway = buildHomeComGatewayPersister(persistDeps);
1365
+ this.clients = new HomeComClientRegistry(this.ctx.logger, {
1366
+ onTokensRotated: (integrationId, tokens) => persistTokens(integrationId, tokens),
1367
+ onGatewayChosen: (integrationId, gatewayId) => void persistGateway(integrationId, gatewayId)
1368
+ });
962
1369
  this.clients.setOnConnected((integrationId) => {
963
1370
  this.setIntegrationDevicesOnline(integrationId, true);
964
1371
  });
@@ -967,17 +1374,33 @@ var BoschHomeComProviderAddon = class extends BaseDeviceProvider {
967
1374
  });
968
1375
  await this.reconcileIntegrations();
969
1376
  this.subscribeIntegrationLifecycle();
970
- return [
971
- ...regs,
972
- {
973
- capability: deviceAdoptionCapability,
974
- provider: this.buildAdoptionProvider()
975
- },
976
- {
977
- capability: connectionTestCapability,
978
- provider: this.buildConnectionTestProvider()
1377
+ return {
1378
+ providers: [
1379
+ ...regs,
1380
+ {
1381
+ capability: deviceAdoptionCapability,
1382
+ provider: this.buildAdoptionProvider()
1383
+ },
1384
+ {
1385
+ capability: connectionTestCapability,
1386
+ provider: this.buildConnectionTestProvider()
1387
+ }
1388
+ ],
1389
+ customActions: homeComActions,
1390
+ actionHandlers: {
1391
+ beginSignIn: async () => ({
1392
+ ...beginHomeComSignIn(),
1393
+ summary: "Open the link in a browser and sign in."
1394
+ }),
1395
+ finishSignIn: async (input) => {
1396
+ const tokens = await completeHomeComSignIn(input);
1397
+ return {
1398
+ ...tokens,
1399
+ summary: tokens.username.length > 0 ? `Signed in as ${tokens.username}. Save to connect.` : "Signed in. Save to connect."
1400
+ };
1401
+ }
979
1402
  }
980
- ];
1403
+ };
981
1404
  }
982
1405
  async onShutdown() {
983
1406
  try {