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