@camstack/addon-provider-wyze 0.2.18 → 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 +253 -38
  2. package/dist/addon.mjs +253 -38
  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
@@ -19611,7 +19627,14 @@ targets: array(object({
19611
19627
  "sleeping",
19612
19628
  "unreachable",
19613
19629
  "waking"
19614
- ]).nullable()
19630
+ ]).nullable(),
19631
+ /** A battery camera (whatever its current state). An AWAKE battery
19632
+ * camera is deliberately NOT recaptured on the poll cadence — every
19633
+ * capture is a camera hit that would keep it out of sleep — so its
19634
+ * cached frame legitimately ages past the currency ceiling while
19635
+ * nothing is streaming. A surface must keep painting it (a fresh
19636
+ * frame is captured at each wake), not blank to "unavailable". */
19637
+ battery: boolean()
19615
19638
  })))
19616
19639
  },
19617
19640
  status: {
@@ -20308,8 +20331,28 @@ var ClipSchema = object({
20308
20331
  startMs: number(),
20309
20332
  endMs: number()
20310
20333
  }),
20311
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20312
- 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()
20313
20356
  });
20314
20357
  var ClipPlaybackSchema = object({
20315
20358
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -21884,10 +21927,18 @@ settings: record(string(), unknown()) });
21884
21927
  * descriptive — it never changes routing.
21885
21928
  */
21886
21929
  var ConnectionTestDescriptorSchema = object({ label: string() });
21887
- method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21888
- kind: "mutation",
21889
- auth: "admin"
21890
- }), 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
+ };
21891
21942
  /**
21892
21943
  * Upstream-system connectivity sensor — distinct from `device-status`,
21893
21944
  * which is the kernel-managed online/offline flag for the device's
@@ -26595,7 +26646,9 @@ var ExportOptionsSchema = object({
26595
26646
  includeAudio: boolean(),
26596
26647
  maxLifeMs: number().int().positive(),
26597
26648
  deleteAfterDownload: boolean(),
26598
- 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()
26599
26652
  }).superRefine((v, ctx) => {
26600
26653
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26601
26654
  code: ZodIssueCode.custom,
@@ -26654,10 +26707,18 @@ var ExportBytesSchema = object({
26654
26707
  });
26655
26708
  method(object({
26656
26709
  deviceId: number(),
26657
- profile: string(),
26710
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
26711
+ profile: string().optional(),
26712
+ profiles: array(string()).min(1).optional(),
26658
26713
  fromMs: number(),
26659
26714
  toMs: number(),
26660
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
+ });
26661
26722
  }), ExportRecordSchema, {
26662
26723
  kind: "mutation",
26663
26724
  auth: "protected"
@@ -29599,15 +29660,6 @@ var BaseDeviceProvider = class extends BaseAddon {
29599
29660
  labels: ["probe not implemented"]
29600
29661
  };
29601
29662
  }
29602
- /**
29603
- * Top-level devices restored at once in {@link onRestoreDevices}.
29604
- *
29605
- * Four covers the fleets this ships to without turning a boot into a burst a
29606
- * camera NVR answers with a refusal. A provider whose upstream is a single
29607
- * session with a serial command channel (a Baichuan hub, an NVR that
29608
- * serialises ISAPI) should lower it; nothing needs to raise it.
29609
- */
29610
- restoreConcurrency = 4;
29611
29663
  async restoreDevices(savedDevices) {
29612
29664
  await this.onRestoreDevices(savedDevices);
29613
29665
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -29662,14 +29714,7 @@ var BaseDeviceProvider = class extends BaseAddon {
29662
29714
  });
29663
29715
  }
29664
29716
  };
29665
- let nextTopLevel = 0;
29666
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
29667
- for (;;) {
29668
- const saved = topLevel[nextTopLevel++];
29669
- if (saved === void 0) return;
29670
- await restoreOne(saved);
29671
- }
29672
- }));
29717
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
29673
29718
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
29674
29719
  for (const saved of childRows) {
29675
29720
  const Class = this.deviceClasses[saved.type];
@@ -40624,7 +40669,8 @@ var WyzeClientRegistry = class {
40624
40669
  credentials,
40625
40670
  cameraListCache: [],
40626
40671
  cameraListFetchedAt: 0,
40627
- cameraListInFlight: null
40672
+ cameraListInFlight: null,
40673
+ lastFetchError: null
40628
40674
  });
40629
40675
  }
40630
40676
  /** Drop the client for an integration that no longer exists / was disabled. */
@@ -40652,6 +40698,14 @@ var WyzeClientRegistry = class {
40652
40698
  this.#entries.clear();
40653
40699
  }
