@camstack/addon-provider-wyze 0.1.7 → 0.1.8

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/addon.js CHANGED
@@ -21,8 +21,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- let node_fs = require("node:fs");
25
24
  let node_path = require("node:path");
25
+ let node_fs = require("node:fs");
26
26
  let crypto$1 = require("crypto");
27
27
  let dgram = require("dgram");
28
28
  dgram = __toESM(dgram, 1);
@@ -6644,6 +6644,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6644
6644
  input: unknown()
6645
6645
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
6646
6646
  //#endregion
6647
+ //#region ../types/dist/err-msg-IQTHeDzc.mjs
6648
+ /**
6649
+ import { errMsg } from '@camstack/types'
6650
+ * Extract a human-readable message from an unknown error value.
6651
+ * Replaces the ubiquitous `errMsg(err)` pattern.
6652
+ */
6653
+ function errMsg(err) {
6654
+ if (err instanceof Error) return err.message;
6655
+ if (typeof err === "string") return err;
6656
+ return String(err);
6657
+ }
6658
+ //#endregion
6647
6659
  //#region ../types/dist/index.mjs
6648
6660
  /**
6649
6661
  * Deep wiring healthcheck — snapshot of active reachability probes across
@@ -15048,19 +15060,36 @@ var ResyncResultSchema = object({
15048
15060
  * provider re-derived the device. 0/absent for a normal incremental re-sync. */
15049
15061
  removedChildren: number().int().nonnegative().optional()
15050
15062
  });
15051
- method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
15052
- kind: "mutation",
15053
- auth: "admin"
15054
- }), method(AdoptInputSchema, AdoptResultSchema, {
15055
- kind: "mutation",
15056
- auth: "admin"
15057
- }), method(ReleaseInputSchema, _void(), {
15058
- kind: "mutation",
15059
- auth: "admin"
15060
- }), method(ResyncInputSchema, ResyncResultSchema, {
15061
- kind: "mutation",
15062
- auth: "admin"
15063
- });
15063
+ var deviceAdoptionCapability = {
15064
+ name: "device-adoption",
15065
+ scope: "system",
15066
+ mode: "singleton",
15067
+ status: {
15068
+ schema: AdoptionStatusSchema,
15069
+ kind: "poll"
15070
+ },
15071
+ methods: {
15072
+ listCandidateFilters: method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }),
15073
+ listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
15074
+ getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
15075
+ refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, {
15076
+ kind: "mutation",
15077
+ auth: "admin"
15078
+ }),
15079
+ adopt: method(AdoptInputSchema, AdoptResultSchema, {
15080
+ kind: "mutation",
15081
+ auth: "admin"
15082
+ }),
15083
+ release: method(ReleaseInputSchema, _void(), {
15084
+ kind: "mutation",
15085
+ auth: "admin"
15086
+ }),
15087
+ resync: method(ResyncInputSchema, ResyncResultSchema, {
15088
+ kind: "mutation",
15089
+ auth: "admin"
15090
+ })
15091
+ }
15092
+ };
15064
15093
  /**
15065
15094
  * `device-export` — collection cap for addons that export camstack
15066
15095
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
@@ -18293,6 +18322,17 @@ var AvailableIntegrationTypeSchema = object({
18293
18322
  iconUrl: string().nullable(),
18294
18323
  color: string(),
18295
18324
  instanceMode: string(),
18325
+ /**
18326
+ * Integration wizard `mode` (LOCKED MODEL): `standalone` (create
18327
+ * immediately then add devices, no config step/button), `account` (config
18328
+ * step), or `broker` (broker step). Derived server-side by
18329
+ * `getAvailableTypes` when the addon manifest omits an explicit `mode`.
18330
+ */
18331
+ mode: _enum([
18332
+ "standalone",
18333
+ "account",
18334
+ "broker"
18335
+ ]),
18296
18336
  discoveryMode: string(),
