@camstack/addon-provider-dreo 0.2.20 → 0.2.22

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 +187 -44
  2. package/dist/addon.mjs +187 -44
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -35,7 +35,7 @@ var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["modu
35
35
  //#endregion
36
36
  let crypto$1 = require("crypto");
37
37
  let events = require("events");
38
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
38
+ //#region ../types/dist/event-category-XfKNtfCc.mjs
39
39
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
40
40
  EventCategory["SystemBoot"] = "system.boot";
41
41
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -52,9 +52,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
52
52
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
53
53
  /**
54
54
  * A newer addon or server-root package version was found by the
55
- * authoritative registry check. Emitted once per
56
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
57
- * polling of the same result is deduplicated by the checker.
55
+ * authoritative registry check. Emitted once when any observed
56
+ * `latestVersion` changes (or a package/node first appears behind);
57
+ * the payload carries the full currently-available list. Repeated
58
+ * polling of the same latests is silent.
58
59
  */
59
60
  EventCategory["UpdateAvailable"] = "update.available";
60
61
  /**
@@ -73,6 +74,22 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
73
74
  EventCategory["AddonInstalled"] = "addon.installed";
74
75
  EventCategory["AddonUninstalled"] = "addon.uninstalled";
75
76
  EventCategory["AddonCrashed"] = "addon.crashed";
77
+ /**
78
+ * A RUNNER the D6 crash circuit-breaker gave up on — terminal.
79
+ *
80
+ * `AddonCrashed` is the routine, self-healing fact ("it crashed; it is being
81
+ * respawned"); this is the one that never resolves itself. Emitted exactly
82
+ * once per trip, by `process-service.ts`, carrying the node, the runner, the
83
+ * addons it hosted and the crash count that tripped the breaker.
84
+ *
85
+ * It exists because a runner marked terminally `failed` used to be SILENT:
86
+ * on 2026-08-18 the `recorder` runner died of three unhandled ffmpeg spawn
87
+ * errors, the breaker stopped respawning it (correctly), `/health` kept
88
+ * returning 200, and fleet-wide recording was gone for 3h15 before the
89
+ * operator's phone told him. The Notification Center's system-event intake
90
+ * consumes this category and maps it to the `addon-crash-loop` kind.
91
+ */
92
+ EventCategory["AddonRunnerFailed"] = "addon.runner-failed";
76
93
  EventCategory["AddonError"] = "addon.error";
77
94
  EventCategory["AddonPageReady"] = "addon.page-ready";
78
95
  EventCategory["AddonWidgetReady"] = "addon.widget-ready";
@@ -7584,6 +7601,10 @@ var RecordingBandSchema = object({
7584
7601
  preBufferSec: number().min(0).optional(),
7585
7602
  postBufferSec: number().min(0).optional()
7586
7603
  });
7604
+ ({
7605
+ preBufferSec: 10,
7606
+ postBufferSec: 30
7607
+ }).postBufferSec * 1e3;
7587
7608
  /**
7588
7609
  * Per-device retention overrides. Every field is optional; an unset or `0`
7589
7610
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7631,6 +7652,12 @@ var RecordingConfigSchema = object({
7631
7652
  /** DERIVED summary of `bands`, stamped by the recorder on every save.
7632
7653
  * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7633
7654
  mode: RecordingStorageModeSchema.optional(),
7655
+ /**
7656
+ * Which assigned broker slots to record. Absent / empty = {@link
7657
+ * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
7658
+ * camera's currently assigned slots — never `mid` unless the operator
7659
+ * picks it, and never a slot the broker has not assigned.
7660
+ */
7634
7661
  profiles: array(CamProfileSchema).optional(),
7635
7662
  segmentSeconds: number().int().positive().optional(),
7636
7663
  /**
@@ -7711,7 +7738,11 @@ var RelocateFootageInputSchema = object({
7711
7738
  profiles: array(string()).optional(),
7712
7739
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7713
7740
  * never allowed to starve live writers. */
7714
- throttleMbps: number().min(1).max(1e3).optional()
7741
+ throttleMbps: number().min(1).max(1e3).optional(),
7742
+ /** Move only segments whose startMs is >= this. Absent = the whole source
7743
+ * pile. Used when a full drain is too expensive and the operator only
7744
+ * wants the recent window on the new disk. */
7745
+ sinceMs: number().int().optional()
7715
7746
  });
7716
7747
  /** Internal, lease-scoped participant operation. It is intentionally separate
7717
7748
  * from persistent recording settings: a migration never changes
@@ -14593,6 +14624,7 @@ var NcSystemEventKindSchema = _enum([
14593
14624
  "node-offline",
14594
14625
  "node-inference-unavailable",
14595
14626
  "detection-blind",
14627
+ "addon-crash-loop",
14596
14628
  "addon-update-available",
14597
14629
  "server-update-available",
14598
14630
  "alarm-triggered",
@@ -14600,6 +14632,9 @@ var NcSystemEventKindSchema = _enum([
14600
14632
  "alarm-disarmed",
14601
14633
  "alarm-arming",
14602
14634
  "alarm-arm-refused",
14635
+ "addon-updated",
14636
+ "server-updated",
14637
+ "export-completed",
14603
14638
  "camera-online",
14604
14639
  "camera-offline",
14605
14640
  "camera-disabled",
@@ -16493,13 +16528,19 @@ var RetrainStatusSchema = _enum([
16493
16528
  * "never marked" from "already trained" must read `retrainStatus`.
16494
16529
  *
16495
16530
  * `debug` does NOT pin; it is attention, not durability.
16531
+ *
16532
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16533
+ * A favourited track is skipped by retention the same way `staging` is, but
16534
+ * it does not enter `none|staging|trained` and has no staging budget.
16496
16535
  */
