@camstack/addon-provider-wyze 0.2.19 → 0.2.23

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.
Files changed (3) hide show
  1. package/dist/addon.js +245 -37
  2. package/dist/addon.mjs +245 -37
  3. package/package.json +4 -1
package/dist/addon.js CHANGED
@@ -30,7 +30,8 @@ let events = require("events");
30
30
  let net = require("net");
31
31
  net = __toESM(net, 1);
32
32
  let node_child_process = require("node:child_process");
33
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
33
+ let node_tls = require("node:tls");
34
+ //#region ../types/dist/event-category-C0lyLd5U.mjs
34
35
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
35
36
  EventCategory["SystemBoot"] = "system.boot";
36
37
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -47,9 +48,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
47
48
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
48
49
  /**
49
50
  * A newer addon or server-root package version was found by the
50
- * authoritative registry check. Emitted once per
51
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
52
- * polling of the same result is deduplicated by the checker.
51
+ * authoritative registry check. Emitted once when any observed
52
+ * `latestVersion` changes (or a package/node first appears behind);
53
+ * the payload carries the full currently-available list. Repeated
54
+ * polling of the same latests is silent.
53
55
  */
54
56
  EventCategory["UpdateAvailable"] = "update.available";
55
57
  /**
@@ -7579,6 +7581,10 @@ var RecordingBandSchema = object({
7579
7581
  preBufferSec: number().min(0).optional(),
7580
7582
  postBufferSec: number().min(0).optional()
7581
7583
  });
7584
+ ({
7585
+ preBufferSec: 10,
7586
+ postBufferSec: 30
7587
+ }).postBufferSec * 1e3;
7582
7588
  /**
7583
7589
  * Per-device retention overrides. Every field is optional; an unset or `0`
7584
7590
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -14595,6 +14601,9 @@ var NcSystemEventKindSchema = _enum([
14595
14601
  "alarm-disarmed",
14596
14602
  "alarm-arming",
14597
14603
  "alarm-arm-refused",
14604
+ "addon-updated",
14605
+ "server-updated",
14606
+ "export-completed",
14598
14607
  "camera-online",
14599
14608
  "camera-offline",
14600
14609
  "camera-disabled",
@@ -16488,13 +16497,19 @@ var RetrainStatusSchema = _enum([
16488
16497
  * "never marked" from "already trained" must read `retrainStatus`.
16489
16498
  *
16490
16499
  * `debug` does NOT pin; it is attention, not durability.
16500
+ *
16501
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16502
+ * A favourited track is skipped by retention the same way `staging` is, but
16503
+ * it does not enter `none|staging|trained` and has no staging budget.
16491
16504
  */
16492
16505
  var TrackFlagFields = {
16493
16506
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16494
16507
  * `'staging'`. */
16495
16508
  markForTrain: boolean().optional(),
16496
16509
  /** Operator marked this track for diagnostic attention. */
16497
- debug: boolean().optional()
16510
+ debug: boolean().optional(),
16511
+ /** Operator favourited this track. Pins it against pruning. */
16512
+ favourited: boolean().optional()
16498
16513
  };
16499
16514
  /**
16500
16515
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16519,6 +16534,7 @@ var TrackFlagsSchema = object({
16519
16534
  trackId: string(),
16520
16535
  markForTrain: boolean(),
16521
16536
  debug: boolean(),
16537
+ favourited: boolean(),
16522
16538
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16523
16539
  * a track row) because this shape is only ever produced by the write body,
16524
16540
  * which always knows it — and a surface that has just written needs to render
@@ -20315,8 +20331,28 @@ var ClipSchema = object({
20315
20331
  startMs: number(),
20316
20332
  endMs: number()
20317
20333
  }),
20318
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20319
- thumbnail: string().optional()
20334
+ /**
20335
+ * Lazy thumbnail URL, never inlined.
20336
+ *
20337
+ * Recording-derived clips (events-mode keep-window, and the prepared
20338
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20339
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20340
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20341
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20342
+ * mint their own stills.
20343
+ */
20344
+ thumbnail: string().optional(),
20345
+ /** Analytics event ids that overlap this visit. Empty on footage-only clips.
20346
+ * The default provider's visit grain puts many motion heartbeats on one clip
20347
+ * instead of minting one clip per marker. */
20348
+ eventIds: array(string()).optional(),
20349
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20350
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20351
+ * bar keeps showing them via `recording.getAvailability`. */
20352
+ holes: array(object({
20353
+ startMs: number(),
20354
+ endMs: number()
20355
+ })).optional()
20320
20356
  });
20321
20357
  var ClipPlaybackSchema = object({
20322
20358
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -21891,10 +21927,18 @@ settings: record(string(), unknown()) });
21891
21927
  * descriptive — it never changes routing.
21892
21928
  */
21893
21929
  var ConnectionTestDescriptorSchema = object({ label: string() });
