@camstack/addon-provider-hikvision 1.2.25 → 1.2.28

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/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-XfKNtfCc.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
  /**
@@ -44,6 +45,22 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
44
45
  EventCategory["AddonInstalled"] = "addon.installed";
45
46
  EventCategory["AddonUninstalled"] = "addon.uninstalled";
46
47
  EventCategory["AddonCrashed"] = "addon.crashed";
48
+ /**
49
+ * A RUNNER the D6 crash circuit-breaker gave up on — terminal.
50
+ *
51
+ * `AddonCrashed` is the routine, self-healing fact ("it crashed; it is being
52
+ * respawned"); this is the one that never resolves itself. Emitted exactly
53
+ * once per trip, by `process-service.ts`, carrying the node, the runner, the
54
+ * addons it hosted and the crash count that tripped the breaker.
55
+ *
56
+ * It exists because a runner marked terminally `failed` used to be SILENT:
57
+ * on 2026-08-18 the `recorder` runner died of three unhandled ffmpeg spawn
58
+ * errors, the breaker stopped respawning it (correctly), `/health` kept
59
+ * returning 200, and fleet-wide recording was gone for 3h15 before the
60
+ * operator's phone told him. The Notification Center's system-event intake
61
+ * consumes this category and maps it to the `addon-crash-loop` kind.
62
+ */
63
+ EventCategory["AddonRunnerFailed"] = "addon.runner-failed";
47
64
  EventCategory["AddonError"] = "addon.error";
48
65
  EventCategory["AddonPageReady"] = "addon.page-ready";
49
66
  EventCategory["AddonWidgetReady"] = "addon.widget-ready";
@@ -7543,6 +7560,10 @@ var RecordingBandSchema = object({
7543
7560
  preBufferSec: number().min(0).optional(),
7544
7561
  postBufferSec: number().min(0).optional()
7545
7562
  });
7563
+ ({
7564
+ preBufferSec: 10,
7565
+ postBufferSec: 30
7566
+ }).postBufferSec * 1e3;
7546
7567
  /**
7547
7568
  * Per-device retention overrides. Every field is optional; an unset or `0`
7548
7569
  * value inherits the node-wide recorder default. Only footage-lifetime limits
@@ -7590,6 +7611,12 @@ var RecordingConfigSchema = object({
7590
7611
  /** DERIVED summary of `bands`, stamped by the recorder on every save.
7591
7612
  * Authoring it has no effect — see {@link RecordingStorageModeSchema}. */
7592
7613
  mode: RecordingStorageModeSchema.optional(),
7614
+ /**
7615
+ * Which assigned broker slots to record. Absent / empty = {@link
7616
+ * DEFAULT_RECORDING_PROFILES} (`high`+`low`) intersected with the
7617
+ * camera's currently assigned slots — never `mid` unless the operator
7618
+ * picks it, and never a slot the broker has not assigned.
7619
+ */
7593
7620
  profiles: array(CamProfileSchema).optional(),
7594
7621
  segmentSeconds: number().int().positive().optional(),
7595
7622
  /**
@@ -7670,7 +7697,11 @@ var RelocateFootageInputSchema = object({
7670
7697
  profiles: array(string()).optional(),
7671
7698
  /** Copy throttle in MB/s (default 40) — the drain is a background chore,
7672
7699
  * never allowed to starve live writers. */
7673
- throttleMbps: number().min(1).max(1e3).optional()
7700
+ throttleMbps: number().min(1).max(1e3).optional(),
7701
+ /** Move only segments whose startMs is >= this. Absent = the whole source
7702
+ * pile. Used when a full drain is too expensive and the operator only
7703
+ * wants the recent window on the new disk. */
7704
+ sinceMs: number().int().optional()
7674
7705
  });
7675
7706
  /** Internal, lease-scoped participant operation. It is intentionally separate
7676
7707
  * from persistent recording settings: a migration never changes
@@ -14722,6 +14753,7 @@ var NcSystemEventKindSchema = _enum([
14722
14753
  "node-offline",
14723
14754
  "node-inference-unavailable",
14724
14755
  "detection-blind",
14756
+ "addon-crash-loop",
14725
14757
  "addon-update-available",
14726
14758
  "server-update-available",
14727
14759
  "alarm-triggered",
@@ -14729,6 +14761,9 @@ var NcSystemEventKindSchema = _enum([
14729
14761
  "alarm-disarmed",
14730
14762
  "alarm-arming",
14731
14763
  "alarm-arm-refused",
14764
+ "addon-updated",
14765
+ "server-updated",
14766
+ "export-completed",
14732
14767
  "camera-online",
14733
14768
  "camera-offline",
14734
14769
  "camera-disabled",
@@ -16622,13 +16657,19 @@ var RetrainStatusSchema = _enum([
16622
16657
  * "never marked" from "already trained" must read `retrainStatus`.
16623
16658
  *
16624
16659
  * `debug` does NOT pin; it is attention, not durability.
16660
+ *
16661
+ * `favourited` IS a BOOLEAN pin (durability bit), not a retrain lifecycle.
16662
+ * A favourited track is skipped by retention the same way `staging` is, but
16663
+ * it does not enter `none|staging|trained` and has no staging budget.
16625
16664
  */