18297
18337
  /**
18298
18338
  * Which integration-marker cap the addon declared, so the wizard can
@@ -27191,6 +27231,147 @@ var WyzeCloudClient = class {
27191
27231
  }
27192
27232
  };
27193
27233
  //#endregion
27234
+ //#region src/error-classifier.ts
27235
+ function isWyzeAuthError(err) {
27236
+ return typeof err === "object" && err !== null && "needsMfa" in err && err.needsMfa === true;
27237
+ }
27238
+ function classifyCloudError(err) {
27239
+ if (isWyzeAuthError(err)) return {
27240
+ kind: "mfa",
27241
+ message: `Wyze account requires MFA${typeof err.mfaType === "string" ? ` (${err.mfaType})` : ""}. The bridge library has no MFA-completion flow — use a sub-account or disable MFA on this account.`
27242
+ };
27243
+ const message = err instanceof Error ? err.message : String(err);
27244
+ const lower = message.toLowerCase();
27245
+ if (lower.includes("429") || lower.includes("too many") || lower.includes("rate")) return {
27246
+ kind: "rate-limited",
27247
+ message: `Wyze auth is rate-limited (~1 request / 30s per IP). Retry later — the session is persisted and reused to avoid this. ${message}`
27248
+ };
27249
+ if (lower.includes("password") || lower.includes("credential") || lower.includes("login failed") || lower.includes("access token") || lower.includes("unauthorized")) return {
27250
+ kind: "credentials",
27251
+ message: `Wyze login failed — check email / password / API key / Key ID. ${message}`
27252
+ };
27253
+ if (lower.includes("dtls") || lower.includes("keyframe") || lower.includes("p2p") || lower.includes("discover") || lower.includes("handshake") || lower.includes("kauth")) return {
27254
+ kind: "p2p",
27255
+ message: `Wyze P2P session failed (DTLS / discovery / keyframe). ${message}`
27256
+ };
27257
+ return {
27258
+ kind: "unknown",
27259
+ message
27260
+ };
27261
+ }
27262
+ //#endregion
27263
+ //#region src/client-registry.ts
27264
+ /**
27265
+ * Per-integration Wyze cloud client registry (account mode).
27266
+ *
27267
+ * Under the LOCKED integration/adoption model, Wyze is a `mode: account`
27268
+ * addon: each Wyze integration carries its own account credentials in its
27269
+ * `integration.settings`, and this registry holds one
27270
+ * {@link WyzeCloudClient} per `integrationId`
27271
+ * (`Map<integrationId, WyzeCloudClient>`). Multi-account = multiple
27272
+ * integrations; there is NO shared broker.
27273
+ *
27274
+ * Each client's persisted session lives under a per-integration
27275
+ * subdirectory of `dataDir` so two accounts never clobber each other's
27276
+ * `wyze-session.json`.
27277
+ *
27278
+ * The registry also owns a per-integration camera-list cache (debounced to
27279
+ * respect Wyze's auth rate-limit) so both the adoption provider and the
27280
+ * live cameras read a single shared, rate-safe list per account.
27281
+ */
27282
+ var CAMERA_LIST_REFRESH_MS = 5 * 6e4;
27283
+ /** True when two credential blobs are identical (session-preserving reconcile). */
27284
+ function sameCredentials(a, b) {
27285
+ return a.email === b.email && a.password === b.password && a.apiKey === b.apiKey && a.apiId === b.apiId;
27286
+ }
27287
+ /**
27288
+ * Owns the `Map<integrationId, WyzeCloudClient>`. Callers reconcile the map
27289
+ * against the live integration list on boot + on every integration lifecycle
27290
+ * event; device classes + the adoption provider resolve their client by
27291
+ * `integrationId`.
27292
+ */
27293
+ var WyzeClientRegistry = class {
27294
+ #entries = /* @__PURE__ */ new Map();
27295
+ #deps;
27296
+ constructor(deps) {
27297
+ this.#deps = deps;
27298
+ }
27299
+ /**
27300
+ * Ensure a client exists for `integrationId` with the given credentials.
27301
+ * Idempotent: an existing entry with identical credentials is preserved
27302
+ * (keeps its live session + cache); a credentials change rebuilds it.
27303
+ */
27304
+ upsert(integrationId, credentials) {
27305
+ const existing = this.#entries.get(integrationId);
27306
+ if (existing && sameCredentials(existing.credentials, credentials)) return;
27307
+ const client = new WyzeCloudClient({
27308
+ dataDir: (0, node_path.join)(this.#deps.dataDir, "integrations", integrationId),
27309
+ logger: this.#deps.logger.child(`cloud:${integrationId}`),
27310
+ credentials
27311
+ });
27312
+ this.#entries.set(integrationId, {
27313
+ client,
27314
+ credentials,
27315
+ cameraListCache: [],
27316
+ cameraListFetchedAt: 0,
27317
+ cameraListInFlight: null
27318
+ });
27319
+ }
27320
+ /** Drop the client for an integration that no longer exists / was disabled. */
27321
+ remove(integrationId) {
27322
+ this.#entries.delete(integrationId);
27323
+ }
27324
+ /** Replace the whole set with `keep` — removes any client not in the set. */
27325
+ retain(keep) {
27326
+ for (const id of [...this.#entries.keys()]) if (!keep.has(id)) this.#entries.delete(id);
27327
+ }
27328
+ /** The live client for an integration, or null when unknown. */
27329
+ client(integrationId) {
27330
+ return this.#entries.get(integrationId)?.client ?? null;
27331
+ }
27332
+ /** True when a client is registered for the integration. */
27333
+ has(integrationId) {
27334
+ return this.#entries.has(integrationId);
27335
+ }
27336
+ /** The registered integration ids (one per account). */
27337
+ list() {
27338
+ return [...this.#entries.keys()];
27339
+ }
27340
+ /** Remove all clients (full shutdown). */
27341
+ clear() {
27342
+ this.#entries.clear();
27343
+ }
27344
+ /**
27345
+ * Cloud camera list for ONE integration, cached + debounced + single-flight
27346
+ * to avoid auth rate-limits. Returns the last cache (possibly empty) when the
27347
+ * integration is unknown or the fetch fails.
27348
+ */
27349
+ async getCameraList(integrationId, force = false) {
27350
+ const entry = this.#entries.get(integrationId);
27351
+ if (!entry) return [];
27352
+ const fresh = Date.now() - entry.cameraListFetchedAt < CAMERA_LIST_REFRESH_MS;
27353
+ if (!force && fresh && entry.cameraListCache.length > 0) return entry.cameraListCache;
27354
+ if (entry.cameraListInFlight) return entry.cameraListInFlight;
27355
+ const run = entry.client.getCameraList().then((cams) => {
27356
+ entry.cameraListCache = cams;
27357
+ entry.cameraListFetchedAt = Date.now();
27358
+ return cams;
27359
+ }).catch((err) => {
27360
+ const classified = classifyCloudError(err);
27361
+ this.#deps.logger.error("Wyze getCameraList failed", { meta: {
27362
+ integrationId,
27363
+ kind: classified.kind,
27364
+ error: classified.message
27365
+ } });
27366
+ return entry.cameraListCache;
27367
+ }).finally(() => {
27368
+ entry.cameraListInFlight = null;
27369
+ });
27370
+ entry.cameraListInFlight = run;
27371
+ return run;
27372
+ }
27373
+ };
27374
+ //#endregion
27194
27375
  //#region src/constants.ts