21894
- method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21895
- kind: "mutation",
21896
- auth: "admin"
21897
- }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
21930
+ var connectionTestCapability = {
21931
+ name: "connection-test",
21932
+ scope: "system",
21933
+ mode: "collection",
21934
+ methods: {
21935
+ testSettings: method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21936
+ kind: "mutation",
21937
+ auth: "admin"
21938
+ }),
21939
+ describeTest: method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" })
21940
+ }
21941
+ };
21898
21942
  /**
21899
21943
  * Upstream-system connectivity sensor — distinct from `device-status`,
21900
21944
  * which is the kernel-managed online/offline flag for the device's
@@ -26602,7 +26646,9 @@ var ExportOptionsSchema = object({
26602
26646
  includeAudio: boolean(),
26603
26647
  maxLifeMs: number().int().positive(),
26604
26648
  deleteAfterDownload: boolean(),
26605
- title: string().max(200).optional()
26649
+ title: string().max(200).optional(),
26650
+ /** Notification-output target ids to ping when this export becomes ready. */
26651
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26606
26652
  }).superRefine((v, ctx) => {
26607
26653
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26608
26654
  code: ZodIssueCode.custom,
@@ -26661,10 +26707,18 @@ var ExportBytesSchema = object({
26661
26707
  });
26662
26708
  method(object({
26663
26709
  deviceId: number(),
26664
- profile: string(),
26710
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
26711
+ profile: string().optional(),
26712
+ profiles: array(string()).min(1).optional(),
26665
26713
  fromMs: number(),
26666
26714
  toMs: number(),
26667
26715
  options: ExportOptionsSchema
26716
+ }).superRefine((v, ctx) => {
26717
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
26718
+ code: ZodIssueCode.custom,
26719
+ message: "pass profiles[] (min 1) or legacy profile",
26720
+ path: ["profiles"]
26721
+ });
26668
26722
  }), ExportRecordSchema, {
26669
26723
  kind: "mutation",
26670
26724
  auth: "protected"
@@ -29606,15 +29660,6 @@ var BaseDeviceProvider = class extends BaseAddon {
29606
29660
  labels: ["probe not implemented"]
29607
29661
  };
29608
29662
  }
29609
- /**
29610
- * Top-level devices restored at once in {@link onRestoreDevices}.
29611
- *
29612
- * Four covers the fleets this ships to without turning a boot into a burst a
29613
- * camera NVR answers with a refusal. A provider whose upstream is a single
29614
- * session with a serial command channel (a Baichuan hub, an NVR that
29615
- * serialises ISAPI) should lower it; nothing needs to raise it.
29616
- */
29617
- restoreConcurrency = 4;
29618
29663
  async restoreDevices(savedDevices) {
29619
29664
  await this.onRestoreDevices(savedDevices);
29620
29665
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -29669,14 +29714,7 @@ var BaseDeviceProvider = class extends BaseAddon {
29669
29714
  });
29670
29715
  }
29671
29716
  };
29672
- let nextTopLevel = 0;
29673
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
29674
- for (;;) {
29675
- const saved = topLevel[nextTopLevel++];
29676
- if (saved === void 0) return;
29677
- await restoreOne(saved);
29678
- }
29679
- }));
29717
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
29680
29718
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
29681
29719
  for (const saved of childRows) {
29682
29720
  const Class = this.deviceClasses[saved.type];
@@ -40631,7 +40669,8 @@ var WyzeClientRegistry = class {
40631
40669
  credentials,
40632
40670
  cameraListCache: [],
40633
40671
  cameraListFetchedAt: 0,
40634
- cameraListInFlight: null
40672
+ cameraListInFlight: null,
40673
+ lastFetchError: null
40635
40674
  });
40636
40675
  }
40637
40676
  /** Drop the client for an integration that no longer exists / was disabled. */
@@ -40659,6 +40698,14 @@ var WyzeClientRegistry = class {
40659
40698
  this.#entries.clear();
40660
40699
  }