16497
16536
  var TrackFlagFields = {
16498
16537
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16499
16538
  * `'staging'`. */
16500
16539
  markForTrain: boolean().optional(),
16501
16540
  /** Operator marked this track for diagnostic attention. */
16502
- debug: boolean().optional()
16541
+ debug: boolean().optional(),
16542
+ /** Operator favourited this track. Pins it against pruning. */
16543
+ favourited: boolean().optional()
16503
16544
  };
16504
16545
  /**
16505
16546
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16524,6 +16565,7 @@ var TrackFlagsSchema = object({
16524
16565
  trackId: string(),
16525
16566
  markForTrain: boolean(),
16526
16567
  debug: boolean(),
16568
+ favourited: boolean(),
16527
16569
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16528
16570
  * a track row) because this shape is only ever produced by the write body,
16529
16571
  * which always knows it — and a surface that has just written needs to render
@@ -17156,6 +17198,12 @@ var TrackCascadeCountsSchema = object({
17156
17198
  /** Per-track CLIP search vectors removed (best-effort). */
17157
17199
  embeddings: number().int()
17158
17200
  });
17201
+ /** Disk-wins reconcile: media rows whose blobs are gone, then empty tracks. */
17202
+ var DiskReconcileCountsSchema = object({
17203
+ mediaDropped: number().int(),
17204
+ tracks: number().int(),
17205
+ events: number().int()
17206
+ });
17159
17207
  /** Event-store footprint for one camera. */