16626
16665
  var TrackFlagFields = {
16627
16666
  /** Operator marked this track as training material — i.e. `retrainStatus` is
16628
16667
  * `'staging'`. */
16629
16668
  markForTrain: boolean().optional(),
16630
16669
  /** Operator marked this track for diagnostic attention. */
16631
- debug: boolean().optional()
16670
+ debug: boolean().optional(),
16671
+ /** Operator favourited this track. Pins it against pruning. */
16672
+ favourited: boolean().optional()
16632
16673
  };
16633
16674
  /**
16634
16675
  * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
@@ -16653,6 +16694,7 @@ var TrackFlagsSchema = object({
16653
16694
  trackId: string(),
16654
16695
  markForTrain: boolean(),
16655
16696
  debug: boolean(),
16697
+ favourited: boolean(),
16656
16698
  /** The lifecycle state the boolean was derived from. Required here (unlike on
16657
16699
  * a track row) because this shape is only ever produced by the write body,
16658
16700
  * which always knows it — and a surface that has just written needs to render
@@ -17285,6 +17327,12 @@ var TrackCascadeCountsSchema = object({
17285
17327
  /** Per-track CLIP search vectors removed (best-effort). */
17286
17328
  embeddings: number().int()
17287
17329
  });
17330
+ /** Disk-wins reconcile: media rows whose blobs are gone, then empty tracks. */
17331
+ var DiskReconcileCountsSchema = object({
17332
+ mediaDropped: number().int(),
17333
+ tracks: number().int(),
17334
+ events: number().int()
17335
+ });
17288
17336
  /** Event-store footprint for one camera. */
