@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.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
2
  import { join } from "node:path";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { createCipheriv, createDecipheriv, createHash, createHmac, createPublicKey, diffieHellman, generateKeyPairSync, randomBytes } from "crypto";
5
5
  import * as dgram from "dgram";
6
6
  import { EventEmitter } from "events";
@@ -6623,6 +6623,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6623
6623
  input: unknown()
6624
6624
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
6625
6625
  //#endregion
6626
+ //#region ../types/dist/err-msg-IQTHeDzc.mjs
6627
+ /**
6628
+ import { errMsg } from '@camstack/types'
6629
+ * Extract a human-readable message from an unknown error value.
6630
+ * Replaces the ubiquitous `errMsg(err)` pattern.
6631
+ */
6632
+ function errMsg(err) {
6633
+ if (err instanceof Error) return err.message;
6634
+ if (typeof err === "string") return err;
6635
+ return String(err);
6636
+ }
6637
+ //#endregion
6626
6638
  //#region ../types/dist/index.mjs
6627
6639
  /**
6628
6640
  * Deep wiring healthcheck — snapshot of active reachability probes across
@@ -15027,19 +15039,36 @@ var ResyncResultSchema = object({
15027
15039
  * provider re-derived the device. 0/absent for a normal incremental re-sync. */
15028
15040
  removedChildren: number().int().nonnegative().optional()
15029
15041
  });
15030
- 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, {
15031
- kind: "mutation",
15032
- auth: "admin"
15033
- }), method(AdoptInputSchema, AdoptResultSchema, {
15034
- kind: "mutation",
15035
- auth: "admin"
15036
- }), method(ReleaseInputSchema, _void(), {
15037
- kind: "mutation",
15038
- auth: "admin"
15039
- }), method(ResyncInputSchema, ResyncResultSchema, {
15040
- kind: "mutation",
15041
- auth: "admin"
15042
- });
15042
+ var deviceAdoptionCapability = {
15043
+ name: "device-adoption",
15044
+ scope: "system",
15045
+ mode: "singleton",
15046
+ status: {
15047
+ schema: AdoptionStatusSchema,
15048
+ kind: "poll"
15049
+ },
15050
+ methods: {
15051
+ listCandidateFilters: method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }),
15052
+ listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
15053
+ getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
15054
+ refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, {
15055
+ kind: "mutation",
15056
+ auth: "admin"
15057
+ }),
15058
+ adopt: method(AdoptInputSchema, AdoptResultSchema, {
15059
+ kind: "mutation",
15060
+ auth: "admin"
15061
+ }),
15062
+ release: method(ReleaseInputSchema, _void(), {
15063
+ kind: "mutation",
15064
+ auth: "admin"
15065
+ }),
15066
+ resync: method(ResyncInputSchema, ResyncResultSchema, {
15067
+ kind: "mutation",
15068
+ auth: "admin"
15069
+ })
15070
+ }
15071
+ };
15043
15072
  /**
15044
15073
  * `device-export` — collection cap for addons that export camstack
15045
15074
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
@@ -18272,6 +18301,17 @@ var AvailableIntegrationTypeSchema = object({
18272
18301
  iconUrl: string().nullable(),
18273
18302
  color: string(),
18274
18303
  instanceMode: string(),
18304
+ /**
18305
+ * Integration wizard `mode` (LOCKED MODEL): `standalone` (create
18306
+ * immediately then add devices, no config step/button), `account` (config
18307
+ * step), or `broker` (broker step). Derived server-side by
18308
+ * `getAvailableTypes` when the addon manifest omits an explicit `mode`.
18309
+ */
18310
+ mode: _enum([
18311
+ "standalone",
18312
+ "account",
18313
+ "broker"
18314
+ ]),
18275
18315
  discoveryMode: string(),
