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