17160
17208
  var EventStoreDeviceFootprintSchema = object({
17161
17209
  deviceId: number(),
@@ -17332,6 +17380,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17332
17380
  }), method(object({ deviceId: number() }), TrackCascadeCountsSchema, {
17333
17381
  kind: "mutation",
17334
17382
  auth: "admin"
17383
+ }), method(object({ deviceId: number() }), DiskReconcileCountsSchema, {
17384
+ kind: "mutation",
17385
+ auth: "admin"
17335
17386
  }), method(object({
17336
17387
  deviceId: number(),
17337
17388
  trackIds: array(string()).min(1)
@@ -18882,6 +18933,24 @@ var CameraStatusDegradationSchema = object({
18882
18933
  *
18883
18934
  * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
18884
18935
  */
18936
+ var DiskReconcileJobSchema = object({
18937
+ state: _enum([
18938
+ "idle",
18939
+ "running",
18940
+ "done",
18941
+ "error"
18942
+ ]),
18943
+ total: number().int().nonnegative(),
18944
+ completed: number().int().nonnegative(),
18945
+ currentDeviceId: number().int().nullable(),
18946
+ failed: array(number().int()).readonly(),
18947
+ mediaDropped: number().int().nonnegative(),
18948
+ tracks: number().int().nonnegative(),
18949
+ events: number().int().nonnegative(),
18950
+ startedAtMs: number().int().nullable(),
18951
+ finishedAtMs: number().int().nullable(),
18952
+ error: string().nullable()
18953
+ });
18885
18954
  var CameraStatusSchema = object({
18886
18955
  deviceId: number(),
18887
18956
  assignment: CameraAssignmentStatusSchema,
@@ -19113,7 +19182,10 @@ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
19113
19182
  }), CameraSwitchGroupSchema, {
19114
19183
  kind: "mutation",
19115
19184
  auth: "admin"
19116
- }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19185
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), DiskReconcileJobSchema, {
19186
+ kind: "mutation",
19187
+ auth: "admin"
19188
+ }), method(_void(), DiskReconcileJobSchema, { auth: "admin" }), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19117
19189
  name: string(),
19118
19190
  description: string().optional(),
19119
19191
  config: CameraPipelineConfigSchema
@@ -20186,20 +20258,6 @@ var VectorStatsResultSchema = object({
20186
20258
  exact: boolean()
20187
20259
  });
20188
20260
  method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
20189
- /**
20190
- * `videoclips` — the unified, navigable-clip surface for a camera.
20191
- *
20192
- * A device-scoped WRAPPER cap (like `pipeline-analytics`): exactly one active
20193
- * provider per device, substitutable. The DEFAULT provider (registered by
20194
- * `addon-post-analysis`, `defaultActive: true`) composes the analytics event
20195
- * log (markers + thumbnails) with the recorder's `getPlaybackManifest` — a clip
20196
- * is a time-WINDOW over existing footage, never a separate file. A camera that
20197
- * exposes NATIVE onboard clips (Reolink/Hikvision NVR) can later substitute the
20198
- * wrapper provider for its device and serve its own clip catalog + URLs.
20199
- *
20200
- * A `Clip` is purely time-based (subtree-blind): playback resolves segments by
20201
- * temporal overlap, so the API never decides `continuous` vs `events`.
20202
- */
20203
20261
  var ClipSchema = object({
20204
20262
  /** Opaque, provider-namespaced id. The default provider encodes the time
20205
20263
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -20216,8 +20274,65 @@ var ClipSchema = object({
20216
20274
  startMs: number(),
20217
20275
  endMs: number()
20218
20276
  }),
20219
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20220
- thumbnail: string().optional()
20277
+ /**
20278
+ * Distinct object classes attached to this visit (`person`, `car`, …),
20279
+ * dominant first, capped at {@link MAX_CLIP_LABELS}. The ribbon renders these
20280
+ * instead of the bare kind — "Object" tells the operator nothing a colour bar
20281
+ * did not. Absent (never empty) when the visit attached no classified object
20282
+ * event, so a motion/audio-only visit keeps its kind label.
20283
+ */
20284
+ labels: array(string()).max(3).optional(),
20285
+ /**
20286
+ * Lazy thumbnail URL, never inlined.
20287
+ *
20288
+ * Recording-derived clips (events-mode keep-window, and the prepared
20289
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20290
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20291
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20292
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20293
+ * mint their own stills.
20294
+ *
20295
+ * **VOUCHED, never fabricated.** A provider emits this only for an event it
20296
+ * has CONFIRMED owns at least one media row (its own, or its owning track's).
20297
+ * Absent is meaningful — "this visit has no event still" — never "we did not
20298
+ * look". Stamping it from a URL template made 35% of one camera's clips point
20299
+ * at a 404 (device 3836, 2026-08-17: 64 of 179 clips dead, 63 of them motion).
20300
+ * A read that FAILS drops the claim; it never invents it.
20301
+ */
20302
+ thumbnail: string().optional(),
20303
+ /**
20304
+ * An instant INSIDE this visit's footage, hole-safe, where a recorded still
20305
+ * can be decoded. Present whenever the visit came from recorded availability;
20306
+ * absent on a per-event padded window (there is no footage to promise).
20307
+ *
20308
+ * This is not a thumbnail and not a second byte path: it is the argument to
20309
+ * the recorder's existing still route. The surface — never the provider —
20310
+ * decides whether to use it. It is the midpoint of the visit's LONGEST
20311
+ * contiguous range, not of the visit: a visit spans its holes by
20312
+ * construction, so a naive midpoint lands in dead air.
20313
+ */
20314
+ stillAtMs: number().optional(),
20315
+ /**
20316
+ * Analytics event ids that overlap this visit — a BOUNDED sample, newest
20317
+ * first within kind (object → motion → audio), capped at
20318
+ * {@link MAX_CLIP_EVENT_IDS}. Empty on footage-only clips.
20319
+ *
20320
+ * Bounded because it is not a payload the surface pages through: one visit on
20321
+ * device 615 carried 2 345 ids, and `eventIds` was 99% of a 153 KB
20322
+ * camera-day. Read {@link eventCount} for the true total.
20323
+ */
20324
+ eventIds: array(string()).optional(),
20325
+ /** How many analytics events actually overlap this visit. Differs from
20326
+ * `eventIds.length` exactly when the sample was capped — so a truncated
20327
+ * list is never mistaken for a quiet visit. */
20328
+ eventCount: number().int().nonnegative().optional(),
20329
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20330
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20331
+ * bar keeps showing them via `recording.getAvailability`. */
20332
+ holes: array(object({
20333
+ startMs: number(),
20334
+ endMs: number()
20335
+ })).optional()
20221
20336
  });
20222
20337
  var ClipPlaybackSchema = object({
20223
20338
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -20681,7 +20796,14 @@ var SearchResultSchema = object({
20681
20796
  });
20682
20797
  var AutoUpdateSettingsSchema = object({
20683
20798
  channel: ChannelSchema,
20684
- intervalSeconds: number()
20799
+ intervalSeconds: number(),
20800
+ /**
20801
+ * Cadence of the "an update exists" POLLER, in seconds. Independent of
20802
+ * `channel`: the poller runs while auto-apply is `off`, because being told
20803
+ * about a publish and installing it are different decisions. Clamped
20804
+ * server-side to 900 s … 604800 s; defaults to 21600 s (6 h).
20805
+ */
20806
+ updateCheckIntervalSeconds: number()
20685
20807
  });
20686
20808
  var AddonAutoUpdateSchema = ChannelWithInheritSchema;
20687
20809
  var RestartAddonResultSchema = unknown();
@@ -20822,7 +20944,9 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
20822
20944
  auth: "admin"
20823
20945
  }), method(_void(), AutoUpdateSettingsSchema, { auth: "admin" }), method(object({
20824
20946
  channel: ChannelSchema,
20825
- intervalSeconds: number().min(300).max(86400).optional()
20947
+ intervalSeconds: number().min(300).max(86400).optional(),
20948
+ /** Availability-poll cadence; see AutoUpdateSettingsSchema. */
20949
+ updateCheckIntervalSeconds: number().min(900).max(604800).optional()
20826
20950
  }), unknown(), {
20827
20951
  kind: "mutation",
20828
20952
  auth: "admin"
@@ -26511,7 +26635,9 @@ var ExportOptionsSchema = object({
26511
26635
  includeAudio: boolean(),
26512
26636
  maxLifeMs: number().int().positive(),
26513
26637
  deleteAfterDownload: boolean(),
26514
- title: string().max(200).optional()
26638
+ title: string().max(200).optional(),
26639
+ /** Notification-output target ids to ping when this export becomes ready. */
26640
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26515
26641
  }).superRefine((v, ctx) => {
26516
26642
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26517
26643
  code: ZodIssueCode.custom,
@@ -26570,10 +26696,18 @@ var ExportBytesSchema = object({
26570
26696
  });
26571
26697
  method(object({
26572
26698
  deviceId: number(),
26573
- profile: string(),
26699
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
26700
+ profile: string().optional(),
26701
+ profiles: array(string()).min(1).optional(),
26574
26702
  fromMs: number(),
26575
26703
  toMs: number(),
26576
26704
  options: ExportOptionsSchema
26705
+ }).superRefine((v, ctx) => {
26706
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
26707
+ code: ZodIssueCode.custom,
26708
+ message: "pass profiles[] (min 1) or legacy profile",
26709
+ path: ["profiles"]
26710
+ });
26577
26711
  }), ExportRecordSchema, {
26578
26712
  kind: "mutation",
26579
26713
  auth: "protected"
@@ -29515,15 +29649,6 @@ var BaseDeviceProvider = class extends BaseAddon {
29515
29649
  labels: ["probe not implemented"]
29516
29650
  };
29517
29651
  }
29518
- /**
29519
- * Top-level devices restored at once in {@link onRestoreDevices}.
29520
- *
29521
- * Four covers the fleets this ships to without turning a boot into a burst a
29522
- * camera NVR answers with a refusal. A provider whose upstream is a single
29523
- * session with a serial command channel (a Baichuan hub, an NVR that
29524
- * serialises ISAPI) should lower it; nothing needs to raise it.
29525
- */
29526
- restoreConcurrency = 4;
29527
29652
  async restoreDevices(savedDevices) {
29528
29653
  await this.onRestoreDevices(savedDevices);
29529
29654
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -29578,14 +29703,7 @@ var BaseDeviceProvider = class extends BaseAddon {
29578
29703
  });
29579
29704
  }
29580
29705
  };
29581
- let nextTopLevel = 0;
29582
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
29583
- for (;;) {
29584
- const saved = topLevel[nextTopLevel++];
29585
- if (saved === void 0) return;
29586
- await restoreOne(saved);
29587
- }
29588
- }));
29706
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
29589
29707
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
29590
29708
  for (const saved of childRows) {
29591
29709
  const Class = this.deviceClasses[saved.type];
@@ -32858,6 +32976,12 @@ Object.freeze({
32858
32976
  addonId: null,
32859
32977
  access: "create"
32860
32978
  },
32979
+ "pipelineAnalytics.reconcileFromDisk": {
32980
+ capName: "pipeline-analytics",
32981
+ capScope: "device",
32982
+ addonId: null,
32983
+ access: "create"
32984
+ },
32861
32985
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
32862
32986
  capName: "pipeline-analytics",
32863
32987
  capScope: "device",
@@ -33260,6 +33384,12 @@ Object.freeze({
33260
33384
  addonId: null,
33261
33385
  access: "view"
33262
33386
  },
33387
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
33388
+ capName: "pipeline-orchestrator",
33389
+ capScope: "system",
33390
+ addonId: null,
33391
+ access: "view"
33392
+ },
33263
33393
  "pipelineOrchestrator.listAgentSettings": {
33264
33394
  capName: "pipeline-orchestrator",
33265
33395
  capScope: "system",
@@ -33284,6 +33414,12 @@ Object.freeze({
33284
33414
  addonId: null,
33285
33415
  access: "create"
33286
33416
  },
33417
+ "pipelineOrchestrator.reconcileFromDisk": {
33418
+ capName: "pipeline-orchestrator",
33419
+ capScope: "system",
33420
+ addonId: null,
33421
+ access: "create"
33422
+ },
33287
33423
  "pipelineOrchestrator.removeAgentSettings": {
33288
33424
  capName: "pipeline-orchestrator",
33289
33425
  capScope: "system",
@@ -36275,6 +36411,11 @@ Object.freeze({
36275
36411
  form: "single",
36276
36412
  optional: true
36277
36413
  }],
36414
+ "pipelineAnalytics.reconcileFromDisk": [{
36415
+ name: "deviceId",
36416
+ form: "single",
36417
+ optional: false
36418
+ }],
36278
36419
  "pipelineAnalytics.restageRetrainTrack": [{
36279
36420
  name: "deviceId",
36280
36421
  form: "single",
@@ -37340,6 +37481,8 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
37340
37481
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
37341
37482
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
37342
37483
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
37484
+ var MB = 1024 * 1024;
37485
+ 1024 * MB, 3072 * MB;
37343
37486
  //#endregion
37344
37487
  //#region src/config.ts
37345
37488
  /**
package/dist/addon.mjs CHANGED
@@ -36,7 +36,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
37
37
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
38
38
  //#endregion
39
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
39
+ //#region ../types/dist/event-category-XfKNtfCc.mjs
40
40
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
41
41
  EventCategory["SystemBoot"] = "system.boot";
42
42
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -53,9 +53,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
53
53
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
54
54
  /**
55
55
  * A newer addon or server-root package version was found by the
56
- * authoritative registry check. Emitted once per
57
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
58
- * polling of the same result is deduplicated by the checker.
56
+ * authoritative registry check. Emitted once when any observed
57
+ * `latestVersion` changes (or a package/node first appears behind);
58
+ * the payload carries the full currently-available list. Repeated
59
+ * polling of the same latests is silent.
59
60
  */
60
61
  EventCategory["UpdateAvailable"] = "update.available";
61
62
  /**
@@ -74,6 +75,22 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
74
75
  EventCategory["AddonInstalled"] = "addon.installed";
75
76
  EventCategory["AddonUninstalled"] = "addon.uninstalled";
76
77
  EventCategory["AddonCrashed"] = "addon.crashed";
78
+ /**
79
+ * A RUNNER the D6 crash circuit-breaker gave up on — terminal.
80
+ *
81
+ * `AddonCrashed` is the routine, self-healing fact ("it crashed; it is being
82
+ * respawned"); this is the one that never resolves itself. Emitted exactly
83
+ * once per trip, by `process-service.ts`, carrying the node, the runner, the
84
+ * addons it hosted and the crash count that tripped the breaker.
85
+ *
86
+ * It exists because a runner marked terminally `failed` used to be SILENT:
87
+ * on 2026-08-18 the `recorder` runner died of three unhandled ffmpeg spawn
88
+ * errors, the breaker stopped respawning it (correctly), `/health` kept
89
+ * returning 200, and fleet-wide recording was gone for 3h15 before the
90
+ * operator's phone told him. The Notification Center's system-event intake
91
+ * consumes this category and maps it to the `addon-crash-loop` kind.
92
+ */
93
+ EventCategory["AddonRunnerFailed"] = "addon.runner-failed";
77
94
  EventCategory["AddonError"] = "addon.error";
78
95
  EventCategory["AddonPageReady"] = "addon.page-ready";
79
96
  EventCategory["AddonWidgetReady"] = "addon.widget-ready";
@@ -7585,6 +7602,10 @@ var RecordingBandSchema = object({
7585
7602
  preBufferSec: number().min(0).optional(),
7586
7603
  postBufferSec: number().min(0).optional()
7587
7604
  });
7605
+ ({
7606
+ preBufferSec: 10,
7607
+ postBufferSec: 30
7608
+ }).postBufferSec * 1e3;
7588
7609
  /**
7589
7610
  * Per-device retention overrides. Every field is optional; an unset or `0`
7590
7611
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7632,6 +7653,12 @@ var RecordingConfigSchema = object({
7632
7653
  /** DERIVED summary of `bands`, stamped by the recorder on every save.
7633
7654
  * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7634
7655
  mode: RecordingStorageModeSchema.optional(),
7656
+ /**
7657
+ * Which assigned broker slots to record. Absent / empty = {@link
7658
+ * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
7659
+ * camera's currently assigned slots — never `mid` unless the operator
7660
+ * picks it, and never a slot the broker has not assigned.
7661
+ */
7635
7662
  profiles: array(CamProfileSchema).optional(),
7636
7663
  segmentSeconds: number().int().positive().optional(),
7637
7664
  /**
@@ -7712,7 +7739,11 @@ var RelocateFootageInputSchema = object({
7712
7739
  profiles: array(string()).optional(),
7713
7740
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7714
7741
  * never allowed to starve live writers. */
7715
- throttleMbps: number().min(1).max(1e3).optional()
7742
+ throttleMbps: number().min(1).max(1e3).optional(),
7743
+ /** Move only segments whose startMs is >= this. Absent = the whole source
7744
+ * pile. Used when a full drain is too expensive and the operator only
7745
+ * wants the recent window on the new disk. */
7746
+ sinceMs: number().int().optional()
7716
7747
  });
7717
7748
  /** Internal, lease-scoped participant operation. It is intentionally separate
7718
7749
  * from persistent recording settings: a migration never changes
@@ -14594,6 +14625,7 @@ var NcSystemEventKindSchema = _enum([
14594
14625
  "node-offline",
14595
14626
  "node-inference-unavailable",
14596
14627
  "detection-blind",
14628
+ "addon-crash-loop",
14597
14629
  "addon-update-available",
14598
14630
  "server-update-available",
14599
14631
  "alarm-triggered",
@@ -14601,6 +14633,9 @@ var NcSystemEventKindSchema = _enum([
14601
14633
  "alarm-disarmed",
14602
14634
  "alarm-arming",
14603
14635
  "alarm-arm-refused",
14636
+ "addon-updated",
14637
+ "server-updated",
14638
+ "export-completed",
14604
14639
  "camera-online",
14605
14640
  "camera-offline",
14606
14641
  "camera-disabled",
@@ -16494,13 +16529,19 @@ var RetrainStatusSchema = _enum([
16494
16529
  * "never marked" from "already trained" must read `retrainStatus`.
16495
16530
  *
16496
16531
  * `debug` does NOT pin; it is attention, not durability.
16532
+ *
16533
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16534
+ * A favourited track is skipped by retention the same way `staging` is, but
16535
+ * it does not enter `none|staging|trained` and has no staging budget.
16497
16536
  */
16498
16537
  var TrackFlagFields = {
16499
16538
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16500
16539
  * `'staging'`. */
16501
16540
  markForTrain: boolean().optional(),
16502
16541
  /** Operator marked this track for diagnostic attention. */
16503
- debug: boolean().optional()
16542
+ debug: boolean().optional(),
16543
+ /** Operator favourited this track. Pins it against pruning. */
16544
+ favourited: boolean().optional()
16504
16545
  };
16505
16546
  /**
16506
16547
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16525,6 +16566,7 @@ var TrackFlagsSchema = object({
16525
16566
  trackId: string(),
16526
16567
  markForTrain: boolean(),
16527
16568
  debug: boolean(),
16569
+ favourited: boolean(),
16528
16570
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16529
16571
  * a track row) because this shape is only ever produced by the write body,
16530
16572
  * which always knows it — and a surface that has just written needs to render
@@ -17157,6 +17199,12 @@ var TrackCascadeCountsSchema = object({
17157
17199
  /** Per-track CLIP search vectors removed (best-effort). */
17158
17200
  embeddings: number().int()
17159
17201
  });
17202
+ /** Disk-wins reconcile: media rows whose blobs are gone, then empty tracks. */
17203
+ var DiskReconcileCountsSchema = object({
17204
+ mediaDropped: number().int(),
17205
+ tracks: number().int(),
17206
+ events: number().int()
17207
+ });
17160
17208
  /** Event-store footprint for one camera. */
17161
17209
  var EventStoreDeviceFootprintSchema = object({
17162
17210
  deviceId: number(),
@@ -17333,6 +17381,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17333
17381
  }), method(object({ deviceId: number() }), TrackCascadeCountsSchema, {
17334
17382
  kind: "mutation",
17335
17383
  auth: "admin"
17384
+ }), method(object({ deviceId: number() }), DiskReconcileCountsSchema, {
17385
+ kind: "mutation",
17386
+ auth: "admin"
17336
17387
  }), method(object({
17337
17388
  deviceId: number(),
17338
17389
  trackIds: array(string()).min(1)
@@ -18883,6 +18934,24 @@ var CameraStatusDegradationSchema = object({
18883
18934
  *
18884
18935
  * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
18885
18936
  */
18937
+ var DiskReconcileJobSchema = object({
18938
+ state: _enum([
18939
+ "idle",
18940
+ "running",
18941
+ "done",
18942
+ "error"
18943
+ ]),
18944
+ total: number().int().nonnegative(),
18945
+ completed: number().int().nonnegative(),
18946
+ currentDeviceId: number().int().nullable(),
18947
+ failed: array(number().int()).readonly(),
18948
+ mediaDropped: number().int().nonnegative(),
18949
+ tracks: number().int().nonnegative(),
18950
+ events: number().int().nonnegative(),
18951
+ startedAtMs: number().int().nullable(),
18952
+ finishedAtMs: number().int().nullable(),
18953
+ error: string().nullable()
18954
+ });
18886
18955
  var CameraStatusSchema = object({
18887
18956
  deviceId: number(),
18888
18957
  assignment: CameraAssignmentStatusSchema,
@@ -19114,7 +19183,10 @@ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
19114
19183
  }), CameraSwitchGroupSchema, {
19115
19184
  kind: "mutation",
19116
19185
  auth: "admin"
19117
- }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19186
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), DiskReconcileJobSchema, {
19187
+ kind: "mutation",
19188
+ auth: "admin"
19189
+ }), method(_void(), DiskReconcileJobSchema, { auth: "admin" }), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19118
19190
  name: string(),
19119
19191
  description: string().optional(),
19120
19192
  config: CameraPipelineConfigSchema
@@ -20187,20 +20259,6 @@ var VectorStatsResultSchema = object({
20187
20259
  exact: boolean()
20188
20260
  });
20189
20261
  method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
20190
- /**
20191
- * `videoclips` — the unified, navigable-clip surface for a camera.
20192
- *
20193
- * A device-scoped WRAPPER cap (like `pipeline-analytics`): exactly one active
20194
- * provider per device, substitutable. The DEFAULT provider (registered by
20195
- * `addon-post-analysis`, `defaultActive: true`) composes the analytics event
20196
- * log (markers + thumbnails) with the recorder's `getPlaybackManifest` — a clip
20197
- * is a time-WINDOW over existing footage, never a separate file. A camera that
20198
- * exposes NATIVE onboard clips (Reolink/Hikvision NVR) can later substitute the
20199
- * wrapper provider for its device and serve its own clip catalog + URLs.
20200
- *
20201
- * A `Clip` is purely time-based (subtree-blind): playback resolves segments by
20202
- * temporal overlap, so the API never decides `continuous` vs `events`.
20203
- */
20204
20262
  var ClipSchema = object({
20205
20263
  /** Opaque, provider-namespaced id. The default provider encodes the time
20206
20264
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -20217,8 +20275,65 @@ var ClipSchema = object({
20217
20275
  startMs: number(),
20218
20276
  endMs: number()
20219
20277
  }),
20220
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20221
- thumbnail: string().optional()
20278
+ /**
20279
+ * Distinct object classes attached to this visit (`person`, `car`, …),
20280
+ * dominant first, capped at {@link MAX_CLIP_LABELS}. The ribbon renders these
20281
+ * instead of the bare kind — "Object" tells the operator nothing a colour bar
20282
+ * did not. Absent (never empty) when the visit attached no classified object
20283
+ * event, so a motion/audio-only visit keeps its kind label.
20284
+ */
20285
+ labels: array(string()).max(3).optional(),
20286
+ /**
20287
+ * Lazy thumbnail URL, never inlined.
20288
+ *
20289
+ * Recording-derived clips (events-mode keep-window, and the prepared
20290
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20291
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20292
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20293
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20294
+ * mint their own stills.
20295
+ *
20296
+ * **VOUCHED, never fabricated.** A provider emits this only for an event it
20297
+ * has CONFIRMED owns at least one media row (its own, or its owning track's).
20298
+ * Absent is meaningful — "this visit has no event still" — never "we did not
20299
+ * look". Stamping it from a URL template made 35% of one camera's clips point
20300
+ * at a 404 (device 3836, 2026-08-17: 64 of 179 clips dead, 63 of them motion).
20301
+ * A read that FAILS drops the claim; it never invents it.
20302
+ */
20303
+ thumbnail: string().optional(),
20304
+ /**
20305
+ * An instant INSIDE this visit's footage, hole-safe, where a recorded still
20306
+ * can be decoded. Present whenever the visit came from recorded availability;
20307
+ * absent on a per-event padded window (there is no footage to promise).
20308
+ *
20309
+ * This is not a thumbnail and not a second byte path: it is the argument to
20310
+ * the recorder's existing still route. The surface — never the provider —
20311
+ * decides whether to use it. It is the midpoint of the visit's LONGEST
20312
+ * contiguous range, not of the visit: a visit spans its holes by
20313
+ * construction, so a naive midpoint lands in dead air.
20314
+ */
20315
+ stillAtMs: number().optional(),
20316
+ /**
20317
+ * Analytics event ids that overlap this visit — a BOUNDED sample, newest
20318
+ * first within kind (object → motion → audio), capped at
20319
+ * {@link MAX_CLIP_EVENT_IDS}. Empty on footage-only clips.
20320
+ *
20321
+ * Bounded because it is not a payload the surface pages through: one visit on
20322
+ * device 615 carried 2 345 ids, and `eventIds` was 99% of a 153 KB
20323
+ * camera-day. Read {@link eventCount} for the true total.
20324
+ */
20325
+ eventIds: array(string()).optional(),
20326
+ /** How many analytics events actually overlap this visit. Differs from
20327
+ * `eventIds.length` exactly when the sample was capped — so a truncated
20328
+ * list is never mistaken for a quiet visit. */
20329
+ eventCount: number().int().nonnegative().optional(),
20330
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20331
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20332
+ * bar keeps showing them via `recording.getAvailability`. */
20333
+ holes: array(object({
20334
+ startMs: number(),
20335
+ endMs: number()
20336
+ })).optional()
20222
20337
  });
20223
20338
  var ClipPlaybackSchema = object({
20224
20339
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -20682,7 +20797,14 @@ var SearchResultSchema = object({
20682
20797
  });
20683
20798
  var AutoUpdateSettingsSchema = object({
20684
20799
  channel: ChannelSchema,
20685
- intervalSeconds: number()
20800
+ intervalSeconds: number(),
20801
+ /**
20802
+ * Cadence of the "an update exists" POLLER, in seconds. Independent of
20803
+ * `channel`: the poller runs while auto-apply is `off`, because being told
20804
+ * about a publish and installing it are different decisions. Clamped
20805
+ * server-side to 900 s … 604800 s; defaults to 21600 s (6 h).
20806
+ */
20807
+ updateCheckIntervalSeconds: number()
20686
20808
  });
20687
20809
  var AddonAutoUpdateSchema = ChannelWithInheritSchema;
20688
20810
  var RestartAddonResultSchema = unknown();
@@ -20823,7 +20945,9 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
20823
20945
  auth: "admin"
20824
20946
  }), method(_void(), AutoUpdateSettingsSchema, { auth: "admin" }), method(object({
20825
20947
  channel: ChannelSchema,
20826
- intervalSeconds: number().min(300).max(86400).optional()
20948
+ intervalSeconds: number().min(300).max(86400).optional(),
20949
+ /** Availability-poll cadence; see AutoUpdateSettingsSchema. */
20950
+ updateCheckIntervalSeconds: number().min(900).max(604800).optional()
20827
20951
  }), unknown(), {
20828
20952
  kind: "mutation",
20829
20953
  auth: "admin"
@@ -26512,7 +26636,9 @@ var ExportOptionsSchema = object({
26512
26636
  includeAudio: boolean(),
26513
26637
  maxLifeMs: number().int().positive(),
26514
26638
  deleteAfterDownload: boolean(),
26515
- title: string().max(200).optional()
26639
+ title: string().max(200).optional(),
26640
+ /** Notification-output target ids to ping when this export becomes ready. */
26641
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26516
26642
  }).superRefine((v, ctx) => {
26517
26643
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26518
26644
  code: ZodIssueCode.custom,
@@ -26571,10 +26697,18 @@ var ExportBytesSchema = object({
26571
26697
  });
26572
26698
  method(object({
26573
26699
  deviceId: number(),
26574
- profile: string(),
26700
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
26701
+ profile: string().optional(),
26702
+ profiles: array(string()).min(1).optional(),
26575
26703
  fromMs: number(),
26576
26704
  toMs: number(),
26577
26705
  options: ExportOptionsSchema
26706
+ }).superRefine((v, ctx) => {
26707
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
26708
+ code: ZodIssueCode.custom,
26709
+ message: "pass profiles[] (min 1) or legacy profile",
26710
+ path: ["profiles"]
26711
+ });
26578
26712
  }), ExportRecordSchema, {
26579
26713
  kind: "mutation",
26580
26714
  auth: "protected"
@@ -29516,15 +29650,6 @@ var BaseDeviceProvider = class extends BaseAddon {
29516
29650
  labels: ["probe not implemented"]
29517
29651
  };
29518
29652
  }
29519
- /**
29520
- * Top-level devices restored at once in {@link onRestoreDevices}.
29521
- *
29522
- * Four covers the fleets this ships to without turning a boot into a burst a
29523
- * camera NVR answers with a refusal. A provider whose upstream is a single
29524
- * session with a serial command channel (a Baichuan hub, an NVR that
29525
- * serialises ISAPI) should lower it; nothing needs to raise it.
29526
- */
29527
- restoreConcurrency = 4;
29528
29653
  async restoreDevices(savedDevices) {
29529
29654
  await this.onRestoreDevices(savedDevices);
29530
29655
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -29579,14 +29704,7 @@ var BaseDeviceProvider = class extends BaseAddon {
29579
29704
  });
29580
29705
  }
29581
29706
  };
29582
- let nextTopLevel = 0;
29583
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
29584
- for (;;) {
29585
- const saved = topLevel[nextTopLevel++];
29586
- if (saved === void 0) return;
29587
- await restoreOne(saved);
29588
- }
29589
- }));
29707
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
29590
29708
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
29591
29709
  for (const saved of childRows) {
29592
29710
  const Class = this.deviceClasses[saved.type];
@@ -32859,6 +32977,12 @@ Object.freeze({
32859
32977
  addonId: null,
32860
32978
  access: "create"
32861
32979
  },
32980
+ "pipelineAnalytics.reconcileFromDisk": {
32981
+ capName: "pipeline-analytics",
32982
+ capScope: "device",
32983
+ addonId: null,
32984
+ access: "create"
32985
+ },
32862
32986
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
32863
32987
  capName: "pipeline-analytics",
32864
32988
  capScope: "device",
@@ -33261,6 +33385,12 @@ Object.freeze({
33261
33385
  addonId: null,
33262
33386
  access: "view"
33263
33387
  },
33388
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
33389
+ capName: "pipeline-orchestrator",
33390
+ capScope: "system",
33391
+ addonId: null,
33392
+ access: "view"
33393
+ },
33264
33394
  "pipelineOrchestrator.listAgentSettings": {
33265
33395
  capName: "pipeline-orchestrator",
33266
33396
  capScope: "system",
@@ -33285,6 +33415,12 @@ Object.freeze({
33285
33415
  addonId: null,
33286
33416
  access: "create"
33287
33417
  },
33418
+ "pipelineOrchestrator.reconcileFromDisk": {
33419
+ capName: "pipeline-orchestrator",
33420
+ capScope: "system",
33421
+ addonId: null,
33422
+ access: "create"
33423
+ },
33288
33424
  "pipelineOrchestrator.removeAgentSettings": {
33289
33425
  capName: "pipeline-orchestrator",
33290
33426
  capScope: "system",
@@ -36276,6 +36412,11 @@ Object.freeze({
36276
36412
  form: "single",
36277
36413
  optional: true
36278
36414
  }],
36415
+ "pipelineAnalytics.reconcileFromDisk": [{
36416
+ name: "deviceId",
36417
+ form: "single",
36418
+ optional: false
36419
+ }],
36279
36420
  "pipelineAnalytics.restageRetrainTrack": [{
36280
36421
  name: "deviceId",
36281
36422
  form: "single",
@@ -37341,6 +37482,8 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
37341
37482
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
37342
37483
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
37343
37484
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
37485
+ var MB = 1024 * 1024;
37486
+ 1024 * MB, 3072 * MB;
37344
37487
  //#endregion
37345
37488
  //#region src/config.ts
37346
37489
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-dreo",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Dreo smart-device (fan / air-circulator / purifier / heater / humidifier) device-provider addon for CamStack — wraps the @apocaliss92/nodedreo Dreo cloud client (REST + WebSocket)",
5
5
  "keywords": [
6
6
  "camstack",