@camstack/addon-provider-hikvision 1.2.25 → 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
@@ -20449,8 +20504,28 @@ var ClipSchema = object({
20449
20504
  startMs: number(),
20450
20505
  endMs: number()
20451
20506
  }),
20452
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20453
- 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()
20454
20529
  });
20455
20530
  var ClipPlaybackSchema = object({
20456
20531
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -26869,7 +26944,9 @@ var ExportOptionsSchema = object({
26869
26944
  includeAudio: boolean(),
26870
26945
  maxLifeMs: number().int().positive(),
26871
26946
  deleteAfterDownload: boolean(),
26872
- 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()
26873
26950
  }).superRefine((v, ctx) => {
26874
26951
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26875
26952
  code: ZodIssueCode.custom,
@@ -26928,10 +27005,18 @@ var ExportBytesSchema = object({
26928
27005
  });
26929
27006
  method(object({
26930
27007
  deviceId: number(),
26931
- profile: string(),
27008
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
27009
+ profile: string().optional(),
27010
+ profiles: array(string()).min(1).optional(),
26932
27011
  fromMs: number(),
26933
27012
  toMs: number(),
26934
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
+ });
26935
27020
  }), ExportRecordSchema, {
26936
27021
  kind: "mutation",
26937
27022
  auth: "protected"
@@ -30086,15 +30171,6 @@ var BaseDeviceProvider = class extends BaseAddon {
30086
30171
  labels: ["probe not implemented"]
30087
30172
  };
30088
30173
  }
30089
- /**
30090
- * Top-level devices restored at once in {@link onRestoreDevices}.
30091
- *
30092
- * Four covers the fleets this ships to without turning a boot into a burst a
30093
- * camera NVR answers with a refusal. A provider whose upstream is a single
30094
- * session with a serial command channel (a Baichuan hub, an NVR that
30095
- * serialises ISAPI) should lower it; nothing needs to raise it.
30096
- */
30097
- restoreConcurrency = 4;
30098
30174
  async restoreDevices(savedDevices) {
30099
30175
  await this.onRestoreDevices(savedDevices);
30100
30176
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -30149,14 +30225,7 @@ var BaseDeviceProvider = class extends BaseAddon {
30149
30225
  });
30150
30226
  }
30151
30227
  };
30152
- let nextTopLevel = 0;
30153
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
30154
- for (;;) {
30155
- const saved = topLevel[nextTopLevel++];
30156
- if (saved === void 0) return;
30157
- await restoreOne(saved);
30158
- }
30159
- }));
30228
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
30160
30229
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
30161
30230
  for (const saved of childRows) {
30162
30231
  const Class = this.deviceClasses[saved.type];
@@ -33560,6 +33629,12 @@ Object.freeze({
33560
33629
  addonId: null,
33561
33630
  access: "create"
33562
33631
  },
33632
+ "pipelineAnalytics.reconcileFromDisk": {
33633
+ capName: "pipeline-analytics",
33634
+ capScope: "device",
33635
+ addonId: null,
33636
+ access: "create"
33637
+ },
33563
33638
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
33564
33639
  capName: "pipeline-analytics",
33565
33640
  capScope: "device",
@@ -33962,6 +34037,12 @@ Object.freeze({
33962
34037
  addonId: null,
33963
34038
  access: "view"
33964
34039
  },
34040
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
34041
+ capName: "pipeline-orchestrator",
34042
+ capScope: "system",
34043
+ addonId: null,
34044
+ access: "view"
34045
+ },
33965
34046
  "pipelineOrchestrator.listAgentSettings": {
33966
34047
  capName: "pipeline-orchestrator",
33967
34048
  capScope: "system",
@@ -33986,6 +34067,12 @@ Object.freeze({
33986
34067
  addonId: null,
33987
34068
  access: "create"
33988
34069
  },
34070
+ "pipelineOrchestrator.reconcileFromDisk": {
34071
+ capName: "pipeline-orchestrator",
34072
+ capScope: "system",
34073
+ addonId: null,
34074
+ access: "create"
34075
+ },
33989
34076
  "pipelineOrchestrator.removeAgentSettings": {
33990
34077
  capName: "pipeline-orchestrator",
33991
34078
  capScope: "system",
@@ -36977,6 +37064,11 @@ Object.freeze({
36977
37064
  form: "single",
36978
37065
  optional: true
36979
37066
  }],
37067
+ "pipelineAnalytics.reconcileFromDisk": [{
37068
+ name: "deviceId",
37069
+ form: "single",
37070
+ optional: false
37071
+ }],
36980
37072
  "pipelineAnalytics.restageRetrainTrack": [{
36981
37073
  name: "deviceId",
36982
37074
  form: "single",
@@ -40697,7 +40789,7 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40697
40789
  for (const block of parts.blocks) {
40698
40790
  const parsed = parseMultipartBlock(block);
40699
40791
  if (!parsed) continue;
40700
- 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)) {
40701
40793
  const ev = parseAlertXml(parsed.bodyText);
40702
40794
  if (ev) handlers.onEvent(ev);
40703
40795
  }
@@ -40729,14 +40821,24 @@ function splitOnBoundary(buf, dashBoundary) {
40729
40821
  tail
40730
40822
  };
40731
40823
  }
40824
+ function looksLikeAlertXml(text) {
40825
+ return text.includes("<eventType>") || text.includes("EventNotificationAlert");
40826
+ }
40732
40827
  function parseMultipartBlock(block) {
40733
- let start = 0;
40734
- while (start < block.length && (block[start] === 13 || block[start] === 10)) start++;
40735
- const headerEnd = findHeaderEnd(block, start);
40736
- if (headerEnd < 0) return null;
40737
- const headerBlob = Buffer.from(block.subarray(start, headerEnd)).toString("utf8");
40738
- const bodyStart = headerEnd + 4;
40739
- 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
+ }
40740
40842
  const headers = {};
40741
40843
  for (const line of headerBlob.split(/\r?\n/)) {
40742
40844
  const idx = line.indexOf(":");
@@ -40752,6 +40854,28 @@ function findHeaderEnd(buf, from) {
40752
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;
40753
40855
  return -1;
40754
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
+ }
40755
40879
  function parseAlertXml(xml) {
40756
40880
  const type = extractTag(xml, "eventType");
40757
40881
  if (!type) return null;
@@ -44412,7 +44536,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44412
44536
  handleAlarmEvent(ev) {
44413
44537
  const camStreamId = this.resolveAlarmCamStreamId(ev.channelId);
44414
44538
  const isMotion = ev.type === "vmd" || ev.type === "motiondetection";
44415
- 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);
44416
44540
  if (isMotion) {
44417
44541
  this.handleMotionEvent(ev, camStreamId);
44418
44542
  return;
@@ -44422,7 +44546,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44422
44546
  this.handleMotionEvent(ev, camStreamId);
44423
44547
  return;
44424
44548
  }
44425
- 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", {
44426
44560
  tags: { deviceId: this.id },
44427
44561
  meta: {
44428
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
@@ -20450,8 +20505,28 @@ var ClipSchema = object({
20450
20505
  startMs: number(),
20451
20506
  endMs: number()
20452
20507
  }),
20453
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20454
- 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()
20455
20530
  });
20456
20531
  var ClipPlaybackSchema = object({
20457
20532
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -26870,7 +26945,9 @@ var ExportOptionsSchema = object({
26870
26945
  includeAudio: boolean(),
26871
26946
  maxLifeMs: number().int().positive(),
26872
26947
  deleteAfterDownload: boolean(),
26873
- 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()
26874
26951
  }).superRefine((v, ctx) => {
26875
26952
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26876
26953
  code: ZodIssueCode.custom,
@@ -26929,10 +27006,18 @@ var ExportBytesSchema = object({
26929
27006
  });
26930
27007
  method(object({
26931
27008
  deviceId: number(),
26932
- profile: string(),
27009
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
27010
+ profile: string().optional(),
27011
+ profiles: array(string()).min(1).optional(),
26933
27012
  fromMs: number(),
26934
27013
  toMs: number(),
26935
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
+ });
26936
27021
  }), ExportRecordSchema, {
26937
27022
  kind: "mutation",
26938
27023
  auth: "protected"
@@ -30087,15 +30172,6 @@ var BaseDeviceProvider = class extends BaseAddon {
30087
30172
  labels: ["probe not implemented"]
30088
30173
  };
30089
30174
  }
30090
- /**
30091
- * Top-level devices restored at once in {@link onRestoreDevices}.
30092
- *
30093
- * Four covers the fleets this ships to without turning a boot into a burst a
30094
- * camera NVR answers with a refusal. A provider whose upstream is a single
30095
- * session with a serial command channel (a Baichuan hub, an NVR that
30096
- * serialises ISAPI) should lower it; nothing needs to raise it.
30097
- */
30098
- restoreConcurrency = 4;
30099
30175
  async restoreDevices(savedDevices) {
30100
30176
  await this.onRestoreDevices(savedDevices);
30101
30177
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -30150,14 +30226,7 @@ var BaseDeviceProvider = class extends BaseAddon {
30150
30226
  });
30151
30227
  }
30152
30228
  };
30153
- let nextTopLevel = 0;
30154
- await Promise.all(Array.from({ length: Math.min(Math.max(1, this.restoreConcurrency), topLevel.length) }, async () => {
30155
- for (;;) {
30156
- const saved = topLevel[nextTopLevel++];
30157
- if (saved === void 0) return;
30158
- await restoreOne(saved);
30159
- }
30160
- }));
30229
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
30161
30230
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
30162
30231
  for (const saved of childRows) {
30163
30232
  const Class = this.deviceClasses[saved.type];
@@ -33561,6 +33630,12 @@ Object.freeze({
33561
33630
  addonId: null,
33562
33631
  access: "create"
33563
33632
  },
33633
+ "pipelineAnalytics.reconcileFromDisk": {
33634
+ capName: "pipeline-analytics",
33635
+ capScope: "device",
33636
+ addonId: null,
33637
+ access: "create"
33638
+ },
33564
33639
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
33565
33640
  capName: "pipeline-analytics",
33566
33641
  capScope: "device",
@@ -33963,6 +34038,12 @@ Object.freeze({
33963
34038
  addonId: null,
33964
34039
  access: "view"
33965
34040
  },
34041
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
34042
+ capName: "pipeline-orchestrator",
34043
+ capScope: "system",
34044
+ addonId: null,
34045
+ access: "view"
34046
+ },
33966
34047
  "pipelineOrchestrator.listAgentSettings": {
33967
34048
  capName: "pipeline-orchestrator",
33968
34049
  capScope: "system",
@@ -33987,6 +34068,12 @@ Object.freeze({
33987
34068
  addonId: null,
33988
34069
  access: "create"
33989
34070
  },
34071
+ "pipelineOrchestrator.reconcileFromDisk": {
34072
+ capName: "pipeline-orchestrator",
34073
+ capScope: "system",
34074
+ addonId: null,
34075
+ access: "create"
34076
+ },
33990
34077
  "pipelineOrchestrator.removeAgentSettings": {
33991
34078
  capName: "pipeline-orchestrator",
33992
34079
  capScope: "system",
@@ -36978,6 +37065,11 @@ Object.freeze({
36978
37065
  form: "single",
36979
37066
  optional: true
36980
37067
  }],
37068
+ "pipelineAnalytics.reconcileFromDisk": [{
37069
+ name: "deviceId",
37070
+ form: "single",
37071
+ optional: false
37072
+ }],
36981
37073
  "pipelineAnalytics.restageRetrainTrack": [{
36982
37074
  name: "deviceId",
36983
37075
  form: "single",
@@ -40698,7 +40790,7 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40698
40790
  for (const block of parts.blocks) {
40699
40791
  const parsed = parseMultipartBlock(block);
40700
40792
  if (!parsed) continue;
40701
- 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)) {
40702
40794
  const ev = parseAlertXml(parsed.bodyText);
40703
40795
  if (ev) handlers.onEvent(ev);
40704
40796
  }
@@ -40730,14 +40822,24 @@ function splitOnBoundary(buf, dashBoundary) {
40730
40822
  tail
40731
40823
  };
40732
40824
  }
40825
+ function looksLikeAlertXml(text) {
40826
+ return text.includes("<eventType>") || text.includes("EventNotificationAlert");
40827
+ }
40733
40828
  function parseMultipartBlock(block) {
40734
- let start = 0;
40735
- while (start < block.length && (block[start] === 13 || block[start] === 10)) start++;
40736
- const headerEnd = findHeaderEnd(block, start);
40737
- if (headerEnd < 0) return null;
40738
- const headerBlob = Buffer.from(block.subarray(start, headerEnd)).toString("utf8");
40739
- const bodyStart = headerEnd + 4;
40740
- 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
+ }
40741
40843
  const headers = {};
40742
40844
  for (const line of headerBlob.split(/\r?\n/)) {
40743
40845
  const idx = line.indexOf(":");
@@ -40753,6 +40855,28 @@ function findHeaderEnd(buf, from) {
40753
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;
40754
40856
  return -1;
40755
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
+ }
40756
40880
  function parseAlertXml(xml) {
40757
40881
  const type = extractTag(xml, "eventType");
40758
40882
  if (!type) return null;
@@ -44413,7 +44537,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44413
44537
  handleAlarmEvent(ev) {
44414
44538
  const camStreamId = this.resolveAlarmCamStreamId(ev.channelId);
44415
44539
  const isMotion = ev.type === "vmd" || ev.type === "motiondetection";
44416
- 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);
44417
44541
  if (isMotion) {
44418
44542
  this.handleMotionEvent(ev, camStreamId);
44419
44543
  return;
@@ -44423,7 +44547,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44423
44547
  this.handleMotionEvent(ev, camStreamId);
44424
44548
  return;
44425
44549
  }
44426
- 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", {
44427
44561
  tags: { deviceId: this.id },
44428
44562
  meta: {
44429
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.25",
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",