40654
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
+ /**
40655
40709
  * Cloud camera list for ONE integration, cached + debounced + single-flight
40656
40710
  * to avoid auth rate-limits. Returns the last cache (possibly empty) when the
40657
40711
  * integration is unknown or the fetch fails.
@@ -40665,9 +40719,11 @@ var WyzeClientRegistry = class {
40665
40719
  const run = entry.client.getCameraList().then((cams) => {
40666
40720
  entry.cameraListCache = cams;
40667
40721
  entry.cameraListFetchedAt = Date.now();
40722
+ entry.lastFetchError = null;
40668
40723
  return cams;
40669
40724
  }).catch((err) => {
40670
40725
  const classified = classifyCloudError(err);
40726
+ entry.lastFetchError = classified;
40671
40727
  this.#deps.logger.error("Wyze getCameraList failed", { meta: {
40672
40728
  integrationId,
40673
40729
  kind: classified.kind,
@@ -41163,11 +41219,16 @@ function buildWyzeAdoptionProvider(deps) {
41163
41219
  let candidateCount = 0;
41164
41220
  for (const integrationId of listIntegrations()) candidateCount += (await candidatesForIntegration(integrationId)).length;
41165
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
+ }
41166
41227
  return {
41167
41228
  lastDiscoveryAt: Date.now(),
41168
41229
  candidateCount,
41169
41230
  adoptedCount,
41170
- lastError: null
41231
+ lastError: fetchErrors.length > 0 ? fetchErrors.join("; ") : null
41171
41232
  };
41172
41233
  } catch (err) {
41173
41234
  logger.warn("wyze adoption: getStatus failed", { meta: { error: errMsg(err) } });
@@ -41182,11 +41243,12 @@ function buildWyzeAdoptionProvider(deps) {
41182
41243
  refresh: async ({ integrationId }) => {
41183
41244
  const candidateCount = (await candidatesForIntegration(integrationId, true)).length;
41184
41245
  const adoptedCount = adoptedMapForIntegration(integrationId, await listAdopted()).size;
41246
+ const fetchErr = deps.getLastFetchError?.(integrationId);
41185
41247
  return {
41186
41248
  lastDiscoveryAt: Date.now(),
41187
41249
  candidateCount,
41188
41250
  adoptedCount,
41189
- lastError: null
41251
+ lastError: fetchErr ? fetchErr.message : null
41190
41252
  };
41191
41253
  },
41192
41254
  adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
@@ -41918,6 +41980,139 @@ var WyzeCamera = class extends BaseDevice {
41918
41980
  static fromCloud = cameraConfigFromCloud;
41919
41981
  };
41920
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
41921
42116
  //#region src/addon.ts
41922
42117
  function getString(obj, key) {
41923
42118
  const v = obj[key];
@@ -41964,6 +42159,7 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41964
42159
  super({});
41965
42160
  }
41966
42161
  async onInitialize() {
42162
+ applyWyzeCloudTlsTrust();
41967
42163
  const regs = await super.onInitialize();
41968
42164
  this.clients = new WyzeClientRegistry({
41969
42165
  dataDir: this.ctx.dataDir,
@@ -41985,10 +42181,17 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41985
42181
  });
41986
42182
  await this.reconcileIntegrations();
41987
42183
  this.subscribeIntegrationLifecycle();
41988
- return [...regs, {
41989
- capability: deviceAdoptionCapability,
41990
- provider: this.buildAdoptionProvider()
41991
- }];
42184
+ return [
42185
+ ...regs,
42186
+ {
42187
+ capability: deviceAdoptionCapability,
42188
+ provider: this.buildAdoptionProvider()
42189
+ },
42190
+ {
42191
+ capability: connectionTestCapability,
42192
+ provider: this.buildConnectionTestProvider()
42193
+ }
42194
+ ];
41992
42195
  }
41993
42196
  async onShutdown() {
41994
42197
  this.clients?.clear();
@@ -42051,10 +42254,22 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
42051
42254
  this.wireCameraDeps(dev);
42052
42255
  await dev.materializeStreamSocket(camStreamId);
42053
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
+ }
42054
42268
  buildAdoptionProvider() {
42055
42269
  return buildWyzeAdoptionProvider({
42056
42270
  logger: this.ctx.logger,
42057
42271
  getCameraList: (integrationId, force) => this.requireClients().getCameraList(integrationId, force),
42272
+ getLastFetchError: (integrationId) => this.requireClients().getLastFetchError(integrationId),
42058
42273
  hasIntegration: (integrationId) => this.requireClients().has(integrationId),
42059
42274
  listIntegrations: () => this.requireClients().list(),
42060
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
@@ -19590,7 +19606,14 @@ targets: array(object({
19590
19606
  "sleeping",
19591
19607
  "unreachable",
19592
19608
  "waking"
19593
- ]).nullable()
19609
+ ]).nullable(),
19610
+ /** A battery camera (whatever its current state). An AWAKE battery
19611
+ * camera is deliberately NOT recaptured on the poll cadence — every
19612
+ * capture is a camera hit that would keep it out of sleep — so its
19613
+ * cached frame legitimately ages past the currency ceiling while
19614
+ * nothing is streaming. A surface must keep painting it (a fresh
19615
+ * frame is captured at each wake), not blank to "unavailable". */
19616
+ battery: boolean()
19594
19617
  })))