27195
27376
  /**
27196
27377
  * Single source of truth for the Wyze provider addon id. Must match the
@@ -27429,35 +27610,6 @@ function buildSettingsSchema() {
27429
27610
  }] };
27430
27611
  }
27431
27612
  //#endregion
27432
- //#region src/error-classifier.ts
27433
- function isWyzeAuthError(err) {
27434
- return typeof err === "object" && err !== null && "needsMfa" in err && err.needsMfa === true;
27435
- }
27436
- function classifyCloudError(err) {
27437
- if (isWyzeAuthError(err)) return {
27438
- kind: "mfa",
27439
- message: `Wyze account requires MFA${typeof err.mfaType === "string" ? ` (${err.mfaType})` : ""}. The bridge library has no MFA-completion flow — use a sub-account or disable MFA on this account.`
27440
- };
27441
- const message = err instanceof Error ? err.message : String(err);
27442
- const lower = message.toLowerCase();
27443
- if (lower.includes("429") || lower.includes("too many") || lower.includes("rate")) return {
27444
- kind: "rate-limited",
27445
- message: `Wyze auth is rate-limited (~1 request / 30s per IP). Retry later — the session is persisted and reused to avoid this. ${message}`
27446
- };
27447
- if (lower.includes("password") || lower.includes("credential") || lower.includes("login failed") || lower.includes("access token") || lower.includes("unauthorized")) return {
27448
- kind: "credentials",
27449
- message: `Wyze login failed — check email / password / API key / Key ID. ${message}`
27450
- };
27451
- if (lower.includes("dtls") || lower.includes("keyframe") || lower.includes("p2p") || lower.includes("discover") || lower.includes("handshake") || lower.includes("kauth")) return {
27452
- kind: "p2p",
27453
- message: `Wyze P2P session failed (DTLS / discovery / keyframe). ${message}`
27454
- };
27455
- return {
27456
- kind: "unknown",
27457
- message
27458
- };
27459
- }
27460
- //#endregion
27461
27613
  //#region src/mapping.ts
27462
27614
  /** Canonical camstack codec name from the lib's `videoType`. */