18276
18316
  /**
18277
18317
  * Which integration-marker cap the addon declared, so the wizard can
@@ -27170,6 +27210,147 @@ var WyzeCloudClient = class {
27170
27210
  }
27171
27211
  };
27172
27212
  //#endregion
27213
+ //#region src/error-classifier.ts
27214
+ function isWyzeAuthError(err) {
27215
+ return typeof err === "object" && err !== null && "needsMfa" in err && err.needsMfa === true;
27216
+ }
27217
+ function classifyCloudError(err) {
27218
+ if (isWyzeAuthError(err)) return {
27219
+ kind: "mfa",
27220
+ 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.`
27221
+ };
27222
+ const message = err instanceof Error ? err.message : String(err);
27223
+ const lower = message.toLowerCase();
27224
+ if (lower.includes("429") || lower.includes("too many") || lower.includes("rate")) return {
27225
+ kind: "rate-limited",
27226
+ message: `Wyze auth is rate-limited (~1 request / 30s per IP). Retry later — the session is persisted and reused to avoid this. ${message}`
27227
+ };
27228
+ if (lower.includes("password") || lower.includes("credential") || lower.includes("login failed") || lower.includes("access token") || lower.includes("unauthorized")) return {
27229
+ kind: "credentials",
27230
+ message: `Wyze login failed — check email / password / API key / Key ID. ${message}`
27231
+ };
27232
+ if (lower.includes("dtls") || lower.includes("keyframe") || lower.includes("p2p") || lower.includes("discover") || lower.includes("handshake") || lower.includes("kauth")) return {
27233
+ kind: "p2p",
27234
+ message: `Wyze P2P session failed (DTLS / discovery / keyframe). ${message}`
27235
+ };
27236
+ return {
27237
+ kind: "unknown",
27238
+ message
27239
+ };
27240
+ }
27241
+ //#endregion
27242
+ //#region src/client-registry.ts
27243
+ /**
27244
+ * Per-integration Wyze cloud client registry (account mode).
27245
+ *
27246
+ * Under the LOCKED integration/adoption model, Wyze is a `mode: account`
27247
+ * addon: each Wyze integration carries its own account credentials in its
27248
+ * `integration.settings`, and this registry holds one
27249
+ * {@link WyzeCloudClient} per `integrationId`
27250
+ * (`Map<integrationId, WyzeCloudClient>`). Multi-account = multiple
27251
+ * integrations; there is NO shared broker.
27252
+ *
27253
+ * Each client's persisted session lives under a per-integration
27254
+ * subdirectory of `dataDir` so two accounts never clobber each other's
27255
+ * `wyze-session.json`.
27256
+ *
27257
+ * The registry also owns a per-integration camera-list cache (debounced to
27258
+ * respect Wyze's auth rate-limit) so both the adoption provider and the
27259
+ * live cameras read a single shared, rate-safe list per account.
27260
+ */
27261
+ var CAMERA_LIST_REFRESH_MS = 5 * 6e4;
27262
+ /** True when two credential blobs are identical (session-preserving reconcile). */
27263
+ function sameCredentials(a, b) {
27264
+ return a.email === b.email && a.password === b.password && a.apiKey === b.apiKey && a.apiId === b.apiId;
27265
+ }
27266
+ /**
27267
+ * Owns the `Map<integrationId, WyzeCloudClient>`. Callers reconcile the map
27268
+ * against the live integration list on boot + on every integration lifecycle
27269
+ * event; device classes + the adoption provider resolve their client by
27270
+ * `integrationId`.
27271
+ */
27272
+ var WyzeClientRegistry = class {
27273
+ #entries = /* @__PURE__ */ new Map();
27274
+ #deps;
27275
+ constructor(deps) {
27276
+ this.#deps = deps;
27277
+ }
27278
+ /**
27279
+ * Ensure a client exists for `integrationId` with the given credentials.
27280
+ * Idempotent: an existing entry with identical credentials is preserved
27281
+ * (keeps its live session + cache); a credentials change rebuilds it.
27282
+ */
27283
+ upsert(integrationId, credentials) {
27284
+ const existing = this.#entries.get(integrationId);
27285
+ if (existing && sameCredentials(existing.credentials, credentials)) return;
27286
+ const client = new WyzeCloudClient({
27287
+ dataDir: join(this.#deps.dataDir, "integrations", integrationId),
27288
+ logger: this.#deps.logger.child(`cloud:${integrationId}`),
27289
+ credentials
27290
+ });
27291
+ this.#entries.set(integrationId, {
27292
+ client,
27293
+ credentials,
27294
+ cameraListCache: [],
27295
+ cameraListFetchedAt: 0,
27296
+ cameraListInFlight: null
27297
+ });
27298
+ }
27299
+ /** Drop the client for an integration that no longer exists / was disabled. */
27300
+ remove(integrationId) {
27301
+ this.#entries.delete(integrationId);
27302
+ }
27303
+ /** Replace the whole set with `keep` — removes any client not in the set. */
27304
+ retain(keep) {
27305
+ for (const id of [...this.#entries.keys()]) if (!keep.has(id)) this.#entries.delete(id);
27306
+ }
27307
+ /** The live client for an integration, or null when unknown. */
27308
+ client(integrationId) {
27309
+ return this.#entries.get(integrationId)?.client ?? null;
27310
+ }
27311
+ /** True when a client is registered for the integration. */
27312
+ has(integrationId) {
27313
+ return this.#entries.has(integrationId);
27314
+ }
27315
+ /** The registered integration ids (one per account). */
27316
+ list() {
27317
+ return [...this.#entries.keys()];
27318
+ }
27319
+ /** Remove all clients (full shutdown). */
27320
+ clear() {
27321
+ this.#entries.clear();
27322
+ }
27323
+ /**
27324
+ * Cloud camera list for ONE integration, cached + debounced + single-flight
27325
+ * to avoid auth rate-limits. Returns the last cache (possibly empty) when the
27326
+ * integration is unknown or the fetch fails.
27327
+ */
27328
+ async getCameraList(integrationId, force = false) {
27329
+ const entry = this.#entries.get(integrationId);
27330
+ if (!entry) return [];
27331
+ const fresh = Date.now() - entry.cameraListFetchedAt < CAMERA_LIST_REFRESH_MS;
27332
+ if (!force && fresh && entry.cameraListCache.length > 0) return entry.cameraListCache;
27333
+ if (entry.cameraListInFlight) return entry.cameraListInFlight;
27334
+ const run = entry.client.getCameraList().then((cams) => {
27335
+ entry.cameraListCache = cams;
27336
+ entry.cameraListFetchedAt = Date.now();
27337
+ return cams;
27338
+ }).catch((err) => {
27339
+ const classified = classifyCloudError(err);
27340
+ this.#deps.logger.error("Wyze getCameraList failed", { meta: {
27341
+ integrationId,
27342
+ kind: classified.kind,
27343
+ error: classified.message
27344
+ } });
27345
+ return entry.cameraListCache;
27346
+ }).finally(() => {
27347
+ entry.cameraListInFlight = null;
27348
+ });
27349
+ entry.cameraListInFlight = run;
27350
+ return run;
27351
+ }
27352
+ };
27353
+ //#endregion
27173
27354
  //#region src/constants.ts
