@camstack/addon-provider-bosch 0.1.11 → 0.1.13

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_dist = require("../dist-Dwa_n1ZO.js");
3
- const require_account_integration_offline = require("../account-integration-offline-BDOU9UIQ.js");
2
+ const require_dist = require("../dist-CPBdrxct.js");
3
+ const require_account_integration_offline = require("../account-integration-offline-Bk-vELSf.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");
@@ -34,6 +34,53 @@ var homeConnectActions = require_dist.defineCustomActions({
34
34
  })
35
35
  });
36
36
  //#endregion
37
+ //#region src/home-connect/home-connect-container-tracker.ts
38
+ /**
39
+ * Put a reconciler watch on every adopted appliance container not watched yet. Called at
40
+ * init, on connect, on adopt AND after the device restore — in a forked runner the devices
41
+ * are restored only after `onInitialize` returns, so a pass at init sees none of them and
42
+ * the containers were never watched: a description that arrived later derived no children.
43
+ * Returns how many containers it started watching.
44
+ */
45
+ async function trackHomeConnectContainers(deps, trigger) {
46
+ let started = 0;
47
+ let devices;
48
+ try {
49
+ devices = await deps.listDevices();
50
+ } catch (err) {
51
+ deps.logger.warn("Home Connect could not list its devices to watch their children", { meta: {
52
+ trigger,
53
+ error: require_dist.errMsg(err)
54
+ } });
55
+ return 0;
56
+ }
57
+ for (const d of devices) {
58
+ if (d.type !== require_dist.DeviceType.Container || deps.reconciler.tracks(d.id)) continue;
59
+ const cfg = await deps.loadConfig(d.id).catch(() => ({}));
60
+ const brokerId = cfg["brokerId"];
61
+ const haId = cfg["haId"];
62
+ if (cfg["system"] !== "home-connect" || typeof brokerId !== "string") continue;
63
+ if (typeof haId !== "string") continue;
64
+ deps.reconciler.track({
65
+ deviceId: d.id,
66
+ brokerId,
67
+ haId
68
+ });
69
+ started += 1;
70
+ deps.logger.info("Home Connect watching appliance children", {
71
+ tags: {
72
+ deviceId: d.id,
73
+ integrationId: brokerId
74
+ },
75
+ meta: {
76
+ haId,
77
+ trigger
78
+ }
79
+ });
80
+ }
81
+ return started;
82
+ }
83
+ //#endregion
37
84
  //#region src/home-connect/home-connect-xml.ts
38
85
  var TOKEN_RE = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!DOCTYPE[^>]*>|<\/([\w:.-]+)\s*>|<([\w:.-]+)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g;
39
86
  var ATTR_RE = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
@@ -464,8 +511,52 @@ function decodeZipPayload(method, payload) {
464
511
  }
465
512
  return null;
466
513
  }
467
- /** Extract file bodies from a ZIP (store + deflate). */
514
+ var EOCD_SIG = 101010256;
515
+ var CENTRAL_SIG = 33639248;
516
+ var LOCAL_SIG = 67324752;
517
+ /** The end-of-central-directory record is 22 bytes plus a comment of at most 64 KiB. */
518
+ var EOCD_MAX_SCAN = 65557;
519
+ function findEndOfCentralDirectory(zip) {
520
+ const lowest = Math.max(0, zip.length - EOCD_MAX_SCAN);
521
+ for (let pos = zip.length - 22; pos >= lowest; pos -= 1) if (readUInt32LE(zip, pos) === EOCD_SIG) return pos;
522
+ return null;
523
+ }
524
+ /**
525
+ * Entries named by the central directory — the authoritative sizes. A streamed archive (bit 3,
526
+ * as a Java `ZipOutputStream` writes it, and as the cloud IDDF arrives) has ZERO sizes in every
527
+ * local header; only the central directory and the data descriptors carry them.
528
+ */
529
+ function entriesFromCentralDirectory(zip) {
530
+ const eocd = findEndOfCentralDirectory(zip);
531
+ if (eocd === null) return null;
532
+ const count = readUInt16LE(zip, eocd + 10);
533
+ let pos = readUInt32LE(zip, eocd + 16);
534
+ const out = /* @__PURE__ */ new Map();
535
+ for (let i = 0; i < count && pos + 46 <= zip.length; i += 1) {
536
+ if (readUInt32LE(zip, pos) !== CENTRAL_SIG) break;
537
+ const method = readUInt16LE(zip, pos + 10);
538
+ const compSize = readUInt32LE(zip, pos + 20);
539
+ const nameLen = readUInt16LE(zip, pos + 28);
540
+ const extraLen = readUInt16LE(zip, pos + 30);
541
+ const commentLen = readUInt16LE(zip, pos + 32);
542
+ const localOffset = readUInt32LE(zip, pos + 42);
543
+ const name = zip.subarray(pos + 46, pos + 46 + nameLen).toString("utf8");
544
+ pos += 46 + nameLen + extraLen + commentLen;
545
+ if (localOffset + 30 > zip.length || readUInt32LE(zip, localOffset) !== LOCAL_SIG) continue;
546
+ const dataStart = localOffset + 30 + readUInt16LE(zip, localOffset + 26) + readUInt16LE(zip, localOffset + 28);
547
+ const decoded = decodeZipPayload(method, zip.subarray(dataStart, dataStart + compSize));
548
+ if (decoded !== null) out.set(name, decoded);
549
+ }
550
+ return out;
551
+ }
552
+ /** Extract file bodies from a ZIP (store + deflate): central directory first, local headers as a fallback. */
468
553
  function extractZipEntries(zip) {
554
+ if (zip.length < 22) return /* @__PURE__ */ new Map();
555
+ const central = entriesFromCentralDirectory(zip);
556
+ if (central !== null && central.size > 0) return central;
557
+ return entriesFromLocalHeaders(zip);
558
+ }
559
+ function entriesFromLocalHeaders(zip) {
469
560
  const out = /* @__PURE__ */ new Map();
470
561
  let pos = 0;
471
562
  while (pos + 30 <= zip.length) {
@@ -500,6 +591,13 @@ function parseIddfZipBuffer(zip, haId) {
500
591
  haId
501
592
  });
502
593
  }
