@camstack/addon-provider-bosch 0.1.54 → 0.1.56

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,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_bosch_instances = require("../bosch-instances-DkTzZrlR.js");
3
- const require_account_integration_offline = require("../account-integration-offline-DK6CYsvB.js");
2
+ const require_bosch_instances = require("../bosch-instances-BnuV5On6.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-DOH2c7x2.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_tls = require("node:tls");
6
6
  let node_zlib = require("node:zlib");
@@ -1,5 +1,5 @@
1
- import { $ as unknown, A as runningStateCapability, B as isSecretMarker, C as integrationInstancesCapability, D as quantityFromUnit, E as powerMeterCapability, F as createEvent, G as array, H as rowsDepsOver, I as customAction, L as defineCustomActions, P as RecordIntegrationInstances, Q as union, R as defineEntryKind, S as ingestReading, T as plainEventVocabulary, U as _enum, V as isSecretMarkerLike, X as record, Y as object, Z as string, _ as eventEmitterCapability, a as BaseDevice, c as RUNNING_STATE_COLD, et as DeviceRole, f as consumablesCapability, g as enumSensorCapability, h as dishwasherCapability, i as testBoschInstance, k as runningFromDishwasherOperation, m as deviceAdoptionCapability, n as boschRowsPorts, nt as errMsg, o as BaseDeviceProvider, q as literal, r as keepBodyKeys, rt as EventCategory, s as DishwasherOperationStateSchema, t as boschRowsMigration, tt as DeviceType, u as binaryCapability, w as numericSensorCapability, z as entryBody } from "../bosch-instances-DMzZSMxW.mjs";
2
- import { a as loginFailureForAttempt, c as HOME_CONNECT_REDIRECT_URI, i as createSignInAttemptStore, n as cascadeRemoveDevicesForIntegration, o as parseOAuthCallback, r as buildSignInGuideFields, t as setIntegrationDevicesOnline } from "../account-integration-offline-C9glrBvy.mjs";
1
+ import { $ as literal, B as defineCustomActions, C as ingestReading, D as plainEventVocabulary, E as numericSensorCapability, G as isSecretMarkerLike, H as entryBody, I as RecordIntegrationInstances, J as _enum, K as rowsDepsOver, M as runningStateCapability, O as powerMeterCapability, R as createEvent, V as defineEntryKind, W as isSecretMarker, X as array, _ as enumSensorCapability, a as BaseDevice, at as unknown, c as RUNNING_STATE_COLD, ct as errMsg, d as binaryCapability, g as dishwasherCapability, h as deviceAdoptionCapability, i as testBoschInstance, it as union, j as runningFromDishwasherOperation, k as quantityFromUnit, lt as EventCategory, n as boschRowsPorts, nt as record, o as BaseDeviceProvider, ot as DeviceRole, p as consumablesCapability, r as keepBodyKeys, rt as string, s as DishwasherOperationStateSchema, st as DeviceType, t as boschRowsMigration, tt as object, v as eventEmitterCapability, w as integrationInstancesCapability, z as customAction } from "../bosch-instances-REcWLM14.mjs";
2
+ import { a as loginFailureForAttempt, c as HOME_CONNECT_REDIRECT_URI, i as createSignInAttemptStore, n as cascadeRemoveDevicesForIntegration, o as parseOAuthCallback, r as buildSignInGuideFields, t as setIntegrationDevicesOnline } from "../account-integration-offline-fB8V7XNu.mjs";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { connect } from "node:tls";
5
5
  import { inflateRawSync } from "node:zlib";
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_bosch_instances = require("../bosch-instances-DkTzZrlR.js");
3
- const require_account_integration_offline = require("../account-integration-offline-DK6CYsvB.js");
2
+ const require_bosch_instances = require("../bosch-instances-BnuV5On6.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-DOH2c7x2.js");
4
4
  let node_crypto = require("node:crypto");
5
5
  let node_child_process = require("node:child_process");
6
6
  let node_fs = require("node:fs");
@@ -458,6 +458,17 @@ async function exploreHomeComResources(input) {
458
458
  }
459
459
  //#endregion
460
460
  //#region src/homecom/custom-actions.ts
461
+ /** A stored secret as the form sees it (`SecretFieldView`): set or not, never the value. */
462
+ var secretView = require_bosch_instances.custom(require_bosch_instances.isSecretFieldView, "a stored secret view");
463
+ /**
464
+ * A sign-in secret in the result: in clear only for a form that has no record yet (the wizard
465
+ * stores it on create); for an existing instance it is already stored and comes back as a view.
466
+ */
467
+ var signInSecret = require_bosch_instances.union([
468
+ require_bosch_instances.string().min(1),
469
+ secretView,
470
+ require_bosch_instances._null()
471
+ ]).meta({ secret: true });
461
472
  var gatewayChoice = require_bosch_instances.object({
462
473
  value: require_bosch_instances.string(),
463
474
  label: require_bosch_instances.string(),
@@ -496,11 +507,17 @@ var homeComActions = require_bosch_instances.defineCustomActions({
496
507
  }),
497
508
  finishSignIn: require_bosch_instances.customAction(require_bosch_instances.object({
498
509
  attemptId: require_bosch_instances.string().min(1),
499
- callback: require_bosch_instances.string().min(1)
510
+ callback: require_bosch_instances.string().min(1),
511
+ /** An existing instance: the new sign-in is stored on it and applied at once. */
512
+ integrationId: require_bosch_instances.string().min(1).optional()
500
513
  }), require_bosch_instances.object({
501
- token: require_bosch_instances.string().min(1).meta({ secret: true }),
502
- refresh: require_bosch_instances.string().min(1).meta({ secret: true }),
503
- username: require_bosch_instances.string().meta({ secret: true }),
514
+ token: signInSecret,
515
+ refresh: signInSecret,
516
+ username: require_bosch_instances.union([
517
+ require_bosch_instances.string(),
518
+ secretView,
519
+ require_bosch_instances._null()
520
+ ]).meta({ secret: true }),
504
521
  accountLabel: require_bosch_instances.string(),
505
522
  gatewayCount: require_bosch_instances.number(),
506
523
  summary: require_bosch_instances.string(),
@@ -512,13 +529,18 @@ var homeComActions = require_bosch_instances.defineCustomActions({
512
529
  kind: "mutation",
513
530
  auth: "admin"
514
531
  }),
515
- signOut: require_bosch_instances.customAction(require_bosch_instances.object({}), require_bosch_instances.object({
516
- signedOut: require_bosch_instances.literal(true),
517
- summary: require_bosch_instances.string()
518
- }), {
519
- kind: "mutation",
520
- auth: "admin"
521
- }),
532
+ signOut: require_bosch_instances.customAction(
533
+ /** An existing instance: its stored sign-in is cleared and its connection stopped. */
534
+ require_bosch_instances.object({ integrationId: require_bosch_instances.string().min(1).optional() }),
535
+ require_bosch_instances.object({
536
+ signedOut: require_bosch_instances.literal(true),
537
+ summary: require_bosch_instances.string()
538
+ }),
539
+ {
540
+ kind: "mutation",
541
+ auth: "admin"
542
+ }
543
+ ),
522
544
  /** Read-only walk of the gateway's resource tree, for diagnosing what it serves. */
523
545
  exploreResources: require_bosch_instances.customAction(exploreResourcesInput, exploreResourcesOutput, {
524
546
  kind: "query",
@@ -526,6 +548,132 @@ var homeComActions = require_bosch_instances.defineCustomActions({
526
548
  })
527
549
  });
528
550
  //#endregion
551
+ //#region src/homecom/homecom-account.ts
552
+ /**
553
+ * Sign in / sign out of an EXISTING HomeCom instance, written through the instance record — the
554
+ * owner of the tokens (keychain-backed secret fields). The record's change hook reconciles the
555
+ * integration: new tokens are applied to the running manager, cleared ones stop it.
556
+ */
557
+ /** Forget the account: the three secrets are cleared in the keychain, the account labels reset. */
558
+ async function signOutHomeComInstance(integrationId, instances) {
559
+ await instances.updateInstance({
560
+ id: integrationId,
561
+ values: {
562
+ token: require_bosch_instances.SECRET_CLEAR,
563
+ refresh: require_bosch_instances.SECRET_CLEAR,
564
+ username: require_bosch_instances.SECRET_CLEAR,
565
+ accountLabel: "",
566
+ gatewayCount: 0
567
+ }
568
+ });
569
+ }
570
+ /**
571
+ * Store a finished sign-in on the instance. The gateway (and its brand / region) is written only
572
+ * when the sign-in settled on one: a choice still open keeps the stored gateway.
573
+ */
574
+ async function storeHomeComSignIn(integrationId, finished, instances) {
575
+ await instances.updateInstance({
576
+ id: integrationId,
577
+ values: {
578
+ token: finished.token,
579
+ refresh: finished.refresh,
580
+ username: finished.username,
581
+ accountLabel: finished.accountLabel,
582
+ gatewayCount: finished.gatewayCount,
583
+ ...finished.gatewayId !== void 0 ? { gatewayId: finished.gatewayId } : {},
584
+ ...finished.brand !== void 0 ? { brand: finished.brand } : {},
585
+ ...finished.region !== void 0 ? { region: finished.region } : {}
586
+ }
587
+ });
588
+ }
589
+ /**
590
+ * The answer to a sign-in on an existing instance: the tokens are on the record now, so the form
591
+ * gets the record's "set" views (as `hydrateSchema` hands them out) in their place — a Save that
592
+ * follows keeps them, and the values never travel to the browser.
593
+ */
594
+ async function storedSignInResult(integrationId, finished, handle) {
595
+ const data = (await handle.get(integrationId))?.data ?? {};
596
+ const { token: _token, refresh: _refresh, username: _username, ...open } = finished;
597
+ return {
598
+ ...open,
599
+ token: require_bosch_instances.toSecretFieldView(data["token"]),
600
+ refresh: require_bosch_instances.toSecretFieldView(data["refresh"]),
601
+ username: require_bosch_instances.toSecretFieldView(data["username"])
602
+ };
603
+ }
604
+ //#endregion
605
+ //#region src/homecom/homecom-reconcile.ts
606
+ /**
607
+ * Bring the running HomeCom managers in line with the enabled integrations:
608
+ *
609
+ * - signed in -> upsert (a changed connection is applied to the running manager);
610
+ * - signed out -> the manager stops and the devices go offline; the integration still
611
+ * exists, so its devices and its session alert are KEPT;
612
+ * - unreadable -> left as it is (a locked keychain is not a sign out, nor a deletion);
613
+ * - gone / disabled -> the manager stops and the caller drops what belonged to it.
614
+ *
615
+ * "Gone" is judged over EVERY id the addon still holds something for — a running manager, an
616
+ * instance record, a device — not only the running managers: an integration that was signed out
617
+ * or unreadable has no manager, and its devices and alert must still go when it is deleted.
618
+ */
619
+ async function reconcileHomeComIntegrations(deps) {
620
+ const enabled = await deps.listEnabled();
621
+ const present = new Set(enabled.map((integration) => integration.id));
622
+ for (const integration of enabled) await reconcileOne(deps, integration);
623
+ const vanished = [...new Set([...deps.registry.list(), ...await deps.knownIds()])].filter((id) => !present.has(id));
624
+ await deps.registry.retain(present);
625
+ for (const integrationId of vanished) await deps.onVanished(integrationId);
626
+ }
627
+ async function reconcileOne(deps, integration) {
628
+ const tags = { integrationId: integration.id };
629
+ try {
630
+ const connection = await deps.connectionOf(integration.id);
631
+ deps.onNamed(integration.id, integration.name);
632
+ if (connection === null) {
633
+ if (deps.registry.has(integration.id)) {
634
+ await deps.registry.remove(integration.id);
635
+ deps.logger.info("HomeCom integration has no sign-in — stopped, devices offline", { tags });
636
+ }
637
+ deps.onSignedOut(integration.id);
638
+ return;
639
+ }
640
+ await deps.registry.upsert(integration.id, integration.name, connection);
641
+ } catch (err) {
642
+ deps.logger.warn("HomeCom reconcile failed for integration", {
643
+ tags,
644
+ meta: { error: require_bosch_instances.errMsg(err) }
645
+ });
646
+ }
647
+ }
648
+ //#endregion
649
+ //#region src/homecom/homecom-session-alert.ts
650
+ /** The standard session-lost alert for HomeCom; "Sign in again" opens the integration page. */
651
+ function createHomeComSessionAlert(deps) {
652
+ const tracker = new require_bosch_instances.SessionHealthTracker({
653
+ kind: "homecom",
654
+ providerName: "Bosch HomeCom",
655
+ logger: deps.logger,
656
+ getAlerts: deps.getAlerts,
657
+ debounceMs: deps.debounceMs ?? 0,
658
+ addonId: deps.addonId,
659
+ severity: "critical"
660
+ });
661
+ return {
662
+ authFailed: (integrationId) => tracker.lost(integrationId, {
663
+ integrationName: deps.nameOf(integrationId),
664
+ reason: "token-expired",
665
+ action: {
666
+ label: "Sign in again",
667
+ href: require_bosch_instances.integrationPageHref(deps.addonId, integrationId),
668
+ navigation: "app"
669
+ }
670
+ }),
671
+ connected: (integrationId) => tracker.restored(integrationId),
672
+ removed: (integrationId) => tracker.forget(integrationId),
673
+ dispose: () => tracker.dispose()
674
+ };
675
+ }
676
+ //#endregion
529
677
  //#region src/homecom/homecom-explore-action.ts
530
678
  /** The action refused before it walked anything; `reason` says why. */
531
679
  var HomeComExploreRefusedError = class extends Error {
@@ -1845,6 +1993,14 @@ function homeComResourceUrl(gatewayId, resourcePath) {
1845
1993
  const path = resourcePath.startsWith("/") ? resourcePath : `/${resourcePath}`;
1846
1994
  return `${HOMECOM_POINTT_API_ORIGIN}${HOMECOM_GATEWAYS_PATH}${encodeURIComponent(gatewayId)}/resource${path}`;
1847
1995
  }
1996
+ async function isInvalidGrantBody(res) {
1997
+ try {
1998
+ const body = await res.json();
1999
+ return isRecord$1(body) && body["error"] === "invalid_grant";
2000
+ } catch {
2001
+ return false;
2002
+ }
2003
+ }
1848
2004
  function createHomeComApiClient(config, gatewayId, deps = {}) {
1849
2005
  const fetchFn = deps.fetchFn ?? globalThis.fetch.bind(globalThis);
1850
2006
  const refreshOnUnauthorized = deps.refreshOnUnauthorized ?? true;
@@ -1965,6 +2121,7 @@ function createHomeComApiClient(config, gatewayId, deps = {}) {
1965
2121
  });
1966
2122
  if (!res.ok) {
1967
2123
  if (res.status === 401 || res.status === 403) throw new HomeComAuthError("refresh refused");
2124
+ if (res.status === 400 && await isInvalidGrantBody(res)) throw new HomeComAuthError("refresh refused");
1968
2125
  throw new Error(`HomeCom token refresh failed: HTTP ${res.status}`);
1969
2126
  }
1970
2127
  const body = await res.json();
@@ -2384,7 +2541,10 @@ function buildSerialPatcher(deps) {
2384
2541
  const run = async () => {
2385
2542
  try {
2386
2543
  const stored = await deps.read(integrationId);
2387
- if (stored === null) return;
2544
+ if (stored === null) {
2545
+ deps.logger.warn(`HomeCom not persisting ${deps.what} — the integration record is gone`, { tags: { integrationId } });
2546
+ return;
2547
+ }
2388
2548
  if (stored.locked) {
2389
2549
  deps.logger.warn(`HomeCom not persisting ${deps.what} — stored secrets are locked`, { tags: { integrationId } });
2390
2550
  return;
@@ -2798,6 +2958,7 @@ var HomeComIntegrationManager = class {
2798
2958
  #makeClient;
2799
2959
  #pollIntervalMs;
2800
2960
  #onGatewayChosen;
2961
+ #onAuthFailed;
2801
2962
  #gateways = [];
2802
2963
  #client = null;
2803
2964
  #gatewayId = null;
@@ -2816,6 +2977,7 @@ var HomeComIntegrationManager = class {
2816
2977
  const fetchFn = options.fetchFn;
2817
2978
  const bridge = options.bridge;
2818
2979
  this.#onGatewayChosen = options.onGatewayChosen ?? (() => void 0);
2980
+ this.#onAuthFailed = options.onAuthFailed ?? (() => void 0);
2819
2981
  const onTokensRotated = async (tokens) => {
2820
2982
  this.#connection = {
2821
2983
  ...this.#connection,
@@ -2884,7 +3046,9 @@ var HomeComIntegrationManager = class {
2884
3046
  }, this.#pollIntervalMs);
2885
3047
  } catch (err) {
2886
3048
  this.#status = "error";
2887
- this.#error = err instanceof HomeComAuthError ? "authentication failed — sign in again" : require_bosch_instances.errMsg(err);
3049
+ const authFailed = err instanceof HomeComAuthError;
3050
+ this.#error = authFailed ? "authentication failed — sign in again" : require_bosch_instances.errMsg(err);
3051
+ if (authFailed) this.#onAuthFailed(this.#id);
2888
3052
  this.#client = null;
2889
3053
  this.#surfaceSink.set(this.#id, null);
2890
3054
  this.#onDisconnected(this.#id);
@@ -2916,6 +3080,7 @@ var HomeComIntegrationManager = class {
2916
3080
  this.#surfaceSink.updateSnapshot(this.#id, snapshot);
2917
3081
  this.#lastCheckedAt = Date.now();
2918
3082
  } catch (err) {
3083
+ if (err instanceof HomeComAuthError) this.#onAuthFailed(this.#id);
2919
3084
  this.#logger.warn("HomeCom poll failed", { meta: {
2920
3085
  integrationId: this.#id,
2921
3086
  error: require_bosch_instances.errMsg(err)
@@ -2950,6 +3115,7 @@ var HomeComClientRegistry = class {
2950
3115
  #makeManager;
2951
3116
  #onTokensRotated;
2952
3117
  #onGatewayChosen;
3118
+ #onAuthFailed;
2953
3119
  #entries = /* @__PURE__ */ new Map();
2954
3120
  constructor(logger, deps = {}) {
2955
3121
  this.#logger = logger;
@@ -2957,6 +3123,7 @@ var HomeComClientRegistry = class {
2957
3123
  this.#onDisconnected = deps.onDisconnected ?? (() => void 0);
2958
3124
  this.#onTokensRotated = deps.onTokensRotated ?? (async () => void 0);
2959
3125
  this.#onGatewayChosen = deps.onGatewayChosen ?? (() => void 0);
3126
+ this.#onAuthFailed = deps.onAuthFailed ?? (() => void 0);
2960
3127
  const fetchFn = deps.fetchFn;
2961
3128
  const makeClient = deps.makeClient;
2962
3129
  const bridge = deps.bridge;
@@ -2969,6 +3136,7 @@ var HomeComClientRegistry = class {
2969
3136
  onDisconnected: opts.onDisconnected,
2970
3137
  onTokensRotated: opts.onTokensRotated,
2971
3138
  onGatewayChosen: opts.onGatewayChosen,
3139
+ onAuthFailed: opts.onAuthFailed,
2972
3140
  surfaceSink: homeComConnections,
2973
3141
  fetchFn,
2974
3142
  makeClient,
@@ -3008,7 +3176,8 @@ var HomeComClientRegistry = class {
3008
3176
  onConnected: (id) => this.#onConnected(id),
3009
3177
  onDisconnected: (id) => this.#onDisconnected(id),
3010
3178
  onTokensRotated: (tokens) => this.#onTokensRotated(integrationId, tokens),
3011
- onGatewayChosen: (gatewayId) => this.#onGatewayChosen(integrationId, gatewayId)
3179
+ onGatewayChosen: (gatewayId) => this.#onGatewayChosen(integrationId, gatewayId),
3180
+ onAuthFailed: (id) => this.#onAuthFailed(id)
3012
3181
  });
3013
3182
  this.#entries.set(integrationId, {
3014
3183
  manager,
@@ -3660,6 +3829,8 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3660
3829
  };
3661
3830
  clients = null;
3662
3831
  instances = null;
3832
+ sessionAlert = null;
3833
+ integrationNames = /* @__PURE__ */ new Map();
3663
3834
  constructor() {
3664
3835
  super({});
3665
3836
  }
@@ -3686,12 +3857,21 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3686
3857
  const persistTokens = buildHomeComTokenPersister(persistDeps);
3687
3858
  const persistGateway = buildHomeComGatewayPersister(persistDeps);
3688
3859
  const bridge = await this.createBridge();
3860
+ const sessionAlert = createHomeComSessionAlert({
3861
+ logger: this.ctx.logger,
3862
+ getAlerts: () => this.ctx.api.alerts,
3863
+ nameOf: (id) => this.integrationNames.get(id) ?? id,
3864
+ addonId: this.addonId
3865
+ });
3866
+ this.sessionAlert = sessionAlert;
3689
3867
  this.clients = new HomeComClientRegistry(this.ctx.logger, {
3868
+ onAuthFailed: (integrationId) => sessionAlert.authFailed(integrationId),
3690
3869
  ...bridge !== null ? { bridge } : {},
3691
3870
  onTokensRotated: (integrationId, tokens) => persistTokens(integrationId, tokens),
3692
3871
  onGatewayChosen: (integrationId, gatewayId) => void persistGateway(integrationId, gatewayId)
3693
3872
  });
3694
3873
  this.clients.setOnConnected((integrationId) => {
3874
+ sessionAlert.connected(integrationId);
3695
3875
  this.setIntegrationDevicesOnline(integrationId, true);
3696
3876
  });
3697
3877
  this.clients.setOnDisconnected((integrationId) => {
@@ -3722,15 +3902,30 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3722
3902
  ...publishHomeComSignIn(homeComSignInAttempts, Date.now()),
3723
3903
  summary: "Open the sign-in page, sign in, then paste the address the browser stops on."
3724
3904
  }),
3725
- finishSignIn: async (input) => finishHomeComSignIn({
3726
- attemptId: input.attemptId,
3727
- callback: input.callback,
3728
- nowMs: Date.now()
3729
- }, homeComSignInAttempts),
3730
- signOut: async () => ({
3731
- signedOut: true,
3732
- summary: "Signed out."
3733
- }),
3905
+ finishSignIn: async (input) => {
3906
+ const finished = await finishHomeComSignIn({
3907
+ attemptId: input.attemptId,
3908
+ callback: input.callback,
3909
+ nowMs: Date.now()
3910
+ }, homeComSignInAttempts);
3911
+ const integrationId = input.integrationId;
3912
+ if (integrationId === void 0) return finished;
3913
+ await this.storeSignIn(integrationId, () => storeHomeComSignIn(integrationId, finished, built.instances));
3914
+ return storedSignInResult(integrationId, finished, built.handle);
3915
+ },
3916
+ signOut: async (input) => {
3917
+ const integrationId = input.integrationId;
3918
+ if (integrationId === void 0) return {
3919
+ signedOut: true,
3920
+ summary: "Signed out."
3921
+ };
3922
+ await this.storeSignIn(integrationId, () => signOutHomeComInstance(integrationId, built.instances));
3923
+ this.ctx.logger.info("HomeCom signed out — stored sign-in cleared", { tags: { integrationId } });
3924
+ return {
3925
+ signedOut: true,
3926
+ summary: "Signed out. The devices stay offline until you sign in again."
3927
+ };
3928
+ },
3734
3929
  exploreResources: explore
3735
3930
  }
3736
3931
  };
@@ -3743,6 +3938,8 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3743
3938
  }
3744
3939
  this.clients = null;
3745
3940
  this.instances = null;
3941
+ this.sessionAlert?.dispose();
3942
+ this.sessionAlert = null;
3746
3943
  await super.onShutdown();
3747
3944
  }
3748
3945
  /** The `homecom_alt` bridge on the embedded Python; null leaves k40/k30/icom on plain reads. */
@@ -3773,38 +3970,60 @@ var BoschHomeComProviderAddon = class extends require_bosch_instances.BaseDevice
3773
3970
  if (typeof nativeId !== "string" || nativeId.length === 0) throw new Error("homecom generateStableId: nativeId required");
3774
3971
  return homeComStableId(integrationId, nativeId);
3775
3972
  }
3973
+ /** Write a sign-in change on an instance that exists; a missing one is refused by name. */
3974
+ async storeSignIn(integrationId, write) {
3975
+ if (await homeComStoredOf(this.requireInstances().instances, integrationId) === null) throw new Error(`HomeCom integration ${integrationId} does not exist`);
3976
+ await write();
3977
+ }
3776
3978
  async reconcileIntegrations() {
3777
3979
  const reg = this.requireClients();
3778
3980
  try {
3779
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
3780
- const surviving = /* @__PURE__ */ new Set();
3781
- for (const integration of mine) try {
3782
- const connection = await homeComConnectionOf(this.requireInstances().instances, integration.id);
3783
- if (!connection) continue;
3784
- await reg.upsert(integration.id, integration.name, connection);
3785
- surviving.add(integration.id);
3786
- } catch (err) {
3787
- this.ctx.logger.warn("HomeCom reconcile failed for integration", { meta: {
3788
- integrationId: integration.id,
3789
- error: require_bosch_instances.errMsg(err)
3790
- } });
3791
- }
3792
- const vanished = reg.list().filter((id) => !surviving.has(id));
3793
- await reg.retain(surviving);
3794
- const deviceReg = this.ctx.kernel.deviceRegistry;
3795
- const deviceApi = this.ctx.kernel.devices;
3796
- if (deviceReg && deviceApi) for (const integrationId of vanished) await require_account_integration_offline.cascadeRemoveDevicesForIntegration({
3797
- reg: deviceReg,
3798
- devices: deviceApi,
3799
- addonId: this.addonId,
3800
- system: "homecom",
3801
- integrationId,
3981
+ await reconcileHomeComIntegrations({
3982
+ listEnabled: async () => {
3983
+ return (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
3984
+ },
3985
+ knownIds: () => this.knownIntegrationIds(),
3986
+ connectionOf: (id) => homeComConnectionOf(this.requireInstances().instances, id),
3987
+ registry: reg,
3988
+ onNamed: (id, name) => this.integrationNames.set(id, name),
3989
+ onSignedOut: (id) => this.setIntegrationDevicesOnline(id, false),
3990
+ onVanished: (id) => this.dropIntegration(id),
3802
3991
  logger: this.ctx.logger
3803
3992
  });
3804
3993
  } catch (err) {
3805
3994
  this.ctx.logger.warn("HomeCom integration reconcile failed", { meta: { error: require_bosch_instances.errMsg(err) } });
3806
3995
  }
3807
3996
  }
3997
+ /** Every integration this addon holds a record or a top-level device for. */
3998
+ async knownIntegrationIds() {
3999
+ const ids = new Set(this.integrationNames.keys());
4000
+ for (const row of await this.requireInstances().instances.listResolved()) ids.add(row.id);
4001
+ const reg = this.ctx.kernel.deviceRegistry;
4002
+ const devices = this.ctx.kernel.devices;
4003
+ if (reg && devices) for (const d of reg.getAllForAddon(this.addonId)) {
4004
+ if (d.parentDeviceId !== null) continue;
4005
+ const cfg = await devices.loadConfig(d.id).catch(() => ({}));
4006
+ const brokerId = cfg["brokerId"];
4007
+ if (cfg["system"] === "homecom" && typeof brokerId === "string") ids.add(brokerId);
4008
+ }
4009
+ return [...ids];
4010
+ }
4011
+ /** The integration is gone: its alert and its devices go with it. */
4012
+ async dropIntegration(integrationId) {
4013
+ this.integrationNames.delete(integrationId);
4014
+ this.sessionAlert?.removed(integrationId);
4015
+ const deviceReg = this.ctx.kernel.deviceRegistry;
4016
+ const deviceApi = this.ctx.kernel.devices;
4017
+ if (!deviceReg || !deviceApi) return;
4018
+ await require_account_integration_offline.cascadeRemoveDevicesForIntegration({
4019
+ reg: deviceReg,
4020
+ devices: deviceApi,
4021
+ addonId: this.addonId,
4022
+ system: "homecom",
4023
+ integrationId,
4024
+ logger: this.ctx.logger
4025
+ });
4026
+ }
3808
4027
  subscribeIntegrationLifecycle() {
3809
4028
  const handler = (event) => {
3810
4029
  const addonId = event.data["addonId"];