40661
40700
  /**
40701
+ * The last classified error from a `getCameraList` fetch failure for an
40702
+ * integration, or null when the last fetch succeeded (or no fetch has run
40703
+ * yet). Used by `getStatus` to surface the reason for an empty adoption list.
40704
+ */
40705
+ getLastFetchError(integrationId) {
40706
+ return this.#entries.get(integrationId)?.lastFetchError ?? null;
40707
+ }
40708
+ /**
40662
40709
  * Cloud camera list for ONE integration, cached + debounced + single-flight
40663
40710
  * to avoid auth rate-limits. Returns the last cache (possibly empty) when the
40664
40711
  * integration is unknown or the fetch fails.
@@ -40672,9 +40719,11 @@ var WyzeClientRegistry = class {
40672
40719
  const run = entry.client.getCameraList().then((cams) => {
40673
40720
  entry.cameraListCache = cams;
40674
40721
  entry.cameraListFetchedAt = Date.now();
40722
+ entry.lastFetchError = null;
40675
40723
  return cams;
40676
40724
  }).catch((err) => {
40677
40725
  const classified = classifyCloudError(err);
40726
+ entry.lastFetchError = classified;
40678
40727
  this.#deps.logger.error("Wyze getCameraList failed", { meta: {
40679
40728
  integrationId,
40680
40729
  kind: classified.kind,
@@ -41170,11 +41219,16 @@ function buildWyzeAdoptionProvider(deps) {
41170
41219
  let candidateCount = 0;
41171
41220
  for (const integrationId of listIntegrations()) candidateCount += (await candidatesForIntegration(integrationId)).length;
41172
41221
  const adoptedCount = (await listAdopted()).length;
41222
+ const fetchErrors = [];
41223
+ if (deps.getLastFetchError) for (const integrationId of listIntegrations()) {
41224
+ const err = deps.getLastFetchError(integrationId);
41225
+ if (err) fetchErrors.push(`[${integrationId}] ${err.message}`);
41226
+ }
41173
41227
  return {
41174
41228
  lastDiscoveryAt: Date.now(),
41175
41229
  candidateCount,
41176
41230
  adoptedCount,
41177
- lastError: null
41231
+ lastError: fetchErrors.length > 0 ? fetchErrors.join("; ") : null
41178
41232
  };
41179
41233
  } catch (err) {
41180
41234
  logger.warn("wyze adoption: getStatus failed", { meta: { error: errMsg(err) } });
@@ -41189,11 +41243,12 @@ function buildWyzeAdoptionProvider(deps) {
41189
41243
  refresh: async ({ integrationId }) => {
41190
41244
  const candidateCount = (await candidatesForIntegration(integrationId, true)).length;
41191
41245
  const adoptedCount = adoptedMapForIntegration(integrationId, await listAdopted()).size;
41246
+ const fetchErr = deps.getLastFetchError?.(integrationId);
41192
41247
  return {
41193
41248
  lastDiscoveryAt: Date.now(),
41194
41249
  candidateCount,
41195
41250
  adoptedCount,
41196
- lastError: null
41251
+ lastError: fetchErr ? fetchErr.message : null
41197
41252
  };
41198
41253
  },
41199
41254
  adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
@@ -41925,6 +41980,139 @@ var WyzeCamera = class extends BaseDevice {
41925
41980
  static fromCloud = cameraConfigFromCloud;
41926
41981
  };
41927
41982
  //#endregion
41983
+ //#region src/wyze-connection-test.ts
41984
+ /**
41985
+ * Production default: creates a `WyzeCloud` with no-op session hooks so
41986
+ * `testSettings` never writes or reads `wyze-session.json`.
41987
+ */
41988
+ function defaultWyzeCloudFacadeFactory(credentials) {
41989
+ const cloud = new WyzeCloud({
41990
+ apiKey: credentials.apiKey,
41991
+ apiId: credentials.apiId,
41992
+ loadSession: () => null,
41993
+ saveSession: () => void 0,
41994
+ clearSession: () => void 0
41995
+ });
41996
+ return {
41997
+ ensureSession: (email, password) => cloud.ensureSession(email, password),
41998
+ getCameraList: () => cloud.getCameraList()
41999
+ };
42000
+ }
42001
+ var wyzeCredentialsSchema = object({
42002
+ email: string().min(1).describe("Wyze account email"),
42003
+ password: string().min(1).describe("Wyze account password"),
42004
+ apiKey: string().min(1).describe("Wyze developer API key"),
42005
+ apiId: string().min(1).describe("Wyze developer Key ID")
42006
+ });
42007
+ var TEST_LABEL = "Signs in to the Wyze cloud with these account credentials";
42008
+ function buildWyzeConnectionTestProvider(deps) {
42009
+ const { makeCloud, logger } = deps;
42010
+ return {
42011
+ describeTest: async () => ({ label: TEST_LABEL }),
42012
+ testSettings: async ({ settings }) => {
42013
+ const parsed = wyzeCredentialsSchema.safeParse(settings);
42014
+ if (!parsed.success) {
42015
+ const missing = parsed.error.issues.map((i) => i.path.join(".") || "(root)").join(", ");
42016
+ logger.warn("Wyze connection test: settings incomplete", { meta: { missing } });
42017
+ return {
42018
+ outcome: "rejected",
42019
+ error: `Wyze account settings are incomplete or invalid: ${missing}`
42020
+ };
42021
+ }
42022
+ const { email, password, apiKey, apiId } = parsed.data;
42023
+ const startedAt = Date.now();
42024
+ const cloud = makeCloud({
42025
+ email,
42026
+ password,
42027
+ apiKey,
42028
+ apiId
42029
+ });
42030
+ try {
42031
+ await cloud.ensureSession(email, password);
42032
+ let cameraCount = null;
42033
+ try {
42034
+ cameraCount = (await cloud.getCameraList()).length;
42035
+ } catch {}
42036
+ return {
42037
+ outcome: "validated",
42038
+ latencyMs: Date.now() - startedAt,
42039
+ ...cameraCount !== null ? { detail: `Found ${cameraCount} camera${cameraCount === 1 ? "" : "s"} on this account` } : {}
42040
+ };
42041
+ } catch (err) {
42042
+ const classified = classifyCloudError(err);
42043
+ if (classified.kind === "mfa") {
42044
+ logger.warn("Wyze connection test: MFA required", { meta: { email } });
42045
+ return {
42046
+ outcome: "rejected",
42047
+ error: classified.message
42048
+ };
42049
+ }
42050
+ if (classified.kind === "credentials") {
42051
+ logger.warn("Wyze connection test: credentials refused", { meta: { email } });
42052
+ return {
42053
+ outcome: "rejected",
42054
+ error: "Wyze refused these credentials — check the email, password, API Key and Key ID. " + classified.message
42055
+ };
42056
+ }
42057
+ logger.warn("Wyze connection test: could not complete", { meta: {
42058
+ email,
42059
+ kind: classified.kind,
42060
+ error: errMsg(err)
42061
+ } });
42062
+ return {
42063
+ outcome: "inconclusive",
42064
+ error: classified.kind === "rate-limited" ? `Could not verify credentials — rate-limited by the Wyze cloud. Retry in ~30 s. ${classified.message}` : `Could not reach the Wyze cloud to verify these credentials: ${errMsg(err)}`
42065
+ };
42066
+ }
42067
+ }
42068
+ };
42069
+ }
42070
+ //#endregion
42071
+ //#region src/wyze-tls-trust.ts
42072
+ /**
42073
+ * Ubuntu 24.04's `ca-certificates` (2026-06) dropped DigiCert Global Root CA
42074
+ * (the 2006 SHA-1-era root, still valid until 2031). Wyze's `*.wyzecam.com`
42075
+ * leaf is issued by "DigiCert TLS RSA SHA256 2020 CA1", which chains to that
42076
+ * root. Node fetch then fails with `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` and
42077
+ * the adoption panel shows an empty camera list.
42078
+ *
42079
+ * macOS still trusts this root in the system keychain, which is why the same
42080
+ * URL verifies on the developer Mac and fails inside the hub container.
42081
+ *
42082
+ * The PEM is DigiCert's public root (https://cacerts.digicert.com/DigiCertGlobalRootCA.crt.pem).
42083
+ * Applied once per addon process via `tls.setDefaultCACertificates` — does
42084
+ * NOT disable verification, it only restores the missing root.
42085
+ */
42086
+ var DIGICERT_GLOBAL_ROOT_CA_PEM = `-----BEGIN CERTIFICATE-----
42087
+ MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
42088
+ MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
42089
+ d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
42090
+ QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
42091
+ MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
42092
+ b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
42093
+ 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
42094
+ CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
42095
+ nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
42096
+ 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
42097
+ T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
42098
+ gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
42099
+ BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
42100
+ TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
42101
+ DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
42102
+ hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
42103
+ 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
42104
+ PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
42105
+ YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
42106
+ CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
42107
+ -----END CERTIFICATE-----
42108
+ `;
42109
+ var applied = false;
42110
+ function applyWyzeCloudTlsTrust() {
42111
+ if (applied) return;
42112
+ applied = true;
42113
+ (0, node_tls.setDefaultCACertificates)([...(0, node_tls.getCACertificates)(), DIGICERT_GLOBAL_ROOT_CA_PEM]);
42114
+ }
42115
+ //#endregion
41928
42116
  //#region src/addon.ts