17289
17337
  var EventStoreDeviceFootprintSchema = object({
17290
17338
  deviceId: number(),
@@ -17461,6 +17509,9 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
17461
17509
  }), method(object({ deviceId: number() }), TrackCascadeCountsSchema, {
17462
17510
  kind: "mutation",
17463
17511
  auth: "admin"
17512
+ }), method(object({ deviceId: number() }), DiskReconcileCountsSchema, {
17513
+ kind: "mutation",
17514
+ auth: "admin"
17464
17515
  }), method(object({
17465
17516
  deviceId: number(),
17466
17517
  trackIds: array(string()).min(1)
@@ -19011,6 +19062,24 @@ var CameraStatusDegradationSchema = object({
19011
19062
  *
19012
19063
  * See spec: `docs/superpowers/specs/2026-06-24-camera-status-aggregator-cap.md`
19013
19064
  */
19065
+ var DiskReconcileJobSchema = object({
19066
+ state: _enum([
19067
+ "idle",
19068
+ "running",
19069
+ "done",
19070
+ "error"
19071
+ ]),
19072
+ total: number().int().nonnegative(),
19073
+ completed: number().int().nonnegative(),
19074
+ currentDeviceId: number().int().nullable(),
19075
+ failed: array(number().int()).readonly(),
19076
+ mediaDropped: number().int().nonnegative(),
19077
+ tracks: number().int().nonnegative(),
19078
+ events: number().int().nonnegative(),
19079
+ startedAtMs: number().int().nullable(),
19080
+ finishedAtMs: number().int().nullable(),
19081
+ error: string().nullable()
19082
+ });
19014
19083
  var CameraStatusSchema = object({
19015
19084
  deviceId: number(),
19016
19085
  assignment: CameraAssignmentStatusSchema,
@@ -19242,7 +19311,10 @@ method(StorageMigrationLeaseInputSchema, object({ paused: literal(true) }), {
19242
19311
  }), CameraSwitchGroupSchema, {
19243
19312
  kind: "mutation",
19244
19313
  auth: "admin"
19245
- }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19314
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), DiskReconcileJobSchema, {
19315
+ kind: "mutation",
19316
+ auth: "admin"
19317
+ }), method(_void(), DiskReconcileJobSchema, { auth: "admin" }), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
19246
19318
  name: string(),
19247
19319
  description: string().optional(),
19248
19320
  config: CameraPipelineConfigSchema
@@ -20419,20 +20491,6 @@ var VectorStatsResultSchema = object({
20419
20491
  exact: boolean()
20420
20492
  });
20421
20493
  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);
20422
- /**
20423
- * `videoclips` — the unified, navigable-clip surface for a camera.
20424
- *
20425
- * A device-scoped WRAPPER cap (like `pipeline-analytics`): exactly one active
20426
- * provider per device, substitutable. The DEFAULT provider (registered by
20427
- * `addon-post-analysis`, `defaultActive: true`) composes the analytics event
20428
- * log (markers + thumbnails) with the recorder's `getPlaybackManifest` — a clip
20429
- * is a time-WINDOW over existing footage, never a separate file. A camera that
20430
- * exposes NATIVE onboard clips (Reolink/Hikvision NVR) can later substitute the
20431
- * wrapper provider for its device and serve its own clip catalog + URLs.
20432
- *
20433
- * A `Clip` is purely time-based (subtree-blind): playback resolves segments by
20434
- * temporal overlap, so the API never decides `continuous` vs `events`.
20435
- */
20436
20494
  var ClipSchema = object({
20437
20495
  /** Opaque, provider-namespaced id. The default provider encodes the time
20438
20496
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -20449,8 +20507,57 @@ var ClipSchema = object({
20449
20507
  startMs: number(),
20450
20508
  endMs: number()
20451
20509
  }),
20452
- /** Thumbnail URL (lazy; e.g. analytics `getEventMedia`). Never inlined. */
20453
- thumbnail: string().optional()
20510
+ /**
20511
+ * Lazy thumbnail URL, never inlined.
20512
+ *
20513
+ * Recording-derived clips (events-mode keep-window, and the prepared
20514
+ * continuous event+fragment visit) MUST use the snapshot of the **main
20515
+ * event of the interval** — `getEventMedia({ eventId, kind: 'snapshot' })`
20516
+ * of the event that owns `kind` (object > motion > audio). Do not extract
20517
+ * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20518
+ * mint their own stills.
20519
+ *
20520
+ * **VOUCHED, never fabricated.** A provider emits this only for an event it
20521
+ * has CONFIRMED owns at least one media row (its own, or its owning track's).
20522
+ * Absent is meaningful — "this visit has no event still" — never "we did not
20523
+ * look". Stamping it from a URL template made 35% of one camera's clips point
20524
+ * at a 404 (device 3836, 2026-08-17: 64 of 179 clips dead, 63 of them motion).
20525
+ * A read that FAILS drops the claim; it never invents it.
20526
+ */
20527
+ thumbnail: string().optional(),
20528
+ /**
20529
+ * An instant INSIDE this visit's footage, hole-safe, where a recorded still
20530
+ * can be decoded. Present whenever the visit came from recorded availability;
20531
+ * absent on a per-event padded window (there is no footage to promise).
20532
+ *
20533
+ * This is not a thumbnail and not a second byte path: it is the argument to
20534
+ * the recorder's existing still route. The surface — never the provider —
20535
+ * decides whether to use it. It is the midpoint of the visit's LONGEST
20536
+ * contiguous range, not of the visit: a visit spans its holes by
20537
+ * construction, so a naive midpoint lands in dead air.
20538
+ */
20539
+ stillAtMs: number().optional(),
20540
+ /**
20541
+ * Analytics event ids that overlap this visit — a BOUNDED sample, newest
20542
+ * first within kind (object → motion → audio), capped at
20543
+ * {@link MAX_CLIP_EVENT_IDS}. Empty on footage-only clips.
20544
+ *
20545
+ * Bounded because it is not a payload the surface pages through: one visit on
20546
+ * device 615 carried 2 345 ids, and `eventIds` was 99% of a 153 KB
20547
+ * camera-day. Read {@link eventCount} for the true total.
20548
+ */
20549
+ eventIds: array(string()).optional(),
20550
+ /** How many analytics events actually overlap this visit. Differs from
20551
+ * `eventIds.length` exactly when the sample was capped — so a truncated
20552
+ * list is never mistaken for a quiet visit. */
20553
+ eventCount: number().int().nonnegative().optional(),
20554
+ /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20555
+ * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20556
+ * bar keeps showing them via `recording.getAvailability`. */
20557
+ holes: array(object({
20558
+ startMs: number(),
20559
+ endMs: number()
20560
+ })).optional()
20454
20561
  });
20455
20562
  var ClipPlaybackSchema = object({
20456
20563
  /** HLS master URL through the hub data-plane (Range + token in path). */
@@ -20914,7 +21021,14 @@ var SearchResultSchema = object({
20914
21021
  });
20915
21022
  var AutoUpdateSettingsSchema = object({
20916
21023
  channel: ChannelSchema,
20917
- intervalSeconds: number()
21024
+ intervalSeconds: number(),
21025
+ /**
21026
+ * Cadence of the "an update exists" POLLER, in seconds. Independent of
21027
+ * `channel`: the poller runs while auto-apply is `off`, because being told
21028
+ * about a publish and installing it are different decisions. Clamped
21029
+ * server-side to 900 s … 604800 s; defaults to 21600 s (6 h).
21030
+ */
21031
+ updateCheckIntervalSeconds: number()
20918
21032
  });
20919
21033
  var AddonAutoUpdateSchema = ChannelWithInheritSchema;
20920
21034
  var RestartAddonResultSchema = unknown();
@@ -21055,7 +21169,9 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
21055
21169
  auth: "admin"
21056
21170
  }), method(_void(), AutoUpdateSettingsSchema, { auth: "admin" }), method(object({
21057
21171
  channel: ChannelSchema,
21058
- intervalSeconds: number().min(300).max(86400).optional()
21172
+ intervalSeconds: number().min(300).max(86400).optional(),
21173
+ /** Availability-poll cadence; see AutoUpdateSettingsSchema. */
21174
+ updateCheckIntervalSeconds: number().min(900).max(604800).optional()
21059
21175
  }), unknown(), {
21060
21176
  kind: "mutation",
21061
21177
  auth: "admin"
@@ -26869,7 +26985,9 @@ var ExportOptionsSchema = object({
26869
26985
  includeAudio: boolean(),
26870
26986
  maxLifeMs: number().int().positive(),
26871
26987
  deleteAfterDownload: boolean(),
26872
- title: string().max(200).optional()
26988
+ title: string().max(200).optional(),
26989
+ /** Notification-output target ids to ping when this export becomes ready. */
26990
+ notifyTargetIds: array(string().min(1)).max(20).optional()
26873
26991
  }).superRefine((v, ctx) => {
26874
26992
  if (v.speed !== void 0 && v.timelapse !== void 0) ctx.addIssue({
26875
26993
  code: ZodIssueCode.custom,
@@ -26928,10 +27046,18 @@ var ExportBytesSchema = object({
26928
27046
  });
26929
27047
  method(object({
26930
27048
  deviceId: number(),
26931
- profile: string(),
27049
+ /** @deprecated Prefer `profiles`. Kept so timelapse/notifiers keep working. */
27050
+ profile: string().optional(),
27051
+ profiles: array(string()).min(1).optional(),
26932
27052
  fromMs: number(),
26933
27053
  toMs: number(),
26934
27054
  options: ExportOptionsSchema
27055
+ }).superRefine((v, ctx) => {
27056
+ if ((v.profiles !== void 0 && v.profiles.length > 0 ? v.profiles : v.profile !== void 0 ? [v.profile] : []).length < 1) ctx.addIssue({
27057
+ code: ZodIssueCode.custom,
27058
+ message: "pass profiles[] (min 1) or legacy profile",
27059
+ path: ["profiles"]
27060
+ });
26935
27061
  }), ExportRecordSchema, {
26936
27062
  kind: "mutation",
26937
27063
  auth: "protected"
@@ -30086,15 +30212,6 @@ var BaseDeviceProvider = class extends BaseAddon {
30086
30212
  labels: ["probe not implemented"]
30087
30213
  };
30088
30214
  }
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
30215
  async restoreDevices(savedDevices) {
30099
30216
  await this.onRestoreDevices(savedDevices);
30100
30217
  if (savedDevices.length > 0) this.ctx.logger.info(`Restored ${savedDevices.length} ${this.providerName} device(s)`);
@@ -30149,14 +30266,7 @@ var BaseDeviceProvider = class extends BaseAddon {
30149
30266
  });
30150
30267
  }
30151
30268
  };
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
- }));
30269
+ await Promise.all(topLevel.map((saved) => restoreOne(saved)));
30160
30270
  const childRows = savedDevices.filter((s) => s.parentDeviceId !== null);
30161
30271
  for (const saved of childRows) {
30162
30272
  const Class = this.deviceClasses[saved.type];
@@ -33560,6 +33670,12 @@ Object.freeze({
33560
33670
  addonId: null,
33561
33671
  access: "create"
33562
33672
  },
33673
+ "pipelineAnalytics.reconcileFromDisk": {
33674
+ capName: "pipeline-analytics",
33675
+ capScope: "device",
33676
+ addonId: null,
33677
+ access: "create"
33678
+ },
33563
33679
  "pipelineAnalytics.refreshStorageLocationsForMigration": {
33564
33680
  capName: "pipeline-analytics",
33565
33681
  capScope: "device",
@@ -33962,6 +34078,12 @@ Object.freeze({
33962
34078
  addonId: null,
33963
34079
  access: "view"
33964
34080
  },
34081
+ "pipelineOrchestrator.getReconcileFromDiskStatus": {
34082
+ capName: "pipeline-orchestrator",
34083
+ capScope: "system",
34084
+ addonId: null,
34085
+ access: "view"
34086
+ },
33965
34087
  "pipelineOrchestrator.listAgentSettings": {
33966
34088
  capName: "pipeline-orchestrator",
33967
34089
  capScope: "system",
@@ -33986,6 +34108,12 @@ Object.freeze({
33986
34108
  addonId: null,
33987
34109
  access: "create"
33988
34110
  },
34111
+ "pipelineOrchestrator.reconcileFromDisk": {
34112
+ capName: "pipeline-orchestrator",
34113
+ capScope: "system",
34114
+ addonId: null,
34115
+ access: "create"
34116
+ },
33989
34117
  "pipelineOrchestrator.removeAgentSettings": {
33990
34118
  capName: "pipeline-orchestrator",
33991
34119
  capScope: "system",
@@ -36977,6 +37105,11 @@ Object.freeze({
36977
37105
  form: "single",
36978
37106
  optional: true
36979
37107
  }],
37108
+ "pipelineAnalytics.reconcileFromDisk": [{
37109
+ name: "deviceId",
37110
+ form: "single",
37111
+ optional: false
37112
+ }],
36980
37113
  "pipelineAnalytics.restageRetrainTrack": [{
36981
37114
  name: "deviceId",
36982
37115
  form: "single",
@@ -38042,6 +38175,8 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
38042
38175
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
38043
38176
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
38044
38177
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
38178
+ var MB = 1024 * 1024;
38179
+ 1024 * MB, 3072 * MB;
38045
38180
  //#endregion
38046
38181
  //#region src/accessories/base.ts
38047
38182
  /**
@@ -40604,36 +40739,87 @@ function parseTwoWayAudioChannels(xml) {
40604
40739
  return out;
40605
40740
  }
40606
40741
  /**
40607
- * Idle watchdog for the alarm stream. Hikvision firmware emits a keep-alive
40608
- * heartbeat (typically a `videoloss`/`inactive` alert) roughly every ~5s even
40609
- * when nothing is happening, so a total absence of ANY bytes for this long
40610
- * means the pipe is dead either a half-open TCP connection (no FIN, no data)
40611
- * that would otherwise block `reader.read()` forever, or a stalled proxy/NAT
40612
- * conntrack entry between the hub and a camera on a different subnet. When it
40613
- * fires we cancel the reader, surfacing a clean stream-end that the reconnect
40614
- * path treats as a recoverable disconnect.
40615
- */
40616
- var ALARM_STREAM_IDLE_TIMEOUT_MS = 3e4;
40617
- /**
40618
- * Subscribe to the camera's alarm stream. Returns an `AbortController`
40619
- * `controller.abort()` tears the subscription down. Reconnect logic
40620
- * is the caller's responsibility (we keep the parser simple and let
40621
- * the device class own the lifecycle / backoff timing).
40622
- *
40623
- * Every terminal outcome that is NOT a deliberate `controller.abort()` is
40624
- * reported through `onError` exactly once, so the caller's reconnect logic
40625
- * always fires:
40626
- * - HTTP / boundary failure at subscribe time,
40627
- * - a thrown error while pumping,
40628
- * - AND a clean stream-end (`done`). Hikvision cameras recycle the
40629
- * alertStream HTTP connection periodically (firmware keep-alive limits,
40630
- * internal event-subsystem restarts, or an idle NAT/proxy hop closing the
40631
- * socket). A clean close used to fall through silently — `onError` never
40632
- * fired, the caller's `alarmController` stayed non-null, and the device
40633
- * never resubscribed so motion stopped permanently until the provider
40634
- * restarted. Treating clean-end as a recoverable disconnect closes that gap.
40635
- */
40636
- function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
40742
+ * Why the parser adopts the boundary it SEES rather than the one declared.
40743
+ *
40744
+ * Five cameras on `C-R-*` firmware (I91DN/I91DD V5.7.1, I91EV V5.8.10) have
40745
+ * never delivered one motion event here, while the same cameras deliver real
40746
+ * hardware VMD on the same endpoint to another NVR stack. The one dimension
40747
+ * where that stack is more permissive is the multipart boundary: it accepts a
40748
+ * part framed with the declared `--<boundary>` OR with the literal constant
40749
+ * `--boundary`, because "older hikvision send a boundary in the headers, but
40750
+ * then use a totally different constant boundary value".
40751
+ *
40752
+ * We take the general form instead of that special case: if the DECLARED
40753
+ * token has never once matched, adopt the token from the first `--…` line
40754
+ * actually on the wire and use it from then on. It subsumes the literal
40755
+ * `--boundary` case (that IS the observed token on those cameras) and it also
40756
+ * covers the next firmware that invents a third constant, which a hard-coded
40757
+ * fallback would not. The risk is bounded on both sides: adoption is only ever
40758
+ * attempted while the declared token has produced ZERO parts, the candidate
40759
+ * must occupy a whole line of RFC 2046 boundary characters, and the closing
40760
+ * delimiter (`--<token>--`) is rejected.
40761
+ */
40762
+ function detectWireBoundary(buf) {
40763
+ const text = Buffer.from(buf).toString("binary");
40764
+ const lineRe = /(?:^|\r?\n)--([0-9A-Za-z'()+_,./:=?-]{1,70})\r?\n/g;
40765
+ for (;;) {
40766
+ const m = lineRe.exec(text);
40767
+ if (!m) return null;
40768
+ const token = m[1];
40769
+ if (token.endsWith("--")) continue;
40770
+ return token;
40771
+ }
40772
+ }
40773
+ /**
40774
+ * First occurrence of each kind is logged immediately; further occurrences are
40775
+ * counted and folded into the next line after `intervalMs`. A stream that
40776
+ * drops every part therefore costs one line per minute per kind, not one per
40777
+ * chunk.
40778
+ */
40779
+ function createAlarmDiagnostics(logger, deviceId, intervalMs) {
40780
+ const lastLoggedAt = /* @__PURE__ */ new Map();
40781
+ const suppressed = /* @__PURE__ */ new Map();
40782
+ const report = (kind, message, meta, level = "warn") => {
40783
+ if (!logger) return;
40784
+ const now = Date.now();
40785
+ const last = lastLoggedAt.get(kind);
40786
+ if (last !== void 0 && now - last < intervalMs) {
40787
+ suppressed.set(kind, (suppressed.get(kind) ?? 0) + 1);
40788
+ return;
40789
+ }
40790
+ const since = suppressed.get(kind) ?? 0;
40791
+ suppressed.set(kind, 0);
40792
+ lastLoggedAt.set(kind, now);
40793
+ const extras = {
40794
+ ...deviceId === null ? {} : { tags: { deviceId } },
40795
+ meta: {
40796
+ kind,
40797
+ ...meta,
40798
+ ...since > 0 ? { suppressedSincePrevious: since } : {}
40799
+ }
40800
+ };
40801
+ if (level === "debug") logger.debug(message, extras);
40802
+ else logger.warn(message, extras);
40803
+ };
40804
+ return { report };
40805
+ }
40806
+ /** First line of a buffer, clipped — the single most useful field when asking
40807
+ * "what is this camera actually sending?". */
40808
+ function firstLineOf(buf, maxChars = 120) {
40809
+ const line = Buffer.from(buf.subarray(0, 1024)).toString("utf8").split(/\r?\n/, 1)[0] ?? "";
40810
+ return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line;
40811
+ }
40812
+ /** First whole line that starts with `--` — i.e. the framing the camera really
40813
+ * uses. `null` when the buffer contains no such line. */
40814
+ function firstDashLineOf(buf, maxChars = 120) {
40815
+ const text = Buffer.from(buf.subarray(0, 4096)).toString("utf8");
40816
+ for (const line of text.split(/\r?\n/)) {
40817
+ if (!line.startsWith("--")) continue;
40818
+ return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line;
40819
+ }
40820
+ return null;
40821
+ }
40822
+ function subscribeAlarms(client, handlers, options = {}) {
40637
40823
  const controller = new AbortController();
40638
40824
  let settled = false;
40639
40825
  const fail = (err) => {
@@ -40660,7 +40846,7 @@ function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIM
40660
40846
  return;
40661
40847
  }
40662
40848
  handlers.onConnected?.();
40663
- await pumpAlarmStream(res.body, boundary, handlers, idleTimeoutMs);
40849
+ await pumpAlarmStream(res.body, boundary, handlers, options);
40664
40850
  fail(/* @__PURE__ */ new Error("alarm stream ended"));
40665
40851
  } catch (err) {
40666
40852
  fail(err);
@@ -40672,10 +40858,65 @@ function parseBoundary(contentType) {
40672
40858
  const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
40673
40859
  return m ? m[1].trim() : null;
40674
40860
  }
40675
- async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
40861
+ async function pumpAlarmStream(stream, declaredBoundary, handlers, options) {
40862
+ const idleTimeoutMs = options.idleTimeoutMs ?? 3e4;
40863
+ const maxBufferBytes = options.maxBufferBytes ?? 1048576;
40864
+ const retainBytes = options.retainBytes ?? 65536;
40865
+ const unparsedWarnBytes = options.unparsedWarnBytes ?? 32768;
40866
+ const diagnostics = createAlarmDiagnostics(options.logger ?? null, options.deviceId ?? null, options.diagnosticIntervalMs ?? 6e4);
40676
40867
  const reader = stream.getReader();
40677
- const dashBoundary = `--${boundary}`;
40868
+ let activeBoundary = declaredBoundary;
40869
+ let activeDashBoundary = `--${activeBoundary}`;
40870
+ /** Has the ACTIVE token ever framed anything? Gates boundary adoption. */
40871
+ let matchedAnyBoundary = false;
40872
+ /**
40873
+ * Is the fragment that precedes the next boundary junk?
40874
+ *
40875
+ * True only at the head of the stream (RFC 1521 preamble), after we already
40876
+ * emitted the bytes sitting in the buffer, and after the overflow cap cut
40877
+ * the buffer mid-part. It used to be true unconditionally, which meant every
40878
+ * split after the first DISCARDED a completed part — on a stream that
40879
+ * delivers one part per TCP read, that is every event the camera ever sent.
40880
+ */
40881
+ let dropLeadingFragment = true;
40678
40882
  let bufferedBytes = new Uint8Array(0);
40883
+ const dispatchBlock = (block) => {
40884
+ const parsed = parseMultipartBlock(block);
40885
+ if (!parsed.ok) {
40886
+ diagnostics.report("part-unparseable", "hikvision alarm stream: multipart part dropped", {
40887
+ reason: parsed.reason,
40888
+ bytes: block.byteLength,
40889
+ firstLine: firstLineOf(block)
40890
+ });
40891
+ return;
40892
+ }
40893
+ const { contentType, bodyText } = parsed.block;
40894
+ if (!(contentType.startsWith("application/xml") || contentType.startsWith("text/xml") || looksLikeAlertXml(bodyText))) {
40895
+ if (contentType.startsWith("image/")) {
40896
+ diagnostics.report("part-image-ignored", "hikvision alarm stream: image part ignored (not forwarded yet)", {
40897
+ contentType,
40898
+ bytes: block.byteLength
40899
+ }, "debug");
40900
+ return;
40901
+ }
40902
+ diagnostics.report("part-not-alert-xml", "hikvision alarm stream: part dropped — neither an xml Content-Type nor alert-shaped XML", {
40903
+ contentType,
40904
+ bytes: block.byteLength,
40905
+ bodyPreview: bodyText.slice(0, 120)
40906
+ });
40907
+ return;
40908
+ }
40909
+ const ev = parseAlertXml(bodyText);
40910
+ if (!ev) {
40911
+ diagnostics.report("alert-xml-without-event-type", "hikvision alarm stream: alert part dropped — no <eventType> tag", {
40912
+ contentType,
40913
+ bytes: block.byteLength,
40914
+ bodyPreview: bodyText.slice(0, 120)
40915
+ });
40916
+ return;
40917
+ }
40918
+ handlers.onEvent(ev);
40919
+ };
40679
40920
  let idleTimer = null;
40680
40921
  const armIdle = () => {
40681
40922
  if (idleTimeoutMs <= 0) return;
@@ -40692,16 +40933,57 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40692
40933
  armIdle();
40693
40934
  if (!value || value.byteLength === 0) continue;
40694
40935
  bufferedBytes = concat(bufferedBytes, value);
40695
- const parts = splitOnBoundary(bufferedBytes, dashBoundary);
40696
- bufferedBytes = parts.tail;
40697
- for (const block of parts.blocks) {
40698
- const parsed = parseMultipartBlock(block);
40699
- if (!parsed) continue;
40700
- if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml")) {
40701
- const ev = parseAlertXml(parsed.bodyText);
40702
- if (ev) handlers.onEvent(ev);
40936
+ let split = splitOnBoundary(bufferedBytes, activeDashBoundary, dropLeadingFragment);
40937
+ if (!split.sawBoundary && !matchedAnyBoundary) {
40938
+ const observed = detectWireBoundary(bufferedBytes);
40939
+ if (observed !== null && observed !== activeBoundary) {
40940
+ diagnostics.report("boundary-adopted", "hikvision alarm stream: declared multipart boundary never matched — adopting the boundary seen on the wire", {
40941
+ declaredBoundary,
40942
+ observedBoundary: observed,
40943
+ bufferedBytes: bufferedBytes.byteLength,
40944
+ firstDashLine: firstDashLineOf(bufferedBytes),
40945
+ firstLine: firstLineOf(bufferedBytes)
40946
+ });
40947
+ activeBoundary = observed;
40948
+ activeDashBoundary = `--${observed}`;
40949
+ split = splitOnBoundary(bufferedBytes, activeDashBoundary, dropLeadingFragment);
40950
+ }
40951
+ }
40952
+ if (split.sawBoundary) {
40953
+ matchedAnyBoundary = true;
40954
+ dropLeadingFragment = false;
40955
+ }
40956
+ bufferedBytes = split.tail;
40957
+ for (const block of split.blocks) dispatchBlock(block);
40958
+ if (!dropLeadingFragment) {
40959
+ const early = takeCompletePart(bufferedBytes);
40960
+ if (early) {
40961
+ dispatchBlock(early.block);
40962
+ bufferedBytes = early.rest;
40963
+ dropLeadingFragment = true;
40703
40964
  }
40704
40965
  }
40966
+ if (!matchedAnyBoundary && bufferedBytes.byteLength >= unparsedWarnBytes) diagnostics.report("boundary-never-matched", "hikvision alarm stream: no multipart boundary found in the buffered bytes — every part is being dropped", {
40967
+ declaredBoundary,
40968
+ activeBoundary,
40969
+ bufferedBytes: bufferedBytes.byteLength,
40970
+ firstDashLine: firstDashLineOf(bufferedBytes),
40971
+ firstLine: firstLineOf(bufferedBytes)
40972
+ });
40973
+ if (bufferedBytes.byteLength > maxBufferBytes) {
40974
+ diagnostics.report("buffer-overflow", "hikvision alarm stream: unparsed multipart buffer hit its cap — discarding the head", {
40975
+ declaredBoundary,
40976
+ activeBoundary,
40977
+ matchedAnyBoundary,
40978
+ bufferedBytes: bufferedBytes.byteLength,
40979
+ maxBufferBytes,
40980
+ retainBytes,
40981
+ firstDashLine: firstDashLineOf(bufferedBytes),
40982
+ firstLine: firstLineOf(bufferedBytes)
40983
+ });
40984
+ bufferedBytes = bufferedBytes.slice(bufferedBytes.byteLength - retainBytes);
40985
+ dropLeadingFragment = true;
40986
+ }
40705
40987
  }
40706
40988
  } finally {
40707
40989
  if (idleTimer) clearTimeout(idleTimer);
@@ -40716,27 +40998,68 @@ function concat(a, b) {
40716
40998
  out.set(b, a.byteLength);
40717
40999
  return out;
40718
41000
  }
40719
- function splitOnBoundary(buf, dashBoundary) {
41001
+ function splitOnBoundary(buf, dashBoundary, dropLeadingFragment) {
40720
41002
  const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
40721
41003
  if (parts.length <= 1) return {
40722
41004
  blocks: [],
40723
- tail: buf
41005
+ tail: buf,
41006
+ sawBoundary: false
40724
41007
  };
40725
41008
  const tail = Buffer.from(parts.pop(), "binary");
40726
- parts.shift();
41009
+ if (dropLeadingFragment) parts.shift();
40727
41010
  return {
40728
41011
  blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
40729
- tail
41012
+ tail,
41013
+ sawBoundary: true
40730
41014
  };
40731
41015
  }
40732
- 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);
41016
+ function parseContentLength(headerBlob) {
41017
+ const m = /^content-length\s*:\s*(\d+)\s*$/im.exec(headerBlob);
41018
+ if (!m) return null;
41019
+ const n = Number.parseInt(m[1], 10);
41020
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
41021
+ }
41022
+ /**
41023
+ * If `buf` (which must start immediately after a boundary) already holds a
41024
+ * whole part per its `Content-Length`, return it and the leftover bytes.
41025
+ * Firmware that omits `Content-Length` falls back to boundary-framed delivery.
41026
+ */
41027
+ function takeCompletePart(buf) {
41028
+ const headerEnd = findHeaderEnd(buf, 0);
40736
41029
  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);
41030
+ const contentLength = parseContentLength(Buffer.from(buf.subarray(0, headerEnd)).toString("utf8"));
41031
+ if (contentLength === null) return null;
41032
+ const end = headerEnd + 4 + contentLength;
41033
+ if (buf.byteLength < end) return null;
41034
+ return {
41035
+ block: buf.slice(0, end),
41036
+ rest: buf.slice(end)
41037
+ };
41038
+ }
41039
+ function looksLikeAlertXml(text) {
41040
+ return text.includes("<eventType>") || text.includes("EventNotificationAlert");
41041
+ }
41042
+ function parseMultipartBlock(block) {
41043
+ const headerEnd = findHeaderEnd(block, 0);
41044
+ let headerBlob = "";
41045
+ let bodySlice;
41046
+ if (headerEnd >= 0) {
41047
+ headerBlob = Buffer.from(block.subarray(0, headerEnd)).toString("utf8");
41048
+ bodySlice = block.subarray(headerEnd + 4);
41049
+ } else {
41050
+ const raw = Buffer.from(block).toString("utf8");
41051
+ if (!looksLikeAlertXml(raw)) return {
41052
+ ok: false,
41053
+ reason: "no-header-separator-and-not-alert-xml"
41054
+ };
41055
+ return {
41056
+ ok: true,
41057
+ block: {
41058
+ contentType: "",
41059
+ bodyText: raw.replace(/\r?\n--$/m, "").trim()
41060
+ }
41061
+ };
41062
+ }
40740
41063
  const headers = {};
40741
41064
  for (const line of headerBlob.split(/\r?\n/)) {
40742
41065
  const idx = line.indexOf(":");
@@ -40744,14 +41067,39 @@ function parseMultipartBlock(block) {
40744
41067
  headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
40745
41068
  }
40746
41069
  return {
40747
- contentType: headers["content-type"] ?? "",
40748
- bodyText: Buffer.from(bodySlice).toString("utf8").replace(/\r?\n--$/m, "").trim()
41070
+ ok: true,
41071
+ block: {
41072
+ contentType: headers["content-type"] ?? "",
41073
+ bodyText: Buffer.from(bodySlice).toString("utf8").replace(/\r?\n--$/m, "").trim()
41074
+ }
40749
41075
  };
40750
41076
  }
40751
41077
  function findHeaderEnd(buf, from) {
40752
41078
  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
41079
  return -1;
40754
41080
  }
41081
+ /**
41082
+ * Event types that imply onboard motion. Compared after `eventType`
41083
+ * is lowercased. Some firmwares emit `regionExit`, others
41084
+ * `regionexiting` — accept both.
41085
+ */
41086
+ function isHikvisionMotionAlarmType(type) {
41087
+ switch (type) {
41088
+ case "vmd":
41089
+ case "motiondetection":
41090
+ case "linedetection":
41091
+ case "fielddetection":
41092
+ case "regionentrance":
41093
+ case "regionexit":
41094
+ case "regionexiting":
41095
+ case "attendedbaggage":
41096
+ case "unattendedbaggage": return true;
41097
+ default: return false;
41098
+ }
41099
+ }
41100
+ function isHikvisionSmartAlarmType(type) {
41101
+ return type !== "vmd" && type !== "motiondetection" && isHikvisionMotionAlarmType(type);
41102
+ }
40755
41103
  function parseAlertXml(xml) {
40756
41104
  const type = extractTag(xml, "eventType");
40757
41105
  if (!type) return null;
@@ -44398,6 +44746,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44398
44746
  this.alarmController = null;
44399
44747
  this.scheduleAlarmReconnect();
44400
44748
  }
44749
+ }, {
44750
+ logger: this.ctx.logger,
44751
+ deviceId: this.id
44401
44752
  });
44402
44753
  }
44403
44754
  scheduleAlarmReconnect() {
@@ -44412,7 +44763,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44412
44763
  handleAlarmEvent(ev) {
44413
44764
  const camStreamId = this.resolveAlarmCamStreamId(ev.channelId);
44414
44765
  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";
44766
+ const isSmart = isHikvisionSmartAlarmType(ev.type);
44416
44767
  if (isMotion) {
44417
44768
  this.handleMotionEvent(ev, camStreamId);
44418
44769
  return;
@@ -44422,7 +44773,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44422
44773
  this.handleMotionEvent(ev, camStreamId);
44423
44774
  return;
44424
44775
  }
44425
- this.ctx.logger.debug("hikvision: unhandled alarm event", {
44776
+ if (ev.type === "videoloss") {
44777
+ this.ctx.logger.debug("hikvision: unhandled alarm event", {
44778
+ tags: { deviceId: this.id },
44779
+ meta: {
44780
+ type: ev.type,
44781
+ state: ev.state
44782
+ }
44783
+ });
44784
+ return;
44785
+ }
44786
+ this.ctx.logger.info("hikvision: unhandled alarm event", {
44426
44787
  tags: { deviceId: this.id },
44427
44788
  meta: {
44428
44789
  type: ev.type,