19595
19618
  },
19596
19619
  status: {
@@ -20287,8 +20310,28 @@ var ClipSchema = object({
20287
20310
  startMs: number(),
20288
20311
  endMs: number()
20289
20312
  }),
20290
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20291
- 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()
20292
20335
  });
20293
20336
  var ClipPlaybackSchema = object({
20294
20337
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -21863,10 +21906,18 @@ settings: record(string(), unknown()) });
21863
21906
  * descriptive — it never changes routing.
21864
21907
  */
21865
21908
  var ConnectionTestDescriptorSchema = object({ label: string() });
21866
- method(ConnectionTestInputSchema, ConnectionTestOutcomeSchema, {
21867
- kind: "mutation",
21868
- auth: "admin"
21869
- }), 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
+ };
21870
21921
  /**
21871
21922
  * Upstream-system connectivity sensor — distinct from `device-status`,
21872
21923
  * which is the kernel-managed online/offline flag for the device's
@@ -26574,7 +26625,9 @@ var ExportOptionsSchema = object({
26574
26625
  includeAudio: boolean(),
26575
26626
  maxLifeMs: number().int().positive(),
26576
26627
  deleteAfterDownload: boolean(),
26577
- 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()
26578
26631
  }).superRefine((v, ctx) => {
26579
26632
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26580
26633
  code: ZodIssueCode.custom,
@@ -26633,10 +26686,18 @@ var ExportBytesSchema = object({
26633
26686
  });
26634
26687
  method(object({
26635
26688
  deviceId: number(),
26636
- profile: string(),
26689
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
26690
+ profile: string().optional(),
26691
+ profiles: array(string()).min(1).optional(),
26637
26692
  fromMs: number(),
26638
26693
  toMs: number(),
26639
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
+ });
26640
26701
  }), ExportRecordSchema, {
26641
26702
  kind: "mutation",
26642
26703
  auth: "protected"
@@ -29578,15 +29639,6 @@ var BaseDeviceProvider = class extends BaseAddon {
29578
29639
  labels: ["probe not implemented"]
29579
29640
  };
29580
29641
  }
29581
- /**
29582
- * Top-level devices restored at once in {@link onRestoreDevices}.
29583
- *
29584
- * Four covers the fleets this ships to without turning a boot into a burst a
29585
- * camera NVR answers with a refusal. A provider whose upstream is a single
29586
- * session with a serial command channel (a Baichuan hub, an NVR that
29587
- * serialises ISAPI) should lower it; nothing needs to raise it.
29588
- */
29589
- restoreConcurrency = 4;
29590
29642
  async restoreDevices(savedDevices) {
29591
29643
  await this.onRestoreDevices(savedDevices);
29592
29644
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -29641,14 +29693,7 @@ var BaseDeviceProvider = class extends BaseAddon {
29641
29693
  });
29642
29694
  }
29643
29695
  };
29644
- let nextTopLevel = 0;
29645
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
29646
- for (;;) {
29647
- const saved = topLevel[nextTopLevel++];
29648
- if (saved === void 0) return;
29649
- await restoreOne(saved);
29650
- }
29651
- }));
29696
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
29652
29697
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
29653
29698
  for (const saved of childRows) {
29654
29699
  const Class = this.deviceClasses[saved.type];
@@ -40603,7 +40648,8 @@ var WyzeClientRegistry = class {
40603
40648
  credentials,
40604
40649
  cameraListCache: [],
40605
40650
  cameraListFetchedAt: 0,
40606
- cameraListInFlight: null
40651
+ cameraListInFlight: null,
40652
+ lastFetchError: null
40607
40653
  });
40608
40654
  }
40609
40655
  /** Drop the client for an integration that no longer exists / was disabled. */
@@ -40631,6 +40677,14 @@ var WyzeClientRegistry = class {
40631
40677
  this.#entries.clear();
40632
40678
  }