41929
42117
  function getString(obj, key) {
41930
42118
  const v = obj[key];
@@ -41971,6 +42159,7 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41971
42159
  super({});
41972
42160
  }
41973
42161
  async onInitialize() {
42162
+ applyWyzeCloudTlsTrust();
41974
42163
  const regs = await super.onInitialize();
41975
42164
  this.clients = new WyzeClientRegistry({
41976
42165
  dataDir: this.ctx.dataDir,
@@ -41992,10 +42181,17 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41992
42181
  });
41993
42182
  await this.reconcileIntegrations();
41994
42183
  this.subscribeIntegrationLifecycle();
41995
- return [...regs, {
41996
- capability: deviceAdoptionCapability,
41997
- provider: this.buildAdoptionProvider()
41998
- }];
42184
+ return [
42185
+ ...regs,
42186
+ {
42187
+ capability: deviceAdoptionCapability,
42188
+ provider: this.buildAdoptionProvider()
42189
+ },
42190
+ {
42191
+ capability: connectionTestCapability,
42192
+ provider: this.buildConnectionTestProvider()
42193
+ }
42194
+ ];
41999
42195
  }
42000
42196
  async onShutdown() {
42001
42197
  this.clients?.clear();
@@ -42058,10 +42254,22 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
42058
42254
  this.wireCameraDeps(dev);
42059
42255
  await dev.materializeStreamSocket(camStreamId);
42060
42256
  }