27174
27355
  /**
27175
27356
  * Single source of truth for the Wyze provider addon id. Must match the
@@ -27408,35 +27589,6 @@ function buildSettingsSchema() {
27408
27589
  }] };
27409
27590
  }
27410
27591
  //#endregion
27411
- //#region src/error-classifier.ts
27412
- function isWyzeAuthError(err) {
27413
- return typeof err === "object" && err !== null && "needsMfa" in err && err.needsMfa === true;
27414
- }
27415
- function classifyCloudError(err) {
27416
- if (isWyzeAuthError(err)) return {
27417
- kind: "mfa",
27418
- 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.`
27419
- };
27420
- const message = err instanceof Error ? err.message : String(err);
27421
- const lower = message.toLowerCase();
27422
- if (lower.includes("429") || lower.includes("too many") || lower.includes("rate")) return {
27423
- kind: "rate-limited",
27424
- message: `Wyze auth is rate-limited (~1 request / 30s per IP). Retry later — the session is persisted and reused to avoid this. ${message}`
27425
- };
27426
- if (lower.includes("password") || lower.includes("credential") || lower.includes("login failed") || lower.includes("access token") || lower.includes("unauthorized")) return {
27427
- kind: "credentials",
27428
- message: `Wyze login failed — check email / password / API key / Key ID. ${message}`
27429
- };
27430
- if (lower.includes("dtls") || lower.includes("keyframe") || lower.includes("p2p") || lower.includes("discover") || lower.includes("handshake") || lower.includes("kauth")) return {
27431
- kind: "p2p",
27432
- message: `Wyze P2P session failed (DTLS / discovery / keyframe). ${message}`
27433
- };
27434
- return {
27435
- kind: "unknown",
27436
- message
27437
- };
27438
- }
27439
- //#endregion
27440
27592
  //#region src/mapping.ts
27441
27593
  /** Canonical camstack codec name from the lib's `videoType`. */