40633
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
+ /**
40634
40688
  * Cloud camera list for ONE integration, cached + debounced + single-flight
40635
40689
  * to avoid auth rate-limits. Returns the last cache (possibly empty) when the
40636
40690
  * integration is unknown or the fetch fails.
@@ -40644,9 +40698,11 @@ var WyzeClientRegistry = class {
40644
40698
  const run = entry.client.getCameraList().then((cams) => {
40645
40699
  entry.cameraListCache = cams;
40646
40700
  entry.cameraListFetchedAt = Date.now();
40701
+ entry.lastFetchError = null;
40647
40702
  return cams;
40648
40703
  }).catch((err) => {
40649
40704
  const classified = classifyCloudError(err);
40705
+ entry.lastFetchError = classified;
40650
40706
  this.#deps.logger.error("Wyze getCameraList failed", { meta: {
40651
40707
  integrationId,
40652
40708
  kind: classified.kind,
@@ -41142,11 +41198,16 @@ function buildWyzeAdoptionProvider(deps) {
41142
41198
  let candidateCount = 0;
41143
41199
  for (const integrationId of listIntegrations()) candidateCount += (await candidatesForIntegration(integrationId)).length;
41144
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
+ }
41145
41206
  return {
41146
41207
  lastDiscoveryAt: Date.now(),
41147
41208
  candidateCount,
41148
41209
  adoptedCount,
41149
- lastError: null
41210
+ lastError: fetchErrors.length > 0 ? fetchErrors.join("; ") : null
41150
41211
  };
41151
41212
  } catch (err) {
41152
41213
  logger.warn("wyze adoption: getStatus failed", { meta: { error: errMsg(err) } });
@@ -41161,11 +41222,12 @@ function buildWyzeAdoptionProvider(deps) {
41161
41222
  refresh: async ({ integrationId }) => {
41162
41223
  const candidateCount = (await candidatesForIntegration(integrationId, true)).length;
41163
41224
  const adoptedCount = adoptedMapForIntegration(integrationId, await listAdopted()).size;
41225
+ const fetchErr = deps.getLastFetchError?.(integrationId);
41164
41226
  return {
41165
41227
  lastDiscoveryAt: Date.now(),
41166
41228
  candidateCount,
41167
41229
  adoptedCount,
41168
- lastError: null
41230
+ lastError: fetchErr ? fetchErr.message : null
41169
41231
  };
41170
41232
  },
41171
41233
  adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
@@ -41897,6 +41959,139 @@ var WyzeCamera = class extends BaseDevice {
41897
41959
  static fromCloud = cameraConfigFromCloud;
41898
41960
  };
41899
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
41900
42095
  //#region src/addon.ts
41901
42096
  function getString(obj, key) {
41902
42097
  const v = obj[key];
@@ -41943,6 +42138,7 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41943
42138
  super({});
41944
42139
  }
41945
42140
  async onInitialize() {
42141
+ applyWyzeCloudTlsTrust();
41946
42142
  const regs = await super.onInitialize();
41947
42143
  this.clients = new WyzeClientRegistry({
41948
42144
  dataDir: this.ctx.dataDir,
@@ -41964,10 +42160,17 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
41964
42160
  });
41965
42161
  await this.reconcileIntegrations();
41966
42162
  this.subscribeIntegrationLifecycle();
41967
- return [...regs, {
41968
- capability: deviceAdoptionCapability,
41969
- provider: this.buildAdoptionProvider()
41970
- }];
42163
+ return [
42164
+ ...regs,
42165
+ {
42166
+ capability: deviceAdoptionCapability,
42167
+ provider: this.buildAdoptionProvider()
42168
+ },
42169
+ {
42170
+ capability: connectionTestCapability,
42171
+ provider: this.buildConnectionTestProvider()
42172
+ }
42173
+ ];
41971
42174
  }
41972
42175
  async onShutdown() {
41973
42176
  this.clients?.clear();
@@ -42030,10 +42233,22 @@ var WyzeProviderAddon = class extends BaseDeviceProvider {
42030
42233
  this.wireCameraDeps(dev);
42031
42234
  await dev.materializeStreamSocket(camStreamId);
42032
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
+ }
42033
42247
  buildAdoptionProvider() {
42034
42248
  return buildWyzeAdoptionProvider({
42035
42249
  logger: this.ctx.logger,
42036
42250
  getCameraList: (integrationId, force) => this.requireClients().getCameraList(integrationId, force),
42251
+ getLastFetchError: (integrationId) => this.requireClients().getLastFetchError(integrationId),
42037
42252
  hasIntegration: (integrationId) => this.requireClients().has(integrationId),
42038
42253
  listIntegrations: () => this.requireClients().list(),
42039
42254
  listAdopted: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-wyze",
3
- "version": "0.2.18",
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
  }