42257
+ /**
42258
+ * Pre-creation credential check. Registered unconditionally — it must answer
42259
+ * BEFORE any integration for this addon exists, so it depends on nothing in
42260
+ * {@link WyzeClientRegistry} and opens its own throwaway session (no disk I/O).
42261
+ */
42262
+ buildConnectionTestProvider() {
42263
+ return buildWyzeConnectionTestProvider({
42264
+ makeCloud: defaultWyzeCloudFacadeFactory,
42265
+ logger: this.ctx.logger
42266
+ });
42267
+ }
42061
42268
  buildAdoptionProvider() {
42062
42269
  return buildWyzeAdoptionProvider({
42063
42270
  logger: this.ctx.logger,
42064
42271
  getCameraList: (integrationId, force) => this.requireClients().getCameraList(integrationId, force),
42272
+ getLastFetchError: (integrationId) => this.requireClients().getLastFetchError(integrationId),
42065
42273
  hasIntegration: (integrationId) => this.requireClients().has(integrationId),
42066
42274
  listIntegrations: () => this.requireClients().list(),
42067
42275
  listAdopted: async () => {
package/dist/addon.mjs CHANGED
@@ -6,10 +6,11 @@ import * as dgram from "dgram";
6
6
  import { EventEmitter } from "events";
7
7
  import * as net from "net";
8
8
  import { spawn } from "node:child_process";
9
+ import { getCACertificates, setDefaultCACertificates } from "node:tls";
9
10
  //#region \0rolldown/runtime.js
10
11
  var __require$1 = /* @__PURE__ */ createRequire(import.meta.url);
11
12
  //#endregion
12
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
13
+ //#region ../types/dist/event-category-C0lyLd5U.mjs
13
14
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
14
15
  EventCategory["SystemBoot"] = "system.boot";
15
16
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -26,9 +27,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
26
27
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
27
28
  /**
28
29
  * A newer addon or server-root package version was found by the
29
- * authoritative registry check. Emitted once per
30
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
31
- * polling of the same result is deduplicated by the checker.
30
+ * authoritative registry check. Emitted once when any observed
31
+ * `latestVersion` changes (or a package/node first appears behind);
32
+ * the payload carries the full currently-available list. Repeated
33
+ * polling of the same latests is silent.
32
34
  */
33
35
  EventCategory["UpdateAvailable"] = "update.available";
34
36
  /**
@@ -7558,6 +7560,10 @@ var RecordingBandSchema = object({
7558
7560
  preBufferSec: number().min(0).optional(),
7559
7561
  postBufferSec: number().min(0).optional()
7560
7562
  });
7563
+ ({
7564
+ preBufferSec: 10,
7565
+ postBufferSec: 30
7566
+ }).postBufferSec * 1e3;
7561
7567
  /**
7562
7568
  * Per-device retention overrides. Every field is optional; an unset or `0`
7563
7569
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -14574,6 +14580,9 @@ var NcSystemEventKindSchema = _enum([
14574
14580
  "alarm-disarmed",
14575
14581
  "alarm-arming",
14576
14582
  "alarm-arm-refused",
14583
+ "addon-updated",
14584
+ "server-updated",
14585
+ "export-completed",
14577
14586
  "camera-online",
14578
14587
  "camera-offline",
14579
14588
  "camera-disabled",
@@ -16467,13 +16476,19 @@ var RetrainStatusSchema = _enum([
16467
16476
  * "never marked" from "already trained" must read `retrainStatus`.
16468
16477
  *
16469
16478
  * `debug` does NOT pin; it is attention, not durability.
16479
+ *
16480
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16481
+ * A favourited track is skipped by retention the same way `staging` is, but
16482
+ * it does not enter `none|staging|trained` and has no staging budget.
16470
16483
  */
16471
16484
  var TrackFlagFields = {
16472
16485
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16473
16486
  * `'staging'`. */
16474
16487
  markForTrain: boolean().optional(),
16475
16488
  /** Operator marked this track for diagnostic attention. */
16476
- debug: boolean().optional()
16489
+ debug: boolean().optional(),
16490
+ /** Operator favourited this track. Pins it against pruning. */
16491
+ favourited: boolean().optional()
16477
16492
  };
16478
16493
  /**
16479
16494
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16498,6 +16513,7 @@ var TrackFlagsSchema = object({
16498
16513
  trackId: string(),
16499
16514
  markForTrain: boolean(),
16500
16515
  debug: boolean(),
16516
+ favourited: boolean(),
16501
16517
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16502
16518
  * a track row) because this shape is only ever produced by the write body,
16503
16519
  * which always knows it — and a surface that has just written needs to render
@@ -20294,8 +20310,28 @@ var ClipSchema = object({
20294
20310
  startMs: number(),
20295
20311
  endMs: number()
20296
20312
  }),
20297
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20298
- thumbnail: string().optional()
20313
+ /**
20314
+ * Lazy thumbnail URL, never inlined.
20315
+ *
20316
+ * Recording-derived clips (events-mode keep-window, and the prepared
20317
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20318
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20319
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20320
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20321
+ * mint their own stills.
20322
+ */
20323
+ thumbnail: string().optional(),
20324
+ /** Analytics event ids that overlap this visit. Empty on footage-only clips.
20325
+ * The default provider's visit grain puts many motion heartbeats on one clip
20326
+ * instead of minting one clip per marker. */
20327
+ eventIds: array(string()).optional(),
20328
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20329
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20330
+ * bar keeps showing them via `recording.getAvailability`. */
20331
+ holes: array(object({
20332
+ startMs: number(),
20333
+ endMs: number()
20334
+ })).optional()
20299
20335
  });
20300
20336
  var ClipPlaybackSchema = object({
20301
20337
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -21870,10 +21906,18 @@ settings: record(string(), unknown()) });
21870
21906
  * descriptive — it never changes routing.
21871
21907
  */
21872
21908
  var ConnectionTestDescriptorSchema = object({ label: string() });
21873
- method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21874
- kind: "mutation",
21875
- auth: "admin"
21876
- }), method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" });
21909
+ var connectionTestCapability = {
21910
+ name: "connection-test",
21911
+ scope: "system",
21912
+ mode: "collection",
21913
+ methods: {
21914
+ testSettings: method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21915
+ kind: "mutation",
21916
+ auth: "admin"
21917
+ }),
21918
+ describeTest: method(_void(), ConnectionTestDescriptorSchema, { auth: "admin" })
21919
+ }
21920
+ };
21877
21921
  /**
21878
21922
  * Upstream-system connectivity sensor — distinct from `device-status`,
21879
21923
  * which is the kernel-managed online/offline flag for the device's
@@ -26581,7 +26625,9 @@ var ExportOptionsSchema = object({
26581
26625
  includeAudio: boolean(),
26582
26626
  maxLifeMs: number().int().positive(),
26583
26627
  deleteAfterDownload: boolean(),
26584
- title: string().max(200).optional()
26628
+ title: string().max(200).optional(),
26629
+ /** Notification-output target ids to ping when this export becomes ready. */
26630
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26585
26631
  }).superRefine((v, ctx) => {
26586
26632
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26587
26633
  code: ZodIssueCode.custom,
@@ -26640,10 +26686,18 @@ var ExportBytesSchema = object({
26640
26686
  });
26641
26687
  method(object({
26642
26688
  deviceId: number(),
26643
- profile: string(),
26689
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
26690
+ profile: string().optional(),
26691
+ profiles: array(string()).min(1).optional(),
26644
26692
  fromMs: number(),
26645
26693
  toMs: number(),
26646
26694
  options: ExportOptionsSchema
26695
+ }).superRefine((v, ctx) => {
26696
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
26697
+ code: ZodIssueCode.custom,
26698
+ message: "pass profiles[] (min 1) or legacy profile",
26699
+ path: ["profiles"]
26700
+ });
26647
26701
  }), ExportRecordSchema, {
26648
26702
  kind: "mutation",
26649
26703
  auth: "protected"
@@ -29585,15 +29639,6 @@ var BaseDeviceProvider = class extends BaseAddon {
29585
29639
  labels: ["probe not implemented"]
29586
29640
  };
29587
29641
  }
29588
- /**
29589
- * Top-level devices restored at once in {@link onRestoreDevices}.
29590
- *
29591
- * Four covers the fleets this ships to without turning a boot into a burst a
29592
- * camera NVR answers with a refusal. A provider whose upstream is a single
29593
- * session with a serial command channel (a Baichuan hub, an NVR that
29594
- * serialises ISAPI) should lower it; nothing needs to raise it.
29595
- */
29596
- restoreConcurrency = 4;
29597
29642
  async restoreDevices(savedDevices) {
29598
29643
  await this.onRestoreDevices(savedDevices);
29599
29644
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -29648,14 +29693,7 @@ var BaseDeviceProvider = class extends BaseAddon {
29648
29693
  });
29649
29694
  }
