@camstack/addon-provider-hikvision 1.2.24 → 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,4 +35,3 @@ The probe runs once per camera lifetime (after the first successful `publishToBr
35
35
  ## References
36
36
 
37
37
  - ISAPI Service v2 & v3 specs (Hikvision ISAPI doc package — vendor download).
38
- - Scrypted Hikvision plugin — `plugins/hikvision/src/` in the [Scrypted](https://github.com/koush/scrypted) repository (port reference for endpoint patterns + alarm-stream parsing).
package/dist/addon.js CHANGED
@@ -6,7 +6,7 @@ let node_crypto = require("node:crypto");
6
6
  let node_http = require("node:http");
7
7
  let node_https = require("node:https");
8
8
  let node_os = require("node:os");
9
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
9
+ //#region ../types/dist/event-category-C0lyLd5U.mjs
10
10
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
11
11
  EventCategory["SystemBoot"] = "system.boot";
12
12
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -23,9 +23,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
23
23
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
24
24
  /**
25
25
  * A newer addon or server-root package version was found by the
26
- * authoritative registry check. Emitted once per
27
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
28
- * polling of the same result is deduplicated by the checker.
26
+ * authoritative registry check. Emitted once when any observed
27
+ * `latestVersion` changes (or a package/node first appears behind);
28
+ * the payload carries the full currently-available list. Repeated
29
+ * polling of the same latests is silent.
29
30
  */
30
31
  EventCategory["UpdateAvailable"] = "update.available";
31
32
  /**
@@ -7543,6 +7544,10 @@ var RecordingBandSchema = object({
7543
7544
  preBufferSec: number().min(0).optional(),
7544
7545
  postBufferSec: number().min(0).optional()
7545
7546
  });
7547
+ ({
7548
+ preBufferSec: 10,
7549
+ postBufferSec: 30
7550
+ }).postBufferSec * 1e3;
7546
7551
  /**
7547
7552
  * Per-device retention overrides. Every field is optional; an unset or `0`
7548
7553
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7590,6 +7595,12 @@ var RecordingConfigSchema = object({
7590
7595
  /** DERIVED summary of `bands`, stamped by the recorder on every save.
7591
7596
  * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7592
7597
  mode: RecordingStorageModeSchema.optional(),
7598
+ /**
7599
+ * Which assigned broker slots to record. Absent / empty = {@link
7600
+ * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
7601
+ * camera's currently assigned slots — never `mid` unless the operator
7602
+ * picks it, and never a slot the broker has not assigned.
7603
+ */
7593
7604
  profiles: array(CamProfileSchema).optional(),
7594
7605
  segmentSeconds: number().int().positive().optional(),
7595
7606
  /**
@@ -7670,7 +7681,11 @@ var RelocateFootageInputSchema = object({
7670
7681
  profiles: array(string()).optional(),
7671
7682
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7672
7683
  * never allowed to starve live writers. */
7673
- throttleMbps: number().min(1).max(1e3).optional()
7684
+ throttleMbps: number().min(1).max(1e3).optional(),
7685
+ /** Move only segments whose startMs is >= this. Absent = the whole source
7686
+ * pile. Used when a full drain is too expensive and the operator only
7687
+ * wants the recent window on the new disk. */
7688
+ sinceMs: number().int().optional()
7674
7689
  });
7675
7690
  /** Internal, lease-scoped participant operation. It is intentionally separate
7676
7691
  * from persistent recording settings: a migration never changes
@@ -14729,6 +14744,9 @@ var NcSystemEventKindSchema = _enum([
14729
14744
  "alarm-disarmed",
14730
14745
  "alarm-arming",
14731
14746
  "alarm-arm-refused",
14747
+ "addon-updated",
14748
+ "server-updated",
14749
+ "export-completed",
14732
14750
  "camera-online",
14733
14751
  "camera-offline",
14734
14752
  "camera-disabled",
@@ -16622,13 +16640,19 @@ var RetrainStatusSchema = _enum([
16622
16640
  * "never marked" from "already trained" must read `retrainStatus`.
16623
16641
  *
16624
16642
  * `debug` does NOT pin; it is attention, not durability.
16643
+ *
16644
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16645
+ * A favourited track is skipped by retention the same way `staging` is, but
16646
+ * it does not enter `none|staging|trained` and has no staging budget.
16625
16647
  */
16626
16648
  var TrackFlagFields = {
16627
16649
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16628
16650
  * `'staging'`. */
16629
16651
  markForTrain: boolean().optional(),
16630
16652
  /** Operator marked this track for diagnostic attention. */
16631
- debug: boolean().optional()
16653
+ debug: boolean().optional(),
16654
+ /** Operator favourited this track. Pins it against pruning. */
16655
+ favourited: boolean().optional()
16632
16656
  };
16633
16657
  /**
16634
16658
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16653,6 +16677,7 @@ var TrackFlagsSchema = object({
16653
16677
  trackId: string(),
16654
16678
  markForTrain: boolean(),
16655
16679
  debug: boolean(),
16680
+ favourited: boolean(),
16656
16681
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16657
16682
  * a track row) because this shape is only ever produced by the write body,
16658
16683
  * which always knows it — and a surface that has just written needs to render
@@ -17285,6 +17310,12 @@ var TrackCascadeCountsSchema = object({
17285
17310
  /** Per-track CLIP search vectors removed (best-effort). */
17286
17311
  embeddings: number().int()
17287
17312
  });
17313
+ /** Disk-wins reconcile: media rows whose blobs are gone, then empty tracks. */
17314
+ var DiskReconcileCountsSchema = object({
17315
+ mediaDropped: number().int(),
17316
+ tracks: number().int(),
17317
+ events: number().int()
17318
+ });
17288
17319
  /** Event-store footprint for one camera. */
17289
17320
  var EventStoreDeviceFootprintSchema = object({
17290
17321
  deviceId: number(),
@@ -17461,6 +17492,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17461
17492
  }), method(object({ deviceId: number() }), TrackCascadeCountsSchema, {
17462
17493
  kind: "mutation",
17463
17494
  auth: "admin"
17495
+ }), method(object({ deviceId: number() }), DiskReconcileCountsSchema, {
17496
+ kind: "mutation",
17497
+ auth: "admin"
17464
17498
  }), method(object({
17465
17499
  deviceId: number(),
17466
17500
  trackIds: array(string()).min(1)
@@ -19011,6 +19045,24 @@ var CameraStatusDegradationSchema = object({
19011
19045
  *
19012
19046
  * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
19013
19047
  */
19048
+ var DiskReconcileJobSchema = object({
19049
+ state: _enum([
19050
+ "idle",
19051
+ "running",
19052
+ "done",
19053
+ "error"
19054
+ ]),
19055
+ total: number().int().nonnegative(),
19056
+ completed: number().int().nonnegative(),
19057
+ currentDeviceId: number().int().nullable(),
19058
+ failed: array(number().int()).readonly(),
19059
+ mediaDropped: number().int().nonnegative(),
19060
+ tracks: number().int().nonnegative(),
19061
+ events: number().int().nonnegative(),
19062
+ startedAtMs: number().int().nullable(),
19063
+ finishedAtMs: number().int().nullable(),
19064
+ error: string().nullable()
19065
+ });
19014
19066
  var CameraStatusSchema = object({
19015
19067
  deviceId: number(),
19016
19068
  assignment: CameraAssignmentStatusSchema,
@@ -19242,7 +19294,10 @@ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
19242
19294
  }), CameraSwitchGroupSchema, {
19243
19295
  kind: "mutation",
19244
19296
  auth: "admin"
19245
- }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19297
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), DiskReconcileJobSchema, {
19298
+ kind: "mutation",
19299
+ auth: "admin"
19300
+ }), method(_void(), DiskReconcileJobSchema, { auth: "admin" }), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19246
19301
  name: string(),
19247
19302
  description: string().optional(),
19248
19303
  config: CameraPipelineConfigSchema
@@ -19745,7 +19800,14 @@ targets: array(object({
19745
19800
  "sleeping",
19746
19801
  "unreachable",
19747
19802
  "waking"
19748
- ]).nullable()
19803
+ ]).nullable(),
19804
+ /** A battery camera (whatever its current state). An AWAKE battery
19805
+ * camera is deliberately NOT recaptured on the poll cadence — every
19806
+ * capture is a camera hit that would keep it out of sleep — so its
19807
+ * cached frame legitimately ages past the currency ceiling while
19808
+ * nothing is streaming. A surface must keep painting it (a fresh
19809
+ * frame is captured at each wake), not blank to "unavailable". */
19810
+ battery: boolean()
19749
19811
  })))
19750
19812
  },
19751
19813
  status: {
@@ -20442,8 +20504,28 @@ var ClipSchema = object({
20442
20504
  startMs: number(),
20443
20505
  endMs: number()
20444
20506
  }),
20445
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20446
- thumbnail: string().optional()
20507
+ /**
20508
+ * Lazy thumbnail URL, never inlined.
20509
+ *
20510
+ * Recording-derived clips (events-mode keep-window, and the prepared
20511
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20512
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20513
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20514
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20515
+ * mint their own stills.
20516
+ */
20517
+ thumbnail: string().optional(),
20518
+ /** Analytics event ids that overlap this visit. Empty on footage-only clips.
20519
+ * The default provider's visit grain puts many motion heartbeats on one clip
20520
+ * instead of minting one clip per marker. */
20521
+ eventIds: array(string()).optional(),
20522
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20523
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20524
+ * bar keeps showing them via `recording.getAvailability`. */
20525
+ holes: array(object({
20526
+ startMs: number(),
20527
+ endMs: number()
20528
+ })).optional()
20447
20529
  });
20448
20530
  var ClipPlaybackSchema = object({
20449
20531
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -26862,7 +26944,9 @@ var ExportOptionsSchema = object({
26862
26944
  includeAudio: boolean(),
26863
26945
  maxLifeMs: number().int().positive(),
26864
26946
  deleteAfterDownload: boolean(),
26865
- title: string().max(200).optional()
26947
+ title: string().max(200).optional(),
26948
+ /** Notification-output target ids to ping when this export becomes ready. */
26949
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26866
26950
  }).superRefine((v, ctx) => {
26867
26951
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26868
26952
  code: ZodIssueCode.custom,
@@ -26921,10 +27005,18 @@ var ExportBytesSchema = object({
26921
27005
  });
26922
27006
  method(object({
26923
27007
  deviceId: number(),
26924
- profile: string(),
27008
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
27009
+ profile: string().optional(),
27010
+ profiles: array(string()).min(1).optional(),
26925
27011
  fromMs: number(),
26926
27012
  toMs: number(),
26927
27013
  options: ExportOptionsSchema
27014
+ }).superRefine((v, ctx) => {
27015
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
27016
+ code: ZodIssueCode.custom,
27017
+ message: "pass profiles[] (min 1) or legacy profile",
27018
+ path: ["profiles"]
27019
+ });
26928
27020
  }), ExportRecordSchema, {
26929
27021
  kind: "mutation",
26930
27022
  auth: "protected"
@@ -30079,15 +30171,6 @@ var BaseDeviceProvider = class extends BaseAddon {
30079
30171
  labels: ["probe not implemented"]
30080
30172
  };
30081
30173
  }
30082
- /**
30083
- * Top-level devices restored at once in {@link onRestoreDevices}.
30084
- *
30085
- * Four covers the fleets this ships to without turning a boot into a burst a
30086
- * camera NVR answers with a refusal. A provider whose upstream is a single
30087
- * session with a serial command channel (a Baichuan hub, an NVR that
30088
- * serialises ISAPI) should lower it; nothing needs to raise it.
30089
- */
30090
- restoreConcurrency = 4;
30091
30174
  async restoreDevices(savedDevices) {
30092
30175
  await this.onRestoreDevices(savedDevices);
30093
30176
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -30142,14 +30225,7 @@ var BaseDeviceProvider = class extends BaseAddon {
30142
30225
  });
30143
30226
  }
30144
30227
  };
30145
- let nextTopLevel = 0;
30146
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
30147
- for (;;) {
30148
- const saved = topLevel[nextTopLevel++];
30149
- if (saved === void 0) return;
30150
- await restoreOne(saved);
30151
- }
30152
- }));
30228
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
30153
30229
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
30154
30230
  for (const saved of childRows) {
30155
30231
  const Class = this.deviceClasses[saved.type];
@@ -33553,6 +33629,12 @@ Object.freeze({
33553
33629
  addonId: null,
33554
33630
  access: "create"
33555
33631
  },
33632
+ "pipelineAnalytics.reconcileFromDisk": {
33633
+ capName: "pipeline-analytics",
33634
+ capScope: "device",
33635
+ addonId: null,
33636
+ access: "create"
33637
+ },
33556
33638
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
33557
33639
  capName: "pipeline-analytics",
33558
33640
  capScope: "device",
@@ -33955,6 +34037,12 @@ Object.freeze({
33955
34037
  addonId: null,
33956
34038
  access: "view"
33957
34039
  },
34040
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
34041
+ capName: "pipeline-orchestrator",
34042
+ capScope: "system",
34043
+ addonId: null,
34044
+ access: "view"
34045
+ },
33958
34046
  "pipelineOrchestrator.listAgentSettings": {
33959
34047
  capName: "pipeline-orchestrator",
33960
34048
  capScope: "system",
@@ -33979,6 +34067,12 @@ Object.freeze({
33979
34067
  addonId: null,
33980
34068
  access: "create"
33981
34069
  },
34070
+ "pipelineOrchestrator.reconcileFromDisk": {
34071
+ capName: "pipeline-orchestrator",
34072
+ capScope: "system",
34073
+ addonId: null,
34074
+ access: "create"
34075
+ },
33982
34076
  "pipelineOrchestrator.removeAgentSettings": {
33983
34077
  capName: "pipeline-orchestrator",
33984
34078
  capScope: "system",
@@ -36970,6 +37064,11 @@ Object.freeze({
36970
37064
  form: "single",
36971
37065
  optional: true
36972
37066
  }],
37067
+ "pipelineAnalytics.reconcileFromDisk": [{
37068
+ name: "deviceId",
37069
+ form: "single",
37070
+ optional: false
37071
+ }],
36973
37072
  "pipelineAnalytics.restageRetrainTrack": [{
36974
37073
  name: "deviceId",
36975
37074
  form: "single",
@@ -40690,7 +40789,7 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40690
40789
  for (const block of parts.blocks) {
40691
40790
  const parsed = parseMultipartBlock(block);
40692
40791
  if (!parsed) continue;
40693
- if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml")) {
40792
+ if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml") || looksLikeAlertXml(parsed.bodyText)) {
40694
40793
  const ev = parseAlertXml(parsed.bodyText);
40695
40794
  if (ev) handlers.onEvent(ev);
40696
40795
  }
@@ -40722,14 +40821,24 @@ function splitOnBoundary(buf, dashBoundary) {
40722
40821
  tail
40723
40822
  };
40724
40823
  }
40824
+ function looksLikeAlertXml(text) {
40825
+ return text.includes("<eventType>") || text.includes("EventNotificationAlert");
40826
+ }
40725
40827
  function parseMultipartBlock(block) {
40726
- let start = 0;
40727
- while (start < block.length && (block[start] === 13 || block[start] === 10)) start++;
40728
- const headerEnd = findHeaderEnd(block, start);
40729
- if (headerEnd < 0) return null;
40730
- const headerBlob = Buffer.from(block.subarray(start, headerEnd)).toString("utf8");
40731
- const bodyStart = headerEnd + 4;
40732
- const bodySlice = block.subarray(bodyStart);
40828
+ const headerEnd = findHeaderEnd(block, 0);
40829
+ let headerBlob = "";
40830
+ let bodySlice;
40831
+ if (headerEnd >= 0) {
40832
+ headerBlob = Buffer.from(block.subarray(0, headerEnd)).toString("utf8");
40833
+ bodySlice = block.subarray(headerEnd + 4);
40834
+ } else {
40835
+ const raw = Buffer.from(block).toString("utf8");
40836
+ if (!looksLikeAlertXml(raw)) return null;
40837
+ return {
40838
+ contentType: "",
40839
+ bodyText: raw.replace(/\r?\n--$/m, "").trim()
40840
+ };
40841
+ }
40733
40842
  const headers = {};
40734
40843
  for (const line of headerBlob.split(/\r?\n/)) {
40735
40844
  const idx = line.indexOf(":");
@@ -40745,6 +40854,28 @@ function findHeaderEnd(buf, from) {
40745
40854
  for (let i = from; i + 3 < buf.length; i++) if (buf[i] === 13 && buf[i + 1] === 10 && buf[i + 2] === 13 && buf[i + 3] === 10) return i;
40746
40855
  return -1;
40747
40856
  }
40857
+ /**
40858
+ * Event types that imply onboard motion. Compared after `eventType`
40859
+ * is lowercased. Some firmwares emit `regionExit`, others
40860
+ * `regionexiting` — accept both.
40861
+ */
40862
+ function isHikvisionMotionAlarmType(type) {
40863
+ switch (type) {
40864
+ case "vmd":
40865
+ case "motiondetection":
40866
+ case "linedetection":
40867
+ case "fielddetection":
40868
+ case "regionentrance":
40869
+ case "regionexit":
40870
+ case "regionexiting":
40871
+ case "attendedbaggage":
40872
+ case "unattendedbaggage": return true;
40873
+ default: return false;
40874
+ }
40875
+ }
40876
+ function isHikvisionSmartAlarmType(type) {
40877
+ return type !== "vmd" && type !== "motiondetection" && isHikvisionMotionAlarmType(type);
40878
+ }
40748
40879
  function parseAlertXml(xml) {
40749
40880
  const type = extractTag(xml, "eventType");
40750
40881
  if (!type) return null;
@@ -44405,7 +44536,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44405
44536
  handleAlarmEvent(ev) {
44406
44537
  const camStreamId = this.resolveAlarmCamStreamId(ev.channelId);
44407
44538
  const isMotion = ev.type === "vmd" || ev.type === "motiondetection";
44408
- const isSmart = ev.type === "linedetection" || ev.type === "fielddetection" || ev.type === "regionentrance" || ev.type === "regionexiting" || ev.type === "attendedbaggage" || ev.type === "unattendedbaggage";
44539
+ const isSmart = isHikvisionSmartAlarmType(ev.type);
44409
44540
  if (isMotion) {
44410
44541
  this.handleMotionEvent(ev, camStreamId);
44411
44542
  return;
@@ -44415,7 +44546,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44415
44546
  this.handleMotionEvent(ev, camStreamId);
44416
44547
  return;
44417
44548
  }
44418
- this.ctx.logger.debug("hikvision: unhandled alarm event", {
44549
+ if (ev.type === "videoloss") {
44550
+ this.ctx.logger.debug("hikvision: unhandled alarm event", {
44551
+ tags: { deviceId: this.id },
44552
+ meta: {
44553
+ type: ev.type,
44554
+ state: ev.state
44555
+ }
44556
+ });
44557
+ return;
44558
+ }
44559
+ this.ctx.logger.info("hikvision: unhandled alarm event", {
44419
44560
  tags: { deviceId: this.id },
44420
44561
  meta: {
44421
44562
  type: ev.type,
package/dist/addon.mjs CHANGED
@@ -7,7 +7,7 @@ import { networkInterfaces } from "node:os";
7
7
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
8
8
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
9
9
  //#endregion
10
- //#region ../types/dist/event-category-Bxo5yJjt.mjs
10
+ //#region ../types/dist/event-category-C0lyLd5U.mjs
11
11
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
12
12
  EventCategory["SystemBoot"] = "system.boot";
13
13
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -24,9 +24,10 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
24
24
  EventCategory["SystemRestartCompleted"] = "system.restart-completed";
25
25
  /**
26
26
  * A newer addon or server-root package version was found by the
27
- * authoritative registry check. Emitted once per
28
- * `(target, packageName, currentVersion, latestVersion)` transition; repeated
29
- * polling of the same result is deduplicated by the checker.
27
+ * authoritative registry check. Emitted once when any observed
28
+ * `latestVersion` changes (or a package/node first appears behind);
29
+ * the payload carries the full currently-available list. Repeated
30
+ * polling of the same latests is silent.
30
31
  */
31
32
  EventCategory["UpdateAvailable"] = "update.available";
32
33
  /**
@@ -7544,6 +7545,10 @@ var RecordingBandSchema = object({
7544
7545
  preBufferSec: number().min(0).optional(),
7545
7546
  postBufferSec: number().min(0).optional()
7546
7547
  });
7548
+ ({
7549
+ preBufferSec: 10,
7550
+ postBufferSec: 30
7551
+ }).postBufferSec * 1e3;
7547
7552
  /**
7548
7553
  * Per-device retention overrides. Every field is optional; an unset or `0`
7549
7554
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7591,6 +7596,12 @@ var RecordingConfigSchema = object({
7591
7596
  /** DERIVED summary of `bands`, stamped by the recorder on every save.
7592
7597
  * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7593
7598
  mode: RecordingStorageModeSchema.optional(),
7599
+ /**
7600
+ * Which assigned broker slots to record. Absent / empty = {@link
7601
+ * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
7602
+ * camera's currently assigned slots — never `mid` unless the operator
7603
+ * picks it, and never a slot the broker has not assigned.
7604
+ */
7594
7605
  profiles: array(CamProfileSchema).optional(),
7595
7606
  segmentSeconds: number().int().positive().optional(),
7596
7607
  /**
@@ -7671,7 +7682,11 @@ var RelocateFootageInputSchema = object({
7671
7682
  profiles: array(string()).optional(),
7672
7683
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7673
7684
  * never allowed to starve live writers. */
7674
- throttleMbps: number().min(1).max(1e3).optional()
7685
+ throttleMbps: number().min(1).max(1e3).optional(),
7686
+ /** Move only segments whose startMs is >= this. Absent = the whole source
7687
+ * pile. Used when a full drain is too expensive and the operator only
7688
+ * wants the recent window on the new disk. */
7689
+ sinceMs: number().int().optional()
7675
7690
  });
7676
7691
  /** Internal, lease-scoped participant operation. It is intentionally separate
7677
7692
  * from persistent recording settings: a migration never changes
@@ -14730,6 +14745,9 @@ var NcSystemEventKindSchema = _enum([
14730
14745
  "alarm-disarmed",
14731
14746
  "alarm-arming",
14732
14747
  "alarm-arm-refused",
14748
+ "addon-updated",
14749
+ "server-updated",
14750
+ "export-completed",
14733
14751
  "camera-online",
14734
14752
  "camera-offline",
14735
14753
  "camera-disabled",
@@ -16623,13 +16641,19 @@ var RetrainStatusSchema = _enum([
16623
16641
  * "never marked" from "already trained" must read `retrainStatus`.
16624
16642
  *
16625
16643
  * `debug` does NOT pin; it is attention, not durability.
16644
+ *
16645
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16646
+ * A favourited track is skipped by retention the same way `staging` is, but
16647
+ * it does not enter `none|staging|trained` and has no staging budget.
16626
16648
  */
16627
16649
  var TrackFlagFields = {
16628
16650
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16629
16651
  * `'staging'`. */
16630
16652
  markForTrain: boolean().optional(),
16631
16653
  /** Operator marked this track for diagnostic attention. */
16632
- debug: boolean().optional()
16654
+ debug: boolean().optional(),
16655
+ /** Operator favourited this track. Pins it against pruning. */
16656
+ favourited: boolean().optional()
16633
16657
  };
16634
16658
  /**
16635
16659
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16654,6 +16678,7 @@ var TrackFlagsSchema = object({
16654
16678
  trackId: string(),
16655
16679
  markForTrain: boolean(),
16656
16680
  debug: boolean(),
16681
+ favourited: boolean(),
16657
16682
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16658
16683
  * a track row) because this shape is only ever produced by the write body,
16659
16684
  * which always knows it — and a surface that has just written needs to render
@@ -17286,6 +17311,12 @@ var TrackCascadeCountsSchema = object({
17286
17311
  /** Per-track CLIP search vectors removed (best-effort). */
17287
17312
  embeddings: number().int()
17288
17313
  });
17314
+ /** Disk-wins reconcile: media rows whose blobs are gone, then empty tracks. */
17315
+ var DiskReconcileCountsSchema = object({
17316
+ mediaDropped: number().int(),
17317
+ tracks: number().int(),
17318
+ events: number().int()
17319
+ });
17289
17320
  /** Event-store footprint for one camera. */
17290
17321
  var EventStoreDeviceFootprintSchema = object({
17291
17322
  deviceId: number(),
@@ -17462,6 +17493,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17462
17493
  }), method(object({ deviceId: number() }), TrackCascadeCountsSchema, {
17463
17494
  kind: "mutation",
17464
17495
  auth: "admin"
17496
+ }), method(object({ deviceId: number() }), DiskReconcileCountsSchema, {
17497
+ kind: "mutation",
17498
+ auth: "admin"
17465
17499
  }), method(object({
17466
17500
  deviceId: number(),
17467
17501
  trackIds: array(string()).min(1)
@@ -19012,6 +19046,24 @@ var CameraStatusDegradationSchema = object({
19012
19046
  *
19013
19047
  * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
19014
19048
  */
19049
+ var DiskReconcileJobSchema = object({
19050
+ state: _enum([
19051
+ "idle",
19052
+ "running",
19053
+ "done",
19054
+ "error"
19055
+ ]),
19056
+ total: number().int().nonnegative(),
19057
+ completed: number().int().nonnegative(),
19058
+ currentDeviceId: number().int().nullable(),
19059
+ failed: array(number().int()).readonly(),
19060
+ mediaDropped: number().int().nonnegative(),
19061
+ tracks: number().int().nonnegative(),
19062
+ events: number().int().nonnegative(),
19063
+ startedAtMs: number().int().nullable(),
19064
+ finishedAtMs: number().int().nullable(),
19065
+ error: string().nullable()
19066
+ });
19015
19067
  var CameraStatusSchema = object({
19016
19068
  deviceId: number(),
19017
19069
  assignment: CameraAssignmentStatusSchema,
@@ -19243,7 +19295,10 @@ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
19243
19295
  }), CameraSwitchGroupSchema, {
19244
19296
  kind: "mutation",
19245
19297
  auth: "admin"
19246
- }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19298
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), DiskReconcileJobSchema, {
19299
+ kind: "mutation",
19300
+ auth: "admin"
19301
+ }), method(_void(), DiskReconcileJobSchema, { auth: "admin" }), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19247
19302
  name: string(),
19248
19303
  description: string().optional(),
19249
19304
  config: CameraPipelineConfigSchema
@@ -19746,7 +19801,14 @@ targets: array(object({
19746
19801
  "sleeping",
19747
19802
  "unreachable",
19748
19803
  "waking"
19749
- ]).nullable()
19804
+ ]).nullable(),
19805
+ /** A battery camera (whatever its current state). An AWAKE battery
19806
+ * camera is deliberately NOT recaptured on the poll cadence — every
19807
+ * capture is a camera hit that would keep it out of sleep — so its
19808
+ * cached frame legitimately ages past the currency ceiling while
19809
+ * nothing is streaming. A surface must keep painting it (a fresh
19810
+ * frame is captured at each wake), not blank to "unavailable". */
19811
+ battery: boolean()
19750
19812
  })))
19751
19813
  },
19752
19814
  status: {
@@ -20443,8 +20505,28 @@ var ClipSchema = object({
20443
20505
  startMs: number(),
20444
20506
  endMs: number()
20445
20507
  }),
20446
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20447
- thumbnail: string().optional()
20508
+ /**
20509
+ * Lazy thumbnail URL, never inlined.
20510
+ *
20511
+ * Recording-derived clips (events-mode keep-window, and the prepared
20512
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20513
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20514
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20515
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20516
+ * mint their own stills.
20517
+ */
20518
+ thumbnail: string().optional(),
20519
+ /** Analytics event ids that overlap this visit. Empty on footage-only clips.
20520
+ * The default provider's visit grain puts many motion heartbeats on one clip
20521
+ * instead of minting one clip per marker. */
20522
+ eventIds: array(string()).optional(),
20523
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20524
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20525
+ * bar keeps showing them via `recording.getAvailability`. */
20526
+ holes: array(object({
20527
+ startMs: number(),
20528
+ endMs: number()
20529
+ })).optional()
20448
20530
  });
20449
20531
  var ClipPlaybackSchema = object({
20450
20532
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -26863,7 +26945,9 @@ var ExportOptionsSchema = object({
26863
26945
  includeAudio: boolean(),
26864
26946
  maxLifeMs: number().int().positive(),
26865
26947
  deleteAfterDownload: boolean(),
26866
- title: string().max(200).optional()
26948
+ title: string().max(200).optional(),
26949
+ /** Notification-output target ids to ping when this export becomes ready. */
26950
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26867
26951
  }).superRefine((v, ctx) => {
26868
26952
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26869
26953
  code: ZodIssueCode.custom,
@@ -26922,10 +27006,18 @@ var ExportBytesSchema = object({
26922
27006
  });
26923
27007
  method(object({
26924
27008
  deviceId: number(),
26925
- profile: string(),
27009
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
27010
+ profile: string().optional(),
27011
+ profiles: array(string()).min(1).optional(),
26926
27012
  fromMs: number(),
26927
27013
  toMs: number(),
26928
27014
  options: ExportOptionsSchema
27015
+ }).superRefine((v, ctx) => {
27016
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
27017
+ code: ZodIssueCode.custom,
27018
+ message: "pass profiles[] (min 1) or legacy profile",
27019
+ path: ["profiles"]
27020
+ });
26929
27021
  }), ExportRecordSchema, {
26930
27022
  kind: "mutation",
26931
27023
  auth: "protected"
@@ -30080,15 +30172,6 @@ var BaseDeviceProvider = class extends BaseAddon {
30080
30172
  labels: ["probe not implemented"]
30081
30173
  };
30082
30174
  }
30083
- /**
30084
- * Top-level devices restored at once in {@link onRestoreDevices}.
30085
- *
30086
- * Four covers the fleets this ships to without turning a boot into a burst a
30087
- * camera NVR answers with a refusal. A provider whose upstream is a single
30088
- * session with a serial command channel (a Baichuan hub, an NVR that
30089
- * serialises ISAPI) should lower it; nothing needs to raise it.
30090
- */
30091
- restoreConcurrency = 4;
30092
30175
  async restoreDevices(savedDevices) {
30093
30176
  await this.onRestoreDevices(savedDevices);
30094
30177
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -30143,14 +30226,7 @@ var BaseDeviceProvider = class extends BaseAddon {
30143
30226
  });
30144
30227
  }
30145
30228
  };
30146
- let nextTopLevel = 0;
30147
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
30148
- for (;;) {
30149
- const saved = topLevel[nextTopLevel++];
30150
- if (saved === void 0) return;
30151
- await restoreOne(saved);
30152
- }
30153
- }));
30229
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
30154
30230
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
30155
30231
  for (const saved of childRows) {
30156
30232
  const Class = this.deviceClasses[saved.type];
@@ -33554,6 +33630,12 @@ Object.freeze({
33554
33630
  addonId: null,
33555
33631
  access: "create"
33556
33632
  },
33633
+ "pipelineAnalytics.reconcileFromDisk": {
33634
+ capName: "pipeline-analytics",
33635
+ capScope: "device",
33636
+ addonId: null,
33637
+ access: "create"
33638
+ },
33557
33639
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
33558
33640
  capName: "pipeline-analytics",
33559
33641
  capScope: "device",
@@ -33956,6 +34038,12 @@ Object.freeze({
33956
34038
  addonId: null,
33957
34039
  access: "view"
33958
34040
  },
34041
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
34042
+ capName: "pipeline-orchestrator",
34043
+ capScope: "system",
34044
+ addonId: null,
34045
+ access: "view"
34046
+ },
33959
34047
  "pipelineOrchestrator.listAgentSettings": {
33960
34048
  capName: "pipeline-orchestrator",
33961
34049
  capScope: "system",
@@ -33980,6 +34068,12 @@ Object.freeze({
33980
34068
  addonId: null,
33981
34069
  access: "create"
33982
34070
  },
34071
+ "pipelineOrchestrator.reconcileFromDisk": {
34072
+ capName: "pipeline-orchestrator",
34073
+ capScope: "system",
34074
+ addonId: null,
34075
+ access: "create"
34076
+ },
33983
34077
  "pipelineOrchestrator.removeAgentSettings": {
33984
34078
  capName: "pipeline-orchestrator",
33985
34079
  capScope: "system",
@@ -36971,6 +37065,11 @@ Object.freeze({
36971
37065
  form: "single",
36972
37066
  optional: true
36973
37067
  }],
37068
+ "pipelineAnalytics.reconcileFromDisk": [{
37069
+ name: "deviceId",
37070
+ form: "single",
37071
+ optional: false
37072
+ }],
36974
37073
  "pipelineAnalytics.restageRetrainTrack": [{
36975
37074
  name: "deviceId",
36976
37075
  form: "single",
@@ -40691,7 +40790,7 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40691
40790
  for (const block of parts.blocks) {
40692
40791
  const parsed = parseMultipartBlock(block);
40693
40792
  if (!parsed) continue;
40694
- if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml")) {
40793
+ if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml") || looksLikeAlertXml(parsed.bodyText)) {
40695
40794
  const ev = parseAlertXml(parsed.bodyText);
40696
40795
  if (ev) handlers.onEvent(ev);
40697
40796
  }
@@ -40723,14 +40822,24 @@ function splitOnBoundary(buf, dashBoundary) {
40723
40822
  tail
40724
40823
  };
40725
40824
  }
40825
+ function looksLikeAlertXml(text) {
40826
+ return text.includes("<eventType>") || text.includes("EventNotificationAlert");
40827
+ }
40726
40828
  function parseMultipartBlock(block) {
40727
- let start = 0;
40728
- while (start < block.length && (block[start] === 13 || block[start] === 10)) start++;
40729
- const headerEnd = findHeaderEnd(block, start);
40730
- if (headerEnd < 0) return null;
40731
- const headerBlob = Buffer.from(block.subarray(start, headerEnd)).toString("utf8");
40732
- const bodyStart = headerEnd + 4;
40733
- const bodySlice = block.subarray(bodyStart);
40829
+ const headerEnd = findHeaderEnd(block, 0);
40830
+ let headerBlob = "";
40831
+ let bodySlice;
40832
+ if (headerEnd >= 0) {
40833
+ headerBlob = Buffer.from(block.subarray(0, headerEnd)).toString("utf8");
40834
+ bodySlice = block.subarray(headerEnd + 4);
40835
+ } else {
40836
+ const raw = Buffer.from(block).toString("utf8");
40837
+ if (!looksLikeAlertXml(raw)) return null;
40838
+ return {
40839
+ contentType: "",
40840
+ bodyText: raw.replace(/\r?\n--$/m, "").trim()
40841
+ };
40842
+ }
40734
40843
  const headers = {};
40735
40844
  for (const line of headerBlob.split(/\r?\n/)) {
40736
40845
  const idx = line.indexOf(":");
@@ -40746,6 +40855,28 @@ function findHeaderEnd(buf, from) {
40746
40855
  for (let i = from; i + 3 < buf.length; i++) if (buf[i] === 13 && buf[i + 1] === 10 && buf[i + 2] === 13 && buf[i + 3] === 10) return i;
40747
40856
  return -1;
40748
40857
  }
40858
+ /**
40859
+ * Event types that imply onboard motion. Compared after `eventType`
40860
+ * is lowercased. Some firmwares emit `regionExit`, others
40861
+ * `regionexiting` — accept both.
40862
+ */
40863
+ function isHikvisionMotionAlarmType(type) {
40864
+ switch (type) {
40865
+ case "vmd":
40866
+ case "motiondetection":
40867
+ case "linedetection":
40868
+ case "fielddetection":
40869
+ case "regionentrance":
40870
+ case "regionexit":
40871
+ case "regionexiting":
40872
+ case "attendedbaggage":
40873
+ case "unattendedbaggage": return true;
40874
+ default: return false;
40875
+ }
40876
+ }
40877
+ function isHikvisionSmartAlarmType(type) {
40878
+ return type !== "vmd" && type !== "motiondetection" && isHikvisionMotionAlarmType(type);
40879
+ }
40749
40880
  function parseAlertXml(xml) {
40750
40881
  const type = extractTag(xml, "eventType");
40751
40882
  if (!type) return null;
@@ -44406,7 +44537,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44406
44537
  handleAlarmEvent(ev) {
44407
44538
  const camStreamId = this.resolveAlarmCamStreamId(ev.channelId);
44408
44539
  const isMotion = ev.type === "vmd" || ev.type === "motiondetection";
44409
- const isSmart = ev.type === "linedetection" || ev.type === "fielddetection" || ev.type === "regionentrance" || ev.type === "regionexiting" || ev.type === "attendedbaggage" || ev.type === "unattendedbaggage";
44540
+ const isSmart = isHikvisionSmartAlarmType(ev.type);
44410
44541
  if (isMotion) {
44411
44542
  this.handleMotionEvent(ev, camStreamId);
44412
44543
  return;
@@ -44416,7 +44547,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44416
44547
  this.handleMotionEvent(ev, camStreamId);
44417
44548
  return;
44418
44549
  }
44419
- this.ctx.logger.debug("hikvision: unhandled alarm event", {
44550
+ if (ev.type === "videoloss") {
44551
+ this.ctx.logger.debug("hikvision: unhandled alarm event", {
44552
+ tags: { deviceId: this.id },
44553
+ meta: {
44554
+ type: ev.type,
44555
+ state: ev.state
44556
+ }
44557
+ });
44558
+ return;
44559
+ }
44560
+ this.ctx.logger.info("hikvision: unhandled alarm event", {
44420
44561
  tags: { deviceId: this.id },
44421
44562
  meta: {
44422
44563
  type: ev.type,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-hikvision",
3
- "version": "1.2.24",
3
+ "version": "1.2.27",
4
4
  "description": "Hikvision camera device provider addon for CamStack — ISAPI over HTTP(S) with digest auth (snapshot, alarm stream, RTSP discovery)",
5
5
  "keywords": [
6
6
  "camstack",