594
+ /**
595
+ * The IDDF request carries the bearer token and NOTHING else — the reference downloaders send
596
+ * no `Accept`, and the endpoint answered 403 to `accept: application/zip` on a live hub.
597
+ */
598
+ function homeConnectIddfHeaders(token) {
599
+ return { authorization: `Bearer ${token}` };
600
+ }
503
601
  var HOME_CONNECT_IDDF_URL = (haId) => `https://eu.services.home-connect.com/api/iddf/v1/iddf/${encodeURIComponent(haId)}`;
504
602
  //#endregion
505
603
  //#region src/home-connect/home-connect-login.ts
@@ -683,10 +781,7 @@ async function appliancesForToken(token, fetchImpl, jar) {
683
781
  if (!appliance) continue;
684
782
  const iddfResponse = await fetchImpl(HOME_CONNECT_IDDF_URL(haId), {
685
783
  method: "GET",
686
- headers: {
687
- authorization: `Bearer ${token}`,
688
- accept: "application/zip"
689
- },
784
+ headers: homeConnectIddfHeaders(token),
690
785
  redirect: "manual"
691
786
  });
692
787
  if (iddfResponse.status < 400 && iddfResponse.arrayBuffer) {
@@ -709,8 +804,8 @@ async function appliancesForToken(token, fetchImpl, jar) {
709
804
  /** Load every appliance on the account. A stale access token is refreshed once when a refresh token exists. */
710
805
  async function fetchHomeConnectAccount(creds, fetchImpl = homeConnectLoginFetch) {
711
806
  const jar = new CookieJar();
712
- let token = creds.token;
713
- let refresh = creds.refresh ?? "";
807
+ const token = creds.token;
808
+ const refresh = creds.refresh ?? "";
714
809
  try {
715
810
  const appliances = await appliancesForToken(token, fetchImpl, jar);
716
811
  return {
@@ -722,23 +817,76 @@ async function fetchHomeConnectAccount(creds, fetchImpl = homeConnectLoginFetch)
722
817
  } catch (err) {
723
818
  if (refresh.length === 0) throw err;
724
819
  }
725
- const refreshed = await exchange(fetchImpl, jar, `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
726
- grant_type: "refresh_token",
727
- refresh_token: refresh,
728
- client_id: APP_ID$1
729
- }).toString());
730
- if (refreshed.status >= 400) throw new Error("Home Connect sign-in was rejected");
731
- const next = tokensFromBody(refreshed.body);
732
- token = next.token;
733
- refresh = next.refresh;
734
- const appliances = await appliancesForToken(token, fetchImpl, jar);
820
+ const next = await refreshHomeConnectToken(refresh, fetchImpl);
821
+ if (!next.ok) throw new Error("Home Connect sign-in was rejected");
822
+ const appliances = await appliancesForToken(next.token, fetchImpl, jar);
735
823
  return {
736
- token,
737
- refresh,
824
+ token: next.token,
825
+ refresh: next.refresh,
738
826
  appliances,
739
827
  summary: accountSummary(appliances)
740
828
  };
741
829
  }
830
+ /**
831
+ * Exchange the refresh token for a new access token. Never throws; a failure says why and
832
+ * carries no credential. A response without a new refresh token keeps the one that was used.
833
+ */
834
+ async function refreshHomeConnectToken(refresh, fetchImpl = homeConnectLoginFetch) {
835
+ if (refresh.length === 0) return {
836
+ ok: false,
837
+ reason: "no-refresh-token",
838
+ status: null
839
+ };
840
+ let status;
841
+ let body;
842
+ try {
843
+ const refreshed = await exchange(fetchImpl, new CookieJar(), `${OAUTH_BASE}token`, "POST", { "content-type": "application/x-www-form-urlencoded" }, new URLSearchParams({
844
+ grant_type: "refresh_token",
845
+ refresh_token: refresh,
846
+ client_id: APP_ID$1
847
+ }).toString());
848
+ status = refreshed.status;
849
+ body = refreshed.body;
850
+ } catch (err) {
851
+ return {
852
+ ok: false,
853
+ reason: "network",
854
+ status: null,
855
+ error: err instanceof Error ? err.message : "unknown error"
856
+ };
857
+ }
858
+ if (status >= 400) return {
859
+ ok: false,
860
+ reason: "rejected",
861
+ status
862
+ };
863
+ try {
864
+ const next = tokensFromBody(body);
865
+ return {
866
+ ok: true,
867
+ token: next.token,
868
+ refresh: next.refresh.length > 0 ? next.refresh : refresh
869
+ };
870
+ } catch {
871
+ return {
872
+ ok: false,
873
+ reason: "unreadable-response",
874
+ status
875
+ };
876
+ }
877
+ }
878
+ /** The access token's JWT `exp` in epoch ms; `null` when the token carries none. */
879
+ function homeConnectTokenExpiresAtMs(token) {
880
+ const payload = token.split(".")[1];
881
+ if (!payload) return null;
882
+ try {
883
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
884
+ if (!isRecord$1(parsed) || typeof parsed["exp"] !== "number") return null;
885
+ return parsed["exp"] * 1e3;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
742
890
  /** Browser login link, matching Home Connect Local (`redirect_url` + the app scope). */
743
891
  function beginHomeConnectSignIn() {
744
892
  const verifier = b64url((0, node_crypto.randomBytes)(32));
@@ -2745,34 +2893,141 @@ function accountClient(appliances, makeGateway) {
2745
2893
  }
2746
2894
  };
2747
2895
  }
2748
- //#endregion
2749
- //#region src/home-connect/home-connect-iddf-fetch.ts
2750
2896
  function entityCountInStoredDescription(description) {
2751
2897
  if (description === void 0) return 0;
2752
2898
  return entityIndexFromDescription(description).size;
2753
2899
  }
2754
- async function fetchHomeConnectIddfCompact(haId, token, fetchImpl) {
2900
+ /** How many leading body bytes the unparsable-body probe logs. */
2901
+ var HEAD_HEX_BYTES = 16;
2902
+ async function fetchHomeConnectIddf(haId, token, fetchImpl) {
2755
2903
  const response = await fetchImpl(HOME_CONNECT_IDDF_URL(haId), {
2756
2904
  method: "GET",
2757
- headers: {
2758
- authorization: `Bearer ${token}`,
2759
- accept: "application/zip"
2760
- },
2905
+ headers: homeConnectIddfHeaders(token),
2761
2906
  redirect: "manual"
2762
2907
  });
2763
- if (response.status >= 400 || !response.arrayBuffer) return null;
2764
- const bytes = await response.arrayBuffer();
2765
- return parseIddfZipBuffer(Buffer.from(bytes), haId);
2908
+ const contentType = response.headers.get("content-type");
2909
+ const headers = {
2910
+ status: response.status,
2911
+ contentType,
2912
+ contentEncoding: response.headers.get("content-encoding"),
2913
+ contentDisposition: response.headers.get("content-disposition")
2914
+ };
2915
+ const unread = {
2916
+ ...headers,
2917
+ bytes: null,
2918
+ zipEntries: null,
2919
+ zipEntryNames: null,
2920
+ headHex: null
2921
+ };
2922
+ if (response.status === 401) return {
2923
+ ok: false,
2924
+ reason: "unauthorized",
2925
+ entityCount: 0,
2926
+ ...unread
2927
+ };
2928
+ if (response.status >= 400) return {
2929
+ ok: false,
2930
+ reason: "http-error",
2931
+ entityCount: 0,
2932
+ ...unread
2933
+ };
2934
+ if (!response.arrayBuffer) return {
2935
+ ok: false,
2936
+ reason: "no-body",
2937
+ entityCount: 0,
2938
+ ...unread
2939
+ };
2940
+ const body = Buffer.from(await response.arrayBuffer());
2941
+ const compact = parseIddfZipBuffer(body, haId);
2942
+ const read = () => {
2943
+ const names = [...extractZipEntries(body).keys()];
2944
+ return {
2945
+ ...headers,
2946
+ bytes: body.length,
2947
+ zipEntries: names.length,
2948
+ zipEntryNames: names,
2949
+ headHex: body.subarray(0, HEAD_HEX_BYTES).toString("hex")
2950
+ };
2951
+ };
2952
+ if (compact === null) return {
2953
+ ok: false,
2954
+ reason: "unparsable-zip",
2955
+ entityCount: 0,
2956
+ ...read()
2957
+ };
2958
+ const entityCount = entityCountForCompact(compact);
2959
+ if (entityCount === 0) return {
2960
+ ok: false,
2961
+ reason: "no-entities",
2962
+ entityCount,
2963
+ ...read()
2964
+ };
2965
+ return {
2966
+ ok: true,
2967
+ compact,
2968
+ entityCount
2969
+ };
2766
2970
  }
2767
2971
  function compactDescriptionToStored(compact) {
2768
2972
  return JSON.parse(JSON.stringify(compact));
2769
2973
  }
2770
2974
  function entityCountForCompact(compact) {
2771
- if (compact === null) return 0;
2772
2975
  return entityIndexFromDescription(JSON.parse(JSON.stringify(compact))).size;
2773
2976
  }
2977
+ function refreshFailureMeta(failure) {
2978
+ return {
2979
+ reason: `refresh-${failure.reason}`,
2980
+ status: failure.status,
2981
+ ...failure.error !== void 0 ? { error: failure.error } : {}
2982
+ };
2983
+ }
2984
+ /** Refresh once; on failure the state is untouched (D49) and the reason is logged. */
2985
+ async function refreshOnce(state, trigger, fetchImpl, hooks) {
2986
+ if (state.refreshTried) return false;
2987
+ state.refreshTried = true;
2988
+ const result = await refreshHomeConnectToken(state.refresh, fetchImpl);
2989
+ if (!result.ok) {
2990
+ hooks.onWarn?.("Home Connect access token refresh failed — keeping the stored token", {
2991
+ trigger,
2992
+ ...refreshFailureMeta(result)
2993
+ });
2994
+ return false;
2995
+ }
2996
+ state.token = result.token;
2997
+ state.refresh = result.refresh;
2998
+ state.rotated = true;
2999
+ hooks.onInfo?.("Home Connect access token refreshed", { trigger });
3000
+ return true;
3001
+ }
3002
+ function tokenNeedsRefresh(token, now) {
3003
+ const expiresAt = homeConnectTokenExpiresAtMs(token);
3004
+ return expiresAt !== null && expiresAt - now <= 3e5;
3005
+ }
3006
+ async function fetchWithRefresh(haId, state, fetchImpl, hooks) {
3007
+ const first = await fetchHomeConnectIddf(haId, state.token, fetchImpl);
3008
+ if (first.ok || first.reason !== "unauthorized") return first;
3009
+ if (!await refreshOnce(state, "unauthorized", fetchImpl, hooks)) return first;
3010
+ return fetchHomeConnectIddf(haId, state.token, fetchImpl);
3011
+ }
3012
+ function failureMeta(failure) {
3013
+ return {
3014
+ reason: failure.reason,
3015
+ status: failure.status,
3016
+ contentType: failure.contentType,
3017
+ bytes: failure.bytes,
3018
+ zipEntries: failure.zipEntries,
3019
+ zipEntryNames: failure.zipEntryNames,
3020
+ headHex: failure.headHex,
3021
+ contentEncoding: failure.contentEncoding,
3022
+ contentDisposition: failure.contentDisposition,
3023
+ entityCount: failure.entityCount
3024
+ };
3025
+ }
2774
3026
  /**
2775
3027
  * Fill missing or outdated IDDF entity maps on stored appliances (TLS first).
3028
+ * An access token at or near its expiry is refreshed first, and a 401 refreshes once and
3029
+ * retries; a rotated pair is returned on the connection (`tokenRotated`) for the caller to
3030
+ * persist. A failed refresh changes nothing (D49).
2776
3031
  * An appliance whose IDDF cannot be fetched keeps whatever it had and is listed in
2777
3032
  * `missing` so the caller can retry — the entity map of ANOTHER model is never
2778
3033
  * substituted: the UIDs differ per model and a wrong map decodes values into the
@@ -2783,47 +3038,71 @@ async function enrichHomeConnectConnection(connection, fetchImpl, hooks = {}) {
2783
3038
  if (appliances.length === 0) return {
2784
3039
  connection,
2785
3040
  changed: false,
3041
+ tokenRotated: false,
2786
3042
  missing: []
2787
3043
  };
2788
3044
  const token = connection.token;
2789
3045
  if (require_dist.isSecretMarker(token) || require_dist.isSecretMarkerLike(token)) return {
2790
3046
  connection,
2791
3047
  changed: false,
3048
+ tokenRotated: false,
2792
3049
  missing: unmapped(appliances)
2793
3050
  };
3051
+ const refreshStored = connection.refresh ?? "";
3052
+ const state = {
3053
+ token,
3054
+ refresh: require_dist.isSecretMarker(refreshStored) || require_dist.isSecretMarkerLike(refreshStored) ? "" : refreshStored,
3055
+ rotated: false,
3056
+ refreshTried: false
3057
+ };
3058
+ const pending = appliances.filter((row) => row.mode === "TLS" && !isCurrentDescription(row.deviceDescription));
3059
+ if (pending.length > 0 && tokenNeedsRefresh(state.token, (hooks.now ?? Date.now)())) await refreshOnce(state, "expired", fetchImpl, hooks);
2794
3060
  let changed = false;
2795
3061
  const nextAppliances = [];
2796
3062
  for (const row of appliances) {
2797
- if (!(row.mode === "TLS" && !isCurrentDescription(row.deviceDescription))) {
3063
+ if (!pending.includes(row)) {
2798
3064
  nextAppliances.push(row);
2799
3065
  continue;
2800
3066
  }
2801
- let compact = null;
3067
+ let result = null;
2802
3068
  try {
2803
- compact = await fetchHomeConnectIddfCompact(row.haId, token, fetchImpl);
3069
+ result = await fetchWithRefresh(row.haId, state, fetchImpl, hooks);
2804
3070
  } catch (err) {
2805
- hooks.onInfo?.("Home Connect IDDF fetch failed", {
3071
+ hooks.onWarn?.("Home Connect IDDF fetch failed", {
2806
3072
  haId: row.haId,
2807
3073
  error: require_dist.errMsg(err)
2808
3074
  });
2809
3075
  }
2810
- if (compact === null || entityCountForCompact(compact) === 0) {
2811
- hooks.onInfo?.("Home Connect IDDF unavailable — appliance stays degraded until it loads", { haId: row.haId });
3076
+ if (result === null || !result.ok) {
3077
+ hooks.onWarn?.("Home Connect IDDF unavailable — appliance stays degraded until it loads", {
3078
+ haId: row.haId,
3079
+ ...result === null ? { reason: "fetch-threw" } : failureMeta(result)
3080
+ });
2812
3081
  nextAppliances.push(row);
2813
3082
  continue;
2814
3083
  }
2815
3084
  changed = true;
3085
+ hooks.onInfo?.("Home Connect IDDF loaded", {
3086
+ haId: row.haId,
3087
+ entityCount: result.entityCount
3088
+ });
2816
3089
  nextAppliances.push({
2817
3090
  ...row,
2818
- deviceDescription: compactDescriptionToStored(compact)
3091
+ deviceDescription: compactDescriptionToStored(result.compact)
2819
3092
  });
2820
3093
  }
3094
+ const withAppliances = changed ? {
3095
+ ...connection,
3096
+ appliances: nextAppliances
3097
+ } : connection;
2821
3098
  return {
2822
- connection: changed ? {
2823
- ...connection,
2824
- appliances: nextAppliances
2825
- } : connection,
3099
+ connection: state.rotated ? {
3100
+ ...withAppliances,
3101
+ token: state.token,
3102
+ refresh: state.refresh
3103
+ } : withAppliances,
2826
3104
  changed,
3105
+ tokenRotated: state.rotated,
2827
3106
  missing: unmapped(nextAppliances)
2828
3107
  };
2829
3108
  }
@@ -2885,6 +3164,19 @@ var HOME_CONNECT_IDDF_RETRY_MS = [
2885
3164
  3e5,
2886
3165
  6e5
2887
3166
  ];
3167
+ /** Route the enrich pass's lines to the logger, tagged with the integration (haId in meta). */
3168
+ function enrichLogHooks(logger, integrationId) {
3169
+ return {
3170
+ onInfo: (message, meta) => logger?.info(message, {
3171
+ tags: { integrationId },
3172
+ meta
3173
+ }),
3174
+ onWarn: (message, meta) => logger?.warn(message, {
3175
+ tags: { integrationId },
3176
+ meta
3177
+ })
3178
+ };
3179
+ }
2888
3180
  async function openStoredOrAccount(connection, deps) {
2889
3181
  let working = connection;
2890
3182
  let missing = [];
@@ -2894,12 +3186,7 @@ async function openStoredOrAccount(connection, deps) {
2894
3186
  tags: { integrationId: deps.integrationId },
2895
3187
  meta: { tokenIsSecretMarker: require_dist.isSecretMarker(token) }
2896
3188
  });
2897
- const enriched = await enrichHomeConnectConnection(working, deps.fetchImpl ?? homeConnectLoginFetch, { onInfo: (message, meta) => {
2898
- deps.logger?.info(message, {
2899
- tags: { integrationId: deps.integrationId },
2900
- meta
2901
- });
2902
- } });
3189
+ const enriched = await enrichHomeConnectConnection(working, deps.fetchImpl ?? homeConnectLoginFetch, enrichLogHooks(deps.logger, deps.integrationId));
2903
3190
  working = enriched.connection;
2904
3191
  missing = enriched.missing;
2905
3192
  if (enriched.changed) deps.logger?.info("Home Connect enriched appliance IDDF from cloud", {
@@ -3085,14 +3372,13 @@ var HomeConnectIntegrationManager = class {
3085
3372
  const client = this.#client;
3086
3373
  if (client === null) return;
3087
3374
  try {
3088
- const result = await enrichHomeConnectConnection(this.#connection, this.#fetchImpl ?? homeConnectLoginFetch, { onInfo: (message, meta) => {
3089
- this.#logger.info(message, {
3090
- tags: { integrationId: this.#id },
3091
- meta
3092
- });
3093
- } });
3375
+ const result = await enrichHomeConnectConnection(this.#connection, this.#fetchImpl ?? homeConnectLoginFetch, enrichLogHooks(this.#logger, this.#id));
3094
3376
  if (this.#client !== client) return;
3095
3377
  this.#missing = result.missing;
3378
+ if (result.tokenRotated && !result.changed) {
3379
+ this.#connection = result.connection;
3380
+ this.#onConnectionUpdated?.(this.#id, this.#connection);
3381
+ }
3096
3382
  if (result.changed) {
3097
3383
  this.#connection = result.connection;
3098
3384
  for (const row of result.connection.appliances ?? []) {
@@ -3320,6 +3606,55 @@ function buildHomeConnectConnectionTestProvider(deps) {
3320
3606
  };
3321
3607
  }
3322
3608
  //#endregion
3609
+ //#region src/home-connect/home-connect-connection-persist.ts
3610
+ function locked(value) {
3611
+ return require_dist.isSecretMarker(value) || require_dist.isSecretMarkerLike(value);
3612
+ }
3613
+ /**
3614
+ * Persist what a connection learned at runtime — enriched IDDF entity maps and a rotated
3615
+ * access / refresh token — in ONE `setSettings` write. The tokens are named in `secretKeys`
3616
+ * so they land in the keychain, never on the plain blob. Writes nothing when a stored secret
3617
+ * cannot be read right now: the write would drop a key it cannot see or be refused. Never
3618
+ * throws and never logs a value.
3619
+ */
3620
+ function buildHomeConnectConnectionPersister(deps) {
3621
+ return async (integrationId, connection) => {
3622
+ if (!connection.appliances || connection.appliances.length === 0) return;
3623
+ try {
3624
+ const settings = await deps.getSettings(integrationId);
3625
+ const stored = readApplianceSecrets(settings[APPLIANCE_SECRETS_KEY]);
3626
+ const lockedTokens = ["token", "refresh"].filter((key) => locked(settings[key]));
3627
+ if (stored.kind === "unresolved" || stored.kind === "invalid" || lockedTokens.length > 0) {
3628
+ deps.logger.warn("Home Connect not persisting the connection — stored secrets are locked or unreadable", {
3629
+ tags: { integrationId },
3630
+ meta: {
3631
+ applianceSecrets: stored.kind,
3632
+ lockedTokens
3633
+ }
3634
+ });
3635
+ return;
3636
+ }
3637
+ const refresh = connection.refresh ?? "";
3638
+ const merged = homeConnectSettingsForPersist({
3639
+ ...settings,
3640
+ token: connection.token,
3641
+ ...refresh.length > 0 ? { refresh } : {},
3642
+ appliances: mergeAppliancesForPersist(settings["appliances"], connection.appliances)
3643
+ });
3644
+ await deps.setSettings({
3645
+ id: integrationId,
3646
+ settings: merged,
3647
+ secretKeys: [...HOME_CONNECT_SECRET_KEYS]
3648
+ });
3649
+ } catch (err) {
3650
+ deps.logger.warn("Home Connect failed to persist the connection", {
3651
+ tags: { integrationId },
3652
+ meta: { error: require_dist.errMsg(err) }
3653
+ });
3654
+ }
3655
+ };
3656
+ }
3657
+ //#endregion
3323
3658
  //#region src/home-connect/home-connect-units.ts
3324
3659
  /**
3325
3660
  * Units are a property of the ENTITY, fixed for its whole series. A value is never
@@ -3434,14 +3769,20 @@ function operationStateOf(raw) {
3434
3769
  const parsed = require_dist.DishwasherOperationStateSchema.safeParse(raw);
3435
3770
  return parsed.success ? parsed.data : null;
3436
3771
  }
3772
+ /** The reported state of EVERY listed event the description has — each read on its own. */
3773
+ function eventStates(view, shortNames) {
3774
+ return (view.description?.entities() ?? []).filter((e) => e.kind === "event" && shortNames.includes(featureShortName(e.name))).map((e) => eventActive(valueOf(view, e)));
3775
+ }
3776
+ /**
3777
+ * Any empty-class event raised → `empty`; else any nearly-empty one raised → `low`; else any of
3778
+ * them reading clear → `ok`; nothing reported → `null` (unknown, never `ok`).
3779
+ */
3437
3780
  function consumableFromEvents(view, nearlyEmpty, empty) {
3438
- const nearly = findEntity(view.description, ["event"], nearlyEmpty);
3439
- const gone = findEntity(view.description, ["event"], empty);
3440
- if (nearly === void 0 && gone === void 0) return null;
3441
- if (eventActive(valueOf(view, gone)) === true) return "empty";
3442
- const nearlyActive = eventActive(valueOf(view, nearly));
3443
- if (nearlyActive === true) return "low";
3444
- if (nearlyActive === false) return "ok";
3781
+ const gone = eventStates(view, empty);
3782
+ const nearly = eventStates(view, nearlyEmpty);
3783
+ if (gone.includes(true)) return "empty";
3784
+ if (nearly.includes(true)) return "low";
3785
+ if (gone.includes(false) || nearly.includes(false)) return "ok";
3445
3786
  return null;
3446
3787
  }
3447
3788
  function programKey(raw) {
@@ -3531,8 +3872,8 @@ function dishwasherStatusFromFeatures(input) {
3531
3872
  localControlActive: boolOf(valueOf(input, findEntity(description, ["status"], ["LocalControlActive"]))),
3532
3873
  powerOn: powerRaw === null ? null : powerRaw === "On",
3533
3874
  powerState: powerRaw,
3534
- saltLevel: consumableFromEvents(input, ["SaltNearlyEmpty"], ["SaltEmpty"]),
3535
- rinseAidLevel: consumableFromEvents(input, ["RinseAidNearlyEmpty"], ["RinseAidEmpty"]),
3875
+ saltLevel: consumableFromEvents(input, ["SaltNearlyEmpty"], ["SaltEmpty", "SaltLack"]),
3876
+ rinseAidLevel: consumableFromEvents(input, ["RinseAidNearlyEmpty"], ["RinseAidEmpty", "RinseAidLack"]),
3536
3877
  halfLoad: optionBool(input, ["HalfLoad"]),
3537
3878
  hygienePlus: optionBool(input, ["HygienePlus"]),
3538
3879
  extraDry: optionBool(input, ["ExtraDry"]),
@@ -3942,22 +4283,38 @@ function legacyKindBinding(kind, name) {
3942
4283
  function isSyntheticKey(key) {
3943
4284
  return key.startsWith("HomeConnect.Info.") || key.startsWith("HomeConnect.Network.");
3944
4285
  }
4286
+ /** Keys of persisted children the description does not list (info / network keys never count). */
4287
+ function keysMissingFromDescription(persisted, description) {
4288
+ return new Set(persisted.filter((b) => !isSyntheticKey(b.key) && description.entityByName(b.key) === void 0).map((b) => b.key));
4289
+ }
3945
4290
  /**
3946
4291
  * The bindings a container should carry: what the description derives now, plus what it
3947
- * already had — unless the entity left the description. A value that is unknown right now
3948
- * (the link dropped) never removes a child; only the appliance dropping the entity does.
4292
+ * already had. A child leaves only when its entity is missing from TWO agreeing reads (D49):
4293
+ * `missingBefore` is what the previous read lacked — one description that omits an entity is
4294
+ * not enough to delete a device. A derived binding for a key the container already carries
4295
+ * keeps the existing child's `stableIdSuffix` and name, so a container saved before the
4296
+ * derived model (legacy children) keeps its device ids instead of being rebuilt.
3949
4297
  */
3950
- function mergeChildBindings(persisted, derived, description) {
4298
+ function mergeChildBindings(persisted, derived, description, missingBefore = /* @__PURE__ */ new Set()) {
4299
+ const missingNow = keysMissingFromDescription(persisted, description);
3951
4300
  const out = /* @__PURE__ */ new Map();
3952
- for (const kept of persisted) if (isSyntheticKey(kept.key) || description.entityByName(kept.key) !== void 0) out.set(kept.stableIdSuffix, kept);
3953
- for (const binding of derived) out.set(binding.stableIdSuffix, binding);
4301
+ const suffixByKey = /* @__PURE__ */ new Map();
4302
+ for (const kept of persisted) {
4303
+ if (missingNow.has(kept.key) && missingBefore.has(kept.key)) continue;
4304
+ out.set(kept.stableIdSuffix, kept);
4305
+ suffixByKey.set(kept.key, kept);
4306
+ }
4307
+ for (const binding of derived) {
4308
+ const existing = suffixByKey.get(binding.key);
4309
+ const merged = existing === void 0 ? binding : {
4310
+ ...binding,
4311
+ stableIdSuffix: existing.stableIdSuffix,
4312
+ name: existing.name
4313
+ };
4314
+ out.set(merged.stableIdSuffix, merged);
4315
+ }
3954
4316
  return [...out.values()].toSorted((a, b) => a.stableIdSuffix.localeCompare(b.stableIdSuffix));
3955
4317
  }
3956
- /**
3957
- * Keeps each adopted appliance's child devices equal to what its description declares.
3958
- * Runs on the addon, outside the container (rebuilding a container from inside itself
3959
- * would tear down the code that asked for it).
3960
- */
3961
4318
  var HomeConnectChildReconciler = class {
3962
4319
  #deps;
3963
4320
  #tracked = /* @__PURE__ */ new Map();
@@ -3970,6 +4327,8 @@ var HomeConnectChildReconciler = class {
3970
4327
  const entry = {
3971
4328
  a: appliance,
3972
4329
  timer: null,
4330
+ missingBefore: /* @__PURE__ */ new Set(),
4331
+ waiting: null,
3973
4332
  unwatch: this.#deps.watch(appliance.brokerId, appliance.haId, () => this.schedule(appliance.deviceId))
3974
4333
  };
3975
4334
  this.#tracked.set(appliance.deviceId, entry);
@@ -4005,8 +4364,18 @@ var HomeConnectChildReconciler = class {
4005
4364
  const { brokerId, haId } = entry.a;
4006
4365
  const client = this.#deps.getClient(brokerId);
4007
4366
  const description = client?.getDescription(haId) ?? null;
4008
- if (client === null || client.getLinkState(haId) !== "connected") return;
4009
- if (description === null || description.state === "missing") return;
4367
+ const waiting = client === null || client.getLinkState(haId) !== "connected" ? "link-down" : description === null || description.state === "missing" ? "description-missing" : null;
4368
+ if (waiting !== entry.waiting) {
4369
+ entry.waiting = waiting;
4370
+ if (waiting !== null) this.#deps.logger.info("Home Connect child reconcile waiting", {
4371
+ tags: { deviceId },
4372
+ meta: {
4373
+ haId,
4374
+ reason: waiting
4375
+ }
4376
+ });
4377
+ }
4378
+ if (client === null || description === null || waiting !== null) return;
4010
4379
  try {
4011
4380
  const { bindings: persisted, malformed } = await this.#deps.readPersisted(deviceId);
4012
4381
  if (malformed > 0) {
@@ -4019,7 +4388,13 @@ var HomeConnectChildReconciler = class {
4019
4388
  const merged = mergeChildBindings(persisted, deriveChildBindings({
4020
4389
  description,
4021
4390
  features: client.getFeatures(haId)
4022
- }), description);
4391
+ }), description, entry.missingBefore);
4392
+ const missingNow = keysMissingFromDescription(merged, description);
4393
+ if (missingNow.size > 0 && missingNow.size !== entry.missingBefore.size) this.#deps.logger.info("Home Connect children kept until a second read agrees they left", {
4394
+ tags: { deviceId },
4395
+ meta: { missing: [...missingNow] }
4396
+ });
4397
+ entry.missingBefore = missingNow;
4023
4398
  if (JSON.stringify(merged) === JSON.stringify(sorted(persisted))) return;
4024
4399
  await this.#deps.applyEntities(deviceId, merged);
4025
4400
  this.#deps.logger.info("Home Connect children re-derived from the appliance description", {
@@ -4041,6 +4416,19 @@ function sorted(list) {
4041
4416
  return list.toSorted((a, b) => a.stableIdSuffix.localeCompare(b.stableIdSuffix));
4042
4417
  }
4043
4418
  /**
4419
+ * A container's persisted bindings from its config. One saved before the derived model has no
4420
+ * `entities`, only the legacy `children` the device publishes today: those ARE its bindings,
4421
+ * so the first derived write keeps them (same suffixes, same device ids) instead of reading
4422
+ * "no bindings" and rebuilding every child.
4423
+ */
4424
+ function persistedChildBindingsFromConfig(cfg) {
4425
+ if (cfg["entities"] === void 0) return {
4426
+ bindings: legacyChildBindings(cfg["children"]),
4427
+ malformed: 0
4428
+ };
4429
+ return parsePersistedBindings(cfg["entities"]);
4430
+ }
4431
+ /**
4044
4432
  * Parse a container's persisted `entities`. A row that does not parse is COUNTED, not dropped
4045
4433
  * silently: the caller must not write a list that would lose it.
4046
4434
  */
@@ -4702,14 +5090,14 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
4702
5090
  } });
4703
5091
  this.clients.setOnConnected((integrationId) => {
4704
5092
  this.setIntegrationDevicesOnline(integrationId, true);
4705
- this.trackAppliances();
5093
+ this.trackAppliances("connected");
4706
5094
  });
4707
5095
  this.clients.setOnDisconnected((integrationId) => {
4708
5096
  this.setIntegrationDevicesOnline(integrationId, false);
4709
5097
  });
4710
5098
  this.reconciler = this.buildChildReconciler();
4711
5099
  await this.reconcileIntegrations();
4712
- await this.trackAppliances();
5100
+ await this.trackAppliances("init");
4713
5101
  this.subscribeIntegrationLifecycle();
4714
5102
  return {
4715
5103
  providers: [
@@ -4784,30 +5172,13 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
4784
5172
  if (type === require_dist.DeviceType.Container) return `home-connect:${integrationId}:${haId}:appliance`;
4785
5173
  return `home-connect:${integrationId}:${haId}`;
4786
5174
  }
4787
- async persistIntegrationAppliances(integrationId, connection) {
4788
- if (!connection.appliances || connection.appliances.length === 0) return;
4789
- try {
4790
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integrationId });
4791
- const stored = readApplianceSecrets(settings[APPLIANCE_SECRETS_KEY]);
4792
- if (stored.kind === "unresolved" || stored.kind === "invalid") {
4793
- this.ctx.logger.warn("Home Connect not persisting the enriched IDDF — the stored appliance keys are locked or unreadable", { tags: { integrationId } });
4794
- return;
4795
- }
4796
- const merged = homeConnectSettingsForPersist({
4797
- ...settings,
4798
- appliances: mergeAppliancesForPersist(settings["appliances"], connection.appliances)
4799
- });
4800
- await this.ctx.api.integrations.setSettings.mutate({
4801
- id: integrationId,
4802
- settings: merged,
4803
- secretKeys: [...HOME_CONNECT_SECRET_KEYS]
4804
- });
4805
- } catch (err) {
4806
- this.ctx.logger.warn("Home Connect failed to persist enriched appliance IDDF", {
4807
- tags: { integrationId },
4808
- meta: { error: require_dist.errMsg(err) }
4809
- });
4810
- }
5175
+ /** Enriched IDDF maps and a rotated token pair, in one keychain-aware write. */
5176
+ persistIntegrationAppliances(integrationId, connection) {
5177
+ return buildHomeConnectConnectionPersister({
5178
+ getSettings: (id) => this.ctx.api.integrations.getSettings.query({ id }),
5179
+ setSettings: (input) => this.ctx.api.integrations.setSettings.mutate(input),
5180
+ logger: this.ctx.logger
5181
+ })(integrationId, connection);
4811
5182
  }
4812
5183
  async reconcileIntegrations() {
4813
5184
  const reg = this.requireClients();
@@ -4913,7 +5284,7 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
4913
5284
  name,
4914
5285
  integrationId
4915
5286
  });
4916
- this.trackAppliances();
5287
+ this.trackAppliances("adopt");
4917
5288
  return { deviceId: device.id };
4918
5289
  },
4919
5290
  removeDevice: async (id) => {
@@ -4947,31 +5318,32 @@ var BoschHomeConnectProviderAddon = class extends require_dist.BaseDeviceProvide
4947
5318
  watch: (integrationId, haId, listener) => homeConnectConnections.watch(integrationId, haId, listener),
4948
5319
  getClient: (integrationId) => homeConnectConnections.getClient(integrationId),
4949
5320
  readPersisted: async (deviceId) => {
4950
- return parsePersistedBindings((await devices().loadConfig(deviceId))["entities"]);
5321
+ return persistedChildBindingsFromConfig(await devices().loadConfig(deviceId));
4951
5322
  },
4952
5323
  applyEntities: (deviceId, entities) => devices().applyDerivedSpec(deviceId, { config: { entities: [...entities] } }),
4953
5324
  logger: this.ctx.logger
4954
5325
  });
4955
5326
  }
5327
+ /**
5328
+ * The runner restores devices only AFTER `onInitialize` returned, so the init pass of
5329
+ * {@link trackAppliances} sees none: watch the restored containers here, or a description
5330
+ * that loads later never derives their children.
5331
+ */
5332
+ async restoreDevices(savedDevices) {
5333
+ await super.restoreDevices(savedDevices);
5334
+ await this.trackAppliances("restore");
5335
+ }
4956
5336
  /** Keep a reconciler watch on every adopted appliance container. */
4957
- async trackAppliances() {
4958
- const reg = this.ctx.kernel.deviceRegistry;
5337
+ async trackAppliances(trigger) {
4959
5338
  const devices = this.ctx.kernel.devices;
4960
5339
  const reconciler = this.reconciler;
4961
- if (!reg || !devices || !reconciler) return;
4962
- for (const d of reg.getAllForAddon(this.addonId)) {
4963
- if (d.type !== require_dist.DeviceType.Container || reconciler.tracks(d.id)) continue;
4964
- const cfg = await devices.loadConfig(d.id).catch(() => ({}));
4965
- const brokerId = cfg["brokerId"];
4966
- const haId = cfg["haId"];
4967
- if (cfg["system"] !== "home-connect" || typeof brokerId !== "string") continue;
4968
- if (typeof haId !== "string") continue;
4969
- reconciler.track({
4970
- deviceId: d.id,
4971
- brokerId,
4972
- haId
4973
- });
4974
- }
5340
+ if (!reconciler) return;
5341
+ await trackHomeConnectContainers({
5342
+ listDevices: () => devices.getAll(),
5343
+ loadConfig: (id) => devices.loadConfig(id),
5344
+ reconciler,
5345
+ logger: this.ctx.logger
5346
+ }, trigger);
4975
5347
  }
4976
5348
  globalSettingsSchema() {
4977
5349
  return buildHomeConnectFormSchema();