29650
29695
  };
29651
- let nextTopLevel = 0;
29652
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
29653
- for (;;) {
29654
- const saved = topLevel[nextTopLevel++];
29655
- if (saved === void 0) return;
29656
- await restoreOne(saved);
29657
- }
29658
- }));
29696
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
29659
29697
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
29660
29698
  for (const saved of childRows) {
29661
29699
  const Class = this.deviceClasses[saved.type];
@@ -40610,7 +40648,8 @@ var WyzeClientRegistry = class {
40610
40648
  credentials,
40611
40649
  cameraListCache: [],
40612
40650
  cameraListFetchedAt: 0,
40613
- cameraListInFlight: null
40651
+ cameraListInFlight: null,
40652
+ lastFetchError: null
40614
40653
  });
40615
40654
  }
40616
40655
  /** Drop the client for an integration that no longer exists / was disabled. */
@@ -40638,6 +40677,14 @@ var WyzeClientRegistry = class {
40638
40677
  this.#entries.clear();
40639
40678
  }
40640
40679
  /**
40680
+ * The last classified error from a `getCameraList` fetch failure for an
40681
+ * integration, or null when the last fetch succeeded (or no fetch has run
40682
+ * yet). Used by `getStatus` to surface the reason for an empty adoption list.
40683
+ */
40684
+ getLastFetchError(integrationId) {
40685
+ return this.#entries.get(integrationId)?.lastFetchError ?? null;
40686
+ }
40687
+ /**
40641
40688
  * Cloud camera list for ONE integration, cached + debounced + single-flight
40642
40689
  * to avoid auth rate-limits. Returns the last cache (possibly empty) when the
40643
40690
  * integration is unknown or the fetch fails.
@@ -40651,9 +40698,11 @@ var WyzeClientRegistry = class {
40651
40698
  const run = entry.client.getCameraList().then((cams) => {
40652
40699
  entry.cameraListCache = cams;
40653
40700
  entry.cameraListFetchedAt = Date.now();
40701
+ entry.lastFetchError = null;
40654
40702
  return cams;
40655
40703
  }).catch((err) => {
40656
40704
  const classified = classifyCloudError(err);
40705
+ entry.lastFetchError = classified;
40657
40706
  this.#deps.logger.error("Wyze getCameraList failed", { meta: {
40658
40707
  integrationId,
40659
40708
  kind: classified.kind,
@@ -41149,11 +41198,16 @@ function buildWyzeAdoptionProvider(deps) {
41149
41198
  let candidateCount = 0;
41150
41199
  for (const integrationId of listIntegrations()) candidateCount += (await candidatesForIntegration(integrationId)).length;
41151
41200
  const adoptedCount = (await listAdopted()).length;
41201
+ const fetchErrors = [];
41202
+ if (deps.getLastFetchError) for (const integrationId of listIntegrations()) {
41203
+ const err = deps.getLastFetchError(integrationId);
41204
+ if (err) fetchErrors.push(`[${integrationId}] ${err.message}`);
41205
+ }
41152
41206
  return {
41153
41207
  lastDiscoveryAt: Date.now(),
41154
41208
  candidateCount,
41155
41209
  adoptedCount,
41156
- lastError: null
41210
+ lastError: fetchErrors.length > 0 ? fetchErrors.join("; ") : null
41157
41211
  };
41158
41212
  } catch (err) {
41159
41213
  logger.warn("wyze adoption: getStatus failed", { meta: { error: errMsg(err) } });
@@ -41168,11 +41222,12 @@ function buildWyzeAdoptionProvider(deps) {
41168
41222
  refresh: async ({ integrationId }) => {
41169
41223
  const candidateCount = (await candidatesForIntegration(integrationId, true)).length;
41170
41224
  const adoptedCount = adoptedMapForIntegration(integrationId, await listAdopted()).size;
41225
+ const fetchErr = deps.getLastFetchError?.(integrationId);
41171
41226
  return {
41172
41227
  lastDiscoveryAt: Date.now(),
41173
41228
  candidateCount,
41174
41229
  adoptedCount,
41175
- lastError: null
41230
+ lastError: fetchErr ? fetchErr.message : null
41176
41231
  };
41177
41232
  },
41178
41233
  adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
@@ -41904,6 +41959,139 @@ var WyzeCamera = class extends BaseDevice {
41904
41959
  static fromCloud = cameraConfigFromCloud;
41905
41960
  };
41906
41961
  //#endregion
41962
+ //#region src/wyze-connection-test.ts
41963
+ /**
41964
+ * Production default: creates a `WyzeCloud` with no-op session hooks so
41965
+ * `testSettings` never writes or reads `wyze-session.json`.
41966
+ */
41967
+ function defaultWyzeCloudFacadeFactory(credentials) {
41968
+ const cloud = new WyzeCloud({
41969
+ apiKey: credentials.apiKey,
41970
+ apiId: credentials.apiId,
41971
+ loadSession: () => null,
41972
+ saveSession: () => void 0,
41973
+ clearSession: () => void 0
41974
+ });
41975
+ return {
41976
+ ensureSession: (email, password) => cloud.ensureSession(email, password),
41977
+ getCameraList: () => cloud.getCameraList()
41978
+ };
41979
+ }
41980
+ var wyzeCredentialsSchema = object({
41981
+ email: string().min(1).describe("Wyze account email"),
41982
+ password: string().min(1).describe("Wyze account password"),
41983
+ apiKey: string().min(1).describe("Wyze developer API key"),
41984
+ apiId: string().min(1).describe("Wyze developer Key ID")
41985
+ });
41986
+ var TEST_LABEL = "Signs in to the Wyze cloud with these account credentials";
41987
+ function buildWyzeConnectionTestProvider(deps) {
41988
+ const { makeCloud, logger } = deps;
41989
+ return {
41990
+ describeTest: async () => ({ label: TEST_LABEL }),
41991
+ testSettings: async ({ settings }) => {
41992
+ const parsed = wyzeCredentialsSchema.safeParse(settings);
41993
+ if (!parsed.success) {
41994
+ const missing = parsed.error.issues.map((i) => i.path.join(".") || "(root)").join(", ");
41995
+ logger.warn("Wyze connection test: settings incomplete", { meta: { missing } });
41996
+ return {
41997
+ outcome: "rejected",
41998
+ error: `Wyze account settings are incomplete or invalid: ${missing}`
41999
+ };
42000
+ }
42001
+ const { email, password, apiKey, apiId } = parsed.data;
42002
+ const startedAt = Date.now();
42003
+ const cloud = makeCloud({
42004
+ email,
42005
+ password,
42006
+ apiKey,
42007
+ apiId
42008
+ });
42009
+ try {
42010
+ await cloud.ensureSession(email, password);
42011
+ let cameraCount = null;
42012
+ try {
42013
+ cameraCount = (await cloud.getCameraList()).length;
42014
+ } catch {}
42015
+ return {
42016
+ outcome: "validated",
42017
+ latencyMs: Date.now() - startedAt,
42018
+ ...cameraCount !== null ? { detail: `Found ${cameraCount} camera${cameraCount === 1 ? "" : "s"} on this account` } : {}
42019
+ };
42020
+ } catch (err) {
42021
+ const classified = classifyCloudError(err);
42022
+ if (classified.kind === "mfa") {
42023
+ logger.warn("Wyze connection test: MFA required", { meta: { email } });
42024
+ return {
42025
+ outcome: "rejected",
42026
+ error: classified.message
42027
+ };
42028
+ }
42029
+ if (classified.kind === "credentials") {
42030
+ logger.warn("Wyze connection test: credentials refused", { meta: { email } });
42031
+ return {
42032
+ outcome: "rejected",
42033
+ error: "Wyze refused these credentials — check the email, password, API Key and Key ID. " + classified.message
42034
+ };
42035
+ }
42036
+ logger.warn("Wyze connection test: could not complete", { meta: {
42037
+ email,
42038
+ kind: classified.kind,
42039
+ error: errMsg(err)
42040
+ } });
42041
+ return {
42042
+ outcome: "inconclusive",
42043
+ error: classified.kind === "rate-limited" ? `Could not verify credentials — rate-limited by the Wyze cloud. Retry in ~30 s. ${classified.message}` : `Could not reach the Wyze cloud to verify these credentials: ${errMsg(err)}`
42044
+ };
42045
+ }
42046
+ }
42047
+ };
42048
+ }
42049
+ //#endregion
42050
+ //#region src/wyze-tls-trust.ts
42051
+ /**
42052
+ * Ubuntu 24.04's `ca-certificates` (2026-06) dropped DigiCert Global Root CA
42053
+ * (the 2006 SHA-1-era root, still valid until 2031). Wyze's `*.wyzecam.com`
42054
+ * leaf is issued by "DigiCert TLS RSA SHA256 2020 CA1", which chains to that
42055
+ * root. Node fetch then fails with `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` and
42056
+ * the adoption panel shows an empty camera list.
42057
+ *
42058
+ * macOS still trusts this root in the system keychain, which is why the same
42059
+ * URL verifies on the developer Mac and fails inside the hub container.
42060
+ *
42061
+ * The PEM is DigiCert's public root (https://cacerts.digicert.com/DigiCertGlobalRootCA.crt.pem).
42062
+ * Applied once per addon process via `tls.setDefaultCACertificates` — does
42063
+ * NOT disable verification, it only restores the missing root.
42064
+ */
42065
+ var DIGICERT_GLOBAL_ROOT_CA_PEM = `-----BEGIN CERTIFICATE-----
42066
+ MIIDrzCCApegAwIBAgIQCDvgVpBCRrGhdWrJWZHHSjANBgkqhkiG9w0BAQUFADBh
42067
+ MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3
42068
+ d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBD
42069
+ QTAeFw0wNjExMTAwMDAwMDBaFw0zMTExMTAwMDAwMDBaMGExCzAJBgNVBAYTAlVT
42070
+ MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j
42071
+ b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IENBMIIBIjANBgkqhkiG
42072
+ 9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4jvhEXLeqKTTo1eqUKKPC3eQyaKl7hLOllsB
42073
+ CSDMAZOnTjC3U/dDxGkAV53ijSLdhwZAAIEJzs4bg7/fzTtxRuLWZscFs3YnFo97
42074
+ nh6Vfe63SKMI2tavegw5BmV/Sl0fvBf4q77uKNd0f3p4mVmFaG5cIzJLv07A6Fpt
42075
+ 43C/dxC//AH2hdmoRBBYMql1GNXRor5H4idq9Joz+EkIYIvUX7Q6hL+hqkpMfT7P
42076
+ T19sdl6gSzeRntwi5m3OFBqOasv+zbMUZBfHWymeMr/y7vrTC0LUq7dBMtoM1O/4
42077
+ gdW7jVg/tRvoSSiicNoxBN33shbyTApOB6jtSj1etX+jkMOvJwIDAQABo2MwYTAO
42078
+ BgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUA95QNVbR
42079
+ TLtm8KPiGxvDl7I90VUwHwYDVR0jBBgwFoAUA95QNVbRTLtm8KPiGxvDl7I90VUw
42080
+ DQYJKoZIhvcNAQEFBQADggEBAMucN6pIExIK+t1EnE9SsPTfrgT1eXkIoyQY/Esr
42081
+ hMAtudXH/vTBH1jLuG2cenTnmCmrEbXjcKChzUyImZOMkXDiqw8cvpOp/2PV5Adg
42082
+ 06O/nVsJ8dWO41P0jmP6P6fbtGbfYmbW0W5BjfIttep3Sp+dWOIrWcBAI+0tKIJF
42083
+ PnlUkiaY4IBIqDfv8NZ5YBberOgOzW6sRBc4L0na4UU+Krk2U886UAb3LujEV0ls
42084
+ YSEY1QSteDwsOoBrp+uvFRTp2InBuThs4pFsiv9kuXclVzDAGySj4dzp30d8tbQk
42085
+ CAUw7C29C79Fv1C5qfPrmAESrciIxpg0X40KPMbp1ZWVbd4=
42086
+ -----END CERTIFICATE-----
42087
+ `;
42088
+ var applied = false;
42089
+ function applyWyzeCloudTlsTrust() {
42090
+ if (applied) return;
42091
+ applied = true;
42092
+ setDefaultCACertificates([...getCACertificates(), DIGICERT_GLOBAL_ROOT_CA_PEM]);
42093
+ }
42094
+ //#endregion
41907
42095
  //#region src/addon.ts
