@camstack/addon-provider-bosch 0.1.11 → 0.1.12
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.
- package/dist/{account-integration-offline-BDOU9UIQ.js → account-integration-offline-CQ9kUtrn.js} +1 -1
- package/dist/{account-integration-offline-CifBq3c2.mjs → account-integration-offline-ei95Ru3m.mjs} +1 -1
- package/dist/{dist-Dwa_n1ZO.js → dist-CiKY7_En.js} +9 -15
- package/dist/{dist-EaW3D8xD.mjs → dist-DwSpbpO9.mjs} +9 -15
- package/dist/home-connect/addon.js +493 -121
- package/dist/home-connect/addon.mjs +493 -121
- package/dist/homecom/addon.js +2 -2
- package/dist/homecom/addon.mjs +2 -2
- package/dist/shc/addon.js +1 -1
- package/dist/shc/addon.mjs +1 -1
- package/package.json +1 -1
|
@@ -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-
|
|
2
|
-
import { n as cascadeRemoveDevicesForIntegration, r as parseOAuthCallback, t as setIntegrationDevicesOnline } from "../account-integration-offline-
|
|
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-DwSpbpO9.mjs";
|
|
2
|
+
import { n as cascadeRemoveDevicesForIntegration, r as parseOAuthCallback, t as setIntegrationDevicesOnline } from "../account-integration-offline-ei95Ru3m.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
|
-
|
|
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
|
-
|
|
712
|
-
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2763
|
-
const
|
|
2764
|
-
|
|
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 (!
|
|
3062
|
+
if (!pending.includes(row)) {
|
|
2797
3063
|
nextAppliances.push(row);
|
|
2798
3064
|
continue;
|
|
2799
3065
|
}
|
|
2800
|
-
let
|
|
3066
|
+
let result = null;
|
|
2801
3067
|
try {
|
|
2802
|
-
|
|
3068
|
+
result = await fetchWithRefresh(row.haId, state, fetchImpl, hooks);
|
|
2803
3069
|
} catch (err) {
|
|
2804
|
-
hooks.
|
|
3070
|
+
hooks.onWarn?.("Home Connect IDDF fetch failed", {
|
|
2805
3071
|
haId: row.haId,
|
|
2806
3072
|
error: errMsg(err)
|
|
2807
3073
|
});
|
|
2808
3074
|
}
|
|
2809
|
-
if (
|
|
2810
|
-
hooks.
|
|
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:
|
|
2822
|
-
...
|
|
2823
|
-
|
|
2824
|
-
|
|
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,
|
|
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,
|
|
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
|
|
3438
|
-
const
|
|
3439
|
-
if (
|
|
3440
|
-
if (
|
|
3441
|
-
|
|
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
|
|
3947
|
-
*
|
|
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
|
-
|
|
3952
|
-
for (const
|
|
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
|
-
|
|
4008
|
-
if (
|
|
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
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
4791
|
-
|
|
4792
|
-
|
|
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
|
|
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 (!
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
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();
|