27463
27615
  function codecFromVideoType(videoType) {
@@ -27489,9 +27641,14 @@ function stableIdForMac(mac) {
27489
27641
  * Reduce a cloud `WyzeCamera` blob to the persisted camera config. Pure:
27490
27642
  * `resolution` / `bitrate` / `motionSource` carry their schema defaults
27491
27643
  * here (the device config schema re-applies them on parse).
27644
+ *
27645
+ * `integrationId` stamps the owning account so the camera resolves its
27646
+ * per-account cloud client from the registry. Defaults to `''` (legacy
27647
+ * single-global-credentials cameras) when not supplied by the adopt path.
27492
27648
  */
27493
- function cameraConfigFromCloud(cam) {
27649
+ function cameraConfigFromCloud(cam, integrationId = "") {
27494
27650
  return {
27651
+ integrationId,
27495
27652
  mac: cam.mac,
27496
27653
  p2pId: cam.p2pId,
27497
27654
  enr: cam.enr,
@@ -27557,6 +27714,15 @@ var WyzeResolutionSchema = _enum([
27557
27714
  var WyzeBitrateSchema = _enum(["max", "sd"]);
27558
27715
  var WyzeMotionSourceSchema = _enum(["cloud", "boa"]);
27559
27716
  var wyzeCameraSchema = object({
27717
+ /**
27718
+ * Owning Wyze integration id (account). Stamped at adopt/create so the
27719
+ * camera resolves its per-account {@link WyzeCloudClient} from the client
27720
+ * registry (multi-account: one integration = one account = one client).
27721
+ * Optional for back-compat with cameras persisted under the legacy
27722
+ * single-global-credentials model — those resolve via the migration
27723
+ * fallback until re-adopted.
27724
+ */
27725
+ integrationId: string().default(""),
27560
27726
  /** Hardware MAC — globally unique, the stableId discriminator. */
27561
27727
  mac: string().describe("Wyze camera MAC (hardware id)"),
27562
27728
  /** P2P UID (TUTK) — the cloud `device_params.p2p_id`. */
@@ -27584,6 +27750,192 @@ var wyzeCameraSchema = object({
27584
27750
  motionSource: WyzeMotionSourceSchema.default("cloud")
27585
27751
  });
27586
27752
  //#endregion
27753
+ //#region src/candidates.ts
27754
+ /**
27755
+ * Pure mapping: cloud camera list → `device-adoption` candidates.
27756
+ *
27757
+ * Mirrors `buildDreoCandidates` — no network I/O, no side effects, so it is
27758
+ * exhaustively unit-testable without the lib or a live account. A camera is a
27759
+ * candidate only when it carries the local P2P parameters the DTLS client
27760
+ * needs (`isAdoptableCamera`).
27761
+ */
27762
+ /**
27763
+ * Map live cloud cameras to adoption candidates. The `childNativeId` is the
27764
+ * camera MAC — globally unique + durable, the same key used for the device
27765
+ * stableId — so re-discovery of an already-adopted camera is de-duped.
27766
+ */
27767
+ function buildWyzeCandidates(input) {
27768
+ const out = [];
27769
+ for (const cam of input.cameras) {
27770
+ if (!isAdoptableCamera(cam)) continue;
27771
+ const adoptedId = input.adopted.get(cam.mac.toUpperCase()) ?? null;
27772
+ out.push({
27773
+ childNativeId: cam.mac,
27774
+ name: cam.nickname || cam.productModel || cam.mac,
27775
+ type: DeviceType.Camera,
27776
+ status: cam.isOnline === false ? "offline" : "online",
27777
+ metadata: {
27778
+ serialNumber: cam.mac,
27779
+ model: cam.productModel,
27780
+ manufacturer: "Wyze"
27781
+ },
27782
+ alreadyAdopted: adoptedId !== null,
27783
+ adoptedDeviceId: adoptedId
27784
+ });
27785
+ }
27786
+ return out;
27787
+ }
27788
+ //#endregion
27789
+ //#region src/wyze-adoption-provider.ts
27790
+ /**
27791
+ * `device-adoption` cap provider for the Wyze addon (account mode).
27792
+ *
27793
+ * Broker-less: candidates + adoption are resolved directly by
27794
+ * `integrationId` → the per-account {@link WyzeClientRegistry} client. Mirrors
27795
+ * `buildDreoAdoptionProvider`'s shape (listCandidateFilters / listCandidates /
27796
+ * getCandidate / getStatus / refresh / adopt / release / resync) with a single
27797
+ * `devices` granularity (one Camera per cloud device). Pure builder: every
27798
+ * side-effecting dependency is injected.
27799
+ */
27800
+ /** The single granularity this provider advertises: whole-device adoption. */
27801
+ var DEVICES_FILTER = {
27802
+ id: "devices",
27803
+ label: "Cameras",
27804
+ isDefault: true
27805
+ };
27806
+ /** Build a `MAC(uppercased) → CamStack deviceId` map for one integration. */
27807
+ function adoptedMapForIntegration(integrationId, adopted) {
27808
+ const map = /* @__PURE__ */ new Map();
27809
+ for (const device of adopted) {
27810
+ if (device.config["integrationId"] !== integrationId) continue;
27811
+ const mac = device.config["mac"];
27812
+ if (typeof mac === "string" && mac.length > 0) map.set(mac.toUpperCase(), device.id);
27813
+ }
27814
+ return map;
27815
+ }
27816
+ function buildWyzeAdoptionProvider(deps) {
27817
+ const { getCameraList, hasIntegration, listIntegrations, listAdopted, adoptCamera, removeDevice, findDeviceConfig, logger } = deps;
27818
+ async function candidatesForIntegration(integrationId, force = false) {
27819
+ return buildWyzeCandidates({
27820
+ cameras: await getCameraList(integrationId, force),
27821
+ adopted: adoptedMapForIntegration(integrationId, await listAdopted())
27822
+ });
27823
+ }
27824
+ function applyCandidateTextFilter(cands, filterText) {
27825
+ let filtered = [...cands];
27826
+ if (filterText === void 0) return filtered;
27827
+ const { search, adoptedOnly, unadoptedOnly } = filterText;
27828
+ if (search !== void 0 && search.length > 0) {
27829
+ const lower = search.toLowerCase();
27830
+ filtered = filtered.filter((c) => c.name.toLowerCase().includes(lower) || (c.metadata.model?.toLowerCase().includes(lower) ?? false) || c.childNativeId.toLowerCase().includes(lower));
27831
+ }
27832
+ if (adoptedOnly === true) filtered = filtered.filter((c) => c.alreadyAdopted);
27833
+ if (unadoptedOnly === true) filtered = filtered.filter((c) => !c.alreadyAdopted);
27834
+ return filtered;
27835
+ }
27836
+ return {
27837
+ listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
27838
+ listCandidates: async ({ integrationId, page, pageSize, filterText }) => {
27839
+ const filtered = applyCandidateTextFilter(await candidatesForIntegration(integrationId), filterText);
27840
+ const start = (page - 1) * pageSize;
27841
+ return {
27842
+ candidates: filtered.slice(start, start + pageSize),
27843
+ totalCount: filtered.length,
27844
+ page,
27845
+ pageSize
27846
+ };
27847
+ },
27848
+ getCandidate: async ({ integrationId, childNativeId }) => {
27849
+ return (await candidatesForIntegration(integrationId)).find((c) => c.childNativeId === childNativeId) ?? null;
27850
+ },
27851
+ getStatus: async () => {
27852
+ try {
27853
+ let candidateCount = 0;
27854
+ for (const integrationId of listIntegrations()) candidateCount += (await candidatesForIntegration(integrationId)).length;
27855
+ const adoptedCount = (await listAdopted()).length;
27856
+ return {
27857
+ lastDiscoveryAt: Date.now(),
27858
+ candidateCount,
27859
+ adoptedCount,
27860
+ lastError: null
27861
+ };
27862
+ } catch (err) {
27863
+ logger.warn("wyze adoption: getStatus failed", { meta: { error: errMsg(err) } });
27864
+ return {
27865
+ lastDiscoveryAt: null,
27866
+ candidateCount: 0,
27867
+ adoptedCount: 0,
27868
+ lastError: errMsg(err)
27869
+ };
27870
+ }
27871
+ },
27872
+ refresh: async ({ integrationId }) => {
27873
+ const candidateCount = (await candidatesForIntegration(integrationId, true)).length;
27874
+ const adoptedCount = adoptedMapForIntegration(integrationId, await listAdopted()).size;
27875
+ return {
27876
+ lastDiscoveryAt: Date.now(),
27877
+ candidateCount,
27878
+ adoptedCount,
27879
+ lastError: null
27880
+ };
27881
+ },
27882
+ adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
27883
+ if (!hasIntegration(integrationId)) throw new Error(`wyze adopt: integration ${integrationId} not connected`);
27884
+ const cameras = await getCameraList(integrationId, true);
27885
+ const adopted = [];
27886
+ let failures = 0;
27887
+ for (const mac of childNativeIds) try {
27888
+ const cam = cameras.find((c) => c.mac.toUpperCase() === mac.toUpperCase());
27889
+ if (cam === void 0) {
27890
+ logger.warn("wyze adopt: camera not found on account — skipping", { meta: {
27891
+ mac,
27892
+ integrationId
27893
+ } });
27894
+ failures++;
27895
+ continue;
27896
+ }
27897
+ const name = perCandidate?.[mac]?.name ?? cam.nickname ?? cam.productModel ?? mac;
27898
+ const { deviceId } = await adoptCamera({
27899
+ integrationId,
27900
+ mac: cam.mac,
27901
+ name
27902
+ });
27903
+ adopted.push({
27904
+ childNativeId: mac,
27905
+ parentDeviceId: deviceId,
27906
+ accessoryDeviceIds: []
27907
+ });
27908
+ } catch (err) {
27909
+ logger.warn("wyze adopt: failed to adopt camera", { meta: {
27910
+ mac,
27911
+ integrationId,
27912
+ error: errMsg(err)
27913
+ } });
27914
+ failures++;
27915
+ }
27916
+ if (adopted.length === 0 && failures > 0) throw new Error(`wyze adopt: all ${failures} adopt(s) failed`);
27917
+ return { adopted };
27918
+ },
27919
+ release: async ({ camDeviceId }) => {
27920
+ await removeDevice(camDeviceId);
27921
+ },
27922
+ resync: async ({ camDeviceId }) => {
27923
+ const cfg = await findDeviceConfig(camDeviceId);
27924
+ if (cfg === null) throw new Error(`wyze resync: device ${camDeviceId} not found`);
27925
+ const integrationId = typeof cfg["integrationId"] === "string" ? cfg["integrationId"] : "";
27926
+ const mac = typeof cfg["mac"] === "string" ? cfg["mac"] : "";
27927
+ if (mac.length === 0) throw new Error(`wyze resync: device ${camDeviceId} has no MAC`);
27928
+ if (integrationId.length > 0 && hasIntegration(integrationId)) {
27929
+ if (!(await getCameraList(integrationId)).some((c) => c.mac.toUpperCase() === mac.toUpperCase())) throw new Error(`wyze resync: camera ${mac} no longer present on integration ${integrationId}`);
27930
+ }
27931
+ return {
27932
+ changed: false,
27933
+ rebuiltChildren: 0
27934
+ };
27935
+ }
27936
+ };
27937
+ }
27938
+ //#endregion
27587
27939
  //#region src/snapshot-ffmpeg.ts
27588
27940
  /**
27589
27941
  * Decode a raw Annex-B keyframe (H264 or H265) into a single JPEG via
@@ -28257,34 +28609,56 @@ var WyzeCamera = class extends BaseDevice {
28257
28609
  };
28258
28610
  //#endregion
28259
28611
  //#region src/addon.ts
28260
- var CAMERA_LIST_REFRESH_MS = 5 * 6e4;
28261
28612
  function getString(obj, key) {
28262
28613
  const v = obj[key];
28263
28614
  return typeof v === "string" ? v : "";
28264
28615
  }
28616
+ /** Extract account credentials from an integration's settings, or null. */
28617
+ function credentialsFromSettings(settings) {
28618
+ const email = getString(settings, "email").trim();
28619
+ const password = getString(settings, "password");
28620
+ const apiKey = getString(settings, "apiKey").trim();
28621
+ const apiId = getString(settings, "apiId").trim();
28622
+ if (!email || !password || !apiKey || !apiId) return null;
28623
+ return {
28624
+ email,
28625
+ password,
28626
+ apiKey,
28627
+ apiId
28628
+ };
28629
+ }
28265
28630
  /**
28266
- * Wyze device-provider addon. Discovers cameras via the Wyze cloud
28267
- * (`getCameraList`), persists each camera's P2P parameters, and feeds the
28268
- * stream-broker through the `pull-rfc4571` lazy-publish + dial-time
28269
- * `materializeStreamSocket` path structurally identical to Reolink.
28631
+ * Wyze device-provider addon `mode: account` (multi-account).
28632
+ *
28633
+ * Each Wyze integration carries its own account credentials in its
28634
+ * `integration.settings`; the addon holds one `WyzeCloudClient` per
28635
+ * `integrationId` in {@link WyzeClientRegistry} (`Map<integrationId,
28636
+ * WyzeCloudClient>`). There is NO shared broker. A `device-adoption` cap
28637
+ * provider enumerates each account's cameras and adopts them; each adopted
28638
+ * camera is stamped with its `integrationId` and resolves its account client
28639
+ * by that id.
28640
+ *
28641
+ * Cameras still feed the stream-broker through the `pull-rfc4571`
28642
+ * lazy-publish + dial-time `materializeStreamSocket` path — structurally
28643
+ * identical to Reolink.
28270
28644
  *
28271
28645
  * `hub-only`: the loopback RFC 4571 server binds 127.0.0.1, so the broker
28272
- * that dials it must be co-resident (same as every other camstack
28273
- * provider).
28646
+ * that dials it must be co-resident.
28274
28647
  */
28275
28648
  var WyzeProviderAddon = class extends BaseDeviceProvider {
28276
28649
  addonId = WYZE_ADDON_ID;
28277
28650
  providerName = "Wyze";
28278
28651
  deviceClasses = { [DeviceType.Camera]: WyzeCamera };
28279
- cloud = null;
28280
- cameraListCache = [];
28281
- cameraListFetchedAt = 0;
28282
- cameraListInFlight = null;
28652
+ clients = null;
28283
28653
  constructor() {
28284
28654
  super({});
28285
28655
  }
28286
28656
  async onInitialize() {
28287
28657
  const regs = await super.onInitialize();
28658
+ this.clients = new WyzeClientRegistry({
28659
+ dataDir: this.ctx.dataDir,
28660
+ logger: this.ctx.logger
28661
+ });
28288
28662
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
28289
28663
  const data = event.data;
28290
28664
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
@@ -28294,15 +28668,68 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28294
28668
  tags: { deviceId },
28295
28669
  meta: {
28296
28670
  camStreamId: data.camStreamId ?? null,
28297
- error: err instanceof Error ? err.message : String(err)
28671
+ error: errMsg(err)
28298
28672
  }
28299
28673
  });
28300
28674
  });
28301
28675
  });
28302
- return regs;
28676
+ await this.reconcileIntegrations();
28677
+ this.subscribeIntegrationLifecycle();
28678
+ return [...regs, {
28679
+ capability: deviceAdoptionCapability,
28680
+ provider: this.buildAdoptionProvider()
28681
+ }];
28682
+ }
28683
+ async onShutdown() {
28684
+ this.clients?.clear();
28685
+ this.clients = null;
28686
+ await super.onShutdown();
28687
+ }
28688
+ requireClients() {
28689
+ if (!this.clients) throw new Error("Wyze provider not initialised");
28690
+ return this.clients;
28691
+ }
28692
+ /**
28693
+ * Rebuild the `Map<integrationId, WyzeCloudClient>` from the live
28694
+ * integration list: for each surviving Wyze integration read its settings
28695
+ * and upsert a client (session-preserving if credentials are unchanged);
28696
+ * drop clients whose integration was deleted/disabled. Idempotent; runs on
28697
+ * boot + on every integration lifecycle event. Guarded so a failure never
28698
+ * fails init.
28699
+ */
28700
+ async reconcileIntegrations() {
28701
+ const reg = this.requireClients();
28702
+ try {
28703
+ const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
28704
+ const surviving = /* @__PURE__ */ new Set();
28705
+ for (const integration of mine) try {
28706
+ const credentials = credentialsFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
28707
+ if (!credentials) {
28708
+ this.ctx.logger.warn("Wyze integration has no complete credentials — skipping", { meta: { integrationId: integration.id } });
28709
+ continue;
28710
+ }
28711
+ reg.upsert(integration.id, credentials);
28712
+ surviving.add(integration.id);
28713
+ } catch (err) {
28714
+ this.ctx.logger.warn("Wyze reconcile: failed to read integration settings", { meta: {
28715
+ integrationId: integration.id,
28716
+ error: errMsg(err)
28717
+ } });
28718
+ }
28719
+ reg.retain(surviving);
28720
+ } catch (err) {
28721
+ this.ctx.logger.warn("Wyze integration reconcile failed", { meta: { error: errMsg(err) } });
28722
+ }
28303
28723
  }
28304
- async onConfigChanged() {
28305
- this.cloud = null;
28724
+ subscribeIntegrationLifecycle() {
28725
+ const handler = (event) => {
28726
+ const addonId = event.data["addonId"];
28727
+ if (typeof addonId === "string" && addonId !== this.ctx.id) return;
28728
+ this.reconcileIntegrations();
28729
+ };
28730
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
28731
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
28732
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
28306
28733
  }
28307
28734
  async refreshDeviceStream(deviceId, camStreamId) {
28308
28735
  const dev = this.ctx.kernel.deviceRegistry?.getById(deviceId);
@@ -28314,94 +28741,57 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28314
28741
  this.wireCameraDeps(dev);
28315
28742
  await dev.materializeStreamSocket(camStreamId);
28316
28743
  }
28317
- async getGlobalSettings() {
28318
- const raw = await this.ctx.settings?.readAddonStore() ?? {};
28319
- return hydrateSchema(buildSettingsSchema(), raw);
28320
- }
28321
- async updateGlobalSettings(patch) {
28322
- await this.ctx.settings?.writeAddonStore(patch);
28323
- await this.onConfigChanged();
28324
- }
28325
- async readCredentials() {
28326
- const raw = await this.ctx.settings?.readAddonStore() ?? {};
28327
- const email = getString(raw, "email").trim();
28328
- const password = getString(raw, "password");
28329
- const apiKey = getString(raw, "apiKey").trim();
28330
- const apiId = getString(raw, "apiId").trim();
28331
- if (!email || !password || !apiKey || !apiId) return null;
28332
- return {
28333
- email,
28334
- password,
28335
- apiKey,
28336
- apiId
28337
- };
28338
- }
28339
- /** Lazily build the shared cloud client from persisted credentials. */
28340
- async ensureCloud() {
28341
- if (this.cloud) return this.cloud;
28342
- const credentials = await this.readCredentials();
28343
- if (!credentials) {
28344
- this.ctx.logger.warn("Wyze credentials not configured — set email / password / API key / Key ID in the addon settings");
28345
- return null;
28346
- }
28347
- this.cloud = new WyzeCloudClient({
28348
- dataDir: this.ctx.dataDir,
28349
- logger: this.ctx.logger.child("cloud"),
28350
- credentials
28351
- });
28352
- return this.cloud;
28353
- }
28354
- /** Cloud camera list, cached + debounced to avoid auth rate-limits. */
28355
- async getCameraList(force = false) {
28356
- const fresh = Date.now() - this.cameraListFetchedAt < CAMERA_LIST_REFRESH_MS;
28357
- if (!force && fresh && this.cameraListCache.length > 0) return this.cameraListCache;
28358
- if (this.cameraListInFlight) return this.cameraListInFlight;
28359
- const cloud = await this.ensureCloud();
28360
- if (!cloud) return this.cameraListCache;
28361
- const run = cloud.getCameraList().then((cams) => {
28362
- this.cameraListCache = cams;
28363
- this.cameraListFetchedAt = Date.now();
28364
- return cams;
28365
- }).catch((err) => {
28366
- const classified = classifyCloudError(err);
28367
- this.ctx.logger.error("Wyze getCameraList failed", { meta: {
28368
- kind: classified.kind,
28369
- error: classified.message
28370
- } });
28371
- return this.cameraListCache;
28372
- }).finally(() => {
28373
- this.cameraListInFlight = null;
28374
- });
28375
- this.cameraListInFlight = run;
28376
- return run;
28377
- }
28378
- async supportsDiscovery() {
28379
- return true;
28380
- }
28381
- async discoverDevices() {
28382
- const cams = await this.getCameraList(true);
28383
- const candidates = [];
28384
- for (const cam of cams) {
28385
- if (!isAdoptableCamera(cam)) continue;
28386
- candidates.push({
28387
- stableId: stableIdForMac(cam.mac),
28388
- type: DeviceType.Camera,
28389
- suggestedName: cam.nickname || cam.productModel || cam.mac,
28390
- prefilledConfig: cameraConfigFromCloud(cam)
28391
- });
28392
- }
28393
- return candidates;
28394
- }
28395
- async adoptDiscoveredDevice(input) {
28396
- const config = wyzeCameraSchema.parse(input.candidate.prefilledConfig);
28397
- return this.createDevice({
28398
- type: DeviceType.Camera,
28399
- config: {
28400
- ...config,
28401
- name: input.candidate.suggestedName
28744
+ buildAdoptionProvider() {
28745
+ return buildWyzeAdoptionProvider({
28746
+ logger: this.ctx.logger,
28747
+ getCameraList: (integrationId, force) => this.requireClients().getCameraList(integrationId, force),
28748
+ hasIntegration: (integrationId) => this.requireClients().has(integrationId),
28749
+ listIntegrations: () => this.requireClients().list(),
28750
+ listAdopted: async () => {
28751
+ const reg = this.ctx.kernel.deviceRegistry;
28752
+ const devices = this.ctx.kernel.devices;
28753
+ if (!reg || !devices) return [];
28754
+ const out = [];
28755
+ for (const d of reg.getAllForAddon(this.addonId)) {
28756
+ if (d.parentDeviceId !== null) continue;
28757
+ const config = await devices.loadConfig(d.id).catch(() => ({}));
28758
+ out.push({
28759
+ id: d.id,
28760
+ config
28761
+ });
28762
+ }
28763
+ return out;
28764
+ },
28765
+ adoptCamera: async ({ integrationId, mac, name }) => {
28766
+ const cam = (await this.requireClients().getCameraList(integrationId)).find((c) => c.mac.toUpperCase() === mac.toUpperCase());
28767
+ if (!cam) throw new Error(`wyze adopt: camera ${mac} not found on integration ${integrationId}`);
28768
+ const config = {
28769
+ ...cameraConfigFromCloud(cam, integrationId),
28770
+ name
28771
+ };
28772
+ const parsed = wyzeCameraSchema.parse(config);
28773
+ return { deviceId: (await this.createDevice({
28774
+ type: DeviceType.Camera,
28775
+ config: {
28776
+ ...parsed,
28777
+ name,
28778
+ integrationId
28779
+ }
28780
+ })).id };
28781
+ },
28782
+ removeDevice: async (id) => {
28783
+ await this.ctx.kernel.devices?.remove(id);
28784
+ },
28785
+ findDeviceConfig: async (id) => {
28786
+ const devices = this.ctx.kernel.devices;
28787
+ if (!devices) return null;
28788
+ return devices.loadConfig(id).catch(() => null);
28402
28789
  }
28403
28790
  });
28404
28791
  }
28792
+ async getGlobalSettings() {
28793
+ return hydrateSchema(buildSettingsSchema(), {});
28794
+ }
28405
28795
  async onGetCreationSchema(type) {
28406
28796
  if (type !== DeviceType.Camera) return null;
28407
28797
  return buildCreationFormSchema();
@@ -28410,20 +28800,25 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28410
28800
  if (type !== DeviceType.Camera) throw new Error(`Wyze provider does not support device type: ${type}`);
28411
28801
  const name = getString(config, "name").trim();
28412
28802
  if (!name) throw new Error("Camera name is required");
28803
+ const integrationId = getString(config, "integrationId").trim();
28413
28804
  const mac = getString(config, "mac").trim();
28414
28805
  let merged = config;
28415
- if (mac) {
28416
- const fromCloud = (await this.getCameraList()).find((c) => c.mac.toUpperCase() === mac.toUpperCase());
28806
+ if (mac && integrationId) {
28807
+ const fromCloud = (await this.requireClients().getCameraList(integrationId)).find((c) => c.mac.toUpperCase() === mac.toUpperCase());
28417
28808
  if (fromCloud) merged = {
28418
28809
  ...config,
28419
- ...cameraConfigFromCloud(fromCloud)
28810
+ ...cameraConfigFromCloud(fromCloud, integrationId)
28420
28811
  };
28421
28812
  }
28422
- const parsed = wyzeCameraSchema.parse(merged);
28813
+ const parsed = wyzeCameraSchema.parse({
28814
+ ...merged,
28815
+ integrationId
28816
+ });
28423
28817
  return {
28424
28818
  meta: {
28425
28819
  type: DeviceType.Camera,
28426
- name
28820
+ name,
28821
+ ...integrationId ? { integrationId } : {}
28427
28822
  },
28428
28823
  config: parsed,
28429
28824
  onAfterCreate: async (device) => {
@@ -28437,16 +28832,19 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28437
28832
  throw new Error("Wyze: cannot persist a camera without a MAC address");
28438
28833
  }
28439
28834
  /**
28440
- * Inject the cloud resolvers into a camera so it can (a) refresh its
28441
- * local IP before each dial and (b) poll cloud events for motion +
28442
- * thumbnails — without holding a direct provider reference.
28835
+ * Inject the per-account cloud resolvers into a camera so it can (a) refresh
28836
+ * its local IP before each dial and (b) poll cloud events for motion +
28837
+ * thumbnails — resolving its account {@link WyzeCloudClient} by the camera's
28838
+ * own `integrationId` config, without holding a direct provider reference.
28443
28839
  */
28444
28840
  wireCameraDeps(camera) {
28445
28841
  const mac = camera.config.get("mac");
28842
+ const integrationId = camera.config.get("integrationId");
28446
28843
  camera.setDeps({
28447
- cloud: () => this.cloud,
28844
+ cloud: () => this.clients?.client(integrationId) ?? null,
28448
28845
  resolveCloudCamera: async () => {
28449
- return (await this.getCameraList()).find((c) => c.mac.toUpperCase() === mac.toUpperCase()) ?? null;
28846
+ if (!integrationId) return null;
28847
+ return (await this.requireClients().getCameraList(integrationId)).find((c) => c.mac.toUpperCase() === mac.toUpperCase()) ?? null;
28450
28848
  }
28451
28849
  });
28452
28850
  }
@@ -28458,5 +28856,8 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28458
28856
  };
28459
28857
  //#endregion
28460
28858
  exports.WyzeCamera = WyzeCamera;
28859
+ exports.WyzeClientRegistry = WyzeClientRegistry;
28461
28860
  exports.WyzeProviderAddon = WyzeProviderAddon;
28861
+ exports.buildWyzeAdoptionProvider = buildWyzeAdoptionProvider;
28862
+ exports.buildWyzeCandidates = buildWyzeCandidates;
28462
28863
  exports.wyzeCameraSchema = wyzeCameraSchema;