41908
42096
  function getString(obj, key) {
41909
42097
  const v = obj[key];
@@ -41950,6 +42138,7 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41950
42138
  super({});
41951
42139
  }
41952
42140
  async onInitialize() {
42141
+ applyWyzeCloudTlsTrust();
41953
42142
  const regs = await super.onInitialize();
41954
42143
  this.clients = new WyzeClientRegistry({
41955
42144
  dataDir: this.ctx.dataDir,
@@ -41971,10 +42160,17 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41971
42160
  });
41972
42161
  await this.reconcileIntegrations();
41973
42162
  this.subscribeIntegrationLifecycle();
41974
- return [...regs, {
41975
- capability: deviceAdoptionCapability,
41976
- provider: this.buildAdoptionProvider()
41977
- }];
42163
+ return [
42164
+ ...regs,
42165
+ {
42166
+ capability: deviceAdoptionCapability,
42167
+ provider: this.buildAdoptionProvider()
42168
+ },
42169
+ {
42170
+ capability: connectionTestCapability,
42171
+ provider: this.buildConnectionTestProvider()
42172
+ }
42173
+ ];
41978
42174
  }
41979
42175
  async onShutdown() {
41980
42176
  this.clients?.clear();
@@ -42037,10 +42233,22 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
42037
42233
  this.wireCameraDeps(dev);
42038
42234
  await dev.materializeStreamSocket(camStreamId);
42039
42235
  }