27442
27594
  function codecFromVideoType(videoType) {
@@ -27468,9 +27620,14 @@ function stableIdForMac(mac) {
27468
27620
  * Reduce a cloud `WyzeCamera` blob to the persisted camera config. Pure:
27469
27621
  * `resolution` / `bitrate` / `motionSource` carry their schema defaults
27470
27622
  * here (the device config schema re-applies them on parse).
27623
+ *
27624
+ * `integrationId` stamps the owning account so the camera resolves its
27625
+ * per-account cloud client from the registry. Defaults to `''` (legacy
27626
+ * single-global-credentials cameras) when not supplied by the adopt path.
27471
27627
  */
27472
- function cameraConfigFromCloud(cam) {
27628
+ function cameraConfigFromCloud(cam, integrationId = "") {
27473
27629
  return {
27630
+ integrationId,
27474
27631
  mac: cam.mac,
27475
27632
  p2pId: cam.p2pId,
27476
27633
  enr: cam.enr,
@@ -27536,6 +27693,15 @@ var WyzeResolutionSchema = _enum([
27536
27693
  var WyzeBitrateSchema = _enum(["max", "sd"]);
27537
27694
  var WyzeMotionSourceSchema = _enum(["cloud", "boa"]);
27538
27695
  var wyzeCameraSchema = object({
27696
+ /**
27697
+ * Owning Wyze integration id (account). Stamped at adopt/create so the
27698
+ * camera resolves its per-account {@link WyzeCloudClient} from the client
27699
+ * registry (multi-account: one integration = one account = one client).
27700
+ * Optional for back-compat with cameras persisted under the legacy
27701
+ * single-global-credentials model — those resolve via the migration
27702
+ * fallback until re-adopted.
27703
+ */
27704
+ integrationId: string().default(""),
27539
27705
  /** Hardware MAC — globally unique, the stableId discriminator. */
27540
27706
  mac: string().describe("Wyze camera MAC (hardware id)"),
27541
27707
  /** P2P UID (TUTK) — the cloud `device_params.p2p_id`. */
@@ -27563,6 +27729,192 @@ var wyzeCameraSchema = object({
27563
27729
  motionSource: WyzeMotionSourceSchema.default("cloud")
27564
27730
  });
27565
27731
  //#endregion
27732
+ //#region src/candidates.ts
27733
+ /**
27734
+ * Pure mapping: cloud camera list → `device-adoption` candidates.
27735
+ *
27736
+ * Mirrors `buildDreoCandidates` — no network I/O, no side effects, so it is
27737
+ * exhaustively unit-testable without the lib or a live account. A camera is a
27738
+ * candidate only when it carries the local P2P parameters the DTLS client
27739
+ * needs (`isAdoptableCamera`).
27740
+ */
27741
+ /**
27742
+ * Map live cloud cameras to adoption candidates. The `childNativeId` is the
27743
+ * camera MAC — globally unique + durable, the same key used for the device
27744
+ * stableId — so re-discovery of an already-adopted camera is de-duped.
27745
+ */
27746
+ function buildWyzeCandidates(input) {
27747
+ const out = [];
27748
+ for (const cam of input.cameras) {
27749
+ if (!isAdoptableCamera(cam)) continue;
27750
+ const adoptedId = input.adopted.get(cam.mac.toUpperCase()) ?? null;
27751
+ out.push({
27752
+ childNativeId: cam.mac,
27753
+ name: cam.nickname || cam.productModel || cam.mac,
27754
+ type: DeviceType.Camera,
27755
+ status: cam.isOnline === false ? "offline" : "online",
27756
+ metadata: {
27757
+ serialNumber: cam.mac,
27758
+ model: cam.productModel,
27759
+ manufacturer: "Wyze"
27760
+ },
27761
+ alreadyAdopted: adoptedId !== null,
27762
+ adoptedDeviceId: adoptedId
27763
+ });
27764
+ }
27765
+ return out;
27766
+ }
27767
+ //#endregion
27768
+ //#region src/wyze-adoption-provider.ts
27769
+ /**
27770
+ * `device-adoption` cap provider for the Wyze addon (account mode).
27771
+ *
27772
+ * Broker-less: candidates + adoption are resolved directly by
27773
+ * `integrationId` → the per-account {@link WyzeClientRegistry} client. Mirrors
27774
+ * `buildDreoAdoptionProvider`'s shape (listCandidateFilters / listCandidates /
27775
+ * getCandidate / getStatus / refresh / adopt / release / resync) with a single
27776
+ * `devices` granularity (one Camera per cloud device). Pure builder: every
27777
+ * side-effecting dependency is injected.
27778
+ */
27779
+ /** The single granularity this provider advertises: whole-device adoption. */
27780
+ var DEVICES_FILTER = {
27781
+ id: "devices",
27782
+ label: "Cameras",
27783
+ isDefault: true
27784
+ };
27785
+ /** Build a `MAC(uppercased) → CamStack deviceId` map for one integration. */
27786
+ function adoptedMapForIntegration(integrationId, adopted) {
27787
+ const map = /* @__PURE__ */ new Map();
27788
+ for (const device of adopted) {
27789
+ if (device.config["integrationId"] !== integrationId) continue;
27790
+ const mac = device.config["mac"];
27791
+ if (typeof mac === "string" && mac.length > 0) map.set(mac.toUpperCase(), device.id);
27792
+ }
27793
+ return map;
27794
+ }
27795
+ function buildWyzeAdoptionProvider(deps) {
27796
+ const { getCameraList, hasIntegration, listIntegrations, listAdopted, adoptCamera, removeDevice, findDeviceConfig, logger } = deps;
27797
+ async function candidatesForIntegration(integrationId, force = false) {
27798
+ return buildWyzeCandidates({
27799
+ cameras: await getCameraList(integrationId, force),
27800
+ adopted: adoptedMapForIntegration(integrationId, await listAdopted())
27801
+ });
27802
+ }
27803
+ function applyCandidateTextFilter(cands, filterText) {
27804
+ let filtered = [...cands];
27805
+ if (filterText === void 0) return filtered;
27806
+ const { search, adoptedOnly, unadoptedOnly } = filterText;
27807
+ if (search !== void 0 && search.length > 0) {
27808
+ const lower = search.toLowerCase();
27809
+ filtered = filtered.filter((c) => c.name.toLowerCase().includes(lower) || (c.metadata.model?.toLowerCase().includes(lower) ?? false) || c.childNativeId.toLowerCase().includes(lower));
27810
+ }
27811
+ if (adoptedOnly === true) filtered = filtered.filter((c) => c.alreadyAdopted);
27812
+ if (unadoptedOnly === true) filtered = filtered.filter((c) => !c.alreadyAdopted);
27813
+ return filtered;
27814
+ }
27815
+ return {
27816
+ listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
27817
+ listCandidates: async ({ integrationId, page, pageSize, filterText }) => {
27818
+ const filtered = applyCandidateTextFilter(await candidatesForIntegration(integrationId), filterText);
27819
+ const start = (page - 1) * pageSize;
27820
+ return {
27821
+ candidates: filtered.slice(start, start + pageSize),
27822
+ totalCount: filtered.length,
27823
+ page,
27824
+ pageSize
27825
+ };
27826
+ },
27827
+ getCandidate: async ({ integrationId, childNativeId }) => {
27828
+ return (await candidatesForIntegration(integrationId)).find((c) => c.childNativeId === childNativeId) ?? null;
27829
+ },
27830
+ getStatus: async () => {
27831
+ try {
27832
+ let candidateCount = 0;
27833
+ for (const integrationId of listIntegrations()) candidateCount += (await candidatesForIntegration(integrationId)).length;
27834
+ const adoptedCount = (await listAdopted()).length;
27835
+ return {
27836
+ lastDiscoveryAt: Date.now(),
27837
+ candidateCount,
27838
+ adoptedCount,
27839
+ lastError: null
27840
+ };
27841
+ } catch (err) {
27842
+ logger.warn("wyze adoption: getStatus failed", { meta: { error: errMsg(err) } });
27843
+ return {
27844
+ lastDiscoveryAt: null,
27845
+ candidateCount: 0,
27846
+ adoptedCount: 0,
27847
+ lastError: errMsg(err)
27848
+ };
27849
+ }
27850
+ },
27851
+ refresh: async ({ integrationId }) => {
27852
+ const candidateCount = (await candidatesForIntegration(integrationId, true)).length;
27853
+ const adoptedCount = adoptedMapForIntegration(integrationId, await listAdopted()).size;
27854
+ return {
27855
+ lastDiscoveryAt: Date.now(),
27856
+ candidateCount,
27857
+ adoptedCount,
27858
+ lastError: null
27859
+ };
27860
+ },
27861
+ adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
27862
+ if (!hasIntegration(integrationId)) throw new Error(`wyze adopt: integration ${integrationId} not connected`);
27863
+ const cameras = await getCameraList(integrationId, true);
27864
+ const adopted = [];
27865
+ let failures = 0;
27866
+ for (const mac of childNativeIds) try {
27867
+ const cam = cameras.find((c) => c.mac.toUpperCase() === mac.toUpperCase());
27868
+ if (cam === void 0) {
27869
+ logger.warn("wyze adopt: camera not found on account — skipping", { meta: {
27870
+ mac,
27871
+ integrationId
27872
+ } });
27873
+ failures++;
27874
+ continue;
27875
+ }
27876
+ const name = perCandidate?.[mac]?.name ?? cam.nickname ?? cam.productModel ?? mac;
27877
+ const { deviceId } = await adoptCamera({
27878
+ integrationId,
27879
+ mac: cam.mac,
27880
+ name
27881
+ });
27882
+ adopted.push({
27883
+ childNativeId: mac,
27884
+ parentDeviceId: deviceId,
27885
+ accessoryDeviceIds: []
27886
+ });
27887
+ } catch (err) {
27888
+ logger.warn("wyze adopt: failed to adopt camera", { meta: {
27889
+ mac,
27890
+ integrationId,
27891
+ error: errMsg(err)
27892
+ } });
27893
+ failures++;
27894
+ }
27895
+ if (adopted.length === 0 && failures > 0) throw new Error(`wyze adopt: all ${failures} adopt(s) failed`);
27896
+ return { adopted };
27897
+ },
27898
+ release: async ({ camDeviceId }) => {
27899
+ await removeDevice(camDeviceId);
27900
+ },
27901
+ resync: async ({ camDeviceId }) => {
27902
+ const cfg = await findDeviceConfig(camDeviceId);
27903
+ if (cfg === null) throw new Error(`wyze resync: device ${camDeviceId} not found`);
27904
+ const integrationId = typeof cfg["integrationId"] === "string" ? cfg["integrationId"] : "";
27905
+ const mac = typeof cfg["mac"] === "string" ? cfg["mac"] : "";
27906
+ if (mac.length === 0) throw new Error(`wyze resync: device ${camDeviceId} has no MAC`);
27907
+ if (integrationId.length > 0 && hasIntegration(integrationId)) {
27908
+ if (!(await getCameraList(integrationId)).some((c) => c.mac.toUpperCase() === mac.toUpperCase())) throw new Error(`wyze resync: camera ${mac} no longer present on integration ${integrationId}`);
27909
+ }
27910
+ return {
27911
+ changed: false,
27912
+ rebuiltChildren: 0
27913
+ };
27914
+ }
27915
+ };
27916
+ }
27917
+ //#endregion
27566
27918
  //#region src/snapshot-ffmpeg.ts
27567
27919
  /**
27568
27920
  * Decode a raw Annex-B keyframe (H264 or H265) into a single JPEG via
@@ -28236,34 +28588,56 @@ var WyzeCamera = class extends BaseDevice {
28236
28588
  };
28237
28589
  //#endregion
28238
28590
  //#region src/addon.ts
28239
- var CAMERA_LIST_REFRESH_MS = 5 * 6e4;
28240
28591
  function getString(obj, key) {
28241
28592
  const v = obj[key];
28242
28593
  return typeof v === "string" ? v : "";
28243
28594
  }
28595
+ /** Extract account credentials from an integration's settings, or null. */
28596
+ function credentialsFromSettings(settings) {
28597
+ const email = getString(settings, "email").trim();
28598
+ const password = getString(settings, "password");
28599
+ const apiKey = getString(settings, "apiKey").trim();
28600
+ const apiId = getString(settings, "apiId").trim();
28601
+ if (!email || !password || !apiKey || !apiId) return null;
28602
+ return {
28603
+ email,
28604
+ password,
28605
+ apiKey,
28606
+ apiId
28607
+ };
28608
+ }
28244
28609
  /**
28245
- * Wyze device-provider addon. Discovers cameras via the Wyze cloud
28246
- * (`getCameraList`), persists each camera's P2P parameters, and feeds the
28247
- * stream-broker through the `pull-rfc4571` lazy-publish + dial-time
28248
- * `materializeStreamSocket` path structurally identical to Reolink.
28610
+ * Wyze device-provider addon `mode: account` (multi-account).
28611
+ *
28612
+ * Each Wyze integration carries its own account credentials in its
28613
+ * `integration.settings`; the addon holds one `WyzeCloudClient` per
28614
+ * `integrationId` in {@link WyzeClientRegistry} (`Map<integrationId,
28615
+ * WyzeCloudClient>`). There is NO shared broker. A `device-adoption` cap
28616
+ * provider enumerates each account's cameras and adopts them; each adopted
28617
+ * camera is stamped with its `integrationId` and resolves its account client
28618
+ * by that id.
28619
+ *
28620
+ * Cameras still feed the stream-broker through the `pull-rfc4571`
28621
+ * lazy-publish + dial-time `materializeStreamSocket` path — structurally
28622
+ * identical to Reolink.
28249
28623
  *
28250
28624
  * `hub-only`: the loopback RFC 4571 server binds 127.0.0.1, so the broker
28251
- * that dials it must be co-resident (same as every other camstack
28252
- * provider).
28625
+ * that dials it must be co-resident.
28253
28626
  */
28254
28627
  var WyzeProviderAddon = class extends BaseDeviceProvider {
28255
28628
  addonId = WYZE_ADDON_ID;
28256
28629
  providerName = "Wyze";
28257
28630
  deviceClasses = { [DeviceType.Camera]: WyzeCamera };
28258
- cloud = null;
28259
- cameraListCache = [];
28260
- cameraListFetchedAt = 0;
28261
- cameraListInFlight = null;
28631
+ clients = null;
28262
28632
  constructor() {
28263
28633
  super({});
28264
28634
  }
28265
28635
  async onInitialize() {
28266
28636
  const regs = await super.onInitialize();
28637
+ this.clients = new WyzeClientRegistry({
28638
+ dataDir: this.ctx.dataDir,
28639
+ logger: this.ctx.logger
28640
+ });
28267
28641
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
28268
28642
  const data = event.data;
28269
28643
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
@@ -28273,15 +28647,68 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28273
28647
  tags: { deviceId },
28274
28648
  meta: {
28275
28649
  camStreamId: data.camStreamId ?? null,
28276
- error: err instanceof Error ? err.message : String(err)
28650
+ error: errMsg(err)
28277
28651
  }
28278
28652
  });
28279
28653
  });
28280
28654
  });
28281
- return regs;
28655
+ await this.reconcileIntegrations();
28656
+ this.subscribeIntegrationLifecycle();
28657
+ return [...regs, {
28658
+ capability: deviceAdoptionCapability,
28659
+ provider: this.buildAdoptionProvider()
28660
+ }];
28661
+ }
28662
+ async onShutdown() {
28663
+ this.clients?.clear();
28664
+ this.clients = null;
28665
+ await super.onShutdown();
28666
+ }
28667
+ requireClients() {
28668
+ if (!this.clients) throw new Error("Wyze provider not initialised");
28669
+ return this.clients;
28670
+ }
28671
+ /**
28672
+ * Rebuild the `Map<integrationId, WyzeCloudClient>` from the live
28673
+ * integration list: for each surviving Wyze integration read its settings
28674
+ * and upsert a client (session-preserving if credentials are unchanged);
28675
+ * drop clients whose integration was deleted/disabled. Idempotent; runs on
28676
+ * boot + on every integration lifecycle event. Guarded so a failure never
28677
+ * fails init.
28678
+ */
28679
+ async reconcileIntegrations() {
28680
+ const reg = this.requireClients();
28681
+ try {
28682
+ const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
28683
+ const surviving = /* @__PURE__ */ new Set();
28684
+ for (const integration of mine) try {
28685
+ const credentials = credentialsFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
28686
+ if (!credentials) {
28687
+ this.ctx.logger.warn("Wyze integration has no complete credentials — skipping", { meta: { integrationId: integration.id } });
28688
+ continue;
28689
+ }
28690
+ reg.upsert(integration.id, credentials);
28691
+ surviving.add(integration.id);
28692
+ } catch (err) {
28693
+ this.ctx.logger.warn("Wyze reconcile: failed to read integration settings", { meta: {
28694
+ integrationId: integration.id,
28695
+ error: errMsg(err)
28696
+ } });
28697
+ }
28698
+ reg.retain(surviving);
28699
+ } catch (err) {
28700
+ this.ctx.logger.warn("Wyze integration reconcile failed", { meta: { error: errMsg(err) } });
28701
+ }
28282
28702
  }
28283
- async onConfigChanged() {
28284
- this.cloud = null;
28703
+ subscribeIntegrationLifecycle() {
28704
+ const handler = (event) => {
28705
+ const addonId = event.data["addonId"];
28706
+ if (typeof addonId === "string" && addonId !== this.ctx.id) return;
28707
+ this.reconcileIntegrations();
28708
+ };
28709
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
28710
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
28711
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
28285
28712
  }
28286
28713
  async refreshDeviceStream(deviceId, camStreamId) {
28287
28714
  const dev = this.ctx.kernel.deviceRegistry?.getById(deviceId);
@@ -28293,94 +28720,57 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28293
28720
  this.wireCameraDeps(dev);
28294
28721
  await dev.materializeStreamSocket(camStreamId);
28295
28722
  }
28296
- async getGlobalSettings() {
28297
- const raw = await this.ctx.settings?.readAddonStore() ?? {};
28298
- return hydrateSchema(buildSettingsSchema(), raw);
28299
- }
28300
- async updateGlobalSettings(patch) {
28301
- await this.ctx.settings?.writeAddonStore(patch);
28302
- await this.onConfigChanged();
28303
- }
28304
- async readCredentials() {
28305
- const raw = await this.ctx.settings?.readAddonStore() ?? {};
28306
- const email = getString(raw, "email").trim();
28307
- const password = getString(raw, "password");
28308
- const apiKey = getString(raw, "apiKey").trim();
28309
- const apiId = getString(raw, "apiId").trim();
28310
- if (!email || !password || !apiKey || !apiId) return null;
28311
- return {
28312
- email,
28313
- password,
28314
- apiKey,
28315
- apiId
28316
- };
28317
- }
28318
- /** Lazily build the shared cloud client from persisted credentials. */
28319
- async ensureCloud() {
28320
- if (this.cloud) return this.cloud;
28321
- const credentials = await this.readCredentials();
28322
- if (!credentials) {
28323
- this.ctx.logger.warn("Wyze credentials not configured — set email / password / API key / Key ID in the addon settings");
28324
- return null;
28325
- }
28326
- this.cloud = new WyzeCloudClient({
28327
- dataDir: this.ctx.dataDir,
28328
- logger: this.ctx.logger.child("cloud"),
28329
- credentials
28330
- });
28331
- return this.cloud;
28332
- }
28333
- /** Cloud camera list, cached + debounced to avoid auth rate-limits. */
28334
- async getCameraList(force = false) {
28335
- const fresh = Date.now() - this.cameraListFetchedAt < CAMERA_LIST_REFRESH_MS;
28336
- if (!force && fresh && this.cameraListCache.length > 0) return this.cameraListCache;
28337
- if (this.cameraListInFlight) return this.cameraListInFlight;
28338
- const cloud = await this.ensureCloud();
28339
- if (!cloud) return this.cameraListCache;
28340
- const run = cloud.getCameraList().then((cams) => {
28341
- this.cameraListCache = cams;
28342
- this.cameraListFetchedAt = Date.now();
28343
- return cams;
28344
- }).catch((err) => {
28345
- const classified = classifyCloudError(err);
28346
- this.ctx.logger.error("Wyze getCameraList failed", { meta: {
28347
- kind: classified.kind,
28348
- error: classified.message
28349
- } });
28350
- return this.cameraListCache;
28351
- }).finally(() => {
28352
- this.cameraListInFlight = null;
28353
- });
28354
- this.cameraListInFlight = run;
28355
- return run;
28356
- }
28357
- async supportsDiscovery() {
28358
- return true;
28359
- }
28360
- async discoverDevices() {
28361
- const cams = await this.getCameraList(true);
28362
- const candidates = [];
28363
- for (const cam of cams) {
28364
- if (!isAdoptableCamera(cam)) continue;
28365
- candidates.push({
28366
- stableId: stableIdForMac(cam.mac),
28367
- type: DeviceType.Camera,
28368
- suggestedName: cam.nickname || cam.productModel || cam.mac,
28369
- prefilledConfig: cameraConfigFromCloud(cam)
28370
- });
28371
- }
28372
- return candidates;
28373
- }
28374
- async adoptDiscoveredDevice(input) {
28375
- const config = wyzeCameraSchema.parse(input.candidate.prefilledConfig);
28376
- return this.createDevice({
28377
- type: DeviceType.Camera,
28378
- config: {
28379
- ...config,
28380
- name: input.candidate.suggestedName
28723
+ buildAdoptionProvider() {
28724
+ return buildWyzeAdoptionProvider({
28725
+ logger: this.ctx.logger,
28726
+ getCameraList: (integrationId, force) => this.requireClients().getCameraList(integrationId, force),
28727
+ hasIntegration: (integrationId) => this.requireClients().has(integrationId),
28728
+ listIntegrations: () => this.requireClients().list(),
28729
+ listAdopted: async () => {
28730
+ const reg = this.ctx.kernel.deviceRegistry;
28731
+ const devices = this.ctx.kernel.devices;
28732
+ if (!reg || !devices) return [];
28733
+ const out = [];
28734
+ for (const d of reg.getAllForAddon(this.addonId)) {
28735
+ if (d.parentDeviceId !== null) continue;
28736
+ const config = await devices.loadConfig(d.id).catch(() => ({}));
28737
+ out.push({
28738
+ id: d.id,
28739
+ config
28740
+ });
28741
+ }
28742
+ return out;
28743
+ },
28744
+ adoptCamera: async ({ integrationId, mac, name }) => {
28745
+ const cam = (await this.requireClients().getCameraList(integrationId)).find((c) => c.mac.toUpperCase() === mac.toUpperCase());
28746
+ if (!cam) throw new Error(`wyze adopt: camera ${mac} not found on integration ${integrationId}`);
28747
+ const config = {
28748
+ ...cameraConfigFromCloud(cam, integrationId),
28749
+ name
28750
+ };
28751
+ const parsed = wyzeCameraSchema.parse(config);
28752
+ return { deviceId: (await this.createDevice({
28753
+ type: DeviceType.Camera,
28754
+ config: {
28755
+ ...parsed,
28756
+ name,
28757
+ integrationId
28758
+ }
28759
+ })).id };
28760
+ },
28761
+ removeDevice: async (id) => {
28762
+ await this.ctx.kernel.devices?.remove(id);
28763
+ },
28764
+ findDeviceConfig: async (id) => {
28765
+ const devices = this.ctx.kernel.devices;
28766
+ if (!devices) return null;
28767
+ return devices.loadConfig(id).catch(() => null);
28381
28768
  }
28382
28769
  });
28383
28770
  }
28771
+ async getGlobalSettings() {
28772
+ return hydrateSchema(buildSettingsSchema(), {});
28773
+ }
28384
28774
  async onGetCreationSchema(type) {
28385
28775
  if (type !== DeviceType.Camera) return null;
28386
28776
  return buildCreationFormSchema();
@@ -28389,20 +28779,25 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28389
28779
  if (type !== DeviceType.Camera) throw new Error(`Wyze provider does not support device type: ${type}`);
28390
28780
  const name = getString(config, "name").trim();
28391
28781
  if (!name) throw new Error("Camera name is required");
28782
+ const integrationId = getString(config, "integrationId").trim();
28392
28783
  const mac = getString(config, "mac").trim();
28393
28784
  let merged = config;
28394
- if (mac) {
28395
- const fromCloud = (await this.getCameraList()).find((c) => c.mac.toUpperCase() === mac.toUpperCase());
28785
+ if (mac && integrationId) {
28786
+ const fromCloud = (await this.requireClients().getCameraList(integrationId)).find((c) => c.mac.toUpperCase() === mac.toUpperCase());
28396
28787
  if (fromCloud) merged = {
28397
28788
  ...config,
28398
- ...cameraConfigFromCloud(fromCloud)
28789
+ ...cameraConfigFromCloud(fromCloud, integrationId)
28399
28790
  };
28400
28791
  }
28401
- const parsed = wyzeCameraSchema.parse(merged);
28792
+ const parsed = wyzeCameraSchema.parse({
28793
+ ...merged,
28794
+ integrationId
28795
+ });
28402
28796
  return {
28403
28797
  meta: {
28404
28798
  type: DeviceType.Camera,
28405
- name
28799
+ name,
28800
+ ...integrationId ? { integrationId } : {}
28406
28801
  },
28407
28802
  config: parsed,
28408
28803
  onAfterCreate: async (device) => {
@@ -28416,16 +28811,19 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28416
28811
  throw new Error("Wyze: cannot persist a camera without a MAC address");
28417
28812
  }
28418
28813
  /**
28419
- * Inject the cloud resolvers into a camera so it can (a) refresh its
28420
- * local IP before each dial and (b) poll cloud events for motion +
28421
- * thumbnails — without holding a direct provider reference.
28814
+ * Inject the per-account cloud resolvers into a camera so it can (a) refresh
28815
+ * its local IP before each dial and (b) poll cloud events for motion +
28816
+ * thumbnails — resolving its account {@link WyzeCloudClient} by the camera's
28817
+ * own `integrationId` config, without holding a direct provider reference.
28422
28818
  */
28423
28819
  wireCameraDeps(camera) {
28424
28820
  const mac = camera.config.get("mac");
28821
+ const integrationId = camera.config.get("integrationId");
28425
28822
  camera.setDeps({
28426
- cloud: () => this.cloud,
28823
+ cloud: () => this.clients?.client(integrationId) ?? null,
28427
28824
  resolveCloudCamera: async () => {
28428
- return (await this.getCameraList()).find((c) => c.mac.toUpperCase() === mac.toUpperCase()) ?? null;
28825
+ if (!integrationId) return null;
28826
+ return (await this.requireClients().getCameraList(integrationId)).find((c) => c.mac.toUpperCase() === mac.toUpperCase()) ?? null;
28429
28827
  }
28430
28828
  });
28431
28829
  }
@@ -28436,4 +28834,4 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
28436
28834
  }
28437
28835
  };
28438
28836
  //#endregion
28439
- export { WyzeProviderAddon, wyzeCameraSchema as n, WyzeCamera as t };
28837
+ export { WyzeProviderAddon, WyzeClientRegistry as a, wyzeCameraSchema as i, buildWyzeAdoptionProvider as n, buildWyzeCandidates as r, WyzeCamera as t };