@camstack/addon-provider-hikvision 1.2.4 → 1.2.5

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
@@ -12991,6 +12991,12 @@ var motionCooldownMsField = {
12991
12991
  default: 3e4,
12992
12992
  step: 500
12993
12993
  };
12994
+ var maxSessionHoldMsField = {
12995
+ min: 0,
12996
+ max: 6e5,
12997
+ default: 12e4,
12998
+ step: 5e3
12999
+ };
12994
13000
  var motionFpsField = {
12995
13001
  min: 1,
12996
13002
  max: 30,
@@ -13138,6 +13144,19 @@ var RunnerCameraConfigSchema = object({
13138
13144
  "on-motion"
13139
13145
  ]).default("always-on"),
13140
13146
  motionCooldownMs: number().min(motionCooldownMsField.min).default(motionCooldownMsField.default),
13147
+ /**
13148
+ * Orchestrator-side on-motion session-hold cap (ms). While an on-motion
13149
+ * detection session is active and ≥1 confirmed non-stationary track is
13150
+ * still live, the orchestrator keeps the session open past
13151
+ * `motionCooldownMs` (a slowly-moving subject can stop re-triggering the
13152
+ * camera's VMD yet is still being tracked frame-to-frame) — up to this many
13153
+ * ms since the session opened, after which it closes regardless. `0`
13154
+ * disables the hold (legacy cooldown-only teardown). Not consumed by the
13155
+ * runner itself — carried here so it shares the per-camera device-settings
13156
+ * surface with `motionCooldownMs`; the orchestrator reads it off the
13157
+ * resolved `CameraDetectionConfig`.
13158
+ */
13159
+ maxSessionHoldMs: number().min(maxSessionHoldMsField.min).max(maxSessionHoldMsField.max).optional(),
13141
13160
  motionFps: number().min(motionFpsField.min).max(motionFpsField.max).default(motionFpsField.default),
13142
13161
  detectionFps: number().min(detectionFpsField.min).max(detectionFpsField.max).default(detectionFpsField.default),
13143
13162
  motionStreamId: string(),
@@ -13227,7 +13246,7 @@ var RunnerCameraConfigSchema = object({
13227
13246
  */
13228
13247
  inferenceDevices: array(RunnerInferenceDeviceSchema).readonly().optional()
13229
13248
  });
13230
- motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
13249
+ motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, maxSessionHoldMsField.min, maxSessionHoldMsField.max, maxSessionHoldMsField.step, maxSessionHoldMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
13231
13250
  /**
13232
13251
  * Runtime load summary returned by `getLocalLoad`. Used by the orchestrator's
13233
13252
  * load-balancing levels (L2 capacity-based, L3 hardware-aware) to decide
@@ -19996,7 +20015,10 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19996
20015
  }), method(object({
19997
20016
  eventId: string(),
19998
20017
  kind: MediaFileKindEnum.optional()
19999
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
20018
+ }), array(MediaFileSchema).readonly()), method(object({
20019
+ trackId: string(),
20020
+ kinds: array(MediaFileKindEnum).optional()
20021
+ }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
20000
20022
  deviceId: number(),
20001
20023
  timestamp: number(),
20002
20024
  frameWidth: number(),
@@ -31194,13 +31216,44 @@ function parseTwoWayAudioChannels(xml) {
31194
31216
  return out;
31195
31217
  }
31196
31218
  /**
31219
+ * Idle watchdog for the alarm stream. Hikvision firmware emits a keep-alive
31220
+ * heartbeat (typically a `videoloss`/`inactive` alert) roughly every ~5s even
31221
+ * when nothing is happening, so a total absence of ANY bytes for this long
31222
+ * means the pipe is dead — either a half-open TCP connection (no FIN, no data)
31223
+ * that would otherwise block `reader.read()` forever, or a stalled proxy/NAT
31224
+ * conntrack entry between the hub and a camera on a different subnet. When it
31225
+ * fires we cancel the reader, surfacing a clean stream-end that the reconnect
31226
+ * path treats as a recoverable disconnect.
31227
+ */
31228
+ var ALARM_STREAM_IDLE_TIMEOUT_MS = 3e4;
31229
+ /**
31197
31230
  * Subscribe to the camera's alarm stream. Returns an `AbortController`
31198
31231
  * — `controller.abort()` tears the subscription down. Reconnect logic
31199
31232
  * is the caller's responsibility (we keep the parser simple and let
31200
31233
  * the device class own the lifecycle / backoff timing).
31201
- */
31202
- function subscribeAlarms(client, handlers) {
31234
+ *
31235
+ * Every terminal outcome that is NOT a deliberate `controller.abort()` is
31236
+ * reported through `onError` exactly once, so the caller's reconnect logic
31237
+ * always fires:
31238
+ * - HTTP / boundary failure at subscribe time,
31239
+ * - a thrown error while pumping,
31240
+ * - AND a clean stream-end (`done`). Hikvision cameras recycle the
31241
+ * alertStream HTTP connection periodically (firmware keep-alive limits,
31242
+ * internal event-subsystem restarts, or an idle NAT/proxy hop closing the
31243
+ * socket). A clean close used to fall through silently — `onError` never
31244
+ * fired, the caller's `alarmController` stayed non-null, and the device
31245
+ * never resubscribed — so motion stopped permanently until the provider
31246
+ * restarted. Treating clean-end as a recoverable disconnect closes that gap.
31247
+ */
31248
+ function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
31203
31249
  const controller = new AbortController();
31250
+ let settled = false;
31251
+ const fail = (err) => {
31252
+ if (settled) return;
31253
+ settled = true;
31254
+ if (controller.signal.aborted) return;
31255
+ handlers.onError(err);
31256
+ };
31204
31257
  (async () => {
31205
31258
  try {
31206
31259
  const res = await client.request("/ISAPI/Event/notification/alertStream", {
@@ -31209,20 +31262,20 @@ function subscribeAlarms(client, handlers) {
31209
31262
  timeoutMs: null
31210
31263
  });
31211
31264
  if (!res.ok || !res.body) {
31212
- handlers.onError(/* @__PURE__ */ new Error(`alarm stream HTTP ${res.status}`));
31265
+ fail(/* @__PURE__ */ new Error(`alarm stream HTTP ${res.status}`));
31213
31266
  return;
31214
31267
  }
31215
31268
  const ct = res.headers.get("content-type") ?? "";
31216
31269
  const boundary = parseBoundary(ct);
31217
31270
  if (!boundary) {
31218
- handlers.onError(/* @__PURE__ */ new Error(`alarm stream missing multipart boundary in Content-Type: "${ct}"`));
31271
+ fail(/* @__PURE__ */ new Error(`alarm stream missing multipart boundary in Content-Type: "${ct}"`));
31219
31272
  return;
31220
31273
  }
31221
31274
  handlers.onConnected?.();
31222
- await pumpAlarmStream(res.body, boundary, handlers);
31275
+ await pumpAlarmStream(res.body, boundary, handlers, idleTimeoutMs);
31276
+ fail(/* @__PURE__ */ new Error("alarm stream ended"));
31223
31277
  } catch (err) {
31224
- if (controller.signal.aborted) return;
31225
- handlers.onError(err);
31278
+ fail(err);
31226
31279
  }
31227
31280
  })();
31228
31281
  return controller;
@@ -31231,14 +31284,24 @@ function parseBoundary(contentType) {
31231
31284
  const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
31232
31285
  return m ? m[1].trim() : null;
31233
31286
  }
31234
- async function pumpAlarmStream(stream, boundary, handlers) {
31287
+ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
31235
31288
  const reader = stream.getReader();
31236
31289
  const dashBoundary = `--${boundary}`;
31237
31290
  let bufferedBytes = new Uint8Array(0);
31291
+ let idleTimer = null;
31292
+ const armIdle = () => {
31293
+ if (idleTimeoutMs <= 0) return;
31294
+ if (idleTimer) clearTimeout(idleTimer);
31295
+ idleTimer = setTimeout(() => {
31296
+ reader.cancel().catch(() => {});
31297
+ }, idleTimeoutMs);
31298
+ };
31238
31299
  try {
31300
+ armIdle();
31239
31301
  while (true) {
31240
31302
  const { value, done } = await reader.read();
31241
31303
  if (done) break;
31304
+ armIdle();
31242
31305
  if (!value || value.byteLength === 0) continue;
31243
31306
  bufferedBytes = concat(bufferedBytes, value);
31244
31307
  const parts = splitOnBoundary(bufferedBytes, dashBoundary);
@@ -31253,6 +31316,7 @@ async function pumpAlarmStream(stream, boundary, handlers) {
31253
31316
  }
31254
31317
  }
31255
31318
  } finally {
31319
+ if (idleTimer) clearTimeout(idleTimer);
31256
31320
  try {
31257
31321
  reader.releaseLock();
31258
31322
  } catch {}
package/dist/addon.mjs CHANGED
@@ -12992,6 +12992,12 @@ var motionCooldownMsField = {
12992
12992
  default: 3e4,
12993
12993
  step: 500
12994
12994
  };
12995
+ var maxSessionHoldMsField = {
12996
+ min: 0,
12997
+ max: 6e5,
12998
+ default: 12e4,
12999
+ step: 5e3
13000
+ };
12995
13001
  var motionFpsField = {
12996
13002
  min: 1,
12997
13003
  max: 30,
@@ -13139,6 +13145,19 @@ var RunnerCameraConfigSchema = object({
13139
13145
  "on-motion"
13140
13146
  ]).default("always-on"),
13141
13147
  motionCooldownMs: number().min(motionCooldownMsField.min).default(motionCooldownMsField.default),
13148
+ /**
13149
+ * Orchestrator-side on-motion session-hold cap (ms). While an on-motion
13150
+ * detection session is active and ≥1 confirmed non-stationary track is
13151
+ * still live, the orchestrator keeps the session open past
13152
+ * `motionCooldownMs` (a slowly-moving subject can stop re-triggering the
13153
+ * camera's VMD yet is still being tracked frame-to-frame) — up to this many
13154
+ * ms since the session opened, after which it closes regardless. `0`
13155
+ * disables the hold (legacy cooldown-only teardown). Not consumed by the
13156
+ * runner itself — carried here so it shares the per-camera device-settings
13157
+ * surface with `motionCooldownMs`; the orchestrator reads it off the
13158
+ * resolved `CameraDetectionConfig`.
13159
+ */
13160
+ maxSessionHoldMs: number().min(maxSessionHoldMsField.min).max(maxSessionHoldMsField.max).optional(),
13142
13161
  motionFps: number().min(motionFpsField.min).max(motionFpsField.max).default(motionFpsField.default),
13143
13162
  detectionFps: number().min(detectionFpsField.min).max(detectionFpsField.max).default(detectionFpsField.default),
13144
13163
  motionStreamId: string(),
@@ -13228,7 +13247,7 @@ var RunnerCameraConfigSchema = object({
13228
13247
  */
13229
13248
  inferenceDevices: array(RunnerInferenceDeviceSchema).readonly().optional()
13230
13249
  });
13231
- motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
13250
+ motionFpsField.min, motionFpsField.max, motionFpsField.step, motionFpsField.default, detectionFpsField.min, detectionFpsField.max, detectionFpsField.step, detectionFpsField.default, motionCooldownMsField.min, motionCooldownMsField.max, motionCooldownMsField.step, motionCooldownMsField.default, maxSessionHoldMsField.min, maxSessionHoldMsField.max, maxSessionHoldMsField.step, maxSessionHoldMsField.default, occupancyRecheckSecField.min, occupancyRecheckSecField.max, occupancyRecheckSecField.step, occupancyRecheckSecField.default, occupancyRecheckFramesField.min, occupancyRecheckFramesField.max, occupancyRecheckFramesField.step, occupancyRecheckFramesField.default;
13232
13251
  /**
13233
13252
  * Runtime load summary returned by `getLocalLoad`. Used by the orchestrator's
13234
13253
  * load-balancing levels (L2 capacity-based, L3 hardware-aware) to decide
@@ -19997,7 +20016,10 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
19997
20016
  }), method(object({
19998
20017
  eventId: string(),
19999
20018
  kind: MediaFileKindEnum.optional()
20000
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
20019
+ }), array(MediaFileSchema).readonly()), method(object({
20020
+ trackId: string(),
20021
+ kinds: array(MediaFileKindEnum).optional()
20022
+ }), array(MediaFileSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
20001
20023
  deviceId: number(),
20002
20024
  timestamp: number(),
20003
20025
  frameWidth: number(),
@@ -31195,13 +31217,44 @@ function parseTwoWayAudioChannels(xml) {
31195
31217
  return out;
31196
31218
  }
31197
31219
  /**
31220
+ * Idle watchdog for the alarm stream. Hikvision firmware emits a keep-alive
31221
+ * heartbeat (typically a `videoloss`/`inactive` alert) roughly every ~5s even
31222
+ * when nothing is happening, so a total absence of ANY bytes for this long
31223
+ * means the pipe is dead — either a half-open TCP connection (no FIN, no data)
31224
+ * that would otherwise block `reader.read()` forever, or a stalled proxy/NAT
31225
+ * conntrack entry between the hub and a camera on a different subnet. When it
31226
+ * fires we cancel the reader, surfacing a clean stream-end that the reconnect
31227
+ * path treats as a recoverable disconnect.
31228
+ */
31229
+ var ALARM_STREAM_IDLE_TIMEOUT_MS = 3e4;
31230
+ /**
31198
31231
  * Subscribe to the camera's alarm stream. Returns an `AbortController`
31199
31232
  * — `controller.abort()` tears the subscription down. Reconnect logic
31200
31233
  * is the caller's responsibility (we keep the parser simple and let
31201
31234
  * the device class own the lifecycle / backoff timing).
31202
- */
31203
- function subscribeAlarms(client, handlers) {
31235
+ *
31236
+ * Every terminal outcome that is NOT a deliberate `controller.abort()` is
31237
+ * reported through `onError` exactly once, so the caller's reconnect logic
31238
+ * always fires:
31239
+ * - HTTP / boundary failure at subscribe time,
31240
+ * - a thrown error while pumping,
31241
+ * - AND a clean stream-end (`done`). Hikvision cameras recycle the
31242
+ * alertStream HTTP connection periodically (firmware keep-alive limits,
31243
+ * internal event-subsystem restarts, or an idle NAT/proxy hop closing the
31244
+ * socket). A clean close used to fall through silently — `onError` never
31245
+ * fired, the caller's `alarmController` stayed non-null, and the device
31246
+ * never resubscribed — so motion stopped permanently until the provider
31247
+ * restarted. Treating clean-end as a recoverable disconnect closes that gap.
31248
+ */
31249
+ function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
31204
31250
  const controller = new AbortController();
31251
+ let settled = false;
31252
+ const fail = (err) => {
31253
+ if (settled) return;
31254
+ settled = true;
31255
+ if (controller.signal.aborted) return;
31256
+ handlers.onError(err);
31257
+ };
31205
31258
  (async () => {
31206
31259
  try {
31207
31260
  const res = await client.request("/ISAPI/Event/notification/alertStream", {
@@ -31210,20 +31263,20 @@ function subscribeAlarms(client, handlers) {
31210
31263
  timeoutMs: null
31211
31264
  });
31212
31265
  if (!res.ok || !res.body) {
31213
- handlers.onError(/* @__PURE__ */ new Error(`alarm stream HTTP ${res.status}`));
31266
+ fail(/* @__PURE__ */ new Error(`alarm stream HTTP ${res.status}`));
31214
31267
  return;
31215
31268
  }
31216
31269
  const ct = res.headers.get("content-type") ?? "";
31217
31270
  const boundary = parseBoundary(ct);
31218
31271
  if (!boundary) {
31219
- handlers.onError(/* @__PURE__ */ new Error(`alarm stream missing multipart boundary in Content-Type: "${ct}"`));
31272
+ fail(/* @__PURE__ */ new Error(`alarm stream missing multipart boundary in Content-Type: "${ct}"`));
31220
31273
  return;
31221
31274
  }
31222
31275
  handlers.onConnected?.();
31223
- await pumpAlarmStream(res.body, boundary, handlers);
31276
+ await pumpAlarmStream(res.body, boundary, handlers, idleTimeoutMs);
31277
+ fail(/* @__PURE__ */ new Error("alarm stream ended"));
31224
31278
  } catch (err) {
31225
- if (controller.signal.aborted) return;
31226
- handlers.onError(err);
31279
+ fail(err);
31227
31280
  }
31228
31281
  })();
31229
31282
  return controller;
@@ -31232,14 +31285,24 @@ function parseBoundary(contentType) {
31232
31285
  const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
31233
31286
  return m ? m[1].trim() : null;
31234
31287
  }
31235
- async function pumpAlarmStream(stream, boundary, handlers) {
31288
+ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
31236
31289
  const reader = stream.getReader();
31237
31290
  const dashBoundary = `--${boundary}`;
31238
31291
  let bufferedBytes = new Uint8Array(0);
31292
+ let idleTimer = null;
31293
+ const armIdle = () => {
31294
+ if (idleTimeoutMs <= 0) return;
31295
+ if (idleTimer) clearTimeout(idleTimer);
31296
+ idleTimer = setTimeout(() => {
31297
+ reader.cancel().catch(() => {});
31298
+ }, idleTimeoutMs);
31299
+ };
31239
31300
  try {
31301
+ armIdle();
31240
31302
  while (true) {
31241
31303
  const { value, done } = await reader.read();
31242
31304
  if (done) break;
31305
+ armIdle();
31243
31306
  if (!value || value.byteLength === 0) continue;
31244
31307
  bufferedBytes = concat(bufferedBytes, value);
31245
31308
  const parts = splitOnBoundary(bufferedBytes, dashBoundary);
@@ -31254,6 +31317,7 @@ async function pumpAlarmStream(stream, boundary, handlers) {
31254
31317
  }
31255
31318
  }
31256
31319
  } finally {
31320
+ if (idleTimer) clearTimeout(idleTimer);
31257
31321
  try {
31258
31322
  reader.releaseLock();
31259
31323
  } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-hikvision",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
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",