42236
+ /**
42237
+ * Pre-creation credential check. Registered unconditionally — it must answer
42238
+ * BEFORE any integration for this addon exists, so it depends on nothing in
42239
+ * {@link WyzeClientRegistry} and opens its own throwaway session (no disk I/O).
42240
+ */
42241
+ buildConnectionTestProvider() {
42242
+ return buildWyzeConnectionTestProvider({
42243
+ makeCloud: defaultWyzeCloudFacadeFactory,
42244
+ logger: this.ctx.logger
42245
+ });
42246
+ }
42040
42247
  buildAdoptionProvider() {
42041
42248
  return buildWyzeAdoptionProvider({
42042
42249
  logger: this.ctx.logger,
42043
42250
  getCameraList: (integrationId, force) => this.requireClients().getCameraList(integrationId, force),
42251
+ getLastFetchError: (integrationId) => this.requireClients().getLastFetchError(integrationId),
42044
42252
  hasIntegration: (integrationId) => this.requireClients().has(integrationId),
42045
42253
  listIntegrations: () => this.requireClients().list(),
42046
42254
  listAdopted: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-wyze",
3
- "version": "0.2.19",
3
+ "version": "0.2.23",
4
4
  "description": "Wyze camera device-provider addon for CamStack — wraps the @apocaliss92/wyze-bridge-js P2P/DTLS client, feeding the stream-broker via the pull-rfc4571 lazy-publish path (a structural twin of addon-provider-reolink)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -54,6 +54,9 @@
54
54
  },
55
55
  {
56
56
  "name": "stream-params"
57
+ },
58
+ {
59
+ "name": "connection-test"
57
60
  }
58
61
  ]
59
62
  }